Mirror server can promise a Content-Length the file no longer has - #133
Merged
Conversation
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>
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>
This was referenced Aug 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
newChunkedResponseremoves 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/jsonnever 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: -1there 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. WithAccept-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-Encodingand 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