fix(dev): hoist socket-error backstop to module top-level so lifecycle events can't remove it#916
Conversation
…trace Two changes intended to pin down why the process-level handler from PR #913 still doesn't catch the ECONNRESET trace some users report: 1. Move installation from inside configureServer to module top-level, guarded by Symbol.for to prevent double-install. Earlier versions tied teardown to httpServer 'close', which Vite emits on dep re-optimization, full reloads, and other lifecycle events — leaving a window where the listener is absent when a stale stream errors. Module-level install + Symbol guard removes that window entirely. 2. Add an opt-in console.warn marker behind VINEXT_DEBUG_SOCKET_ERRORS=1 that fires whenever the listener absorbs a peer-disconnect error. The visible `node:events:487 throw er; // Unhandled 'error' event` output otherwise leaves it ambiguous whether the listener never ran (install / lifecycle issue) or ran but was somehow bypassed (different async surface / dispatcher). The marker disambiguates. No behavior change for users not setting the env flag beyond the hoisting. Filter codes and re-throw shape are unchanged from #913. Refs #905. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
commit: |
|
|
/bigbonk review |
There was a problem hiding this comment.
Reviewing the move to a top-level install. The core insight is sound: httpServer.once('close', ...) does fire on dep re-optimization and other lifecycle events, leaving windows where the listener is absent. The repro trace in the description (absorbed uncaughtException ECONNRESET printed where the crash used to be) is good evidence the new placement actually solves the residual #905 case. A few things worth flagging before merge.
1. The "dev-only" framing in the JSDoc is incomplete
The comment says:
Dev-only by virtue of being unused by the production prod-server entry, which runs as a Cloudflare Worker where socket lifecycle is owned by the runtime.
That's accurate for the Workers runtime, but packages/vinext/src/index.ts is also loaded in two other Node contexts that aren't the dev server:
vinext build/vp pack— the plugin module is evaluated for every build run, including CI builds that prerender or fetch external resources. A realECONNRESETfrom a pre-render fetch would now be silently swallowed instead of failing the build.vp testworkers — multiple test files import../packages/vinext/src/index.jsdirectly (tests/shims.test.ts:6,tests/tsconfig-paths-vite8.test.ts:6,tests/tsconfig-path-alias-build.test.ts:7). Every Vitest worker that imports those tests installs the process-level handlers.
This is probably fine in practice — peer-disconnect codes are rare in build/test contexts — but the JSDoc claim that the listener is "dev-only" is doing more work than it should. Worth either narrowing the install to command === 'serve' (gated inside config() or configResolved() so we still beat configureServer lifecycle), or updating the JSDoc to acknowledge the install runs whenever the plugin module is loaded and explain why that's still acceptable.
2. Removing the middleware-mode skip is a behavior change
The previous code had:
// Skipped in middleware mode (httpServer is null): the embedding host
// owns process-level handlers, and we have no reliable teardown hook
// to remove ours, so installation would leak.
if (server.httpServer) { ... }The new top-level install drops that guard. Embedders running vinext in middleware mode (Express/Connect/etc. integrations) now get vinext's process-level uncaughtException and unhandledRejection listeners installed unconditionally, with no opt-out. The original comment correctly identified this as a footgun ("the embedding host owns process-level handlers"). Either restore the gate (check process.env or a vinext option) or, at minimum, drop a note in the JSDoc that middleware-mode hosts now share these listeners.
3. Listener-ordering side effect on non-peer-disconnect errors
Synchronously throwing from an uncaughtException listener aborts iteration of the remaining listeners — the comment from the deleted block called this out for the loop case, but the ordering implication is new with this PR. Quick sanity check:
process.on('uncaughtException', (e) => { console.log('FIRST'); }); // earlier loader
process.on('uncaughtException', (e) => { if (e.code === 'X') return; throw e; }); // vinext
process.on('uncaughtException', (e) => { console.log('LAST'); }); // later loader
setImmediate(() => { const e = new Error('boom'); e.code = 'OTHER'; throw e; });
// Output: FIRST (then crash, "LAST" never runs)By hoisting to module top level, vinext's listener is now registered before most user/tooling listeners (test runner debug hooks, crash reporters, Sentry's onUncaughtException, etc.). For peer-disconnect codes the early-return is fine. For non-peer-disconnect errors the sync re-throw still crashes the process with the correct stack — Node's default handler runs because no listener fully handled it — but later-registered listeners that wanted to observe the crash (telemetry, structured logging) never see it.
That's a real regression vs. the prior configureServer install, where vinext's listener was registered after Vite/user setup and was at the end of the queue. Probably acceptable for the dev server (where users mostly want the crash to surface and don't have telemetry attached), but combined with #1 this also affects build runs and test workers where third-party observability is more common. Worth at least documenting; ideally the listener uses process.nextTick(() => { throw err; }) and accepts the re-entry consideration with a guard, or schedules via queueMicrotask. The deleted comment block specifically rejected nextTick because it would re-enter the same listener — but a one-shot guard (e.g., if (rethrowing) process.exit(1)) would let earlier listeners surface the error first while still preserving crash semantics.
4. Symbol.for guard caveat
Symbol.for("vinext.devSocketErrorBackstop") shares state across module realms, so if two different vinext versions are loaded in the same process the first to evaluate wins and the second's filtering rules silently don't apply. Not a bug, just worth a one-liner in the JSDoc — "first-loaded version wins" — so future debugging doesn't get confused if peer disconnect handling changes between versions.
Smaller things
- Line 530 logs "dev socket-error backstop installed" only when
debugis set, but the listener is installed unconditionally. That's intended, but the log line itself is a minor leak of internal terminology to user stderr — fine since it's gated behind the env var. - The
(err as Error & { code?: string }).codecast is repeated three times.isPeerDisconnectalready extractscode; consider returning the code fromisPeerDisconnect(or inlining a tinygetCodehelper) so debug logs reuse it without recasting. unhandledRejectioncallback signature: Node passes(reason, promise). Thereason: unknowntyping is correct, but for completeness the signature could include the promise — not actionable, just noting.
Bottom line
The mechanism is correct and the diagnostic trace is a nice addition. My main asks before merge:
- Either gate the install on
command === 'serve'(preferred) or update the JSDoc to be honest that it runs in build/test contexts too and explain why that's OK. - Decide explicitly whether middleware-mode embedders should keep getting process-level listeners installed, and document the answer.
- Briefly acknowledge the listener-ordering side effect in the comment, since it's newly relevant once we run before everyone else.
|
Posted the review on PR #916. Summary of the main points:
Plus a few smaller code-quality notes (repeated casts, signature pedantry). |
Three issues from PR #916 review: 1. Module-top-level install was wrong — it ran during 'vinext build', 'vp pack', and Vitest worker imports too, so the JSDoc 'dev-only' claim was inaccurate and a real ECONNRESET from a pre-render fetch in CI would have been silently swallowed. Move the install back into configureServer (which only fires in dev) but **without** binding teardown to httpServer 'close' — that was the original lifecycle bug PR #913 had. Symbol.for guard makes re-invocation a no-op so the listener still survives server restarts within a session. 2. Restore the middleware-mode skip dropped in the previous commit. Embedders running vinext in middleware mode (Express/Connect) keep ownership of their own process-level handlers. Gated on server.httpServer, matching the prior PR #913 behavior. 3. Document the listener-ordering implication of installing during configureServer (registers late in the queue, so Sentry / structured logging / test-runner hooks still observe non-peer-disconnect errors before vinext's sync re-throw aborts iteration), and the Symbol.for first-loaded-wins caveat for the multi-version case. Refactor: collapse the duplicated `(err as Error & { code }).code` casts into a single `peerDisconnectCode` helper that returns the matched code or undefined, so the debug log path reuses it without recasting. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks — all four addressed in 455a61b. 1 + 2 (dev-only framing & middleware-mode skip): Moved the install back into 3 (listener ordering): Documented in the JSDoc — by going back to 4 (Symbol.for caveat): Added the "first-loaded-wins" line to the JSDoc. Smaller items:
|
…ver too late)
The previous attempt to address bonk's review by moving install back
into configureServer was wrong — it didn't restore working behavior:
$ VINEXT_DEBUG_SOCKET_ERRORS=1 vp dlx vinext@455a61b dev
...
GET /stage/oliver 200 in 1.1s
node:events:487
throw er; // Unhandled 'error' event
Error: read ECONNRESET
...
No `[vinext] dev socket-error backstop installed` startup line. The
function never ran. Cause: in vite-plus's plugin lifecycle,
`server.httpServer` is null at the moment configureServer fires, so
the `if (server.httpServer)` middleware-mode guard skipped install.
Requests still get served (httpServer is created later) but the
listener was never attached.
Hoist back to module load. To address bonk's "loaded in build/test
contexts too" concern, gate install on:
- `process.env.VITEST === "true"` → Vitest worker imports
- `process.argv` contains "build" → `vinext build` / `vp build`
`process.argv` is read because the CLI entry imports index.ts before
it has a chance to set an env var, and `configResolved` runs too late
for module-load gating.
Updated JSDoc to be honest about the install context, the listener-
ordering implication, the middleware-mode embedder situation, and the
Symbol.for first-loaded-wins caveat.
Refs #905.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
You were right — that broke it. Pushed ffc5684 re-hoisting back to module load. Root cause of the configureServer regression: in vite-plus's plugin lifecycle, Bonk's concerns re-addressed without going back into the broken hook:
Worth re-running once pkg.pr.new rebuilds: The startup |
Replace the negative VITEST/build skip list with a single positive check: install only when argv[2] === "dev". Matches `vinext dev`, `vp dev`, `vite dev`, and any CLI following the "<bin> dev" convention. Everything else — build, Vitest workers, library embedders with a custom runner — skips install, so genuine peer-disconnect errors surface normally in those contexts. Cleaner default: no listener unless we're confident this is a dev server invocation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace argv-sniffing with the canonical signal: Vite's `config()`
hook receives `{ command: "serve" | "build" }` directly. Install
the backstop only when `command === "serve"`. Works identically for
`vinext dev`, `vp dev`, `vite dev`, and library embedders that call
`createServer` themselves — anywhere Vite considers itself a dev
server.
`config()` runs before `configureServer` (so before httpServer
matters) and before the dep-optimization or full-reload events that
broke the earlier `httpServer.close`-tied teardown. Symbol.for guard
keeps the install idempotent across server restarts within a session.
Removes:
- Module-load-time install + argv[2] === "dev" check
- VITEST / build env-var negative skip list
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`command === "serve"` covers both `vite dev` and `vite preview` (the post-build static server). Preview doesn't stream RSC and doesn't need this guard, so narrow to `command === "serve" && !env.isPreview`. `vinext start` runs prod-server.ts directly without Vite, so it was already correctly excluded — `config()` never fires there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Next.js installs uncaughtException + unhandledRejection handlers in
their router-server unconditionally — no dev/prod gate. Both the
Node.js dev server and the Node.js prod server share the same
process-level error guards. See vercel/next.js
packages/next/src/server/lib/router-server.ts:809-810.
vinext was only guarding the dev path. `vinext start` runs
prod-server.ts directly as a Node HTTP server (used for self-hosted
deploys; Cloudflare Workers prod doesn't load this module — the
runtime owns socket lifecycle there) and had the same theoretical
exposure to peer-disconnect crashes through the pipeline() / fetch()
paths it streams responses through.
Refactor:
- Extract the install function from index.ts into a new
src/server/socket-error-backstop.ts (drop the "Dev" prefix —
no longer dev-only).
- Keep the call from the vinext:config plugin's config() hook,
gated on command === "serve" && !isPreview (covers vinext dev /
vp dev / vite dev / library embedders).
- Add a parallel call at the top of startProdServer() in
prod-server.ts (covers vinext start).
vinext is more conservative than Next.js's log-only handler — we
filter strictly on peer-disconnect codes and sync re-throw the rest,
so genuine bugs still surface. The parity is in *where* we install,
not what we swallow.
Vitest workers and `vinext build` never reach either entry point, so
peer-disconnect errors in those contexts surface normally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Pushed 501dd95 — extends coverage to What Next.js does: router-server.ts:809-810 installs What vinext now does to match:
Refactor:
Where vinext intentionally diverges from Next.js: their Comments + docstring at the call sites and in the new module updated to match. |
The Vite config() hook gate (`command === "serve" && !isPreview`) proved unreliable in vite-plus's lifecycle — the hook didn't fire (or didn't pass command correctly), and install was silently skipped. Field reproducer: published 501dd95 had the call site in the bundle at the right spot, but `VINEXT_DEBUG_SOCKET_ERRORS=1` produced no startup marker, immediate ECONNRESET crash on first request. Move the install back to module load — invoked unconditionally as a side effect when socket-error-backstop.ts is imported. This was the last shape verified to work via the diagnostic flag. Drop the import-and-call pattern in index.ts in favor of a side-effect import (`import "./server/socket-error-backstop.js"`), which is enough to trigger the auto-install. The import order keeps the install ahead of every other vinext server code path. Skip in Vitest workers via `process.env.VITEST === "true"` so genuine peer-disconnect errors during test runs surface normally. Build runs are unaffected (short-lived, no peer-disconnect-prone streams) — matches Next.js's pattern of installing in any HTTP-serving entry without further gating. prod-server.ts still calls installSocketErrorBackstop() explicitly for `vinext start`. Idempotent via Symbol.for guard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Remove the side-effect import + auto-install indirection. Just call the function explicitly at module top-level in index.ts, where it's obvious. Same observable behavior, clearer code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
/bigbonk review |
|
Reviewing the latest iteration after 1. Build-time prerender now silently swallows real ECONNRESET errorsThis is the headline concern. The JSDoc explicitly justifies the lack of a build-time gate by saying:
That claim is incorrect. Concrete failure mode: an upstream API the user prerenders against drops a TCP connection mid-response → ECONNRESET → backstop absorbs it → the affected This is the same concern the previous review's #1 raised about
The first option is preferable — a flaky upstream taking down the build is the correct behavior, not something to swallow. 2. Stale comment in
|
|
Posted the review on PR #916. Summary of the main points:
Bottom line: only #1 blocks merge; everything else is nice-to-have. |
Bonk's PR #916 review caught a real corruption bug: the build path calls startProdServer() during prerender (build/run-prerender.ts:181, build/prerender.ts:398, :720), so the install fires twice during a build — once at index.ts module load, once inside startProdServer. User fetch() calls inside prerendered pages can hit ECONNRESET from flaky upstream APIs. With the backstop installed, those errors are silently absorbed instead of crashing the build, producing corrupt prerendered HTML/RSC output. Add NODE_ENV gating inside installSocketErrorBackstop: - Skip if NODE_ENV === "production" → covers vinext build + prerender - Skip if NODE_ENV === "test" → covers test runners that follow convention - Skip if VITEST === "true" → kept for Vitest specifically Vite sets NODE_ENV=production for the build command before plugins load, so the gate fires correctly. Trade-off: vinext start with NODE_ENV=production set in shell will also skip install — losing strict Next.js parity for that path. Acceptable: prod-server's pipeline() callbacks already handle the streaming case, and the real-world bug reports are all dev-server. Also fix the now-stale comment at prod-server.ts:817 that still referenced the reverted "Vite plugin config() hook" install path, and document the listener-ordering trade-off more honestly in the JSDoc — `index.ts` imports synchronously at the top of every user's vite.config.ts, registering vinext's listener earlier than user / tooling crash reporters. Sync re-throw on non-peer-disconnect errors still surfaces the crash but later-registered observers don't see the event; users who need crash-reporter visibility for those errors must register their handler before importing vinext. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the install-time NODE_ENV gate (which broke vinext start parity by skipping installation entirely when NODE_ENV=production) with a fire-time VINEXT_PRERENDER check inside the listener. The listener is always installed, but during prerender it re-throws all errors unconditionally — acting as if no listener were present — so user fetch() ECONNRESETs during prerender crash the build with the original stack instead of being silently absorbed into corrupt prerendered output. The fire-time check (vs. install-time) is necessary because index.ts loads at Vite plugin import — well before prerender begins — and the Symbol.for guard then makes any later install call a no-op. A static install-time gate can't catch the prerender phase that follows. Set VINEXT_PRERENDER=1 in run-prerender.ts at the top of runPrerender() so the flag covers the entire prerender orchestration including startProdServer setup. prerender.ts already sets the same var around its actual render passes; this widens the scope. Restores Next.js-parity install for vinext start (NODE_ENV gate is gone). Test-runner skip stays install-time on VITEST / NODE_ENV=test since those contexts genuinely shouldn't have the listener. Refs PR #916 review feedback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Pushed b3e8759 addressing bonk's blocker. The fix:
Net behavior:
|
|
/bigbonk re-review for issues |
There was a problem hiding this comment.
Reviewing b3e8759 after the fix-up for the prerender silent-corruption blocker. The new design is sound and bonk's blocker is genuinely addressed. A couple of follow-ups worth flagging.
1. Stale comment in prod-server.ts:816-820
The b3e8759 commit rewrote the socket-error-backstop JSDoc to drop the NODE_ENV gate in favor of a fire-time VINEXT_PRERENDER check, but the comment at the second install site wasn't updated:
// Process-level peer-disconnect backstop. installSocketErrorBackstop
// self-gates on NODE_ENV: this call is a no-op during prerender
// (NODE_ENV=production) so user fetch() calls hit by ECONNRESET
// still crash the build instead of producing corrupt output. Fires
// for vinext start only when NODE_ENV is unset.
installSocketErrorBackstop();Both claims here are now wrong:
- "self-gates on NODE_ENV" — the install no longer gates on NODE_ENV at all. The current install-time gate is
VITEST === "true" || NODE_ENV === "test". - "no-op during prerender (NODE_ENV=production)" — install does happen during prerender now; the prerender bypass is fire-time via
VINEXT_PRERENDERinside the listener. - "Fires for vinext start only when NODE_ENV is unset" — fires for
vinext startregardless of NODE_ENV.
The end-user behavior is correct (Next.js parity for vinext start is restored, prerender still re-throws), but the comment now describes an intermediate iteration that was reverted in the same commit. Suggest replacing with something like:
// Process-level peer-disconnect backstop. Idempotent via the
// Symbol.for guard inside installSocketErrorBackstop, so this
// call is a no-op when index.ts has already installed it. Kept
// here for the rare case where prod-server is loaded without
// index.ts also being loaded (none of the current entry points
// hit this, but it preserves Next.js's "install everywhere a
// Node HTTP server runs" parity). Prerender bypass is fire-time
// via VINEXT_PRERENDER, not install-time.2. prerender.ts's delete VINEXT_PRERENDER clobbers runPrerender's set
Tracing through the hybrid build path (run-prerender.ts:133 → prerenderApp → prerenderPages):
runPrerender:133setsVINEXT_PRERENDER=1(no try/finally — never restored).prerenderAppenters its try, re-setsVINEXT_PRERENDER=1atprerender.ts:690. Its finallydeletes the var atprerender.ts:1116.- After
prerenderAppreturns, the var is deleted (not restored to the valuerunPrerenderset). prerenderPagesruns next, re-setsVINEXT_PRERENDER=1atprerender.ts:373, finallydeletes at line 645.- After
runPrerender's try block completes,VINEXT_PRERENDERis deleted, butsharedProdServer.close()runs in the outer finally atrun-prerender.ts:271.
The gap windows where VINEXT_PRERENDER is unset during a prerender run:
- Between
prerenderAppreturning andprerenderPagessetting the var. Synchronous, near-zero in practice but technically a window where ECONNRESET escaping during shared-server idle would be absorbed instead of crashing. - During
sharedProdServer.close()inrun-prerender.ts:268-273. TCP half-close can asynchronously surface ECONNRESET on the server-side socket; with the var unset, the backstop absorbs.
Neither window can produce corrupt prerendered output (rendering is already complete by the time these fire), so this isn't a #1-style silent-corruption blocker. But it's worth fixing for hygiene: prerender.ts's set/delete should save = process.env.VINEXT_PRERENDER; ... ; if (save === undefined) delete process.env.VINEXT_PRERENDER; else process.env.VINEXT_PRERENDER = save; so callers' set isn't clobbered.
Trivial change inside prerender.ts; doesn't block merge.
3. Orchestrator-induced ECONNRESET during prerender now crashes the build
The current behavior:
process.on("uncaughtException", (err: Error) => {
if (process.env.VINEXT_PRERENDER === "1") throw err; // unconditional re-throw
const code = peerDisconnectCode(err);
...
});During prerender, any uncaught error — including an ECONNRESET on the local 127.0.0.1 prod server caused by the orchestrator aborting/timing out a route fetch — crashes the build. This is the right tradeoff for ECONNRESETs that originate from user fetch() calls (silent-corruption avoidance), but it conflates them with ECONNRESETs that originate from the orchestrator's own HTTP client behavior.
Concrete failure mode: runPrerender orchestrator fetches /some/slow/route, an upstream issue inside the prod server makes that response take >30s, the orchestrator's HTTP client aborts (or the route render hangs and the orchestrator times out), ECONNRESET surfaces on the prod server's socket, escapes to uncaughtException, build crashes with an opaque "Error: read ECONNRESET" rather than the actual route error.
This is rare and probably acceptable — a hung prerender route is itself a build problem worth surfacing — but the tradeoff isn't documented. The JSDoc currently says only that user fetch() calls during prerender hit external APIs that can drop connections. Worth adding a one-liner: "Note: this also makes the prod server's own incoming-connection ECONNRESETs (orchestrator client aborts) fatal during prerender. Acceptable because such errors usually indicate a stuck route render, but the resulting crash will surface as ECONNRESET rather than the underlying cause."
A more nuanced approach would re-throw only when peerDisconnectCode(err) is set during prerender (so non-peer-disconnect errors crash with their original stack as before, but peer-disconnects also crash with ECONNRESET). The current throw err unconditional path achieves the same effect for peer-disconnects but is needlessly broad — non-peer-disconnect errors would crash anyway via the second throw err later in the listener. Simplification: keep unconditional re-throw but note in code that it's defense in depth, not behavior change for non-peer-disconnects.
4. JSDoc claim about index.ts load timing is slightly imprecise
* Doing the check at install time wouldn't work: `index.ts` loads at
* Vite plugin import (well before prerender starts), so by the time
* `VINEXT_PRERENDER` is set the listener would already be installed
* via the Symbol.for guard.
Strictly: the Symbol.for guard would prevent re-install, but the install was always a one-shot anyway — so the relevant point is "by the time VINEXT_PRERENDER is set, the listener is already installed (period); we cannot uninstall and re-install it." The Symbol.for is doing the de-dup work, not the timing work. Minor wording nit; the intent is clear.
5. (Pre-existing, not introduced by this PR) prod-server.ts:999-1000 comment
// Forward to RSC handler — the endpoint is implemented there and has
// access to the in-process map. VINEXT_PRERENDER=1 must be set (it is,
// since this server is only started during the prerender phase).
// Fall through to the RSC handler below."This server is only started during the prerender phase" is wrong — startProdServer is also the entry point for vinext start self-hosted Node deploys, where VINEXT_PRERENDER is unset and the prerender endpoints correctly 403. Not introduced by this PR; fix opportunistically if you're touching the file.
Bottom line
The fix is correct and bonk's prior #1 is addressed. The headline issue is the stale comment in prod-server.ts:816-820 which now contradicts the code behavior — that should be updated before merge to avoid future-debugger confusion. Items 2-5 are nice-to-haves and don't block.
Verifying the fix end-to-end:
| Context | installSocketErrorBackstop runs? |
VINEXT_PRERENDER at fire time |
Net behavior |
|---|---|---|---|
vinext dev |
yes (index.ts top-level) | unset | filters peer-disconnect ✓ |
vinext start |
yes (index.ts + prod-server, second is no-op) | unset | filters peer-disconnect ✓ (Next.js parity) |
vinext build non-prerender phase |
yes (index.ts) | unset | filters peer-disconnect (acceptable; build doesn't open prerender-prone sockets) |
Prerender (within vinext build) |
yes (already installed) | "1" | re-throws unconditionally ✓ |
| Vitest workers | no | n/a | normal Node behavior ✓ |
This matches the table in the PR comment. The mechanism holds up.
|
Review posted on PR #916. Summary of the main points:
Verified the end-to-end matrix matches the author's claim — the fix design holds up. Bonk's prior #1 (silent-corruption blocker) is genuinely resolved. |
Three issues bonk flagged after the prerender fix landed: 1. Stale comment in prod-server.ts:816-820 — described the NODE_ENV self-gate that was reverted in b3e8759. Replace with one that describes the current behavior: idempotent install via Symbol.for guard, kept here for entry points that load prod-server without index.ts (Next.js parity), prerender bypass is fire-time via VINEXT_PRERENDER not install-time. 2. prerender.ts's finally blocks `delete VINEXT_PRERENDER` clobbers any value set by the caller. After prerenderApp returns inside runPrerender (which sets the flag for the whole orchestration), the var is deleted instead of restored to runPrerender's "1", leaving narrow gap windows around shared-server close where ECONNRESET would be absorbed instead of re-thrown. Save the prior value and restore it. Doesn't cause prerender output corruption (rendering already complete by these gaps) but it's the correct hygiene. 3. Document the orchestrator-induced ECONNRESET trade-off in the backstop's JSDoc — unconditional re-throw during prerender means a hung-route + orchestrator timeout surfaces the build crash as ECONNRESET rather than the route's own error. Acceptable but worth calling out so debuggers know to set VINEXT_DEBUG_SOCKET_ERRORS=1 to disambiguate. Plus minor JSDoc wording nit: the "Symbol.for guard prevents re-install" line was load-bearing in the wrong way — the relevant point is timing, not de-dup. Reworded. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Pushed 7894608 addressing the follow-up. #1 Stale comment in prod-server.ts — replaced. Now describes the current behavior: idempotent install via the Symbol.for guard, kept at this site for entry points that load prod-server without index.ts (Next.js parity), prerender bypass is fire-time via #2 prerender.ts clobbering runPrerender's set — both #3 Orchestrator-induced ECONNRESET docs — added a paragraph to the backstop JSDoc calling out that an orchestrator client abort during prerender will surface the build crash as #4 JSDoc wording nit — reworded "by the time #5 Pre-existing prod-server.ts:999-1000 comment — left for now; happy to fix opportunistically in a follow-up if useful but not touching this PR's scope. |
|
/bigbonk re-review for issues |
|
@james-elicx Bonk workflow was cancelled. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk re-review for genuine issues |
There was a problem hiding this comment.
Reviewing 7894608 after the follow-up. The previous-review blockers are all addressed cleanly. Verified end-to-end:
prod-server.ts:809-823— comment now accurately describes idempotent install + Symbol.for guard + fire-timeVINEXT_PRERENDERbypass. ✓prerender.ts:373-374 / 646-647and:695-696 / 1122-1123— save/restore pattern aroundVINEXT_PRERENDERcorrectly preservesrunPrerender's outer set across the innerprerenderApp/prerenderPagesfinally blocks. ✓socket-error-backstop.ts:46-54— orchestrator-ECONNRESET tradeoff documented. ✓socket-error-backstop.ts:41-44— JSDoc wording corrected; Symbol.for is doing de-dup, not timing. ✓
Nothing here blocks merge. A few small things worth noting before sign-off, none of which are regressions vs. the prior iteration:
1. runPrerender sets VINEXT_PRERENDER without a save/restore wrapper
packages/vinext/src/build/run-prerender.ts:133 sets the var unconditionally and never restores it. For the CLI build path this is fine (process exits after). For tests calling runPrerender directly, VINEXT_PRERENDER is left set in the worker after the function returns.
This doesn't currently cause test failures because the backstop's install gate (VITEST === "true") skips installation in Vitest workers, so the dirty env var doesn't trip the prerender bypass on a listener that doesn't exist. But it's an environmental side-effect that future test code or a runtime that doesn't set VITEST could trip on.
Suggest mirroring the same save/restore pattern used in prerender.ts:
const previousPrerenderFlag = process.env.VINEXT_PRERENDER;
process.env.VINEXT_PRERENDER = "1";
try {
// ... existing body
} finally {
if (previousPrerenderFlag === undefined) delete process.env.VINEXT_PRERENDER;
else process.env.VINEXT_PRERENDER = previousPrerenderFlag;
}Trivial and keeps the contract symmetric with prerender.ts's helpers.
2. index.ts:118-126 install-site comment is slightly imprecise
// Self-gates on NODE_ENV / VITEST so build (incl. prerender) and tests
// skip cleanly. See socket-error-backstop.ts for full rationale.
installSocketErrorBackstop();The "build (incl. prerender) ... skip cleanly" framing is wrong. The install is not skipped during build — it runs at module load (vinext build imports index.ts). Prerender isn't skipped at install either; prerender bypass is fire-time via VINEXT_PRERENDER inside the listener. The install gate is only VITEST === "true" || NODE_ENV === "test" (per socket-error-backstop.ts:86).
This is the same kind of stale-iteration comment that prod-server.ts:816-820 was carrying before 7894608 fixed it. Since the JSDoc inside socket-error-backstop.ts explains the actual behavior at length, the call-site comment can be much shorter and just point at the module:
// Install the process-level peer-disconnect backstop at module load.
// Skips Vitest workers via env-var gate; bypasses during prerender via
// fire-time VINEXT_PRERENDER check. See socket-error-backstop.ts.
installSocketErrorBackstop();3. No focused unit test for socket-error-backstop.ts — carried over from prior review
This was item #5 in the previous review and was acknowledged as nice-to-have. Still applies. The module now has 113 lines of subtle process-state logic with multiple branching conditions:
- Symbol.for idempotency (line 85)
- Test-runner short-circuit (line 86)
VINEXT_PRERENDERre-throw (lines 96, 105)- Peer-disconnect filter (lines 97-101, 106-110)
- Sync re-throw on non-peer-disconnect (lines 102, 111)
Per AGENTS.md "testing guidance for entry refactors": when behavior moves into a typed module, focused helper tests are preferred. The pure predicate peerDisconnectCode is trivial to export-and-test in isolation; the install logic can be tested by clearing the Symbol.for guard between calls and asserting process.listeners("uncaughtException").length.
Not a merge blocker, but the module would benefit from a test file to prevent regressions on the next iteration of this code (e.g., if someone adds another env-var bypass or filter case).
4. unhandledRejection's throw reason with non-Error rejections
When reason is a non-Error (e.g., a string, null, or a plain object without .code), peerDisconnectCode(reason) returns undefined and the listener falls through to throw reason. Node converts that to an uncaughtException, which re-enters vinext's uncaughtException listener. That second pass either (a) hits VINEXT_PRERENDER and re-throws, or (b) peerDisconnectCode returns undefined and re-throws.
The terminal behavior is correct (process crashes), but the crash surface for non-Error rejections is now layered — Node's default unhandled-rejection diagnostics (the Promise { ... } reference, the node --unhandled-rejections=warn switch) are bypassed because we converted it into an uncaught exception. Strictly: this is the same behavior as process.on("unhandledRejection", (r) => { throw r; }) without vinext, but worth a comment noting that vinext does not preserve Node's unhandled-rejection surface for non-peer-disconnect non-Error rejections.
Minor; not actionable unless the surface matters in practice.
Bottom line
Ready to merge from my side. Item #1 is the only one I'd suggest fixing in this PR (trivial save/restore symmetry); items #2-#4 are documentation/test-coverage hygiene that can land in a follow-up.
|
The review was posted successfully as a PR review (not as an issue comment, which is why it didn't show under Summary of the main points:
Bottom line: only #1 is worth fixing in this PR; #2-#4 can land in a follow-up. |
- run-prerender.ts: save/restore VINEXT_PRERENDER to avoid leaking the env var into Vitest workers (mirrors prerender.ts pattern). - index.ts: correct stale install-site comment — install gate is Vitest-only; build/prerender bypass is fire-time via VINEXT_PRERENDER. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
/bigbonk re-review for genuine issues |
|
Committed Round 3 follow-up addressing bonk's most recent review: The latest commit on the branch ( This commit (
Item #4 (non-Error
|
… gate Addresses bonk PR #916 review item #3 (carried over). Extracts 'peerDisconnectCode' as an exported pure predicate so the matching logic can be tested in isolation without process-state mutation, and adds a thin 'isSocketErrorBackstopInstalled()' query so the test can verify the Vitest install-gate short-circuit fires in worker processes. Tests cover: - ECONNRESET / EPIPE / ECONNABORTED accepted, other codes rejected - non-Error / null / primitive reasons handled (unhandledRejection fires with arbitrary reason values) - Install gate skips Vitest workers (process.env.VITEST === 'true') Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… gate Addresses bonk PR #916 review item #3 (carried over). Extracts 'peerDisconnectCode' as an exported pure predicate so the matching logic can be tested in isolation without process-state mutation, and adds a thin 'isSocketErrorBackstopInstalled()' query so the test can verify the Vitest install-gate short-circuit fires in worker processes. Tests cover: - ECONNRESET / EPIPE / ECONNABORTED accepted, other codes rejected - non-Error / null / primitive reasons handled (unhandledRejection fires with arbitrary reason values) - Install gate skips Vitest workers (process.env.VITEST === 'true') Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
d6a9d5b to
8fa627a
Compare
|
/bigbonk re-review |
There was a problem hiding this comment.
Reviewing 8fa627a after the round-3 follow-up adding the focused unit test. Verified prior items end-to-end:
run-prerender.ts:133-134, 274-275— save/restore wrapper aroundVINEXT_PRERENDERmatches the symmetric pattern used inprerender.ts. ✓index.ts:118-124— install-site comment now accurately describes module-load install + Vitest gate + fire-time prerender bypass. ✓socket-error-backstop.ts:88-91—peerDisconnectCodeextracted as a pure exported predicate, no process-state mutation. ✓tests/socket-error-backstop.test.ts— 3 predicate tests + 1 gate-skip test, ~270ms. ✓
Nothing here blocks merge. A few small observations, all carry-overs or hygiene notes that don't justify another iteration on this PR:
1. Test coverage is narrower than the commit message claims
The commit message lists coverage as:
- ECONNRESET / EPIPE / ECONNABORTED matched
- Other codes (ENOENT, EHOSTUNREACH, no code) rejected
- Non-Error / null / primitive reasons handled gracefully
- Install gate skips Vitest workers
Strictly accurate for the predicate, but the listener behavior (absorb vs re-throw, VINEXT_PRERENDER bypass, debug log emission) isn't exercised. Idempotency-via-Symbol.for is also untested — the gate-skip test short-circuits before it reaches the Symbol.for guard, so the guard's de-dup behavior gets no coverage. Neither is realistically testable inside a Vitest worker without forking a child process, so the practical answer is "not worth it" — but the commit message could be honest that this is predicate coverage, not full listener coverage.
2. isSocketErrorBackstopInstalled is "test-only" but exported through the module's public surface
The JSDoc says "Not part of the public API." but it's a normal export. Anything that imports socket-error-backstop.ts can call it. Cosmetic — convention is to use _ prefix or push to a __test__ re-export — but the module isn't part of the package's public entry, so realistic blast radius is zero.
3. Gate-skip test is somewhat self-validating
expect(process.env.VITEST).toBe("true");
expect(isSocketErrorBackstopInstalled()).toBe(false);If the gate ever regressed (e.g. VITEST !== "true" flipped), this test would still need someone to also import index.ts for the install path to be tripped, which the test file doesn't do. Today the test passes because installSocketErrorBackstop() is never called inside the worker — same outcome as a properly-gated install. To genuinely test the gate, the test would need to call installSocketErrorBackstop() directly and assert no listener was added. Trivial to strengthen:
const before = process.listenerCount("uncaughtException");
installSocketErrorBackstop();
expect(process.listenerCount("uncaughtException")).toBe(before);
expect(isSocketErrorBackstopInstalled()).toBe(false);Worth doing in a follow-up; the assertion as-shipped passes vacuously if either gate or install is broken.
4. Carry-overs that remain unaddressed (acceptable)
unhandledRejectionwith non-Error reasons bypasses Node's default unhandled-rejection diagnostics — author correctly noted this as minor and acknowledged in PR comments.- No test for the
VINEXT_PRERENDERre-throw path. Same forking-worker constraint as #1.
Bottom line
Merge-ready. The mechanism is solid, prior blockers are properly addressed, and the test coverage that was added is real coverage of the pure predicate. Items above are hygiene-only and don't justify another iteration on this PR.
|
Posted the re-review on PR #916. Summary of findings: No genuine blockers remaining. All prior review items are properly addressed:
Minor observations (none blocking):
PR is merge-ready. |
Summary
Real fix for the residual #905 crashes that #913 didn't catch. Confirmed via the opt-in trace below.
#913 installed the
uncaughtExceptionhandler insideconfigureServerand tore it down onhttpServer'close'. Vite emitscloseon dep re-optimization, full reloads, and other lifecycle events — leaving a window where the listener is absent when a stale stream errors. The exact pattern users were hitting:Reproducer confirmed with the diagnostic flag from this PR:
The marker firing where the crash used to happen confirms the listener is now in place when the error fires.
Changes
Hoist install from
configureServerto module top-level, guarded bySymbol.for("vinext.devSocketErrorBackstop")to prevent double-install (e.g. when vinext is loaded by both a project and a sibling workspace package). No teardown — the listener lives for the process.Add opt-in
VINEXT_DEBUG_SOCKET_ERRORS=1trace that logs when the listener absorbs a peer-disconnect. Default off; useful for confirming the listener fires when users still see crashes in the field.Filter codes (
ECONNRESET/EPIPE/ECONNABORTED) and synchronous re-throw of non-peer-disconnect errors are unchanged from #913.Refs
EventEmitter.prototype.emitpatch was the wrong direction — domain can replace prototype emit on its load, so that approach was fragile)Test plan
vp checkpasses (1 pre-existing benchmarksCannot find module 'vinext'error is unrelated)VINEXT_DEBUG_SOCKET_ERRORS=1 vp devno longer crashes;[vinext] absorbed uncaughtException ECONNRESETprinted where the crash used to be🤖 Generated with Claude Code