mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 13:11:31 -10:00
* 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.
71 lines
2.5 KiB
Go
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
|
|
}
|