realtimeBroker.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. import { mkdtempSync, readFileSync } from 'node:fs'
  2. import { join } from 'node:path'
  3. import { tmpdir } from 'node:os'
  4. import { once } from 'node:events'
  5. import { WebSocketServer, type WebSocket } from 'ws'
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import { RealtimeBroker, type RealtimeActivity, type RealtimePrincipal, type StreamEntry } from '../../src/server/realtimeBroker.js'
  8. import { RealtimeReceipts } from '../../src/server/realtimeReceipts.js'
  9. const cleanup: Array<() => void> = []
  10. afterEach(() => cleanup.splice(0).reverse().forEach(f => f()))
  11. async function fixture() {
  12. const home = mkdtempSync(join(tmpdir(), 'yaoyao-realtime-test-'))
  13. const ws = new WebSocketServer({ port: 0, host: '127.0.0.1' })
  14. await once(ws, 'listening')
  15. cleanup.push(() => { for (const c of ws.clients) c.terminate(); ws.close() })
  16. const commands: any[] = []
  17. let count = 0
  18. let seq = 0, truncate = false, dropPrompt = false
  19. const history: any[] = []
  20. let peer: WebSocket
  21. ws.on('connection', socket => {
  22. peer = socket; count++
  23. socket.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready', payload: { replay_epoch: 'test' } } }))
  24. socket.on('message', raw => {
  25. const f = JSON.parse(raw.toString()); commands.push(f)
  26. if (f.method === 'prompt.submit' && dropPrompt) { socket.terminate(); return }
  27. if (f.method === 'session.events.since') {
  28. socket.send(JSON.stringify({ id: f.id, result: { epoch: 'test', truncated: truncate,
  29. events: history.filter(e => e.seq > f.params.last_seen) } }))
  30. } else if (f.method === 'session.branch') {
  31. socket.send(JSON.stringify({ id: f.id, result: { session_id: 'runtime-branch', stored_session_id: 'stored-branch' } }))
  32. } else if (f.method === 'session.create' || f.method === 'session.resume') {
  33. socket.send(JSON.stringify({ id: f.id, result: { session_id: 'runtime-1', stored_session_id: 'stored-1', running: false } }))
  34. } else {
  35. setTimeout(() => socket.send(JSON.stringify({ id: f.id, result: { status: 'submitted' } })), 20)
  36. }
  37. })
  38. })
  39. let now = Date.now()
  40. const activities: RealtimeActivity[] = []
  41. const broker = new RealtimeBroker(home, () => now, change => activities.push(change))
  42. cleanup.push(() => broker.close())
  43. const principal = (key: string): RealtimePrincipal => ({ key, upstreamKey: 'one-service', paired: false,
  44. valid: () => true, url: async () => new URL(`ws://127.0.0.1:${(ws.address() as any).port}`) })
  45. return { home, broker, principal, commands, activities, count: () => count, advance: (n: number) => { now += n },
  46. disconnect: () => peer!.terminate(), truncate: () => { truncate = true }, dropPrompt: () => { dropPrompt = true },
  47. emit: (type: string, payload: unknown) => {
  48. const params = { type, session_id: 'runtime-1', seq: ++seq, payload }; history.push(params)
  49. if (peer!.readyState === 1) peer!.send(JSON.stringify({ method: 'event', params }))
  50. } }
  51. }
  52. const resume = { method: 'session.resume', params: { session_id: 'stored-1', profile: 'default' } }
  53. describe('realtime broker', () => {
  54. it('shares upstream across subscribers and isolates unregistered routes', async () => {
  55. const f = await fixture()
  56. const a = await f.broker.create(f.principal('alice'), 'chat')
  57. const b = await f.broker.create(f.principal('bob'), 'chat')
  58. const c = await f.broker.create(f.principal('alice'), 'chat')
  59. await f.broker.command(a, 'resume-a', resume)
  60. await f.broker.command(c, 'resume-c', resume)
  61. const seenA: StreamEntry[] = [], seenB: StreamEntry[] = [], seenC: StreamEntry[] = []
  62. f.broker.subscribe(a, undefined, x => seenA.push(x)); f.broker.subscribe(b, undefined, x => seenB.push(x)); f.broker.subscribe(c, undefined, x => seenC.push(x))
  63. f.emit('message.delta', { text: '你好' })
  64. await new Promise(r => setTimeout(r, 30))
  65. expect(f.count()).toBe(1)
  66. expect(seenA.some(x => x.data.includes('你好'))).toBe(true)
  67. expect(seenC.some(x => x.data.includes('你好'))).toBe(true)
  68. expect(seenB.some(x => x.data.includes('你好'))).toBe(false)
  69. expect(() => f.broker.get(f.principal('bob'), a.id)).toThrow('Channel not found')
  70. })
  71. it('deduplicates simultaneous commands and rejects a changed payload', async () => {
  72. const f = await fixture(), p = f.principal('alice')
  73. const c = await f.broker.create(p, 'chat')
  74. await f.broker.command(c, 'open', resume)
  75. const prompt = { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'secret prompt' } }
  76. const [one, two] = await Promise.all([f.broker.command(c, 'outbox-1', prompt), f.broker.command(c, 'outbox-1', prompt)])
  77. expect(one).toEqual(two); expect(one.state).toBe('confirmed')
  78. expect(f.commands.filter(x => x.method === 'prompt.submit')).toHaveLength(1)
  79. expect(() => f.broker.command(c, 'outbox-1', { ...prompt, params: { ...prompt.params, text: 'different' } })).toThrow('conflict')
  80. expect(() => f.broker.receipt(f.principal('bob'), 'outbox-1')).toThrow('Receipt not found')
  81. for (const file of ['realtime-receipts.sqlite3', 'realtime-receipts.sqlite3-wal']) expect(readFileSync(join(f.home, file)).includes(Buffer.from('secret prompt'))).toBe(false)
  82. })
  83. it('keeps upstream alive after downstream detach and replays exact cursor', async () => {
  84. const f = await fixture(), p = f.principal('alice')
  85. const c = await f.broker.create(p, 'chat')
  86. await f.broker.command(c, 'open', resume)
  87. const entries: StreamEntry[] = []
  88. const off = f.broker.subscribe(c, undefined, x => entries.push(x))
  89. const cursor = entries.at(-1)!.id
  90. off(); f.emit('message.start', {}); f.emit('message.delta', { text: 'background' })
  91. await new Promise(r => setTimeout(r, 30))
  92. const replay: StreamEntry[] = []
  93. f.broker.subscribe(c, cursor, x => replay.push(x))
  94. expect(replay).toHaveLength(2); expect(f.count()).toBe(1)
  95. f.advance(600_001)
  96. expect(() => f.broker.subscribe(c, cursor, () => {})).toThrow('Replay window expired')
  97. })
  98. it('does not execute runtime commands without a route subscription', async () => {
  99. const f = await fixture()
  100. const c = await f.broker.create(f.principal('alice'), 'chat')
  101. const result = await f.broker.command(c, 'forbidden', { method: 'session.interrupt', params: { session_id: 'guessed' } })
  102. expect(result.state).toBe('rejected'); expect(f.commands).toHaveLength(0)
  103. })
  104. it('marks write-ahead admissions unknown after a process restart', () => {
  105. const home = mkdtempSync(join(tmpdir(), 'yaoyao-receipts-test-'))
  106. const a = new RealtimeReceipts(home)
  107. a.reserve('alice', 'submitted', '{"text":"private"}'); a.close()
  108. const b = new RealtimeReceipts(home)
  109. expect(b.reserve('alice', 'submitted', '{"text":"private"}')).toMatchObject({ state: 'unknown' })
  110. b.close()
  111. })
  112. it('registers branch identities without losing the original subscription', async () => {
  113. const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
  114. await f.broker.command(c, 'open', resume)
  115. const branch = await f.broker.command(c, 'branch', { method: 'session.branch', params: { session_id: 'runtime-1' } })
  116. expect(branch.state).toBe('confirmed')
  117. const sent = await f.broker.command(c, 'branched-prompt', { method: 'prompt.submit', params: { session_id: 'runtime-branch', text: 'branch' } })
  118. expect(sent.state).toBe('confirmed')
  119. expect(c.routes.size).toBe(2)
  120. })
  121. it('does not let guessed approval IDs escape a subscribed session', async () => {
  122. const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
  123. await f.broker.command(c, 'open', resume)
  124. const result = await f.broker.command(c, 'approval', { method: 'approval.respond', params: { session_id: 'runtime-1', request_id: 'another-users-approval', approved: true } })
  125. expect(result).toMatchObject({ state: 'rejected', response: { error: { code: 'interaction_forbidden' } } })
  126. expect(f.commands.filter(x => x.method === 'approval.respond')).toHaveLength(0)
  127. })
  128. it('page reloads cannot exhaust live-stream capacity with detached handles', async () => {
  129. const f = await fixture(), p = f.principal('alice')
  130. for (let i = 0; i < 40; i++) await f.broker.create(p, 'chat')
  131. expect(f.broker.channels.size).toBe(32)
  132. expect(f.count()).toBe(1)
  133. })
  134. it('recovers the upstream replay ring without losing offline deltas', async () => {
  135. const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
  136. await f.broker.command(c, 'open', resume)
  137. const received: StreamEntry[] = []
  138. f.broker.subscribe(c, undefined, e => received.push(e))
  139. f.emit('message.delta', { text: 'before' })
  140. await vi.waitFor(() => expect(received.some(e => e.data.includes('before'))).toBe(true))
  141. f.disconnect(); f.emit('message.delta', { text: 'offline' })
  142. await vi.waitFor(() => expect(received.filter(e => e.data.includes('offline'))).toHaveLength(1), { timeout: 2500 })
  143. expect(f.count()).toBe(2)
  144. expect(received.filter(e => e.event === 'reset')).toHaveLength(0)
  145. })
  146. it('reports reset when upstream history was truncated', async () => {
  147. const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
  148. await f.broker.command(c, 'open', resume)
  149. const received: StreamEntry[] = []
  150. f.broker.subscribe(c, undefined, e => received.push(e))
  151. f.truncate(); f.disconnect()
  152. await vi.waitFor(() => expect(received.some(e => e.event === 'reset')).toBe(true), { timeout: 2500 })
  153. })
  154. it('never repeats a prompt when the upstream acceptance reply is lost', async () => {
  155. const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
  156. await f.broker.command(c, 'open', resume)
  157. f.dropPrompt()
  158. const prompt = { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'do once' } }
  159. const result = await f.broker.command(c, 'lost-ack', prompt)
  160. expect(result.state).toBe('unknown')
  161. expect((await f.broker.command(c, 'lost-ack', prompt)).state).toBe('unknown')
  162. expect(f.commands.filter(f => f.method === 'prompt.submit')).toHaveLength(1)
  163. })
  164. it('invalidates read snapshots before/after mutation and before delivering live events', async () => {
  165. const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
  166. await f.broker.command(c, 'open', resume)
  167. await f.broker.command(c, 'send', { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'once' } })
  168. expect(f.activities.filter(c => c.kind === 'command' && c.name === 'prompt.submit')).toEqual([
  169. { kind: 'command', name: 'prompt.submit', sessionId: 'stored-1' },
  170. { kind: 'command', name: 'prompt.submit', sessionId: 'stored-1' },
  171. ])
  172. const seen: StreamEntry[] = []
  173. f.broker.subscribe(c, undefined, entry => {
  174. if (entry.data.includes('message.delta')) expect(f.activities.at(-1)).toMatchObject({ kind: 'event', name: 'message.delta', sessionId: 'stored-1' })
  175. seen.push(entry)
  176. })
  177. f.emit('message.delta', { text: 'fresh' })
  178. await vi.waitFor(() => expect(seen.some(e => e.data.includes('fresh'))).toBe(true))
  179. })
  180. })