wenay-common2 1.0.73 → 1.0.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +3 -2
  2. package/demo/client.ts +137 -32
  3. package/demo/index.html +7 -0
  4. package/demo/server.ts +79 -42
  5. package/doc/INTENT.md +31 -31
  6. package/doc/ROADMAP.md +233 -230
  7. package/doc/changes/1.0.74.md +12 -0
  8. package/doc/changes/1.0.75.md +10 -0
  9. package/doc/wenay-common2-rare.md +705 -649
  10. package/doc/wenay-common2.md +438 -396
  11. package/lib/Common/events/replay-route.js +5 -2
  12. package/lib/Common/events/replay-wire.js +137 -47
  13. package/lib/Common/events/route-signal-webrtc.d.ts +1 -1
  14. package/lib/Common/events/route-signal-webrtc.js +15 -5
  15. package/lib/Common/events/transport-lifecycle.d.ts +23 -0
  16. package/lib/Common/events/transport-lifecycle.js +94 -0
  17. package/lib/Common/media/media-view.d.ts +2 -2
  18. package/lib/Common/media/media-view.js +23 -4
  19. package/lib/Common/peer/peer-call.d.ts +65 -0
  20. package/lib/Common/peer/peer-call.js +173 -0
  21. package/lib/Common/peer/peer-client.d.ts +9 -0
  22. package/lib/Common/peer/peer-host.d.ts +14 -1
  23. package/lib/Common/peer/peer-host.js +48 -4
  24. package/lib/Common/peer/peer-index.d.ts +2 -0
  25. package/lib/Common/peer/peer-index.js +2 -0
  26. package/lib/Common/peer/peer-media-relay.d.ts +22 -0
  27. package/lib/Common/peer/peer-media-relay.js +155 -0
  28. package/lib/Common/rcp/rpc-client.js +346 -111
  29. package/lib/Common/rcp/rpc-clientHub.d.ts +2 -0
  30. package/lib/Common/rcp/rpc-clientHub.js +120 -25
  31. package/lib/Common/rcp/rpc-server.js +33 -9
  32. package/observe/listen-core.test.ts +1 -2
  33. package/oracle/realsocket/lifecycle.spec.ts +3 -3
  34. package/oracle/realsocket/replay-reconnect-auth.spec.ts +111 -0
  35. package/oracle/realsocket/replay-reconnect.spec.ts +592 -0
  36. package/package.json +1 -4
  37. package/replay/media-view.test.ts +4 -4
  38. package/replay/peer-call.test.ts +226 -0
  39. package/rpc.md +19 -4
  40. package/doc/changes/1.0.64.md +0 -10
  41. package/doc/changes/1.0.65.md +0 -8
