socketduct 0.1.17 → 0.1.19

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.
@@ -132,8 +132,8 @@ test("route-class clients fail closed per OPEN while unaffected routes and legac
132
132
  hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
133
133
  tls,
134
134
  authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port,
135
- reconnectDelayMs: 10_000,
136
- maxReconnectDelayMs: 10_000
135
+ reconnectDelayMs: 10,
136
+ maxReconnectDelayMs: 20
137
137
  })
138
138
  const gatewayB = new OutboundReverseGateway({
139
139
  gatewayId: "server-b-starlink",
@@ -141,8 +141,8 @@ test("route-class clients fail closed per OPEN while unaffected routes and legac
141
141
  hub: {host: "127.0.0.1", port: hub.port, token: "gateway-b-token"},
142
142
  tls,
143
143
  authorizeTarget: (target) => target.host === targetB.host && target.port === targetB.port,
144
- reconnectDelayMs: 10_000,
145
- maxReconnectDelayMs: 10_000
144
+ reconnectDelayMs: 10,
145
+ maxReconnectDelayMs: 20
146
146
  })
147
147
  t.after(() => gatewayA.close())
148
148
  t.after(() => gatewayB.close())
@@ -206,18 +206,22 @@ test("route-class clients fail closed per OPEN while unaffected routes and legac
206
206
  await Promise.all([onceEvent(streamA, "connect"), onceEvent(streamB, "connect")])
207
207
  assert.deepEqual(await Promise.all([exchange(streamA, "one"), exchange(streamB, "two")]), ["a:one", "b:two"])
208
208
 
209
- const streamAError = nextError(streamA)
209
+ const resumed = Promise.all([onceEvent(streamA, "ready"), onceEvent(streamB, "ready")])
210
210
  await hub.closeActiveGateway("server-a-starlink")
211
- assert.equal((await streamAError).code, "GATEWAY_DISCONNECTED")
212
- await waitUntil(() => hub.status().healthyGateways === 1, "gateway A disconnect")
213
-
214
- const disconnected = client.createConnection({host: targetA.host, port: targetA.port})
215
- assert.equal((await nextError(disconnected)).code, "GATEWAY_UNAVAILABLE")
211
+ await withTimeout(resumed, "route-class streams resume after gateway transport replacement")
212
+ await waitUntil(() => hub.status().healthyGateways === 2, "gateway A re-registration")
213
+ assert.equal(await exchange(streamA, "resumed"), "a:resumed")
216
214
  assert.equal(await exchange(streamB, "still-open"), "b:still-open")
215
+
216
+ const anotherA = client.createConnection({host: targetA.host, port: targetA.port})
217
+ await onceEvent(anotherA, "connect")
218
+ assert.equal(await exchange(anotherA, "new-a"), "a:new-a")
217
219
  const anotherB = client.createConnection({host: targetB.host, port: targetB.port})
218
220
  await onceEvent(anotherB, "connect")
219
221
  assert.equal(await exchange(anotherB, "new"), "b:new")
220
222
 
223
+ streamA.destroy()
224
+ anotherA.destroy()
221
225
  await gatewayA.close()
222
226
  const replacementA = new OutboundReverseGateway({
223
227
  gatewayId: "server-a-starlink",
@@ -232,7 +236,7 @@ test("route-class clients fail closed per OPEN while unaffected routes and legac
232
236
  const rejoinedA = client.createConnection({host: targetA.host, port: targetA.port})
233
237
  await onceEvent(rejoinedA, "connect")
234
238
  assert.equal(await exchange(rejoinedA, "rejoined"), "a:rejoined")
235
- assert.equal(client.status().physicalConnects, 1)
239
+ assert(client.status().physicalConnects >= 2)
236
240
  assert.equal(hub.status().clientPhysicalConnections, 1)
237
241
  streamB.destroy()
238
242
  anotherB.destroy()
@@ -360,7 +364,7 @@ test("a backpressured gateway does not block DATA or a new OPEN for another rout
360
364
  t.after(() => socket.destroy())
361
365
  return nextError(socket)
362
366
  })
363
- assert.equal((await withTimeout(streamAError, "gateway A output limit")).code, "GATEWAY_DISCONNECTED")
367
+ assert.equal((await withTimeout(streamAError, "gateway A output limit")).code, "OUTPUT_BACKPRESSURE_LIMIT")
364
368
  await withTimeout(Promise.all(queuedAErrors), "queued gateway A stream cleanup")
365
369
  assert.equal(await exchange(streamB, "after-limit"), "b:after-limit")
366
370
  assert.equal(hub.status().healthyGateways, 1)
@@ -289,6 +289,17 @@ test("route-class members are first-class reverse transports with route-class al
289
289
  assert(connectionIds.every((connectionId) => typeof connectionId === "string"))
290
290
  assert.equal(new Set(connectionIds).size, 2)
291
291
 
292
+ assert.throws(() => set.recoverConnection(/** @type {Parameters<MultiplexedSocketductTransportSet['recoverConnection']>[0]} */ (/** @type {unknown} */ ({host: "target.internal", port: 443, identifier: "route_stream_01"}))), /exactly one.*gatewayId.*routeClass/i)
293
+ assert.throws(() => set.recoverConnection(/** @type {Parameters<MultiplexedSocketductTransportSet['recoverConnection']>[0]} */ (/** @type {unknown} */ ({gatewayId: "starlink", routeClass: "zyxel", host: "target.internal", port: 443, identifier: "route_stream_01"}))), /exactly one.*gatewayId.*routeClass/i)
294
+ assert.throws(() => set.recoverConnection({routeClass: "missing", host: "target.internal", port: 443, identifier: "route_stream_01"}), /unknown routeClass/i)
295
+ const recovered = set.recoverConnection({routeClass: "zyxel", host: "target.internal", port: 443, identifier: "route_stream_01", sessionName: "durable-route"})
296
+ assert.equal(/** @type {{gatewayId?: string}} */ (recovered).gatewayId, undefined)
297
+ assert.equal(/** @type {{routeClass?: string}} */ (recovered).routeClass, "zyxel")
298
+ assert.equal(Object.getOwnPropertyDescriptor(recovered, "routeClass")?.writable, false)
299
+ assert.deepEqual(members.map((member) => member.opens), [0, 1])
300
+ assert.equal(/** @type {{recover?: unknown}} */ (at(members, 1).recoveryConnectionCalls[0]).recover, true)
301
+ assert.equal(Object.hasOwn(/** @type {object} */ (at(members, 1).recoveryConnectionCalls[0]), "routeClass"), false)
302
+
292
303
  const started = set.start()
293
304
  at(members, 0).ready()
294
305
  await started
@@ -845,7 +856,7 @@ test("pending socket end reserves and sends DataView bytes", async () => {
845
856
  await set.close()
846
857
  })
847
858
 
848
- test("recovery requires an original configured gateway and registration before start", async () => {
859
+ test("recovery requires one original configured selector and registration before start", async () => {
849
860
  const {set, members} = fixture()
850
861
  assert.throws(() => set.recoverConnection(/** @type {Parameters<MultiplexedSocketductTransportSet['recoverConnection']>[0]} */ (/** @type {unknown} */ ({host: "target.internal", port: 443, identifier: "stable_stream_01"}))), /gatewayId/i)
851
862
  assert.throws(() => set.recoverConnection({gatewayId: "unknown", host: "target.internal", port: 443, identifier: "stable_stream_01"}), /unknown gatewayId/i)
@@ -191,6 +191,139 @@ test("SessionSpool synchronously rejects expired durable recovery state", async
191
191
  })
192
192
  })
193
193
 
194
+ test("SessionSpool loadChunksSync restores only unacknowledged owned chunks", async () => {
195
+ await withTempDir(async (root) => {
196
+ const sessionId = "session_recovery_chunks"
197
+ const spool = new SessionSpool({rootDir: root, maxBytes: 16})
198
+ await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
199
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("old")})
200
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("new")})
201
+
202
+ const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 3, 6)
203
+
204
+ assert.deepEqual(chunks.map((chunk) => chunk.sequence), [6])
205
+ assert.equal(chunks[0]?.data.toString(), "new")
206
+ assert.equal(spool.getUsedBytes(sessionId), 3)
207
+ assert.throws(
208
+ () => spool.loadChunksSync(sessionId, "other-owner", 3, 6),
209
+ /Invalid Socketduct recovery chunks/
210
+ )
211
+ })
212
+ })
213
+
214
+ test("SessionSpool recovery repairs interruption after chunk append before usage update", async () => {
215
+ await withTempDir(async (root) => {
216
+ const sessionId = "session_append_interruption"
217
+ const spool = new SessionSpool({rootDir: root, maxBytes: 16})
218
+ await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
219
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
220
+ const chunksPath = join(root, sessionId, "chunks.jsonl")
221
+ const committed = await readFile(chunksPath, "utf8")
222
+ await writeFile(chunksPath, `${committed}${JSON.stringify({sequence: 6, dataBase64: Buffer.from("two").toString("base64")})}\n`)
223
+
224
+ const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3)
225
+
226
+ assert.deepEqual(chunks.map((chunk) => chunk.sequence), [3])
227
+ assert.equal(await readFile(chunksPath, "utf8"), committed)
228
+ assert.deepEqual(JSON.parse(await readFile(join(root, sessionId, "usage.json"), "utf8")), {totalBytes: 3})
229
+ assert.equal((await stat(chunksPath)).mode & 0o777, 0o600)
230
+ assert.equal((await stat(join(root, sessionId, "usage.json"))).mode & 0o777, 0o600)
231
+ })
232
+ })
233
+
234
+ test("SessionSpool recovery repairs interruption after usage update before recovery commit", async () => {
235
+ await withTempDir(async (root) => {
236
+ const sessionId = "session_usage_interruption"
237
+ const spool = new SessionSpool({rootDir: root, maxBytes: 16})
238
+ await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
239
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
240
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("two")})
241
+
242
+ const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3)
243
+
244
+ assert.deepEqual(chunks.map((chunk) => chunk.sequence), [3])
245
+ assert.deepEqual(JSON.parse(await readFile(join(root, sessionId, "usage.json"), "utf8")), {totalBytes: 3})
246
+ })
247
+ })
248
+
249
+ test("SessionSpool recovery repairs interruption after acknowledged chunk truncation", async () => {
250
+ await withTempDir(async (root) => {
251
+ const sessionId = "session_truncate_interruption"
252
+ const spool = new SessionSpool({rootDir: root, maxBytes: 16})
253
+ await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
254
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
255
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("two")})
256
+ const chunksPath = join(root, sessionId, "chunks.jsonl")
257
+ await writeFile(chunksPath, `${JSON.stringify({sequence: 6, dataBase64: Buffer.from("two").toString("base64")})}\n`)
258
+
259
+ const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 3, 6)
260
+
261
+ assert.deepEqual(chunks.map((chunk) => chunk.sequence), [6])
262
+ assert.deepEqual(JSON.parse(await readFile(join(root, sessionId, "usage.json"), "utf8")), {totalBytes: 3})
263
+ })
264
+ })
265
+
266
+ test("SessionSpool recovery repairs a partial trailing JSONL record after committed chunks", async () => {
267
+ await withTempDir(async (root) => {
268
+ const sessionId = "session_partial_interruption"
269
+ const spool = new SessionSpool({rootDir: root, maxBytes: 16})
270
+ await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
271
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
272
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("two")})
273
+ const chunksPath = join(root, sessionId, "chunks.jsonl")
274
+ const committed = await readFile(chunksPath, "utf8")
275
+ await writeFile(chunksPath, `${committed}{"sequence":9,"dataBase64":"thr`)
276
+
277
+ const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 6)
278
+
279
+ assert.deepEqual(chunks.map((chunk) => chunk.sequence), [3, 6])
280
+ assert.equal(await readFile(chunksPath, "utf8"), committed)
281
+ })
282
+ })
283
+
284
+ test("SessionSpool recovery rejects corruption before or outside a partial uncommitted tail", async () => {
285
+ await withTempDir(async (root) => {
286
+ const sessionId = "session_committed_corruption"
287
+ const spool = new SessionSpool({rootDir: root, maxBytes: 16})
288
+ await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
289
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
290
+ const chunksPath = join(root, sessionId, "chunks.jsonl")
291
+ await writeFile(chunksPath, `${await readFile(chunksPath, "utf8")}{"sequence":6,"dataBase64":"tw`)
292
+
293
+ assert.throws(
294
+ () => spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 6),
295
+ /Invalid Socketduct recovery chunks/
296
+ )
297
+ })
298
+
299
+ await withTempDir(async (root) => {
300
+ const sessionId = "session_tail_corruption"
301
+ const spool = new SessionSpool({rootDir: root, maxBytes: 16})
302
+ await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
303
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
304
+ const chunksPath = join(root, sessionId, "chunks.jsonl")
305
+ await writeFile(chunksPath, `${await readFile(chunksPath, "utf8")}{not-json}\n`)
306
+
307
+ assert.throws(
308
+ () => spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3),
309
+ /Invalid Socketduct recovery chunks/
310
+ )
311
+ })
312
+
313
+ await withTempDir(async (root) => {
314
+ const sessionId = "session_usage_corruption"
315
+ const spool = new SessionSpool({rootDir: root, maxBytes: 16})
316
+ await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
317
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
318
+ await writeFile(join(root, sessionId, "usage.json"), "{not-json\n")
319
+
320
+ assert.throws(
321
+ () => spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3),
322
+ /Invalid Socketduct recovery chunks/
323
+ )
324
+ })
325
+ })
326
+
194
327
  test("SessionSpool cleans expired abandoned entries so their stable IDs can be reused", async () => {
195
328
  await withTempDir(async (root) => {
196
329
  let now = 1_000_000