From e09c10f5bd37318373d8fbcab66dc1c5d43a29a2 Mon Sep 17 00:00:00 2001 From: "Nicholas S. Husin" Date: Tue, 14 Jul 2026 10:57:27 -0400 Subject: [PATCH 1/7] net/http: return 404 when redirecting paths with escaped slashes When a FileServer redirects to a canonical path, it uses relative redirects, to ensure compatibility with StripPrefix. If the request path contained escaped slashes (%2f), the relative redirect can be misresolved because the client and server disagree on the path segments. Since there is no generalized fix that works in all cases, return a 404 instead of redirecting when the request path contains escaped slashes. For #80289 Change-Id: Ice139c4ed2ece221eb36225c7ddcd2fb6a6a6964 Reviewed-on: https://go-review.googlesource.com/c/go/+/799982 Commit-Queue: Nicholas Husin Auto-Submit: Nicholas Husin LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com Reviewed-by: Damien Neil Reviewed-by: Nicholas Husin --- src/net/http/fs.go | 6 ++++++ src/net/http/fs_test.go | 18 +++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/net/http/fs.go b/src/net/http/fs.go index 821b9b1266d21f..240a8dc675ddc5 100644 --- a/src/net/http/fs.go +++ b/src/net/http/fs.go @@ -784,6 +784,12 @@ func toHTTPError(err error) (msg string, httpStatus int) { // localRedirect gives a Moved Permanently response. // It does not convert relative paths to absolute paths like Redirect does. func localRedirect(w ResponseWriter, r *Request, newPath string) { + // There is no reliable way for us to redirect correctly when the path has + // escaped slashes, since StripPrefix might be in use. Just return 404. + if p := r.URL.EscapedPath(); strings.Contains(p, "%2f") || strings.Contains(p, "%2F") { + NotFound(w, r) + return + } if q := r.URL.RawQuery; q != "" { newPath += "?" + q } diff --git a/src/net/http/fs_test.go b/src/net/http/fs_test.go index d7859b9e9c9f87..6edaab03af7235 100644 --- a/src/net/http/fs_test.go +++ b/src/net/http/fs_test.go @@ -269,10 +269,19 @@ func TestServeContentWithEmptyContentIgnoreRanges(t *testing.T) { var fsRedirectTestData = []struct { original, redirect string + status int }{ - {"/test/index.html", "/test/"}, - {"/test/testdata", "/test/testdata/"}, - {"/test/testdata/file/", "/test/testdata/file"}, + {"/test/index.html", "/test/", 200}, + {"/test/testdata", "/test/testdata/", 200}, + {"/test/testdata/file/", "/test/testdata/file", 200}, + // Redirect attempts for path with escaped slashes should result in 404. + // However, escaped paths are okay if they do not trigger a redirect. + // See https://go.dev/issue/80289. + {"/test%2ftestdata", "/test/testdata", 404}, + {"/test/testdata%2ffile/", "/test/testdata/file/", 404}, + {"/test/testdata%2Findex.html", "/test/testdata/index.html", 404}, + {"/test/testdata%2ffile", "/test/testdata/file", 200}, + {"/test/testdata%2F", "/test/testdata/", 200}, } func TestFSRedirect(t *testing.T) { run(t, testFSRedirect) } @@ -288,6 +297,9 @@ func testFSRedirect(t *testing.T, mode testMode) { if g, e := res.Request.URL.Path, data.redirect; g != e { t.Errorf("redirect from %s: got %s, want %s", data.original, g, e) } + if res.StatusCode != data.status { + t.Errorf("redirect from %s: got status %d, want %d", data.original, res.StatusCode, data.status) + } } } From e41453845b4e5295b212aad7d6bcc9d80b4ae672 Mon Sep 17 00:00:00 2001 From: Dmitri Shuralyov Date: Tue, 14 Jul 2026 14:59:28 -0400 Subject: [PATCH 2/7] cmd/vet: improve how stdversion analyzer refers to methods Pull in the fix implemented in CL 800620. Fixes #80363. [git-generate] updatestd -goroot=$(pwd) -branch=internal-branch.go1.27-vendor Change-Id: I1b4dcbccdaa22f0949edd9add26fae0a3ff5409a Reviewed-on: https://go-review.googlesource.com/c/go/+/798502 Auto-Submit: Dmitri Shuralyov LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com Reviewed-by: Alan Donovan Auto-Submit: Alan Donovan Reviewed-by: Dmitri Shuralyov Commit-Queue: Alan Donovan --- src/cmd/go.mod | 2 +- src/cmd/go.sum | 4 +- .../analysis/passes/stdversion/stdversion.go | 31 +++++++------- .../x/tools/internal/typesinternal/toonew.go | 40 +++++++++++++------ src/cmd/vendor/modules.txt | 2 +- 5 files changed, 47 insertions(+), 32 deletions(-) diff --git a/src/cmd/go.mod b/src/cmd/go.mod index ebe4c80adbbdce..9aa7c87ac0e0cf 100644 --- a/src/cmd/go.mod +++ b/src/cmd/go.mod @@ -11,7 +11,7 @@ require ( golang.org/x/sys v0.45.0 golang.org/x/telemetry v0.0.0-20260519152614-eab6ae52b5e2 golang.org/x/term v0.43.0 - golang.org/x/tools v0.45.1-0.20260710204130-3cdf838841de + golang.org/x/tools v0.45.1-0.20260714184632-28657d66a087 ) require ( diff --git a/src/cmd/go.sum b/src/cmd/go.sum index 026005eff21a99..9c6aa59dc288d6 100644 --- a/src/cmd/go.sum +++ b/src/cmd/go.sum @@ -22,7 +22,7 @@ golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/tools v0.45.1-0.20260710204130-3cdf838841de h1:DAFpdvgVicTKCGgv6rwHZv1Nk1fRPM1r/yyTbH9XbaA= -golang.org/x/tools v0.45.1-0.20260710204130-3cdf838841de/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.45.1-0.20260714184632-28657d66a087 h1:OBacbO7Pun0pcIzRzkngMhqkKWPEgHQP6EZmltKDw4o= +golang.org/x/tools v0.45.1-0.20260714184632-28657d66a087/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= rsc.io/markdown v0.0.0-20240306144322-0bf8f97ee8ef h1:mqLYrXCXYEZOop9/Dbo6RPX11539nwiCNBb1icVPmw8= rsc.io/markdown v0.0.0-20240306144322-0bf8f97ee8ef/go.mod h1:8xcPgWmwlZONN1D9bjxtHEjrUtSEa3fakVF8iaewYKQ= diff --git a/src/cmd/vendor/golang.org/x/tools/go/analysis/passes/stdversion/stdversion.go b/src/cmd/vendor/golang.org/x/tools/go/analysis/passes/stdversion/stdversion.go index d1fda880e847a4..3d44c4e86da6f9 100644 --- a/src/cmd/vendor/golang.org/x/tools/go/analysis/passes/stdversion/stdversion.go +++ b/src/cmd/vendor/golang.org/x/tools/go/analysis/passes/stdversion/stdversion.go @@ -15,6 +15,7 @@ import ( "golang.org/x/tools/go/analysis" "golang.org/x/tools/go/analysis/passes/inspect" "golang.org/x/tools/go/ast/inspector" + "golang.org/x/tools/internal/stdlib" "golang.org/x/tools/internal/typesinternal" "golang.org/x/tools/internal/versions" ) @@ -64,8 +65,8 @@ func run(pass *analysis.Pass) (any, error) { pkg *types.Package version string } - memo := make(map[key]map[types.Object]string) // records symbol's minimum Go version - disallowedSymbols := func(pkg *types.Package, version string) map[types.Object]string { + memo := make(map[key]map[types.Object]stdlib.Symbol) + disallowedSymbols := func(pkg *types.Package, version string) map[types.Object]stdlib.Symbol { k := key{pkg, version} disallowed, ok := memo[k] if !ok { @@ -98,20 +99,12 @@ func run(pass *analysis.Pass) (any, error) { if fileVersion != "" { if obj, ok := pass.TypesInfo.Uses[n]; ok && obj.Pkg() != nil { disallowed := disallowedSymbols(obj.Pkg(), fileVersion) - if minVersion, ok := disallowed[origin(obj)]; ok { - // Some symbols are accessible before their release but - // only with specific build tags unknown to us here. - // Avoid false positives in such cases. - // TODO(mkalil): move this check into typesinternal.TooNewStdSymbols. - if obj.Pkg().Path() == "testing/synctest" && versions.AtLeast(fileVersion, "go1.24") { - break // requires go1.24 && goexperiment.synctest || go1.25 - } - noun := "module" - if fileVersion != pkgVersion { - noun = "file" - } + if sym, ok := disallowed[origin(obj)]; ok { pass.ReportRangef(n, "%s.%s requires %v or later (%s is %s)", - obj.Pkg().Name(), obj.Name(), minVersion, noun, fileVersion) + obj.Pkg().Name(), sym.Name, + sym.Version, + cond(fileVersion != pkgVersion, "file", "module"), + fileVersion) } } } @@ -134,3 +127,11 @@ func origin(obj types.Object) types.Object { } return obj } + +func cond[T any](cond bool, t, f T) T { + if cond { + return t + } else { + return f + } +} diff --git a/src/cmd/vendor/golang.org/x/tools/internal/typesinternal/toonew.go b/src/cmd/vendor/golang.org/x/tools/internal/typesinternal/toonew.go index cc86487eaa0a07..9cc58fd146566f 100644 --- a/src/cmd/vendor/golang.org/x/tools/internal/typesinternal/toonew.go +++ b/src/cmd/vendor/golang.org/x/tools/internal/typesinternal/toonew.go @@ -13,20 +13,26 @@ import ( // TooNewStdSymbols computes the set of package-level symbols // exported by pkg that are not available at the specified version. -// The result maps each symbol to its minimum version. // // The pkg is allowed to contain type errors. -func TooNewStdSymbols(pkg *types.Package, version string) map[types.Object]string { - disallowed := make(map[types.Object]string) +func TooNewStdSymbols(pkg *types.Package, version string) map[types.Object]stdlib.Symbol { + disallowed := make(map[types.Object]stdlib.Symbol) + + // Some symbols are accessible before their release but + // only with specific build tags unknown to us here. + // Avoid false positives in such cases. + if pkg.Path() == "testing/synctest" && versions.AtLeast(version, "go1.24") { + // requires go1.24 && goexperiment.synctest || go1.25 + return disallowed + } // Pass 1: package-level symbols. symbols := stdlib.PackageSymbols[pkg.Path()] for _, sym := range symbols { - symver := sym.Version.String() - if versions.Before(version, symver) { + if versions.Before(version, sym.Version.String()) { switch sym.Kind { case stdlib.Func, stdlib.Var, stdlib.Const, stdlib.Type: - disallowed[pkg.Scope().Lookup(sym.Name)] = symver + disallowed[pkg.Scope().Lookup(sym.Name)] = sym } } } @@ -60,28 +66,36 @@ func TooNewStdSymbols(pkg *types.Package, version string) map[types.Object]strin // spuriously cause the analyzer to report a reference to a // too-new symbol even though this expression compiles just // fine (with the fake implementation) using go1.21. + var noSym stdlib.Symbol + depth := make(map[types.Object]int) for _, sym := range symbols { - symVersion := sym.Version.String() - if !versions.Before(version, symVersion) { + if !versions.Before(version, sym.Version.String()) { continue // allowed } var obj types.Object + var indices []int switch sym.Kind { case stdlib.Field: typename, name := sym.SplitField() - if t := pkg.Scope().Lookup(typename); t != nil && disallowed[t] == "" { - obj, _, _ = types.LookupFieldOrMethod(t.Type(), false, pkg, name) + if t := pkg.Scope().Lookup(typename); t != nil && disallowed[t] == noSym { + obj, indices, _ = types.LookupFieldOrMethod(t.Type(), false, pkg, name) } case stdlib.Method: ptr, recvname, name := sym.SplitMethod() - if t := pkg.Scope().Lookup(recvname); t != nil && disallowed[t] == "" { - obj, _, _ = types.LookupFieldOrMethod(t.Type(), ptr, pkg, name) + if t := pkg.Scope().Lookup(recvname); t != nil && disallowed[t] == noSym { + obj, indices, _ = types.LookupFieldOrMethod(t.Type(), ptr, pkg, name) } } if obj != nil { - disallowed[obj] = symVersion + // In the presence of embedding, two or more "pkg.T.name" + // strings may map to the same types.Object. + // Prefer the Object with the shorter index path. + if min, ok := depth[obj]; !ok || len(indices) < min { + depth[obj] = len(indices) + disallowed[obj] = sym + } } } diff --git a/src/cmd/vendor/modules.txt b/src/cmd/vendor/modules.txt index bbe443fd3a6105..6e513d8a5f175e 100644 --- a/src/cmd/vendor/modules.txt +++ b/src/cmd/vendor/modules.txt @@ -73,7 +73,7 @@ golang.org/x/text/internal/tag golang.org/x/text/language golang.org/x/text/transform golang.org/x/text/unicode/norm -# golang.org/x/tools v0.45.1-0.20260710204130-3cdf838841de +# golang.org/x/tools v0.45.1-0.20260714184632-28657d66a087 ## explicit; go 1.25.0 golang.org/x/tools/cmd/bisect golang.org/x/tools/cover From e824a5b76cb0decfac736e85d0fd0fee464bd900 Mon Sep 17 00:00:00 2001 From: Weixie Cui Date: Thu, 28 May 2026 15:02:54 +0000 Subject: [PATCH 3/7] encoding/pem: handle error from Close Change-Id: I80de837203b6d35b303bf8127447d78f09f82c31 GitHub-Last-Rev: d6f1671f9f9a87026a10864197c4a924f5b7e654 GitHub-Pull-Request: golang/go#79641 Reviewed-on: https://go-review.googlesource.com/c/go/+/782400 Reviewed-by: Sean Liao Auto-Submit: Sean Liao Reviewed-by: Mark Freeman Reviewed-by: Mateusz Poliwczak LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com Reviewed-by: Filippo Valsorda Reviewed-by: David Chase --- src/encoding/pem/pem.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/encoding/pem/pem.go b/src/encoding/pem/pem.go index 6bf2b41ad0eb7f..0498a45c188d53 100644 --- a/src/encoding/pem/pem.go +++ b/src/encoding/pem/pem.go @@ -307,8 +307,12 @@ func Encode(out io.Writer, b *Block) error { if _, err := b64.Write(b.Bytes); err != nil { return err } - b64.Close() - breaker.Close() + if err := b64.Close(); err != nil { + return err + } + if err := breaker.Close(); err != nil { + return err + } if _, err := out.Write(pemEnd[1:]); err != nil { return err From f5c3217500dc436e6e6bc6c73a72c3719c984ce2 Mon Sep 17 00:00:00 2001 From: Damien Neil Date: Thu, 25 Jun 2026 13:17:49 -0700 Subject: [PATCH 4/7] net/http: don't rely on server request body not changing Middleware layers might change a server Request's body, wrapping it in some other type or replacing it entirely. Remove various type assertions of a response.req.Body to *body or *expectContinueReader. Instead, add fields on the response that track the original body. Add a test demonstrating one bug which this fixes: TestServerConnectionReuse/large_wrapped_body previously failed because response.closedRequestBodyEarly failed to recognize an early-closed request body when the body of the Request provided to the handler was modified. This CL introduces a small behavioral change: When sending a response to an HTTP/1 request, we attempt to consume the request body before sending the response (except when full duplex responses are enabled). When the request body is larger than maxPostHandlerReadBytes, we now make one attempt to drain it. Previously, we would make one direct read from the Request.Body (possibly replaced by milddleware), followed by a second read in (*body).Close when cleaning up the request if the first read failed to drain the body. We now call (*body).Close in both places, which will perform the drain exactly once. Change-Id: I0f9507c1bd4d24bfc80daf82c5fdb0746a6a6964 Reviewed-on: https://go-review.googlesource.com/c/go/+/794640 Reviewed-by: Nicholas Husin LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com Reviewed-by: Nicholas Husin --- src/net/http/client_test.go | 2 +- src/net/http/http1_server_test.go | 114 +++++++++++++++++++ src/net/http/serve_test.go | 181 ++++++++++++++++++++++++++++++ src/net/http/server.go | 124 +++++++------------- src/net/http/transfer.go | 23 +++- 5 files changed, 352 insertions(+), 92 deletions(-) create mode 100644 src/net/http/http1_server_test.go diff --git a/src/net/http/client_test.go b/src/net/http/client_test.go index 895b4024840df6..edbeae0764c784 100644 --- a/src/net/http/client_test.go +++ b/src/net/http/client_test.go @@ -2240,7 +2240,7 @@ func testClientCallsCloseOnlyOnce(t *testing.T, mode testMode) { // Issue occurred non-deterministically: needed to occur after a successful // write (into TCP buffer) but before end of body. for i := 0; i < 50 && !t.Failed(); i++ { - body := &issue40382Body{t: t, n: 300000} + body := &issue40382Body{t: t, n: 256 << 10} // maxPostHandlerReadBytes large req, err := NewRequest(MethodPost, cst.ts.URL, body) if err != nil { t.Fatal(err) diff --git a/src/net/http/http1_server_test.go b/src/net/http/http1_server_test.go new file mode 100644 index 00000000000000..17c31a877c08f0 --- /dev/null +++ b/src/net/http/http1_server_test.go @@ -0,0 +1,114 @@ +// Copyright 2026 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package http_test + +import ( + "bufio" + "errors" + "internal/nettest" + "internal/synctest" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// An http1ServerTest tests an HTTP/1 server using a fake network. +// It must be used in a synctest bubble. +type http1ServerTest struct { + t *testing.T + ts *httptest.Server +} + +func newHTTP1ServerTest(t *testing.T, h http.HandlerFunc) *http1ServerTest { + if h == nil { + h = func(w http.ResponseWriter, req *http.Request) {} + } + st := &http1ServerTest{ + t: t, + ts: httptest.NewTestServer(t, h), + } + return st +} + +// client returns a Client that sends requests to the server. +func (st *http1ServerTest) client() *http.Client { + return st.ts.Client() +} + +// transport returns a Transport that sends requests to the server. +func (st *http1ServerTest) transport() *http.Transport { + return st.ts.Client().Transport.(*http.Transport) +} + +// dial returns a connection to the server. +func (st *http1ServerTest) dial() *http1TestConn { + t := st.t + t.Helper() + nc, err := st.transport().DialContext(st.t.Context(), "tcp", "example.tld") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + nc.Close() + }) + conn := nc.(*nettest.Conn) + conn.SetReadError(errWouldBlock) // effectively make reads non-blocking + return &http1TestConn{ + t: st.t, + conn: conn, + bufr: bufio.NewReader(conn), + } +} + +var errWouldBlock = errors.New("would block") + +type http1TestConn struct { + t *testing.T + conn *nettest.Conn + bufr *bufio.Reader +} + +// writeMessage writes a number of CRLF-terminated lines to the connection. +func (tc *http1TestConn) writeMessage(lines ...string) { + t := tc.t + t.Helper() + if _, err := tc.conn.Write([]byte(strings.Join(lines, "\r\n") + "\r\n")); err != nil { + t.Fatalf("conn write: %v", err) + } +} + +// readResponse reads a response from the connection (not including the response body). +func (tc *http1TestConn) readResponse() *http.Response { + t := tc.t + t.Helper() + synctest.Wait() + resp, err := http.ReadResponse(tc.bufr, nil) + if err != nil { + t.Fatalf("ReadResponse: %v", err) + } + return resp +} + +// wantIdle asserts that the connection is not closed and has no pending data to read. +func (tc *http1TestConn) wantIdle() { + t := tc.t + t.Helper() + synctest.Wait() + if got, err := tc.bufr.Peek(32); len(got) != 0 || !errors.Is(err, errWouldBlock) { + t.Fatalf("read from conn: %q, %v; expect conn to be idle", got, err) + } +} + +// wantClosed asserts that the connection is read-closed and has no pending data to read. +func (tc *http1TestConn) wantClosed() { + t := tc.t + t.Helper() + synctest.Wait() + if got, err := tc.bufr.Peek(32); len(got) != 0 || err != io.EOF { + t.Fatalf("read from conn: %q; expect conn to be closed", got) + } +} diff --git a/src/net/http/serve_test.go b/src/net/http/serve_test.go index c681e01f423e3a..19aa63f8cc5beb 100644 --- a/src/net/http/serve_test.go +++ b/src/net/http/serve_test.go @@ -7938,3 +7938,184 @@ func TestServerHTTP2Disabled(t *testing.T) { srv.Shutdown(t.Context()) }) } + +func TestServerConnectionReuse(t *testing.T) { + for _, test := range []struct { + name string + message []string + handler HandlerFunc + continueBodySize int + want100Continue bool + wantResponse int + wantReused bool + skip string + }{{ + name: "small body", + message: []string{ + "POST / HTTP/1.1", + "Host: example.tld", + "Content-Length: 1", + "", + "x", + }, + wantResponse: 200, + wantReused: true, + }, { + name: "large body", + message: []string{ + "POST / HTTP/1.1", + "Host: example.tld", + "Content-Length: 300000", // more than maxPostHandlerReadBytes + "", + // body is never sent + }, + wantResponse: 200, + wantReused: false, + }, { + name: "small body full duplex", + message: []string{ + "POST / HTTP/1.1", + "Host: example.tld", + "Content-Length: 1", + "", + "x", + }, + handler: func(w ResponseWriter, req *Request) { + // Enable full duplex to avoid trying to read the request before + // writing the response. + NewResponseController(w).EnableFullDuplex() + }, + wantResponse: 200, + wantReused: true, + }, { + name: "large body full duplex", + message: []string{ + "POST / HTTP/1.1", + "Host: example.tld", + "Content-Length: 300000", // more than maxPostHandlerReadBytes + "", + // body is never sent + }, + handler: func(w ResponseWriter, req *Request) { + // Enable full duplex to avoid trying to read the request before + // writing the response. + NewResponseController(w).EnableFullDuplex() + }, + wantResponse: 200, + wantReused: false, + }, { + // Send a request with a 1-byte body, which the server handler never reads. + // We should either send a 100-Continue and read the body + // or we should close the connection. + // + // Right now, the server hangs trying to read the request body + // the client isn't sending. + skip: "https://go.dev/issue/75933", + + name: "100-continue unconsumed small body", + message: []string{ + "POST / HTTP/1.1", + "Host: example.tld", + "Expect: 100-continue", + "Content-Length: 1", + "", + // body is never sent + }, + want100Continue: false, + wantResponse: 200, + wantReused: true, + }, { + name: "100-continue unconsumed large body", + message: []string{ + "POST / HTTP/1.1", + "Host: example.tld", + "Expect: 100-continue", + "Content-Length: 300000", // more than maxPostHandlerReadBytes + "", + // body is never sent + }, + want100Continue: false, + wantResponse: 200, + wantReused: false, + }, { + name: "100-continue consumed small body", + message: []string{ + "POST / HTTP/1.1", + "Host: example.tld", + "Expect: 100-continue", + "Content-Length: 1", + "", + }, + handler: func(w ResponseWriter, req *Request) { + io.Copy(io.Discard, req.Body) + }, + want100Continue: true, + continueBodySize: 1, + wantResponse: 200, + wantReused: true, + }, { + name: "small wrapped body", + message: []string{ + "POST / HTTP/1.1", + "Host: example.tld", + "Content-Length: 1", + "", + "x", + }, + handler: func(w ResponseWriter, req *Request) { + // Enable full duplex to avoid trying to read the request before + // writing the response. + NewResponseController(w).EnableFullDuplex() + + // Middleware wraps the Request.Body in some other type. + req.Body = struct{ io.ReadCloser }{req.Body} + }, + wantResponse: 200, + wantReused: true, + }, { + name: "large wrapped body", + message: []string{ + "POST / HTTP/1.1", + "Host: example.tld", + "Content-Length: 300000", // more than maxPostHandlerReadBytes + "", + }, + handler: func(w ResponseWriter, req *Request) { + // Enable full duplex to avoid trying to read the request before + // writing the response. + NewResponseController(w).EnableFullDuplex() + + // Middleware wraps the Request.Body in some other type. + req.Body = struct{ io.ReadCloser }{req.Body} + }, + wantResponse: 200, + wantReused: false, + }} { + t.Run(test.name, func(t *testing.T) { + if test.skip != "" { + t.Skip(test.skip) + } + synctest.Test(t, func(t *testing.T) { + st := newHTTP1ServerTest(t, test.handler) + conn := st.dial() + conn.writeMessage(test.message...) + resp := conn.readResponse() + if got, want := resp.StatusCode == 100, test.want100Continue; got != want { + t.Fatalf("100-Continue response: %v, want %v", got, want) + } + if resp.StatusCode == 100 { + conn.conn.Write(bytes.Repeat([]byte("x"), test.continueBodySize)) + resp = conn.readResponse() + } + if got, want := resp.StatusCode, test.wantResponse; got != want { + t.Fatalf("got response %v, want %v", got, want) + } + if test.wantReused { + conn.wantIdle() + } else { + conn.wantClosed() + } + }) + }) + } +} diff --git a/src/net/http/server.go b/src/net/http/server.go index ae2142705d4640..cdf5c43c2ab040 100644 --- a/src/net/http/server.go +++ b/src/net/http/server.go @@ -426,12 +426,13 @@ func (cw *chunkWriter) close() { // A response represents the server side of an HTTP response. type response struct { conn *conn - req *Request // request for this response - reqBody io.ReadCloser + req *Request // request for this response + reqBody *body // nil when NoBody cancelCtx context.CancelFunc // when ServeHTTP exits wroteHeader bool // a non-1xx header has been (logically) written wants10KeepAlive bool // HTTP/1.0 w/ Connection "keep-alive" wantsClose bool // HTTP request has Connection "close" + ecReader *expectContinueReader // canWriteContinue is an atomic boolean that says whether or // not a 100 Continue header can be written to the @@ -579,8 +580,7 @@ func (w *response) requestTooLarge() { // the request body and flags the connection to be closed after the reply, as // the client will never send the body. func (w *response) disableWriteContinue(skipDrain bool) { - ecr, ok := w.reqBody.(*expectContinueReader) - if !ok { + if w.ecReader == nil { return } w.writeContinueMu.Lock() @@ -591,7 +591,7 @@ func (w *response) disableWriteContinue(skipDrain bool) { // "Connection: close" header in the response. w.closeAfterReply = true // Ensure that the body will not be drained in Close. - ecr.closed.Store(true) + w.ecReader.closed.Store(true) } } w.writeContinueMu.Unlock() @@ -989,7 +989,6 @@ type expectContinueReader struct { resp *response readCloser io.ReadCloser closed atomic.Bool - sawEOF atomic.Bool } func (ecr *expectContinueReader) Read(p []byte) (n int, err error) { @@ -1006,11 +1005,7 @@ func (ecr *expectContinueReader) Read(p []byte) (n int, err error) { } w.writeContinueMu.Unlock() } - n, err = ecr.readCloser.Read(p) - if err == io.EOF { - ecr.sawEOF.Store(true) - } - return + return ecr.readCloser.Read(p) } func (ecr *expectContinueReader) Close() error { @@ -1095,8 +1090,14 @@ func (c *conn) readRequest(ctx context.Context) (w *response, err error) { req.ctx = ctx req.RemoteAddr = c.remoteAddr req.TLS = c.tlsState - if body, ok := req.Body.(*body); ok { - body.doEarlyClose = true + var reqBody *body + switch b := req.Body.(type) { + case noBody: + case *body: + reqBody = b + reqBody.doEarlyClose = true + default: + panic(fmt.Errorf("http: unexpected request body type %T", req.Body)) } c.rwc.SetReadDeadline(wholeReqDeadline) @@ -1105,7 +1106,7 @@ func (c *conn) readRequest(ctx context.Context) (w *response, err error) { conn: c, cancelCtx: cancelCtx, req: req, - reqBody: req.Body, + reqBody: reqBody, handlerHeader: make(Header), contentLength: -1, @@ -1425,7 +1426,7 @@ func (cw *chunkWriter) writeHeader(p []byte) { // than to read the body. For now, assume that if we're sending // headers, the handler is done reading the body and we should // drop the connection if we haven't seen EOF. - if ecr, ok := w.req.Body.(*expectContinueReader); ok && !ecr.sawEOF.Load() { + if w.ecReader != nil && w.reqBody.bodyRemains() { w.closeAfterReply = true } @@ -1443,53 +1444,28 @@ func (cw *chunkWriter) writeHeader(p []byte) { // before reading a response may deadlock in this case. // This behavior has been present since CL 5268043 (2011), however, // so it doesn't seem to be causing problems. - if w.req.ContentLength != 0 && !w.closeAfterReply && !w.fullDuplex { + if w.req.ContentLength != 0 && w.reqBody != nil && !w.closeAfterReply && !w.fullDuplex { var discard, tooBig bool - - switch bdy := w.req.Body.(type) { - case *expectContinueReader: - // We only get here if we have already fully consumed the request body - // (see above). - case *body: - bdy.mu.Lock() - switch { - case bdy.closed: - if !bdy.sawEOF { - // Body was closed in handler with non-EOF error. - w.closeAfterReply = true - } - case bdy.unreadDataSizeLocked() >= maxPostHandlerReadBytes: - tooBig = true - default: - discard = true + w.reqBody.mu.Lock() + switch { + case w.reqBody.closed: + if !w.reqBody.sawEOF { + // Body was closed in handler with non-EOF error. + w.closeAfterReply = true } - bdy.mu.Unlock() + case w.reqBody.unreadDataSizeLocked() >= maxPostHandlerReadBytes: + tooBig = true default: discard = true } + w.reqBody.mu.Unlock() if discard { - _, err := io.CopyN(io.Discard, w.reqBody, maxPostHandlerReadBytes+1) - switch err { - case nil: - // There must be even more data left over. - tooBig = true - case ErrBodyReadAfterClose: - // Body was already consumed and closed. - case io.EOF: - // The remaining body was just consumed, close it. - err = w.reqBody.Close() - if err != nil { - w.closeAfterReply = true - } - default: - // Some other kind of error occurred, like a read timeout, or - // corrupt chunked encoding. In any case, whatever remains - // on the wire must not be parsed as another HTTP request. + w.reqBody.Close() + if w.reqBody.didEarlyClose() { w.closeAfterReply = true } } - if tooBig { w.requestTooLarge() delHeader("Connection") @@ -1728,6 +1704,7 @@ func (w *response) finishRequest() { w.conn.bufw.Flush() w.conn.r.abortPendingRead() + w.reqBody.registerOnHitEOF(nil) // prevent new background read from starting if w.canWriteContinue.Load() { w.disableWriteContinue(true) @@ -1735,6 +1712,8 @@ func (w *response) finishRequest() { // Close the body (regardless of w.closeAfterReply) so we can // re-use its bufio.Reader later safely. + // + // In full-duplex mode, this may also drain the remaining request body. w.reqBody.Close() if w.req.MultipartForm != nil { @@ -1771,8 +1750,7 @@ func (w *response) shouldReuseConnection() bool { } func (w *response) closedRequestBodyEarly() bool { - body, ok := w.req.Body.(*body) - return ok && body.didEarlyClose() + return w.reqBody != nil && w.reqBody.didEarlyClose() } func (w *response) Flush() { @@ -2128,9 +2106,9 @@ func (c *conn) serve(ctx context.Context) { if req.expectsContinue() { if req.ProtoAtLeast(1, 1) && req.ContentLength != 0 { // Wrap the Body reader with one that replies on the connection - req.Body = &expectContinueReader{readCloser: req.Body, resp: w} + w.ecReader = &expectContinueReader{readCloser: req.Body, resp: w} w.canWriteContinue.Store(true) - w.reqBody = req.Body + req.Body = w.ecReader } } else if req.Header.get("Expect") != "" { w.sendExpectationFailed() @@ -2139,8 +2117,11 @@ func (c *conn) serve(ctx context.Context) { c.curReq.Store(w) - if requestBodyRemains(req.Body) { - registerOnHitEOF(req.Body, w.conn.r.startBackgroundRead) + // Start background read, which detects when a client has closed its connection + // while a request handler is still running. When the request has a body, we + // start the background read only after the entire body has been consumed. + if w.reqBody.bodyRemains() { + w.reqBody.registerOnHitEOF(w.conn.r.startBackgroundRead) } else { w.conn.r.startBackgroundRead() } @@ -2344,33 +2325,6 @@ func (w *response) closeNotify() { } } -func registerOnHitEOF(rc io.ReadCloser, fn func()) { - switch v := rc.(type) { - case *expectContinueReader: - registerOnHitEOF(v.readCloser, fn) - case *body: - v.registerOnHitEOF(fn) - default: - panic("unexpected type " + fmt.Sprintf("%T", rc)) - } -} - -// requestBodyRemains reports whether future calls to Read -// on rc might yield more data. -func requestBodyRemains(rc io.ReadCloser) bool { - if rc == NoBody { - return false - } - switch v := rc.(type) { - case *expectContinueReader: - return requestBodyRemains(v.readCloser) - case *body: - return v.bodyRemains() - default: - panic("unexpected type " + fmt.Sprintf("%T", rc)) - } -} - // The HandlerFunc type is an adapter to allow the use of // ordinary functions as HTTP handlers. If f is a function // with the appropriate signature, HandlerFunc(f) is a diff --git a/src/net/http/transfer.go b/src/net/http/transfer.go index 1a3afc6f5fbd5c..451a7d2263f8ad 100644 --- a/src/net/http/transfer.go +++ b/src/net/http/transfer.go @@ -838,6 +838,9 @@ type body struct { var ErrBodyReadAfterClose = errors.New("http: invalid Read on closed Body") func (b *body) Read(p []byte) (n int, err error) { + if b == nil { + return 0, io.EOF + } b.mu.Lock() defer b.mu.Unlock() if b.closed { @@ -978,6 +981,9 @@ func (b *body) unreadDataSizeLocked() int64 { } func (b *body) Close() error { + if b == nil { + return nil + } b.mu.Lock() defer b.mu.Unlock() if b.closed { @@ -1001,12 +1007,11 @@ func (b *body) Close() error { var n int64 // Consume the body, or, which will also lead to us reading // the trailer headers after the body, if present. - n, err = io.CopyN(io.Discard, bodyLocked{b}, maxPostHandlerReadBytes) - if err == io.EOF { - err = nil - } - if n == maxPostHandlerReadBytes { - b.earlyClose = true + n, err = io.CopyN(io.Discard, bodyLocked{b}, maxPostHandlerReadBytes+1) + b.earlyClose = true + if err == io.EOF && n <= maxPostHandlerReadBytes { + b.earlyClose = false + b.sawEOF = true } } default: @@ -1027,12 +1032,18 @@ func (b *body) didEarlyClose() bool { // bodyRemains reports whether future Read calls might // yield data. func (b *body) bodyRemains() bool { + if b == nil { + return false + } b.mu.Lock() defer b.mu.Unlock() return !b.sawEOF } func (b *body) registerOnHitEOF(fn func()) { + if b == nil { + return + } b.mu.Lock() defer b.mu.Unlock() b.onHitEOF = fn From a5f29a67d7a26ce6d4d7c2fbae12d93a4ea8b24e Mon Sep 17 00:00:00 2001 From: Michael Pratt Date: Tue, 14 Jul 2026 12:48:45 -0400 Subject: [PATCH 5/7] encoding/json/v2: add UnmarshalDecode example Add an example of using UnmmarshalDecode to unmarshal a stream of JSON values. This example is pretty much a straight port of encoding/json.ExampleDecode. Change-Id: I9625db144dcad658ac762646ffcc4f016a6a6964 Reviewed-on: https://go-review.googlesource.com/c/go/+/800640 LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com Reviewed-by: Damien Neil Reviewed-by: Joseph Tsai --- src/encoding/json/v2/example_test.go | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/encoding/json/v2/example_test.go b/src/encoding/json/v2/example_test.go index 2596a10abfde1d..c0a8587425d668 100644 --- a/src/encoding/json/v2/example_test.go +++ b/src/encoding/json/v2/example_test.go @@ -10,6 +10,7 @@ import ( "bytes" "errors" "fmt" + "io" "log" "net/http" "net/netip" @@ -559,3 +560,31 @@ func ExampleWithUnmarshalers_recordOffsets() { // Output: // 3:3: source and destination must both be specified } + +// UnmarshalDecode can be used to unmarshal a stream of whitespace-delimited +// JSON values. +func ExampleUnmarshalDecode_stream() { + const jsonStream = ` + {"Name": "Platypus", "Order": "Monotremata"} + {"Name": "Quoll", "Order": "Dasyuromorphia"} + {"Name": "Gopher", "Order": "Rodentia"} +` + type Animal struct { + Name string + Order string + } + dec := jsontext.NewDecoder(strings.NewReader(jsonStream)) + for { + var a Animal + if err := json.UnmarshalDecode(dec, &a); err == io.EOF { + break + } else if err != nil { + log.Fatal(err) + } + fmt.Printf("%s: %s\n", a.Name, a.Order) + } + // Output: + // Platypus: Monotremata + // Quoll: Dasyuromorphia + // Gopher: Rodentia +} From 6c87434d24a30d91c75ada81ea3f82bfaa42271f Mon Sep 17 00:00:00 2001 From: Michael Matloob Date: Tue, 14 Jul 2026 17:46:36 -0400 Subject: [PATCH 6/7] internal/goversion: update Version to 1.28 Go 1.28 is in the process of being opened for development (to be eventually released). This change marks the very beginning of its development cycle, updating the Version value accordingly. For #40705 For #79581 Change-Id: I5612e7ed16f64ef94fdfb53f0f6481e16a6a6964 Reviewed-on: https://go-review.googlesource.com/c/go/+/800721 LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com Reviewed-by: Michael Matloob Reviewed-by: Michael Pratt --- src/internal/goversion/goversion.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/goversion/goversion.go b/src/internal/goversion/goversion.go index b33e0d46cc31fe..abba8ccadcc44b 100644 --- a/src/internal/goversion/goversion.go +++ b/src/internal/goversion/goversion.go @@ -9,4 +9,4 @@ package goversion // // It should be updated at the start of each development cycle to be // the version of the next Go 1.x release. See go.dev/issue/40705. -const Version = 27 +const Version = 28 From 74499361ee24fc6de2f9c3c7d310f0469cee2175 Mon Sep 17 00:00:00 2001 From: Michael Matloob Date: Tue, 14 Jul 2026 17:48:48 -0400 Subject: [PATCH 7/7] doc: initialize next directory for Go 1.28 Following the "For the release team" steps in README: cd doc cp -R initial/ next $EDITOR next/1-intro.md For #79581 Change-Id: I0e56193e2020d0d3804fde9ec6114bef6a6a6964 Reviewed-on: https://go-review.googlesource.com/c/go/+/800722 Reviewed-by: Michael Matloob Reviewed-by: Michael Pratt LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com --- doc/next/1-intro.md | 8 ++++++++ doc/next/2-language.md | 3 +++ doc/next/3-tools.md | 6 ++++++ doc/next/4-runtime.md | 1 + doc/next/5-toolchain.md | 7 +++++++ doc/next/6-stdlib/0-heading.md | 2 ++ doc/next/6-stdlib/99-minor/0-heading.md | 1 + doc/next/6-stdlib/99-minor/README | 1 + doc/next/7-ports.md | 2 ++ 9 files changed, 31 insertions(+) create mode 100644 doc/next/1-intro.md create mode 100644 doc/next/2-language.md create mode 100644 doc/next/3-tools.md create mode 100644 doc/next/4-runtime.md create mode 100644 doc/next/5-toolchain.md create mode 100644 doc/next/6-stdlib/0-heading.md create mode 100644 doc/next/6-stdlib/99-minor/0-heading.md create mode 100644 doc/next/6-stdlib/99-minor/README create mode 100644 doc/next/7-ports.md diff --git a/doc/next/1-intro.md b/doc/next/1-intro.md new file mode 100644 index 00000000000000..709ddf58378e5e --- /dev/null +++ b/doc/next/1-intro.md @@ -0,0 +1,8 @@ + + +## DRAFT RELEASE NOTES — Introduction to Go 1.28 {#introduction} + +**Go 1.28 is not yet released. These are work-in-progress release notes. +Go 1.28 is expected to be released in February 2027.** diff --git a/doc/next/2-language.md b/doc/next/2-language.md new file mode 100644 index 00000000000000..61030bd67606b0 --- /dev/null +++ b/doc/next/2-language.md @@ -0,0 +1,3 @@ +## Changes to the language {#language} + + diff --git a/doc/next/3-tools.md b/doc/next/3-tools.md new file mode 100644 index 00000000000000..5638f240a5b127 --- /dev/null +++ b/doc/next/3-tools.md @@ -0,0 +1,6 @@ +## Tools {#tools} + +### Go command {#go-command} + +### Cgo {#cgo} + diff --git a/doc/next/4-runtime.md b/doc/next/4-runtime.md new file mode 100644 index 00000000000000..1f8e445e0b10de --- /dev/null +++ b/doc/next/4-runtime.md @@ -0,0 +1 @@ +## Runtime {#runtime} diff --git a/doc/next/5-toolchain.md b/doc/next/5-toolchain.md new file mode 100644 index 00000000000000..0f4a816479754c --- /dev/null +++ b/doc/next/5-toolchain.md @@ -0,0 +1,7 @@ +## Compiler {#compiler} + +## Assembler {#assembler} + +## Linker {#linker} + + diff --git a/doc/next/6-stdlib/0-heading.md b/doc/next/6-stdlib/0-heading.md new file mode 100644 index 00000000000000..a992170d433326 --- /dev/null +++ b/doc/next/6-stdlib/0-heading.md @@ -0,0 +1,2 @@ +## Standard library {#library} + diff --git a/doc/next/6-stdlib/99-minor/0-heading.md b/doc/next/6-stdlib/99-minor/0-heading.md new file mode 100644 index 00000000000000..266d98f496a779 --- /dev/null +++ b/doc/next/6-stdlib/99-minor/0-heading.md @@ -0,0 +1 @@ +### Minor changes to the library {#minor_library_changes} diff --git a/doc/next/6-stdlib/99-minor/README b/doc/next/6-stdlib/99-minor/README new file mode 100644 index 00000000000000..fac778de050642 --- /dev/null +++ b/doc/next/6-stdlib/99-minor/README @@ -0,0 +1 @@ +API changes and other small changes to the standard library go here. diff --git a/doc/next/7-ports.md b/doc/next/7-ports.md new file mode 100644 index 00000000000000..8bea3f8fbc33f9 --- /dev/null +++ b/doc/next/7-ports.md @@ -0,0 +1,2 @@ +## Ports {#ports} +