wenay-common2 1.0.69 → 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 +10 -1
- package/demo/client.ts +100 -0
- package/demo/index.html +28 -0
- package/demo/server.ts +43 -0
- package/doc/changes/1.0.70.md +4 -0
- 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 +6 -1
- 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,199 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// replay/socket-replay.test.ts
|
|
3
|
+
//
|
|
4
|
+
// Слой B через НАСТОЯЩИЙ Socket.IO localhost WebSocket:
|
|
5
|
+
// зеркало стора по replay-линии (keyframe + seq-дельты).
|
|
6
|
+
// Запуск:
|
|
7
|
+
// npx ts-node replay/socket-replay.test.ts
|
|
8
|
+
// ============================================================
|
|
9
|
+
|
|
10
|
+
import express from 'express'
|
|
11
|
+
import {createServer} from 'http'
|
|
12
|
+
import type {AddressInfo} from 'net'
|
|
13
|
+
import {Server as SocketIOServer} from 'socket.io'
|
|
14
|
+
import {io} from 'socket.io-client'
|
|
15
|
+
import {listen as createListenPair} from '../src/Common/events/Listen'
|
|
16
|
+
import {createRpcClientHub} from '../src/Common/rcp/rpc-clientHub'
|
|
17
|
+
import {createRpcServerAuto} from '../src/Common/rcp/rpc-server-auto'
|
|
18
|
+
import {createStore, StorePatch} from '../src/Common/Observe/store'
|
|
19
|
+
import {flushReactive} from '../src/Common/Observe/reactive'
|
|
20
|
+
import {exposeStoreReplay, syncStoreReplay} from '../src/Common/Observe/store-replay'
|
|
21
|
+
import {ReplayRemote} from '../src/Common/events/replay-index'
|
|
22
|
+
|
|
23
|
+
let fails = 0
|
|
24
|
+
const ok = (condition: any, message: string) => {
|
|
25
|
+
if (!condition) { fails++; console.log(' FAIL', message) }
|
|
26
|
+
else console.log(' OK ', message)
|
|
27
|
+
}
|
|
28
|
+
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
|
29
|
+
const json = (v: any) => JSON.stringify(v)
|
|
30
|
+
|
|
31
|
+
async function waitFor(label: string, cond: () => boolean) {
|
|
32
|
+
for (let i = 0; i < 120; i++) {
|
|
33
|
+
if (cond()) return
|
|
34
|
+
await delay(25)
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`timeout: ${label}`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type World = {
|
|
40
|
+
units: Record<string, {hp: number, x: number}>
|
|
41
|
+
tick: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function startRealServer(object: object) {
|
|
45
|
+
const app = express()
|
|
46
|
+
const httpServer = createServer(app)
|
|
47
|
+
const ioServer = new SocketIOServer(httpServer, {maxHttpBufferSize: 1e8})
|
|
48
|
+
ioServer.on('connection', socket => {
|
|
49
|
+
const [disconnect, disconnectListen] = createListenPair<[]>()
|
|
50
|
+
socket.on('disconnect', () => disconnect())
|
|
51
|
+
createRpcServerAuto({
|
|
52
|
+
socket: {
|
|
53
|
+
emit: (key, data) => socket.emit(key, data),
|
|
54
|
+
on: (key, cb) => socket.on(key, cb),
|
|
55
|
+
},
|
|
56
|
+
socketKey: 'replay-store',
|
|
57
|
+
object,
|
|
58
|
+
disconnectListen,
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
await new Promise<void>((resolve, reject) => {
|
|
62
|
+
httpServer.once('error', reject)
|
|
63
|
+
httpServer.listen(0, '127.0.0.1', resolve)
|
|
64
|
+
})
|
|
65
|
+
const port = (httpServer.address() as AddressInfo).port
|
|
66
|
+
console.log(` [server] listening on 127.0.0.1:${port}`)
|
|
67
|
+
return {
|
|
68
|
+
port,
|
|
69
|
+
close: () => new Promise<void>(resolve => {
|
|
70
|
+
ioServer.close()
|
|
71
|
+
httpServer.close(() => resolve())
|
|
72
|
+
}),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function startRealClient<T extends object>(port: number) {
|
|
77
|
+
const hub = createRpcClientHub(
|
|
78
|
+
() => io(`http://127.0.0.1:${port}`, {transports: ['websocket'], forceNew: true}),
|
|
79
|
+
r => ({api: r<T>('replay-store')}) as const,
|
|
80
|
+
)
|
|
81
|
+
const clients = await hub.setToken(null)
|
|
82
|
+
await clients.api.readyStrict()
|
|
83
|
+
return {
|
|
84
|
+
client: clients.api,
|
|
85
|
+
close: () => hub.socket?.disconnect?.(),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function main() {
|
|
90
|
+
console.log('\n[socket-replay] store mirror over a real Socket.IO wire')
|
|
91
|
+
|
|
92
|
+
const backend = createStore<World>({units: {alpha: {hp: 100, x: 0}}, tick: 0}, {drain: 'micro'})
|
|
93
|
+
const exposed = exposeStoreReplay(backend, {history: 6})
|
|
94
|
+
|
|
95
|
+
// счётчики серверной цены: pull-ов быть не должно, keyframe — только по fallback
|
|
96
|
+
const counters = {get: 0, since: 0, keyframe: 0}
|
|
97
|
+
const facade = {
|
|
98
|
+
...exposed.api,
|
|
99
|
+
get: (mask?: any) => { counters.get++; return exposed.api.get(mask) },
|
|
100
|
+
replay: {
|
|
101
|
+
...exposed.api.replay,
|
|
102
|
+
since: (s: number) => { counters.since++; return exposed.api.replay.since(s) },
|
|
103
|
+
keyframe: () => { counters.keyframe++; return exposed.api.replay.keyframe() },
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type Client = Awaited<ReturnType<typeof startRealClient<typeof facade>>>
|
|
108
|
+
const server = await startRealServer(facade)
|
|
109
|
+
let c1: Client | null = null
|
|
110
|
+
let c2: Client | null = null
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
// ============ свежий клиент: keyframe + live ============
|
|
114
|
+
c1 = await startRealClient<typeof facade>(server.port)
|
|
115
|
+
const deep1 = c1.client.func as any
|
|
116
|
+
const remote1: ReplayRemote<[StorePatch]> = {
|
|
117
|
+
line: deep1.replay.line,
|
|
118
|
+
since: (s: number) => deep1.replay.since(s),
|
|
119
|
+
keyframe: () => deep1.replay.keyframe(),
|
|
120
|
+
}
|
|
121
|
+
const mirror = createStore<World>({units: {}, tick: -1})
|
|
122
|
+
let lastSeq = -1
|
|
123
|
+
const sub = syncStoreReplay(mirror, remote1, {onSeq: s => lastSeq = s})
|
|
124
|
+
await sub.ready
|
|
125
|
+
ok(json(mirror.state) == json(backend.snapshot()), 'fresh client converged via keyframe over the wire')
|
|
126
|
+
ok(counters.keyframe == 1 && counters.since == 0, 'exactly one keyframe request, no tail request')
|
|
127
|
+
|
|
128
|
+
backend.state.units['beta'] = {hp: 50, x: 5}
|
|
129
|
+
backend.state.tick = 1
|
|
130
|
+
await flushReactive(backend.state)
|
|
131
|
+
await waitFor('live patches over wire', () => mirror.state.tick == 1 && 'beta' in mirror.state.units)
|
|
132
|
+
ok(json(mirror.state) == json(backend.snapshot()), 'live patches keep the wire mirror converged')
|
|
133
|
+
ok(counters.get == 0, 'pure push: server get() was never pulled')
|
|
134
|
+
|
|
135
|
+
// ============ обрыв связи, изменения в оффлайне ============
|
|
136
|
+
sub()
|
|
137
|
+
c1.close()
|
|
138
|
+
c1 = null
|
|
139
|
+
await delay(30)
|
|
140
|
+
|
|
141
|
+
backend.state.units['alpha'].hp = 75
|
|
142
|
+
await flushReactive(backend.state)
|
|
143
|
+
backend.state.tick = 2
|
|
144
|
+
await flushReactive(backend.state)
|
|
145
|
+
|
|
146
|
+
// ============ реконнект с since → только хвост ============
|
|
147
|
+
const kfBefore = counters.keyframe
|
|
148
|
+
c2 = await startRealClient<typeof facade>(server.port)
|
|
149
|
+
const deep2 = c2.client.func as any
|
|
150
|
+
const remote2: ReplayRemote<[StorePatch]> = {
|
|
151
|
+
line: deep2.replay.line,
|
|
152
|
+
since: (s: number) => deep2.replay.since(s),
|
|
153
|
+
keyframe: () => deep2.replay.keyframe(),
|
|
154
|
+
}
|
|
155
|
+
let tailPatches = 0
|
|
156
|
+
const sub2 = syncStoreReplay(mirror, remote2, {
|
|
157
|
+
since: lastSeq,
|
|
158
|
+
onSeq: s => { lastSeq = s; tailPatches++ },
|
|
159
|
+
})
|
|
160
|
+
await sub2.ready
|
|
161
|
+
ok(json(mirror.state) == json(backend.snapshot()), 'reconnect with since converged over the wire')
|
|
162
|
+
ok(counters.keyframe == kfBefore, 'reconnect did NOT cost a snapshot — tail of patches sufficed')
|
|
163
|
+
ok(tailPatches == 2, `tail was exactly the missed patches (got ${tailPatches})`)
|
|
164
|
+
|
|
165
|
+
// ============ долгий оффлайн → журнал (history: 6) вытеснен → keyframe ============
|
|
166
|
+
sub2()
|
|
167
|
+
for (let i = 3; i <= 30; i++) {
|
|
168
|
+
backend.state.tick = i
|
|
169
|
+
await flushReactive(backend.state)
|
|
170
|
+
}
|
|
171
|
+
let resyncDeliveries = 0
|
|
172
|
+
const sub3 = syncStoreReplay(mirror, remote2, {
|
|
173
|
+
since: lastSeq,
|
|
174
|
+
onSeq: s => { lastSeq = s; resyncDeliveries++ },
|
|
175
|
+
})
|
|
176
|
+
await sub3.ready
|
|
177
|
+
ok(json(mirror.state) == json(backend.snapshot()), 'evicted journal → fresh keyframe converged')
|
|
178
|
+
ok(counters.keyframe == kfBefore + 1, 'fallback took exactly one fresh keyframe')
|
|
179
|
+
ok(resyncDeliveries == 1, `no backlog: 28 missed patches collapsed into 1 keyframe (got ${resyncDeliveries})`)
|
|
180
|
+
|
|
181
|
+
// live после fallback продолжает идти
|
|
182
|
+
backend.state.tick = 31
|
|
183
|
+
await flushReactive(backend.state)
|
|
184
|
+
await waitFor('live after fallback', () => mirror.state.tick == 31)
|
|
185
|
+
ok(true, 'live stream continues after keyframe recovery')
|
|
186
|
+
sub3()
|
|
187
|
+
|
|
188
|
+
console.log(fails ? `\n${fails} FAILED` : '\nall passed')
|
|
189
|
+
} finally {
|
|
190
|
+
c1?.close()
|
|
191
|
+
c2?.close()
|
|
192
|
+
await delay(20)
|
|
193
|
+
exposed.close()
|
|
194
|
+
await server.close()
|
|
195
|
+
}
|
|
196
|
+
if (fails) process.exit(1)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
main().catch(e => { console.error(e); process.exit(1) })
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// replay/staleness.test.ts
|
|
3
|
+
//
|
|
4
|
+
// Вотчдог тухлости: продьюсер (withReplayListen {staleMs, onStale},
|
|
5
|
+
// isStale()/lastTs()) и клиент (replaySubscribe {staleMs, onStale,
|
|
6
|
+
// skewMs}, off.isStale()/off.lastTs()). Сценарии: молчащая линия
|
|
7
|
+
// (edge в обе стороны, не будильник); холодная линия без таймера;
|
|
8
|
+
// ленивые геттеры без onStale; тухлый keyframe — stale сразу по
|
|
9
|
+
// доставке; НЕТ флапа на историческом хвосте since-catch-up;
|
|
10
|
+
// допуск skewMs; снятие таймеров off()/close().
|
|
11
|
+
// Запуск:
|
|
12
|
+
// npx ts-node replay/staleness.test.ts
|
|
13
|
+
// ============================================================
|
|
14
|
+
|
|
15
|
+
import {replayListen, exposeReplay, replaySubscribe, StaleInfo, ReplayRemote} from '../src/Common/events/replay-index'
|
|
16
|
+
|
|
17
|
+
let fails = 0
|
|
18
|
+
const ok = (condition: any, message: string) => {
|
|
19
|
+
if (!condition) { fails++; console.log(' FAIL', message) }
|
|
20
|
+
else console.log(' OK ', message)
|
|
21
|
+
}
|
|
22
|
+
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
|
23
|
+
const edgesOf = (list: StaleInfo[]) => list.map(i => i.stale ? 'T' : 'F').join(',')
|
|
24
|
+
|
|
25
|
+
async function main() {
|
|
26
|
+
console.log('\n[staleness] producer: silent line, edge-triggered both directions')
|
|
27
|
+
{
|
|
28
|
+
const edges: StaleInfo[] = []
|
|
29
|
+
const [emit, listen] = replayListen<[number]>({history: 8, staleMs: 40, onStale: i => edges.push(i)})
|
|
30
|
+
emit(1)
|
|
31
|
+
ok(!listen.isStale() && listen.lastTs() > 0, 'fresh right after emit')
|
|
32
|
+
await delay(100)
|
|
33
|
+
ok(edgesOf(edges) == 'T', `fresh->stale fired exactly once, not a repeating alarm (${edgesOf(edges)})`)
|
|
34
|
+
ok(listen.isStale(), 'isStale() agrees while silent')
|
|
35
|
+
ok(edges[0].age >= 40 && edges[0].lastTs == listen.lastTs(), 'edge info carries lastTs/age')
|
|
36
|
+
emit(2)
|
|
37
|
+
ok(edgesOf(edges) == 'T,F', 'stale->fresh fired on the reviving emit')
|
|
38
|
+
ok(!listen.isStale(), 'fresh again')
|
|
39
|
+
await delay(100)
|
|
40
|
+
ok(edgesOf(edges) == 'T,F,T', 'goes stale again after the next silence')
|
|
41
|
+
listen.close()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
console.log('\n[staleness] producer: cold line stays free')
|
|
45
|
+
{
|
|
46
|
+
const edges: StaleInfo[] = []
|
|
47
|
+
const [, listen] = replayListen<[number]>({history: 8, staleMs: 30, onStale: i => edges.push(i)})
|
|
48
|
+
await delay(90)
|
|
49
|
+
ok(edges.length == 0, 'no events ever -> watchdog never armed, no edges')
|
|
50
|
+
ok(!listen.isStale() && listen.lastTs() == 0, 'cold line is not stale (freshness unknown, not expired)')
|
|
51
|
+
listen.close()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
console.log('\n[staleness] producer: lazy getters without onStale (no timer needed)')
|
|
55
|
+
{
|
|
56
|
+
const [emit, listen] = replayListen<[number]>({history: 8, staleMs: 30})
|
|
57
|
+
emit(1)
|
|
58
|
+
await delay(70)
|
|
59
|
+
ok(listen.isStale(), 'isStale() computes lazily, no onStale installed')
|
|
60
|
+
emit(2)
|
|
61
|
+
ok(!listen.isStale(), 'revives on emit')
|
|
62
|
+
listen.close()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log('\n[staleness] producer: close() disarms the watchdog')
|
|
66
|
+
{
|
|
67
|
+
const edges: StaleInfo[] = []
|
|
68
|
+
const [emit, listen] = replayListen<[number]>({history: 8, staleMs: 30, onStale: i => edges.push(i)})
|
|
69
|
+
emit(1)
|
|
70
|
+
listen.close()
|
|
71
|
+
await delay(90)
|
|
72
|
+
ok(edges.length == 0, 'no stale edge after close()')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log('\n[staleness] client: silent line over the wire shape (arrival gap)')
|
|
76
|
+
{
|
|
77
|
+
const [emit, listen] = replayListen<[number]>({history: 8})
|
|
78
|
+
const remote = exposeReplay(listen) as unknown as ReplayRemote<[number]>
|
|
79
|
+
const edges: StaleInfo[] = []
|
|
80
|
+
const seen: number[] = []
|
|
81
|
+
const sub = replaySubscribe(remote, v => seen.push(v), {staleMs: 40, onStale: i => edges.push(i)})
|
|
82
|
+
await sub.ready
|
|
83
|
+
emit(1)
|
|
84
|
+
ok(!sub.isStale() && sub.lastTs() == listen.lastTs(), 'fresh after delivery, lastTs mirrors the envelope')
|
|
85
|
+
await delay(100)
|
|
86
|
+
ok(edgesOf(edges) == 'T', `silent wire -> one stale edge (${edgesOf(edges)})`)
|
|
87
|
+
ok(sub.isStale(), 'off.isStale() agrees')
|
|
88
|
+
emit(2)
|
|
89
|
+
ok(edgesOf(edges) == 'T,F' && seen.join(',') == '1,2', 'delivery revives: fresh edge once, data intact')
|
|
90
|
+
await delay(100)
|
|
91
|
+
ok(edgesOf(edges) == 'T,F,T', 'stale again after the next silence')
|
|
92
|
+
sub()
|
|
93
|
+
emit(3)
|
|
94
|
+
await delay(100)
|
|
95
|
+
ok(edgesOf(edges) == 'T,F,T', 'off() disarms the client watchdog — no edges after unsubscribe')
|
|
96
|
+
listen.close()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log('\n[staleness] client: dead producer at connect (silent from birth)')
|
|
100
|
+
{
|
|
101
|
+
const [, listen] = replayListen<[number]>({history: 8})
|
|
102
|
+
const remote = exposeReplay(listen) as unknown as ReplayRemote<[number]>
|
|
103
|
+
const edges: StaleInfo[] = []
|
|
104
|
+
const sub = replaySubscribe(remote, () => {}, {staleMs: 40, onStale: i => edges.push(i)})
|
|
105
|
+
await sub.ready
|
|
106
|
+
await delay(100)
|
|
107
|
+
ok(edgesOf(edges) == 'T', 'no envelope ever -> stale from the subscribe-time arrival gap')
|
|
108
|
+
const lazy = replaySubscribe(remote, () => {}, {staleMs: 40})
|
|
109
|
+
await lazy.ready
|
|
110
|
+
await delay(80)
|
|
111
|
+
ok(lazy.isStale(), 'isStale() works lazily without onStale (no timer)')
|
|
112
|
+
sub(); lazy()
|
|
113
|
+
listen.close()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.log('\n[staleness] client: stale keyframe reported IMMEDIATELY at delivery')
|
|
117
|
+
{
|
|
118
|
+
let state = 7
|
|
119
|
+
// часы продьюсера «остановились» 500 мс назад — keyframe приедет сейчас, но с старым ts
|
|
120
|
+
const producerNow = () => Date.now() - 500
|
|
121
|
+
const [, listen] = replayListen<[number]>({history: 8, current: () => [state], now: producerNow})
|
|
122
|
+
const remote = exposeReplay(listen) as unknown as ReplayRemote<[number]>
|
|
123
|
+
const edges: StaleInfo[] = []
|
|
124
|
+
const seen: number[] = []
|
|
125
|
+
const sub = replaySubscribe(remote, v => seen.push(v), {staleMs: 100, onStale: i => edges.push(i)})
|
|
126
|
+
await sub.ready
|
|
127
|
+
ok(seen.join(',') == '7', 'keyframe delivered')
|
|
128
|
+
ok(edgesOf(edges) == 'T', 'stale edge right after catch-up — no staleMs of waiting')
|
|
129
|
+
ok(sub.isStale() && sub.lastTs() <= Date.now() - 500 + 5, 'lastTs is the old producer ts')
|
|
130
|
+
sub()
|
|
131
|
+
listen.close()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log('\n[staleness] client: historical since-tail does NOT flap')
|
|
135
|
+
{
|
|
136
|
+
let t = Date.now() - 300
|
|
137
|
+
const [emit, listen] = replayListen<[number]>({history: 16, now: () => t})
|
|
138
|
+
emit(1); emit(2); emit(3) // старый хвост (ts на 300 мс в прошлом)
|
|
139
|
+
t = Date.now()
|
|
140
|
+
emit(4); emit(5) // голова свежая
|
|
141
|
+
const remote = exposeReplay(listen) as unknown as ReplayRemote<[number]>
|
|
142
|
+
const edges: StaleInfo[] = []
|
|
143
|
+
const seen: number[] = []
|
|
144
|
+
const sub = replaySubscribe(remote, v => seen.push(v), {since: 0, staleMs: 100, onStale: i => edges.push(i)})
|
|
145
|
+
await sub.ready
|
|
146
|
+
ok(seen.join(',') == '1,2,3,4,5', 'full tail replayed')
|
|
147
|
+
ok(edges.length == 0, 'old tail ts never flapped stale mid-catch-up; head is fresh -> no edge at all')
|
|
148
|
+
ok(!sub.isStale(), 'fresh after handover')
|
|
149
|
+
sub()
|
|
150
|
+
listen.close()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
console.log('\n[staleness] client: skewMs tolerance for producer/client clock disagreement')
|
|
154
|
+
{
|
|
155
|
+
let state = 1
|
|
156
|
+
const skewedNow = () => Date.now() - 80 // продьюсер отстаёт на 80 мс
|
|
157
|
+
const [, listen] = replayListen<[number]>({history: 8, current: () => [state], now: skewedNow})
|
|
158
|
+
const remote = exposeReplay(listen) as unknown as ReplayRemote<[number]>
|
|
159
|
+
const strict: StaleInfo[] = []
|
|
160
|
+
const s1 = replaySubscribe(remote, () => {}, {staleMs: 50, onStale: i => strict.push(i)})
|
|
161
|
+
await s1.ready
|
|
162
|
+
ok(edgesOf(strict) == 'T', 'skewMs=0: an 80ms-old ts trips the 50ms threshold at delivery')
|
|
163
|
+
s1()
|
|
164
|
+
const tolerant: StaleInfo[] = []
|
|
165
|
+
const s2 = replaySubscribe(remote, () => {}, {staleMs: 50, skewMs: 100, onStale: i => tolerant.push(i)})
|
|
166
|
+
await s2.ready
|
|
167
|
+
ok(tolerant.length == 0, 'skewMs=100 absorbs the clock offset — no false stale')
|
|
168
|
+
s2()
|
|
169
|
+
listen.close()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
console.log(fails ? `\n${fails} FAILED` : '\nall passed')
|
|
173
|
+
if (fails) process.exit(1)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
main().catch(e => { console.error(e); process.exit(1) })
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import {createStore} from '../src/Common/Observe/store'
|
|
2
|
+
import {flushReactive} from '../src/Common/Observe/reactive'
|
|
3
|
+
import {exposeStoreReplay, syncStoreReplay} from '../src/Common/Observe/store-replay'
|
|
4
|
+
import {ReplayRemote} from '../src/Common/events/replay-index'
|
|
5
|
+
|
|
6
|
+
let fails = 0
|
|
7
|
+
const ok = (condition: any, message: string) => {
|
|
8
|
+
if (!condition) { fails++; console.log(' FAIL', message) }
|
|
9
|
+
else console.log(' OK ', message)
|
|
10
|
+
}
|
|
11
|
+
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
|
12
|
+
const json = (v: any) => JSON.stringify(v)
|
|
13
|
+
|
|
14
|
+
type World = {
|
|
15
|
+
units: Record<string, {hp: number, x: number}>
|
|
16
|
+
tick: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// in-proc «провод»: методы становятся async с задержкой — как настоящий RPC
|
|
20
|
+
function makeRemote(exposed: ReturnType<typeof exposeStoreReplay<World>>, lag = 15) {
|
|
21
|
+
const counters = {since: 0, keyframe: 0}
|
|
22
|
+
const remote: ReplayRemote<any> = {
|
|
23
|
+
line: exposed.replay.line,
|
|
24
|
+
since: async (s: number) => { counters.since++; await delay(lag); return exposed.replay.getSince(s) ?? null },
|
|
25
|
+
keyframe: async () => { counters.keyframe++; await delay(lag); return exposed.replay.keyframe() ?? null },
|
|
26
|
+
}
|
|
27
|
+
return {remote, counters}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ascendingUnique = (seqs: number[]) => seqs.every((s, i) => i == 0 || s > seqs[i - 1])
|
|
31
|
+
|
|
32
|
+
async function main() {
|
|
33
|
+
console.log('\n[store-replay] fresh mirror: keyframe as root patch')
|
|
34
|
+
{
|
|
35
|
+
const backend = createStore<World>({units: {a: {hp: 100, x: 0}}, tick: 0})
|
|
36
|
+
const exposed = exposeStoreReplay(backend, {history: 100})
|
|
37
|
+
const {remote, counters} = makeRemote(exposed)
|
|
38
|
+
const mirror = createStore<World>({units: {}, tick: -1})
|
|
39
|
+
const seqs: number[] = []
|
|
40
|
+
const sub = syncStoreReplay(mirror, remote, {onSeq: s => seqs.push(s)})
|
|
41
|
+
await sub.ready
|
|
42
|
+
ok(json(mirror.state) == json(backend.snapshot()), 'since<0 → keyframe → mirror equals backend')
|
|
43
|
+
ok(counters.keyframe == 1 && counters.since == 0, 'keyframe requested exactly once, no tail call')
|
|
44
|
+
|
|
45
|
+
backend.state.units['b'] = {hp: 50, x: 5}
|
|
46
|
+
backend.state.tick = 1
|
|
47
|
+
await flushReactive(backend.state)
|
|
48
|
+
await delay(5)
|
|
49
|
+
ok(json(mirror.state) == json(backend.snapshot()), 'live patches keep mirror converged')
|
|
50
|
+
ok(ascendingUnique(seqs), `delivered seqs strictly ascending: ${seqs.join(',')}`)
|
|
51
|
+
sub()
|
|
52
|
+
exposed.close()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log('\n[store-replay] reconnect with since → tail only, no snapshot')
|
|
56
|
+
{
|
|
57
|
+
const backend = createStore<World>({units: {a: {hp: 100, x: 0}}, tick: 0})
|
|
58
|
+
const exposed = exposeStoreReplay(backend, {history: 100})
|
|
59
|
+
const {remote} = makeRemote(exposed)
|
|
60
|
+
const mirror = createStore<World>({units: {}, tick: -1})
|
|
61
|
+
const sub = syncStoreReplay(mirror, remote)
|
|
62
|
+
await sub.ready
|
|
63
|
+
sub() // «обрыв связи»
|
|
64
|
+
|
|
65
|
+
backend.state.units['a'].hp = 75
|
|
66
|
+
backend.state.units['c'] = {hp: 10, x: 9}
|
|
67
|
+
await flushReactive(backend.state)
|
|
68
|
+
backend.state.tick = 2
|
|
69
|
+
await flushReactive(backend.state)
|
|
70
|
+
|
|
71
|
+
const {remote: remote2, counters: counters2} = makeRemote(exposed)
|
|
72
|
+
const sub2 = syncStoreReplay(mirror, remote2, {since: sub.seq()})
|
|
73
|
+
await sub2.ready
|
|
74
|
+
ok(json(mirror.state) == json(backend.snapshot()), 'reconnect via since converges')
|
|
75
|
+
ok(counters2.keyframe == 0 && counters2.since == 1, 'reconnect cost = tail of patches, NOT a snapshot')
|
|
76
|
+
sub2()
|
|
77
|
+
exposed.close()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log('\n[store-replay] long offline → journal evicted → fresh keyframe, no backlog')
|
|
81
|
+
{
|
|
82
|
+
const backend = createStore<World>({units: {}, tick: 0})
|
|
83
|
+
const exposed = exposeStoreReplay(backend, {history: 3})
|
|
84
|
+
const {remote} = makeRemote(exposed)
|
|
85
|
+
const mirror = createStore<World>({units: {}, tick: -1})
|
|
86
|
+
const sub = syncStoreReplay(mirror, remote)
|
|
87
|
+
await sub.ready
|
|
88
|
+
sub()
|
|
89
|
+
|
|
90
|
+
for (let i = 1; i <= 20; i++) {
|
|
91
|
+
backend.state.tick = i
|
|
92
|
+
await flushReactive(backend.state)
|
|
93
|
+
}
|
|
94
|
+
let applied = 0
|
|
95
|
+
const {remote: remote2, counters: counters2} = makeRemote(exposed)
|
|
96
|
+
const counting: ReplayRemote<any> = {...remote2, line: {on: (cb: any) => remote2.line.on((ev: any) => { applied++; cb(ev) })}}
|
|
97
|
+
const sub2 = syncStoreReplay(mirror, counting, {since: sub.seq()})
|
|
98
|
+
await sub2.ready
|
|
99
|
+
ok(json(mirror.state) == json(backend.snapshot()), 'evicted → keyframe fallback converges')
|
|
100
|
+
ok(counters2.keyframe == 1, 'fallback took exactly one fresh keyframe')
|
|
101
|
+
ok(applied == 0, 'NO backlog: 20 offline patches never traveled')
|
|
102
|
+
sub2()
|
|
103
|
+
exposed.close()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log('\n[store-replay] events racing the catch-up window (async handover)')
|
|
107
|
+
{
|
|
108
|
+
const backend = createStore<World>({units: {a: {hp: 1, x: 1}}, tick: 0})
|
|
109
|
+
const exposed = exposeStoreReplay(backend, {history: 100})
|
|
110
|
+
const {remote} = makeRemote(exposed, 30)
|
|
111
|
+
const mirror = createStore<World>({units: {}, tick: -1})
|
|
112
|
+
const seqs: number[] = []
|
|
113
|
+
const sub = syncStoreReplay(mirror, remote, {onSeq: s => seqs.push(s)})
|
|
114
|
+
// мутации ВО ВРЕМЯ ожидания keyframe: попадут и в снапшот, и в live-очередь
|
|
115
|
+
backend.state.tick = 1
|
|
116
|
+
await flushReactive(backend.state)
|
|
117
|
+
backend.state.units['a'].hp = 2
|
|
118
|
+
await flushReactive(backend.state)
|
|
119
|
+
await sub.ready
|
|
120
|
+
backend.state.tick = 3
|
|
121
|
+
await flushReactive(backend.state)
|
|
122
|
+
await delay(5)
|
|
123
|
+
ok(json(mirror.state) == json(backend.snapshot()), 'overlap keyframe/queue converges')
|
|
124
|
+
ok(ascendingUnique(seqs), `no seq delivered twice: ${seqs.join(',')}`)
|
|
125
|
+
sub()
|
|
126
|
+
exposed.close()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log('\n[store-replay] seq from another server life → keyframe resets the line down')
|
|
130
|
+
{
|
|
131
|
+
const backend = createStore<World>({units: {z: {hp: 9, x: 9}}, tick: 100})
|
|
132
|
+
const exposed = exposeStoreReplay(backend, {history: 100}) // свежий сервер: head мал
|
|
133
|
+
const {remote, counters} = makeRemote(exposed)
|
|
134
|
+
const mirror = createStore<World>({units: {}, tick: -1})
|
|
135
|
+
const sub = syncStoreReplay(mirror, remote, {since: 99999})
|
|
136
|
+
await sub.ready
|
|
137
|
+
ok(json(mirror.state) == json(backend.snapshot()), 'future seq → keyframe fallback')
|
|
138
|
+
ok(counters.keyframe == 1, 'keyframe taken once')
|
|
139
|
+
backend.state.tick = 101
|
|
140
|
+
await flushReactive(backend.state)
|
|
141
|
+
await delay(5)
|
|
142
|
+
ok(mirror.state.tick == 101, 'live events AFTER the reset are not muted by the stale big seq')
|
|
143
|
+
sub()
|
|
144
|
+
exposed.close()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log(fails ? `\n${fails} FAILED` : '\nall passed')
|
|
148
|
+
if (fails) process.exit(1)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
main().catch(e => { console.error(e); process.exit(1) })
|