fix(dev-server): release watchers and reload subscriptions when the bind fails - #3588
Conversation
📝 WalkthroughWalkthrough
ChangesDev server startup cleanup
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant DevServer
participant startAndBind
participant HTTPPort
participant ReloadNotifier
DevServer->>startAndBind: start startup
startAndBind->>HTTPPort: bind port
HTTPPort-->>startAndBind: binding failure
DevServer->>ReloadNotifier: stop subscriptions
DevServer-->>DevServer: rethrow startup error
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 29fd85ee43
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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 `@src/server/dev-server/server.ts`:
- Around line 503-513: Update the cleanup sequence in stop() to clear each
resource handle before invoking its callback, and catch/debug-log failures
independently so one cleanup error does not stop subsequent cleanup. Apply this
to reloadUnsubscribe, invalidateUnsubscribe, releaseExternalBroadcastSource, and
later watcher resources while preserving the original start() bind error
behavior.
In `@tests/integration/server/dev-server-start-failure.test.ts`:
- Around line 81-146: Move the “DevServer start failure” test containing the
`DevServer` bind-failure scenario beside the `DevServer` source module, updating
imports and path-dependent setup as needed. Preserve the real occupied-port bind
failure coverage and all watcher and ReloadNotifier cleanup assertions.
🪄 Autofix
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: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 1d43630a-231d-4fb3-98cb-47e75392d0b4
📒 Files selected for processing (2)
src/server/dev-server/server.tstests/integration/server/dev-server-start-failure.test.ts
Addresses CodeRabbit on #3588: a release callback that throws would exit stop() before the remaining subscriptions and the watcher were released. All three handles are now cleared before any of them is invoked, so a throwing release cannot leave a handle set for a later stop() to invoke a second time — which for the external broadcast source would decrement a process-wide counter twice and suppress HMR for an unrelated dev server. Each release is then run through a helper that debug-logs a failure instead of stranding the steps after it. The three callbacks are all non-throwing closures today (two Set.delete unsubscribes and a counter decrement), so this is defence in depth on a teardown path rather than a fix for a reachable failure.
29fd85e to
189e815
Compare
|
Declining this one, with reasoning on the record. The AGENTS.md rule reads "Keep test files colocated as More importantly, this test cannot be a unit test as On the concern itself — that focused verification of |
|
Applied in 189e815, with one change to the proposed shape. The ordering point is the valuable half, and I took it further than the suggestion: all three handles are now cleared up front, before any of them is invoked. Clearing each handle immediately before its own call still leaves the later handles set if an earlier release throws, so a subsequent Each release then runs through a module-level For the record on reachability: all three callbacks are non-throwing closures today — two are |
Addresses CodeRabbit on #3588: a release callback that throws would exit stop() before the remaining subscriptions and the watcher were released. All three handles are now cleared before any of them is invoked, so a throwing release cannot leave a handle set for a later stop() to invoke a second time — which for the external broadcast source would decrement a process-wide counter twice and suppress HMR for an unrelated dev server. Each release is then run through a helper that debug-logs a failure instead of stranding the steps after it. The three callbacks are all non-throwing closures today (two Set.delete unsubscribes and a counter decrement), so this is defence in depth on a teardown path rather than a fix for a reachable failure.
189e815 to
98b013c
Compare
…ind fails DevServer.start() registers the file watchers, both ReloadNotifier subscriptions and the HMR external broadcast source before it binds the HTTP port. Callers only ever receive the instance after start() resolves — startDevServer() constructs it, awaits start() and returns it — so a bind failure dropped the half-built instance with no handle and left nobody able to call stop(). Every registration then survived for the life of the process. The leaked broadcast source is the worst of the three: it decrements a process-wide counter, and while it is held HMRHandler skips its own broadcast, so a single failed start could silently disable HMR for the dev server that starts next. Reachable from the probe-then-bind race in `veryfront dev`, and from any adapter.serve() failure that is not a port collision (bad bind address, permission denied on a privileged port). start() now routes failures through stop() and rethrows. stop() is already null-safe at every step, so it tears down however far start() got, and it remains the single teardown path — a new registration added to start() cannot drift out of sync with a second cleanup list. Fixing it here rather than in startDevServer() also covers callers that construct DevServer directly. This matches the release-before-rethrow the codebase already uses in bootstrap.ts (orchestrateOrDisposeFS) and in the Deno http-server adapter. stop() now clears the release handles after calling them, so the double-stop this introduces cannot decrement the broadcast counter twice. Surfaced by CodeRabbit on #3562 and declined there as out of scope.
Addresses CodeRabbit on #3588: a release callback that throws would exit stop() before the remaining subscriptions and the watcher were released. All three handles are now cleared before any of them is invoked, so a throwing release cannot leave a handle set for a later stop() to invoke a second time — which for the external broadcast source would decrement a process-wide counter twice and suppress HMR for an unrelated dev server. Each release is then run through a helper that debug-logs a failure instead of stranding the steps after it. The three callbacks are all non-throwing closures today (two Set.delete unsubscribes and a counter decrement), so this is defence in depth on a teardown path rather than a fix for a reachable failure.
The teardown helper added above the class shifted the "export class DevServer" declaration from L57 to L70, so the generated source link went stale. Regenerated with "deno task docs" under the Deno version CI pins (2.7.7 per .github/actions/setup-deno/action.yml) rather than hand-edited; the local default 2.7.12 reflows every table and produces a 42-file diff.
Both reviewers on #3588 flagged that AGENTS.md requires test files to sit beside the source they cover, and they were right — the file is now at src/server/dev-server/server-start-failure.integration.test.ts, so a focused run of src/server/dev-server/ exercises the bind-failure cleanup. Named *.integration.test.ts rather than *.test.ts because it needs a real project directory, a full bootstrapDev(), a real OS file watcher and a real TCP bind. That suffix is excluded from the unit shard by deno.json and follows src/proxy/routing-invalidation.integration.test.ts, which is the existing precedent for a colocated integration-weight test importing tests/_helpers. It still runs in the coverage shards and the pre-push suite, so the regression stays gated.
98b013c to
79e50d6
Compare
|
Correcting my earlier reply on test placement — I declined this too quickly, and both reviewers were right. The test is now colocated at What changed my mind was checking the two things I had assumed were blockers, and finding neither holds:
So the suffix resolves my only real objection while satisfying the AGENTS.md rule, and it directly addresses the concern raised — a focused Re-verified after the move: it still fails without the fix for the right reason ( |
The defect
DevServer.start()registers four things before it binds the HTTP port:server.ts(pre-fix)setupFileWatchers()ReloadNotifier.subscribeInvalidateHMRHandler.registerExternalBroadcastSource()ReloadNotifier.subscribeadapter.serve()— the bindAll four are released only by
stop(). ButstartDevServer()(index.ts:15-20) constructs the instance, awaitsstart(), and then returns it — so whenstart()rejects, the half-built instance is dropped with no handle and no caller can ever callstop(). All four registrations survive for the life of the process.The leaked broadcast source is the worst of the three kinds: it decrements a process-wide counter, and while it is held
HMRHandlerskips its own broadcast (hmr.handler.ts:72-78). So one failed start can silently disable HMR for the dev server that starts next.Reachable from the probe-then-bind race in
veryfront dev, and from anyadapter.serve()failure that is not a port collision (bad bind address, permission denied on a privileged port).The fix
start()routes failures throughstop()and rethrows.Chosen over catching in
startDevServer()because:stop()is already the single teardown path, and already null-safe at every step (?.(),if (this.fileWatchSetup),if (this.server)) — it was written to tolerate a partially-built instance. Reusing it means there is no second cleanup list that can drift out of sync when someone adds a new registration tostart().DevServeris exported directly and constructed outsidestartDevServer()(e.g.tests/integration/server/dev-server-handlers.test.ts:28); a fix instartDevServer()would leave those leaking.bootstrap.ts(orchestrateOrDisposeFS, and the comment at :482-484) and the Denohttp-server.tsadapter both release-then-rethrow at the registration site.Cleanup errors are swallowed to a debug log so they can never mask the real reason
start()failed — "port already in use" is what the developer needs to see.stop()now clears all three release handles up front, before any is invoked, and runs each through a smallrelease()helper. This is required, not cosmetic: the reload/invalidate unsubscribes are idempotent (Set.delete), butreleaseExternalBroadcastSourceis not — it decrements a global counter, so the double-stop()this change introduces (failedstart()cleans up, caller still holds the instance and callsstop()) would otherwise corrupt an unrelated dev server's count. Clearing up front rather than per-call means a throwing release cannot leave a later handle set for a secondstop()to re-invoke.Regression test
src/server/dev-server/server-start-failure.integration.test.tsforces a real bind failure — another listener holds the port — after the subscriptions are registered, and asserts the registrations are released. It does not settle for asserting thatstart()rejects; the rejection already happened before this change.Watchers are counted by wrapping the runtime registry adapter's
fs.watch, which is the only seam that observes the watcherDevServerreally opens (it builds its own adapter internally). The test assertswatchers.opened > 0first, so it cannot pass vacuously if that seam ever stops being the one used.Failing before the fix — all three leaks in one diff:
Passing after, with no sanitizer opt-outs — Deno's own op/resource sanitizers confirm the teardown is complete.
It is colocated with the module it covers per AGENTS.md, and named
*.integration.test.tsbecause it needs a real project directory, a fullbootstrapDev(), a real OS file watcher and a real TCP bind. That suffix is excluded from the unit shard bydeno.jsonand followssrc/proxy/routing-invalidation.integration.test.ts; it still runs in the coverage shards and the pre-push suite.Verification
src/server/dev-server/+tests/integration/server/: 52 passed, 0 faileddeno fmt --check,deno lint,deno checkcleanNotes
docs/api-reference/veryfront/server.mdcarries a one-line source-link update: the teardown helper shiftedexport class DevServerfrom L57 to L70. Regenerated withdeno task docsunder the Deno version CI pins (2.7.7), not hand-edited.src/transforms/esm/http-cache.test.ts("returns a signal-less cache follower after its bounded wait", from fix: allow cold remote modules to finish fetching #3553) fired once on an earlier push and presented as three red checks (its coverage shard plus the dependenttests (unit)andcoverage gate). Re-run without changes and it went green; it is untouched by this PR.start()fails,this.readynever settles, so anything awaiting it hangs. Pre-existing and independent of this leak — left alone deliberately.Surfaced by CodeRabbit on #3562 and declined there as out of scope, since that PR touched only
cli/.🤖 Generated with Claude Code