@@ -0,0 +1,592 @@
1
+ // REAL-SOCKET replay reconnect oracle. The replay journals below outlive every
2
+ // Socket.IO connection: reconnect must repair a logical subscription, not create
3
+ // a new application-level replay line. Port 4112.
4
+ import {startRealServer, startRealClient, makeChecker, delay} from './_rs'
5
+ import {replayListen} from '../../src/Common/events/replay-listen'
6
+ import {exposeReplay, replaySubscribe, ReplayRemote} from '../../src/Common/events/replay-wire'
7
+
8
+ const PORT = 4112
9
+ const WAIT_MS = 8000
10
+
11
+ type tCheck = ReturnType<typeof makeChecker>['check']
12
+ type tClient = Awaited<ReturnType<typeof startRealClient>>
13
+
14
+ // ============================================================
15
+ // Persistent server resources
16
+ // ============================================================
17
+
18
+ function createPersistentReplay(history: number, frameDelayMs = 0) {
19
+ const [emit, replay] = replayListen<[number]>({history})
20
+ const exposed = exposeReplay(replay)
21
+
22
+ if (frameDelayMs == 0) return {emit, replay, api: exposed}
23
+
24
+ async function delayedFrame(seq: number, hint?: unknown) {
25
+ await delay(frameDelayMs)
26
+ return exposed.frame(seq, hint)
27
+ }
28
+
29
+ return {
30
+ emit,
31
+ replay,
32
+ api: {...exposed, frame: delayedFrame},
33
+ }
34
+ }
35
+
36
+ const shortLine = createPersistentReplay(32)
37
+ const consumersLine = createPersistentReplay(32)
38
+ const cyclesLine = createPersistentReplay(64, 140)
39
+ const sacredLine = createPersistentReplay(2)
40
+ const disposeLine = createPersistentReplay(16)
41
+ const lifecycleLine = createPersistentReplay(32)
42
+ const rotationLine = createPersistentReplay(16)
43
+
44
+ function makeObject() {
45
+ return {
46
+ identity: {
47
+ deep: {
48
+ orders: {
49
+ events: async function segmentedEvents() { return 'segmented' },
50
+ },
51
+ },
52
+ },
53
+ 'identity.deep': {
54
+ orders: {
55
+ events: async function dottedEvents() { return 'dotted' },
56
+ },
57
+ },
58
+ replay: {
59
+ short: shortLine.api,
60
+ consumers: consumersLine.api,
61
+ cycles: cyclesLine.api,
62
+ sacred: sacredLine.api,
63
+ dispose: disposeLine.api,
64
+ lifecycle: lifecycleLine.api,
65
+ rotation: rotationLine.api,
66
+ },
67
+ }
68
+ }
69
+
70
+ // Every connection has its own RPC server registry. Summing their consumer
71
+ // counts catches callbacks leaked by dead wire-generations.
72
+ const serverApis: any[] = []
73
+
74
+ function rememberServer(api: any) {
75
+ serverApis.push(api)
76
+ }
77
+
78
+ function serverSubscriberCount() {
79
+ let total = 0
80
+ for (const api of serverApis) {
81
+ for (const sub of api.subscriptions()) total += sub.consumers
82
+ }
83
+ return total
84
+ }
85
+
86
+ // ============================================================
87
+ // Test utilities
88
+ // ============================================================
89
+
90
+ async function waitFor(name: string, predicate: () => boolean, timeoutMs = WAIT_MS) {
91
+ const deadline = Date.now() + timeoutMs
92
+ let lastError: any = null
93
+ while (Date.now() < deadline) {
94
+ try {
95
+ if (predicate()) return
96
+ } catch (e) {
97
+ lastError = e
98
+ }
99
+ await delay(10)
100
+ }
101
+ const suffix = lastError == null ? '' : ': ' + String(lastError?.message ?? lastError)
102
+ throw new Error('timeout waiting for ' + name + suffix)
103
+ }
104
+
105
+ function remoteOf(cli: tClient, name: string) {
106
+ return (cli.client.func as any).replay[name] as ReplayRemote<[number]>
107
+ }
108
+
109
+ function currentClient(cli: tClient) {
110
+ return (cli.hub.facade as any).api
111
+ }
112
+
113
+ async function breakTransport(cli: tClient, label: string) {
114
+ const socket = cli.hub.socket as any
115
+ if (!socket?.connected) throw new Error(label + ': socket is not connected')
116
+ const count = cli.hub.connectCount()
117
+ const engine = socket.io?.engine
118
+ if (!engine?.close) throw new Error(label + ': Engine.IO close() is unavailable')
119
+
120
+ // Closing Engine.IO, rather than Socket#disconnect(), is a transient network
121
+ // failure: Socket.IO must reconnect this same Socket object automatically.
122
+ engine.close()
123
+ await waitFor(label + ' disconnect', () => !socket.connected)
124
+ return {socket, count}
125
+ }
126
+
127
+ async function waitReconnect(cli: tClient, broken: Awaited<ReturnType<typeof breakTransport>>, label: string) {
128
+ await waitFor(label + ' reconnect', () =>
129
+ broken.socket.connected && cli.hub.connectCount() == broken.count + 1)
130
+ if (cli.hub.socket !== broken.socket) throw new Error(label + ': Socket.IO object identity changed')
131
+ }
132
+
133
+ async function closeClient(cli: tClient) {
134
+ currentClient(cli)?.dispose?.('reconnect oracle complete')
135
+ cli.close()
136
+ await waitFor('server subscription cleanup', () => serverSubscriberCount() == 0)
137
+ }
138
+
139
+ function errorMessage(error: any) {
140
+ if (typeof error == 'string') return error
141
+ if (typeof error?.message == 'string') return error.message
142
+ if (typeof error?.error?.message == 'string') return error.error.message
143
+ try { return JSON.stringify(error) } catch { return String(error) }
144
+ }
145
+
146
+ function numbers(from: number, to: number) {
147
+ return Array.from({length: to - from + 1}, (_, i) => from + i)
148
+ }
149
+
150
+ // ============================================================
151
+ // Scenario 1 + 2: identity and a short recoverable gap
152
+ // ============================================================
153
+
154
+ async function checkIdentityAndShortReconnect(check: tCheck) {
155
+ const cli = await startRealClient<ReturnType<typeof makeObject>>({port: PORT})
156
+ const client = cli.client as any
157
+
158
+ const pathA = client.func.identity.deep.orders.events
159
+ const pathB = client.func.identity.deep.orders.events
160
+ const parentA = client.func.identity.deep.orders
161
+ const parentB = client.func.identity.deep.orders
162
+ const dotted = client.func['identity.deep'].orders.events
163
+ const strictPath = client.strict.identity.deep.orders.events
164
+ const remote = remoteOf(cli, 'short')
165
+ const socketBefore = cli.hub.socket
166
+
167
+ await check('identity: repeated RPC path returns the same Proxy', () => pathA === pathB, true)
168
+ await check('identity: repeated nested parent returns the same Proxy', () => parentA === parentB, true)
169
+ await check('identity: dotted key does not collide with segmented path', () => dotted !== pathA, true)
170
+ await check('identity: collision-safe paths still call distinct routes', async () =>
171
+ [await pathA(), await dotted()], ['segmented', 'dotted'])
172
+ await check('identity: strict path is stable', () =>
173
+ strictPath === client.strict.identity.deep.orders.events, true)
174
+ await check('identity: incompatible wait surfaces do not share a Proxy', () =>
175
+ pathA !== client.space.identity.deep.orders.events, true)
176
+ await check('identity: client.all remains client.func', () => client.all === client.func, true)
177
+
178
+ const values: number[] = []
179
+ const seqs: number[] = []
180
+ const callbackOrder: string[] = []
181
+ const sub = replaySubscribe(remote, function receiveShort(value) {
182
+ values.push(value)
183
+ callbackOrder.push('value:' + value)
184
+ }, {
185
+ since: 0,
186
+ onSeq: function rememberShortSeq(seq) {
187
+ seqs.push(seq)
188
+ callbackOrder.push('seq:' + seq)
189
+ },
190
+ })
191
+ await sub.ready
192
+
193
+ shortLine.emit(1)
194
+ shortLine.emit(2)
195
+ await waitFor('short seq 1,2', () => values.length == 2)
196
+
197
+ const broken = await breakTransport(cli, 'short')
198
+ shortLine.emit(3)
199
+ shortLine.emit(4)
200
+ await waitReconnect(cli, broken, 'short')
201
+
202
+ // This is live during recovery. It must queue behind the replayed 3,4.
203
+ shortLine.emit(5)
204
+ await waitFor('short recovered 1..5', () => values.length == 5)
205
+ await waitFor('short physical subscription', () => serverSubscriberCount() == 1)
206
+
207
+ await check('short reconnect: values have no gap or duplicate', () => values, numbers(1, 5))
208
+ await check('short reconnect: seqs have no gap or duplicate', () => seqs, numbers(1, 5))
209
+ await check('short reconnect: callback precedes onSeq for every envelope', () => callbackOrder,
210
+ numbers(1, 5).flatMap(n => ['value:' + n, 'seq:' + n]))
211
+ await check('short reconnect: hub keeps the same Socket.IO object', () => cli.hub.socket === socketBefore, true)
212
+ await check('short reconnect: connectCount increments exactly once', () =>
213
+ cli.hub.connectCount(), broken.count + 1)
214
+ await check('short reconnect: remote Proxy identity is unchanged', () =>
215
+ remote === remoteOf(cli, 'short'), true)
216
+ await check('short reconnect: strict Proxy survives schema refresh', () =>
217
+ strictPath === client.strict.identity.deep.orders.events, true)
218
+ await check('short reconnect: one client wire subscription', () =>
219
+ cli.client.api.subscriptions().length, 1)
220
+ await check('short reconnect: one physical server subscriber', serverSubscriberCount, 1)
221
+
222
+ sub()
223
+ await waitFor('short unsubscribe', () => cli.client.api.subscriptions().length == 0)
224
+ await closeClient(cli)
225
+ }
226
+
227
+ // ============================================================
228
+ // Scenario 3: two local consumers remain one physical subscription
229
+ // ============================================================
230
+
231
+ async function checkTwoConsumers(check: tCheck) {
232
+ const cli = await startRealClient<ReturnType<typeof makeObject>>({port: PORT})
233
+ const remote = remoteOf(cli, 'consumers')
234
+ const first: number[] = []
235
+ const second: number[] = []
236
+
237
+ const sub1 = replaySubscribe(remote, function receiveFirst(value) { first.push(value) }, {since: 0})
238
+ const sub2 = replaySubscribe(remote, function receiveSecond(value) { second.push(value) }, {since: 0})
239
+ await Promise.all([sub1.ready, sub2.ready])
240
+
241
+ consumersLine.emit(1)
242
+ consumersLine.emit(2)
243
+ await waitFor('two consumers seq 1,2', () => first.length == 2 && second.length == 2)
244
+
245
+ await check('two consumers: one deduped client wire subscription', () =>
246
+ cli.client.api.subscriptions().length, 1)
247
+ await check('two consumers: client wire has two logical consumers', () =>
248
+ cli.client.api.subscriptions()[0]?.consumers, 2)
249
+ await check('two consumers: one physical server subscriber before reconnect', serverSubscriberCount, 1)
250
+
251
+ const broken = await breakTransport(cli, 'two consumers')
252
+ consumersLine.emit(3)
253
+ consumersLine.emit(4)
254
+ await waitReconnect(cli, broken, 'two consumers')
255
+ consumersLine.emit(5)
256
+ await waitFor('both consumers recovered 1..5', () => first.length == 5 && second.length == 5)
257
+
258
+ await check('two consumers: first recovered exactly 1..5', () => first, numbers(1, 5))
259
+ await check('two consumers: second recovered exactly 1..5', () => second, numbers(1, 5))
260
+ await check('two consumers: reconnect still has one client wire', () =>
261
+ cli.client.api.subscriptions().length, 1)
262
+ await check('two consumers: reconnect preserves refcount two', () =>
263
+ cli.client.api.subscriptions()[0]?.consumers, 2)
264
+ await check('two consumers: reconnect creates one physical server subscriber', serverSubscriberCount, 1)
265
+
266
+ sub1()
267
+ await waitFor('first consumer off', () => cli.client.api.subscriptions()[0]?.consumers == 1)
268
+ consumersLine.emit(6)
269
+ await waitFor('second consumer receives after first off', () => second.length == 6)
270
+ await check('two consumers: off(first) stops only first', () => first, numbers(1, 5))
271
+ await check('two consumers: second remains live after off(first)', () => second, numbers(1, 6))
272
+ await check('two consumers: server wire remains after off(first)', serverSubscriberCount, 1)
273
+
274
+ sub2()
275
+ await waitFor('last consumer removes wire', () =>
276
+ cli.client.api.subscriptions().length == 0 && serverSubscriberCount() == 0)
277
+
278
+ const afterOff = await breakTransport(cli, 'after last off')
279
+ consumersLine.emit(7)
280
+ await waitReconnect(cli, afterOff, 'after last off')
281
+ consumersLine.emit(8)
282
+ await delay(120)
283
+ await check('two consumers: reconnect after last off resurrects nothing', () =>
284
+ [cli.client.api.subscriptions().length, serverSubscriberCount()], [0, 0])
285
+ await check('two consumers: no delivery after final off', () => second, numbers(1, 6))
286
+
287
+ await closeClient(cli)
288
+ }
289
+
290
+ // ============================================================
291
+ // Scenario 4: three reconnects, including overlapping recovery
292
+ // ============================================================
293
+
294
+ async function checkRepeatedReconnect(check: tCheck) {
295
+ const cli = await startRealClient<ReturnType<typeof makeObject>>({port: PORT})
296
+ const values: number[] = []
297
+ const seqs: number[] = []
298
+ const sub = replaySubscribe(remoteOf(cli, 'cycles'), function receiveCycle(value) {
299
+ values.push(value)
300
+ }, {since: 0, onSeq: function rememberCycleSeq(seq) { seqs.push(seq) }})
301
+ await sub.ready
302
+
303
+ cyclesLine.emit(1)
304
+ await waitFor('cycles seq 1', () => values.length == 1)
305
+
306
+ // Cycle one starts a deliberately slow frame request.
307
+ const firstBreak = await breakTransport(cli, 'cycles one')
308
+ cyclesLine.emit(2)
309
+ await waitReconnect(cli, firstBreak, 'cycles one')
310
+ cyclesLine.emit(3)
311
+
312
+ // Cycle two interrupts that catch-up. Its late completion must be stale and
313
+ // must not delete, flush, or otherwise mutate the new wire-generation.
314
+ await delay(20)
315
+ const secondBreak = await breakTransport(cli, 'cycles two')
316
+ cyclesLine.emit(4)
317
+ await waitReconnect(cli, secondBreak, 'cycles two')
318
+ cyclesLine.emit(5)
319
+ await waitFor('cycles recovered through 5', () => values.length == 5)
320
+
321
+ // Cycle three checks the steady-state path after the overlap.
322
+ const thirdBreak = await breakTransport(cli, 'cycles three')
323
+ cyclesLine.emit(6)
324
+ await waitReconnect(cli, thirdBreak, 'cycles three')
325
+ cyclesLine.emit(7)
326
+ await waitFor('cycles recovered through 7', () => values.length == 7)
327
+
328
+ // Let every stale promise finish, then prove the current generation survived.
329
+ await delay(320)
330
+ cyclesLine.emit(8)
331
+ await waitFor('cycles current generation still live', () => values.length == 8)
332
+
333
+ await check('three reconnects: values are strictly 1..8', () => values, numbers(1, 8))
334
+ await check('three reconnects: seqs are strictly 1..8', () => seqs, numbers(1, 8))
335
+ await check('three reconnects: exactly four total connects', () => cli.hub.connectCount(), 4)
336
+ await check('three reconnects: one logical client wire remains', () =>
337
+ cli.client.api.subscriptions().length, 1)
338
+ await check('three reconnects: request/callback tracking stays bounded to the live wire', () =>
339
+ [cli.client.api.pending(), cli.client.api.callbacks()], [1, 1])
340
+ await check('three reconnects: dead server generations retain no subscriber', serverSubscriberCount, 1)
341
+
342
+ sub()
343
+ await closeClient(cli)
344
+ }
345
+
346
+ // ============================================================
347
+ // Scenario 5: sacred journal eviction fails loudly and stays stopped
348
+ // ============================================================
349
+
350
+ async function checkSacredEviction(check: tCheck) {
351
+ const cli = await startRealClient<ReturnType<typeof makeObject>>({port: PORT})
352
+ const values: number[] = []
353
+ const seqs: number[] = []
354
+ const errors: any[] = []
355
+ const sub = replaySubscribe(remoteOf(cli, 'sacred'), function receiveSacred(value) {
356
+ values.push(value)
357
+ }, {
358
+ since: 0,
359
+ onSeq: function rememberSacredSeq(seq) { seqs.push(seq) },
360
+ onError: function rememberSacredError(error) { errors.push(error) },
361
+ })
362
+ await sub.ready
363
+
364
+ sacredLine.emit(1)
365
+ sacredLine.emit(2)
366
+ await waitFor('sacred seq 1,2', () => values.length == 2)
367
+
368
+ const broken = await breakTransport(cli, 'sacred')
369
+ sacredLine.emit(3)
370
+ sacredLine.emit(4)
371
+ sacredLine.emit(5)
372
+ await waitReconnect(cli, broken, 'sacred')
373
+ await waitFor('sacred recovery error', () => errors.length > 0)
374
+
375
+ // A fresh live event after the reported gap must not masquerade as a
376
+ // continuous stream. Recovery is terminal until the caller starts anew.
377
+ sacredLine.emit(6)
378
+ await delay(160)
379
+ const message = errorMessage(errors[0]).toLowerCase()
380
+
381
+ await check('sacred eviction: onError fires once', () => errors.length, 1)
382
+ await check('sacred eviction: error names the requested reconnect seq', () => message.includes('2'), true)
383
+ await check('sacred eviction: error explains eviction/gap', () =>
384
+ message.includes('evict') || message.includes('sacred') || message.includes('gap'), true)
385
+ await check('sacred eviction: post-gap events are not delivered', () => values, [1, 2])
386
+ await check('sacred eviction: seq callback does not claim recovery', () => seqs, [1, 2])
387
+ await check('sacred eviction: handle keeps the honest reconnect point', () => sub.seq(), 2)
388
+ await check('sacred eviction: failed logical subscription is retired', () =>
389
+ cli.client.api.subscriptions().length, 0)
390
+ await check('sacred eviction: failed wire leaves no pending/callback state', () =>
391
+ [cli.client.api.pending(), cli.client.api.callbacks()], [0, 0])
392
+
393
+ sub()
394
+ await closeClient(cli)
395
+ }
396
+
397
+ // ============================================================
398
+ // Scenario 6: dispose while offline is terminal
399
+ // ============================================================
400
+
401
+ async function checkDisposeOffline(check: tCheck) {
402
+ const cli = await startRealClient<ReturnType<typeof makeObject>>({port: PORT})
403
+ const remote = remoteOf(cli, 'dispose')
404
+ const values: number[] = []
405
+ const sub = replaySubscribe(remote, function receiveBeforeDispose(value) { values.push(value) }, {since: 0})
406
+ let rawDrained = false
407
+ const rawHandle = remote.line.on(function observeRawLine() {}) as any
408
+ rawHandle.then(function rawLineDrained() { rawDrained = true })
409
+ await sub.ready
410
+
411
+ disposeLine.emit(1)
412
+ await waitFor('dispose seq 1', () => values.length == 1)
413
+
414
+ const broken = await breakTransport(cli, 'dispose offline')
415
+ cli.client.dispose('offline dispose', {socketAlive: false})
416
+ await waitFor('offline dispose drains raw consumer', () => rawDrained)
417
+ await waitReconnect(cli, broken, 'dispose offline')
418
+
419
+ disposeLine.emit(2)
420
+ await delay(160)
421
+ await check('dispose offline: raw consumer handle is settled', () => rawDrained, true)
422
+ await check('dispose offline: old replay receives nothing else', () => values, [1])
423
+ await check('dispose offline: reconnect restores no client wire', () =>
424
+ cli.client.api.subscriptions().length, 0)
425
+ await check('dispose offline: reconnect restores no server subscriber', serverSubscriberCount, 0)
426
+ await check('dispose offline: reconnect point remains last delivered seq', () => sub.seq(), 1)
427
+
428
+ sub()
429
+ rawHandle()
430
+ await closeClient(cli)
431
+ }
432
+
433
+ // ============================================================
434
+ // Scenario 8: legacy lifecycle slot plus additive multicast listeners
435
+ // ============================================================
436
+
437
+ async function checkLifecycleMulticast(check: tCheck) {
438
+ const cli = await startRealClient<ReturnType<typeof makeObject>>({port: PORT})
439
+ const hub = cli.hub
440
+ const values: number[] = []
441
+ const sub = replaySubscribe(remoteOf(cli, 'lifecycle'), function receiveLifecycle(value) {
442
+ values.push(value)
443
+ }, {since: 0})
444
+ await sub.ready
445
+
446
+ const legacyConnect: number[] = []
447
+ const connectA: number[] = []
448
+ const connectB: number[] = []
449
+ const legacyDisconnect: string[] = []
450
+ const disconnectA: string[] = []
451
+ const disconnectB: string[] = []
452
+
453
+ hub.onConnect(function onLegacyConnect(count) { legacyConnect.push(count) })
454
+ hub.onDisconnect(function onLegacyDisconnect(reason) { legacyDisconnect.push(reason) })
455
+ const offConnectA = hub.connectListen(function onConnectA(count) { connectA.push(count) })
456
+ const offConnectB = hub.connectListen(function onConnectB(count) { connectB.push(count) })
457
+ const offDisconnectA = hub.disconnectListen(function onDisconnectA(reason) { disconnectA.push(reason) })
458
+ const offDisconnectB = hub.disconnectListen(function onDisconnectB(reason) { disconnectB.push(reason) })
459
+
460
+ lifecycleLine.emit(1)
461
+ await waitFor('lifecycle seq 1', () => values.length == 1)
462
+
463
+ const firstBreak = await breakTransport(cli, 'lifecycle first')
464
+ lifecycleLine.emit(2)
465
+ await waitReconnect(cli, firstBreak, 'lifecycle first')
466
+ lifecycleLine.emit(3)
467
+ await waitFor('lifecycle first recovery', () => values.length == 3)
468
+ await waitFor('lifecycle first disconnect notifications', () => disconnectB.length == 1)
469
+
470
+ await check('lifecycle: legacy connect slot fires', () => legacyConnect, [firstBreak.count + 1])
471
+ await check('lifecycle: both additive connect listeners fire', () =>
472
+ [connectA, connectB], [[firstBreak.count + 1], [firstBreak.count + 1]])
473
+ await check('lifecycle: legacy disconnect slot fires once', () => legacyDisconnect.length, 1)
474
+ await check('lifecycle: both additive disconnect listeners fire once', () =>
475
+ [disconnectA.length, disconnectB.length], [1, 1])
476
+
477
+ offConnectA()
478
+ offDisconnectA()
479
+ hub.onConnect(null)
480
+ hub.onDisconnect(null)
481
+
482
+ const secondBreak = await breakTransport(cli, 'lifecycle second')
483
+ lifecycleLine.emit(4)
484
+ await waitReconnect(cli, secondBreak, 'lifecycle second')
485
+ lifecycleLine.emit(5)
486
+ await waitFor('lifecycle second recovery', () => values.length == 5)
487
+ await waitFor('lifecycle second disconnect notification', () => disconnectB.length == 2)
488
+
489
+ await check('lifecycle: off removes only connect listener A', () =>
490
+ [connectA.length, connectB.length], [1, 2])
491
+ await check('lifecycle: off removes only disconnect listener A', () =>
492
+ [disconnectA.length, disconnectB.length], [1, 2])
493
+ await check('lifecycle: clearing legacy slots leaves additive listeners alive', () =>
494
+ [legacyConnect.length, legacyDisconnect.length], [1, 1])
495
+ await check('lifecycle: replay recovery ignores public legacy slot changes', () =>
496
+ values, numbers(1, 5))
497
+ await check('lifecycle: internal recovery still has one server subscriber', serverSubscriberCount, 1)
498
+
499
+ offConnectB()
500
+ offDisconnectB()
501
+ sub()
502
+ await closeClient(cli)
503
+ }
504
+
505
+ // ============================================================
506
+ // Scenario 7: setToken remains a hard generation boundary
507
+ // ============================================================
508
+
509
+ async function checkHardTokenRotation(check: tCheck) {
510
+ const cli = await startRealClient<ReturnType<typeof makeObject>>({port: PORT})
511
+ const oldClient = cli.client
512
+ const oldRemote = remoteOf(cli, 'rotation')
513
+ const oldSocket = cli.hub.socket
514
+ const oldConnectCount = cli.hub.connectCount()
515
+ const values: number[] = []
516
+ const sub = replaySubscribe(oldRemote, function receiveBeforeRotation(value) { values.push(value) }, {since: 0})
517
+ let rawDrained = false
518
+ const rawHandle = oldRemote.line.on(function observeRotationLine() {}) as any
519
+ rawHandle.then(function rotationLineDrained() { rawDrained = true })
520
+ await sub.ready
521
+
522
+ rotationLine.emit(1)
523
+ await waitFor('rotation seq 1', () => values.length == 1)
524
+
525
+ const legacyDisconnect: string[] = []
526
+ const multicastDisconnect: string[] = []
527
+ cli.hub.onDisconnect(function rememberLegacyRotation(reason) { legacyDisconnect.push(reason) })
528
+ const offDisconnect = cli.hub.disconnectListen(function rememberMulticastRotation(reason) {
529
+ multicastDisconnect.push(reason)
530
+ })
531
+
532
+ await cli.hub.setToken('rotated-token')
533
+ const freshClient = currentClient(cli)
534
+ await freshClient.readyStrict()
535
+ await waitFor('rotation drains old consumer', () => rawDrained)
536
+ await delay(100)
537
+
538
+ const freshRemote = freshClient.func.replay.rotation as ReplayRemote<[number]>
539
+ rotationLine.emit(2)
540
+ await delay(140)
541
+
542
+ await check('setToken: old raw subscription handle is drained', () => rawDrained, true)
543
+ await check('setToken: old client has zero subscriptions', () =>
544
+ oldClient.api.subscriptions().length, 0)
545
+ await check('setToken: new generation starts with zero subscriptions', () =>
546
+ freshClient.api.subscriptions().length, 0)
547
+ await check('setToken: old replay is not auto-restored', () => values, [1])
548
+ await check('setToken: creates a new Socket.IO object', () => cli.hub.socket !== oldSocket, true)
549
+ await check('setToken: creates a new RPC Proxy generation', () => freshRemote !== oldRemote, true)
550
+ await check('setToken: connectCount increments once', () =>
551
+ cli.hub.connectCount(), oldConnectCount + 1)
552
+ await check('setToken: legacy disconnect is one logical event', () => legacyDisconnect.length, 1)
553
+ await check('setToken: multicast disconnect is one logical event', () => multicastDisconnect.length, 1)
554
+ await check('setToken: no physical subscription crosses token generation', serverSubscriberCount, 0)
555
+
556
+ offDisconnect()
557
+ cli.hub.onDisconnect(null)
558
+ sub()
559
+ rawHandle()
560
+ await closeClient(cli)
561
+ }
562
+
563
+ async function main() {
564
+ const {check, done} = makeChecker('replay-reconnect')
565
+ const watchdog = setTimeout(function watchdogTimeout() {
566
+ console.error('WATCHDOG timeout')
567
+ process.exit(3)
568
+ }, 120000)
569
+
570
+ const server = await startRealServer({
571
+ port: PORT,
572
+ makeObject,
573
+ onServer: rememberServer,
574
+ })
575
+
576
+ await checkIdentityAndShortReconnect(check)
577
+ await checkTwoConsumers(check)
578
+ await checkRepeatedReconnect(check)
579
+ await checkSacredEviction(check)
580
+ await checkDisposeOffline(check)
581
+ await checkLifecycleMulticast(check)
582
+ await checkHardTokenRotation(check)
583
+
584
+ clearTimeout(watchdog)
585
+ await server.close()
586
+ process.exit(done() == 0 ? 0 : 1)
587
+ }
588
+
589
+ main().catch(function fatal(error) {
590
+ console.error(error)
591
+ process.exit(2)
592
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wenay-common2",
3
- "version": "1.0.73",
3
+ "version": "1.0.75",
4
4
  "description": "Common library",
5
5
  "strict": true,
6
6
  "main": "lib/index.js",
@@ -42,8 +42,5 @@
42
42
  "./package.json": "./package.json",
43
43
  "./media": "./lib/Common/media/media-index.js",
44
44
  "./peer": "./lib/Common/peer/peer-index.js"
45
- },
46
- "dependencies": {
47
- "express": "^5.2.1"
48
45
  }
49
46
  }
