Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions deploy/systemd/shithubd-web.service
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ ExecStartPre=/usr/local/bin/shithubd migrate up
ExecStart=/usr/local/bin/shithubd web
Restart=on-failure
RestartSec=2
# Bound the stop phase. Run() drains for web.shutdown_timeout (10s)
# and then force-closes; anything beyond this is a bug, not a drain.
TimeoutStopSec=30
LimitNOFILE=65535

# Memory ceiling. The 2026-09-02 availability sitrep
Expand Down
45 changes: 45 additions & 0 deletions internal/cache/pagecache/notify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,48 @@ func TestListen_BadPayloadSwallowed(t *testing.T) {
t.Fatal("listener stuck after bad payload — must keep processing")
}
}

// TestListen_ReleasesConnOnCancel pins the shutdown contract: once the
// context is canceled, Listen must release its LISTEN connection so a
// following pool.Close() returns promptly. Before the 2026-09-02 fix the
// web server never canceled this context on SIGTERM, pool.Close()
// blocked on the held connection, and systemd SIGKILLed the process
// after 90 s of 502s on every deploy.
func TestListen_ReleasesConnOnCancel(t *testing.T) {
t.Parallel()
pool := dbtest.NewTestDB(t)

ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
pagecache.Listen(ctx, pool, func(int64, string) {}, slog.New(slog.NewTextHandler(io.Discard, nil)))
}()

deadline := time.Now().Add(5 * time.Second)
for pool.Stat().AcquiredConns() == 0 {
if time.Now().After(deadline) {
cancel()
t.Fatal("listener never acquired a connection")
}
time.Sleep(10 * time.Millisecond)
}

cancel()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("Listen did not return after cancel")
}

closed := make(chan struct{})
go func() {
pool.Close()
close(closed)
}()
select {
case <-closed:
case <-time.After(5 * time.Second):
t.Fatal("pool.Close blocked after listener cancel")
}
}
22 changes: 20 additions & 2 deletions internal/web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ type Options struct {

// Run boots the web server and blocks until shutdown.
func Run(ctx context.Context, opts Options) error {
// Everything started below (pagecache LISTEN, metrics observers,
// pprof) hangs off this context. It is canceled on shutdown so
// goroutines holding pool connections release them before the
// deferred pool.Close(), which otherwise blocks until systemd's
// stop timeout SIGKILLs the process (observed: 90 s of 502s per
// deploy, 2026-09-02 sitrep).
ctx, cancelAll := context.WithCancel(ctx)
defer cancelAll()
cfg, err := config.Load(nil)
if err != nil {
return err
Expand Down Expand Up @@ -475,8 +483,18 @@ func Run(ctx context.Context, opts Options) error {

shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.Web.ShutdownTimeout)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown: %w", err)
shutdownErr := srv.Shutdown(shutdownCtx)
if shutdownErr != nil {
// Drain window elapsed with connections still active (long
// polls, SSE). Close them so their handlers see a canceled
// request context and release whatever they hold.
logger.Warn("shutdown: drain timed out; closing remaining connections", "error", shutdownErr)
_ = srv.Close()
}
// Stop background goroutines before the deferred pool.Close().
cancelAll()
if shutdownErr != nil {
return fmt.Errorf("shutdown: %w", shutdownErr)
}
return nil
}
Expand Down
Loading