You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
@orpc/experimental-durable-iterator: token refresh leaves unhandled rejections, and a closed iterator keeps retrying #2294
@orpc/experimental-durable-iterator 1.15.5 (the client plugin is the same in 1.15.7, 1.16.0 and on the 1.x branch), @orpc/client 1.15.5, partysocket 1.3.0. Seen in Chrome 155 and Firefox 157. The reproduction runs on Bun 1.4.
Reproduction
The same pieces the plugin builds internally: partysocket, the WebSocket RPCLink and ClientRetryPlugin, against an oRPC WebSocket server.
import{createORPCClient}from"@orpc/client";import{ClientRetryPlugin}from"@orpc/client/plugins";import{RPCLink}from"@orpc/client/websocket";import{os}from"@orpc/server";import{RPCHandler}from"@orpc/server/bun-ws";import{WebSocket}from"partysocket";constunhandled: string[]=[];process.on("unhandledRejection",(e: any)=>unhandled.push(`${e?.name}: ${e?.message}`));lethang=false;consthandler=newRPCHandler({subscribe: os.handler(asyncfunction*(){while(true){awaitBun.sleep(300);yield"tick";}}),updateToken: os.handler(async()=>{if(hang)awaitnewPromise(()=>{});}),});constsockets=newSet<any>();constserver=Bun.serve({port: 0,fetch: (req,srv)=>(srv.upgrade(req) ? undefined : newResponse(null,{status: 400})),websocket: {open: (ws)=>voidsockets.add(ws),message: (ws,msg)=>voidhandler.message(ws,msg,{context: {}}),close: (ws)=>{sockets.delete(ws);handler.close(ws);},},});functionconnect(){letattempts=0;constwebsocket=newWebSocket(`ws://localhost:${server.port}`);constclient: any=createORPCClient(newRPCLink({
websocket,clientInterceptors: [async(o)=>(attempts++,o.next())],plugins: [newClientRetryPlugin()],}),);return{ websocket, client,attempts: ()=>attempts};}// 1a. Refresh with the socket closed, as the plugin does it.{const{ websocket, client }=connect();awaitclient.updateToken({token: "a"});websocket.close();setTimeout(async()=>void(awaitclient.updateToken({token: "b"})),0);awaitBun.sleep(200);console.log("1a",unhandled.splice(0));// [ "Error: Cannot send message, WebSocket is not open." ]}// 1b. The connection drops while the update is in flight.{const{ client }=connect();awaitclient.updateToken({token: "a"});hang=true;setTimeout(async()=>void(awaitclient.updateToken({token: "b"})),0);awaitBun.sleep(100);for(constwsofsockets)ws.terminate();awaitBun.sleep(300);hang=false;console.log("1b",unhandled.splice(0));// [ "AbortError: [AsyncIdQueue] Queue[2] was closed or aborted while waiting for pulling." ]}// 2. Subscribe with infinite retry (as the plugin does), then close the socket (closeConnection()).{const{ websocket, client, attempts }=connect();constiterator=awaitclient.subscribe(undefined,{context: {retry: Number.POSITIVE_INFINITY}});constconsumer=(async()=>{forawait(const_ofiterator);return"settled";})();awaitBun.sleep(1000);constbefore=attempts();websocket.close();awaitBun.sleep(9000);console.log("2",attempts()-before,"new attempts in 9 s;",awaitPromise.race([consumer,Bun.sleep(20).then(()=>"pending")]));// 4 new attempts in 9 s; pending}server.stop(true);process.exit(0);
Describe the bug
1. Unhandled rejections from the token refresh
DurableIteratorLinkPlugin refreshes the token in setTimeout(async () => { ... }) and nothing handles that promise:
If the WebSocket is not open when the refresh fires (a background tab whose connection was closed, or partysocket waiting to reconnect), updateToken rejects with Error: Cannot send message, WebSocket is not open.
If the connection drops while the update is in flight, it rejects with AbortError: [AsyncIdQueue] Queue[N] was closed or aborted while waiting for pulling.
Both end up as unhandled rejections in the browser, and error monitoring reports them. Nothing breaks: tokenAndPayload already holds the new token, so the reconnection uses it. The same applies to the retry(...) around next(), which rejects through exit(err) once isFinished is set.
2. A closed iterator keeps retrying every 2 s
When the caller aborts (signal → closeConnection() → websocket.close()), the inner durableClient.subscribe(void 0, { context: { retry: Number.POSITIVE_INFINITY } }) doesn't stop. No signal reaches ClientRetryPlugin, shouldRetry defaults to true, and partysocket's readyState stays CLOSED after close(). So every attempt throws Cannot send message, WebSocket is not open. and gets retried after 2 s, until the page reloads. The consumer's for await never settles either: the pending next() is stuck in the retry loop.
Happy to open a PR against 1.x if the suggested fix below works for you.
Additional context
Suggested fix. What we run as a local patch on 1.15.5, in dist/client/index.mjs. When the update fails, reconnect: the new connection carries the new token. And stop the inner retry once the iterator is closed.
} catch (err) {
if (isFinished) {
exit(err);
}
throw err;
}
+ }).catch((err) => {+ // retry() only gives up through exit(), once the iterator is closed+ if (!isFinished) {+ throw err;+ }
});
+ if (!newTokenAndPayload) {+ return;+ }
const canProactivelyUpdateToken = ...;
tokenAndPayload = newTokenAndPayload;
await refreshTokenBeforeExpire();
if (canProactivelyUpdateToken) {
- await durableClient.updateToken({ token: tokenAndPayload.token });+ // the socket is closed or dropped mid-update; a new connection carries the new token+ await durableClient.updateToken({ token: tokenAndPayload.token }).catch(() => {+ if (!isFinished) {+ websocket.reconnect();+ }+ });
} else {
websocket.reconnect();
}
@@
const iterator_ = await durableClient.subscribe(void 0, {
context: {
- retry: Number.POSITIVE_INFINITY+ retry: Number.POSITIVE_INFINITY,+ // stop retrying once the iterator is closed (abort or return)+ shouldRetry: () => !isFinished
},
With the real plugin, before and after this change (against a fake hub): no unhandled rejection, the reconnection carries the new token, events keep flowing, and a closed iterator makes at most one more attempt and settles. The code is the same on the 1.x branch today (packages/durable-iterator/src/client/plugin.ts). I couldn't find the durable iterator on main (v2), so this is probably a 1.x-only fix.
Logs
1a [ "Error: Cannot send message, WebSocket is not open." ]
1b [ "AbortError: [AsyncIdQueue] Queue[2] was closed or aborted while waiting for pulling." ]
2 4 new attempts in 9 s; pending
The refresh work runs inside a bare setTimeout(async () => { ... }) at plugin.ts:146-193, so any rejection from durableClient.updateToken(...) (plugin.ts:179) or from the retry(...) helper becomes unhandled. Your 1a/1b traces line up exactly.
durableClient.subscribe(void 0, { context: { retry: Number.POSITIVE_INFINITY } }) at plugin.ts:205-210 never receives a stop signal. closeConnection() (plugin.ts:197-201) only sets isFinished, clears the refresh timeout, and closes the socket — it does not reach the inner retry loop, so ClientRetryPlugin keeps retrying every 2 s against a closed socket.
On the proposed fix
shouldRetry: () => !isFinished is valid: it's a supported ClientRetryPluginContext option (packages/client/src/plugins/retry.ts:34) and is re-evaluated per attempt (retry.ts:108), so it sees the closure's current isFinished. That's the cleaner fix for issue 2; forwarding options.signal into the subscribe context would also work since the retry plugin bails on signal.aborted (retry.ts:133-135), but shouldRetry expresses the intent directly.
websocket.reconnect() on a failed proactive update is consistent with how the channel/tags-mismatch branch already behaves (plugin.ts:186).
Two things to settle before a PR
packages/durable-iterator/src/client/plugin.test.ts:283-362 (if refresh token is invalid) currently asserts exactly one unhandled rejection after output.return() (plugin.test.ts:358-361). The retry(...).catch(...) that swallows when isFinished would make that assertion fail, so that test encodes the present behavior and would need to change. Worth confirming that dropping that unhandled rejection is the intended contract.
Those tests mock partysocket (plugin.test.ts:18-28), so 1a/1b/2 can't be reproduced in the current unit suite — a fix should come with an integration-style test using real partysocket, or at least a test that drives the mocked socket's send/readyState to reject.
Scope: confirmed packages/durable-iterator is absent from main (v2), so this is indeed 1.x-only. A targeted PR against 1.x looks appropriate.
Activity
pullfrog commentedon Oct 21, 2026
Thanks for the detailed write-up — I traced both reports through the
1.xsource and can confirm the diagnosis; no duplicate of this exists.Confirmed code paths (
packages/durable-iterator/src/client/plugin.ts):setTimeout(async () => { ... })atplugin.ts:146-193, so any rejection fromdurableClient.updateToken(...)(plugin.ts:179) or from theretry(...)helper becomes unhandled. Your1a/1btraces line up exactly.durableClient.subscribe(void 0, { context: { retry: Number.POSITIVE_INFINITY } })atplugin.ts:205-210never receives a stop signal.closeConnection()(plugin.ts:197-201) only setsisFinished, clears the refresh timeout, and closes the socket — it does not reach the inner retry loop, soClientRetryPluginkeeps retrying every 2 s against a closed socket.On the proposed fix
shouldRetry: () => !isFinishedis valid: it's a supportedClientRetryPluginContextoption (packages/client/src/plugins/retry.ts:34) and is re-evaluated per attempt (retry.ts:108), so it sees the closure's currentisFinished. That's the cleaner fix for issue 2; forwardingoptions.signalinto the subscribe context would also work since the retry plugin bails onsignal.aborted(retry.ts:133-135), butshouldRetryexpresses the intent directly.websocket.reconnect()on a failed proactive update is consistent with how the channel/tags-mismatch branch already behaves (plugin.ts:186).Two things to settle before a PR
packages/durable-iterator/src/client/plugin.test.ts:283-362(if refresh token is invalid) currently asserts exactly one unhandled rejection afteroutput.return()(plugin.test.ts:358-361). Theretry(...).catch(...)that swallows whenisFinishedwould make that assertion fail, so that test encodes the present behavior and would need to change. Worth confirming that dropping that unhandled rejection is the intended contract.partysocket(plugin.test.ts:18-28), so1a/1b/2can't be reproduced in the current unit suite — a fix should come with an integration-style test using realpartysocket, or at least a test that drives the mocked socket'ssend/readyStateto reject.Scope: confirmed
packages/durable-iteratoris absent frommain(v2), so this is indeed1.x-only. A targeted PR against1.xlooks appropriate.deepseek-v4.1-flash(free via Pullfrog for OSS) | 𝕏dinwwwh commentedon Oct 22, 2026
PR is welcome
fix(durable-iterator): handle token refresh failures and stop retryin…
dinwwwh commentedon Oct 22, 2026