Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions dist/truncating_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Truncating archive server for RELEASE-VALIDATION.md §6b.

Serves a single file but lies about its length on the first N requests:
it sends the real `Content-Length` header, then writes only a fraction
of the body and slams the connection shut. That reproduces exactly what
a TLS-inspecting proxy / antivirus middlebox does to a large HTTPS
download — a *clean* early EOF — which the installer must now catch as
"Download incomplete" instead of a baffling "Could not find EOCD".

After the first N truncated responses it serves the file in full, so a
single run exercises BOTH the honest-failure path and the
self-healing retry (the installer retries `DOWNLOAD_ATTEMPTS` times).

Usage:
python dist/truncating_server.py dist/CodeScope-v99.0.0-windows.zip \
--port 8000 --truncate-first 2 --fraction 0.5

Point the dev build at it:
$env:CODESCOPE_DEV_UPDATE_URL =
"http://127.0.0.1:8000/CodeScope-v99.0.0-windows.zip"

- truncate-first 2 (default), DOWNLOAD_ATTEMPTS=3 -> attempts 1-2 are
cut short, attempt 3 succeeds: toast shows the retry self-healing
into "Installing" -> "Update installed".
- truncate-first 999 -> every attempt is cut: the flow ends on the
honest "Download incomplete: received N of M bytes" failure (NOT an
EOCD extract error).
"""
import argparse
import http.server
import os
import sys


def main():
ap = argparse.ArgumentParser()
ap.add_argument("file", help="path to the archive to serve")
ap.add_argument("--port", type=int, default=8000)
ap.add_argument(
"--truncate-first",
type=int,
default=2,
help="cut the body short on the first N requests, then serve full",
)
ap.add_argument(
"--fraction",
type=float,
default=0.5,
help="fraction of the body to send before cutting (0..1)",
)
args = ap.parse_args()

body = open(args.file, "rb").read()
total = len(body)
name = os.path.basename(args.file)
served = {"n": 0}

class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802 (stdlib naming)
served["n"] += 1
request_no = served["n"]
truncate = request_no <= args.truncate_first
# Always advertise the *full* length — the lie that makes a
# short body look like a truncated transfer to the client.
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(total))
self.end_headers()
if truncate:
cut = max(1, int(total * args.fraction))
self.wfile.write(body[:cut])
self.wfile.flush()
# Force a hard close mid-body: the client sees a clean
# EOF after `cut` bytes despite Content-Length == total.
self.connection.close()
sys.stderr.write(
f"[req {request_no}] TRUNCATED {cut}/{total} bytes\n"
)
else:
self.wfile.write(body)
sys.stderr.write(f"[req {request_no}] full {total} bytes\n")
sys.stderr.flush()

def log_message(self, *_): # silence the default access log
pass

httpd = http.server.HTTPServer(("127.0.0.1", args.port), Handler)
sys.stderr.write(
f"serving {name} ({total} bytes) on http://127.0.0.1:{args.port}/ "
f"— truncating first {args.truncate_first} request(s) at "
f"{args.fraction:.0%}\n"
)
sys.stderr.flush()
httpd.serve_forever()


if __name__ == "__main__":
main()
81 changes: 72 additions & 9 deletions docs/RELEASE-VALIDATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,13 @@ Copy-Item target/release/codescope.exe dist/fake/CodeScope.exe
Compress-Archive -Path dist/fake/* `
-DestinationPath "dist/CodeScope-v99.0.0-windows.zip" -Force

# 2. Serve it. A THROTTLED server (vs. `python -m http.server`) makes
# the byte-progress toast visibly tick instead of completing
# instantly over localhost — needed to eyeball the "N / M MB"
# progress. See dist/slow_server.py in PR #249's history, or any
# rate-limited static server on :8000.
python dist/slow_server.py # leave running; serves at ~512 KB/s
# 2. Serve it on :8000. Any static server works for the extraction
# check; `truncating_server.py --truncate-first 0` (full, no cut)
# is in-repo. The byte-progress toast completes near-instantly over
# localhost — to eyeball it ticking you need a rate-limited server
Comment thread
maui1911 marked this conversation as resolved.
# (the retired dist/slow_server.py lives in PR #249's history, or
# use any throttling proxy). Extraction success doesn't depend on it.
python dist/truncating_server.py dist/CodeScope-v99.0.0-windows.zip --port 8000 --truncate-first 0

# 3. Launch the DEBUG dev build with the override pointing at the zip.
# (A release build ignores CODESCOPE_DEV_UPDATE_URL — it's gated
Expand All @@ -103,8 +104,10 @@ cargo run --bin codescope
```

- [ ] Toast appears (~15s after launch).
- [ ] Click "Update". The toast switches to "Downloading update… N / M"
and the byte count **visibly advances** (throttled server).
- [ ] Click "Update". The toast switches to "Downloading update… N / M".
With a throttled server the byte count **visibly advances**; over
a plain localhost server it may jump near-instantly to the total —
either is fine, the extraction check below is what matters.
- [ ] **Extraction succeeds:** the toast reaches "Installing update…"
then "Update installed". (A failure here with "unsupported
extraction method" means a `self_update` compression feature is
Expand All @@ -120,8 +123,68 @@ cargo run --bin codescope
> `compression-flate2` feature) whenever the `self_update` feature set
> or `dist-workspace.toml`'s `unix-archive` changes.

### 6b. Truncated-download resilience (MANDATORY)

Why this exists: §6 serves the archive from a *localhost* server,
which never truncates — so it proves extraction works but structurally
**cannot** catch a download that is cut short on a real network. That
gap shipped a v0.5.0 update that failed for a user behind a
TLS-inspecting proxy with the baffling `ZipError: ... Could not find
EOCD` (the downloaded zip was truncated; the failure only surfaced at
extract time). The installer now verifies `received == Content-Length`
and retries (`download_archive` / `verify_complete` in `src/update.rs`,
PR #275). This check guards that resilience.

`dist/truncating_server.py` advertises the real `Content-Length` but
cuts the body short on the first N requests, then serves it in full —
a faithful stand-in for a middlebox clipping a large HTTPS download.

Stage the fake v99 archive with §6's staging commands — `cargo build
--release --bin codescope`, copy the exe to `dist/fake/CodeScope.exe`,
then `Compress-Archive` it to `dist/CodeScope-v99.0.0-windows.zip`.
Then serve it with the truncating server instead of §6's full one:

```pwsh
# Self-heal: cut the first 2 attempts, serve the 3rd in full.
# DOWNLOAD_ATTEMPTS is 3, so the install should recover on attempt 3.
python dist/truncating_server.py dist/CodeScope-v99.0.0-windows.zip `
--port 8000 --truncate-first 2 --fraction 0.5

# (separate window) launch the DEBUG dev build pointed at it:
$env:CODESCOPE_DEV = "1"
$env:CODESCOPE_DEV_FAKE_UPDATE_TOAST = "1"
$env:CODESCOPE_DEV_UPDATE_URL = "http://127.0.0.1:8000/CodeScope-v99.0.0-windows.zip"
cargo run --bin codescope
```

- [ ] Click "Update". The server log prints two `TRUNCATED` lines then
one `full` line, and the toast **self-heals** through to
"Installing update…" → "Update installed". (The progress bar may
visibly reset between attempts.)

Then restart the server to truncate **every** attempt and confirm the
honest failure (this is the case the EOCD bug used to mangle):

```pwsh
python dist/truncating_server.py dist/CodeScope-v99.0.0-windows.zip `
--port 8000 --truncate-first 999 --fraction 0.5
```

- [ ] Click "Update". The server log shows exactly **3** `TRUNCATED`
lines (the retry budget — *not* a single request that jumps
straight to an extract error), and the toast ends on
**"Download incomplete: received N of M bytes (connection
truncated …)"** — **not** "Could not find EOCD". The failure
toast is brief; watch for it, or read the count of attempts in
the server log as the proof the verify+retry path ran (old code
issued a single GET then failed at extract).

> The unit tests in `src/update.rs` (`verify_complete`) pin the exact
> truncated / over-read / unknown-length wording; this manual pass
> proves the wiring end-to-end against a real socket.

### Sign-off

When all of 1-6 pass, the release is OK to announce. Push the
When all checks (1-6b) pass, the release is OK to announce. Push the
release notes to the GitHub release body, mention any breaking
changes, and link relevant PRs.