Skip to content

fix(dev): backstop uncaughtException for socket errors pipe() misses#913

Merged
james-elicx merged 2 commits into
mainfrom
claude/laughing-proskuriakova-02bf3d
Apr 26, 2026
Merged

fix(dev): backstop uncaughtException for socket errors pipe() misses#913
james-elicx merged 2 commits into
mainfrom
claude/laughing-proskuriakova-02bf3d

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

Follow-up to #911. The connection-level guard installed there fixes the direct res.socket case but doesn't cover Node's pipe() re-emission path. When a Readable source errors and the destination has no 'error' listener, pipe() emits the source's error onto the destination — and the destination isn't always the inbound connection socket.

Real call sites in vinext that hit this:

  • fromWeb(fetch().body).pipe(res) in proxyExternalRewriteNode — source is the upstream fetch body stream, not an inbound socket.
  • Streaming surfaces inside @vitejs/plugin-rsc that own their own pipe topology.
  • Outbound sockets created by fetch() from middleware — these never fire 'connection' on server.httpServer, so they sit entirely outside the existing guard.

Stack trace from the field (vp dlx vinext@54c91f1 dev, after a successful GET /stage/oliver):

Error: read ECONNRESET
    at TCP.onStreamRead (node:internal/stream_base_commons:216:20)
Emitted 'error' event on Socket instance at:
    at Socket.onerror (node:internal/streams/readable:1035:14)
    at Socket.emit (node:events:509:28)
    ...

Socket.onerror at internal/streams/readable:1035 is pipe()'s internal error-propagation function — same crash shape that #911 set out to fix, different installation site.

Approach

Add a process-level uncaughtException handler scoped to the dev server. It drops only peer-disconnect codes (ECONNRESET, EPIPE, ECONNABORTED) and re-throws everything else on nextTick, which preserves Node's default crash semantics for real bugs. The handler is removed on httpServer 'close' so it doesn't leak across restarts.

Why a process-level backstop:

  • It's the only place that catches outbound socket errors from fetch() and pipe re-emissions to non-socket destinations.
  • Filtering by error code keeps it safe — non-disconnect errors still surface exactly as Node would have surfaced them.
  • Dev-only: lives inside configureServer, never installed in build/prod.

Refs

Test plan

  • vp check passes (note: 12 pre-existing typecheck errors about missing peer deps are unchanged)
  • Manual repro from Dev server crashes with Unhandled 'error' event (ECONNRESET) when editing files #905 with proxyExternalRewriteNode in play: dev server no longer crashes after browser disconnect mid-stream
  • Genuine errors thrown from request handlers still crash dev server with full stack (handler re-throws on nextTick)
  • httpServer.close() followed by another listen() doesn't double-register the handler

🤖 Generated with Claude Code

…sses

PR #911 attached an error listener to every connection socket, which
fixes the direct res.socket case. But Node's pipe() machinery has a
separate re-emission path: when a Readable source errors and the
destination has no error listener, pipe() emits the error on the
destination — and that destination is not always the connection
socket. Two real call sites trigger this:

  - fromWeb(fetch().body).pipe(res) in proxyExternalRewriteNode, where
    the source is the upstream fetch body, not the inbound socket
  - streaming surfaces inside @vitejs/plugin-rsc that own their own
    pipe topology

Outbound sockets created by fetch() also never fire 'connection' on
server.httpServer, so they sit completely outside the existing guard.

Add a process-level uncaughtException handler scoped to the dev server
that drops only peer-disconnect codes (ECONNRESET, EPIPE,
ECONNABORTED) and re-throws everything else on nextTick to preserve
Node's default crash behavior for real errors. Removed on httpServer
'close' so it doesn't leak across server restarts.

Refs #905.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review

@pkg-pr-new

pkg-pr-new Bot commented Apr 26, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/vinext@913

commit: 95cd980

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original

@ask-bonk ask-bonk 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.

Reviewer notes

I confirmed the diagnosis of the original crash (pipe re-emission from non-socket destinations and outbound fetch() sockets) — the connection-level guard from #911 genuinely doesn't cover those paths, and a process-level uncaughtException handler is a reasonable backstop. Next.js installs one in dev for similar reasons (packages/next/src/server/lib/router-server.ts ~line 809).

However, the re-throw path for non-disconnect errors does not behave the way the comment claims, and that's a meaningful behavioral regression for genuine errors.

Blocker: re-throw on nextTick creates an infinite loop, doesn't crash

packages/vinext/src/index.ts:2020-2028:

const onUncaught = (err: Error) => {
  if (isPeerDisconnect(err)) return;
  process.nextTick(() => {
    throw err;
  });
};
process.on("uncaughtException", onUncaught);

The comment says this restores Node's default crash behavior — "same shape as not having installed a handler at all." That's not what happens. Node's default crash only fires when there are no uncaughtException listeners. Here the listener is still installed when the nextTick callback runs, so the re-thrown error is caught by the same handler again, which schedules another nextTick throw, which is caught again, forever.

Reproduced locally with the exact pattern:

$ node -e '
process.on(\"uncaughtException\", (err) => {
  console.log(\"caught:\", err.message);
  if (err.code === \"ECONNRESET\") return;
  process.nextTick(() => { throw err; });
});
process.nextTick(() => { throw new Error(\"genuine bug\"); });
setTimeout(() => { console.log(\"still alive\"); process.exit(0); }, 100);
'
caught: genuine bug
caught: genuine bug
caught: genuine bug
... (repeats indefinitely)
still alive

Net effect for vinext dev users: a real programming error that previously would crash the dev server with a stack trace now gets swallowed silently and the event loop spins on it. That is the opposite of the stated intent ("keep real bugs surfacing").

This also means the unchecked test-plan item "Genuine errors thrown from request handlers still crash dev server with full stack" will not pass as written.

Fix options

Pick whichever fits the style of the file; any of these restores the intended semantics:

1. Synchronous re-throw inside the handler (simplest):

const onUncaught = (err: Error) => {
  if (isPeerDisconnect(err)) return;
  throw err; // sync throw inside the handler aborts the process with code 1
};

I verified Node treats a synchronous throw inside an uncaughtException listener as fatal (exit code 7 / 1 depending on version), printing the stack — same observable as no handler.

2. Remove the listener, then re-throw asynchronously:

const onUncaught = (err: Error) => {
  if (isPeerDisconnect(err)) return;
  process.removeListener(\"uncaughtException\", onUncaught);
  process.nextTick(() => { throw err; });
};

3. Log + process.exit(1) — matches what Next.js's setup-exception-listeners.ts does. Loses the default stack-trace formatting unless you console.error(err) first.

I'd lean toward (1) for minimum diff and matching Node's default-crash output most closely.

Minor: leak in middleware mode

server.httpServer?.once(\"close\", ...) is a no-op when server.httpServer is null (Vite middleware mode — vinext embedded in a host server). The uncaughtException listener is never cleaned up in that case. Not a hot path today (no test uses middlewareMode), but worth tying the cleanup to something that always exists, e.g. also hook server.watcher.close or a buildEnd hook, or skip installation entirely when httpServer is null.

Nit: also catch unhandledRejection?

The two real crash sources cited (pipe re-emission, outbound fetch() socket errors from middleware) can both reach the process as unhandledRejection rather than uncaughtException depending on which async surface throws. If the goal is a true backstop for peer-disconnect errors in dev, mirroring the same filter on unhandledRejection would close that gap. Optional — fine to leave for a follow-up if you want to keep this PR tight.

Once the re-throw is fixed

Approach LGTM otherwise. Filter is correctly scoped to dev (configureServer only), correctly scoped to peer-disconnect codes, and the cleanup-on-close pattern is reasonable for the httpServer != null case.

@ask-bonk

ask-bonk Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Review posted as Changes Requested on PR #913.

Summary of findings:

  1. Blocker (verified by reproduction): The process.nextTick(() => { throw err }) re-throw pattern at packages/vinext/src/index.ts:2025-2027 creates an infinite loop for genuine errors, not a crash. Because the listener is still installed when the nextTick callback fires, the re-thrown error is caught by the same handler again. Node's default crash only fires when there are zero uncaughtException listeners — so the comment's claim "same shape as not having installed a handler at all" is incorrect. Net effect: real programming errors get silently swallowed in dev, opposite of intent. Suggested three fix variants (sync throw inside handler is simplest).

  2. Minor: server.httpServer?.once("close", ...) is a no-op in Vite middlewareMode (no httpServer), so the listener leaks across the lifetime of the embedding process.

  3. Nit: unhandledRejection could reach the process from the same async surfaces; consider mirroring the filter there too as a follow-up.

Approach itself is sound — process-level backstop is the right shape, filter codes are correct, scope to dev-only via configureServer is right.

github run

Three issues from PR #913 review:

1. (Blocker) The process.nextTick(() => throw err) re-throw created an
   infinite loop, not a crash: the listener is still installed when the
   nextTick callback fires, so it catches the re-thrown error and
   schedules another, forever — silently swallowing genuine bugs.
   Replace with a synchronous throw inside the handler, which Node
   treats as fatal and produces the same observable as no listener
   (stack to stderr, non-zero exit).

2. Skip installation entirely in middleware mode (httpServer is null).
   The embedding host owns process-level handlers and there's no
   reliable teardown hook to remove ours, so installation would leak.

3. Mirror the same filter onto unhandledRejection — peer-disconnect
   errors from outbound fetch() and pipe re-emissions can surface
   through either channel depending on the async path.

Verified locally: sync throw inside the handler crashes Node with the
original stack and a non-zero exit code; the previous nextTick pattern
loops indefinitely on the same input.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@james-elicx

Copy link
Copy Markdown
Member Author

Thanks for the catch — all three addressed in 95cd980.

Blocker (1): Confirmed the loop locally — your repro reproduces verbatim. Switched to a synchronous throw inside the handler. Verified Node treats it as fatal:

$ node -e 'process.on("uncaughtException", (err) => { if (err.code === "ECONNRESET") return; throw err; }); process.nextTick(() => { throw new Error("genuine bug"); });'
[eval]:1
... uncaughtException listener ... throw err;
                                   ^
Error: genuine bug
    at [eval]:1:...
Node.js v24.15.0

Stack to stderr, non-zero exit — same observable as no listener.

Minor (2): Skipped installation entirely when httpServer is null. Wrapped the whole block in if (server.httpServer) so neither the listeners nor the close hook are registered in middleware mode. Embedding host stays in charge of process handlers, no leak.

Nit (3): Folded in — same isPeerDisconnect filter mirrored onto unhandledRejection with the same sync-throw shape, removed alongside the uncaughtException handler on close.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk re-review for issues

@ask-bonk ask-bonk 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.

Re-review of 95cd980

All three points from the previous review are addressed correctly. Verified locally.

Blocker (1) — sync throw: ✅ fixed

packages/vinext/src/index.ts:2030-2033 and :2034-2037 use a synchronous throw inside the listener instead of process.nextTick(() => { throw err }). Reproduced both shapes:

$ node -e 'process.on("uncaughtException", (err) => { if (err.code === "ECONNRESET") return; throw err; });
           process.nextTick(() => { throw new Error("genuine bug"); });
           setTimeout(() => { console.log("STILL ALIVE"); }, 200);'
[eval]:4
  throw err;
  ^
Error: genuine bug
    at [eval]:6:32
Node.js v24.15.0
# exit=7

No loop, stack to stderr, non-zero exit. ECONNRESET still passes through silently as intended (verified separately).

Minor (2) — middleware-mode leak: ✅ fixed

Wrapping the entire block in if (server.httpServer) (line 2020) is the right call. Embedding host keeps ownership of process handlers; nothing leaks. Consistent with the existing server.httpServer?.on("connection", ...) pattern just above.

Nit (3) — unhandledRejection: ✅ folded in

onUnhandledRejection mirrors the same filter and sync-throw shape (:2034-2037) and is removed alongside onUncaught on close. Verified throw reason inside an unhandledRejection listener also crashes Node fatally (exit 1, stack printed), and the ECONNRESET filter still drops peer disconnects from rejected promises.

One subtle property worth noting: rejected non-Error values (Promise.reject("string"), Promise.reject(null)) flow through fine — (reason as {code?: string} | null)?.code is undefined for primitives and null, so isPeerDisconnect returns false and the throw still surfaces them. Good.

Other observations (non-blocking)

  • Listener accumulation on Vite restart: if Vite reloads the config and re-runs configureServer while the previous httpServer hasn't yet fired 'close', listeners briefly stack. Node warns at 10. Realistically not hit in normal dev, and the close hook does eventually clean up. Fine to leave.
  • No automated test: process-level error handlers are genuinely awkward to test — would require a child-process harness. The manual repro in the PR description is the right level of validation for a dev-only safety net.
  • Wrapped errors: new Error("wrapped", { cause: econnreset }) won't be filtered (top-level code is undefined). Matches the existing socket-level guard's behavior and is the conservative choice — only drop errors that directly look like peer disconnect.

LGTM.

@ask-bonk

ask-bonk Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Review posted as APPROVED on PR #913.

Summary of findings:

All three points from my previous review were addressed correctly in commit 95cd980:

  1. Blocker fixed: Switched from process.nextTick(() => { throw err }) to a synchronous throw err inside the listener. Verified locally — Node treats it as fatal (exit 7, stack to stderr), no infinite loop. Real bugs surface as intended.

  2. Middleware-mode leak fixed: Entire block now wrapped in if (server.httpServer), so listeners aren't installed when the embedding host owns the process.

  3. unhandledRejection mirror added: Same filter, same sync-throw shape, cleaned up alongside uncaughtException on close. Verified the throw-inside-listener pattern is fatal there too, and that primitives/null reasons flow through correctly without false positives.

Flagged three non-blocking observations (listener accumulation on Vite config reload, lack of automated test, wrapped-error chains not filtered) but none are worth holding the PR for.

github run

@james-elicx james-elicx enabled auto-merge (squash) April 26, 2026 20:05
@james-elicx james-elicx merged commit d9b25e5 into main Apr 26, 2026
32 checks passed
@james-elicx james-elicx deleted the claude/laughing-proskuriakova-02bf3d branch April 26, 2026 20:06
james-elicx added a commit that referenced this pull request Apr 26, 2026
PR #911 (connection socket guard) and PR #913 (process-level
uncaughtException backstop) both left a hole. The dev server still
crashes on ECONNRESET in this exact shape:

  node:events:487
        throw er; // Unhandled 'error' event
  Error: read ECONNRESET
      at TCP.onStreamRead
  Emitted 'error' event on Socket instance at:
      at Socket.onerror (node:internal/streams/readable:1035:14)  // pipe()
      at Socket.emit (node:domain:489:12)                          // domain wrap
      at emitErrorNT (node:internal/streams/destroy:170:8)

The Socket.onerror frame is Node's pipe() machinery re-emitting the
source's error onto a destination Stream that has no error listener.
The Socket.emit frame in node:domain wraps the emit and routes the
throw through the deprecated-but-still-loaded domain module, which
bypasses uncaughtException entirely. Neither the per-connection
listener nor a process-level handler can intercept this path.

Three real call sites in vinext dev hit it:
  - fromWeb(fetch().body).pipe(res) in proxyExternalRewriteNode
  - streaming surfaces inside @vitejs/plugin-rsc with their own pipe
    topology (destination is not the inbound connection socket)
  - outbound sockets created by middleware fetch()

The only place to catch every variant is at the source:
EventEmitter.prototype.emit. Patch it to short-circuit when *all
three* conditions hold: type === 'error', the error has a
peer-disconnect code (ECONNRESET / EPIPE / ECONNABORTED), and the
emitter has zero 'error' listeners (the exact condition under which
Node would throw). All other errors — and any error on an emitter
that already has a listener — pass through untouched. Genuine bugs
still surface.

Installed once per process at plugin construction, guarded by a
Symbol.for so dep re-optimization, full reloads, or repeated plugin
invocations can't double-install or tear it down. Belt-and-braces
uncaughtException / unhandledRejection handlers stay in place for
surfaces that bypass EventEmitter.emit (raw promise rejections,
native callbacks) — also synchronous-throw on non-peer-disconnect
errors so Node's default crash semantics are preserved.

Refs #905.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
james-elicx added a commit that referenced this pull request Apr 26, 2026
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>
james-elicx added a commit that referenced this pull request Apr 27, 2026
…e events can't remove it (#916)

* debug(dev): hoist socket-error backstop to module top-level + opt-in 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>

* fix: address bonk review on dev socket-error backstop

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>

* fix(dev): re-hoist socket-error backstop to module load (configureServer 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>

* refactor(dev): positive-gate socket backstop on argv[2]==='dev'

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>

* refactor(dev): gate socket backstop on Vite's command==='serve'

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>

* fix(dev): narrow gate to !isPreview so vite preview skips install

`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>

* fix: extend socket-error backstop to vinext start (Next.js parity)

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>

* fix(dev): auto-install socket-error backstop at module load

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>

* refactor: call installSocketErrorBackstop directly in index.ts

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>

* fix: gate socket backstop on NODE_ENV to keep prerender ECONNRESET fatal

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>

* fix: bypass socket backstop during prerender via VINEXT_PRERENDER

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>

* fix: address bonk PR #916 follow-up review

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>

* fix: address bonk PR #916 follow-up review (round 2)

- 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>

* test: focused unit test for socket-error-backstop predicate + install 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>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
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.

Dev server crashes with Unhandled 'error' event (ECONNRESET) when editing files

1 participant