Skip to content

fix(lifecycle): finish shutdown before the process exits - #596

Merged
SantiagoDePolonia merged 2 commits into
mainfrom
fix/shutdown-truncated-on-interrupt
Jul 26, 2026
Merged

fix(lifecycle): finish shutdown before the process exits#596
SantiagoDePolonia merged 2 commits into
mainfrom
fix/shutdown-truncated-on-interrupt

Conversation

@SantiagoDePolonia

@SantiagoDePolonia SantiagoDePolonia commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Ctrl+C on make run ended in an unexplained failed to shut down server within given timeout followed by make: *** [run] Error 1. Three independent causes, and the one that mattered most was not the one in the error message.

1. The teardown never finished

Run returned as soon as Start returned, while the goroutine doing the teardown was still working. The process exits the moment Run returns, so every closer the teardown had not reached yet — buffered usage records, buffered audit records, the database handle — was dropped. application shutdown complete was never reached on any interrupt, and this happened on every Ctrl+C, not only the ones that logged an error.

Run now waits for that goroutine. The teardown also runs when the server stops without a signal, so a server that dies on its own still releases its resources; App.Shutdown is idempotent, which keeps that harmless on the failed-start path.

2. The scary error was Echo's, not ours

Nothing configured GracefulTimeout, so Echo used its implicit 10s default and reported the cutoff through its own logger as a bare context deadline exceeded. It fires whenever any request is in flight — one open stream is enough.

The window is now set explicitly and documented against the shutdown budget that has to contain it, and the cutoff is reported as the routine event it is. The value stays at 10s deliberately: no drain window covers a streamed model response, so lengthening it would only make Ctrl+C slower without changing the outcome.

3. make: Error 1 is go run

go run exits 1 when interrupted no matter how cleanly the child exits. Verified against a twelve-line program that catches SIGINT and returns 0:

[direct binary] parent_exit=0 output=ready|clean exit 0|
[go run]        parent_exit=1 output=ready|clean exit 0|

make run now builds and execs, which also puts the gateway directly under make's signal handling instead of behind a supervisor.

Verification

Reproduced with a request deliberately held in flight, before and after:

before:  shutting down application... → ERROR failed to shut down server within given timeout → exit (no "complete")
after:   shutting down application... → WARN closing requests still in flight at the shutdown deadline → application shutdown complete

make run + SIGINT now exits 0. Both new lifecycle tests fail against the old code (serveUntilShutdown returned mid-teardown, shutdownCalls = 0, want 1) and pass with the fix.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved graceful shutdown to allow in-flight requests to finish before cleanup proceeds.
    • Ensured shutdown and data flushing complete before the application exits.
    • Added warnings when requests remain active beyond the shutdown deadline.
    • Improved handling when the server stops unexpectedly or encounters a startup failure.
  • Chores

    • Updated the local run workflow to build and execute the application consistently, with improved signal handling for Ctrl+C.

Ctrl+C left the gateway with an unexplained "failed to shut down server
within given timeout" and a bogus "make: *** [run] Error 1". Three
separate causes:

Run returned as soon as Start returned, while the goroutine doing the
teardown was still working. The process exits the moment Run returns, so
every closer the teardown had not reached yet — buffered usage and audit
records, the database handle — was dropped. "application shutdown
complete" was never reached on any interrupt. Run now waits for that
goroutine, and the teardown also runs when the server stops without a
signal.

The graceful drain window was Echo's implicit 10s default, and the
cutoff surfaced through Echo's own logger as a bare ERROR with no
context. The gateway now sets the window itself, documented against the
shutdown budget that has to contain it, and reports the cutoff as the
routine event it is: Ctrl+C during a streamed response cuts the stream,
because no drain window covers model traffic.

`make run` used `go run`, which exits 1 when interrupted even after the
program it supervises exits cleanly — verified against a twelve-line
program that handles SIGINT and returns 0. Building and exec'ing puts
the gateway directly under make's signal handling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 26, 2026 20:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@SantiagoDePolonia, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 41 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3e5b88e2-0869-43b3-8216-76d911a1c425

📥 Commits

Reviewing files that changed from the base of the PR and between 0ac7963 and 864c585.

📒 Files selected for processing (5)
  • Makefile
  • internal/server/http.go
  • internal/server/http_start_test.go
  • run/lifecycle_test.go
  • run/run.go
📝 Walkthrough

Walkthrough

The application now coordinates startup and graceful shutdown through serveUntilShutdown, waits for teardown completion, bounds HTTP request draining, logs shutdown deadlines, and updates the Makefile to build and exec the Swagger-enabled binary.

Changes

Shutdown lifecycle

Layer / File(s) Summary
Lifecycle shutdown coordination
run/run.go, run/lifecycle_test.go
Run uses serveUntilShutdown to handle cancellation, server exits, startup failures, and completed teardown.
HTTP graceful drain configuration
internal/server/http.go, internal/server/http_start_test.go
Echo uses a bounded graceful timeout and logs when requests remain in flight at the deadline; tests verify the configuration.
Local binary execution
Makefile
The run target builds the Swagger-enabled binary with version metadata and execs it with runtime environment variables.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Signal
  participant Run
  participant Application
  participant Shutdown
  Signal->>Run: cancellation
  Run->>Application: start and monitor
  Run->>Shutdown: invoke shutdown with timeout
  Shutdown->>Application: drain and teardown
  Application-->>Shutdown: teardown complete
  Shutdown-->>Run: return
Loading

Possibly related PRs

Suggested reviewers: copilot

Poem

I’m a rabbit guarding the shutdown gate,
Draining each request before it’s late.
The binary hops, then signals fly,
Teardown waits—no tasks left dry.
Version flags sparkle in the night.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: completing shutdown before the process exits.
Description check ✅ Passed The description explains the change, motivation, and verification, though it doesn't follow the template headings exactly.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/shutdown-truncated-on-interrupt

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov-commenter

codecov-commenter commented Jul 26, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 95.45455% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
run/run.go 93.75% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
run/run.go (1)

1-1: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Double shutdown invocation on the failed-Start path is unfixed and untested. When application.Start fails, startApplication shuts down the application itself; once it returns, serveUntilShutdown's goroutine shuts it down again after serverReturned closes — relying entirely on an unverified App.Shutdown idempotency contract, and doubling the worst-case shutdown latency budget.

  • run/run.go#L211-244: remove the inner shutdownApplication call from startApplication's error branch (Line 263-272) and let this goroutine be the sole, unconditional caller of shutdown.
  • run/lifecycle_test.go#L230-237: once fixed, assert app.shutdownCallCount() == 1 in TestServeUntilShutdown_ReturnsStartFailure to lock in single-invocation behavior.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@run/run.go` at line 1, Remove the shutdownApplication call from
