Skip to content

fix(server): graceful shutdown drains held requests; bind failures terminate the process#1020

Merged
acouvreur merged 1 commit into
mainfrom
fix/graceful-http-shutdown
Jul 11, 2026
Merged

fix(server): graceful shutdown drains held requests; bind failures terminate the process#1020
acouvreur merged 1 commit into
mainfrom
fix/graceful-http-shutdown

Conversation

@acouvreur

Copy link
Copy Markdown
Member

Finding

Three related lifecycle defects in the HTTP server, found during a design review of the wiring layer:

1. "Graceful" shutdown resets live connections. server.go says // Graceful web server shutdown. and then calls server.Close() - which is the opposite: it immediately terminates every active connection. Sablier's blocking strategy holds requests open by design (up to strategy.blocking.default-timeout, 1m by default), so every restart/deploy of Sablier reset the very requests it exists to hold, surfacing as 502s at the reverse proxy. The irony: the health endpoint already flips to 503 on shutdown to drain traffic - infrastructure built for draining, defeated by Close().

2. Port-conflict zombie. ListenAndServe errors were only logged (server failed to start) and swallowed. sablier start on a busy port kept running all its watchers with no HTTP listener - the process looks healthy in docker ps while serving nothing, forever.

3. Dead code confirming the intent. sabliercmd.Start creates a 15-second timeout context on shutdown (start.go:194) that is used by nothing - the fossil of a graceful drain that was never wired.

Discovery

The drain regression test holds a blocking request inside a mocked session call, cancels the server context mid-request, and asserts the response completes. Against the Close()-based implementation:

--- FAIL: TestStartDrainsInFlightRequestsOnShutdown
    Get "http://127.0.0.1:.../api/strategies/blocking?names=test": EOF
    : in-flight request must complete during the drain window

The client gets a connection reset exactly as a reverse-proxy plugin would during a deploy.

Fix

  • server.Start now returns an error and owns the full lifecycle: ListenAndServe failures return immediately (killing the zombie mode), and on ctx cancellation it drains with server.Shutdown using a window of max(15s, blocking default timeout + 5s grace) - derived from existing config, no new option - then force-closes whatever remains.
  • sabliercmd.Start waits for the drain to complete before saving state and exiting, and propagates a serve error as a non-zero exit. The dead 15s context is gone.
  • StartHttp (only used by the old path) is removed.

Tests

  • TestStartDrainsInFlightRequestsOnShutdown - deterministic (the request is provably in flight when shutdown starts); fails on Close(), passes on drain.
  • TestStartReturnsOnServeError - bind conflict returns an error instead of hanging.
  • TestDrainTimeout - pins the drain-window policy.

internal/server, internal/api, pkg/sabliercmd tests and golangci-lint pass.

Docs

No config surface changed (the drain window derives from the existing blocking timeout). There is currently no docs page describing server lifecycle/shutdown behavior; the policy is documented in code. Happy to add an ops note to the docs in a follow-up if wanted.

…nd errors

Shutdown called server.Close(), which resets every active connection. The
blocking strategy holds requests open up to its configured timeout by
design, so every restart cut those requests off mid-wait while the health
endpoint was dutifully reporting 503 to drain traffic. The server now uses
server.Shutdown with a drain window of max(15s, blocking default timeout
+ 5s grace), then force-closes whatever remains.

server.Start also returned nothing: a ListenAndServe failure (port already
in use, bad address) was only logged, and the process kept running all its
watchers with no HTTP listener, looking healthy while serving nothing.
Start now returns the serve error and sabliercmd terminates with it.

The dead 15-second timeout context in sabliercmd.Start (created, deferred,
used by nothing - the fossil of an intended drain that was never wired) is
replaced by actually waiting for the drain to complete before saving state
and exiting.

The drain regression test fails against the Close-based implementation
(client gets EOF) and passes with the drain; the bind-failure test pins
that Start returns instead of hanging.
@github-actions

