wenay-common2 1.0.68 → 1.0.70
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.
- package/README.md +12 -1
- package/demo/client.ts +100 -0
- package/demo/index.html +28 -0
- package/demo/server.ts +43 -0
- package/doc/ROADMAP.md +9 -3
- package/doc/changes/1.0.69.md +11 -0
- package/doc/changes/1.0.70.md +4 -0
- package/doc/wenay-common2-rare.md +8 -0
- package/doc/wenay-common2.md +42 -6
- package/lib/Common/events/route-signal-webrtc.js +4 -2
- package/lib/Common/peer/peer-client.d.ts +46 -0
- package/lib/Common/peer/peer-client.js +108 -0
- package/lib/Common/peer/peer-host.d.ts +32 -0
- package/lib/Common/peer/peer-host.js +45 -0
- package/lib/Common/peer/peer-index.d.ts +3 -0
- package/lib/Common/peer/peer-index.js +19 -0
- package/lib/Common/peer/peer-relay.d.ts +14 -0
- package/lib/Common/peer/peer-relay.js +77 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +2 -1
- package/observe/PLAN.md +131 -0
- package/observe/README.md +226 -0
- package/observe/hot-write.test.ts +95 -0
- package/observe/listen-core.test.ts +66 -0
- package/observe/listen-store.test.ts +56 -0
- package/observe/listen.test.ts +92 -0
- package/observe/reactive.test.ts +270 -0
- package/observe/reactive.ts +1 -0
- package/observe/store-manager.test.ts +118 -0
- package/observe/store-mirror.example.ts +74 -0
- package/observe/store.test.ts +235 -0
- package/observe/store.ts +1 -0
- package/observe/usage-real-socket.ts +174 -0
- package/observe/usage.ts +200 -0
- package/oracle/PASSED.md +27 -0
- package/oracle/README.md +12 -0
- package/oracle/fixes-primitives.spec.ts +90 -0
- package/oracle/realsocket/_rs.ts +106 -0
- package/oracle/realsocket/auth.spec.ts +91 -0
- package/oracle/realsocket/callbacks.spec.ts +122 -0
- package/oracle/realsocket/caps.spec.ts +124 -0
- package/oracle/realsocket/core.spec.ts +49 -0
- package/oracle/realsocket/dedupe.spec.ts +142 -0
- package/oracle/realsocket/errors.spec.ts +121 -0
- package/oracle/realsocket/lifecycle.spec.ts +128 -0
- package/oracle/realsocket/limits.spec.ts +98 -0
- package/oracle/realsocket/pipe.spec.ts +101 -0
- package/oracle/realsocket/shape.spec.ts +124 -0
- package/oracle/realsocket/slimv2.spec.ts +116 -0
- package/oracle/realsocket/stress.spec.ts +132 -0
- package/oracle/regression/_clientapiall-replay.fixture.ts +57 -0
- package/oracle/regression/async-queues.spec.ts +254 -0
- package/oracle/regression/bytestream.spec.ts +152 -0
- package/oracle/regression/clientapiall-replay-types.spec.ts +47 -0
- package/oracle/regression/core-clone-equal.spec.ts +124 -0
- package/oracle/regression/data-structures.spec.ts +135 -0
- package/oracle/regression/listen-events.spec.ts +206 -0
- package/oracle/regression/observe-core.spec.ts +278 -0
- package/oracle/regression/package-export.spec.ts +120 -0
- package/oracle/regression/rpc-dedupe-callbacks.spec.ts +195 -0
- package/oracle/regression/rpc-lifecycle.spec.ts +150 -0
- package/oracle/regression/store-each.spec.ts +209 -0
- package/package.json +8 -2
- package/replay/PLAN.md +171 -0
- package/replay/canvas-socket.test.ts +187 -0
- package/replay/coalesce.test.ts +260 -0
- package/replay/conflate-socket.test.ts +288 -0
- package/replay/conflate.test.ts +225 -0
- package/replay/history.test.ts +222 -0
- package/replay/media-socket.test.ts +157 -0
- package/replay/offline-store-socket.test.ts +290 -0
- package/replay/offline-store.test.ts +156 -0
- package/replay/peer-sdk.test.ts +228 -0
- package/replay/replay-listen.test.ts +156 -0
- package/replay/route-coordinator.test.ts +314 -0
- package/replay/route-handoff.test.ts +150 -0
- package/replay/route-webrtc.test.ts +321 -0
- package/replay/rpc-auto.test.ts +256 -0
- package/replay/socket-replay.test.ts +199 -0
- package/replay/staleness.test.ts +176 -0
- package/replay/store-replay.test.ts +151 -0
- package/replay/video-socket.demo.ts +200 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// oracle/realsocket/shape.spec.ts — DISPOSABLE real-socket oracle
|
|
3
|
+
// CATEGORY "shape": adaptive shape compaction (Pkt.SHAPE/CBV) over a REAL WebSocket.
|
|
4
|
+
//
|
|
5
|
+
// Two server streams:
|
|
6
|
+
// monoStream — monomorphic high-freq object stream. After THRESHOLD(5) ticks of the
|
|
7
|
+
// same shape the server standardizes it and switches to compact Pkt.CBV.
|
|
8
|
+
// We assert ALL ticks (>=8) arrive intact incl. Date values, AND that
|
|
9
|
+
// compaction actually engaged (CBV>0) by wrapping socket.emit in onServer.
|
|
10
|
+
// polyStream — polymorphic stream (changing shapes). Adaptive fallback: no shape ever
|
|
11
|
+
// reaches threshold → no data loss; we also assert CBV stayed 0.
|
|
12
|
+
// ============================================================
|
|
13
|
+
import {startRealServer, startRealClient, makeChecker, delay} from './_rs'
|
|
14
|
+
import {listen as createListenPair} from '../../src/Common/events/Listen'
|
|
15
|
+
|
|
16
|
+
const PORT = 4107
|
|
17
|
+
|
|
18
|
+
// captured server-side emit handles (filled when makeObject runs on the connection)
|
|
19
|
+
let emitMono: ((v: any) => void) | null = null
|
|
20
|
+
let emitPoly: ((v: any) => void) | null = null
|
|
21
|
+
|
|
22
|
+
function makeObject() {
|
|
23
|
+
const [mono, monoListen] = createListenPair<any>()
|
|
24
|
+
const [poly, polyListen] = createListenPair<any>()
|
|
25
|
+
emitMono = mono
|
|
26
|
+
emitPoly = poly
|
|
27
|
+
return {monoStream: monoListen, polyStream: polyListen}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function main() {
|
|
31
|
+
const {check, done} = makeChecker('shape')
|
|
32
|
+
const watchdog = setTimeout(() => { console.error('WATCHDOG timeout'); process.exit(3) }, 40000)
|
|
33
|
+
|
|
34
|
+
// counters of raw packet types seen on the REAL server socket
|
|
35
|
+
let cbvCount = 0 // Pkt.CBV = 9 (compact values tick)
|
|
36
|
+
let shapeCount = 0 // Pkt.SHAPE = 8 (shape declaration)
|
|
37
|
+
let cbCount = 0 // Pkt.CB = 2 (full callback tick)
|
|
38
|
+
|
|
39
|
+
const srv = await startRealServer({
|
|
40
|
+
port: PORT,
|
|
41
|
+
makeObject,
|
|
42
|
+
onServer: (_api, socket) => {
|
|
43
|
+
// wrap the genuine socket.emit BEFORE any tick is sent — the rpc-server
|
|
44
|
+
// adapter calls socket.emit(key, packetArray) at send time, so this sees
|
|
45
|
+
// every outgoing packet and lets us observe compaction on the real wire.
|
|
46
|
+
const orig = socket.emit.bind(socket)
|
|
47
|
+
socket.emit = (key: string, d: any) => {
|
|
48
|
+
if (Array.isArray(d)) {
|
|
49
|
+
if (d[0] === 9) cbvCount++
|
|
50
|
+
else if (d[0] === 8) shapeCount++
|
|
51
|
+
else if (d[0] === 2) cbCount++
|
|
52
|
+
}
|
|
53
|
+
return orig(key, d)
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const cli = await startRealClient({port: PORT})
|
|
59
|
+
const api = cli.api
|
|
60
|
+
|
|
61
|
+
// give the connection a moment so makeObject ran and emit handles are captured
|
|
62
|
+
await delay(50)
|
|
63
|
+
await check('emit handles captured', () => emitMono != null && emitPoly != null, true)
|
|
64
|
+
|
|
65
|
+
// ---------- monomorphic stream: subscribe, then fire many same-shape ticks ----------
|
|
66
|
+
const gotMono: any[] = []
|
|
67
|
+
const offMono = api.monoStream.callback((v: any) => gotMono.push(v))
|
|
68
|
+
await delay(60) // subscription round-trip + CAPS handshake over real socket
|
|
69
|
+
|
|
70
|
+
const N = 12
|
|
71
|
+
for (let i = 0; i < N; i++) {
|
|
72
|
+
emitMono!({a: i, when: new Date(i * 1000), tag: 'x'})
|
|
73
|
+
await delay(8)
|
|
74
|
+
}
|
|
75
|
+
await delay(120) // let all compact ticks land
|
|
76
|
+
|
|
77
|
+
await check('mono: all ticks arrived', () => gotMono.length, N)
|
|
78
|
+
await check('mono: first tick values intact', () => [gotMono[0].a, gotMono[0].tag, gotMono[0].when], [0, 'x', new Date(0)])
|
|
79
|
+
await check('mono: last tick values intact (Date)', () => [gotMono[N - 1].a, gotMono[N - 1].tag, gotMono[N - 1].when], [N - 1, 'x', new Date((N - 1) * 1000)])
|
|
80
|
+
// verify EVERY tick is correct and in order (no drop / corruption / reordering)
|
|
81
|
+
await check('mono: every tick exact + ordered', () => gotMono.map(v => [v.a, v.tag, v.when]),
|
|
82
|
+
Array.from({length: N}, (_v, i) => [i, 'x', new Date(i * 1000)]))
|
|
83
|
+
// compaction engaged: after THRESHOLD(5) of the same shape, server declared SHAPE once and sent CBV
|
|
84
|
+
await check('mono: SHAPE declared exactly once', () => shapeCount, 1)
|
|
85
|
+
await check('mono: compaction engaged (CBV>0)', () => cbvCount > 0, true)
|
|
86
|
+
// with threshold 5: ticks 1..5 full (the 5th triggers register→CBV), 6..12 compact.
|
|
87
|
+
// register sends SHAPE+CBV, then each later same-shape tick a CBV → CBV count = N - 4.
|
|
88
|
+
await check('mono: CBV count == N-4 (ticks after threshold)', () => cbvCount, N - 4)
|
|
89
|
+
|
|
90
|
+
offMono()
|
|
91
|
+
await delay(30)
|
|
92
|
+
|
|
93
|
+
// ---------- polymorphic stream: each tick a different shape → adaptive fallback ----------
|
|
94
|
+
const cbvBefore = cbvCount
|
|
95
|
+
const gotPoly: any[] = []
|
|
96
|
+
const offPoly = api.polyStream.callback((v: any) => gotPoly.push(v))
|
|
97
|
+
await delay(60)
|
|
98
|
+
|
|
99
|
+
// 8 ticks, each a distinct shape — no single shape ever reaches THRESHOLD(5)
|
|
100
|
+
emitPoly!({a: 1})
|
|
101
|
+
await delay(8); emitPoly!({b: 2})
|
|
102
|
+
await delay(8); emitPoly!({c: 3, d: 4})
|
|
103
|
+
await delay(8); emitPoly!({e: 5})
|
|
104
|
+
await delay(8); emitPoly!({f: 6, g: 7})
|
|
105
|
+
await delay(8); emitPoly!({h: 8})
|
|
106
|
+
await delay(8); emitPoly!({i: 9, j: 10, k: 11})
|
|
107
|
+
await delay(8); emitPoly!({l: 12})
|
|
108
|
+
await delay(120)
|
|
109
|
+
|
|
110
|
+
await check('poly: all 8 ticks arrived (no loss)', () => gotPoly.length, 8)
|
|
111
|
+
await check('poly: values intact across shapes', () => [gotPoly[0].a, gotPoly[1].b, gotPoly[2].c, gotPoly[2].d, gotPoly[6].i, gotPoly[6].k, gotPoly[7].l],
|
|
112
|
+
[1, 2, 3, 4, 9, 11, 12])
|
|
113
|
+
await check('poly: no compaction (no new CBV)', () => cbvCount - cbvBefore, 0)
|
|
114
|
+
|
|
115
|
+
offPoly()
|
|
116
|
+
await delay(30)
|
|
117
|
+
|
|
118
|
+
clearTimeout(watchdog)
|
|
119
|
+
cli.close()
|
|
120
|
+
await srv.close()
|
|
121
|
+
process.exit(done() === 0 ? 0 : 1)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
main().catch(e => { console.error(e); process.exit(2) })
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// REAL-SOCKET slimv2: NEW slim Listen v2 (slimListen) — unit-surface + end-to-end wire. Port 4110.
|
|
2
|
+
//
|
|
3
|
+
// FINDING (verified against source, see notes in ##RESULT##):
|
|
4
|
+
// isListenCallback() in src/Common/events/Listen.ts identifies a "listen" by
|
|
5
|
+
// STRUCTURAL key-set equality against createListen's full api
|
|
6
|
+
// (func/isRun/run/close/onClose/on/off/count/keys()).
|
|
7
|
+
// A slim SlimListen exposes only {on, close, count}, so isListenCallback(slimListen) === false
|
|
8
|
+
// and rpc-server-auto's resolveTransform will NOT treat a bare SlimListen as a stream node.
|
|
9
|
+
// Therefore the WIRE part uses the FULL listen handle (the one slimListen wraps), while the
|
|
10
|
+
// SLIM surface (on/off/count/close) is unit-asserted directly. We also prove the slim view
|
|
11
|
+
// and the full handle share ONE underlying impl (count() agrees across both faces), so a
|
|
12
|
+
// slimListen stream interoperates with the RPC listen-socket layer exactly like listen —
|
|
13
|
+
// via its full handle.
|
|
14
|
+
import {startRealServer, startRealClient, makeChecker, delay} from './_rs'
|
|
15
|
+
import {slimListen, toSlimListen, createListen, isListenCallback, listen as createListenPair} from '../../src/Common/events/Listen'
|
|
16
|
+
|
|
17
|
+
const PORT = 4110
|
|
18
|
+
|
|
19
|
+
// emit fns + full handles created at module scope so makeObject (per-connection) can place
|
|
20
|
+
// the FULL listen as the wire stream node while the spec drives emit / inspects slim surface.
|
|
21
|
+
const [emitWire, fullWire] = createListenPair<number>() // full handle → goes on the wire
|
|
22
|
+
const slimWire = toSlimListen<number>(fullWire) // slim VIEW over the SAME impl as fullWire
|
|
23
|
+
|
|
24
|
+
function makeObject() {
|
|
25
|
+
// Wire a real stream node. isListenCallback only accepts the FULL listen, so place that.
|
|
26
|
+
// The slim SlimListen (slimWire) is a view over this very handle — same subscriber set.
|
|
27
|
+
return {
|
|
28
|
+
ticks: fullWire,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function main() {
|
|
33
|
+
const {check, done} = makeChecker('slimv2')
|
|
34
|
+
const watchdog = setTimeout(() => { console.error('WATCHDOG timeout'); process.exit(3) }, 40000)
|
|
35
|
+
|
|
36
|
+
// ===== 0. Static finding: SlimListen is NOT detected as a listen by isListenCallback =====
|
|
37
|
+
const [, slimStandalone] = slimListen<number>()
|
|
38
|
+
await check('isListenCallback(full listen) === true', () => isListenCallback(fullWire), true)
|
|
39
|
+
await check('isListenCallback(slim SlimListen) === false', () => isListenCallback(slimStandalone as any), false)
|
|
40
|
+
|
|
41
|
+
// ===== 1. Slim surface unit tests (on/off/count/close) on a standalone slimListen =====
|
|
42
|
+
{
|
|
43
|
+
const [emit, listen] = slimListen<number>()
|
|
44
|
+
const got: number[] = []
|
|
45
|
+
await check('count() starts at 0', () => listen.count(), 0)
|
|
46
|
+
|
|
47
|
+
const off = listen.on(v => got.push(v))
|
|
48
|
+
await check('count() === 1 after on()', () => listen.count(), 1)
|
|
49
|
+
await check('on() returns a function (off)', () => typeof off, 'function')
|
|
50
|
+
|
|
51
|
+
emit(10); emit(20)
|
|
52
|
+
await check('on(cb) receives ticks', () => got.slice(), [10, 20])
|
|
53
|
+
|
|
54
|
+
off()
|
|
55
|
+
await check('count() === 0 after off()', () => listen.count(), 0)
|
|
56
|
+
emit(30)
|
|
57
|
+
await check('off() stops delivery', () => got.slice(), [10, 20])
|
|
58
|
+
|
|
59
|
+
// re-subscribe two, then close() tears everything down
|
|
60
|
+
const g2: number[] = []
|
|
61
|
+
listen.on(v => g2.push(v))
|
|
62
|
+
listen.on(v => g2.push(v))
|
|
63
|
+
await check('count() === 2 after two on()', () => listen.count(), 2)
|
|
64
|
+
emit(7)
|
|
65
|
+
await check('two subscribers each get tick', () => g2.slice(), [7, 7])
|
|
66
|
+
listen.close()
|
|
67
|
+
await check('count() === 0 after close()', () => listen.count(), 0)
|
|
68
|
+
emit(99)
|
|
69
|
+
await check('close() stops delivery', () => g2.slice(), [7, 7])
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ===== 2. Slim view interoperates with full handle (shared impl) =====
|
|
73
|
+
// slimWire is toSlimListen(fullWire); a subscription via the full handle must be visible
|
|
74
|
+
// through slim.count(), proving they are ONE Listen (the same impl slimListen wraps).
|
|
75
|
+
{
|
|
76
|
+
const offFull = fullWire.on(() => {})
|
|
77
|
+
await check('slim.count() reflects full.on', () => slimWire.count(), 1)
|
|
78
|
+
offFull()
|
|
79
|
+
await check('slim.count() reflects full off()', () => slimWire.count(), 0)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ===== 3. END-TO-END wire: full handle (the one slimListen wraps) over a REAL socket =====
|
|
83
|
+
const srv = await startRealServer({port: PORT, makeObject})
|
|
84
|
+
const cli = await startRealClient({port: PORT})
|
|
85
|
+
const api = cli.api
|
|
86
|
+
|
|
87
|
+
const received: number[] = []
|
|
88
|
+
const sub: any = api.ticks.callback((v: number) => received.push(v))
|
|
89
|
+
await delay(150) // let the subscription round-trip over the real WebSocket
|
|
90
|
+
|
|
91
|
+
// server-side subscriber present (the wire created a listen-socket subscriber on fullWire)
|
|
92
|
+
await check('wire: server has >=1 subscriber on the listen', () => fullWire.count() >= 1, true)
|
|
93
|
+
|
|
94
|
+
emitWire(101)
|
|
95
|
+
emitWire(202)
|
|
96
|
+
emitWire(303)
|
|
97
|
+
await delay(200) // real WS latency for emitted ticks to arrive
|
|
98
|
+
|
|
99
|
+
await check('wire: ticks arrive end-to-end', () => received.slice(), [101, 202, 303])
|
|
100
|
+
// slim view sees the SAME live subscriber count as the full wire handle
|
|
101
|
+
await check('wire: slim view agrees with full handle count', () => slimWire.count(), fullWire.count())
|
|
102
|
+
|
|
103
|
+
// unsubscribe over the wire and confirm no further ticks
|
|
104
|
+
sub()
|
|
105
|
+
await delay(150)
|
|
106
|
+
emitWire(404)
|
|
107
|
+
await delay(150)
|
|
108
|
+
await check('wire: off() stops end-to-end delivery', () => received.slice(), [101, 202, 303])
|
|
109
|
+
|
|
110
|
+
clearTimeout(watchdog)
|
|
111
|
+
cli.close()
|
|
112
|
+
await srv.close()
|
|
113
|
+
process.exit(done() === 0 ? 0 : 1)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
main().catch(e => { console.error(e); process.exit(2) })
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// REAL-SOCKET stress: MASS / volume over a genuine WebSocket. Port 4111.
|
|
2
|
+
// (1) 500+ concurrent CALLs — id-pool integrity, no dropped/mismatched RESP.
|
|
3
|
+
// (2) 50+ concurrent subscribers + 100+ high-freq ticks — no loss, clean teardown to 0.
|
|
4
|
+
// (3) mixed concurrent calls + streams.
|
|
5
|
+
import {startRealServer, startRealClient, makeChecker, delay} from './_rs'
|
|
6
|
+
import {listen as createListenPair} from '../../src/Common/events/Listen'
|
|
7
|
+
|
|
8
|
+
const PORT = 4111
|
|
9
|
+
|
|
10
|
+
// ===================================================================
|
|
11
|
+
// facade — captured emit handles (single client connection ⇒ makeObject
|
|
12
|
+
// runs once; we stash the per-connection emitters at module scope).
|
|
13
|
+
// ===================================================================
|
|
14
|
+
let emitTick: ((n: number) => void) | null = null // stream A: high-frequency ticks
|
|
15
|
+
let emitMix: ((n: number) => void) | null = null // stream B: used in the mixed test
|
|
16
|
+
|
|
17
|
+
function makeObject() {
|
|
18
|
+
const [tick, tickListen] = createListenPair<number>()
|
|
19
|
+
const [mix, mixListen] = createListenPair<number>()
|
|
20
|
+
emitTick = tick
|
|
21
|
+
emitMix = mix
|
|
22
|
+
return {
|
|
23
|
+
// identity call — server echoes the request index back; lets us assert
|
|
24
|
+
// every one of N concurrent responses landed on the right promise.
|
|
25
|
+
idn: async (i: number) => i,
|
|
26
|
+
// arithmetic so a mismatched correlation would produce a wrong value, not just a wrong tag.
|
|
27
|
+
sq: async (i: number) => i * i,
|
|
28
|
+
ticks: tickListen,
|
|
29
|
+
mixStream: mixListen,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function main() {
|
|
34
|
+
const {check, done} = makeChecker('stress')
|
|
35
|
+
const watchdog = setTimeout(() => { console.error('WATCHDOG timeout'); process.exit(3) }, 40000)
|
|
36
|
+
|
|
37
|
+
// capture the per-connection server api (for subscriptions() leak checks)
|
|
38
|
+
let srvApi: any = null
|
|
39
|
+
const srv = await startRealServer({port: PORT, makeObject, onServer: (api) => { srvApi = api }})
|
|
40
|
+
const cli = await startRealClient<ReturnType<typeof makeObject>>({port: PORT})
|
|
41
|
+
const api = cli.api
|
|
42
|
+
|
|
43
|
+
// =============================================================
|
|
44
|
+
// (1) MASS CALLs — 600 concurrent, assert every result correct
|
|
45
|
+
// =============================================================
|
|
46
|
+
const N = 600
|
|
47
|
+
const idxs = Array.from({length: N}, (_, i) => i)
|
|
48
|
+
const results = await Promise.all(idxs.map(i => api.idn(i)))
|
|
49
|
+
// every response matches its request index (no mismatched correlation)
|
|
50
|
+
await check('mass: 600 idn results in order', () => results, idxs)
|
|
51
|
+
await check('mass: zero mismatches', () => results.filter((v, i) => v !== i).length, 0)
|
|
52
|
+
|
|
53
|
+
// a second wave through the SAME id-pool — proves released ids are reusable & clean
|
|
54
|
+
const sqResults = await Promise.all(idxs.map(i => api.sq(i)))
|
|
55
|
+
await check('mass: 600 sq second wave', () => sqResults.filter((v, i) => v !== i * i).length, 0)
|
|
56
|
+
// id-pool fully drained back: no pending requests left hanging
|
|
57
|
+
await delay(50)
|
|
58
|
+
await check('mass: pending drained to 0', () => (cli.client.api as any).pending(), 0)
|
|
59
|
+
|
|
60
|
+
// =============================================================
|
|
61
|
+
// (2) MASS SUBSCRIBERS — 60 subscribers, 120 high-freq ticks
|
|
62
|
+
// =============================================================
|
|
63
|
+
const SUBS = 60
|
|
64
|
+
const TICKS = 120
|
|
65
|
+
const buckets: number[][] = Array.from({length: SUBS}, () => [])
|
|
66
|
+
// NB: callback() returns a callable off-handle (a thenable that resolves only
|
|
67
|
+
// when the STREAM ENDS) — do NOT await it, or Promise.all would block forever.
|
|
68
|
+
const offs = buckets.map((b) => (api.ticks as any).callback((v: number) => b.push(v)))
|
|
69
|
+
// give every subscription a round-trip to register on the server
|
|
70
|
+
await delay(120)
|
|
71
|
+
|
|
72
|
+
// Client-side dedup (on by default): 60 identical `ticks.callback(fn)` subscribers
|
|
73
|
+
// collapse to ONE wire subscription; the 60-way fan-out happens locally. So:
|
|
74
|
+
// • client subscriptions(): one entry, consumers == 60 (all local consumers)
|
|
75
|
+
// • server subscriptions(): one node, consumers == 1 (the single wire callback)
|
|
76
|
+
await check('subs: client one wire sub, 60 local consumers', () => {
|
|
77
|
+
const list = (cli.client.api as any).subscriptions() as {consumers: number}[]
|
|
78
|
+
return [list.length, list[0]?.consumers]
|
|
79
|
+
}, [1, SUBS])
|
|
80
|
+
await check('subs: server sees one wire consumer (dedup)', () => {
|
|
81
|
+
const subsList = srvApi.subscriptions() as {consumers: number}[]
|
|
82
|
+
return [subsList.length, subsList.reduce((a, e) => a + e.consumers, 0)]
|
|
83
|
+
}, [1, 1])
|
|
84
|
+
|
|
85
|
+
// high-frequency burst: no awaits between emits
|
|
86
|
+
for (let i = 0; i < TICKS; i++) emitTick!(i)
|
|
87
|
+
// generous drain for the real socket to deliver every CB
|
|
88
|
+
await delay(400)
|
|
89
|
+
|
|
90
|
+
// every subscriber received every tick — no loss, no dup
|
|
91
|
+
const expectedSeq = Array.from({length: TICKS}, (_, i) => i)
|
|
92
|
+
await check('subs: every bucket has all 120 ticks', () => buckets.filter(b => b.length !== TICKS).length, 0)
|
|
93
|
+
await check('subs: total CBs == 60*120', () => buckets.reduce((a, b) => a + b.length, 0), SUBS * TICKS)
|
|
94
|
+
await check('subs: bucket #0 exact sequence', () => buckets[0], expectedSeq)
|
|
95
|
+
await check('subs: bucket #59 exact sequence', () => buckets[SUBS - 1], expectedSeq)
|
|
96
|
+
|
|
97
|
+
// clean teardown — off() each, then both client and server registries empty
|
|
98
|
+
for (const off of offs) off()
|
|
99
|
+
await delay(200)
|
|
100
|
+
await check('subs: client wireSubs back to 0', () => (cli.client.api as any).subscriptions().length, 0)
|
|
101
|
+
await check('subs: server subscriptions back to 0', () => srvApi.subscriptions().length, 0)
|
|
102
|
+
|
|
103
|
+
// post-teardown ticks must reach nobody (no leak / dangling consumer)
|
|
104
|
+
const before = buckets.reduce((a, b) => a + b.length, 0)
|
|
105
|
+
for (let i = 0; i < 30; i++) emitTick!(999)
|
|
106
|
+
await delay(150)
|
|
107
|
+
await check('subs: no delivery after teardown', () => buckets.reduce((a, b) => a + b.length, 0), before)
|
|
108
|
+
|
|
109
|
+
// =============================================================
|
|
110
|
+
// (3) MIXED — concurrent calls + a fresh stream burst together
|
|
111
|
+
// =============================================================
|
|
112
|
+
const mixBuckets: number[][] = Array.from({length: 10}, () => [])
|
|
113
|
+
const mixOffs = mixBuckets.map(b => (api.mixStream as any).callback((v: number) => b.push(v)))
|
|
114
|
+
await delay(80)
|
|
115
|
+
// fire 200 calls AND 50 ticks interleaved
|
|
116
|
+
const callP = Promise.all(Array.from({length: 200}, (_, i) => api.sq(i)))
|
|
117
|
+
for (let i = 0; i < 50; i++) emitMix!(i)
|
|
118
|
+
const mixCalls = await callP
|
|
119
|
+
await delay(250)
|
|
120
|
+
await check('mixed: 200 calls all correct under stream load', () => mixCalls.filter((v, i) => v !== i * i).length, 0)
|
|
121
|
+
await check('mixed: each of 10 subs got all 50 ticks', () => mixBuckets.filter(b => b.length !== 50).length, 0)
|
|
122
|
+
for (const off of mixOffs) off()
|
|
123
|
+
await delay(150)
|
|
124
|
+
await check('mixed: server subscriptions back to 0', () => srvApi.subscriptions().length, 0)
|
|
125
|
+
|
|
126
|
+
clearTimeout(watchdog)
|
|
127
|
+
cli.close()
|
|
128
|
+
await srv.close()
|
|
129
|
+
process.exit(done() === 0 ? 0 : 1)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
main().catch(e => { console.error(e); process.exit(2) })
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Compile-only fixture (checked by clientapiall-replay-types.spec.ts via `tsc --noEmit`).
|
|
2
|
+
// Contract: replay-listen members of an rpc<T>() client project into the client replay
|
|
3
|
+
// surface (ReplaySocketListen) in BOTH typed paths (func/strict) — so
|
|
4
|
+
// `replaySubscribe(client.func.key, cb)` needs NO casts. Never executed at runtime.
|
|
5
|
+
|
|
6
|
+
import {createRpcClient} from '../../src/Common/rcp/rpc-client'
|
|
7
|
+
import type {ListenReplayApi, ReplayEvent} from '../../src/Common/events/replay-listen'
|
|
8
|
+
import {replaySubscribe} from '../../src/Common/events/replay-wire'
|
|
9
|
+
|
|
10
|
+
type tMsg = {text: string, n: number}
|
|
11
|
+
type tApi = {
|
|
12
|
+
hello(name: string): string
|
|
13
|
+
listenMsg: ListenReplayApi<[tMsg]>
|
|
14
|
+
nested: {inner: ListenReplayApi<[number, string]>}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// exact-type equality (invariant check, not just assignability)
|
|
18
|
+
type Eq<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false
|
|
19
|
+
|
|
20
|
+
declare const socket: any
|
|
21
|
+
|
|
22
|
+
export function compileCase() {
|
|
23
|
+
const client = createRpcClient<tApi>({socket, socketKey: 'x'})
|
|
24
|
+
|
|
25
|
+
// the projected member carries the exact envelope type on its line
|
|
26
|
+
type tProjectedEv = Parameters<Parameters<typeof client.func.listenMsg.line.on>[0]>[0]
|
|
27
|
+
const evExact: Eq<tProjectedEv, ReplayEvent<[tMsg]>> = true
|
|
28
|
+
void evExact
|
|
29
|
+
|
|
30
|
+
// inferred subscribe — no casts, cb args typed from the API shape
|
|
31
|
+
const sub = replaySubscribe(client.func.listenMsg, function onMsg(msg) {
|
|
32
|
+
const text: string = msg.text
|
|
33
|
+
void text
|
|
34
|
+
})
|
|
35
|
+
const seq: number = sub.seq()
|
|
36
|
+
void seq
|
|
37
|
+
sub()
|
|
38
|
+
|
|
39
|
+
// explicit generic — remote param accepted with no casts
|
|
40
|
+
const sub2 = replaySubscribe<[number, string]>(client.func.nested.inner, function onPair(n, s) {
|
|
41
|
+
const a: number = n
|
|
42
|
+
const b: string = s
|
|
43
|
+
void a; void b
|
|
44
|
+
})
|
|
45
|
+
sub2()
|
|
46
|
+
|
|
47
|
+
// strict path projects the same way
|
|
48
|
+
const sub3 = replaySubscribe(client.strict.listenMsg, function onMsg2(msg) {
|
|
49
|
+
const n: number = msg.n
|
|
50
|
+
void n
|
|
51
|
+
})
|
|
52
|
+
sub3()
|
|
53
|
+
|
|
54
|
+
// plain functions are untouched by the replay arm
|
|
55
|
+
const p: Promise<string> = client.func.hello('x')
|
|
56
|
+
void p
|
|
57
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAsyncQueue,
|
|
3
|
+
createReadyGate,
|
|
4
|
+
createThrottle,
|
|
5
|
+
enhancedQueueRun,
|
|
6
|
+
} from "../../src/Common/async/waitRun";
|
|
7
|
+
import {promiseProgress} from "../../src/Common/async/promiseProgress";
|
|
8
|
+
|
|
9
|
+
type Test = {
|
|
10
|
+
name: string;
|
|
11
|
+
fn: () => void | Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const tests: Test[] = [];
|
|
15
|
+
|
|
16
|
+
function test(name: string, fn: Test["fn"]) {
|
|
17
|
+
tests.push({name, fn});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function assert(condition: unknown, message: string): asserts condition {
|
|
21
|
+
if (!condition) throw new Error(message);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function assertEq<T>(actual: T, expected: T, message: string) {
|
|
25
|
+
if (actual !== expected) {
|
|
26
|
+
throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function assertRejects(promise: Promise<unknown>, expected: unknown, message: string) {
|
|
31
|
+
try {
|
|
32
|
+
await promise;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
assert(error === expected, message);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`${message}: promise resolved`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function assertThrows(fn: () => unknown, message: string) {
|
|
41
|
+
try {
|
|
42
|
+
fn();
|
|
43
|
+
} catch {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`${message}: function did not throw`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sleep(ms = 0) {
|
|
50
|
+
return new Promise<void>(resolve => setTimeout(resolve, ms));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test("createAsyncQueue rejects concurrency 0", () => {
|
|
54
|
+
assertThrows(() => createAsyncQueue(0), "concurrency 0 throws");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("createAsyncQueue resolves, rejects, limits concurrency, and onIdle waits", async () => {
|
|
58
|
+
const queue = createAsyncQueue(2);
|
|
59
|
+
const failure = new Error("queue failure");
|
|
60
|
+
const events: string[] = [];
|
|
61
|
+
let active = 0;
|
|
62
|
+
let maxActive = 0;
|
|
63
|
+
|
|
64
|
+
const makeTask = (name: string, ms: number, value: string, reject = false) => async () => {
|
|
65
|
+
events.push(`start:${name}`);
|
|
66
|
+
active++;
|
|
67
|
+
maxActive = Math.max(maxActive, active);
|
|
68
|
+
await sleep(ms);
|
|
69
|
+
active--;
|
|
70
|
+
events.push(`end:${name}`);
|
|
71
|
+
if (reject) throw failure;
|
|
72
|
+
return value;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const first = queue.add(makeTask("first", 8, "one"));
|
|
76
|
+
const second = queue.add(makeTask("second", 3, "two", true));
|
|
77
|
+
const third = queue.add(makeTask("third", 1, "three"));
|
|
78
|
+
const secondRejected = assertRejects(second, failure, "rejected task rejects add promise");
|
|
79
|
+
|
|
80
|
+
assertEq(queue.size, 1, "third task is queued while first two are active");
|
|
81
|
+
assertEq(await first, "one", "resolved task returns its value");
|
|
82
|
+
await secondRejected;
|
|
83
|
+
assertEq(await third, "three", "queue continues after rejected task");
|
|
84
|
+
await queue.onIdle();
|
|
85
|
+
|
|
86
|
+
assertEq(queue.size, 0, "queue is empty after onIdle");
|
|
87
|
+
assertEq(active, 0, "no tasks remain active after onIdle");
|
|
88
|
+
assert(maxActive <= 2, "queue never exceeds configured concurrency");
|
|
89
|
+
assert(events.includes("start:third"), "queued task eventually starts");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("createThrottle survives rejected throttled task", async () => {
|
|
93
|
+
const throttle = createThrottle();
|
|
94
|
+
const events: string[] = [];
|
|
95
|
+
|
|
96
|
+
throttle.throttle(0, async () => {
|
|
97
|
+
events.push("first");
|
|
98
|
+
throw new Error("ignored");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await sleep(5);
|
|
102
|
+
|
|
103
|
+
throttle.throttle(0, async () => {
|
|
104
|
+
events.push("second");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await sleep(5);
|
|
108
|
+
assertEq(events.join(","), "first,second", "throttle runs after a rejected task");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("enhancedQueueRun keeps draining after rejected tasks", async () => {
|
|
112
|
+
const queue = enhancedQueueRun(1);
|
|
113
|
+
const failure = new Error("enhanced failure");
|
|
114
|
+
const events: string[] = [];
|
|
115
|
+
|
|
116
|
+
queue.enqueue(async () => {
|
|
117
|
+
events.push("fire-and-forget reject");
|
|
118
|
+
throw failure;
|
|
119
|
+
});
|
|
120
|
+
queue.enqueue(async () => {
|
|
121
|
+
events.push("after fire-and-forget");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await queue.runAll();
|
|
125
|
+
|
|
126
|
+
await assertRejects(
|
|
127
|
+
queue.enqueueAndRun(async () => {
|
|
128
|
+
events.push("reported reject");
|
|
129
|
+
throw failure;
|
|
130
|
+
}),
|
|
131
|
+
failure,
|
|
132
|
+
"enqueueAndRun reports task rejection",
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
await queue.enqueueAndRun(async () => {
|
|
136
|
+
events.push("after reported reject");
|
|
137
|
+
});
|
|
138
|
+
await queue.runAll();
|
|
139
|
+
|
|
140
|
+
assertEq(
|
|
141
|
+
events.join(","),
|
|
142
|
+
"fire-and-forget reject,after fire-and-forget,reported reject,after reported reject",
|
|
143
|
+
"enhancedQueueRun continues after both swallowed and reported rejections",
|
|
144
|
+
);
|
|
145
|
+
assertEq(queue.queueSize, 0, "enhancedQueueRun queue is empty after runAll");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("promiseProgress all returns values and emits ok events", async () => {
|
|
149
|
+
const listened = promiseProgress<string>([
|
|
150
|
+
() => Promise.resolve("alpha"),
|
|
151
|
+
async () => {
|
|
152
|
+
await sleep(1);
|
|
153
|
+
return "beta";
|
|
154
|
+
},
|
|
155
|
+
]);
|
|
156
|
+
const okEvents: string[] = [];
|
|
157
|
+
const errorEvents: string[] = [];
|
|
158
|
+
|
|
159
|
+
listened.onOk((data, i, ok, errors, count) => {
|
|
160
|
+
okEvents.push(`${i}:${data}:${ok}:${errors}:${count}`);
|
|
161
|
+
});
|
|
162
|
+
listened.onError(error => {
|
|
163
|
+
errorEvents.push(String(error));
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const values = await listened.all();
|
|
167
|
+
|
|
168
|
+
assertEq(values.join(","), "alpha,beta", "promiseProgress all resolves values in input order");
|
|
169
|
+
assertEq(okEvents.join("|"), "0:alpha:1:0:2|1:beta:2:0:2", "ok events fire with counts");
|
|
170
|
+
assertEq(errorEvents.length, 0, "no error events fire for successful all");
|
|
171
|
+
assertEq(listened.stats().ok, 2, "status tracks ok count");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("promiseProgress all rejects on errors while events fire", async () => {
|
|
175
|
+
const failure = new Error("listen failure");
|
|
176
|
+
const listened = promiseProgress<string>([
|
|
177
|
+
() => Promise.resolve("alpha"),
|
|
178
|
+
() => Promise.reject(failure),
|
|
179
|
+
async () => {
|
|
180
|
+
await sleep(1);
|
|
181
|
+
return "gamma";
|
|
182
|
+
},
|
|
183
|
+
]);
|
|
184
|
+
const okEvents: string[] = [];
|
|
185
|
+
const errorEvents: string[] = [];
|
|
186
|
+
|
|
187
|
+
listened.onOk((data, i, ok, errors, count) => {
|
|
188
|
+
okEvents.push(`${i}:${data}:${ok}:${errors}:${count}`);
|
|
189
|
+
});
|
|
190
|
+
listened.onError((error, i, ok, errors, count) => {
|
|
191
|
+
errorEvents.push(`${i}:${error === failure}:${ok}:${errors}:${count}`);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await assertRejects(listened.all(), failure, "promiseProgress all rejects with task error");
|
|
195
|
+
await Promise.allSettled(listened.items());
|
|
196
|
+
|
|
197
|
+
assertEq(errorEvents.join("|"), "1:true:1:1:3", "error event fires with counts");
|
|
198
|
+
assertEq(okEvents.join("|"), "0:alpha:1:0:3|2:gamma:2:1:3", "ok events still fire around rejection");
|
|
199
|
+
assertEq(listened.stats().error, 1, "status tracks error count");
|
|
200
|
+
assertEq(listened.stats().ok, 2, "status tracks successful count after rejection");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("ReadyGate continues after thrown task and clears queued tasks", async () => {
|
|
204
|
+
const gate = createReadyGate();
|
|
205
|
+
const failure = new Error("ready failure");
|
|
206
|
+
const events: string[] = [];
|
|
207
|
+
|
|
208
|
+
gate.add(() => {
|
|
209
|
+
events.push("first");
|
|
210
|
+
throw failure;
|
|
211
|
+
});
|
|
212
|
+
gate.add(async () => {
|
|
213
|
+
await sleep(1);
|
|
214
|
+
events.push("second");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
assertEq(gate.tasks().length, 2, "gate stores tasks before ready");
|
|
218
|
+
await assertRejects(gate.ready(), failure, "ready reports first thrown task");
|
|
219
|
+
|
|
220
|
+
assertEq(events.join(","), "first,second", "ready continues after thrown task");
|
|
221
|
+
assertEq(gate.tasks().length, 0, "ready clears queued tasks");
|
|
222
|
+
assert(gate.isReady(), "gate remains ready after thrown task");
|
|
223
|
+
|
|
224
|
+
gate.add(() => {
|
|
225
|
+
events.push("after-ready");
|
|
226
|
+
});
|
|
227
|
+
assertEq(events.join(","), "first,second,after-ready", "tasks added after ready run immediately");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
async function main() {
|
|
231
|
+
let failed = 0;
|
|
232
|
+
|
|
233
|
+
for (const {name, fn} of tests) {
|
|
234
|
+
try {
|
|
235
|
+
await fn();
|
|
236
|
+
console.log(`ok - ${name}`);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
failed++;
|
|
239
|
+
console.error(`not ok - ${name}`);
|
|
240
|
+
console.error(error);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (failed > 0) {
|
|
245
|
+
throw new Error(`${failed} async queue regression test(s) failed`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
console.log(`${tests.length} async queue regression tests passed`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
main().catch(error => {
|
|
252
|
+
console.error(error);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
});
|