Skip to content

Commit 01b4ff6

Browse files
committed
Merge remote-tracking branch 'origin/staging' into codex/shared-resource-lifecycle
2 parents 8244ef2 + 2008383 commit 01b4ff6

2 files changed

Lines changed: 117 additions & 36 deletions

File tree

‎apps/sim/app/workspace/providers/socket-provider.tsx‎

Lines changed: 104 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ import type {
2525
WorkflowRevertedBroadcast,
2626
WorkflowUpdatedBroadcast,
2727
} from '@sim/realtime-protocol/events'
28+
import { getErrorMessage } from '@sim/utils/errors'
2829
import { generateId } from '@sim/utils/id'
29-
import { backoffWithJitter } from '@sim/utils/retry'
30+
import { randomFloat } from '@sim/utils/random'
31+
import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
3032
import { useQueryClient } from '@tanstack/react-query'
3133
import { useParams } from 'next/navigation'
3234
import type { Socket } from 'socket.io-client'
@@ -70,6 +72,25 @@ const MAX_AUTH_RETRY_ATTEMPTS = 5
7072
const AUTH_RETRY_BASE_MS = 1000
7173
const AUTH_RETRY_MAX_MS = 30000
7274

75+
/**
76+
* Upper bound on an honored socket-token `Retry-After`. The token route's IP
77+
* bucket refills over 60 s, so the default 30 s cap would retry into a 429.
78+
*/
79+
const SOCKET_TOKEN_RETRY_AFTER_MAX_MS = 5 * 60_000
80+
81+
/** Up to +20% spread above a `Retry-After` floor, matching `backoffWithJitter`'s band. */
82+
const SOCKET_TOKEN_RETRY_AFTER_JITTER = 0.2
83+
84+
/** A socket-token mint that failed for a retryable reason, with the server's `Retry-After`. */
85+
class SocketTokenUnavailableError extends Error {
86+
readonly retryAfterMs: number | null
87+
88+
constructor(retryAfterMs: number | null) {
89+
super('Failed to generate socket token')
90+
this.retryAfterMs = retryAfterMs
91+
}
92+
}
93+
7394
function getTabSessionId(): string {
7495
if (typeof window === 'undefined') return ''
7596

@@ -185,6 +206,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
185206
const joinRetryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
186207
const authRetryAttemptsRef = useRef(0)
187208
const authRetryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
209+
const tokenRetryAttemptsRef = useRef(0)
188210
const sessionRejectedRef = useRef(false)
189211
const connectFailureCountRef = useRef(0)
190212
const connectFailureReportedRef = useRef(false)
@@ -367,7 +389,9 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
367389
if (res.status === 401) {
368390
throw new Error('Authentication required')
369391
}
370-
throw new Error('Failed to generate socket token')
392+
throw new SocketTokenUnavailableError(
393+
parseRetryAfter(res.headers.get('retry-after'), SOCKET_TOKEN_RETRY_AFTER_MAX_MS)
394+
)
371395
}
372396
const body = await res.json().catch(() => ({}))
373397
const token = body?.token
@@ -407,33 +431,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
407431
timestamp: new Date().toISOString(),
408432
})
409433

