Skip to content

Mirror server can promise a Content-Length the file no longer has - #133

Merged
xroche merged 5 commits into
masterfrom
fix/mirror-index-race
Aug 17, 2026
Merged

Mirror server can promise a Content-Length the file no longer has#133
xroche merged 5 commits into
masterfrom
fix/mirror-index-race

Conversation

@xroche

@xroche xroche commented Aug 17, 2026

Copy link
Copy Markdown
Owner

The mirror server stat'd a file, opened it, and sent the measurement as Content-Length. The engine rewrites index.html in place, so the stream could run dry early, and NanoHTTPD stops there with no padding and no error: the client waits for bytes that never arrive, and on a keep-alive connection it reads the next response as the tail of this one. Serving through NanoHTTPD's own newChunkedResponse removes the declared length, leaving nothing for the body to contradict, and the measuring helpers go with it. Non-text files lose their Content-Length in the trade; text/ and /json never had one, since NanoHTTPD gzips them whenever the client accepts it and chunks the result.

HEAD was broken in its own way. It keeps a stat'd length, because the chunked path prints Content-Length: -1 there instead. But NanoHTTPD's HEAD branch skips only the chunk framing, not the body write, so the reply carried the whole file after its headers, and the next request on that connection read those bytes as a status line. With Accept-Encoding: gzip, which is what a browser sends, it was a gzipped body under no length and no framing at all. HEAD now hands NanoHTTPD an empty stream under the real length and declines gzip, so it never opens the file it describes. The 404 and 403 replies return before any of that, so they were still writing a text body to a HEAD and desyncing the connection one request later. They answer with headers alone now, and a GET keeps its short message.

This does not close #130. The blank page comes from the engine rewriting index.html in place, and fixing that needs an atomic index write in the httrack repo. Chunked framing does not make a short read longer; it only stops the server claiming the file was complete. A browser never saw the old length on index.html because it accepts gzip, but wget and plain curl send no Accept-Encoding and did get one. The tests now drive a real loopback socket end to end and compare what arrives with the file; the truncation case reads one chunk before emptying it, so the rewrite lands mid-body by construction rather than by timing.

Refs #130

xroche and others added 2 commits August 17, 2026 17:55
serve() advertised file.length() beside a stream it had opened
separately, so a request crossing the engine's in-place rewrite of
index.html got a Content-Length describing the other side of it. While
the file sits truncated that length is 0, and the browser shows a blank
page. Take the length from the open descriptor, and close the stream on
the failure path where NanoHTTPD never receives it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
Split the open-and-measure step out of fileResponse, so a test can pin
the length directly and reach the close-on-failure branch. The Javadoc
now also says where the length lands: NanoHTTPD sends a gzipped response
chunked with no Content-Length, and it gzips every text mime type for a
client that accepts one, so index.html in a browser never used it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
NanoHTTPD's newChunkedResponse makes a declared-length-versus-bytes-sent
mismatch structurally impossible, so the measured length and the helpers
that produced it go away. It also closes the window the measure left: a
truncation after it left the body short of the declared length, which
NanoHTTPD sends with no padding and no error, desyncing a keep-alive
connection.

Non-text files lose their Content-Length to this; text/ and /json never
had one, NanoHTTPD gzipping them when the client accepts it. HEAD keeps
a stat'd length, which no body can contradict, because the chunked
response NanoHTTPD builds prints "Content-Length: -1" on a HEAD.

Refs #130

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
@xroche xroche changed the title Mirror server can serve a blank page while the top index is rebuilt Mirror server can promise a Content-Length the file no longer has Aug 17, 2026
xroche and others added 2 commits August 17, 2026 19:45
NanoHTTPD writes a response body for HEAD too: its HEAD branch skips the
chunk framing, not the write. So the previous HEAD reply put the file's
bytes on the wire after the headers, and a keep-alive client read them as
the head of the next response. With Accept-Encoding: gzip, which is what a
browser sends, it was worse: a gzipped body under no Content-Length and no
Transfer-Encoding at all.

HEAD now hands NanoHTTPD an empty stream under the stat'd length, and gzip
is declined for HEAD so nothing re-frames the reply. It also no longer
opens the file it describes.

Refs #130

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
The 404 and 403 returns never reach fileResponse, so they answered a HEAD
with a Content-Length and a text body. A client that skips the HEAD body,
as it may, then reads that body as the start of the next reply and the
keep-alive connection desyncs.

Also pin the truncation test on a first chunk read instead of a sleep, and
cover the gzip an unqualified override would have taken away from GET.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
@xroche
xroche merged commit f7e2ee0 into master Aug 17, 2026
6 checks passed
@xroche
xroche deleted the fix/mirror-index-race branch August 17, 2026 19:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mirror server serves a truncated page while the top index is rebuilt

1 participant