socketduct 0.1.20 → 0.1.22

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.
Files changed (33) hide show
  1. package/changelog.d/20260925180000-manual-ack-durable-admission.md +1 -0
  2. package/changelog.d/20260928120416-reverse-gateway-spool-directory.md +3 -0
  3. package/changelog.d/20260928124500-critical-reserve-bound-test.md +3 -0
  4. package/docs/logging.md +5 -1
  5. package/docs/protocol.md +21 -3
  6. package/docs/reverse-gateway.md +57 -8
  7. package/docs/running.md +3 -2
  8. package/docs/session-lifecycle.md +22 -0
  9. package/docs/socket-api.md +22 -17
  10. package/package.json +1 -1
  11. package/src/client/deferred-virtual-socket.js +42 -1
  12. package/src/client/multiplexed-connection.js +17 -0
  13. package/src/client/multiplexed-transport-set.js +43 -8
  14. package/src/client/multiplexed.js +1 -1
  15. package/src/client/virtual-socket.js +109 -4
  16. package/src/config/environment.js +5 -1
  17. package/src/index.js +1 -1
  18. package/src/reverse/client.js +174 -23
  19. package/src/reverse/gateway.js +10 -1
  20. package/src/reverse/runtime.js +1 -0
  21. package/src/spool/spool-file.js +92 -6
  22. package/tensorbuzz.yml +1 -1
  23. package/test/e2e/reverse-client-recovery-process.test.js +3 -0
  24. package/test/e2e/reverse-gateway-process.test.js +120 -1
  25. package/test/fixtures/reverse-client-recovery.mjs +14 -2
  26. package/test/integration/multiplexed-transport-set.test.js +1 -5
  27. package/test/integration/reverse-gateway.test.js +158 -1
  28. package/test/unit/client/multiplexed-transport-set.test.js +50 -3
  29. package/test/unit/client/virtual-socket.test.js +123 -1
  30. package/test/unit/config/environment.test.js +25 -0
  31. package/test/unit/index.test.js +2 -0
  32. package/test/unit/logging/compressed-log-writer.test.js +3 -1
  33. package/test/unit/spool/spool-file.test.js +58 -0
@@ -8,7 +8,7 @@ import {VirtualSocket} from "../../../src/client/virtual-socket.js"
8
8
  *
9
9
  */
