@@ -25,6 +25,7 @@ import type {
2525 WorkflowRevertedBroadcast ,
2626 WorkflowUpdatedBroadcast ,
2727} from '@sim/realtime-protocol/events'
28+ import { getErrorMessage } from '@sim/utils/errors'
2829import { generateId } from '@sim/utils/id'
2930import { randomFloat } from '@sim/utils/random'
3031import { backoffWithJitter , parseRetryAfter } from '@sim/utils/retry'
@@ -430,6 +431,51 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
430431 timestamp : new Date ( ) . toISOString ( ) ,
431432 } )
432433
434+ /**
435+ * Reports a realtime connection that is not coming back.
436+ *
437+ * A socket that never connects raises no exception anywhere — every
438+ * failure path here is handled, so error tracking sees nothing and the
439+ * only user-visible trace is a "Reconnecting…" toast. Socket.IO then
440+ * retries the same URL forever, so the failure is both permanent and
441+ * silent. This is the one signal that distinguishes "the realtime
442+ * service is down" from "this client resolved the wrong URL", which is
443+ * why the origin is reported alongside the reason.
444+ *
445+ * Fires at most once per socket instance, at the point the toast
446+ * appears, rather than once per retry.
447+ *
448+ * Called once per failed attempt: from `connect_error`, and from the auth
449+ * callback when it ends an attempt itself because no token could be minted
450+ * (that attempt never reaches `connect_error`). `connect_error` is the only
451+ * socket handler that sees exactly one event per attempt: a failed
452+ * *reconnect* also emits the manager's `reconnect_error` (`manager.reconnect()` calls
453+ * `open()`, whose error path emits `error` — which the socket re-emits as
454+ * `connect_error` — and then emits `reconnect_error` itself), so counting
455+ * in both would advance twice per try and trip the threshold early.
456+ */
457+ const reportPersistentConnectFailure = ( reason : string ) => {
458+ connectFailureCountRef . current += 1
459+ if (
460+ connectFailureReportedRef . current ||
461+ connectFailureCountRef . current < CONNECT_FAILURES_BEFORE_REPORT
462+ ) {
463+ return
464+ }
465+ connectFailureReportedRef . current = true
466+
467+ captureClientEvent ( 'realtime_connection_failing' , {
468+ socket_origin : socketOrigin ,
469+ expected_socket_origin_configured : Boolean ( getEnv ( 'NEXT_PUBLIC_SOCKET_URL' ) ?. trim ( ) ) ,
470+ attempts : connectFailureCountRef . current ,
471+ reason,
472+ } )
473+ }
474+
475+ // Each auth callback owns one handshake; a token request that outlives it must
476+ // not end the attempt that replaced it.
477+ let authAttemptGeneration = 0
478+
433479 const socketInstance = io ( socketUrl , {
434480 transports : [ 'websocket' , 'polling' ] ,
435481 withCredentials : true ,
@@ -442,6 +488,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
442488 // otherwise one early 401 would still be set when a later, unrelated
443489 // denial exhausts the retry budget.
444490 sessionRejectedRef . current = false
491+ const attempt = ++ authAttemptGeneration
445492 try {
446493 const freshToken = await generateSocketToken ( )
447494 cb ( { token : freshToken } )
@@ -453,7 +500,13 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
453500 cb ( { token : null } )
454501 return
455502 }
456- if ( ! socketInstance . active || socketInstance . connected ) return
503+ if (
504+ attempt !== authAttemptGeneration ||
505+ ! socketInstance . active ||
506+ socketInstance . connected
507+ ) {
508+ return
509+ }
457510
458511 // Withholding `cb` would park the transport until the server's 45 s connect
459512 // timeout, and the Manager resets its backoff on every open, so pace it here.
@@ -477,6 +530,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
477530 setIsConnecting ( false )
478531 setCurrentSocketId ( null )
479532 setIsReconnecting ( true )
533+ reportPersistentConnectFailure ( getErrorMessage ( error ) )
480534 logger . warn ( 'Socket token unavailable, reconnecting after delay' , {
481535 attempt : tokenRetryAttemptsRef . current ,
482536 delayMs : Math . round ( delayMs ) ,
@@ -490,45 +544,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
490544 } ,
491545 } )
492546
493- /**
494- * Reports a realtime connection that is not coming back.
495- *
496- * A socket that never connects raises no exception anywhere — every
497- * failure path here is handled, so error tracking sees nothing and the
498- * only user-visible trace is a "Reconnecting…" toast. Socket.IO then
499- * retries the same URL forever, so the failure is both permanent and
500- * silent. This is the one signal that distinguishes "the realtime
501- * service is down" from "this client resolved the wrong URL", which is
502- * why the origin is reported alongside the reason.
503- *
504- * Fires at most once per socket instance, at the point the toast
505- * appears, rather than once per retry.
506- *
507- * Called from `connect_error` and nowhere else. That is the only handler
508- * that sees exactly one event per attempt: a failed *reconnect* also
509- * emits the manager's `reconnect_error` (`manager.reconnect()` calls
510- * `open()`, whose error path emits `error` — which the socket re-emits as
511- * `connect_error` — and then emits `reconnect_error` itself), so counting
512- * in both would advance twice per try and trip the threshold early.
513- */
514- const reportPersistentConnectFailure = ( reason : string ) => {
515- connectFailureCountRef . current += 1
516- if (
517- connectFailureReportedRef . current ||
518- connectFailureCountRef . current < CONNECT_FAILURES_BEFORE_REPORT
519- ) {
520- return
521- }
522- connectFailureReportedRef . current = true
523-
524- captureClientEvent ( 'realtime_connection_failing' , {
525- socket_origin : socketOrigin ,
526- expected_socket_origin_configured : Boolean ( getEnv ( 'NEXT_PUBLIC_SOCKET_URL' ) ?. trim ( ) ) ,
527- attempts : connectFailureCountRef . current ,
528- reason,
529- } )
530- }
531-
532547 socketInstance . on ( 'connect' , ( ) => {
533548 setIsConnected ( true )
534549 setIsConnecting ( false )
0 commit comments