Advertise Flusher and Hijacker only when the underlying writer has them - #55
Conversation
The wrapper implemented both unconditionally, so a handler's type assertion succeeded even when the writer underneath supported neither, and the Flush that followed silently did nothing. Timeout is exactly such a writer, so composing it with Gzip reproduced it. The returned writer is now picked from the capabilities of the one it wraps. Hijack also finishes the gzip stream before handing the connection over, so anything already written reaches the wire, and the deferred-commit deviation from net/http header semantics is documented rather than papered over.
Coverage Report for CI Build 32198654843Coverage decreased (-0.7%) to 96.936%Details
Uncovered Changes
Coverage Regressions2 previously-covered lines in 1 file lost coverage.
Coverage Stats
💛 - Coveralls |
WriteHeader only committed when a Content-Type was already set, so the plain upgrade sequence, 101 with no content type followed by Hijack, recorded the status and never forwarded it: Hijack set hijacked and close returned at that guard, leaving the client with the raw protocol bytes and no status line where net/http would have written one. 101 now commits immediately, which it can do because it carries no body to sniff and is already excluded from compression. The regression test omits Content-Type and reads the raw socket, so it fails without the fix. Reported by umputun on #51.
|
the capability selection is right, and thanks for folding the 101 fix in here, one thing left on the error path in if w.gz != nil {
_ = w.gz.Close()
gzPool.Put(w.gz)
w.gz = nil
}
conn, rw, err := h.Hijack()two consequences. A failing safer ordering is close first and return the error, then hijack, and only put and nil the writer once narrow either way, |
|
Fixed in #56, both consequences confirmed on the current code before changing it. The wrapped Three test cases, the first two of which fail without the change. |
…body (#56) Closing the stream discarded its error and released the writer before the hijack was attempted, which cost two things. A failing Close means truncated gzip output, and a hijack succeeding right after hid it. That error is returned now. A failing Hijack left hijacked false with the writer already released, so a handler carrying on would take the plain path and append raw bytes to a body already advertised as Content-Encoding: gzip. The writer stays attached on that path instead, and since it is closed a further write fails with "flate: closed writer" rather than corrupting the response. The deferred close still returns it to the pool, and the pool is safe because Reset clears the closed flag. The writer is only released once Hijack has actually taken the connection. Reported by umputun on #55.
Follow-up to #51, two loose ends from reviewing that branch after it merged.
The wrapper advertised capabilities it did not have.
gzipResponseWriterimplementedFlushandHijackunconditionally, so a handler'sw.(http.Flusher)assertion succeeded regardless of what sat underneath, and the flush that followed silently did nothing when the writer beneath offered noFlush.This is reachable with two middlewares from this repo:
Timeoutwraps withhttp.TimeoutHandler, whose writer implements neitherFlushernorHijacker, astimeout.go:26already notes. ComposingTimeoutwithGzipgave a handler aFlusherthat did nothing at all.The returned writer is now selected from the capabilities of the one it wraps, four small variants over the same core.
Unwrapstays on the core, sohttp.ResponseControllerkeeps working in every case. The new test asserts the advertised set matches, both for a plain server writer and behindTimeout, and fails on the previous code.Hijackabandoned an open gzip stream. Anything the handler had already written stayed in the compressor and never reached the wire. The stream is finished before the connection changes hands.Documentation, not a code change: sniffing forces the commit to be deferred, so when a handler calls
WriteHeaderwithout aContent-Type, headers it changes before the firstWritestill reach the client, wherenet/httpwould ignore them. That is a deliberate trade for being able to compress handlers that never set a content type. It is now written down on the type and in the README rather than left for the next reader to discover. Handlers that mutate headers afterWriteHeaderare relying on a no-op, so the deviation is permissive rather than corrupting.Also picked up the 101 gap umputun reported on #51, since it lives in the same
WriteHeader/Hijackpath this PR already touches.WriteHeaderonly committed once aContent-Typewas known, so the plain upgrade sequence —WriteHeader(101)with no content type, thenHijack— recordedstatusSetandstatusand never forwarded them.Hijacksethijacked,closereturned at that guard, and the client received the raw protocol bytes with no status line at all. The existingTestGzip_SwitchingProtocolspassed because it sets a content type.101 now commits immediately. It can, because it carries no body to sniff and
decidealready excludes it from compression. The regression test omitsContent-Typeand reads the raw socket rather than going through the HTTP client, so it sees what actually reaches the wire; without the fix it getsraw-protocol-bytesand no101 Switching Protocols.Narrow in practice, as noted — gorilla/websocket writes the 101 itself after hijacking rather than calling
WriteHeader— but it was a real difference fromnet/http.