startApplication’s application.Start error branch, allowing the
serveUntilShutdown goroutine to remain the sole unconditional shutdown caller
after serverReturned closes. Update TestServeUntilShutdown_ReturnsStartFailure
to assert app.shutdownCallCount() equals 1, covering the failed-start path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/server/http.go`:
- Around line 48-59: Add an automated assertion covering the relationship
between internal/server/http.go's gracefulDrainTimeout and the run package's
shutdownTimeout, ensuring gracefulDrainTimeout remains strictly shorter. Keep
the existing timeout values and shutdown behavior unchanged, and place the check
where both symbols can be referenced safely.

In `@Makefile`:
- Line 38: Update the gomodel execution command to quote the Make-expanded
LOG_LEVEL and SWAGGER_ENABLED environment assignments, ensuring whitespace and
shell metacharacters remain data while preserving the existing exec behavior.

In `@run/lifecycle_test.go`:
- Around line 230-237: Extend TestServeUntilShutdown_ReturnsStartFailure to
assert the stubLifecycleApp shutdown call count after the start error is
returned, matching the teardown assertion used by
TestServeUntilShutdown_TearsDownWhenServerStopsOnItsOwn and verifying the
expected count for this path.

In `@run/run.go`:
- Around line 211-244: Update serveUntilShutdown and the startup/shutdown
coordination so a failed start does not trigger shutdownApplication twice: track
whether startApplication already performed teardown, and have the shutdown
goroutine skip its call when that path occurred. Preserve shutdown for signal
cancellation and servers that stop normally, and continue waiting for the
selected shutdown to complete before returning.

---

Outside diff comments:
In `@run/run.go`:
- Line 1: Remove the shutdownApplication call from startApplication’s
application.Start error branch, allowing the serveUntilShutdown goroutine to
remain the sole unconditional shutdown caller after serverReturned closes.
Update TestServeUntilShutdown_ReturnsStartFailure to assert
app.shutdownCallCount() equals 1, covering the failed-start path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: cb58d8e1-011e-408f-9dd9-8465e91059e5

📥 Commits

Reviewing files that changed from the base of the PR and between f46b955 and 0ac7963.

📒 Files selected for processing (5)
  • Makefile
  • internal/server/http.go
  • internal/server/http_start_test.go
  • run/lifecycle_test.go
  • run/run.go

Comment thread internal/server/http.go Outdated
Comment on lines +48 to +59

// gracefulDrainTimeout bounds how long the HTTP server waits for in-flight
// requests to finish once shutdown begins. Streamed model responses run far
// longer than any drain window worth waiting for, so this is deliberately a
// cutoff rather than a promise: past it the remaining connections are cut
// and shutdown moves on to flushing usage and audit records. It is sized
// for the short requests that can actually finish — a dashboard fetch, a
// health check — not for model traffic.
//
// It must stay below run.shutdownTimeout, which covers this drain plus
// those flushes and the database close that follow it.
gracefulDrainTimeout = 10 * time.Second

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cross-package timeout invariant isn't enforced anywhere.

The comment states gracefulDrainTimeout "must stay below run.shutdownTimeout," but nothing in the code enforces this — both constants are unexported and defined in separate packages, so a future edit to either one could silently violate the invariant with no compiler or test failure. Consider adding a small test (in either package, or a shared lifecycle-config test) that asserts this relationship, or centralizing both timeouts in one place that both packages reference.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/server/http.go` around lines 48 - 59, Add an automated assertion
covering the relationship between internal/server/http.go's gracefulDrainTimeout
and the run package's shutdownTimeout, ensuring gracefulDrainTimeout remains
strictly shorter. Keep the existing timeout values and shutdown behavior
unchanged, and place the check where both symbols can be referenced safely.

Comment thread Makefile Outdated
Comment thread run/lifecycle_test.go
Comment thread run/run.go
@greptile-apps

greptile-apps Bot commented Jul 26, 2026

Copy link
Copy Markdown

Confidence Score: 2/5

This PR should not merge until shutdown-before-start ordering, missing run-output directory creation, and teardown-error propagation are fixed.

An early cancellation can permanently miss server shutdown and hang the process, fresh-checkout make run fails before startup, and completed teardown failures still produce a successful process exit.

Files Needing Attention: run/run.go and Makefile

T-Rex T-Rex Logs

What T-Rex did

  • Verified that Shutdown can precede server registration by running the focused Go lifecycle test with an already-canceled context and observing Shutdown running while registration was false.
  • Attempted the run target from a clean state with bin absent using a bounded missing-bin harness; the run timed out during compilation, not reproducing the missing-directory error.
  • Launched a focused test for Shutdown failures with an injected non-nil Shutdown error, but the concurrent cold compilation did not finish within the execution budget, so no return value could be captured.
  • Validated the end-to-end shutdown flow by inspecting the gomodel process, confirming the health endpoint reported ok, and that SIGINT delivered a graceful shutdown with exit status 0.

View all artifacts

T-Rex Ran code and verified through T-Rex

Sequence Diagram

sequenceDiagram
  participant C as Run context/signal
  participant R as serveUntilShutdown
  participant A as App.Start
  participant S as App.Shutdown
  R->>A: Start server
  par Cancellation
    C-->>R: context canceled
    R->>S: begin bounded shutdown
    S-->>A: cancel server context
  and Server execution
    A-->>R: server returns
  end
  S-->>R: teardown completes
  R-->>R: return lifecycle result
Loading

Reviews (1): Last reviewed commit: "fix(lifecycle): finish shutdown before t..." | Re-trigger Greptile

