@owlmeans/client-socket 0.1.18-rc.34 → 0.1.18-rc.36

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.
@@ -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
- let lastFrameAt = Date.now()
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
- lastFrameAt = Date.now()
109
+ pingSentAt = null
101
110
  heartbeatTimer = setInterval(() => {
102
111
  if (socket.readyState !== WebSocket.OPEN) return
103
- if (Date.now() - lastFrameAt > policy.heartbeat + policy.pongTimeout) {
104
- // Silent half-open TCP: nothing at all has arrived since well past a heartbeat interval
105
- // plus its grace period — force the close this socket's own stack would eventually
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
- const scheduleReconnect = (code: number) => {
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 >= policy.budget) {
134
- void (async () => {
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
- const socket = await open()
152
- onOpened(socket)
171
+ socket = await open()
153
172
  } catch {
154
- if (retry) scheduleReconnect(1006)
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
- lastFrameAt = Date.now()
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(event.code)
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(() => { attempts = 0; outageStartedAt = null }, policy.stableAfter)
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
  }
@@ -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
- // Echoes everything back (heartbeat pings included) — enough for a test to prove a frame
25
- // sent after a reconnect reaches the SAME pre-drop `observe` handler.
26
- websocket: { open() { }, message(ws, data) { ws.send(data) }, close() { } },
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('gives up after the retry budget elapses: lost, then close, status reads lost, connect() rejects', async () => {
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
  })