Skip to content

Commit da993c5

Browse files
authored
fix(events): start every SSE response once its subscriptions are live (#8700)
* fix(events): start every SSE response once its subscriptions are live Next sends a route's status and headers with the first body chunk, and createSSEStream wrote nothing until an event or the 30 s heartbeat. A client therefore saw no response for up to 30 s after connecting: the desktop inbox doorbell E2E waited 32-34 s for its stream to open in every run, and Sim desktop's doorbell gives up on a response that has not started within 15 s. - createSSEStream writes a `: connected` comment as soon as every subscription is live, which starts the response at once. - PubSubChannel exposes ready(), settled when the process's Redis SUBSCRIBE completes, so a client that reads its state on open cannot miss an event published before the channel was listening. The desktop doorbell, chat status and MCP streams wait on their channels. - The desktop inbox E2E asserts the doorbell opens within the desktop's 15 s handshake and that a ring arrives within 2 s of the open. * fix(events): hold events until the stream opens and bound the wait - Events wait for the opening gate too, so one published while another subscription is still connecting cannot start the response early. - The gate opens at OPEN_DEADLINE_MS (5 s) when a subscription is not live, so a Redis outage degrades to an open stream instead of a silent one. - PubSubChannel subscribes on every ready connection and is not ready again after a dropped one until it has resubscribed. - The desktop inbox E2E compiles the doorbell route with a refused request before timing its open. * fix(events): keep a channel not ready while its subscribe fails or answers a closed connection A failed SUBSCRIBE no longer settles ready(): the stream's opening deadline bounds the wait, and the next connection subscribes again. A subscribe answered after its connection closed is ignored, so it cannot mark a reconnected channel ready before that connection has subscribed. * fix(events): write each stream's events in order and release a stream closed before it opens - Every event goes through one ordered write chain: it is authorized as soon as it arrives, so concurrent events still share one check, but written only after every event before it. An event queued before the stream opened can no longer be overtaken by one that arrived during its authorization. - Closing settles the opening gate as well as clearing its deadline, so a stream closed before it opens is not kept alive by a subscription that never becomes ready. - Tests cover event order, retention after an early close, timer cleanup, and the ready wiring of the mothership and MCP event routes.
1 parent 6713fab commit da993c5

16 files changed

Lines changed: 553 additions & 24 deletions

File tree

‎apps/sim/app/api/desktop/inbox/stream/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
3232
return createSSEStream(request, {
3333
label: 'desktop-inbox',
3434
revalidate: inbox.revalidate,
35-
subscriptions: [{ subscribe: inbox.subscribe }],
35+
subscriptions: [{ subscribe: inbox.subscribe, ready: inbox.ready }],
3636
})
3737
} catch (error) {
3838
if (error instanceof InternalUnauthenticatedError)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
2+
import { mcpPubsubMock, mcpPubsubMockFns } from '@sim/testing/mocks/mcp-pubsub.mock'
3+
import { NextRequest } from 'next/server'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { OPENED_COMMENT } from '@/lib/events/sse-endpoint'
6+
7+
vi.mock('@/lib/mcp/pubsub', () => mcpPubsubMock)
8+
vi.mock('@/lib/mcp/connection-manager', () => ({
9+
mcpConnectionManager: { subscribe: () => () => {} },
10+
}))
11+
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
12+
13+
import { GET } from '@/app/api/mcp/events/route'
14+
15+
describe('MCP tool-change event stream', () => {
16+
beforeEach(() => {
17+
vi.useFakeTimers()
18+
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
19+
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read')
20+
})
21+
22+
afterEach(() => {
23+
vi.useRealTimers()
24+
})
25+
26+
it('opens once tool-change events reach this process', async () => {
27+
let live: () => void = () => {}
28+
const ready = new Promise<void>((resolve) => {
29+
live = resolve
30+
})
31+
mcpPubsubMockFns.mockReady.mockReturnValue(ready)
32+
const response = await GET(new NextRequest('http://localhost/api/mcp/events?workspaceId=ws-1'))
33+
let first: string | undefined
34+
if (!response.body) throw new Error('The event stream has no body')
35+
void response.body
36+
.getReader()
37+
.read()
38+
.then(({ value }) => {
39+
first = new TextDecoder().decode(value)
40+
})
41+
42+
await vi.advanceTimersByTimeAsync(1_000)
43+
expect(first).toBeUndefined()
44+
expect(mcpPubsubMockFns.mockReady).toHaveBeenCalledTimes(2)
45+
46+
live()
47+
await vi.advanceTimersByTimeAsync(0)
48+
expect(first).toBe(OPENED_COMMENT)
49+
})
50+
})

‎apps/sim/app/api/mcp/events/route.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const mcpEventsHandler = createWorkspaceSSE({
3232
})
3333
})
3434
},
35+
ready: async () => mcpPubSub?.ready(),
3536
},
3637
{
3738
subscribe: (workspaceId, send) => {
@@ -45,6 +46,7 @@ const mcpEventsHandler = createWorkspaceSSE({
4546
})
4647
})
4748
},
49+
ready: async () => mcpPubSub?.ready(),
4850
},
4951
],
5052
})