Comment thread run/run.go
Comment on lines +227 to +234
go func() {
select {
case <-ctx.Done():
case <-serverReturned:
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
shutdownDone <- shutdownApplication(application, shutdownCtx)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Shutdown can precede server registration

When the parent context is already canceled or a signal arrives before App.Start registers its cancellation function, this goroutine completes the idempotent shutdown against nil server state. App.Start then starts the server with no remaining shutdown call to cancel it, causing serveUntilShutdown to block indefinitely.

Artifacts

Repro: focused Go lifecycle test covering already-canceled and coordinated early-cancellation scenarios

  • Evidence file captured while the check ran.

Repro: verbose test output with timestamps showing early Shutdown and bounded hangs

  • The full command output behind this check.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment thread Makefile
# handling instead of behind a supervisor.
run:
LOG_LEVEL=$(LOG_LEVEL) SWAGGER_ENABLED=$(SWAGGER_ENABLED) go run -tags=swagger ./cmd/gomodel
go build -tags=swagger -ldflags '$(LDFLAGS)' -o bin/gomodel ./cmd/gomodel

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Run target requires missing directory

When make run is invoked from a fresh checkout or after make clean, this build writes to bin/gomodel without creating the ignored bin directory, causing the command to fail before the gateway starts.

Comment thread run/run.go
Comment on lines +240 to +243
if err := <-shutdownDone; err != nil {
slog.Error("application shutdown error", "error", err)
}
return startErr

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Shutdown failures return success

When draining, flushing buffered usage or audit records, closing storage, or another teardown step fails, this code logs the shutdown error but returns the successful start result. The process therefore exits zero despite incomplete teardown, preventing supervisors and scripts from detecting the failure.

Review follow-ups.

A failed Start used to shut down twice: startApplication tore the
application down itself, then serveUntilShutdown's goroutine did it again
once the server returned. That leaned on an App.Shutdown idempotency
contract nothing in this package verified, and gave the failed-start path
two shutdownTimeout budgets to burn through. serveUntilShutdown is now
the only caller, so every exit — signal, server stopping on its own, or a
Start that never got off the ground — runs teardown exactly once on one
budget, and startApplication is gone.

A teardown that also fails is now logged rather than wrapped around the
start error, since the start error is what the operator needs and what
sets the exit code.

The comment claiming the drain window must stay below the shutdown budget
was load-bearing but unchecked, and the two constants live in different
packages. GracefulDrainTimeout is exported so a test can assert both the
ordering and that enough of the budget is left for the flushes that
follow the drain; verified it fails at 30s and at 26s.

Quotes the Make-expanded values in the run recipe so whitespace or shell
metacharacters in LOG_LEVEL or SWAGGER_ENABLED stay data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 26, 2026 20:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

All four addressed.

Double shutdown on the failed-Start path — agreed, and taken further than "track whether teardown already ran": startApplication is deleted and serveUntilShutdown is now the sole caller of shutdownApplication. Every exit converges on one teardown with one shutdownTimeout budget, which drops the dependency on App.Shutdown idempotency instead of relying on it. TestServeUntilShutdown_TearsDownOnceAfterAFailedStart asserts shutdownCalls == 1.

One behavior change worth calling out: a teardown that also fails is now logged rather than joined onto the returned error. The start error is what the operator needs to see and what sets the exit code, and the shutdown error still reaches the log.

Cross-package invariant — fair; the comment was load-bearing and unchecked. GracefulDrainTimeout is exported and TestGracefulDrainFitsInsideTheShutdownBudget asserts both that it is shorter than shutdownTimeout and that enough budget remains for the flushes that follow the drain. Confirmed it fails in both directions:

30s: GracefulDrainTimeout = 30s must be shorter than shutdownTimeout = 30s
26s: only 4s left for flushing after the drain
10s: ok

Makefile quoting — applied (and to the pro repo's equivalent recipe).

Shutdown-count assertion on the start-failure test — folded into the first item.

Re-verified end to end after the refactor, with a request deliberately held in flight:

shutting down application...
WARN closing requests still in flight at the shutdown deadline graceful_timeout=10s
application shutdown complete
exit 0

@SantiagoDePolonia
SantiagoDePolonia merged commit f2c2964 into main Jul 26, 2026
18 checks passed
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.

3 participants