Skip to content

queue-proxy can crash with concurrent map iteration and map write during HTTP/2 response finalization #16656

Open

Description

@junyicc

What version of Knative?

knative version:1.17.1

Observed in a downstream build based on Knative Serving. The queue-proxy logs report:

"commit":"20c92a1-dirty"

The downstream commit resolves to 20c92a10d2ff6aa0162e0c74f9080e1fac7ec441. The relevant timeout handler code matches current upstream main in the suspected critical path: timeoutWriter.Header() directly returns the underlying http.ResponseWriter header map without synchronization.

I also checked upstream main at dbce551; pkg/http/handler/timeout.go still contains:

func (tw *timeoutWriter) Header() http.Header { return tw.w.Header() }

Expected Behavior

queue-proxy should not crash due to concurrent HTTP/2 response header/trailer access. Request cancellation or timeout should fail only the affected request, not the queue-proxy process.

Actual Behavior

queue-proxy crashed and restarted with exitCode=2. The fatal runtime error was:

fatal error: concurrent map iteration and map write

The panic stack points to HTTP/2 response finalization iterating/promoting trailers while the response header map was concurrently modified:

fatal error: concurrent map iteration and map write

goroutine 1123810148 [running]:
internal/runtime/maps.fatal({0x128031e?, 0xc000524530?})
	runtime/panic.go:1058 +0x18
internal/runtime/maps.(*Iter).Next(0xc000948c20?)
	internal/runtime/maps/table.go:683 +0x86
golang.org/x/net/http2.(*responseWriterState).promoteUndeclaredTrailers(0xc001d67780)
	golang.org/x/net@v0.34.0/http2/server.go:2851 +0x65
golang.org/x/net/http2.(*responseWriterState).writeChunk(0xc001d67780, {0xc0008aa000, 0xf, 0x1000})
	golang.org/x/net@v0.34.0/http2/server.go:2717 +0x77
golang.org/x/net/http2.chunkWriter.Write({0x10610e0?}, {0xc0008aa000?, 0xc0012831e0?, 0x1ce0680?})
	golang.org/x/net@v0.34.0/http2/server.go:2670 +0x1d
bufio.(*Writer).Flush(0xc000b093c0)
	bufio/bufio.go:643 +0x55
golang.org/x/net/http2.(*responseWriter).FlushError(0x142e0a0?)
	golang.org/x/net@v0.34.0/http2/server.go:2935 +0x31
golang.org/x/net/http2.(*responseWriter).Flush(...)
	golang.org/x/net@v0.34.0/http2/server.go:2925
golang.org/x/net/http2.(*responseWriter).handlerDone(0xc001425a50)
	golang.org/x/net@v0.34.0/http2/server.go:3100 +0x25
golang.org/x/net/http2.(*serverConn).runHandler.func1()
	golang.org/x/net@v0.34.0/http2/server.go:2473 +0x205
golang.org/x/net/http2.(*serverConn).runHandler(0x44cf12?, 0x0?, 0x0?, 0xc000146fb8?)
	golang.org/x/net@v0.34.0/http2/server.go:2477 +0x109
created by golang.org/x/net/http2.(*serverConn).scheduleHandler in goroutine 624989236
	golang.org/x/net@v0.34.0/http2/server.go:2409 +0x21d

At the same timestamp, other goroutines were in the queue-proxy timeout / reverse-proxy handler path:

knative.dev/pkg/network.ErrorHandler.func1
	knative.dev/pkg@v0.0.0-20250117084104-c43477f0052b/network/error_handler.go:33
net/http/httputil.(*ReverseProxy).ServeHTTP
	net/http/httputil/reverseproxy.go:486
knative.dev/serving/pkg/queue.(*appRequestMetricsHandler).ServeHTTP
	knative.dev/serving/pkg/queue/request_metric.go:205
knative.dev/serving/pkg/queue/sharedmain.mainHandler.ProxyHandler.func3
	knative.dev/serving/pkg/queue/handler.go:76
net/http.HandlerFunc.ServeHTTP
	net/http/server.go:2294