‎apps/sim/app/api/mothership/events/route.test.ts‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
import { NextRequest } from 'next/server'
1717
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
1818
import { OrchestrationError } from '@/lib/core/orchestration/types'
19-
import { HEARTBEAT_INTERVAL_MS } from '@/lib/events/sse-endpoint'
19+
import { HEARTBEAT_INTERVAL_MS, OPENED_COMMENT } from '@/lib/events/sse-endpoint'
2020
import type { ChatStatusEvent } from '@/lib/mothership/chat-status'
2121
import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error'
2222

@@ -41,13 +41,15 @@ function emit(event: ChatStatusEvent) {
4141
handler(event)
4242
}
4343

44+
/** Every chunk after the stream's opening comment. */
4445
async function collect(body: ReadableStream<Uint8Array>, chunks: string[]) {
4546
const reader = body.getReader()
4647
const decoder = new TextDecoder()
4748
while (true) {
4849
const { done, value } = await reader.read()
4950
if (done) return
50-
chunks.push(decoder.decode(value))
51+
const chunk = decoder.decode(value)
52+
if (chunk !== OPENED_COMMENT) chunks.push(chunk)
5153
}
5254
}
5355

@@ -154,6 +156,7 @@ describe('Mothership owner-scoped event stream', () => {
154156
const response = await GET(request('organizationId=org-1'))
155157
const chunks: string[] = []
156158
const collected = collect(response.body!, chunks)
159+
await vi.advanceTimersByTimeAsync(0)
157160
let authorizeDone: (() => void) | undefined
158161
authorize.mockReturnValueOnce(
159162
new Promise<void>((resolve) => {
@@ -171,11 +174,40 @@ describe('Mothership owner-scoped event stream', () => {
171174
expect(chunks).toEqual([])
172175
})
173176

177+
it.each(['workspaceId=ws-1', 'organizationId=org-1'])(
178+
'opens the %s stream once chat status events reach this process',
179+
async (query) => {
180+
let live: () => void = () => {}
181+
mothershipChatStatusMockFns.mockReady.mockReturnValueOnce(
182+
new Promise<void>((resolve) => {
183+
live = resolve
184+
})
185+
)
186+
const response = await GET(request(query))
187+
let first: string | undefined
188+
if (!response.body) throw new Error('The event stream has no body')
189+
void response.body
190+
.getReader()
191+
.read()
192+
.then(({ value }) => {
193+
first = new TextDecoder().decode(value)
194+
})
195+
196+
await vi.advanceTimersByTimeAsync(1_000)
197+
expect(first).toBeUndefined()
198+
199+
live()
200+
await vi.advanceTimersByTimeAsync(0)
201+
expect(first).toBe(OPENED_COMMENT)
202+
}
203+
)
204+
174205
it('preserves workspace status events and excludes organization events', async () => {
175206
const abort = new AbortController()
176207
const response = await GET(request('workspaceId=ws-1', abort.signal))
177208
const chunks: string[] = []
178209
const collected = collect(response.body!, chunks)
210+
await vi.advanceTimersByTimeAsync(0)
179211
emit({ organizationId: 'org-1', userId: 'user-1', chatId: 'org-chat', type: 'created' })
180212
emit({ workspaceId: 'ws-2', chatId: 'other-workspace-chat', type: 'created' })
181213
emit({ workspaceId: 'ws-1', chatId: 'workspace-chat', type: 'renamed' })

‎apps/sim/app/api/mothership/events/route.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const mothershipEventsHandler = createWorkspaceSSE({
4242
})
4343
})
4444
},
45+
ready: async () => chatPubSub?.ready(),
4546
},
4647
],
4748
})
@@ -80,6 +81,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
8081
timestamp: Date.now(),
8182
})
8283
}) ?? (() => {}),
84+
ready: async () => chatPubSub?.ready(),
8385
},
8486
],
8587
})