10
10
  function fakeConnection() {
11
- const calls = {destroyed: 0, ended: 0, resumed: 0, refs: 0, unrefs: 0}
11
+ const calls = {destroyed: 0, ended: 0, resumed: 0, refs: 0, unrefs: 0, delivered: 0, acknowledged: 0, durableWrites: /** @type {Buffer[]} */ ([])}
12
12
  return {
13
13
  calls,
14
14
  connection: /** @type {import('../../../src/client/multiplexed-connection.js').MultiplexedSocketductConnection} */ (/** @type {unknown} */ ({
@@ -25,6 +25,16 @@ function fakeConnection() {
25
25
  _endStream: (_id, callback) => { calls.ended += 1; callback() },
26
26
  _destroyStream: () => { calls.destroyed += 1 },
27
27
  _resumeReadable: () => { calls.resumed += 1 },
28
+ _deliverReadable: (/** @type {number} */ _id, /** @type {number} */ bytes) => { calls.delivered += bytes },
29
+ _acknowledgeReadable: (/** @type {number} */ _id, /** @type {number} */ bytes) => {
30
+ assert(bytes <= calls.delivered - calls.acknowledged)
31
+ calls.acknowledged += bytes
32
+ return Promise.resolve(calls.acknowledged)
33
+ },
34
+ _writeDurableStream: (/** @type {number} */ _id, /** @type {Buffer} */ data, /** @type {(error?: Error | null) => void} */ callback) => {
35
+ calls.durableWrites.push(Buffer.from(data))
36
+ callback()
37
+ },
28
38
  physicalAddress: () => ({address: "127.0.0.1", port: 43210}),
29
39
  ref: () => { calls.refs += 1 },
30
40
  unref: () => { calls.unrefs += 1 }
@@ -96,3 +106,115 @@ test("VirtualSocket pause/resume, timeout, data and half-close remain stream-loc
96
106
  assert(fake.calls.resumed > 0)
97
107
  socket.destroy()
98
108
  })
109
+
110
+ test("VirtualSocket exposes opt-in manual receive ACK and durable write contracts", async () => {
111
+ const fake = fakeConnection()
112
+ const socket = new VirtualSocket(fake.connection, 7, {
113
+ host: "127.0.0.1",
114
+ port: 80,
115
+ manualReceiveAck: true
116
+ })
117
+ socket._markConnected()
118
+
119
+ const acknowledged = new Promise((resolve, reject) => {
120
+ socket.once("data", (chunk) => {
121
+ socket.acknowledgeReceive(chunk.length).then(resolve, reject)
122
+ })
123
+ })
124
+ socket._receive(Buffer.from("response"))
125
+ assert.equal(await acknowledged, 8)
126
+ assert.equal(fake.calls.delivered, 8)
127
+ assert.equal(fake.calls.acknowledged, 8)
128
+
129
+ await socket.writeDurable(new Uint8Array(Buffer.from("request")))
130
+ assert.deepEqual(fake.calls.durableWrites, [Buffer.from("request")])
131
+ assert.equal(socket.bytesWritten, 7)
132
+ assert.throws(
133
+ () => socket.acknowledgeReceive(1),
134
+ (error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_INVALID_RECEIVE_ACK"
135
+ )
136
+ const closed = new Promise((resolve) => socket.once("close", resolve))
137
+ socket.destroy()
138
+ await closed
139
+ await assert.rejects(
140
+ socket.acknowledgeReceive(1),
141
+ (error) => /** @type {{code?: string}} */ (error).code === "ERR_STREAM_DESTROYED"
142
+ )
143
+ })
144
+
145
+ test("VirtualSocket orders durable writes behind queued ordinary writes", async () => {
146
+ const fake = fakeConnection()
147
+ /** @type {string[]} */
148
+ const delivered = []
149
+ /** @type {(() => void) | undefined} */
150
+ let releaseFirst
151
+ const connection = /** @type {{_writeStream: (id: number, data: Buffer, callback: (error?: Error | null) => void) => void, _writeDurableStream: (id: number, data: Buffer, callback: (error?: Error | null) => void) => void}} */ (/** @type {unknown} */ (fake.connection))
152
+ connection._writeStream = (_id, data, callback) => {
153
+ delivered.push(data.toString())
154
+ if (data.toString() === "A") releaseFirst = callback
155
+ else callback()
156
+ }
157
+ connection._writeDurableStream = (_id, data, callback) => {
158
+ delivered.push(data.toString())
159
+ callback()
160
+ }
161
+ const socket = new VirtualSocket(fake.connection, 9, {host: "127.0.0.1", port: 80})
162
+
163
+ const first = new Promise((resolve, reject) => socket.write("A", (error) => error ? reject(error) : resolve(undefined)))
164
+ const second = new Promise((resolve, reject) => socket.write("B", (error) => error ? reject(error) : resolve(undefined)))
165
+ const durable = socket.writeDurable("C")
166
+ assert(releaseFirst !== undefined)
167
+ releaseFirst()
168
+ await Promise.all([first, second, durable])
169
+
170
+ assert.deepEqual(delivered, ["A", "B", "C"])
171
+ assert.equal(socket.bytesWritten, 3)
172
+ socket.destroy()
173
+ })
174
+
175
+ test("VirtualSocket rejects durable writes after end while ordinary writes remain queued", async () => {
176
+ const fake = fakeConnection()
177
+ /** @type {string[]} */
178
+ const delivered = []
179
+ /** @type {(() => void) | undefined} */
180
+ let releaseFirst
181
+ const connection = /** @type {{_writeStream: (id: number, data: Buffer, callback: (error?: Error | null) => void) => void}} */ (/** @type {unknown} */ (fake.connection))
182
+ connection._writeStream = (_id, data, callback) => {
183
+ delivered.push(data.toString())
184
+ if (data.toString() === "A") releaseFirst = callback
185
+ else callback()
186
+ }
187
+ const socket = new VirtualSocket(fake.connection, 11, {host: "127.0.0.1", port: 80})
188
+
189
+ const first = new Promise((resolve, reject) => socket.write("A", (error) => error ? reject(error) : resolve(undefined)))
190
+ const second = new Promise((resolve, reject) => socket.write("B", (error) => error ? reject(error) : resolve(undefined)))
191
+ const finished = new Promise((resolve) => socket.once("finish", resolve))
192
+ socket.end()
193
+ const rejected = assert.rejects(
194
+ socket.writeDurable("late"),
195
+ (error) => /** @type {{code?: string}} */ (error).code === "ERR_STREAM_WRITE_AFTER_END"
196
+ )
197
+
198
+ assert(releaseFirst !== undefined)
199
+ releaseFirst()
200
+ await Promise.all([first, second, finished, rejected])
201
+ assert.deepEqual(delivered, ["A", "B"])
202
+ assert.deepEqual(fake.calls.durableWrites, [])
203
+ socket.destroy()
204
+ })
205
+
206
+ test("VirtualSocket keeps manual ACK opt-in and reports unsupported durable admission", async () => {
207
+ const fake = fakeConnection()
208
+ delete /** @type {{_writeDurableStream?: unknown}} */ (fake.connection)._writeDurableStream
209
+ const socket = new VirtualSocket(fake.connection, 9, {host: "127.0.0.1", port: 80})
210
+
211
+ assert.throws(
212
+ () => socket.acknowledgeReceive(1),
213
+ (error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_MANUAL_ACK_DISABLED"
214
+ )
215
+ await assert.rejects(
216
+ socket.writeDurable("request"),
217
+ (error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_DURABLE_WRITE_UNSUPPORTED"
218
+ )
219
+ socket.destroy()
220
+ })
@@ -337,6 +337,31 @@ test("loadEnvironmentConfig parses outbound reverse gateway verified TLS setting
337
337
  assert.deepEqual(config.reverseHubTls, {caFile: "/certs/ca.crt", certFile: "/certs/client.crt", keyFile: "/certs/client.key", servername: "server3.example.test"})
338
338
  })
339
339
 
340
+ test("loadEnvironmentConfig parses the reverse-gateway spool directory", () => {
341
+ const config = loadEnvironmentConfig({
342
+ SOCKETDUCT_MODE: "reverse-gateway",
343
+ SOCKETDUCT_REVERSE_GATEWAY_ID: "starlink",
344
+ SOCKETDUCT_REVERSE_CONNECTION_ID: "home_starlink_01",
345
+ SOCKETDUCT_REVERSE_HUB_HOST: "server3.example.test",
346
+ SOCKETDUCT_REVERSE_TOKEN: "gateway-token",
347
+ SOCKETDUCT_TARGET_ALLOWLIST: "docker-server:2375",
348
+ SOCKETDUCT_SPOOL_DIRECTORY: " /spool/reverse-gateway "
349
+ })
350
+ assert.equal(config.spoolDirectory, "/spool/reverse-gateway")
351
+ assert.equal("spoolDirectory" in loadEnvironmentConfig({
352
+ SOCKETDUCT_MODE: "reverse-gateway",
353
+ SOCKETDUCT_REVERSE_GATEWAY_ID: "starlink",
354
+ SOCKETDUCT_REVERSE_CONNECTION_ID: "home_starlink_01",
355
+ SOCKETDUCT_REVERSE_HUB_HOST: "server3.example.test",
356
+ SOCKETDUCT_REVERSE_TOKEN: "gateway-token",
357
+ SOCKETDUCT_TARGET_ALLOWLIST: "docker-server:2375",
358
+ SOCKETDUCT_SPOOL_DIRECTORY: " "
359
+ }), false)
360
+ assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "relay", SOCKETDUCT_SPOOL_DIRECTORY: "/spool"}), /reverse-gateway-only/)
361
+ assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-hub", SOCKETDUCT_SPOOL_DIRECTORY: "/spool"}), /reverse-gateway-only/)
362
+ assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_SPOOL_DIRECTORY: "/spool"}), /reverse-gateway-only/)
363
+ })
364
+
340
365
  test("loadEnvironmentConfig rejects malformed reverse mode policy and credentials", () => {
341
366
  assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "unknown"}), /Unsupported SOCKETDUCT_MODE/)