410-
const socketInstance = io(socketUrl, {
411-
transports: ['websocket', 'polling'],
412-
withCredentials: true,
413-
reconnectionAttempts: Number.POSITIVE_INFINITY,
414-
reconnectionDelay: 1000,
415-
reconnectionDelayMax: 30000,
416-
timeout: 10000,
417-
auth: async (cb) => {
418-
// Reset per attempt so the flag describes THIS handshake only —
419-
// otherwise one early 401 would still be set when a later, unrelated
420-
// denial exhausts the retry budget.
421-
sessionRejectedRef.current = false
422-
try {
423-
const freshToken = await generateSocketToken()
424-
cb({ token: freshToken })
425-
} catch (error) {
426-
logger.error('Failed to generate fresh token for connection:', error)
427-
if (error instanceof Error && error.message === 'Authentication required') {
428-
// True auth failure - pass null token, server will reject with "Authentication required"
429-
sessionRejectedRef.current = true
430-
cb({ token: null })
431-
}
432-
// For server errors, don't call cb - connection will timeout and Socket.IO will retry
433-
}
434-
},
435-
})
436-
437434
/**
438435
* Reports a realtime connection that is not coming back.
439436
*
@@ -448,9 +445,11 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
448445
* Fires at most once per socket instance, at the point the toast
449446
* appears, rather than once per retry.
450447
*
451-
* Called from `connect_error` and nowhere else. That is the only handler
452-
* that sees exactly one event per attempt: a failed *reconnect* also
453-
* emits the manager's `reconnect_error` (`manager.reconnect()` calls
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
454453
* `open()`, whose error path emits `error` — which the socket re-emits as
455454
* `connect_error` — and then emits `reconnect_error` itself), so counting
456455
* in both would advance twice per try and trip the threshold early.
@@ -473,11 +472,84 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
473472
})
474473
}
475474

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+
479+
const socketInstance = io(socketUrl, {
480+
transports: ['websocket', 'polling'],
481+
withCredentials: true,
482+
reconnectionAttempts: Number.POSITIVE_INFINITY,
483+
reconnectionDelay: 1000,
484+
reconnectionDelayMax: 30000,
485+
timeout: 10000,
486+
auth: async (cb) => {
487+
// Reset per attempt so the flag describes THIS handshake only —
488+
// otherwise one early 401 would still be set when a later, unrelated
489+
// denial exhausts the retry budget.
490+
sessionRejectedRef.current = false
491+
const attempt = ++authAttemptGeneration
492+
try {
493+
const freshToken = await generateSocketToken()
494+
cb({ token: freshToken })
495+
} catch (error) {
496+
logger.error('Failed to generate fresh token for connection:', error)
497+
if (error instanceof Error && error.message === 'Authentication required') {
498+
// True auth failure - pass null token, server will reject with "Authentication required"
499+
sessionRejectedRef.current = true
500+
cb({ token: null })
501+
return
502+
}
503+
if (
504+
attempt !== authAttemptGeneration ||
505+
!socketInstance.active ||
506+
socketInstance.connected
507+
) {
508+
return
509+
}
510+
511+
// Withholding `cb` would park the transport until the server's 45 s connect
512+
// timeout, and the Manager resets its backoff on every open, so pace it here.
513+
tokenRetryAttemptsRef.current += 1
514+
const retryAfterMs =
515+
error instanceof SocketTokenUnavailableError ? error.retryAfterMs : null
516+
const backoffMs = backoffWithJitter(tokenRetryAttemptsRef.current, null, {
517+
baseMs: AUTH_RETRY_BASE_MS,
518+
maxMs: AUTH_RETRY_MAX_MS,
519+
})
520+
// Spread tabs behind one IP that share the same 429 window.
521+
const delayMs =
522+
retryAfterMs === null
523+
? backoffMs
524+
: Math.max(
525+
backoffMs,
526+
retryAfterMs * (1 + SOCKET_TOKEN_RETRY_AFTER_JITTER * randomFloat())
527+
)
528+
socketInstance.disconnect()
529+
setIsConnected(false)
530+
setIsConnecting(false)
531+
setCurrentSocketId(null)
532+
setIsReconnecting(true)
533+
reportPersistentConnectFailure(getErrorMessage(error))
534+
logger.warn('Socket token unavailable, reconnecting after delay', {
535+
attempt: tokenRetryAttemptsRef.current,
536+
delayMs: Math.round(delayMs),
537+
})
538+
clearAuthRetryTimeout()
539+
authRetryTimeoutRef.current = setTimeout(() => {
540+
authRetryTimeoutRef.current = null
541+
socketInstance.connect()
542+
}, delayMs)
543+
}
544+
},
545+
})
546+
476547
socketInstance.on('connect', () => {
477548
setIsConnected(true)
478549
setIsConnecting(false)
479550
setIsReconnecting(false)
480551
authRetryAttemptsRef.current = 0
552+
tokenRetryAttemptsRef.current = 0
481553
connectFailureCountRef.current = 0
482554
connectFailureReportedRef.current = false
483555
clearAuthRetryTimeout()

‎apps/sim/scripts/test-mobile-e2e.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,15 @@ async function seed(): Promise<string> {
263263
return encodeURIComponent(`${sessionToken}.${await makeSignature(sessionToken, authSecret)}`)
264264
}
265265

266+
/**
267+
* The rendered workspace content shell. While a streamed page is still arriving, React parks the
268+
* next segment's markup, shell included, in a hidden `<div id="S:n">` until it swaps it in, so the
269+
* class alone can match a second, hidden copy.
270+
*/
271+
function contentShell(page: Page): Locator {
272+
return page.locator('.workspace-content-shell').filter({ visible: true })
273+
}
274+
266275
async function visit(page: Page, route: string) {
267276
const path = `/workspace/${workspaceId}/${route}`
268277
const [response] = await Promise.all([
@@ -280,7 +289,7 @@ async function visit(page: Page, route: string) {
280289
new URL(page.url()).pathname.startsWith(`/workspace/${workspaceId}/`),
281290
'Session was rejected'
282291
)
283-
await expect(page.locator('.workspace-content-shell')).toBeVisible()
292+
await expect(contentShell(page)).toBeVisible()
284293
await expect(page.getByText('Application error', { exact: false })).toHaveCount(0)
285294
const contentByRoute: Record<string, Locator> = {
286295
home: page.getByRole('textbox', { name: 'Message', exact: true }),
@@ -337,7 +346,7 @@ async function expectNoOverflow(page: Page) {
337346
}
338347
)
339348
.toBeLessThanOrEqual(1)
340-
const shell = await page.locator('.workspace-content-shell').boundingBox()
349+
const shell = await contentShell(page).boundingBox()
341350
assert(shell && shell.width > 0 && shell.x >= -1, 'The content must have usable horizontal space')
342351
assert(
343352
shell.x + shell.width <= (page.viewportSize()?.width ?? 0) + 1,
@@ -717,7 +726,7 @@ async function exerciseViewport(
717726
timeout: 120_000,
718727
})
719728
await expect(sidebar).toBeHidden()
720-
await expect(page.locator('.workspace-content-shell')).toBeVisible()
729+
await expect(contentShell(page)).toBeVisible()
721730
await expect(
722731
page.getByRole('button', { name: `Actions for ${folderName}`, exact: true })
723732
).toBeVisible({ timeout: 120_000 })
@@ -1114,7 +1123,7 @@ async function exerciseViewport(
11141123
await expect
11151124
.poll(async () => {
11161125
const canvas = await page.locator('.react-flow').boundingBox()
1117-
const content = await page.locator('.workspace-content-shell').boundingBox()
1126+
const content = await contentShell(page).boundingBox()
11181127
return canvas && content ? canvas.width / content.width : 0
11191128
})
11201129
.toBeGreaterThan(0.95)

0 commit comments

Comments
 (0)