diff --git a/client/client_local_source_test.go b/client/client_local_source_test.go index 38208b9e52f5..06ab274bd988 100644 --- a/client/client_local_source_test.go +++ b/client/client_local_source_test.go @@ -433,3 +433,62 @@ func testParallelLocalBuilds(t *testing.T, sb integration.Sandbox) { err = eg.Wait() require.NoError(t, err) } + +// testLocalSourceManyPatterns checks that filesync patterns are not limited by +// the HTTP/2 header value count. Every pattern is sent as a separate gRPC +// metadata value and Go 1.27 net/http rejects requests with more than 500 of +// them by default. +func testLocalSourceManyPatterns(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + dir := integration.Tmpdir( + t, + fstest.CreateFile("foo", []byte("foo"), 0600), + fstest.CreateFile("ignored-1", []byte("ignored"), 0600), + ) + + const n = 1000 + excludes := make([]string, 0, n) + includes := make([]string, 0, n+1) + for i := 1; i <= n; i++ { + excludes = append(excludes, fmt.Sprintf("ignored-%d", i)) + includes = append(includes, fmt.Sprintf("included-%d", i)) + } + includes = append(includes, "foo") + + for _, tc := range []struct { + name string + opt llb.LocalOption + }{ + {name: "exclude", opt: llb.ExcludePatterns(excludes)}, + {name: "include", opt: llb.IncludePatterns(includes)}, + } { + t.Run(tc.name, func(t *testing.T) { + def, err := llb.Local("mylocal", tc.opt).Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + LocalMounts: map[string]fsutil.FS{ + "mylocal": dir, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "foo")) + require.NoError(t, err) + require.Equal(t, "foo", string(dt)) + + _, err = os.Stat(filepath.Join(destDir, "ignored-1")) + require.ErrorIs(t, err, os.ErrNotExist) + }) + } +} diff --git a/client/client_test.go b/client/client_test.go index f38f55a0506a..d48dbaff9ca8 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -192,6 +192,7 @@ var allTests = []func(t *testing.T, sb integration.Sandbox){ testLocalSymlinkEscape, testMetadataOnlyLocal, testParallelLocalBuilds, + testLocalSourceManyPatterns, // client_mergeop_test.go testMergeOp, diff --git a/session/grpc.go b/session/grpc.go index aa5bd9405ee1..3a47888e6bb4 100644 --- a/session/grpc.go +++ b/session/grpc.go @@ -63,7 +63,7 @@ func serve(ctx context.Context, grpcServer *grpc.Server, conn net.Conn) { conn.Close() }() bklog.G(ctx).Debugf("serving grpc connection") - (&http2.Server{}).ServeConn(conn, &http2.ServeConnOpts{Handler: grpcServer}) //nolint:staticcheck // This existing HTTP/2 connection is already established. + newHTTP2Server(ctx).ServeConn(conn, &http2.ServeConnOpts{Handler: grpcServer}) //nolint:staticcheck // This existing HTTP/2 connection is already established. } func grpcClientConn(ctx context.Context, conn net.Conn, opts map[string][]string) (context.Context, *grpc.ClientConn, error) { diff --git a/session/http2server_go127.go b/session/http2server_go127.go new file mode 100644 index 000000000000..698eba74077e --- /dev/null +++ b/session/http2server_go127.go @@ -0,0 +1,29 @@ +//go:build go1.27 + +package session + +import ( + "context" + "math" + "net/http" + + "github.com/moby/buildkit/util/bklog" + "golang.org/x/net/http2" +) + +// newHTTP2Server returns the HTTP/2 server for session connections. +// +// Since Go 1.27, x/net/http2 is backed by net/http, which limits requests to +// http.DefaultMaxHeaderValueCount (500) header values. Session metadata, such +// as filesync include/exclude patterns, uses one value per item and can exceed +// that. The total header size is still bounded by http.DefaultMaxHeaderBytes. +func newHTTP2Server(ctx context.Context) *http2.Server { //nolint:staticcheck // ServeConn on an established connection needs x/net/http2. + h2 := &http2.Server{} //nolint:staticcheck // ServeConn on an established connection needs x/net/http2. + // h1 only carries config for h2 connections and never serves on its own. + h1 := &http.Server{MaxHeaderValueCount: math.MaxInt} //nolint:gosec // G112: session streams are long-lived, no read timeouts. + if err := http2.ConfigureServer(h1, h2); err != nil { //nolint:staticcheck // h1 is never served, Protocols does not apply. + bklog.G(ctx).WithError(err).Warn("failed to configure session http2 server") + return &http2.Server{} //nolint:staticcheck // ServeConn on an established connection needs x/net/http2. + } + return h2 +} diff --git a/session/http2server_legacy.go b/session/http2server_legacy.go new file mode 100644 index 000000000000..5dd3303ef212 --- /dev/null +++ b/session/http2server_legacy.go @@ -0,0 +1,15 @@ +//go:build !go1.27 + +package session + +import ( + "context" + + "golang.org/x/net/http2" +) + +// newHTTP2Server returns the HTTP/2 server for session connections. Before Go +// 1.27 x/net/http2 has no header value count limit, so no extra setup is needed. +func newHTTP2Server(_ context.Context) *http2.Server { //nolint:staticcheck // ServeConn on an established connection needs x/net/http2. + return &http2.Server{} //nolint:staticcheck // ServeConn on an established connection needs x/net/http2. +}