Skip to content

Commit 76c79d1

Browse files
ggazzoclaude
andcommitted
fix(sdk): catch async throws from bridged frames so the queue keeps draining
Meteor's _streamHandlers.onMessage returns a Promise. Sync try/catch around the bridge call doesn't catch throws inside _process_updated ("No callback invoker for method N" when a stale frame arrives) — the throw escapes as an unhandled rejection and stops Meteor's frame queue, so the next login's result never gets processed and the page stays on /login. Revert the prior _methodInvokers gating (it dropped legitimate login result frames in the same cycle) and instead capture the async rejection at the bridge boundary so individual bad frames don't poison subsequent ones. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f6cd3b7 commit 76c79d1

1 file changed

Lines changed: 19 additions & 27 deletions

File tree

apps/meteor/client/meteor/overrides/ddpSdkCollectionBridge.ts

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,6 @@ const SUBSCRIPTION_LIFECYCLE_FRAMES = new Set(['ready', 'nosub']);
3939
// bridge so SDK's own callAsync flows aren't surfaced into Meteor.
4040
const isSdkInternalId = (id: unknown): boolean => typeof id === 'string' && id.startsWith('rc-ddp-client-');
4141

42-
const getMeteorMethodInvokers = (): Record<string, unknown> => {
43-
return (Meteor.connection as unknown as { _methodInvokers?: Record<string, unknown> })._methodInvokers ?? {};
44-
};
45-
46-
const meteorHasInvoker = (id: unknown): boolean => typeof id === 'string' && id in getMeteorMethodInvokers();
47-
4842
const shouldBridgeToMeteor = (frame: ParsedDdpFrame): boolean => {
4943
if (!frame || typeof frame.msg !== 'string') return false;
5044

@@ -53,29 +47,15 @@ const shouldBridgeToMeteor = (frame: ParsedDdpFrame): boolean => {
5347
}
5448

5549
if (frame.msg === 'result') {
56-
if (isSdkInternalId(frame.id)) return false;
57-
// Drop result frames whose Meteor invoker is gone — feeding them
58-
// surfaces as "Received method result but no methods outstanding" and
59-
// can race with `updated` to leave Meteor's _processOneDataMessage in a
60-
// half-processed state (force-logout cycles invalidate the in-flight
61-
// method's invoker before its result returns over the SDK socket).
62-
return meteorHasInvoker(frame.id);
50+
return !isSdkInternalId(frame.id);
6351
}
6452
if (frame.msg === 'updated') {
6553
const methods = Array.isArray(frame.methods) ? (frame.methods as unknown[]) : [];
66-
if (methods.length === 0) return false;
6754
// If any of the methodIds in the `updated` frame is SDK-internal, drop
6855
// the whole frame: Meteor processes every id and would throw on the
6956
// first miss. In practice an `updated` frame carries ids from a single
7057
// originating method call, so this is all-or-nothing anyway.
71-
if (methods.some(isSdkInternalId)) return false;
72-
// Same defence as for `result` — bridge only when Meteor still has an
73-
// invoker for every methodId. Without this, `_process_updated` throws
74-
// "No callback invoker for method N" out of an async generator and the
75-
// throw escapes the try/catch below as an unhandled rejection,
76-
// aborting Meteor's frame queue (the next login's result never lands,
77-
// userId stays null, and the page is stuck on /login).
78-
return methods.every(meteorHasInvoker);
58+
return methods.length > 0 && !methods.some(isSdkInternalId);
7959
}
8060

8161
return false;
@@ -89,12 +69,24 @@ export const installDdpSdkCollectionBridge = (): void => {
8969
ddp.onMessage((frame) => {
9070
if (!shouldBridgeToMeteor(frame)) return;
9171

92-
// Guard against frames that would collide with Meteor's own subscription
93-
// ids. DDPSDK generates its own ids (rc-ddp-client-<n>); Meteor.connection's
94-
// invokers ignore frames whose id is not in its tables, so a plain
95-
// re-feed is safe.
72+
// `_streamHandlers.onMessage` returns a Promise (the message handler is an
73+
// async generator). A throw inside the inner `_process_updated` /
74+
// `_process_result` (e.g. "No callback invoker for method N" when a
75+
// stale frame arrives after a force-logout cycle invalidates the
76+
// invoker) would otherwise escape this scope as an unhandled rejection,
77+
// aborting Meteor's frame queue and leaving subsequent login result
78+
// frames unprocessed. Wrap the call so both sync throws and async
79+
// rejections are contained — Meteor keeps draining the queue even when
80+
// individual frames hit dead invokers.
9681
try {
97-
Meteor.connection._streamHandlers.onMessage(DDPCommon.stringifyDDP(frame as Parameters<typeof DDPCommon.stringifyDDP>[0]));
82+
const result = Meteor.connection._streamHandlers.onMessage(
83+
DDPCommon.stringifyDDP(frame as Parameters<typeof DDPCommon.stringifyDDP>[0]),
84+
) as unknown;
85+
if (result && typeof (result as Promise<unknown>).then === 'function') {
86+
(result as Promise<unknown>).catch((err) => {
87+
console.warn('[ddpSdk] bridge frame drop (async)', frame.msg, err);
88+
});
89+
}
9890
} catch (err) {
9991
console.warn('[ddpSdk] bridge frame drop', frame.msg, err);
10092
}

0 commit comments

Comments
 (0)