wenay-common2 1.0.74 → 1.0.76

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.
@@ -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.74",
3
+ "version": "1.0.76",
4
4
  "description": "Common library",
5
5
  "strict": true,
6
6
  "main": "lib/index.js",
@@ -16,6 +16,7 @@ import {
16
16
  } from '../src/Common/events/replay-index'
17
17
  import {createRpcClientHub} from '../src/Common/rcp/rpc-clientHub'
18
18
  import {createRpcServerAuto} from '../src/Common/rcp/rpc-server-auto'
19
+ import {decodeMediaFrame, encodeMediaFrame} from '../src/Common/media/media-source'
19
20
 
20
21
  let fails = 0
21
22
  const ok = (condition: any, message: string) => {
@@ -199,12 +200,24 @@ async function main() {
199
200
  await waitFor('datachannel live events', () => got.length >= 4)
200
201
  ok(json(got) == json([0, 1, 2, 3]), 'replay wire over the datachannel is gap-free and dup-free')
201
202
 
203
+ // A direct media route uses the same replay wire. Binary frames must stay
204
+ // Uint8Array end-to-end — JSON's default numeric-key object is unusable by Media.
205
+ const mediaFrame = encodeMediaFrame({
206
+ kind: 'video-frame', codec: 'jpeg', seq: 4, tMono: 40, width: 2, height: 2,
207
+ }, new Uint8Array([77, 101, 100, 105, 97]))
208
+ emit(mediaFrame as any)
209
+ await waitFor('binary frame over datachannel', () => got.length >= 5)
210
+ const deliveredFrame = got[4] as any
211
+ const decodedFrame = deliveredFrame instanceof Uint8Array ? decodeMediaFrame(deliveredFrame) : null
212
+ ok(decodedFrame?.kind == 'video-frame' && json([...decodedFrame.payload]) == json([77, 101, 100, 105, 97]),
213
+ 'direct replay preserves an encoded Media Uint8Array frame byte-for-byte')
214
+
202
215
  // server-side revoke: policy изменилась — direct закрывается, relay продолжает с seq
203
216
  hub.revoke(link.ref.key, ['a', 'b'], 'policy change')
204
217
  await waitFor('revoke fallback', () => link.state() == 'fallback')
205
218
  state = 4; emit(state)
206
219
  await waitFor('relay after revoke', () => got.includes(4))
207
- ok(json(got) == json([0, 1, 2, 3, 4]), 'server revoke: relay resumes from seq, no gap')
220
+ ok(json(got.filter(v => typeof v == 'number')) == json([0, 1, 2, 3, 4]), 'server revoke: relay resumes from seq, no gap')
208
221
 
209
222
  // сервер не раскрывает endpoint: offer отклонён ДО транспорта
210
223
  allowDirect = false
@@ -223,7 +236,7 @@ async function main() {
223
236
  const retry = await link.promoteDirect()
224
237
  state = 5; emit(state)
225
238
  await waitFor('direct again', () => got.includes(5))
226
- ok(retry.ok && link.state() == 'direct' && json(got) == json([0, 1, 2, 3, 4, 5]), 'pair recovers into direct after denials, still gap-free')
239
+ ok(retry.ok && link.state() == 'direct' && json(got.filter(v => typeof v == 'number')) == json([0, 1, 2, 3, 4, 5]), 'pair recovers into direct after denials, still gap-free')
227
240
 
228
241
  coord.close()
229
242
  stopAccept()
package/rpc.md CHANGED
@@ -123,17 +123,32 @@ export const Api = createRpcClientHub(
123
123
  ```
124
124
 
125
125
  ### 3.2 Connection Lifecycle
126
+
127
+ `onConnect` and `onDisconnect` are legacy single-slot setters: each call replaces the previous callback, and passing `null` clears that slot. Use the additive listeners when several independent consumers need lifecycle events.
128
+
126
129
  ```typescript
127
- // Listen to statuses
130
+ // Legacy single-slot callbacks.
128
131
  Api.onConnect((count) => console.log(`Socket connected (attempt ${count})`));
132
+ Api.onDisconnect((reason) => console.log(`Socket disconnected: ${reason}`));
133
+
134
+ // Additive listeners. Each off handle removes only its own callback.
135
+ const offConnect = Api.connectListen((count) => console.log(`Observer connected: ${count}`));
136
+ const offDisconnect = Api.disconnectListen((reason) => console.log(`Observer disconnected: ${reason}`));
129
137
 
130
- // Initiate connect. Creates socket, all channels automatically start.
138
+ // Hard connect/token generation. The promise resolves after the RPC handshake.
131
139
  await Api.connect("USER_SECRET_TOKEN");
132
140
 
133
- // Disconnect
134
- // await Api.connect(null);
141
+ // Calls inside onConnect/connectListen are safe: those callbacks also run after the handshake.
142
+ offConnect();
143
+ offDisconnect();
135
144
  ```
136
145
 
146
+ A transient Socket.IO disconnect on the same socket suspends transport without ending logical Listen consumers. After automatic reconnect, the hub recreates one physical subscription for every still-active deduplicated Listen; consumers removed while offline are not restored.
147
+
148
+ Only logical Listen subscriptions are recovered. Pending or failed ordinary RPC calls and pipelines are not retried, because repeating them could duplicate side effects.
149
+
150
+ `client.dispose()` is terminal for that RPC client. `Api.setToken(token)` and its `Api.connect(token)` alias are hard rotations: the old socket/client generation and its subscriptions are permanently closed, and the new facade starts without inherited subscriptions. `connect(null)` starts a new anonymous generation; it is not a transient reconnect.
151
+
137
152
  ### 3.3 Call Modes
138
153
  Access API channel: `Api.facade.mainAPI`. Hub initializes all channels, so schema loads automatically.
139
154