socketduct 0.1.25 → 0.1.26
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/AGENTS.md +2 -2
- package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
- package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
- package/package.json +2 -2
- package/scripts/test.js +9 -14
- package/src/logging/compressed-log-writer.js +1 -1
- package/src/logging/zstd-frame-codec.js +15 -1
- package/test/e2e/gateway-process.test.js +93 -92
- package/test/e2e/logger-shutdown-process.test.js +40 -37
- package/test/e2e/reverse-client-recovery-process.test.js +107 -103
- package/test/e2e/reverse-gateway-process.test.js +328 -318
- package/test/integration/initial-target-recovery.test.js +333 -334
- package/test/integration/logging-cli.test.js +121 -119
- package/test/integration/logging-compressed-recovery.test.js +96 -95
- package/test/integration/multiplexed-compatibility.test.js +178 -177
- package/test/integration/multiplexed-corrections.test.js +360 -359
- package/test/integration/multiplexed-failures.test.js +253 -251
- package/test/integration/multiplexed-transport-set.test.js +334 -322
- package/test/integration/multiplexed-transport.test.js +135 -134
- package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
- package/test/integration/reverse-gateway.test.js +2309 -2218
- package/test/integration/reverse-resume-continuity.test.js +219 -218
- package/test/integration/reverse-route-class.test.js +358 -341
- package/test/integration/socketduct-http-agent.test.js +314 -312
- package/test/integration/socketduct-https-agent.test.js +516 -485
- package/test/integration/stream-compression.test.js +862 -824
- package/test/integration/tcp-forwarding.test.js +2550 -2447
- package/test/integration/tls-forwarding.test.js +593 -592
- package/test/unit/auth/auth.test.js +43 -44
- package/test/unit/auth/static-token-store.test.js +9 -8
- package/test/unit/client/connect.test.js +41 -40
- package/test/unit/client/deferred-virtual-socket.test.js +95 -94
- package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
- package/test/unit/client/multiplexed-transport-set.test.js +830 -748
- package/test/unit/client/socketduct-socket.test.js +768 -771
- package/test/unit/client/virtual-socket.test.js +198 -168
- package/test/unit/config/docker-gateway-files.test.js +224 -224
- package/test/unit/config/environment.test.js +472 -492
- package/test/unit/index.test.js +53 -52
- package/test/unit/logging/compressed-log-worker.test.js +23 -22
- package/test/unit/logging/compressed-log-writer.test.js +155 -154
- package/test/unit/logging/log-retention.test.js +52 -47
- package/test/unit/logging/log-segment-index.test.js +84 -81
- package/test/unit/logging/zstd-frame-codec.test.js +28 -19
- package/test/unit/protocol/compression.test.js +254 -298
- package/test/unit/protocol/frame.test.js +280 -276
- package/test/unit/protocol/v3-compression.test.js +21 -23
- package/test/unit/protocol/v3-frame.test.js +42 -41
- package/test/unit/release-configuration.test.js +15 -12
- package/test/unit/reverse/disconnect-attribution.test.js +65 -64
- package/test/unit/reverse-terminal-outcomes.test.js +53 -50
- package/test/unit/server/control-handler.test.js +232 -231
- package/test/unit/server/primary.test.js +51 -49
- package/test/unit/server/relay-runtime.test.js +280 -294
- package/test/unit/sessions/session.test.js +48 -62
- package/test/unit/spool/spool-file.test.js +401 -385
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {describe, expect, it} from "@velocious/testing"
|
|
2
2
|
import {access, mkdtemp, readFile, rm, stat, writeFile} from "node:fs/promises"
|
|
3
3
|
import {tmpdir} from "node:os"
|
|
4
4
|
import {join} from "node:path"
|
|
5
|
-
import test from "node:test"
|
|
6
5
|
|
|
7
6
|
import {
|
|
8
7
|
SessionSpool,
|
|
@@ -23,464 +22,481 @@ async function withTempDir(fn) {
|
|
|
23
22
|
}
|
|
24
23
|
}
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
describe("session spool", () => {
|
|
26
|
+
it("SessionSpool creates private per-session temp storage with metadata", async () => {
|
|
27
|
+
await withTempDir(async (root) => {
|
|
28
|
+
const spool = new SessionSpool({rootDir: root})
|
|
29
|
+
const session = await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
|
|
30
|
+
const mode = (await stat(session.directory)).mode & 0o777
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
expect(mode).toBe(0o700)
|
|
33
|
+
expect(session.metadata.sessionId).toBe("session_1234567890")
|
|
34
|
+
expect(session.metadata.ownerId).toBe("user-1")
|
|
35
|
+
})
|
|
35
36
|
})
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
test("SessionSpool writes chunks and reads only chunks after a requested sequence", async () => {
|
|
39
|
-
await withTempDir(async (root) => {
|
|
40
|
-
const spool = new SessionSpool({rootDir: root})
|
|
41
|
-
await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
|
|
42
|
-
await spool.appendChunk({sessionId: "session_1234567890", sequence: 1, data: Buffer.from("one")})
|
|
43
|
-
await spool.appendChunk({sessionId: "session_1234567890", sequence: 2, data: Buffer.from([0, 255])})
|
|
44
|
-
await spool.appendChunk({sessionId: "session_1234567890", sequence: 3, data: Buffer.from("three")})
|
|
45
37
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
38
|
+
it("SessionSpool writes chunks and reads only chunks after a requested sequence", async () => {
|
|
39
|
+
await withTempDir(async (root) => {
|
|
40
|
+
const spool = new SessionSpool({rootDir: root})
|
|
41
|
+
await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
|
|
42
|
+
await spool.appendChunk({sessionId: "session_1234567890", sequence: 1, data: Buffer.from("one")})
|
|
43
|
+
await spool.appendChunk({sessionId: "session_1234567890", sequence: 2, data: Buffer.from([0, 255])})
|
|
44
|
+
await spool.appendChunk({sessionId: "session_1234567890", sequence: 3, data: Buffer.from("three")})
|
|
45
|
+
|
|
46
|
+
const chunks = await spool.readChunksAfter("session_1234567890", 1)
|
|
47
|
+
|
|
48
|
+
expect(chunks.map((chunk) => chunk.sequence)).toEqual([2, 3])
|
|
49
|
+
const firstData = chunks[0]?.data
|
|
50
|
+
if (firstData === undefined) throw new Error("Expected a chunk after sequence 1")
|
|
51
|
+
expect(firstData.equals(Buffer.from([0, 255]))).toBe(true)
|
|
52
|
+
expect(chunks[1]?.data.toString("utf8")).toBe("three")
|
|
53
|
+
})
|
|
51
54
|
})
|
|
52
|
-
})
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
it("SessionSpool truncates acknowledged chunks", async () => {
|
|
57
|
+
await withTempDir(async (root) => {
|
|
58
|
+
const spool = new SessionSpool({rootDir: root})
|
|
59
|
+
await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
|
|
60
|
+
await spool.appendChunk({sessionId: "session_1234567890", sequence: 1, data: Buffer.from("one")})
|
|
61
|
+
await spool.appendChunk({sessionId: "session_1234567890", sequence: 2, data: Buffer.from("two")})
|
|
62
|
+
await spool.truncateAcked("session_1234567890", 1)
|
|
61
63
|
|
|
62
|
-
|
|
64
|
+
expect((await spool.readChunksAfter("session_1234567890", 0)).map((chunk) => chunk.sequence)).toEqual([2])
|
|
65
|
+
})
|
|
63
66
|
})
|
|
64
|
-
})
|
|
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
67
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
68
|
+
it("SessionSpool truncates an acknowledgement inside a chunk in application bytes", async () => {
|
|
69
|
+
await withTempDir(async (root) => {
|
|
70
|
+
const spool = new SessionSpool({rootDir: root})
|
|
71
|
+
const sessionId = "session_partial_ack"
|
|
72
|
+
await spool.create({sessionId, ownerId: "user-1"})
|
|
73
|
+
await spool.appendChunk({sessionId, sequence: 8, data: Buffer.from("abcdefgh")})
|
|
74
|
+
|
|
75
|
+
await spool.truncateAcked(sessionId, 3)
|
|
76
|
+
|
|
77
|
+
const chunks = await spool.readChunksAfter(sessionId, 3)
|
|
78
|
+
expect(chunks.map((chunk) => chunk.sequence)).toEqual([8])
|
|
79
|
+
const data = chunks[0]?.data
|
|
80
|
+
if (data === undefined) throw new Error("Expected a partially truncated chunk")
|
|
81
|
+
expect(data.equals(Buffer.from("defgh"))).toBe(true)
|
|
82
|
+
expect(spool.getUsedBytes(sessionId)).toBe(5)
|
|
83
|
+
})
|
|
78
84
|
})
|
|
79
|
-
})
|
|
80
85
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
86
|
+
it("SessionSpool atomically admits a complete chunk batch before recovery commit", async () => {
|
|
87
|
+
await withTempDir(async (root) => {
|
|
88
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 32})
|
|
89
|
+
const sessionId = "session_durable_batch"
|
|
90
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
91
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 4, data: Buffer.from("base")})
|
|
92
|
+
await spool.writeRecoveryState(sessionId, {version: 3, sendSequence: 4})
|
|
93
|
+
|
|
94
|
+
await spool.appendChunksAndRecoveryState({
|
|
95
|
+
sessionId,
|
|
96
|
+
ownerId: "reverse-client-v3",
|
|
97
|
+
previousSequence: 4,
|
|
98
|
+
chunks: [
|
|
99
|
+
{sequence: 7, data: Buffer.from("one")},
|
|
100
|
+
{sequence: 10, data: Buffer.from("two")}
|
|
101
|
+
],
|
|
102
|
+
recoveryState: {version: 3, sendSequence: 10}
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
expect((await spool.readChunksAfter(sessionId, 0)).map(({sequence, data}) => [sequence, data.toString()])).toEqual([
|
|
106
|
+
[4, "base"],
|
|
107
|
+
[7, "one"],
|
|
108
|
+
[10, "two"]
|
|
109
|
+
])
|
|
110
|
+
expect(spool.readRecoveryStateSync(sessionId, "reverse-client-v3")).toEqual({version: 3, sendSequence: 10})
|
|
111
|
+
expect(spool.getUsedBytes(sessionId)).toBe(10)
|
|
98
112
|
})
|
|
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
113
|
})
|
|
108
|
-
})
|
|
109
114
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
115
|
+
it("SessionSpool recovery lookup distinguishes missing state from invalid state", async () => {
|
|
116
|
+
await withTempDir(async (root) => {
|
|
117
|
+
const spool = new SessionSpool({rootDir: root})
|
|
118
|
+
expect(spool.findRecoveryStateSync("missing_session", "edge-v3")).toBeUndefined()
|
|
114
119
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
120
|
+
await spool.createNew({sessionId: "present_session", ownerId: "edge-v3"})
|
|
121
|
+
await spool.writeRecoveryState("present_session", {version: 3})
|
|
122
|
+
expect(spool.findRecoveryStateSync("present_session", "edge-v3")).toEqual({version: 3})
|
|
118
123
|
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
await writeFile(join(root, "present_session", "recovery.json"), "{invalid\n")
|
|
125
|
+
expect(() => spool.findRecoveryStateSync("present_session", "edge-v3")).toThrow(/Invalid Socketduct recovery state/)
|
|
126
|
+
})
|
|
121
127
|
})
|
|
122
|
-
})
|
|
123
128
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
it("SessionSpool rejects path traversal and mismatched metadata", async () => {
|
|
130
|
+
await withTempDir(async (root) => {
|
|
131
|
+
const spool = new SessionSpool({rootDir: root})
|
|
127
132
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
/invalid session identifier/i
|
|
131
|
-
)
|
|
133
|
+
await expect(
|
|
134
|
+
spool.create({sessionId: "../escape", ownerId: "user-1"})
|
|
135
|
+
).rejects.toThrow(/invalid session identifier/i)
|
|
132
136
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
/does not match spool metadata/i
|
|
137
|
-
)
|
|
137
|
+
await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
|
|
138
|
+
await expect(
|
|
139
|
+
spool.appendChunk({sessionId: "session_1234567890", ownerId: "other-user", sequence: 1, data: Buffer.from("nope")})
|
|
140
|
+
).rejects.toThrow(/does not match spool metadata/i)
|
|
141
|
+
})
|
|
138
142
|
})
|
|
139
|
-
})
|
|
140
143
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
it("SessionSpool.remove removes all session spool data and rejects subsequent operations", async () => {
|
|
145
|
+
await withTempDir(async (root) => {
|
|
146
|
+
const spool = new SessionSpool({rootDir: root})
|
|
147
|
+
const sessionId = "session_1234567890"
|
|
148
|
+
await spool.create({sessionId, ownerId: "user-1"})
|
|
149
|
+
await spool.appendChunk({sessionId, sequence: 1, data: Buffer.from("hello")})
|
|
147
150
|
|
|
148
|
-
|
|
149
|
-
|
|
151
|
+
const dir = join(root, sessionId)
|
|
152
|
+
await access(dir)
|
|
150
153
|
|
|
151
|
-
|
|
154
|
+
await spool.remove(sessionId)
|
|
152
155
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
/ENOENT|no such file/i
|
|
156
|
-
)
|
|
156
|
+
await expect(
|
|
157
|
+
access(dir)
|
|
158
|
+
).rejects.toThrow(/ENOENT|no such file/i)
|
|
157
159
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
/ENOENT|no such file/i
|
|
161
|
-
)
|
|
160
|
+
await expect(
|
|
161
|
+
spool.readChunksAfter(sessionId, 0)
|
|
162
|
+
).rejects.toThrow(/ENOENT|no such file/i)
|
|
163
|
+
})
|
|
162
164
|
})
|
|
163
|
-
})
|
|
164
165
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
it("SessionSpool.remove is safe when session does not exist", async () => {
|
|
167
|
+
await withTempDir(async (root) => {
|
|
168
|
+
const spool = new SessionSpool({rootDir: root})
|
|
168
169
|
|
|
169
|
-
|
|
170
|
-
|
|
170
|
+
// does not throw
|
|
171
|
+
await spool.remove("nonexistent_session")
|
|
172
|
+
})
|
|
171
173
|
})
|
|
172
|
-
})
|
|
173
174
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
assert.equal(err.code, "SPOOL_QUOTA_EXCEEDED")
|
|
190
|
-
assert.ok(err.message.includes("100"))
|
|
191
|
-
return true
|
|
175
|
+
it("SessionSpool enforces maxBytes quota on appendChunk", async () => {
|
|
176
|
+
await withTempDir(async (root) => {
|
|
177
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 100})
|
|
178
|
+
const sessionId = "session_1234567890"
|
|
179
|
+
await spool.create({sessionId, ownerId: "user-1"})
|
|
180
|
+
|
|
181
|
+
await spool.appendChunk({sessionId, sequence: 1, data: Buffer.alloc(50)})
|
|
182
|
+
await spool.appendChunk({sessionId, sequence: 2, data: Buffer.alloc(40)})
|
|
183
|
+
|
|
184
|
+
let error
|
|
185
|
+
try {
|
|
186
|
+
await spool.appendChunk({sessionId, sequence: 3, data: Buffer.alloc(30)})
|
|
187
|
+
} catch (caught) {
|
|
188
|
+
error = caught
|
|
192
189
|
}
|
|
193
|
-
|
|
190
|
+
expect(error).toBeDefined()
|
|
191
|
+
expect(error instanceof SpoolQuotaError).toBe(true)
|
|
192
|
+
if (!(error instanceof SpoolQuotaError)) throw new Error("Expected a SpoolQuotaError")
|
|
193
|
+
expect(error.code).toBe("SPOOL_QUOTA_EXCEEDED")
|
|
194
|
+
expect(error.message.includes("100")).toBe(true)
|
|
195
|
+
})
|
|
194
196
|
})
|
|
195
|
-
})
|
|
196
197
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
198
|
+
it("SessionSpool maxBytes quota does not block under-limit appends", async () => {
|
|
199
|
+
await withTempDir(async (root) => {
|
|
200
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 200})
|
|
201
|
+
const sessionId = "session_1234567890"
|
|
202
|
+
await spool.create({sessionId, ownerId: "user-1"})
|
|
202
203
|
|
|
203
|
-
|
|
204
|
-
|
|
204
|
+
// exactly at limit
|
|
205
|
+
await spool.appendChunk({sessionId, sequence: 1, data: Buffer.alloc(200)})
|
|
205
206
|
|
|
206
|
-
|
|
207
|
-
|
|
207
|
+
const chunks = await spool.readChunksAfter(sessionId, 0)
|
|
208
|
+
expect(chunks.length).toBe(1)
|
|
209
|
+
})
|
|
208
210
|
})
|
|
209
|
-
})
|
|
210
211
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
assert.equal(err.code, "SPOOL_AGE_EXCEEDED")
|
|
230
|
-
return true
|
|
212
|
+
it("SessionSpool enforces maxAgeMs quota for aged-out sessions", async () => {
|
|
213
|
+
await withTempDir(async (root) => {
|
|
214
|
+
let now = 1_000_000
|
|
215
|
+
const maxAgeMs = 60_000
|
|
216
|
+
const spool = new SessionSpool({rootDir: root, maxAgeMs, now: () => now})
|
|
217
|
+
|
|
218
|
+
const session = await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
|
|
219
|
+
|
|
220
|
+
now = 1_000_000 + maxAgeMs - 1
|
|
221
|
+
await spool.appendChunk({sessionId: session.metadata.sessionId, sequence: 1, data: Buffer.from("ok")})
|
|
222
|
+
|
|
223
|
+
now = 1_000_000 + maxAgeMs + 1
|
|
224
|
+
let error
|
|
225
|
+
try {
|
|
226
|
+
await spool.appendChunk({sessionId: session.metadata.sessionId, sequence: 2, data: Buffer.from("x")})
|
|
227
|
+
} catch (caught) {
|
|
228
|
+
error = caught
|
|
231
229
|
}
|
|
232
|
-
|
|
230
|
+
expect(error).toBeDefined()
|
|
231
|
+
expect(error instanceof SpoolQuotaError).toBe(true)
|
|
232
|
+
if (!(error instanceof SpoolQuotaError)) throw new Error("Expected a SpoolQuotaError")
|
|
233
|
+
expect(error.code).toBe("SPOOL_AGE_EXCEEDED")
|
|
234
|
+
})
|
|
233
235
|
})
|
|
234
|
-
})
|
|
235
236
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
237
|
+
it("SessionSpool synchronously rejects expired durable recovery state", async () => {
|
|
238
|
+
await withTempDir(async (root) => {
|
|
239
|
+
let now = 1_000_000
|
|
240
|
+
const spool = new SessionSpool({rootDir: root, maxAgeMs: 60_000, now: () => now})
|
|
241
|
+
const sessionId = "session_recovery_expired"
|
|
242
|
+
await spool.createNew({sessionId, ownerId: "edge-v3"})
|
|
243
|
+
await spool.writeRecoveryState(sessionId, {version: 3})
|
|
244
|
+
|
|
245
|
+
now += 60_001
|
|
246
|
+
let error
|
|
247
|
+
try {
|
|
248
|
+
spool.readRecoveryStateSync(sessionId, "edge-v3")
|
|
249
|
+
} catch (caught) {
|
|
250
|
+
error = caught
|
|
251
|
+
}
|
|
252
|
+
expect(error).toBeDefined()
|
|
253
|
+
expect(error instanceof SpoolQuotaError).toBe(true)
|
|
254
|
+
if (!(error instanceof SpoolQuotaError)) throw new Error("Expected a SpoolQuotaError")
|
|
255
|
+
expect(error.code).toBe("SPOOL_AGE_EXCEEDED")
|
|
256
|
+
})
|
|
249
257
|
})
|
|
250
|
-
})
|
|
251
258
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
/Invalid Socketduct recovery chunks/
|
|
268
|
-
)
|
|
259
|
+
it("SessionSpool loadChunksSync restores only unacknowledged owned chunks", async () => {
|
|
260
|
+
await withTempDir(async (root) => {
|
|
261
|
+
const sessionId = "session_recovery_chunks"
|
|
262
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
263
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
264
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("old")})
|
|
265
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("new")})
|
|
266
|
+
|
|
267
|
+
const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 3, 6)
|
|
268
|
+
|
|
269
|
+
expect(chunks.map((chunk) => chunk.sequence)).toEqual([6])
|
|
270
|
+
expect(chunks[0]?.data.toString()).toBe("new")
|
|
271
|
+
expect(spool.getUsedBytes(sessionId)).toBe(3)
|
|
272
|
+
expect(
|
|
273
|
+
() => spool.loadChunksSync(sessionId, "other-owner", 3, 6)
|
|
274
|
+
).toThrow(/Invalid Socketduct recovery chunks/)
|
|
275
|
+
})
|
|
269
276
|
})
|
|
270
|
-
})
|
|
271
277
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
278
|
+
it("SessionSpool recovery repairs interruption after chunk append before usage update", async () => {
|
|
279
|
+
await withTempDir(async (root) => {
|
|
280
|
+
const sessionId = "session_append_interruption"
|
|
281
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
282
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
283
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
284
|
+
const chunksPath = join(root, sessionId, "chunks.jsonl")
|
|
285
|
+
const committed = await readFile(chunksPath, "utf8")
|
|
286
|
+
await writeFile(chunksPath, `${committed}${JSON.stringify({sequence: 6, dataBase64: Buffer.from("two").toString("base64")})}\n`)
|
|
287
|
+
|
|
288
|
+
const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3)
|
|
289
|
+
|
|
290
|
+
expect(chunks.map((chunk) => chunk.sequence)).toEqual([3])
|
|
291
|
+
expect(await readFile(chunksPath, "utf8")).toBe(committed)
|
|
292
|
+
expect(JSON.parse(await readFile(join(root, sessionId, "usage.json"), "utf8"))).toEqual({totalBytes: 3})
|
|
293
|
+
expect((await stat(chunksPath)).mode & 0o777).toBe(0o600)
|
|
294
|
+
expect((await stat(join(root, sessionId, "usage.json"))).mode & 0o777).toBe(0o600)
|
|
295
|
+
})
|
|
289
296
|
})
|
|
290
|
-
})
|
|
291
297
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
298
|
+
it("SessionSpool recovery repairs interruption after usage update before recovery commit", async () => {
|
|
299
|
+
await withTempDir(async (root) => {
|
|
300
|
+
const sessionId = "session_usage_interruption"
|
|
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
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("two")})
|
|
299
305
|
|
|
300
|
-
|
|
306
|
+
const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3)
|
|
301
307
|
|
|
302
|
-
|
|
303
|
-
|
|
308
|
+
expect(chunks.map((chunk) => chunk.sequence)).toEqual([3])
|
|
309
|
+
expect(JSON.parse(await readFile(join(root, sessionId, "usage.json"), "utf8"))).toEqual({totalBytes: 3})
|
|
310
|
+
})
|
|
304
311
|
})
|
|
305
|
-
})
|
|
306
312
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
313
|
+
it("SessionSpool recovery repairs interruption after acknowledged chunk truncation", async () => {
|
|
314
|
+
await withTempDir(async (root) => {
|
|
315
|
+
const sessionId = "session_truncate_interruption"
|
|
316
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
317
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
318
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
319
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("two")})
|
|
320
|
+
const chunksPath = join(root, sessionId, "chunks.jsonl")
|
|
321
|
+
await writeFile(chunksPath, `${JSON.stringify({sequence: 6, dataBase64: Buffer.from("two").toString("base64")})}\n`)
|
|
316
322
|
|
|
317
|
-
|
|
323
|
+
const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 3, 6)
|
|
318
324
|
|
|
319
|
-
|
|
320
|
-
|
|
325
|
+
expect(chunks.map((chunk) => chunk.sequence)).toEqual([6])
|
|
326
|
+
expect(JSON.parse(await readFile(join(root, sessionId, "usage.json"), "utf8"))).toEqual({totalBytes: 3})
|
|
327
|
+
})
|
|
321
328
|
})
|
|
322
|
-
})
|
|
323
329
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
330
|
+
it("SessionSpool recovery repairs a partial trailing JSONL record after committed chunks", async () => {
|
|
331
|
+
await withTempDir(async (root) => {
|
|
332
|
+
const sessionId = "session_partial_interruption"
|
|
333
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
334
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
335
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
336
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("two")})
|
|
337
|
+
const chunksPath = join(root, sessionId, "chunks.jsonl")
|
|
338
|
+
const committed = await readFile(chunksPath, "utf8")
|
|
339
|
+
await writeFile(chunksPath, `${committed}{"sequence":9,"dataBase64":"thr`)
|
|
340
|
+
|
|
341
|
+
const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 6)
|
|
342
|
+
|
|
343
|
+
expect(chunks.map((chunk) => chunk.sequence)).toEqual([3, 6])
|
|
344
|
+
expect(await readFile(chunksPath, "utf8")).toBe(committed)
|
|
345
|
+
})
|
|
339
346
|
})
|
|
340
|
-
})
|
|
341
347
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
/Invalid Socketduct recovery chunks/
|
|
354
|
-
)
|
|
355
|
-
})
|
|
348
|
+
it("SessionSpool recovery rejects corruption before or outside a partial uncommitted tail", async () => {
|
|
349
|
+
await withTempDir(async (root) => {
|
|
350
|
+
const sessionId = "session_committed_corruption"
|
|
351
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
352
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
353
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
354
|
+
const chunksPath = join(root, sessionId, "chunks.jsonl")
|
|
355
|
+
await writeFile(chunksPath, `${await readFile(chunksPath, "utf8")}{"sequence":6,"dataBase64":"tw`)
|
|
356
|
+
|
|
357
|
+
expect(
|
|
358
|
+
() => spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 6)
|
|
359
|
+
).toThrow(/Invalid Socketduct recovery chunks/)
|
|
360
|
+
})
|
|
356
361
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
/Invalid Socketduct recovery chunks/
|
|
368
|
-
)
|
|
369
|
-
|
|
362
|
+
await withTempDir(async (root) => {
|
|
363
|
+
const sessionId = "session_tail_corruption"
|
|
364
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
365
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
366
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
367
|
+
const chunksPath = join(root, sessionId, "chunks.jsonl")
|
|
368
|
+
await writeFile(chunksPath, `${await readFile(chunksPath, "utf8")}{not-json}\n`)
|
|
369
|
+
|
|
370
|
+
expect(
|
|
371
|
+
() => spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3)
|
|
372
|
+
).toThrow(/Invalid Socketduct recovery chunks/)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
await withTempDir(async (root) => {
|
|
376
|
+
const sessionId = "session_usage_corruption"
|
|
377
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
378
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
379
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
380
|
+
await writeFile(join(root, sessionId, "usage.json"), "{not-json\n")
|
|
370
381
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
376
|
-
await writeFile(join(root, sessionId, "usage.json"), "{not-json\n")
|
|
377
|
-
|
|
378
|
-
assert.throws(
|
|
379
|
-
() => spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3),
|
|
380
|
-
/Invalid Socketduct recovery chunks/
|
|
381
|
-
)
|
|
382
|
+
expect(
|
|
383
|
+
() => spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3)
|
|
384
|
+
).toThrow(/Invalid Socketduct recovery chunks/)
|
|
385
|
+
})
|
|
382
386
|
})
|
|
383
|
-
})
|
|
384
387
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
388
|
+
it("SessionSpool cleans expired abandoned entries so their stable IDs can be reused", async () => {
|
|
389
|
+
await withTempDir(async (root) => {
|
|
390
|
+
let now = 1_000_000
|
|
391
|
+
const sessionId = "session_abandoned_expired"
|
|
392
|
+
const first = new SessionSpool({rootDir: root, maxAgeMs: 60_000, now: () => now})
|
|
393
|
+
await first.createNew({sessionId, ownerId: "edge-v3"})
|
|
394
|
+
|
|
395
|
+
now += 60_001
|
|
396
|
+
const replacement = new SessionSpool({rootDir: root, maxAgeMs: 60_000, now: () => now})
|
|
397
|
+
await replacement.createNew({sessionId, ownerId: "edge-v3"})
|
|
398
|
+
await access(join(root, sessionId))
|
|
399
|
+
})
|
|
396
400
|
})
|
|
397
|
-
})
|
|
398
401
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
402
|
+
it("SessionSpool appendChunk appends lines without rewriting whole file", async () => {
|
|
403
|
+
await withTempDir(async (root) => {
|
|
404
|
+
const spool = new SessionSpool({rootDir: root})
|
|
405
|
+
const sessionId = "session_1234567890"
|
|
406
|
+
await spool.create({sessionId, ownerId: "user-1"})
|
|
407
|
+
|
|
408
|
+
await spool.appendChunk({sessionId, sequence: 1, data: Buffer.from("first")})
|
|
409
|
+
await spool.appendChunk({sessionId, sequence: 2, data: Buffer.from("second")})
|
|
410
|
+
await spool.appendChunk({sessionId, sequence: 3, data: Buffer.from("third")})
|
|
411
|
+
|
|
412
|
+
const raw = await readFile(join(root, sessionId, "chunks.jsonl"), "utf8")
|
|
413
|
+
const lines = raw.trimEnd().split("\n")
|
|
414
|
+
|
|
415
|
+
expect(lines.length >= 3).toBeTruthy()
|
|
416
|
+
const chunk1 = JSON.parse(/** @type {string} */ (lines[0]))
|
|
417
|
+
const chunk2 = JSON.parse(/** @type {string} */ (lines[1]))
|
|
418
|
+
const chunk3 = JSON.parse(/** @type {string} */ (lines[2]))
|
|
419
|
+
expect(chunk1.sequence).toBe(1)
|
|
420
|
+
expect(chunk2.sequence).toBe(2)
|
|
421
|
+
expect(chunk3.sequence).toBe(3)
|
|
422
|
+
})
|
|
419
423
|
})
|
|
420
|
-
})
|
|
421
424
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
425
|
+
it("SessionSpool getUsedBytes tracks per-session spool byte usage", async () => {
|
|
426
|
+
await withTempDir(async (root) => {
|
|
427
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 1000})
|
|
428
|
+
const sessionId = "session_1234567890"
|
|
429
|
+
await spool.create({sessionId, ownerId: "user-1"})
|
|
427
430
|
|
|
428
|
-
|
|
431
|
+
expect(spool.getUsedBytes(sessionId)).toBe(0)
|
|
429
432
|
|
|
430
|
-
|
|
431
|
-
|
|
433
|
+
await spool.appendChunk({sessionId, sequence: 1, data: Buffer.alloc(50)})
|
|
434
|
+
expect(spool.getUsedBytes(sessionId)).toBe(50)
|
|
432
435
|
|
|
433
|
-
|
|
434
|
-
|
|
436
|
+
await spool.appendChunk({sessionId, sequence: 2, data: Buffer.alloc(30)})
|
|
437
|
+
expect(spool.getUsedBytes(sessionId)).toBe(80)
|
|
435
438
|
|
|
436
|
-
|
|
437
|
-
|
|
439
|
+
await spool.truncateAcked(sessionId, 1)
|
|
440
|
+
expect(spool.getUsedBytes(sessionId)).toBe(30)
|
|
441
|
+
})
|
|
438
442
|
})
|
|
439
|
-
})
|
|
440
443
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
})
|
|
445
|
-
|
|
446
|
-
test("SessionSpool reopened with existing data enforces maxBytes quota from durable usage", async () => {
|
|
447
|
-
await withTempDir(async (root) => {
|
|
448
|
-
const sessionId = "session_1234567890"
|
|
444
|
+
it("SessionSpool getUsedBytes returns undefined for non-existent session", () => {
|
|
445
|
+
const spool = new SessionSpool({rootDir: "/tmp"})
|
|
446
|
+
expect(spool.getUsedBytes("nonexistent_session")).toBeUndefined()
|
|
447
|
+
})
|
|
449
448
|
|
|
450
|
-
|
|
451
|
-
await
|
|
452
|
-
|
|
449
|
+
it("SessionSpool reopened with existing data enforces maxBytes quota from durable usage", async () => {
|
|
450
|
+
await withTempDir(async (root) => {
|
|
451
|
+
const sessionId = "session_1234567890"
|
|
453
452
|
|
|
454
|
-
|
|
455
|
-
|
|
453
|
+
const spool1 = new SessionSpool({rootDir: root, maxBytes: 100})
|
|
454
|
+
await spool1.create({sessionId, ownerId: "user-1"})
|
|
455
|
+
await spool1.appendChunk({sessionId, sequence: 1, data: Buffer.alloc(60)})
|
|
456
456
|
|
|
457
|
-
|
|
458
|
-
spool2.appendChunk({sessionId, sequence:
|
|
459
|
-
(err) => err instanceof SpoolQuotaError && err.code === "SPOOL_QUOTA_EXCEEDED"
|
|
460
|
-
)
|
|
457
|
+
const spool2 = new SessionSpool({rootDir: root, maxBytes: 100})
|
|
458
|
+
await spool2.appendChunk({sessionId, sequence: 2, data: Buffer.alloc(30)})
|
|
461
459
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
460
|
+
let error
|
|
461
|
+
try {
|
|
462
|
+
await spool2.appendChunk({sessionId, sequence: 3, data: Buffer.alloc(20)})
|
|
463
|
+
} catch (caught) {
|
|
464
|
+
error = caught
|
|
465
|
+
}
|
|
466
|
+
expect(error).toBeDefined()
|
|
467
|
+
expect(error instanceof SpoolQuotaError).toBe(true)
|
|
468
|
+
if (!(error instanceof SpoolQuotaError)) throw new Error("Expected a SpoolQuotaError")
|
|
469
|
+
expect(error.code).toBe("SPOOL_QUOTA_EXCEEDED")
|
|
470
|
+
|
|
471
|
+
const chunks = await spool2.readChunksAfter(sessionId, 0)
|
|
472
|
+
expect(chunks.length).toBe(2)
|
|
473
|
+
expect(chunks[0]?.sequence).toBe(1)
|
|
474
|
+
expect(chunks[1]?.sequence).toBe(2)
|
|
475
|
+
})
|
|
466
476
|
})
|
|
467
|
-
})
|
|
468
477
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
478
|
+
it("SessionSpool reconciles a stale usage sidecar from durable chunks when reopened", async () => {
|
|
479
|
+
await withTempDir(async (root) => {
|
|
480
|
+
const sessionId = "session_1234567890"
|
|
481
|
+
const spool1 = new SessionSpool({rootDir: root, maxBytes: 20})
|
|
482
|
+
await spool1.create({sessionId, ownerId: "user-1"})
|
|
483
|
+
await spool1.appendChunk({sessionId, sequence: 1, data: Buffer.alloc(10)})
|
|
484
|
+
await writeFile(join(root, sessionId, "usage.json"), JSON.stringify({totalBytes: 20}) + "\n")
|
|
485
|
+
|
|
486
|
+
const spool2 = new SessionSpool({rootDir: root, maxBytes: 20})
|
|
487
|
+
await spool2.appendChunk({sessionId, sequence: 2, data: Buffer.alloc(10)})
|
|
488
|
+
|
|
489
|
+
let error
|
|
490
|
+
try {
|
|
491
|
+
await spool2.appendChunk({sessionId, sequence: 3, data: Buffer.alloc(1)})
|
|
492
|
+
} catch (caught) {
|
|
493
|
+
error = caught
|
|
494
|
+
}
|
|
495
|
+
expect(error).toBeDefined()
|
|
496
|
+
expect(error instanceof SpoolQuotaError).toBe(true)
|
|
497
|
+
if (!(error instanceof SpoolQuotaError)) throw new Error("Expected a SpoolQuotaError")
|
|
498
|
+
expect(error.code).toBe("SPOOL_QUOTA_EXCEEDED")
|
|
499
|
+
expect((await spool2.readChunksAfter(sessionId, 0)).map((chunk) => chunk.data.length)).toEqual([10, 10])
|
|
500
|
+
})
|
|
485
501
|
})
|
|
486
502
|
})
|