Skip to content

feat(rpc): report socket ping timeouts through an onPingTimeout option - #8976

Merged
tim-smart merged 6 commits into
Effect-TS:mainfrom
juliusmarminge:t3code/rpc-client-ping-timeout-hook
Oct 9, 2026
Merged

tim-smart merged 6 commits into
Effect-TS:mainfrom
juliusmarminge:t3code/rpc-client-ping-timeout-hook

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #8825, which closed #8774 and left this part open.

Problem

When an RPC socket client loses its connection, ConnectionHooks.onDisconnect runs, but it cannot tell why. A ping timeout ("the server stopped answering") and a server-side close ("the server closed the connection") look the same to it. A client that shows connection state to a user needs to know which one happened: the first suggests a hung server or a dead network path, the second a restart or deliberate close.

The reason is not observable from outside makeProtocolSocket today. The timeout fails the socket with SocketReadError({ cause: new Error("ping timeout") }), and that error reaches in-flight calls through ClientProtocolError. But a WebSocket error event after open is also a SocketReadError, so the tag alone does not identify a timeout. Matching on the cause's message string is fragile. onDisconnect is an Effect<void> with no access to the error, and the error only reaches calls that are in flight or sent before the next reconnect. The onTransientError docs say directly that a ping timeout "is not reported through this hook".

T3 Code carries a local patch that adds an onPingTimeout effect to ConnectionHooks. Our client sets a flag there and reads it in onDisconnect to choose between "${label} stopped responding." and "${label} disconnected.". It is the last thing we still patch in effect, and this PR is the upstream version of it.

Change

makeProtocolSocket and layerProtocolSocket take a new optional onPingTimeout: Effect<void>, next to onTransientError. It runs when the pinger's timeout drops a connection that had opened. The hook runs after the socket is torn down, so a concurrent socket error cannot interrupt it, and before ConnectionHooks.onDisconnect and before in-flight calls fail with the SocketReadError above. A ping timeout while the socket is still opening fails the attempt as before but does not run the hook. As with onTransientError, defects are logged and ignored so they cannot change how the connection fails. The JSDoc on both functions and on onTransientError now points to the new option.

Why an option on makeProtocolSocket rather than the alternatives:

  • ConnectionHooks.onPingTimeout? (what our patch does). This also works and is additive. But ConnectionHooks is also read by makeProtocolWorker, which has no pinger, so the member would be silently ignored there. The pinger's timing (pingInterval, pingTimeout) and the other socket-specific hook (onTransientError) are already options on makeProtocolSocket, so this keeps the hook next to the setting that triggers it.
  • Passing a reason to onDisconnect. Changing onDisconnect from Effect<void> to a function breaks every existing ConnectionHooks implementation. An additive variant (a second, optional onDisconnectWithReason) would need a public "disconnect reason" type to be designed first, which seems larger than this case warrants.

If maintainers prefer the ConnectionHooks shape, it is a small change to move.

Testing

Two tests in packages/effect/test/rpc/RpcClient.test.ts, next to the existing ping-timeout tests and using TestClock. They record onConnect, onPingTimeout, onDisconnect, and in-flight stream failures.

  • Across two connections: the server closes the first one (SocketCloseError 1006), and the hook does not run while the client stays disconnected past the ping timeout. The reconnected socket then goes silent. Its ping timeout runs the hook before onDisconnect and before the in-flight stream fails with SocketReadError.
  • A ping timeout that fires while the socket is still opening fails the call with SocketReadError and runs onDisconnect, but not the hook.

Frame liveness is already covered by the existing "keeps in-flight streams alive on non-pong frames" test, so there is no separate test for it.

  • pnpm vitest --run packages/effect/test/rpc/ packages/effect/test/cluster/Runners.test.ts: 5 files, 105 passed.
  • The first test fails with RpcClient.ts reverted to main, with the hook run on every socket failure, and with the hook run after onDisconnect. The second fails when the hook is not limited to opened connections.
  • pnpm lint and pnpm check pass.

🤖 Generated with Claude Code

Closes EFF-1905

Add `onPingTimeout` to `RpcClient.makeProtocolSocket` and
`layerProtocolSocket`. It runs when the pinger drops the connection
because no server frame arrived within `pingTimeout`, before
`ConnectionHooks.onDisconnect` and before in-flight calls fail with
`SocketReadError`. Defects are logged and ignored, like
`onTransientError`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 21e20ed

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 32 packages
Name Type
effect Patch
@effect/opentelemetry Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-cloudflare Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/ai-typesafe Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node-shared Patch
@effect/platform-node Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
arbitrary-combinators.ts 38.88 KB 38.88 KB 0.00 KB (0.00%)
basic.ts 7.11 KB 7.11 KB 0.00 KB (0.00%)
batching.ts 10.20 KB 10.20 KB 0.00 KB (0.00%)
brand.ts 6.73 KB 6.73 KB 0.00 KB (0.00%)
cache.ts 10.96 KB 10.96 KB 0.00 KB (0.00%)
config.ts 22.36 KB 22.36 KB 0.00 KB (0.00%)
differ.ts 21.40 KB 21.40 KB 0.00 KB (0.00%)
http-client.ts 22.75 KB 22.75 KB 0.00 KB (0.00%)
http-router.ts 34.19 KB 34.19 KB 0.00 KB (0.00%)
logger.ts 11.28 KB 11.28 KB 0.00 KB (0.00%)
metric.ts 9.08 KB 9.08 KB 0.00 KB (0.00%)
optic.ts 6.96 KB 6.96 KB 0.00 KB (0.00%)
pubsub.ts 15.28 KB 15.28 KB 0.00 KB (0.00%)
queue.ts 12.21 KB 12.21 KB 0.00 KB (0.00%)
schedule.ts 11.30 KB 11.30 KB 0.00 KB (0.00%)
schema-bigdecimal.ts 13.82 KB 13.82 KB 0.00 KB (0.00%)
schema-binary.ts 40.07 KB 40.07 KB 0.00 KB (0.00%)
schema-class.ts 21.16 KB 21.16 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 32.20 KB 32.20 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 27.37 KB 27.37 KB 0.00 KB (0.00%)
schema-string-transformation.ts 14.58 KB 14.58 KB 0.00 KB (0.00%)
schema-string.ts 12.09 KB 12.09 KB 0.00 KB (0.00%)
schema-template-literal.ts 16.15 KB 16.15 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 38.41 KB 38.41 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 25.37 KB 25.37 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 20.32 KB 20.32 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 20.52 KB 20.52 KB 0.00 KB (0.00%)
schema-toFormatter.ts 20.63 KB 20.63 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 25.10 KB 25.10 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 20.61 KB 20.61 KB 0.00 KB (0.00%)
schema.ts 20.37 KB 20.37 KB 0.00 KB (0.00%)
stm.ts 13.33 KB 13.33 KB 0.00 KB (0.00%)
stream.ts 10.04 KB 10.04 KB 0.00 KB (0.00%)

@tim-smart
tim-smart enabled auto-merge (squash) October 9, 2026 20:41
@tim-smart
tim-smart merged commit f9f7a5a into Effect-TS:main Oct 9, 2026
8 of 10 checks passed

This branch is waiting to be deployed

1 waiting deployment
fork — 21e20ed0 Waiting Oct 9, 2026 by effect-bot via approval-gate #18725
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RpcClient: tolerate more than one missed pong on the socket protocol

3 participants