‎apps/sim/lib/desktop/application/executor.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '@/lib/desktop/executor/constants'
1616
import {
1717
type DesktopInboxChangeReason,
18+
desktopInboxDoorbellReady,
1819
onDesktopInboxDoorbell,
1920
} from '@/lib/desktop/executor/doorbell'
2021
import {
@@ -201,6 +202,7 @@ export const openDesktopInboxStream = defineAuthorizedCredentialUserUseCase({
201202
onDesktopInboxDoorbell(deviceId, (reason: DesktopInboxChangeReason) =>
202203
send('inbox_changed', { reason })
203204
),
205+
ready: desktopInboxDoorbellReady,
204206
}
205207
},
206208
})

‎apps/sim/lib/desktop/executor/doorbell.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ export function ringDesktopInbox(deviceId: string, reason: DesktopInboxChangeRea
4343
}
4444
}
4545

46+
/** Settles once this process hears every ring. */
47+
export function desktopInboxDoorbellReady(): Promise<void> {
48+
return channel().ready()
49+
}
50+
4651
/** Subscribes to one device's doorbell; returns the unsubscribe. */
4752
export function onDesktopInboxDoorbell(
4853
deviceId: string,

‎apps/sim/lib/events/pubsub.test.ts‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { EventEmitter } from 'node:events'
2+
import { redisConfigMockFns } from '@sim/testing/mocks/redis-config.mock'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
const { clients } = vi.hoisted(() => ({
6+
clients: [] as Array<EventEmitter & { subscribed: Array<(err: Error | null) => void> }>,
7+
}))
8+
9+
vi.mock('ioredis', () => ({
10+
default: class extends EventEmitter {
11+
subscribed: Array<(err: Error | null) => void> = []
12+
13+
constructor() {
14+
super()
15+
clients.push(this)
16+
}
17+
18+
subscribe(_channel: string, done: (err: Error | null) => void) {
19+
this.subscribed.push(done)
20+
}
21+
22+
publish = vi.fn(async () => 1)
23+
unsubscribe = vi.fn(async () => undefined)
24+
quit = vi.fn(async () => 'OK')
25+
},
26+
}))
27+
28+
import { createPubSubChannel } from '@/lib/events/pubsub'
29+
30+
/** Whether the promise has settled by the time already-queued work runs. */
31+
async function settled(promise: Promise<void>): Promise<boolean> {
32+
return Promise.race([promise.then(() => true), Promise.resolve().then(() => false)])
33+
}
34+
35+
/** The subscriber connection: the channel opens its publisher first. */
36+
function subscriber() {
37+
const client = clients.at(-1)
38+
if (!client) throw new Error('no Redis client')
39+
return client
40+
}
41+
42+
describe('createPubSubChannel over Redis', () => {
43+
beforeEach(() => {
44+
clients.length = 0
45+
redisConfigMockFns.mockGetConfiguredRedisUrl.mockReturnValue('redis://localhost:6379')
46+
})
47+
48+
it('is ready only once its connection has subscribed', async () => {
49+
const channel = createPubSubChannel({ channel: 'test', label: 'Test' })
50+
expect(await settled(channel.ready())).toBe(false)
51+
52+
subscriber().emit('ready')
53+
expect(await settled(channel.ready())).toBe(false)
54+
55+
subscriber().subscribed[0](null)
56+
expect(await settled(channel.ready())).toBe(true)
57+
})
58+
59+
it('is not ready again until a dropped connection has resubscribed', async () => {
60+
const channel = createPubSubChannel({ channel: 'test', label: 'Test' })
61+
subscriber().emit('ready')
62+
subscriber().subscribed[0](null)
63+
64+
subscriber().emit('close')
65+
expect(await settled(channel.ready())).toBe(false)
66+
67+
subscriber().emit('ready')
68+
subscriber().subscribed[1](null)
69+
expect(await settled(channel.ready())).toBe(true)
70+
})
71+
72+
it('is not ready while its subscribe fails, and retries on the next connection', async () => {
73+
const channel = createPubSubChannel({ channel: 'test', label: 'Test' })
74+
subscriber().emit('ready')
75+
76+
subscriber().subscribed[0](new Error('NOPERM'))
77+
expect(await settled(channel.ready())).toBe(false)
78+
79+
subscriber().emit('close')
80+
subscriber().emit('ready')
81+
subscriber().subscribed[1](null)
82+
expect(await settled(channel.ready())).toBe(true)
83+
})
84+
85+
it('ignores a subscribe answered after its connection closed', async () => {
86+
const channel = createPubSubChannel({ channel: 'test', label: 'Test' })
87+
subscriber().emit('ready')
88+
89+
subscriber().emit('close')
90+
subscriber().subscribed[0](null)
91+
expect(await settled(channel.ready())).toBe(false)
92+
93+
subscriber().emit('ready')
94+
subscriber().subscribed[1](null)
95+
expect(await settled(channel.ready())).toBe(true)
96+
})
97+
})

‎apps/sim/lib/events/pubsub.ts‎

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ const logger = createLogger('PubSub')
1616
export interface PubSubChannel<T> {
1717
publish(event: T): void
1818
subscribe(handler: (event: T) => void): () => void
19+
/**
20+
* Settles once this process receives the channel's publications; anything published before
21+
* then reaches no subscriber here. Stays pending while the channel cannot subscribe, so a caller
22+
* that must not wait indefinitely bounds the wait.
23+
*/
24+
ready(): Promise<void>
1925
dispose(): void
2026
}
2127

@@ -29,6 +35,12 @@ class RedisPubSubChannel<T> implements PubSubChannel<T> {
2935
private sub: Redis
3036
private handlers = new Set<(event: T) => void>()
3137
private disposed = false
38+
/** Whether the current connection has subscribed; a dropped connection has to again. */
39+
private listening = false
40+
/** Counts closed connections, so a subscribe answered after its connection closed is ignored. */
41+
private closedConnections = 0
42+
private subscribed: Promise<void> = Promise.resolve()
43+
private markSubscribed: () => void = noop
3244

3345
constructor(
3446
redisUrl: string,
@@ -56,12 +68,28 @@ class RedisPubSubChannel<T> implements PubSubChannel<T> {
5668
this.pub.on('connect', () => logger.info(`${config.label} publish client connected`))
5769
this.sub.on('connect', () => logger.info(`${config.label} subscribe client connected`))
5870

59-
this.sub.subscribe(config.channel, (err) => {
60-
if (err) {
61-
logger.error(`Failed to subscribe to ${config.label} channel:`, err)
62-
} else {
71+
this.awaitSubscription()
72+
// Subscribes on every ready connection: ioredis resubscribes after a reconnect on its own but
73+
// does not report when that lands, and SUBSCRIBE is idempotent. A failed subscribe leaves the
74+
// channel not ready; the next connection tries again.
75+
this.sub.on('ready', () => {
76+
const connection = this.closedConnections
77+
this.sub.subscribe(config.channel, (err) => {
78+
if (connection !== this.closedConnections) return
79+
if (err) {
80+
logger.error(`Failed to subscribe to ${config.label} channel:`, err)
81+
return
82+
}
83+
this.listening = true
6384
logger.info(`Subscribed to ${config.label} channel`)
64-
}
85+
this.markSubscribed()
86+
})
87+
})
88+
this.sub.on('close', () => {
89+
this.closedConnections += 1
90+
if (!this.listening) return
91+
this.listening = false
92+
this.awaitSubscription()
6593
})
6694

6795
this.sub.on('message', (channel: string, message: string) => {
@@ -95,6 +123,16 @@ class RedisPubSubChannel<T> implements PubSubChannel<T> {
95123
}
96124
}
97125

126+
ready(): Promise<void> {
127+
return this.subscribed
128+
}
129+
130+
private awaitSubscription(): void {
131+
this.subscribed = new Promise((resolve) => {
132+
this.markSubscribed = resolve
133+
})
134+
}
135+
98136
dispose(): void {
99137
this.disposed = true
100138
this.handlers.clear()
@@ -130,6 +168,10 @@ class LocalPubSubChannel<T> implements PubSubChannel<T> {
130168
}
131169
}
132170

171+
ready(): Promise<void> {
172+
return Promise.resolve()
173+
}
174+
133175
dispose(): void {
134176
this.emitter.removeAllListeners()
135177
logger.info(`${this.config.label} local pub/sub disposed`)

0 commit comments

Comments
 (0)