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.
- package/changelog.d/20260925180000-manual-ack-durable-admission.md +1 -0
- package/changelog.d/20260928120416-reverse-gateway-spool-directory.md +3 -0
- package/changelog.d/20260928124500-critical-reserve-bound-test.md +3 -0
- package/docs/logging.md +5 -1
- package/docs/protocol.md +21 -3
- package/docs/reverse-gateway.md +57 -8
- package/docs/running.md +3 -2
- package/docs/session-lifecycle.md +22 -0
- package/docs/socket-api.md +22 -17
- package/package.json +1 -1
- package/src/client/deferred-virtual-socket.js +42 -1
- package/src/client/multiplexed-connection.js +17 -0
- package/src/client/multiplexed-transport-set.js +43 -8
- package/src/client/multiplexed.js +1 -1
- package/src/client/virtual-socket.js +109 -4
- package/src/config/environment.js +5 -1
- package/src/index.js +1 -1
- package/src/reverse/client.js +174 -23
- package/src/reverse/gateway.js +10 -1
- package/src/reverse/runtime.js +1 -0
- package/src/spool/spool-file.js +92 -6
- package/tensorbuzz.yml +1 -1
- package/test/e2e/reverse-client-recovery-process.test.js +3 -0
- package/test/e2e/reverse-gateway-process.test.js +120 -1
- package/test/fixtures/reverse-client-recovery.mjs +14 -2
- package/test/integration/multiplexed-transport-set.test.js +1 -5
- package/test/integration/reverse-gateway.test.js +158 -1
- package/test/unit/client/multiplexed-transport-set.test.js +50 -3
- package/test/unit/client/virtual-socket.test.js +123 -1
- package/test/unit/config/environment.test.js +25 -0
- package/test/unit/index.test.js +2 -0
- package/test/unit/logging/compressed-log-writer.test.js +3 -1
- 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/)
|
package/test/unit/index.test.js
CHANGED
|
@@ -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:
|
|
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})
|