knative.dev/serving/pkg/queue/sharedmain.mainHandler.ForwardedShimHandler.func4
	knative.dev/serving/pkg/queue/forwarded_shim.go:54
net/http.HandlerFunc.ServeHTTP
	net/http/server.go:2294
knative.dev/serving/pkg/http/handler.(*timeoutHandler).ServeHTTP.func4
	knative.dev/serving/pkg/http/handler/timeout.go:118

Steps to Reproduce the Problem

I do not yet have a minimal standalone reproducer. The observed production trigger appears to involve:

  1. queue-proxy serving HTTP/2 traffic.
  2. Concurrent proxied requests.
  3. Request cancellation or timeout while response handling is being finalized.
  4. A response header/trailer map is modified while the HTTP/2 server iterates/promotes trailers during handlerDone.

Suspected Cause

The crash looks like concurrent access to the underlying http.Header map owned by the HTTP/2 response writer.

timeoutWriter synchronizes Flush, Write, WriteHeader, and timeout error writes with tw.mu, but Header() returns the raw underlying header map without synchronization:

func (tw *timeoutWriter) Header() http.Header { return tw.w.Header() }

Because callers can mutate the returned map outside timeoutWriter's mutex, the HTTP/2 server may concurrently iterate the same map in promoteUndeclaredTrailers, causing:

fatal error: concurrent map iteration and map write

This may be related to or another manifestation of #15850.

Activity

  1. shashankvarma499 commented on Sep 13, 2026

    @shashankvarma499

    Thanks for the detailed report and the pinned stack trace. I traced the suspected path and I agree the race is real, but the right fix has a couple of valid shapes, so I wanted to lay out the cause and a proposed direction before sending a PR.

    Root cause

    timeoutWriter.Header() in pkg/http/handler/timeout.go returns the underlying writer's map without taking tw.mu:

    func (tw *timeoutWriter) Header() http.Header { return tw.w.Header() }

    Every mutating method (Write, WriteHeader, Flush, and the try* helpers) holds tw.mu, but Header() hands the raw http.Header map back to the caller. A caller on the handler goroutine can then do tw.Header().Set(...) while the HTTP/2 server's writer goroutine iterates the same map in promoteUndeclaredTrailers during writeChunk/handlerDone. That is the "concurrent map iteration and map write" in your stack.

    There is a second aggravating factor in timeoutHandler.ServeHTTP: on the timeout branch it returns as soon as tryTimeoutAndWriteError succeeds, without waiting for the handler goroutine to finish. The inner handler keeps running (and can keep mutating headers / writing) after ServeHTTP has already returned and the HTTP/2 server has begun finalizing the response. The timedOut flag already makes post-timeout Write/WriteHeader/Flush no-ops, but Header() bypasses that guard entirely, which is why header mutation is the path that still races.

    Proposed direction

    The challenge is that simply locking tw.mu around the Header() return does not help, because the caller mutates the returned map after the method returns, and the HTTP/2 server iterates the underlying map directly rather than through the wrapper. Two directions seem viable:

    1. Make post-timeout header mutation a no-op, matching the existing timedOut handling for Write/WriteHeader/Flush. That means Header() returns a header view that refuses (or ignores) mutations once tw.timedOut is set. This is the smallest change and preserves the current abandon-the-handler-on-timeout design.
    2. Ensure the handler goroutine is drained before ServeHTTP returns on the timeout path (wait for done). This removes the concurrent access entirely but changes timeout semantics and could block the timeout path, so it is a larger behavioural change.

    I lean toward (1) as it reuses the existing timedOut mechanism rather than introducing a new synchronization scheme, but (2) is arguably the more complete fix.

    Tests

    • A go test -race unit test in pkg/http/handler that exercises a handler which calls Header().Set after a timeout while the response is being finalized, asserting the write is dropped and no race is reported.
    • Keep the existing timeout tests green, and add a case confirming the pre-timeout Header().Set path is unchanged.

    Defer

    If this is the same underlying issue as #15850, or if there is already an in-flight fix, I will leave it alone. Otherwise I can put up a PR for direction (1) once the approach is confirmed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions