fix(event-hub): register reply subscription when reusing an open socket#386
Conversation
SimpleSocketIOClient.connect() is a singleton keyed on
(serverUrl, apiUser, apiKey), so a second EventHub built with the
same credentials reuses an already-open socket. The connect handler
that registers each hub's topic=ftrack.meta.reply subscription was
wired via socketIo.on("connect", ...) inside EventHub#connect — but
the "connect" event had already fired by the time the second hub
attached, so its listener never ran. With the server enforcing
strict id-equality on the reply target, replies addressed to the
second hub matched zero server-side subscriptions and were silently
dropped, surfacing as publishAndWaitForReply timeouts whenever two
Sessions shared credentials.
Run _onSocketConnected() synchronously inside connect() when the
underlying socket is already connected so late-arriving hubs still
register their reply subscription. The handler is idempotent (it
catches NotUniqueError) so it stays safe for the regular reconnect
path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Hey @seebergs — the result you got ( What you've set upTwo Sessions, same credentials. Each hub then registers a callback for
These are two independent subscriptions on the event server. Crucially, subscriptions are global topic filters — they're not scoped to "hub A's events" or "hub B's events". They both say to the server: "whenever anyone publishes What happens when hub A publishes its ping
Which one arrives first? A's. Both The exact same thing happens when hub B publishes:
Hence Why this isn't a bugThe intuition "I called A useful sanity check: imagine running this code in two separate browser tabs, both logged in as the same user. Tab A publishes a ping → server fans out → both tabs receive and reply → tab A gets the first reply back. Identical behaviour. The socket-sharing in the singleton case isn't what causes the "cross-talk" — it's just pub/sub fan-out, which would happen across any number of sockets. Also: What this PR fixes vs. what it doesn't✅ Fixes: the second hub on a shared socket now reliably tells the server about its ❌ Doesn't change: the pub/sub fan-out semantics. If two hubs subscribe to the same topic, both fire — that's by design, and changing it would mean changing the subscription protocol, not the socket layer. A test that targets this PR specificallyIf you want to manually verify the PR's actual fix in isolation, try this — only one subscriber, and a small delay so the second hub is constructed after the shared socket has opened: async function test() {
const a = new Session('', '', '', { autoConnectEventHub: true });
await new Promise(r => setTimeout(r, 1000)); // let the shared socket finish opening
const b = new Session('', '', '', { autoConnectEventHub: true });
a.eventHub.subscribe("topic=ftrack.test.ping", () => "pong");
// Pre-fix: hangs for 30s, then throws EventServerReplyTimeoutError —
// because hub B never registered its reply subscription
// with the server, so the reply targeted at hub B was
// dropped server-side.
// Post-fix: resolves quickly with "pong".
const res = await b.eventHub.publishAndWaitForReply(
new Event("ftrack.test.ping", {}),
);
return res.data;
}Happy to dig further if anything's still murky. |
Changes
When a JS process constructs two or more
new Session(...)instances with the sameserverUrl+apiUser+apiKey, the second-and-onward Sessions silently lostpublishAndWaitForReplyreplies, surfacing as timeouts. In a 50/50 split between two Sessions, exactly half the calls timed out.Root cause
SimpleSocketIOClient.connect()is a singleton keyed on(serverUrl, apiUser, apiKey)(source/simple_socketio.ts:85), so a secondEventHubbuilt with the same credentials reuses an already-open socket. EachEventHubgenerates its own_idand registerstopic=ftrack.meta.replyinside_onSocketConnected, which is wired viasocketIo.on("connect", ...). By the time the second hub attaches that listener, the"connect"event has already fired and won't fire again, so the listener never runs — the hub never tells the server about a subscription with its own_id. The event server enforces strict id-equality on the reply target, so replies addressed to the second hub match zero server-side subscriptions and are dropped.Fix
EventHub#connect()now invokes_onSocketConnected()synchronously when the underlying socket is already connected, so late-arriving hubs still register their reply subscription. The handler is idempotent (it catchesNotUniqueError), so it remains safe on the existing reconnect path.Tests
Three new tests in
test/event_hub.test.tsunderdescribe("EventHub sharing a SimpleSocketIOClient singleton"):publishAndWaitForReplyroutes replies to the correct hub on a shared socket — drives two hubs publishing concurrently, fires simulated reply events back through the shared socket, asserts each hub's promise resolves with its own reply._onSocketConnectedfor both hubs without duplicating local subscribers — confirms the fast-path composes cleanly with the existing reconnect flow.All three fail in the expected places when the fix is reverted.
Resolves F-990
Test
Automated coverage above. To verify manually: