socketduct 0.1.24 → 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/client/multiplexed-transport-set.js +20 -2
- package/src/logging/compressed-log-writer.js +1 -1
- package/src/logging/zstd-frame-codec.js +15 -1
- package/src/reverse/client.js +44 -1
- package/src/reverse/hub.js +10 -1
- package/src/reverse/runtime.js +11 -2
- 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 +363 -278
- 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 +845 -732
- 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,31 +1,29 @@
|
|
|
1
|
-
import
|
|
2
|
-
import test from "node:test"
|
|
1
|
+
import {describe, expect, it} from "@velocious/testing"
|
|
3
2
|
|
|
4
3
|
import {ZSTD_CODEC, zstdAvailable} from "../../../src/protocol/compression.js"
|
|
5
4
|
import {AdaptiveCompressor, decodeV3Data, encodeV3Data} from "../../../src/protocol/v3-compression.js"
|
|
6
5
|
import {V3FrameFlag} from "../../../src/protocol/v3-frame.js"
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
assert.deepEqual(await decodeV3Data(encoded, ZSTD_CODEC), raw)
|
|
18
|
-
await assert.rejects(decodeV3Data(encoded, undefined), /not negotiated/i)
|
|
7
|
+
describe("v3 DATA compression", () => {
|
|
8
|
+
it("v3 reverse DATA compression is independent, bounded, and negotiated", {skip: !zstdAvailable()}, async () => {
|
|
9
|
+
// Skipped when the Node runtime has no zstd binding.
|
|
10
|
+
const raw = Buffer.from(Array.from({length: 4096}, (_, index) => `{"dockerEvent":${index},"status":"running"}\n`).join(""))
|
|
11
|
+
const encoded = await encodeV3Data(raw, ZSTD_CODEC, new AdaptiveCompressor())
|
|
12
|
+
expect(encoded.flags).toBe(V3FrameFlag.DATA_COMPRESSED)
|
|
13
|
+
expect(encoded.wireBytes < encoded.rawBytes).toBeTruthy()
|
|
14
|
+
expect(Buffer.from(await decodeV3Data(encoded, ZSTD_CODEC)).equals(Buffer.from(raw))).toBe(true)
|
|
15
|
+
await expect(decodeV3Data(encoded, undefined)).rejects.toThrow(/not negotiated/i)
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
})
|
|
17
|
+
const corrupt = {...encoded, payload: Buffer.from(encoded.payload)}
|
|
18
|
+
const last = corrupt.payload.length - 1
|
|
19
|
+
corrupt.payload[last] = /** @type {number} */ (corrupt.payload[last]) ^ 0xff
|
|
20
|
+
await expect(decodeV3Data(corrupt, ZSTD_CODEC)).rejects.toThrow(/decompress/i)
|
|
21
|
+
})
|
|
25
22
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
it("v3 reverse DATA remains raw when adaptive compression cannot save bytes", async () => {
|
|
24
|
+
const raw = Buffer.from("small")
|
|
25
|
+
const encoded = await encodeV3Data(raw, ZSTD_CODEC, new AdaptiveCompressor())
|
|
26
|
+
expect(encoded.flags).toBe(0)
|
|
27
|
+
expect(Buffer.from(await decodeV3Data(encoded, ZSTD_CODEC)).equals(Buffer.from(raw))).toBe(true)
|
|
28
|
+
})
|
|
31
29
|
})
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
import test from "node:test"
|
|
1
|
+
import {describe, expect, it} from "@velocious/testing"
|
|
3
2
|
|
|
4
3
|
import {
|
|
5
4
|
decodeV3Control,
|
|
@@ -10,48 +9,50 @@ import {
|
|
|
10
9
|
V3FrameType
|
|
11
10
|
} from "../../../src/protocol/v3-frame.js"
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
describe("v3 frame protocol", () => {
|
|
13
|
+
it("v3 decoder parses fragmented and coalesced binary frames", () => {
|
|
14
|
+
const first = encodeV3Frame({type: V3FrameType.PING, streamId: 0, sequence: 7})
|
|
15
|
+
const second = encodeV3Frame({type: V3FrameType.DATA, streamId: 1, sequence: 3}, Buffer.from("abc"))
|
|
16
|
+
const wire = Buffer.concat([first, second])
|
|
17
|
+
const decoder = new V3FrameDecoder({maxFrameBytes: 64})
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
expect(decoder.push(wire.subarray(0, 9))).toEqual([])
|
|
20
|
+
const frames = decoder.push(wire.subarray(9))
|
|
21
|
+
const [pingFrame, dataFrame] = frames
|
|
22
|
+
expect(pingFrame?.type).toBe(V3FrameType.PING)
|
|
23
|
+
expect(pingFrame?.flags).toBe(0)
|
|
24
|
+
expect(pingFrame?.streamId).toBe(0)
|
|
25
|
+
expect(pingFrame?.sequence).toBe(7)
|
|
26
|
+
if (!pingFrame) throw new Error("expected a decoded v3 PING frame")
|
|
27
|
+
expect(Buffer.from(pingFrame.payload).toString("hex")).toBe(Buffer.alloc(0).toString("hex"))
|
|
28
|
+
expect(dataFrame?.type).toBe(V3FrameType.DATA)
|
|
29
|
+
expect(dataFrame?.flags).toBe(0)
|
|
30
|
+
expect(dataFrame?.streamId).toBe(1)
|
|
31
|
+
expect(dataFrame?.sequence).toBe(3)
|
|
32
|
+
if (!dataFrame) throw new Error("expected a decoded v3 DATA frame")
|
|
33
|
+
expect(Buffer.from(dataFrame.payload).toString("hex")).toBe(Buffer.from("abc").toString("hex"))
|
|
34
|
+
})
|
|
26
35
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
() => encodeV3Frame({type: V3FrameType.
|
|
30
|
-
|
|
31
|
-
)
|
|
32
|
-
assert.throws(
|
|
33
|
-
() => encodeV3Frame({type: V3FrameType.AUTH, streamId: 1, sequence: 0}, Buffer.from("x")),
|
|
34
|
-
/connection control/i
|
|
35
|
-
)
|
|
36
|
-
assert.throws(() => encodeV3Frame({type: V3FrameType.PING, streamId: 0, sequence: 0, flags: 1}), /flags/i)
|
|
36
|
+
it("v3 framing rejects control/data stream confusion and oversized payloads", () => {
|
|
37
|
+
expect(() => encodeV3Frame({type: V3FrameType.DATA, streamId: 0, sequence: 1}, Buffer.from("x"))).toThrow(/stream ID zero/i)
|
|
38
|
+
expect(() => encodeV3Frame({type: V3FrameType.AUTH, streamId: 1, sequence: 0}, Buffer.from("x"))).toThrow(/connection control/i)
|
|
39
|
+
expect(() => encodeV3Frame({type: V3FrameType.PING, streamId: 0, sequence: 0, flags: 1})).toThrow(/flags/i)
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
/payload length/i
|
|
42
|
-
)
|
|
43
|
-
})
|
|
41
|
+
const decoder = new V3FrameDecoder({maxFrameBytes: 2, maxBufferedBytes: 64})
|
|
42
|
+
expect(() => decoder.push(encodeV3Frame({type: V3FrameType.DATA, streamId: 1, sequence: 3}, Buffer.from("abc")))).toThrow(/payload length/i)
|
|
43
|
+
})
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
})
|
|
45
|
+
it("v3 control payloads are bounded strict JSON objects", () => {
|
|
46
|
+
const encoded = encodeV3Control({token: "secret", versions: [3]}, 128)
|
|
47
|
+
expect(decodeV3Control(encoded, 128)).toEqual({token: "secret", versions: [3]})
|
|
48
|
+
expect(() => encodeV3Control(["not-an-object"], 128)).toThrow(/object/i)
|
|
49
|
+
expect(() => decodeV3Control(Buffer.from("[]"), 128)).toThrow(/object/i)
|
|
50
|
+
expect(() => encodeV3Control({padding: "x".repeat(129)}, 128)).toThrow(/limit/i)
|
|
51
|
+
})
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
it("v3 reserves one negotiated compression flag for DATA only", () => {
|
|
54
|
+
const wire = encodeV3Frame({type: V3FrameType.DATA, streamId: 2, sequence: 10, flags: V3FrameFlag.DATA_COMPRESSED}, Buffer.from("payload"))
|
|
55
|
+
expect(new V3FrameDecoder().push(wire)[0]?.flags).toBe(V3FrameFlag.DATA_COMPRESSED)
|
|
56
|
+
expect(() => encodeV3Frame({type: V3FrameType.GATEWAY_REGISTER, streamId: 0, sequence: 1, flags: V3FrameFlag.DATA_COMPRESSED})).toThrow(/flags/i)
|
|
57
|
+
})
|
|
57
58
|
})
|
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import {describe, expect, it} from "@velocious/testing"
|
|
4
4
|
import {readFile} from "node:fs/promises"
|
|
5
5
|
import {fileURLToPath} from "node:url"
|
|
6
|
-
import test from "node:test"
|
|
7
6
|
|
|
8
7
|
const packageJsonPath = fileURLToPath(new URL("../../package.json", import.meta.url))
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
describe("release configuration", () => {
|
|
10
|
+
it("patch releases use release-patch", async () => {
|
|
11
|
+
const packageJson = /** @type {{devDependencies: Record<string, string>, private?: boolean, scripts: Record<string, string>}} */ (JSON.parse(await readFile(packageJsonPath, "utf8")))
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
expect(packageJson.private).toBe(undefined)
|
|
14
|
+
expect(packageJson.devDependencies["release-patch"]).toBe("^1.0.8")
|
|
15
|
+
const check = packageJson.scripts.check
|
|
16
|
+
const releasePatch = packageJson.scripts["release:patch"]
|
|
17
|
+
expect(check).toBeTruthy()
|
|
18
|
+
expect(releasePatch).toBeTruthy()
|
|
19
|
+
if (check === undefined) throw new Error("Expected a check script")
|
|
20
|
+
if (releasePatch === undefined) throw new Error("Expected a release:patch script")
|
|
21
|
+
expect(check.split(" && ")).toEqual(["npm run lint", "npm run typecheck", "npm test"])
|
|
22
|
+
expect(releasePatch.split(" && ")).toEqual(["npm run check", "release-patch"])
|
|
23
|
+
})
|
|
21
24
|
})
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {describe, expect, it} from "@velocious/testing"
|
|
2
2
|
import {createHmac} from "node:crypto"
|
|
3
|
-
import test from "node:test"
|
|
4
3
|
|
|
5
4
|
import {
|
|
6
5
|
attachDisconnectAttribution,
|
|
@@ -10,73 +9,75 @@ import {
|
|
|
10
9
|
sanitizePeerControlDetail
|
|
11
10
|
} from "../../../src/reverse/disconnect-attribution.js"
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
assert.match(attribution.disconnectIncidentId, /^[A-Za-z0-9_-]{22}$/)
|
|
26
|
-
assert.deepEqual(parseDisconnectAttribution(attribution), attribution)
|
|
27
|
-
assert.equal("generation" in attribution, false)
|
|
12
|
+
describe("disconnect attribution", () => {
|
|
13
|
+
it("disconnect attribution uses one bounded incident and named generations", () => {
|
|
14
|
+
const attribution = createDisconnectAttribution({
|
|
15
|
+
disconnectReason: "remote_socket_error",
|
|
16
|
+
incidentOrigin: "reverse_hub",
|
|
17
|
+
physicalRole: "gateway",
|
|
18
|
+
clientConnectionGeneration: 17,
|
|
19
|
+
gatewayConnectionGeneration: 23,
|
|
20
|
+
fanOutCount: 5,
|
|
21
|
+
resumeSuccessCount: 2,
|
|
22
|
+
terminalStreamCount: 1
|
|
23
|
+
})
|
|
28
24
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
assert.equal(error.disconnectIncidentId, attribution.disconnectIncidentId)
|
|
33
|
-
assert.equal(error.gatewayConnectionGeneration, 23)
|
|
34
|
-
})
|
|
25
|
+
expect(attribution.disconnectIncidentId).toMatch(/^[A-Za-z0-9_-]{22}$/)
|
|
26
|
+
expect(parseDisconnectAttribution(attribution)).toEqual(attribution)
|
|
27
|
+
expect("generation" in attribution).toBe(false)
|
|
35
28
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
disconnectIncidentId
|
|
40
|
-
|
|
41
|
-
incidentOrigin: "reverse_hub",
|
|
42
|
-
physicalRole: "gateway",
|
|
43
|
-
gatewayConnectionGeneration: 9,
|
|
44
|
-
fanOutCount: 4,
|
|
45
|
-
target: "docker.internal:2375",
|
|
46
|
-
token: "secret"
|
|
29
|
+
const error = /** @type {Error & Record<string, unknown>} */ (attachDisconnectAttribution(Object.assign(new Error("classified terminal"), {code: "GATEWAY_DISCONNECTED"}), attribution))
|
|
30
|
+
expect(error.code).toBe("GATEWAY_DISCONNECTED")
|
|
31
|
+
expect(error.disconnectReason).toBe("remote_socket_error")
|
|
32
|
+
expect(error.disconnectIncidentId).toBe(attribution.disconnectIncidentId)
|
|
33
|
+
expect(error.gatewayConnectionGeneration).toBe(23)
|
|
47
34
|
})
|
|
48
35
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
36
|
+
it("peer control detail cannot assert hub-owned disconnect fields", () => {
|
|
37
|
+
const result = sanitizePeerControlDetail({
|
|
38
|
+
code: "TARGET_CLOSED",
|
|
39
|
+
disconnectIncidentId: "AAAAAAAAAAAAAAAAAAAAAA",
|
|
40
|
+
disconnectReason: "hub_shutdown",
|
|
41
|
+
incidentOrigin: "reverse_hub",
|
|
42
|
+
physicalRole: "gateway",
|
|
43
|
+
gatewayConnectionGeneration: 9,
|
|
44
|
+
fanOutCount: 4,
|
|
45
|
+
target: "docker.internal:2375",
|
|
46
|
+
token: "secret"
|
|
47
|
+
})
|
|
52
48
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const expected = createHmac("sha256", key)
|
|
57
|
-
.update("socketduct-connection-id:v1")
|
|
58
|
-
.update(Buffer.from([0]))
|
|
59
|
-
.update("gateway")
|
|
60
|
-
.update(Buffer.from([0]))
|
|
61
|
-
.update(connectionId)
|
|
62
|
-
.digest()
|
|
63
|
-
.subarray(0, 16)
|
|
64
|
-
.toString("hex")
|
|
49
|
+
expect(result.detail).toEqual({code: "TARGET_CLOSED"})
|
|
50
|
+
expect(result.reservedFieldsStripped).toBe(6)
|
|
51
|
+
})
|
|
65
52
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
53
|
+
it("hub connection fingerprints are keyed, role-separated, and truncated to 128 bits", () => {
|
|
54
|
+
const key = Buffer.alloc(32, 0x5a)
|
|
55
|
+
const connectionId = "low_entropy_connection_01"
|
|
56
|
+
const expected = createHmac("sha256", key)
|
|
57
|
+
.update("socketduct-connection-id:v1")
|
|
58
|
+
.update(Buffer.from([0]))
|
|
59
|
+
.update("gateway")
|
|
60
|
+
.update(Buffer.from([0]))
|
|
61
|
+
.update(connectionId)
|
|
62
|
+
.digest()
|
|
63
|
+
.subarray(0, 16)
|
|
64
|
+
.toString("hex")
|
|
65
|
+
|
|
66
|
+
const actual = connectionFingerprint(key, "gateway", connectionId)
|
|
67
|
+
expect(actual).toBe(expected)
|
|
68
|
+
expect(actual).toMatch(/^[a-f0-9]{32}$/)
|
|
69
|
+
expect(actual).not.toBe(connectionId)
|
|
70
|
+
expect(actual).not.toBe(connectionFingerprint(key, "client", connectionId))
|
|
71
|
+
expect(() => connectionFingerprint(Buffer.alloc(31), "gateway", connectionId)).toThrow(/at least 32 bytes/)
|
|
72
|
+
})
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
74
|
+
it("malformed attribution is rejected instead of partially trusted", () => {
|
|
75
|
+
expect(parseDisconnectAttribution({
|
|
76
|
+
disconnectIncidentId: "AAAAAAAAAAAAAAAAAAAAAA",
|
|
77
|
+
disconnectReason: "not-a-reason",
|
|
78
|
+
incidentOrigin: "reverse_hub",
|
|
79
|
+
physicalRole: "gateway",
|
|
80
|
+
gatewayConnectionGeneration: 1
|
|
81
|
+
})).toBeUndefined()
|
|
82
|
+
})
|
|
82
83
|
})
|
|
@@ -1,54 +1,57 @@
|
|
|
1
|
-
import
|
|
2
|
-
import test from "node:test"
|
|
1
|
+
import {describe, expect, it} from "@velocious/testing"
|
|
3
2
|
|
|
4
3
|
import {insertBoundedTerminalOutcome, ownerMayMissTerminalDelivery} from "../../src/reverse/terminal-outcomes.js"
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
5
|
+
describe("reverse terminal outcomes", () => {
|
|
6
|
+
it("a healthy attached physical does not retain a terminal outcome; absent, closing, and pending-resume states do", () => {
|
|
7
|
+
// Healthy attached: the peer receives the terminal frame and retires the
|
|
8
|
+
// stream locally, so it never re-presents it. Retaining here is the
|
|
9
|
+
// unbounded-growth bug: it must be a no-eligibility (no retention).
|
|
10
|
+
expect(ownerMayMissTerminalDelivery({closing: false, resumeRequired: false})).toBe(false)
|
|
11
|
+
// Detached (no physical): the terminal frame cannot be delivered.
|
|
12
|
+
expect(ownerMayMissTerminalDelivery(undefined)).toBe(true)
|
|
13
|
+
// Closing: the frame may not be flushed.
|
|
14
|
+
expect(ownerMayMissTerminalDelivery({closing: true, resumeRequired: false})).toBe(true)
|
|
15
|
+
// Paired-resume / pending-manifest state: the frame is answered through the
|
|
16
|
+
// RESUME manifest instead of a live relay.
|
|
17
|
+
expect(ownerMayMissTerminalDelivery({closing: false, resumeRequired: true})).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("retention is capped at the owner stream bound, evicting the oldest first in deterministic insertion order", () => {
|
|
21
|
+
const outcomes = new Map()
|
|
22
|
+
const limit = 3
|
|
23
|
+
const outcome = (/** @type {string} */ code) => ({code, attribution: undefined})
|
|
24
|
+
|
|
25
|
+
// Within the bound: every outcome is retained, in insertion order.
|
|
26
|
+
insertBoundedTerminalOutcome(outcomes, "s1\0a", outcome("RESUME_STREAM_OMITTED"), limit)
|
|
27
|
+
insertBoundedTerminalOutcome(outcomes, "s2\0b", outcome("BUFFER_LIMIT"), limit)
|
|
28
|
+
expect(outcomes.size).toBe(2)
|
|
29
|
+
expect([...outcomes.keys()]).toEqual(["s1\0a", "s2\0b"])
|
|
30
|
+
|
|
31
|
+
// Exactly at the bound: no eviction yet.
|
|
32
|
+
insertBoundedTerminalOutcome(outcomes, "s3\0c", outcome("TARGET_CLOSED"), limit)
|
|
33
|
+
expect(outcomes.size).toBe(3)
|
|
34
|
+
expect(outcomes.get("s1\0a")?.code).toBe("RESUME_STREAM_OMITTED")
|
|
35
|
+
|
|
36
|
+
// One past the bound: the oldest (s1) is evicted, the newest (s3) is kept.
|
|
37
|
+
insertBoundedTerminalOutcome(outcomes, "s4\0d", outcome("RESUME_SESSION_LOST"), limit)
|
|
38
|
+
expect(outcomes.size).toBe(3)
|
|
39
|
+
// the oldest outcome must be evicted (fail-closed to RESUME_SESSION_LOST)
|
|
40
|
+
expect(outcomes.has("s1\0a")).toBe(false)
|
|
41
|
+
expect([...outcomes.keys()]).toEqual(["s2\0b", "s3\0c", "s4\0d"])
|
|
42
|
+
// the newest causal code must be retained
|
|
43
|
+
expect(outcomes.get("s4\0d")?.code).toBe("RESUME_SESSION_LOST")
|
|
44
|
+
|
|
45
|
+
// Further churn keeps the bound and evicts the next-oldest, deterministically.
|
|
46
|
+
insertBoundedTerminalOutcome(outcomes, "s5\0e", outcome("GATEWAY_MAINTENANCE"), limit)
|
|
47
|
+
expect(outcomes.size).toBe(3)
|
|
48
|
+
expect([...outcomes.keys()]).toEqual(["s3\0c", "s4\0d", "s5\0e"])
|
|
49
|
+
expect(outcomes.has("s2\0b")).toBe(false)
|
|
50
|
+
|
|
51
|
+
// Re-inserting a retained key updates it in place without disturbing the bound.
|
|
52
|
+
insertBoundedTerminalOutcome(outcomes, "s5\0e", outcome("BUFFER_LIMIT"), limit)
|
|
53
|
+
expect(outcomes.size).toBe(3)
|
|
54
|
+
expect(outcomes.get("s5\0e")?.code).toBe("BUFFER_LIMIT")
|
|
55
|
+
expect([...outcomes.keys()]).toEqual(["s3\0c", "s4\0d", "s5\0e"])
|
|
56
|
+
})
|
|
54
57
|
})
|