342
367
  assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-hub", SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "missing-user"}), /gatewayId:username/)
@@ -13,6 +13,7 @@ import {
13
13
  MultiplexedSocketductTransportSet,
14
14
  OutboundReverseGateway,
15
15
  ReverseGatewayConnection,
16
+ SocketductRecoveryLookupError,
16
17
  SocketductHttpAgent,
17
18
  SocketductSocket
18
19
  } from "../../src/index.js"
@@ -57,6 +58,7 @@ test("package exports the protocol-v3 pooled connection and relay entrypoints",
57
58
 
58
59
  assert.equal(multiplexed.MultiplexedSocketductConnection, MultiplexedSocketductConnection)
59
60
  assert.equal(multiplexed.MultiplexedSocketductTransportSet, MultiplexedSocketductTransportSet)
61
+ assert.equal(multiplexed.SocketductRecoveryLookupError, SocketductRecoveryLookupError)
60
62
  assert.equal(createMultiplexedSocketductConnection({
61
63
  relay: {host: "127.0.0.1", port: 3100, token: "test-token"}
62
64
  }) instanceof MultiplexedSocketductConnection, true)
@@ -61,11 +61,13 @@ test("compressed logger durably acknowledges ordered records and closes asynchro
61
61
  test("compressed logger enforces one shared bounded queue with a critical reserve", async (t) => {
62
62
  const root = await mkdtemp(join(tmpdir(), "socketduct-compressed-pressure-"))
63
63
  const directory = join(root, "logs")
64
+ // The reserve must cover the critical event's conservative charge (line bytes × 3 + 128);
65
+ // an accepted-but-undelivered sequence can never be evicted without tearing the durable journal.
64
66
  const logger = createCompressedJsonlFileLogger({
65
67
  directory,
66
68
  role: "reverse-client",
67
69
  maxQueueBytes: 4096,
68
- criticalReserveBytes: 1024,
70
+ criticalReserveBytes: 2048,
69
71
  frameMaxBytes: 1024,
70
72
  flushIntervalMs: 60_000,
71
73
  criticalFlushIntervalMs: 60_000
@@ -63,6 +63,64 @@ test("SessionSpool truncates acknowledged chunks", async () => {
63
63
  })
64
64
  })
65
65
 
66
+ test("SessionSpool truncates an acknowledgement inside a chunk in application bytes", async () => {
67
+ await withTempDir(async (root) => {
68
+ const spool = new SessionSpool({rootDir: root})
69
+ const sessionId = "session_partial_ack"
70
+ await spool.create({sessionId, ownerId: "user-1"})
71
+ await spool.appendChunk({sessionId, sequence: 8, data: Buffer.from("abcdefgh")})
72
+
73
+ await spool.truncateAcked(sessionId, 3)
74
+
75
+ const chunks = await spool.readChunksAfter(sessionId, 3)
76
+ assert.deepEqual(chunks, [{sequence: 8, data: Buffer.from("defgh")}])
77
+ assert.equal(spool.getUsedBytes(sessionId), 5)
78
+ })
79
+ })
80
+
81
+ test("SessionSpool atomically admits a complete chunk batch before recovery commit", async () => {
82
+ await withTempDir(async (root) => {
83
+ const spool = new SessionSpool({rootDir: root, maxBytes: 32})
84
+ const sessionId = "session_durable_batch"
85
+ await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
86
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 4, data: Buffer.from("base")})
87
+ await spool.writeRecoveryState(sessionId, {version: 3, sendSequence: 4})
88
+
89
+ await spool.appendChunksAndRecoveryState({
90
+ sessionId,
91
+ ownerId: "reverse-client-v3",
92
+ previousSequence: 4,
93
+ chunks: [
94
+ {sequence: 7, data: Buffer.from("one")},
95
+ {sequence: 10, data: Buffer.from("two")}
96
+ ],
97
+ recoveryState: {version: 3, sendSequence: 10}
98
+ })
99
+
100
+ assert.deepEqual((await spool.readChunksAfter(sessionId, 0)).map(({sequence, data}) => [sequence, data.toString()]), [
101
+ [4, "base"],
102
+ [7, "one"],
103
+ [10, "two"]
104
+ ])
105
+ assert.deepEqual(spool.readRecoveryStateSync(sessionId, "reverse-client-v3"), {version: 3, sendSequence: 10})
106
+ assert.equal(spool.getUsedBytes(sessionId), 10)
107
+ })
108
+ })
109
+
110
+ test("SessionSpool recovery lookup distinguishes missing state from invalid state", async () => {
111
+ await withTempDir(async (root) => {
112
+ const spool = new SessionSpool({rootDir: root})
113
+ assert.equal(spool.findRecoveryStateSync("missing_session", "edge-v3"), undefined)
114
+
115
+ await spool.createNew({sessionId: "present_session", ownerId: "edge-v3"})
116
+ await spool.writeRecoveryState("present_session", {version: 3})
117
+ assert.deepEqual(spool.findRecoveryStateSync("present_session", "edge-v3"), {version: 3})
118
+
119
+ await writeFile(join(root, "present_session", "recovery.json"), "{invalid\n")
120
+ assert.throws(() => spool.findRecoveryStateSync("present_session", "edge-v3"), /Invalid Socketduct recovery state/)
121
+ })
122
+ })
123
+
66
124
  test("SessionSpool rejects path traversal and mismatched metadata", async () => {
67
125
  await withTempDir(async (root) => {
68
126
  const spool = new SessionSpool({rootDir: root})