@@ -69,10 +69,10 @@ async function main() {
69
69
  player.enable()
70
70
  ok(player.enabled && ctx.resumed == 1, 'enable() builds the injected context and resumes it')
71
71
 
72
- emit(pcmFrame(2), Date.now() - 40)
72
+ emit(pcmFrame(2), Date.now() - 100)
73
73
  ok(player.stats().played == 1 && ctx.scheduled.length == 1, 'enabled player schedules a decoded pcm16 buffer')
74
74
  ok(ctx.scheduled[0].frames == 480, 'sample count survives decode (480 frames)')
75
- ok(player.stats().ageMs >= 40, 'sentAt stamp turns into a real age measurement')
75
+ ok(player.stats().ageMs >= 80, 'sentAt stamp turns into a real age measurement')
76
76
 
77
77
  const before = ctx.scheduled.length
78
78
  emit(videoFrame(3, 320, 240), Date.now())
@@ -106,11 +106,11 @@ async function main() {
106
106
  },
107
107
  })
108
108
 
109
- emit(videoFrame(1, 640, 480), Date.now() - 25)
109
+ emit(videoFrame(1, 640, 480), Date.now() - 100)
110
110
  await delay(10)
111
111
  ok(view.stats().drawn == 1 && drawnImages.length == 1, 'video frame decoded via injected bitmap factory and drawn')
112
112
  ok(canvas.width == 640 && canvas.height == 480, 'canvas takes the frame size')
113
- ok(view.stats().width == 640 && view.stats().ageMs >= 25, 'stats expose frame size and age')
113
+ ok(view.stats().width == 640 && view.stats().ageMs >= 80, 'stats expose frame size and age')
114
114
 
115
115
  emit(pcmFrame(2), Date.now())
116
116
  await delay(10)