go-http-responsewriter-body-discard
Root cause: spaResponseWriter.Write() discarded the body for every status, so GET / sent Content-Length: 660 with zero bytes — curl aborted with (18) end of response with 660 bytes missing.
Fix (~/go-http-responsewriter-body-discard/spa.go): forward 200/3xx headers+bodies untouched; suppress only status >= 400; add a wroteHeader guard; and defer the header until the fallback decides (so an error status can be replaced by index.html with 200 instead of leaking the error page):
type spaResponseWriter struct {
http.ResponseWriter
status int // first status recorded
wroteHeader bool // WriteHeader called on wrapper (guard)
flushed bool // recorded status forwarded to underlying writer
}
func (w *spaResponseWriter) WriteHeader(status int) {
if w.wroteHeader { // net/http semantics: only the first call counts
return
}
w.status = status
w.wroteHeader = true
// Defer forwarding: an error status may still be replaced by index.html.
}
func (w *spaResponseWriter) Write(p []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK) // bare Write implies 200
}
if w.status >= http.StatusBadRequest {
return len(p), nil // suppress only >=400; report full length (no short write)
}
w.forwardHeader()
return w.ResponseWriter.Write(p) // 200/3xx body forwarded verbatim
}
func (w *spaResponseWriter) forwardHeader() {
if w.flushed { return }
w.flushed = true
w.ResponseWriter.WriteHeader(w.status)
}
Fallback handler — after the file server runs, 200/3xx are flushed as-is; error statuses are replaced by index.html (or surfaced if it's missing):
func Fallback(staticDir, indexFile string) http.Handler {
fileServer := http.FileServer(http.Dir(staticDir))
indexPath := filepath.Join(staticDir, indexFile)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rw := &spaResponseWriter{ResponseWriter: w}
fileServer.ServeHTTP(rw, r)
if rw.status < http.StatusBadRequest {
rw.forwardHeader() // 200/3xx: body already forwarded; header too (covers body-less 301)
return
}
if _, err := os.Stat(indexPath); err != nil {
http.Error(w, http.StatusText(rw.status), rw.status) // no silent empty 200
return
}
http.ServeFile(w, r, indexPath) // SPA shell, status 200
})
}
Regression test (spa_test.go, TestRootBodyNotEmpty): asserts root returns 200, len(body) > 100, res.ContentLength == len(body), and the body is byte-identical to index.html.
Verified two ways — unit tests + live curl E2E against freshly built binaries (the "rebuild the stale demo" step is real: the first E2E attempt hit a racy backgrounding failure and required a rebuild/rerun to validate, exactly how the bug slipped through). **Before fix** (buggy binary, live curl): `status=200 downloaded=0`, `curl: (18) end of response with 660 bytes missing` — the reported Content-Length-with-zero-bytes symptom reproduced. **After fix** (live curl against fresh build): | Request | Result | |---|---| | `GET /` | 200, `Content-Length: 660`, **660 bytes received**, `cmp` identical to index.html, >100 ✓ | | `GET /settings` (client route) | 200, 660 bytes = index.html (fallback works) | | `GET /app.js` / `/assets/readme.txt` | 200, verbatim assets, correct Content-Type | | `GET /sub` (real dir, no slash) | **301 forwarded** (3xx path) → `-L` gets `sub/index.html` 200/19 | | missing `index.html` (empty dir) | 404 "Not Found" (10 bytes) — errors aren't swallowed into empty 200s | **`go test` — 8/8 PASS** (gofmt/vet clean), covering: root body regression; client-route fallback ×3 paths; real-asset passthrough; 301 redirect forwarding; missing-index error surfacing; wrapper unit (200 body forwarded, 302 body forwarded, 404 body suppressed with full-length `n`/nil-err and deferred header); `wroteHeader` guard (302 then 418 → 302 wins, header written once); bare `Write` defaults to 200.
{"model": "deepseek-v4-flash", "problem_class": "go-http-responsewriter-body-discard", "result": "passed", "tests": 8}Root cause: spaResponseWriter.Write() discarded the body for every status, so GET / sent Content-Length: 660 with zero bytes — curl aborted with (18) end of response with 660 bytes missing.
Fix (~/go-http-responsewriter-body-discard/spa.go): forward 200/3xx headers+bodies untouched; suppress only status >= 400; add a wroteHeader guard; and defer the header until the fallback decides (so an error status can be replaced by index.html with 200 instead of leaking the error page):
type spaResponseWriter struct {
http.ResponseWriter
status int // first status recorded
wroteHeader bool // WriteHeader called on wrapper (guard)
flushed bool // recorded status forwarded to underlying writer
}
func (w *spaResponseWriter) WriteHeader(status int) {
if w.wroteHeader { // net/http semantics: only the first call counts
return
}
w.status = status
w.wroteHeader = true
// Defer forwarding: an error status may still be replaced by index.html.
}
func (w *spaResponseWriter) Write(p []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK) // bare Write implies 200
}
if w.status >= http.StatusBadRequest {
return len(p), nil // suppress only >=400; report full length (no short write)
}
w.forwardHeader()
return w.ResponseWriter.Write(p) // 200/3xx body forwarded verbatim
}
func (w *spaResponseWriter) forwardHeader() {
if w.flushed { return }
w.flushed = true
w.ResponseWriter.WriteHeader(w.status)
}
Fallback handler — after the file server runs, 200/3xx are flushed as-is; error statuses are replaced by index.html (or surfaced if it's missing):
func Fallback(staticDir, indexFile string) http.Handler {
fileServer := http.FileServer(http.Dir(staticDir))
indexPath := filepath.Join(staticDir, indexFile)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rw := &spaResponseWriter{ResponseWriter: w}
fileServer.ServeHTTP(rw, r)
if rw.status < http.StatusBadRequest {
rw.forwardHeader() // 200/3xx: body already forwarded; header too (covers body-less 301)
return
}
if _, err := os.Stat(indexPath); err != nil {
http.Error(w, http.StatusText(rw.status), rw.status) // no silent empty 200
return
}
http.ServeFile(w, r, indexPath) // SPA shell, status 200
})
}
Regression test (spa_test.go, TestRootBodyNotEmpty): asserts root returns 200, len(body) > 100, res.ContentLength == len(body), and the body is byte-identical to index.html.
Verified two ways — unit tests + live curl E2E against freshly built binaries (the "rebuild the stale demo" step is real: the first E2E attempt hit a racy backgrounding failure and required a rebuild/rerun to validate, exactly how the bug slipped through). **Before fix** (buggy binary, live curl): `status=200 downloaded=0`, `curl: (18) end of response with 660 bytes missing` — the reported Content-Length-with-zero-bytes symptom reproduced. **After fix** (live curl against fresh build): | Request | Result | |---|---| | `GET /` | 200, `Content-Length: 660`, **660 bytes received**, `cmp` identical to index.html, >100 ✓ | | `GET /settings` (client route) | 200, 660 bytes = index.html (fallback works) | | `GET /app.js` / `/assets/readme.txt` | 200, verbatim assets, correct Content-Type | | `GET /sub` (real dir, no slash) | **301 forwarded** (3xx path) → `-L` gets `sub/index.html` 200/19 | | missing `index.html` (empty dir) | 404 "Not Found" (10 bytes) — errors aren't swallowed into empty 200s | **`go test` — 8/8 PASS** (gofmt/vet clean), covering: root body regression; client-route fallback ×3 paths; real-asset passthrough; 301 redirect forwarding; missing-index error surfacing; wrapper unit (200 body forwarded, 302 body forwarded, 404 body suppressed with full-length `n`/nil-err and deferred header); `wroteHeader` guard (302 then 418 → 302 wins, header written once); bare `Write` defaults to 200.
{"model": "deepseek-v4-flash", "problem_class": "go-http-responsewriter-body-discard", "result": "passed", "tests": 8}