Files
zero/internal/oauth/callback_server.go
Vasanth T 547330197b fix(oauth): give every loopback callback server the bounded shutdown (#1079)
* fix(oauth): give every loopback callback server the bounded shutdown

The loopback OAuth callback server was written out three times: the shared
oauth.LoopbackListener, MCP's Login and OpenRouter's login. Each ended with
Shutdown under a one-second deadline, and Shutdown only closes idle
connections. A client that has sent part of a request header is not idle, so it
survived the deadline, and its goroutine and the Serve goroutine stayed alive
after the flow returned (#1028).

#1051 fixed that for LoopbackListener (a header read bound, force-close when
Shutdown runs out of time, and a join on Serve) and left the other two exactly
as they were, because each was its own copy.

The lifecycle now lives once, in oauth.CallbackServer, with #1051's behavior
unchanged: ReadHeaderTimeout of 5s, Shutdown within 1s, Close() on a missed
deadline, and Close returning only after Serve has. LoopbackListener uses it
through an unexported constructor that keeps its ConnState test hook, and MCP's
Login and OpenRouterLogin use StartCallbackServer, which both packages could
already import.

Each call site gets a regression test that stalls a raw client mid-header ahead
of the real callback and then requires the stalled connection to be closed once
the login returns. The ordering does the synchronising: the stalled client is
dialled before the real callback, Serve accepts in order, and the login cannot
finish until the real callback has been served, so no sleep is involved.

Fixes #1028

* test(oauth): pin the callback server's header bound and its join on Serve

Two of the three things CallbackServer does had no test that noticed their
removal, the same as when #1051 introduced them on LoopbackListener: the
five-second header read bound, and Close waiting for the Serve goroutine.

The header test stalls a client mid-header and never calls Close, so it can
only pass on ReadHeaderTimeout; the bound is a package var so the test can
shorten it. It synchronises on StateNew. Go reports StateActive only after a
request read returns, and a stalled header read returns only when the header
timeout fires, so a helper waiting for StateActive depended on the bound under
test and turned its removal into a SETUP INVALID instead of a failure.

The join test uses the case where the join matters: with no connections open,
Shutdown closes the listener and returns at once, so without the join Close can
return before Serve does. Without it the test failed 10 runs out of 10.
2026-10-12 09:33:10 +08:00

71 lines
2.5 KiB
Go

package oauth
import (
"context"
"net"
"net/http"
"time"
)
// callbackReadHeaderTimeout bounds how long an accepted connection may take to
// send its request header. A single-use loopback callback server has no reason
// to wait longer, and without a bound a client that stalls mid-header holds the
// connection, and the server goroutine, open indefinitely. A var so the
// package tests can shorten it.
var callbackReadHeaderTimeout = 5 * time.Second
// callbackShutdownBudget is how long Close waits for in-flight requests before
// it force-closes whatever is still open.
const callbackShutdownBudget = time.Second
// CallbackServer is the lifecycle every loopback OAuth callback server needs,
// in one place.
//
// It exists because the same server was written out three times (the shared
// LoopbackListener, MCP's Login and OpenRouter's login) and a fix to one of
// them did not reach the others. http.Server.Shutdown only closes idle
// connections, so a client that has sent part of a request header is active,
// survives the shutdown deadline, and keeps its goroutine and the Serve
// goroutine alive past the end of the flow (#1028). #1051 closed that for
// LoopbackListener; the other two kept the original construction.
type CallbackServer struct {
server *http.Server
done chan struct{}
}
// StartCallbackServer serves handler on listener until Close.
func StartCallbackServer(listener net.Listener, handler http.Handler) *CallbackServer {
return startCallbackServer(listener, handler, nil)
}
// startCallbackServer is StartCallbackServer with a ConnState hook, which the
// package's own tests use to see a connection reach a given state.
func startCallbackServer(listener net.Listener, handler http.Handler, connState func(net.Conn, http.ConnState)) *CallbackServer {
s := &CallbackServer{
server: &http.Server{
Handler: handler,
ReadHeaderTimeout: callbackReadHeaderTimeout,
ConnState: connState,
},
done: make(chan struct{}),
}
go func() {
_ = s.server.Serve(listener)
close(s.done)
}()
return s
}
// Close shuts the server down within callbackShutdownBudget. A connection still
// open when the budget runs out (a stalled request header, say) is force-closed
// rather than leaked, and Close returns only after the Serve goroutine has
// finished, so nothing this server started outlives the call. Idempotent.
func (s *CallbackServer) Close() {
ctx, cancel := context.WithTimeout(context.Background(), callbackShutdownBudget)
defer cancel()
if err := s.server.Shutdown(ctx); err != nil {
_ = s.server.Close()
}
<-s.done
}