@owlmeans/client-socket 0.1.18-rc.33 → 0.1.18-rc.35
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 +2 -2
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/client-socket/SKILL.md +36 -14
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +1 -0
- package/build/consts.js.map +1 -1
- package/build/helper.d.ts +3 -2
- package/build/helper.d.ts.map +1 -1
- package/build/helper.js +40 -12
- package/build/helper.js.map +1 -1
- package/build/status.d.ts +9 -0
- package/build/status.d.ts.map +1 -1
- package/build/status.js +83 -26
- package/build/status.js.map +1 -1
- package/build/types.d.ts +13 -2
- package/build/types.d.ts.map +1 -1
- package/build/utils/connection.d.ts +5 -0
- package/build/utils/connection.d.ts.map +1 -1
- package/build/utils/connection.js +73 -19
- package/build/utils/connection.js.map +1 -1
- package/package.json +10 -10
- package/src/consts.ts +1 -0
- package/src/helper.ts +40 -12
- package/src/status.ts +81 -25
- package/src/types.ts +13 -2
- package/src/utils/connection.ts +77 -19
- package/tests/reconnect.spec.ts +228 -6
package/src/utils/connection.ts
CHANGED
|
@@ -36,6 +36,11 @@ export interface ManagedConnection {
|
|
|
36
36
|
/** Settles once the first socket opens; rejects with `SocketConnectionError('lost')` once the
|
|
37
37
|
* retry budget elapses first (or the single attempt fails, with `retry: false`). */
|
|
38
38
|
ready: Promise<void>
|
|
39
|
+
/** Restart retrying a connection that gave up after its budget: one attempt at once, then the
|
|
40
|
+
* usual backoff within `policy.reviveBudget`. On a connection still waiting out a backoff
|
|
41
|
+
* delay: attempt at once, and leave it at least `reviveBudget`. A no-op otherwise — a terminal
|
|
42
|
+
* close (client close, 1000/1008, `retry: false`) is never revived. */
|
|
43
|
+
revive: () => void
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
/**
|
|
@@ -55,12 +60,16 @@ export const makeConnection = (opts: ManagedConnectionOptions): ManagedConnectio
|
|
|
55
60
|
let current: WebSocket | null = null
|
|
56
61
|
let closedByClient = false
|
|
57
62
|
let finished = false
|
|
63
|
+
// Gave up after the budget, but still revivable — unlike `finished`, nothing here is final.
|
|
64
|
+
let lost = false
|
|
58
65
|
let attempts = 0
|
|
59
66
|
let outageStartedAt: number | null = null
|
|
67
|
+
let outageBudget = policy.budget
|
|
60
68
|
let stableTimer: ReturnType<typeof setTimeout> | null = null
|
|
61
69
|
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
|
62
70
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
|
63
|
-
|
|
71
|
+
// When the ping still waiting for an answer was sent, or null once anything came back.
|
|
72
|
+
let pingSentAt: number | null = null
|
|
64
73
|
|
|
65
74
|
let resolveReady: () => void = () => void 0
|
|
66
75
|
let rejectReady: (error: Error) => void = () => void 0
|
|
@@ -97,16 +106,21 @@ export const makeConnection = (opts: ManagedConnectionOptions): ManagedConnectio
|
|
|
97
106
|
}
|
|
98
107
|
|
|
99
108
|
const startHeartbeat = (socket: WebSocket) => {
|
|
100
|
-
|
|
109
|
+
pingSentAt = null
|
|
101
110
|
heartbeatTimer = setInterval(() => {
|
|
102
111
|
if (socket.readyState !== WebSocket.OPEN) return
|
|
103
|
-
if (Date.now() -
|
|
104
|
-
// Silent half-open TCP:
|
|
105
|
-
//
|
|
112
|
+
if (pingSentAt != null && Date.now() - pingSentAt > policy.pongTimeout) {
|
|
113
|
+
// Silent half-open TCP: the ping this socket owes an answer to went out long enough ago
|
|
114
|
+
// that any live peer would have replied — force the close its own stack would eventually
|
|
106
115
|
// notice on its own, minutes later.
|
|
107
116
|
socket.close(SOCKET_HEARTBEAT_TIMEOUT_CODE)
|
|
108
117
|
return
|
|
109
118
|
}
|
|
119
|
+
// Measured from the PING rather than from the last frame: a hidden tab's timers are
|
|
120
|
+
// throttled to about one wake-up a minute, so "nothing has arrived for longer than a
|
|
121
|
+
// heartbeat interval" is what throttling looks like, not what a dead socket looks like —
|
|
122
|
+
// it used to kill a perfectly healthy backgrounded connection on every wake-up.
|
|
123
|
+
pingSentAt = Date.now()
|
|
110
124
|
socket.send(JSON.stringify({ type: 'ping' }))
|
|
111
125
|
}, policy.heartbeat)
|
|
112
126
|
}
|
|
@@ -128,14 +142,19 @@ export const makeConnection = (opts: ManagedConnectionOptions): ManagedConnectio
|
|
|
128
142
|
settle(new SocketConnectionError('lost'))
|
|
129
143
|
}
|
|
130
144
|
|
|
131
|
-
|
|
145
|
+
// No `Close` frame here: the connection stays revivable, and `close` means gone for good.
|
|
146
|
+
const giveUp = async () => {
|
|
147
|
+
lost = true
|
|
148
|
+
clearTimers()
|
|
149
|
+
onStatus?.('lost')
|
|
150
|
+
await emitSystem(SocketSystemEvent.Lost, {})
|
|
151
|
+
settle(new SocketConnectionError('lost'))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const scheduleReconnect = () => {
|
|
132
155
|
if (outageStartedAt == null) outageStartedAt = Date.now()
|
|
133
|
-
if (Date.now() - outageStartedAt >=
|
|
134
|
-
void (
|
|
135
|
-
onStatus?.('lost')
|
|
136
|
-
await emitSystem(SocketSystemEvent.Lost, {})
|
|
137
|
-
await finish(code)
|
|
138
|
-
})()
|
|
156
|
+
if (Date.now() - outageStartedAt >= outageBudget) {
|
|
157
|
+
void giveUp()
|
|
139
158
|
return
|
|
140
159
|
}
|
|
141
160
|
attempts += 1
|
|
@@ -147,13 +166,47 @@ export const makeConnection = (opts: ManagedConnectionOptions): ManagedConnectio
|
|
|
147
166
|
|
|
148
167
|
const attemptConnect = async () => {
|
|
149
168
|
if (closedByClient) return
|
|
169
|
+
let socket: WebSocket
|
|
150
170
|
try {
|
|
151
|
-
|
|
152
|
-
onOpened(socket)
|
|
171
|
+
socket = await open()
|
|
153
172
|
} catch {
|
|
154
|
-
if (retry) scheduleReconnect(
|
|
173
|
+
if (retry) scheduleReconnect()
|
|
155
174
|
else void finish(1006)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
if (closedByClient) {
|
|
178
|
+
// Closed while this handshake was in flight — nobody is left to own the socket.
|
|
179
|
+
socket.close(1000)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
onOpened(socket)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const revive = () => {
|
|
186
|
+
if (finished) return
|
|
187
|
+
if (lost) {
|
|
188
|
+
lost = false
|
|
189
|
+
attempts = 1
|
|
190
|
+
outageStartedAt = Date.now()
|
|
191
|
+
outageBudget = policy.reviveBudget
|
|
192
|
+
onStatus?.('reconnecting')
|
|
193
|
+
void emitSystem(SocketSystemEvent.Reconnecting, { attempt: attempts, delay: 0 })
|
|
194
|
+
void attemptConnect()
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
// Still retrying: never give up sooner than a revived connection would — otherwise a retry of
|
|
198
|
+
// its lost siblings is undone moments later when this one's older budget runs out, even by an
|
|
199
|
+
// attempt already in flight — and attempt now rather than after the backoff.
|
|
200
|
+
if (current != null) return
|
|
201
|
+
const now = Date.now()
|
|
202
|
+
if (outageStartedAt != null && outageBudget - (now - outageStartedAt) < policy.reviveBudget) {
|
|
203
|
+
outageStartedAt = now
|
|
204
|
+
outageBudget = policy.reviveBudget
|
|
156
205
|
}
|
|
206
|
+
if (retryTimer == null) return
|
|
207
|
+
clearTimeout(retryTimer)
|
|
208
|
+
retryTimer = null
|
|
209
|
+
void attemptConnect()
|
|
157
210
|
}
|
|
158
211
|
|
|
159
212
|
const onOpened = (socket: WebSocket) => {
|
|
@@ -161,7 +214,8 @@ export const makeConnection = (opts: ManagedConnectionOptions): ManagedConnectio
|
|
|
161
214
|
const wasRetry = attempts > 0
|
|
162
215
|
|
|
163
216
|
const receiveMessage = async (event: MessageEvent) => {
|
|
164
|
-
|
|
217
|
+
// Any frame at all answers the outstanding ping — the point is liveness, not pongs.
|
|
218
|
+
pingSentAt = null
|
|
165
219
|
if (typeof event.data === 'string') {
|
|
166
220
|
try {
|
|
167
221
|
if (JSON.parse(event.data)?.type === 'pong') return
|
|
@@ -204,7 +258,7 @@ export const makeConnection = (opts: ManagedConnectionOptions): ManagedConnectio
|
|
|
204
258
|
}
|
|
205
259
|
model.stage = AuthenticationStage.Init
|
|
206
260
|
await emitSystem(SocketSystemEvent.Disconnected, { code: event.code })
|
|
207
|
-
scheduleReconnect(
|
|
261
|
+
scheduleReconnect()
|
|
208
262
|
}
|
|
209
263
|
|
|
210
264
|
socket.addEventListener('message', messageHandler)
|
|
@@ -213,7 +267,11 @@ export const makeConnection = (opts: ManagedConnectionOptions): ManagedConnectio
|
|
|
213
267
|
startHeartbeat(socket)
|
|
214
268
|
|
|
215
269
|
if (stableTimer != null) clearTimeout(stableTimer)
|
|
216
|
-
stableTimer = setTimeout(() => {
|
|
270
|
+
stableTimer = setTimeout(() => {
|
|
271
|
+
attempts = 0
|
|
272
|
+
outageStartedAt = null
|
|
273
|
+
outageBudget = policy.budget
|
|
274
|
+
}, policy.stableAfter)
|
|
217
275
|
|
|
218
276
|
onStatus?.('online')
|
|
219
277
|
settle()
|
|
@@ -255,5 +313,5 @@ export const makeConnection = (opts: ManagedConnectionOptions): ManagedConnectio
|
|
|
255
313
|
|
|
256
314
|
void attemptConnect()
|
|
257
315
|
|
|
258
|
-
return { connection: model, ready }
|
|
316
|
+
return { connection: model, ready, revive }
|
|
259
317
|
}
|
package/tests/reconnect.spec.ts
CHANGED
|
@@ -14,16 +14,33 @@ import { makeTestContext } from './context.js'
|
|
|
14
14
|
*/
|
|
15
15
|
let server: Server | null = null
|
|
16
16
|
|
|
17
|
-
const startServer = (port: number): Server => {
|
|
17
|
+
const startServer = (port: number, echo: boolean = true): Server => {
|
|
18
18
|
server = Bun.serve({
|
|
19
19
|
port,
|
|
20
20
|
fetch(req, srv) {
|
|
21
21
|
if (srv.upgrade(req)) return undefined
|
|
22
22
|
return new Response('upgrade failed', { status: 400 })
|
|
23
23
|
},
|
|
24
|
-
//
|
|
25
|
-
// sent after a reconnect reaches the SAME
|
|
26
|
-
|
|
24
|
+
// Answers a heartbeat ping with a pong, exactly as `server-socket` does, and echoes every
|
|
25
|
+
// other frame — enough for a test to prove a frame sent after a reconnect reaches the SAME
|
|
26
|
+
// pre-drop `observe` handler. `echo: false` upgrades and then answers nothing at all: a
|
|
27
|
+
// half-open connection, from the client's side.
|
|
28
|
+
websocket: {
|
|
29
|
+
open() { },
|
|
30
|
+
message(ws, data) {
|
|
31
|
+
if (!echo) return
|
|
32
|
+
try {
|
|
33
|
+
if (typeof data === 'string' && JSON.parse(data)?.type === 'ping') {
|
|
34
|
+
ws.send(JSON.stringify({ type: 'pong' }))
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// Not JSON at all — echoed below like any other frame.
|
|
39
|
+
}
|
|
40
|
+
ws.send(data)
|
|
41
|
+
},
|
|
42
|
+
close() { },
|
|
43
|
+
},
|
|
27
44
|
})
|
|
28
45
|
return server
|
|
29
46
|
}
|
|
@@ -91,7 +108,7 @@ describe('@owlmeans/client-socket — reconnect', () => {
|
|
|
91
108
|
// `ready` settles, and here we need to observe frames WHILE every attempt is still failing.
|
|
92
109
|
const policy = {
|
|
93
110
|
minDelay: 200, maxDelay: 3_000, factor: 2, jitter: 0.1,
|
|
94
|
-
budget: 60_000, stableAfter: 999_999, heartbeat: 999_999, pongTimeout: 999_999,
|
|
111
|
+
budget: 60_000, reviveBudget: 60_000, stableAfter: 999_999, heartbeat: 999_999, pongTimeout: 999_999,
|
|
95
112
|
}
|
|
96
113
|
const delays: number[] = []
|
|
97
114
|
const { connection, ready } = makeConnection({
|
|
@@ -137,7 +154,60 @@ describe('@owlmeans/client-socket — reconnect', () => {
|
|
|
137
154
|
expect(events).not.toContain(SocketSystemEvent.Reconnecting)
|
|
138
155
|
}, 5_000)
|
|
139
156
|
|
|
140
|
-
test('
|
|
157
|
+
test('a socket that answers nothing at all is force-closed by the heartbeat', async () => {
|
|
158
|
+
const silent = startServer(0, false)
|
|
159
|
+
|
|
160
|
+
const ctx = makeTestContext({
|
|
161
|
+
reconnect: { heartbeat: 60, pongTimeout: 30, minDelay: 20, maxDelay: 40, budget: 10_000 }
|
|
162
|
+
})
|
|
163
|
+
const connection = await connect(() => `ws://localhost:${silent.port}`, ctx)
|
|
164
|
+
const events = systemEvents(connection)
|
|
165
|
+
|
|
166
|
+
await waitFor(() => events.includes(SocketSystemEvent.Disconnected), 3_000)
|
|
167
|
+
|
|
168
|
+
await connection.close()
|
|
169
|
+
}, 10_000)
|
|
170
|
+
|
|
171
|
+
test('a heartbeat wake-up long after the previous one keeps a socket the server answers', async () => {
|
|
172
|
+
const initial = startServer(0)
|
|
173
|
+
const port = initial.port
|
|
174
|
+
const realNow = Date.now.bind(Date)
|
|
175
|
+
|
|
176
|
+
const ctx = makeTestContext({
|
|
177
|
+
reconnect: { heartbeat: 100, pongTimeout: 50, minDelay: 20, maxDelay: 40, budget: 10_000 }
|
|
178
|
+
})
|
|
179
|
+
const connection = await connect(() => `ws://localhost:${port}`, ctx)
|
|
180
|
+
const events = systemEvents(connection)
|
|
181
|
+
let observed = 0
|
|
182
|
+
connection.observe('probe', async () => { observed += 1 })
|
|
183
|
+
|
|
184
|
+
// What a backgrounded tab looks like: real timers keep firing, but every wake-up lands a
|
|
185
|
+
// minute later than the last as far as the clock is concerned.
|
|
186
|
+
let skew = 0
|
|
187
|
+
Date.now = () => realNow() + skew
|
|
188
|
+
const throttle = setInterval(() => { skew += 60_000 }, 50)
|
|
189
|
+
const wait = async (ms: number) => {
|
|
190
|
+
const until = realNow() + ms
|
|
191
|
+
while (realNow() < until) await Bun.sleep(20)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
await wait(800)
|
|
196
|
+
// Several heartbeat intervals with an apparent minute between each: nothing dropped.
|
|
197
|
+
expect(events).toEqual([])
|
|
198
|
+
|
|
199
|
+
await connection.notify('probe', {})
|
|
200
|
+
const deadline = realNow() + 2_000
|
|
201
|
+
while (observed === 0 && realNow() < deadline) await Bun.sleep(20)
|
|
202
|
+
expect(observed).toBe(1)
|
|
203
|
+
} finally {
|
|
204
|
+
clearInterval(throttle)
|
|
205
|
+
Date.now = realNow
|
|
206
|
+
await connection.close()
|
|
207
|
+
}
|
|
208
|
+
}, 15_000)
|
|
209
|
+
|
|
210
|
+
test('gives up after the retry budget elapses: status reads lost, connect() rejects', async () => {
|
|
141
211
|
const ctx = makeTestContext({ reconnect: { minDelay: 20, maxDelay: 40, budget: 200, stableAfter: 50 } })
|
|
142
212
|
appendSocketStatus(ctx)
|
|
143
213
|
ctx.configure()
|
|
@@ -147,4 +217,156 @@ describe('@owlmeans/client-socket — reconnect', () => {
|
|
|
147
217
|
|
|
148
218
|
expect(ctx.socketStatus().state()).toBe('lost')
|
|
149
219
|
}, 5_000)
|
|
220
|
+
|
|
221
|
+
test('retry() revives a lost connection in place: no close frame, reconnected on the SAME connection', async () => {
|
|
222
|
+
const initial = startServer(0)
|
|
223
|
+
const port = initial.port
|
|
224
|
+
|
|
225
|
+
const ctx = makeTestContext({
|
|
226
|
+
reconnect: { minDelay: 20, maxDelay: 40, budget: 300, reviveBudget: 2_000, stableAfter: 50 }
|
|
227
|
+
})
|
|
228
|
+
appendSocketStatus(ctx)
|
|
229
|
+
ctx.configure()
|
|
230
|
+
await ctx.init()
|
|
231
|
+
|
|
232
|
+
const connection = await connect(() => `ws://localhost:${port}`, ctx)
|
|
233
|
+
const events = systemEvents(connection)
|
|
234
|
+
let observed = 0
|
|
235
|
+
connection.observe('probe', async () => { observed += 1 })
|
|
236
|
+
|
|
237
|
+
stopServer()
|
|
238
|
+
await waitFor(() => events.includes(SocketSystemEvent.Lost), 3_000)
|
|
239
|
+
expect(ctx.socketStatus().state()).toBe('lost')
|
|
240
|
+
// `close` means gone for good — a lost connection is still revivable.
|
|
241
|
+
expect(events).not.toContain(SocketSystemEvent.Close)
|
|
242
|
+
|
|
243
|
+
startServer(port)
|
|
244
|
+
let retried = 0
|
|
245
|
+
ctx.socketStatus().onRetry(() => { retried += 1 })
|
|
246
|
+
expect(ctx.socketStatus().retry()).toBe(true)
|
|
247
|
+
expect(retried).toBe(1)
|
|
248
|
+
expect(ctx.socketStatus().state()).toBe('reconnecting')
|
|
249
|
+
|
|
250
|
+
await waitFor(() => events.includes(SocketSystemEvent.Reconnected), 3_000)
|
|
251
|
+
expect(ctx.socketStatus().state()).toBe('online')
|
|
252
|
+
|
|
253
|
+
await connection.notify('probe', {})
|
|
254
|
+
await waitFor(() => observed === 1, 1_000)
|
|
255
|
+
|
|
256
|
+
// Nothing is lost any more — a second retry has nothing to do and tells nobody.
|
|
257
|
+
expect(ctx.socketStatus().retry()).toBe(false)
|
|
258
|
+
expect(retried).toBe(1)
|
|
259
|
+
|
|
260
|
+
await connection.close()
|
|
261
|
+
}, 10_000)
|
|
262
|
+
|
|
263
|
+
test('a revive that cannot succeed reports lost again once reviveBudget elapses', async () => {
|
|
264
|
+
const initial = startServer(0)
|
|
265
|
+
const port = initial.port
|
|
266
|
+
|
|
267
|
+
const ctx = makeTestContext({
|
|
268
|
+
reconnect: { minDelay: 20, maxDelay: 40, budget: 200, reviveBudget: 200, stableAfter: 50 }
|
|
269
|
+
})
|
|
270
|
+
appendSocketStatus(ctx)
|
|
271
|
+
ctx.configure()
|
|
272
|
+
await ctx.init()
|
|
273
|
+
|
|
274
|
+
const connection = await connect(() => `ws://localhost:${port}`, ctx)
|
|
275
|
+
const events = systemEvents(connection)
|
|
276
|
+
|
|
277
|
+
stopServer()
|
|
278
|
+
await waitFor(() => events.includes(SocketSystemEvent.Lost), 3_000)
|
|
279
|
+
|
|
280
|
+
ctx.socketStatus().retry()
|
|
281
|
+
expect(ctx.socketStatus().state()).toBe('reconnecting')
|
|
282
|
+
await waitFor(() => events.filter(e => e === SocketSystemEvent.Lost).length === 2, 3_000)
|
|
283
|
+
expect(ctx.socketStatus().state()).toBe('lost')
|
|
284
|
+
expect(events).not.toContain(SocketSystemEvent.Close)
|
|
285
|
+
|
|
286
|
+
await connection.close()
|
|
287
|
+
expect(ctx.socketStatus().state()).toBe('online')
|
|
288
|
+
}, 10_000)
|
|
289
|
+
|
|
290
|
+
test('revive() on a connection still retrying attempts at once and outlives its old budget', async () => {
|
|
291
|
+
// Its own budget alone would report lost ~400ms in; the revive at ~50ms must push that out.
|
|
292
|
+
const policy = {
|
|
293
|
+
minDelay: 200, maxDelay: 200, factor: 1, jitter: 0,
|
|
294
|
+
budget: 300, reviveBudget: 1_500, stableAfter: 999_999, heartbeat: 999_999, pongTimeout: 999_999,
|
|
295
|
+
}
|
|
296
|
+
let opens = 0
|
|
297
|
+
const events: string[] = []
|
|
298
|
+
const { connection, ready, revive } = makeConnection({
|
|
299
|
+
policy, retry: true, open: () => { opens += 1; return Promise.reject(new Error('down')) },
|
|
300
|
+
})
|
|
301
|
+
ready.catch(() => void 0)
|
|
302
|
+
connection.listen(async message => {
|
|
303
|
+
const msg = message as EventMessage<unknown>
|
|
304
|
+
if (typeof message === 'object' && msg.type === 'system') events.push(msg.event)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
await waitFor(() => opens === 1, 1_000)
|
|
308
|
+
await Bun.sleep(50)
|
|
309
|
+
revive()
|
|
310
|
+
// At once — not after the 200ms backoff the failed first attempt scheduled.
|
|
311
|
+
expect(opens).toBe(2)
|
|
312
|
+
|
|
313
|
+
await Bun.sleep(600)
|
|
314
|
+
expect(events).not.toContain(SocketSystemEvent.Lost)
|
|
315
|
+
await waitFor(() => events.includes(SocketSystemEvent.Lost), 3_000)
|
|
316
|
+
|
|
317
|
+
await connection.close()
|
|
318
|
+
}, 10_000)
|
|
319
|
+
|
|
320
|
+
test('revive() during an in-flight attempt still extends the budget that attempt will face', async () => {
|
|
321
|
+
// The 2nd attempt hangs 400ms and fails at ~450ms, past the 300ms budget — lost, unless the
|
|
322
|
+
// revive at ~100ms moved the budget first.
|
|
323
|
+
const policy = {
|
|
324
|
+
minDelay: 50, maxDelay: 50, factor: 1, jitter: 0,
|
|
325
|
+
budget: 300, reviveBudget: 1_500, stableAfter: 999_999, heartbeat: 999_999, pongTimeout: 999_999,
|
|
326
|
+
}
|
|
327
|
+
let opens = 0
|
|
328
|
+
const events: string[] = []
|
|
329
|
+
const { connection, ready, revive } = makeConnection({
|
|
330
|
+
policy, retry: true, open: async () => {
|
|
331
|
+
opens += 1
|
|
332
|
+
if (opens === 2) await Bun.sleep(400)
|
|
333
|
+
throw new Error('down')
|
|
334
|
+
},
|
|
335
|
+
})
|
|
336
|
+
ready.catch(() => void 0)
|
|
337
|
+
connection.listen(async message => {
|
|
338
|
+
const msg = message as EventMessage<unknown>
|
|
339
|
+
if (typeof message === 'object' && msg.type === 'system') events.push(msg.event)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
await waitFor(() => opens === 2, 1_000)
|
|
343
|
+
await Bun.sleep(50)
|
|
344
|
+
revive()
|
|
345
|
+
// No second attempt on top of the one in flight.
|
|
346
|
+
expect(opens).toBe(2)
|
|
347
|
+
|
|
348
|
+
await Bun.sleep(600)
|
|
349
|
+
expect(events).not.toContain(SocketSystemEvent.Lost)
|
|
350
|
+
await waitFor(() => events.includes(SocketSystemEvent.Lost), 3_000)
|
|
351
|
+
|
|
352
|
+
await connection.close()
|
|
353
|
+
}, 10_000)
|
|
354
|
+
|
|
355
|
+
test('retry() drops a connection that never opened and tells its owner to dial again', async () => {
|
|
356
|
+
const ctx = makeTestContext({ reconnect: { minDelay: 20, maxDelay: 40, budget: 200, stableAfter: 50 } })
|
|
357
|
+
appendSocketStatus(ctx)
|
|
358
|
+
ctx.configure()
|
|
359
|
+
await ctx.init()
|
|
360
|
+
|
|
361
|
+
await expect(connect(() => 'ws://127.0.0.1:1', ctx)).rejects.toBeTruthy()
|
|
362
|
+
expect(ctx.socketStatus().state()).toBe('lost')
|
|
363
|
+
|
|
364
|
+
let retried = 0
|
|
365
|
+
ctx.socketStatus().onRetry(() => { retried += 1 })
|
|
366
|
+
expect(ctx.socketStatus().retry()).toBe(true)
|
|
367
|
+
|
|
368
|
+
// There was no `Connection` to revive, so nothing is left reporting — the owner re-dials.
|
|
369
|
+
expect(ctx.socketStatus().state()).toBe('online')
|
|
370
|
+
expect(retried).toBe(1)
|
|
371
|
+
}, 5_000)
|
|
150
372
|
})
|