Copy link
Copy Markdown

Test Results

✅ All tests passed! | 763 tests in 100.232s

View HTML Test Report

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
53.3% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@github-actions

Copy link
Copy Markdown
┌───────────────────────────────────────────────────────────────────────────────┐
│ Diff between sablier and sablier                                              │
├─────────┬─────────────────────────────────────┬──────────┬──────────┬─────────┤
│ PERCENT │ NAME                                │ OLD SIZE │ NEW SIZE │ DIFF    │
├─────────┼─────────────────────────────────────┼──────────┼──────────┼─────────┤
│ +0.26%  │ go.opentelemetry.io/otel            │ 1.1 MB   │ 1.1 MB   │ +2.7 kB │
│ +0.09%  │ github.com/sablierapp/sablier       │ 654 kB   │ 655 kB   │ +576 B  │
│ +0.17%  │ go.yaml.in/yaml/v2                  │ 275 kB   │ 275 kB   │ +480 B  │
│ +0.27%  │ go.opentelemetry.io/auto/sdk        │ 89 kB    │ 89 kB    │ +243 B  │
│ +0.10%  │ vendor/golang.org/x/net/http2/hpack │ 35 kB    │ 35 kB    │ +35 B   │
│ +0.00%  │ github.com/google/gnostic-models    │ 1.6 MB   │ 1.6 MB   │ +21 B   │
│ +0.00%  │ runtime                             │ 3.3 MB   │ 3.3 MB   │ +17 B   │
│ +0.01%  │ github.com/spf13/viper              │ 73 kB    │ 73 kB    │ +7 B    │
│ +0.00%  │ k8s.io/api                          │ 17 MB    │ 17 MB    │ +3 B    │
│ +0.00%  │ github.com/emicklei/go-restful/v3   │ 134 kB   │ 134 kB   │ +1 B    │
│ +0.00%  │ text/template                       │ 293 kB   │ 293 kB   │ +1 B    │
│ +0.00%  │ google.golang.org/grpc              │ 1.2 MB   │ 1.2 MB   │ +1 B    │
│ -0.01%  │ github.com/pmezard/go-difflib       │ 17 kB    │ 17 kB    │ -1 B    │
│ -0.00%  │ net                                 │ 1.7 MB   │ 1.7 MB   │ -1 B    │
│ -0.00%  │ crypto                              │ 1.9 MB   │ 1.9 MB   │ -3 B    │
│ -0.00%  │ github.com/quic-go/quic-go          │ 1.3 MB   │ 1.3 MB   │ -5 B    │
│ -0.00%  │ k8s.io/client-go                    │ 14 MB    │ 14 MB    │ -32 B   │
│ -0.00%  │ golang.org/x/net                    │ 902 kB   │ 902 kB   │ -35 B   │
│ -0.02%  │ github.com/gin-gonic/gin            │ 337 kB   │ 337 kB   │ -56 B   │
│ -0.02%  │ go.yaml.in/yaml/v3                  │ 312 kB   │ 312 kB   │ -61 B   │
│ -0.14%  │ gopkg.in/yaml.v3                    │ 306 kB   │ 306 kB   │ -419 B  │
├─────────┼─────────────────────────────────────┼──────────┼──────────┼─────────┤
│ -0.00%  │ .rodata                             │ 2.6 MB   │ 2.6 MB   │ -108 B  │
├─────────┼─────────────────────────────────────┼──────────┼──────────┼─────────┤
│ +0.01%  │ sablier                             │ 62 MB    │ 62 MB    │ +4.1 kB │
│         │ sablier                             │          │          │         │
└─────────┴─────────────────────────────────────┴──────────┴──────────┴─────────┘

@acouvreur acouvreur merged commit 5bb95c9 into main Jul 11, 2026
7 of 8 checks passed
@acouvreur acouvreur deleted the fix/graceful-http-shutdown branch July 11, 2026 06:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant