Skip to content

fix(client): reject WebSocket link calls after the socket closes - #2081

Merged
dinwwwh merged 1 commit into
middleapi:mainfrom
dinwwwh:claude/ecstatic-driscoll-5b2ede
Sep 25, 2026
Merged

dinwwwh merged 1 commit into
middleapi:mainfrom
dinwwwh:claude/ecstatic-driscoll-5b2ede

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Sep 25, 2026

Copy link
Copy Markdown
Member

With reconnect disabled (the default), calls made through the WebSocket link after its socket closed never settled. They now reject with the original WebSocket closed (code X: reason) AbortError.

The link kept reusing the closed connection, and WebSocket.send on a CLOSING/CLOSED socket silently discards data instead of throwing (WHATWG spec; confirmed on Node 24 for both the global WebSocket and ws), so the request waited forever unless the caller passed an abort signal.

Fixes

  • Calls made after a server restart or network drop reject immediately with the close code and reason instead of hanging.
  • Calls made while the server is unreachable (socket closes before opening) reject the same way instead of hanging.
  • Pending requests from these calls no longer pile up in memory.

Performance

  • Nothing is added to the per-call path; the only new work is a cheap closed check per sent message.

Testing

  • Two new tests in rpc-link.test.ts cover close after open and close before open; both hang on main and pass with this change.
  • pnpm vitest run packages/client/src/adapters/websocket tests/rpc and eslint pass.

With reconnect disabled (the default), the WebSocket link kept handing
out the cached peer after its socket closed. `WebSocket.send` on a
CLOSING/CLOSED socket silently discards data instead of throwing, so
every later call stayed pending forever. The same happened when the
socket closed before opening (server unreachable).

The peer's send callback now throws the stored
`WebSocket closed (code X: reason)` AbortError once the socket has
closed, so those calls reject with the original close reason.
@pkg-pr-new

pkg-pr-new Bot commented Sep 25, 2026

Copy link
Copy Markdown
More templates

@orpc/ai-sdk

npm i https://pkg.pr.new/@orpc/ai-sdk@2081

@orpc/arktype

npm i https://pkg.pr.new/@orpc/arktype@2081

@orpc/bun

npm i https://pkg.pr.new/@orpc/bun@2081

@orpc/client

npm i https://pkg.pr.new/@orpc/client@2081

@orpc/cloudflare

npm i https://pkg.pr.new/@orpc/cloudflare@2081

@orpc/contract

npm i https://pkg.pr.new/@orpc/contract@2081

@orpc/experimental-effect

npm i https://pkg.pr.new/@orpc/experimental-effect@2081

@orpc/evlog

npm i https://pkg.pr.new/@orpc/evlog@2081

@orpc/hibernation

npm i https://pkg.pr.new/@orpc/hibernation@2081

@orpc/json-schema

npm i https://pkg.pr.new/@orpc/json-schema@2081

@orpc/experimental-lock

npm i https://pkg.pr.new/@orpc/experimental-lock@2081

@orpc/experimental-msw

npm i https://pkg.pr.new/@orpc/experimental-msw@2081

@orpc/nest

npm i https://pkg.pr.new/@orpc/nest@2081

@orpc/next

npm i https://pkg.pr.new/@orpc/next@2081

@orpc/node

npm i https://pkg.pr.new/@orpc/node@2081

@orpc/openapi

npm i https://pkg.pr.new/@orpc/openapi@2081

@orpc/opentelemetry

npm i https://pkg.pr.new/@orpc/opentelemetry@2081

@orpc/pinia-colada

npm i https://pkg.pr.new/@orpc/pinia-colada@2081

@orpc/pino

npm i https://pkg.pr.new/@orpc/pino@2081

@orpc/publisher

npm i https://pkg.pr.new/@orpc/publisher@2081

@orpc/ratelimit

npm i https://pkg.pr.new/@orpc/ratelimit@2081

@orpc/server

npm i https://pkg.pr.new/@orpc/server@2081

@orpc/shared

npm i https://pkg.pr.new/@orpc/shared@2081

@orpc/swr

npm i https://pkg.pr.new/@orpc/swr@2081

@orpc/tanstack-query

npm i https://pkg.pr.new/@orpc/tanstack-query@2081

@orpc/trpc

npm i https://pkg.pr.new/@orpc/trpc@2081

@orpc/valibot

npm i https://pkg.pr.new/@orpc/valibot@2081

@orpc/zod

npm i https://pkg.pr.new/@orpc/zod@2081

commit: 454c6ee

@codecov

codecov Bot commented Sep 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 30 untouched benchmarks


Comparing dinwwwh:claude/ecstatic-driscoll-5b2ede (454c6ee) with main (b050f30)

Open in CodSpeed

@pullfrog pullfrog 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.

ℹ️ Minor suggestion inline. The fix is correct and the new tests genuinely fail without it.

Reviewed changes

  • Reject calls on a closed socket — WebSocketLinkTransport now captures a per-connection closeReason set by the close listener, and the ClientPeer send callback throws it so requests made after close reject instead of hanging (transport.ts:185-194, :220-230).
  • Corrected stale assumption — dropped the comment claiming WebSocket.send throws on non-open state; per the WHATWG spec it silently discards on CLOSING/CLOSED.
  • Regression tests — two new cases in rpc-link.test.ts for close-after-open and close-before-open. I reverted the source fix locally and confirmed both time out (hang), so they actually pin the bug.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment thread packages/client/src/adapters/websocket/transport.ts
@dinwwwh
dinwwwh merged commit 29285f6 into middleapi:main Sep 25, 2026
11 checks passed
dinwwwh added a commit that referenced this pull request Sep 25, 2026
)

Calls made through the message port `RPCLink` after the port closes now
reject with `AbortError('MessagePort closed')` instead of hanging
forever. In-flight calls cut off by the close now reject with the same
reason instead of the generic "Peer was closed". This is the message
port counterpart of #2081.

## Fixes

- Web `MessagePort`, Node `worker_threads` ports, and Electron
`MessagePortMain` silently drop `postMessage` once closed, so any later
call stayed pending unless the caller passed an abort signal. Those
calls now reject right away and nothing is posted to the dead port.
- Browser extension ports already rejected, because `postMessage` throws
on a disconnected port in both Chrome and Firefox. A remote disconnect
now surfaces the same `AbortError` as the other port kinds.

## Testing

- New test: a call made after close rejects with
`AbortError('MessagePort closed')` and never posts a message. It timed
out before the fix.
- The existing close test now asserts the exact abort reason.
- Checked Node 24 directly: after close, `postMessage` drops messages
without throwing and `close` fires on both ends. Electron, Chrome, and
Firefox behavior was checked against their source.

A client that calls `close()` on its own browser `MessagePort` can still
hang, because the HTML spec fires `close` only at the other end of the
channel. Node and Electron fire it on both ends.
dinwwwh added a commit that referenced this pull request Sep 25, 2026
…d socket (#2083)

With reconnect disabled (the default), calls through the WebSocket link
hung forever when `connect` returned a socket that was already closing
or closed. They now reject immediately with `AbortError('WebSocket is
already closing or closed')`.

This happens when the socket is created ahead of time, as in the
hibernation docs (`connect: () => websocket`): if the server is down and
the socket closes before the first call, that call never settled. A
`close` listener added after the socket closed never fires (confirmed on
Node 24 for both the global `WebSocket` and `ws`), and `send` silently
discards data on a closed socket, so the close handling from #2081 never
ran.

## Fixes

- Calls on a socket that was already closing or closed reject right away
instead of hanging.
- With reconnect enabled, the link still retries as before, but no
longer attaches listeners to the dead socket.

## Notes

- A custom `WebSocketLike` must report `readyState` as `CONNECTING` (0)
or `OPEN` (1) to be used; any other value is treated as closed.

## Testing

- New tests in `rpc-link.test.ts` cover an already closing and an
already closed socket with reconnect disabled (both hang on `main`) and
an already closed socket with reconnect enabled.
- Checked with a real Node `WebSocket` closed by a refused connection:
the call stays pending on `main` and rejects immediately with this
change.
- `pnpm vitest run packages/client/src/adapters/websocket tests/rpc`,
eslint, and `@orpc/client` type-check pass.
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.

1 participant