@botiverse/k-carrier 0.1.0
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/LICENSE +202 -0
- package/NOTICE +24 -0
- package/README.md +62 -0
- package/core/src/artifact/README.md +1 -0
- package/core/src/artifact/collectStream.ts +49 -0
- package/core/src/artifact/download.ts +300 -0
- package/core/src/artifact/errors.ts +24 -0
- package/core/src/artifact/partialPath.ts +18 -0
- package/core/src/artifact/source.ts +59 -0
- package/core/src/artifact/staticManifestSource.ts +185 -0
- package/core/src/artifact/swap.ts +21 -0
- package/core/src/clock.ts +22 -0
- package/core/src/converge/lifecycle.ts +114 -0
- package/core/src/converge/predicates.ts +57 -0
- package/core/src/converge/report.ts +38 -0
- package/core/src/createUpgrader.ts +300 -0
- package/core/src/drive/README.md +1 -0
- package/core/src/index.ts +17 -0
- package/core/src/invariants.ts +243 -0
- package/core/src/lifecycle/hostAdapter.ts +77 -0
- package/core/src/liveness.ts +80 -0
- package/core/src/platform/README.md +1 -0
- package/core/src/platform/index.ts +11 -0
- package/core/src/platform/ops.ts +53 -0
- package/core/src/platform/posix.ts +99 -0
- package/core/src/platform/windows.ts +73 -0
- package/core/src/policy/README.md +1 -0
- package/core/src/progress.ts +54 -0
- package/core/src/provenance/journal.ts +216 -0
- package/core/src/status/report.ts +76 -0
- package/core/src/status/reportStore.ts +67 -0
- package/core/src/txn/effects.ts +40 -0
- package/core/src/txn/engine.ts +275 -0
- package/core/src/txn/fileEffects.ts +129 -0
- package/core/src/txn/hostCallBudget.ts +28 -0
- package/core/src/txn/lock.ts +124 -0
- package/core/src/txn/state.ts +77 -0
- package/core/src/txn/transitions.ts +49 -0
- package/core/src/upgrade/outcome.ts +53 -0
- package/core/src/upgrade/retire.ts +44 -0
- package/core/src/upgrader.ts +124 -0
- package/docs/design-v1.md +246 -0
- package/docs/harness-design.md +170 -0
- package/docs/integration.md +326 -0
- package/docs/prior-art.md +150 -0
- package/docs/test-plan.md +100 -0
- package/harness/README.md +20 -0
- package/harness/src/adapter/probeChecks.ts +93 -0
- package/harness/src/adapter/releaseKnob.ts +65 -0
- package/harness/src/adapter/serviceChecks.ts +260 -0
- package/harness/src/artifact/checks.ts +232 -0
- package/harness/src/artifact/downloadHoles.ts +300 -0
- package/harness/src/artifact/downloadHolesMutations.ts +170 -0
- package/harness/src/artifact/m1.ts +204 -0
- package/harness/src/artifact/m1Resume.ts +141 -0
- package/harness/src/artifact/m3.ts +296 -0
- package/harness/src/artifact/m3Hosts.ts +241 -0
- package/harness/src/artifact/m4.ts +201 -0
- package/harness/src/artifact/m5.ts +239 -0
- package/harness/src/artifact/m6.ts +274 -0
- package/harness/src/artifact/m6Drive.ts +296 -0
- package/harness/src/artifact/m6DriveMutations.ts +70 -0
- package/harness/src/artifact/m6Mutations.ts +136 -0
- package/harness/src/artifact/m6Status.ts +224 -0
- package/harness/src/artifact-factory/checks.ts +68 -0
- package/harness/src/artifact-factory/demo.ts +70 -0
- package/harness/src/artifact-factory/factory.ts +134 -0
- package/harness/src/artifact-factory/run.ts +107 -0
- package/harness/src/blackbox.ts +284 -0
- package/harness/src/cli.ts +281 -0
- package/harness/src/crash/enumerate.ts +66 -0
- package/harness/src/crash/runner.ts +162 -0
- package/harness/src/examples/checks.ts +286 -0
- package/harness/src/fake-host/checks.ts +188 -0
- package/harness/src/fake-host/daemon-entry.ts +39 -0
- package/harness/src/fake-host/daemon.ts +159 -0
- package/harness/src/fake-host/inproc.ts +256 -0
- package/harness/src/fake-server/manifest.ts +68 -0
- package/harness/src/fake-server/server.ts +205 -0
- package/harness/src/fake-server/store.ts +208 -0
- package/harness/src/fixtures/minimal-adapter.ts +108 -0
- package/harness/src/fixtures/service-adapter.ts +187 -0
- package/harness/src/index.ts +11 -0
- package/harness/src/receipt.ts +83 -0
- package/harness/src/runner.ts +268 -0
- package/harness/src/scenario/processScan.ts +55 -0
- package/harness/src/scenario/sandbox.ts +259 -0
- package/harness/src/scenario/virtualClock.ts +56 -0
- package/harness/src/selfverify/selfVerify.ts +136 -0
- package/harness/src/sim/checks.ts +46 -0
- package/harness/src/sim/corpus.ts +36 -0
- package/harness/src/sim/effectRuntime.ts +113 -0
- package/harness/src/sim/error.ts +14 -0
- package/harness/src/sim/prng.ts +29 -0
- package/harness/src/sim/record.ts +76 -0
- package/harness/src/sim/run.ts +148 -0
- package/harness/src/sim/scheduler.ts +72 -0
- package/harness/src/sim/world.ts +284 -0
- package/harness/src/target.ts +86 -0
- package/harness/src/targetCheck.ts +47 -0
- package/harness/src/teeth/artifact.ts +215 -0
- package/harness/src/teeth/artifactDownload.ts +136 -0
- package/harness/src/teeth/artifactFactory.ts +41 -0
- package/harness/src/teeth/artifactM5.ts +68 -0
- package/harness/src/teeth/artifactM6.ts +264 -0
- package/harness/src/teeth/blackbox.ts +22 -0
- package/harness/src/teeth/checks.ts +278 -0
- package/harness/src/teeth/examples.ts +57 -0
- package/harness/src/teeth/fakeHost.ts +120 -0
- package/harness/src/teeth/index.ts +16 -0
- package/harness/src/teeth/m0.ts +135 -0
- package/harness/src/teeth/registry.ts +267 -0
- package/harness/src/teeth/selfCheck.ts +45 -0
- package/harness/src/teeth/sim.ts +61 -0
- package/package.json +43 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L0 artifact acceptance checks — run bodies of the M1 artifact teeth
|
|
3
|
+
* (registered in teeth/artifact.ts). Each throws on violation.
|
|
4
|
+
*
|
|
5
|
+
* The checks consume core's public L0 API (dogfood: the harness is the
|
|
6
|
+
* first consumer) against the fake-server + artifact-factory + real
|
|
7
|
+
* processes:
|
|
8
|
+
* - a tampered artifact is REFUSED (sha256) — real tamper -> real reject;
|
|
9
|
+
* - a kill mid-swap leaves the OLD bytes intact (real process, real
|
|
10
|
+
* SIGKILL — the atomicity contract lives in process reality);
|
|
11
|
+
* - the release source fails closed instead of guessing.
|
|
12
|
+
*/
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { promises as fs } from "node:fs";
|
|
16
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
18
|
+
import { type ToothContext } from "../teeth/registry.ts";
|
|
19
|
+
import { staticManifestSource } from "../../../core/src/artifact/staticManifestSource.ts";
|
|
20
|
+
import type { ReleaseContext, ReleaseSource } from "../../../core/src/artifact/source.ts";
|
|
21
|
+
import { parseManifest, currentPlatformKey } from "../../../core/src/artifact/staticManifestSource.ts";
|
|
22
|
+
import { downloadVerified } from "../../../core/src/artifact/download.ts";
|
|
23
|
+
import { atomicWriteFile } from "../../../core/src/artifact/swap.ts";
|
|
24
|
+
import { FakeServer } from "../fake-server/server.ts";
|
|
25
|
+
import { ArtifactFactory } from "../artifact-factory/factory.ts";
|
|
26
|
+
import { MANIFEST_FILE } from "../fake-server/manifest.ts";
|
|
27
|
+
import { processAlive } from "../fake-host/daemon.ts";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// tampered artifact => refuse install
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export async function checkTamperedArtifactRefused(
|
|
34
|
+
ctx: ToothContext,
|
|
35
|
+
opts: { skipTamper?: boolean } = {},
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
const server = new FakeServer({ storeDir: path.join(ctx.sandboxDir, "store") });
|
|
38
|
+
await server.start();
|
|
39
|
+
const factory = new ArtifactFactory({ cacheDir: path.join(ctx.sandboxDir, "cache") });
|
|
40
|
+
try {
|
|
41
|
+
const platform = currentPlatformKey();
|
|
42
|
+
const rel = await factory.makeRelease({
|
|
43
|
+
version: "1.0.0",
|
|
44
|
+
behavior: "ok",
|
|
45
|
+
store: server.store,
|
|
46
|
+
platform,
|
|
47
|
+
});
|
|
48
|
+
const manifest = parseManifest(
|
|
49
|
+
new TextDecoder().decode(await server.store.readFile(MANIFEST_FILE)),
|
|
50
|
+
);
|
|
51
|
+
const target = manifest.targets[platform];
|
|
52
|
+
assert.ok(target, "release manifest must carry the current platform target");
|
|
53
|
+
if (!opts.skipTamper) {
|
|
54
|
+
await server.corruptByte(rel.artifactFile, 0);
|
|
55
|
+
}
|
|
56
|
+
// core's L0 consumer must refuse the (tampered) artifact
|
|
57
|
+
await assert.rejects(
|
|
58
|
+
downloadVerified({ version: "1.0.0", url: `${server.url.replace(/\/$/u, "")}/${target.file}`, sha256: target.sha256, size: target.size }, { timeoutMs: 5000 }),
|
|
59
|
+
/SHA256_MISMATCH/,
|
|
60
|
+
"a tampered artifact must be refused (skipTamper mutation => RED)",
|
|
61
|
+
);
|
|
62
|
+
} finally {
|
|
63
|
+
await server.stop();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// kill mid-swap => old bytes intact
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
const MID_SWAP_PAYLOAD_BYTES = 64 * 1024 * 1024; // 64MiB: long enough to catch mid-write
|
|
72
|
+
|
|
73
|
+
export async function checkKillMidSwapPreservesOld(
|
|
74
|
+
ctx: ToothContext,
|
|
75
|
+
opts: { skipKill?: boolean } = {},
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
const targetPath = path.join(ctx.sandboxDir, "app.bin");
|
|
78
|
+
const oldBytes = new TextEncoder().encode("old-version-bytes");
|
|
79
|
+
await atomicWriteFile(targetPath, oldBytes);
|
|
80
|
+
|
|
81
|
+
const swapModuleUrl = pathToFileURL(
|
|
82
|
+
path.join(import.meta.dirname, "../../../core/src/artifact/swap.ts"),
|
|
83
|
+
).href;
|
|
84
|
+
const script = [
|
|
85
|
+
`import { atomicWriteFile } from ${JSON.stringify(swapModuleUrl)};`,
|
|
86
|
+
`const fs = await import("node:fs");`,
|
|
87
|
+
`const data = new Uint8Array(${MID_SWAP_PAYLOAD_BYTES});`,
|
|
88
|
+
`data.fill(0x42);`,
|
|
89
|
+
`await atomicWriteFile(process.env.K_SWAP_TARGET, data);`,
|
|
90
|
+
].join("\n");
|
|
91
|
+
const child = spawn(process.execPath, ["--input-type=module", "-e", script], {
|
|
92
|
+
env: { ...process.env, K_SWAP_TARGET: targetPath },
|
|
93
|
+
stdio: "ignore", // no pipes: a failed assertion must not degrade into a hang
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
if (opts.skipKill) {
|
|
98
|
+
// mutation: the kill never lands — the swap completes, so the
|
|
99
|
+
// "old bytes intact" assertion must go RED
|
|
100
|
+
await waitForExit(child);
|
|
101
|
+
assert.ok(
|
|
102
|
+
await equalsFile(targetPath, oldBytes),
|
|
103
|
+
"kill mid-swap must leave the old bytes intact (no kill => RED)",
|
|
104
|
+
);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Wait for the temp file (created BEFORE any data is written).
|
|
109
|
+
await waitForFile(`${targetPath}.tmp`);
|
|
110
|
+
if (process.platform === "win32") {
|
|
111
|
+
// The deterministic mid-swap freeze needs SIGSTOP / a suspend
|
|
112
|
+
// primitive; Windows has no public one without native FFI, so the
|
|
113
|
+
// kill-exactly-before-rename assertions are POSIX-only. The swap
|
|
114
|
+
// completes and recovery below is what Windows verifies.
|
|
115
|
+
await waitForExit(child);
|
|
116
|
+
} else {
|
|
117
|
+
// Freeze the child mid-swap: the rename cannot have happened yet.
|
|
118
|
+
child.kill("SIGSTOP");
|
|
119
|
+
assert.ok(
|
|
120
|
+
await equalsFile(targetPath, oldBytes),
|
|
121
|
+
"the kill must land mid-swap (before the rename)",
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Real SIGKILL: the old bytes must be intact and complete.
|
|
125
|
+
child.kill("SIGKILL");
|
|
126
|
+
assert.ok(
|
|
127
|
+
await equalsFile(targetPath, oldBytes),
|
|
128
|
+
"kill mid-swap must leave the old bytes intact",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Recovery: a subsequent swap succeeds (the leftover .tmp is truncated).
|
|
133
|
+
const recovered = new TextEncoder().encode("recovered");
|
|
134
|
+
await atomicWriteFile(targetPath, recovered);
|
|
135
|
+
assert.deepEqual(new Uint8Array(await fs.readFile(targetPath)), recovered);
|
|
136
|
+
} finally {
|
|
137
|
+
if (child.pid && processAlive(child.pid)) child.kill("SIGKILL");
|
|
138
|
+
await fs.rm(`${targetPath}.tmp`, { force: true }).catch(() => {});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// source boundary => fail-closed
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The source boundary must FAIL CLOSED rather than guess: an unsupported
|
|
149
|
+
* platform, and a named version this source cannot serve, are both refusals.
|
|
150
|
+
* (Version ORDER policy lives in the source, not in K — so this tooth pins
|
|
151
|
+
* refusal behaviour, not a versioning scheme.)
|
|
152
|
+
*/
|
|
153
|
+
export async function checkSourceFailsClosed(
|
|
154
|
+
_ctx: ToothContext,
|
|
155
|
+
makeSource: (served: string) => ReleaseSource = (served) =>
|
|
156
|
+
staticManifestSource({
|
|
157
|
+
baseUrl: "https://cdn.example/app",
|
|
158
|
+
fetchImpl: (async (url: string | URL | Request) => {
|
|
159
|
+
if (String(url).endsWith(".k-sig.json")) {
|
|
160
|
+
return new Response("not found", { status: 404 }); // no signature bundle
|
|
161
|
+
}
|
|
162
|
+
return new Response(
|
|
163
|
+
JSON.stringify({
|
|
164
|
+
version: served,
|
|
165
|
+
targets: { "linux-x64": { file: `app-${served}.bin`, sha256: "a".repeat(64), size: 1 } },
|
|
166
|
+
}),
|
|
167
|
+
{ status: 200 },
|
|
168
|
+
);
|
|
169
|
+
}) as unknown as typeof fetch,
|
|
170
|
+
}),
|
|
171
|
+
): Promise<void> {
|
|
172
|
+
const ctx: ReleaseContext = { currentVersion: "1.0.0", platformKey: "linux-x64" };
|
|
173
|
+
const source = makeSource("1.2.0");
|
|
174
|
+
|
|
175
|
+
// unknown platform => refusal, never a guessed target
|
|
176
|
+
await assert.rejects(
|
|
177
|
+
() => source.checkForUpdate({ ...ctx, platformKey: "sparc-solaris" }),
|
|
178
|
+
/UNSUPPORTED_PLATFORM/u,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// a named version this source does not serve => refusal
|
|
182
|
+
await assert.rejects(() => source.fetchRelease("9.9.9", ctx), /PINNED_VERSION_MISMATCH/u);
|
|
183
|
+
|
|
184
|
+
// and the happy path still resolves completely
|
|
185
|
+
const release = await source.checkForUpdate(ctx);
|
|
186
|
+
assert.ok(release, "a newer served version must produce a release");
|
|
187
|
+
assert.equal(release.version, "1.2.0");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// helpers
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
async function waitForFile(p: string): Promise<void> {
|
|
195
|
+
const deadline = Date.now() + 10000;
|
|
196
|
+
for (;;) {
|
|
197
|
+
try {
|
|
198
|
+
await fs.access(p);
|
|
199
|
+
return;
|
|
200
|
+
} catch {
|
|
201
|
+
// not yet
|
|
202
|
+
}
|
|
203
|
+
if (Date.now() > deadline) throw new Error(`file ${p} never appeared`);
|
|
204
|
+
await new Promise((r) => {
|
|
205
|
+
setTimeout(r, 2);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function equalsFile(p: string, expected: Uint8Array): Promise<boolean> {
|
|
211
|
+
try {
|
|
212
|
+
const got = new Uint8Array(await fs.readFile(p));
|
|
213
|
+
if (got.length !== expected.length) return false;
|
|
214
|
+
for (let i = 0; i < got.length; i++) {
|
|
215
|
+
if (got[i] !== expected[i]) return false;
|
|
216
|
+
}
|
|
217
|
+
return true;
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function waitForExit(child: ChildProcess): Promise<void> {
|
|
224
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
225
|
+
await new Promise<void>((resolve) => {
|
|
226
|
+
child.once("exit", () => resolve());
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// M1 exit: swap-tool end-to-end upgrade + rollback through core's Upgrader
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M1 download-hole acceptance checks — the 8 L0 fixes (486f299..3626b76),
|
|
3
|
+
* one tooth per hole. He wrote the unit tests; these are the RED-able
|
|
4
|
+
* acceptance surfaces: the same assertions as teeth, so the next regression
|
|
5
|
+
* is a red in the gate, not a silent unit-test removal.
|
|
6
|
+
*
|
|
7
|
+
* Holes: ① deadline raced not signalled; ② Rosetta arch lie; ③ progress
|
|
8
|
+
* absent without resumeDir; ④ empty body named as a read failure; ⑤ stall
|
|
9
|
+
* bounds silence (reset on activity); ⑥ in-memory errors classified;
|
|
10
|
+
* ⑦ stall phase named honestly; ⑧ stall timeout exists (⑤'s wedged case).
|
|
11
|
+
*/
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import * as http from "node:http";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { type ToothContext } from "../teeth/registry.ts";
|
|
17
|
+
import { downloadVerified } from "../../../core/src/artifact/download.ts";
|
|
18
|
+
import { platformKeyFor } from "../../../core/src/platform/posix.ts";
|
|
19
|
+
import type { Release } from "../../../core/src/artifact/source.ts";
|
|
20
|
+
import {
|
|
21
|
+
signalOnlyDownload,
|
|
22
|
+
naivePlatformKey,
|
|
23
|
+
probeEverywherePlatformKey,
|
|
24
|
+
arrayBufferDownload,
|
|
25
|
+
emptyPrefixDownload,
|
|
26
|
+
totalTimeoutDownload,
|
|
27
|
+
noStallDownload,
|
|
28
|
+
wrongPhaseDownload,
|
|
29
|
+
} from "./downloadHolesMutations.ts";
|
|
30
|
+
|
|
31
|
+
const BODY = new Uint8Array(4096).fill(7);
|
|
32
|
+
|
|
33
|
+
function releaseFor(url: string): Release {
|
|
34
|
+
return { version: "1.0.0", url, sha256: createHash("sha256").update(BODY).digest("hex"), size: BODY.length };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A server that writes `chunks` pieces, waiting `gapMs` between each;
|
|
38
|
+
* `stallAfterFirst` sends headers + one chunk, then silence. */
|
|
39
|
+
async function serve(chunks: number, gapMs: number, hang = false, stallAfterFirst = false) {
|
|
40
|
+
const server = http.createServer((_req, res) => {
|
|
41
|
+
res.writeHead(200, { "Content-Length": String(BODY.length) });
|
|
42
|
+
if (hang) return; // headers sent, bytes never follow
|
|
43
|
+
const size = Math.ceil(BODY.length / chunks);
|
|
44
|
+
let sent = 0;
|
|
45
|
+
const push = (): void => {
|
|
46
|
+
if (sent >= BODY.length) {
|
|
47
|
+
res.end();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
res.write(Buffer.from(BODY.subarray(sent, sent + size)));
|
|
51
|
+
sent += size;
|
|
52
|
+
if (stallAfterFirst && sent >= size) return; // headers + one chunk, then silence
|
|
53
|
+
setTimeout(push, gapMs);
|
|
54
|
+
};
|
|
55
|
+
push();
|
|
56
|
+
});
|
|
57
|
+
await new Promise<void>((r) => {
|
|
58
|
+
server.listen(0, "127.0.0.1", r);
|
|
59
|
+
});
|
|
60
|
+
const port = (server.address() as { port: number }).port;
|
|
61
|
+
return { url: `http://127.0.0.1:${port}/app.bin`, close: () => server.close() };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A 204 response: ok but with NO readable body. */
|
|
65
|
+
async function serveNoBody() {
|
|
66
|
+
const server = http.createServer((_req, res) => {
|
|
67
|
+
res.writeHead(204);
|
|
68
|
+
res.end();
|
|
69
|
+
});
|
|
70
|
+
await new Promise<void>((r) => {
|
|
71
|
+
server.listen(0, "127.0.0.1", r);
|
|
72
|
+
});
|
|
73
|
+
const port = (server.address() as { port: number }).port;
|
|
74
|
+
return { url: `http://127.0.0.1:${port}/app.bin`, close: () => server.close() };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const neverSettles = (() => new Promise<Response>(() => {})) as unknown as typeof fetch;
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// ① m1.download-timeout-enforced
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
export async function checkDownloadTimeoutEnforced(
|
|
84
|
+
ctx: ToothContext,
|
|
85
|
+
opts: { signalOnly?: boolean } = {},
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
const url = "http://127.0.0.1:1/app.bin"; // nothing listens; the fetch impl decides
|
|
88
|
+
if (opts.signalOnly) {
|
|
89
|
+
// mutation: the deadline is only signalled — a fetch that ignores the
|
|
90
|
+
// signal hangs forever. The bounded race lets the check fail instead of
|
|
91
|
+
// hanging the suite: the bound fires with a DIFFERENT message, so the
|
|
92
|
+
// /timed out after 150ms/ expectation does not match.
|
|
93
|
+
const bound = new Promise<never>((_, reject) => {
|
|
94
|
+
setTimeout(() => reject(new Error("bound")), 500);
|
|
95
|
+
});
|
|
96
|
+
await assert.rejects(
|
|
97
|
+
Promise.race([signalOnlyDownload(releaseFor(url), { timeoutMs: 150, fetchImpl: neverSettles }), bound]),
|
|
98
|
+
/timed out after 150ms/,
|
|
99
|
+
"the deadline must be enforced even when the fetch ignores the signal (signalOnly => RED)",
|
|
100
|
+
);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
await assert.rejects(
|
|
104
|
+
downloadVerified(releaseFor(url), { fetchImpl: neverSettles, timeoutMs: 150, stallTimeoutMs: 0 }),
|
|
105
|
+
/timed out after 150ms/,
|
|
106
|
+
"an uncooperative fetch must not be able to outlive the deadline",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// ② m1.platform-key-native-arch
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
const yes = (): boolean => true;
|
|
115
|
+
const no = (): boolean => false;
|
|
116
|
+
|
|
117
|
+
export async function checkPlatformKeyNativeArch(
|
|
118
|
+
ctx: ToothContext,
|
|
119
|
+
opts: { naiveKey?: boolean; probeEverywhere?: boolean } = {},
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
const key = opts.naiveKey
|
|
122
|
+
? naivePlatformKey
|
|
123
|
+
: opts.probeEverywhere
|
|
124
|
+
? probeEverywherePlatformKey
|
|
125
|
+
: platformKeyFor;
|
|
126
|
+
|
|
127
|
+
assert.equal(key("linux", "x64", no), "linux-x64", "ordinary machines report platform-arch unchanged");
|
|
128
|
+
assert.equal(key("darwin", "arm64", yes), "darwin-arm64", "native arm64 reports arm64");
|
|
129
|
+
assert.equal(
|
|
130
|
+
key("darwin", "x64", yes),
|
|
131
|
+
"darwin-arm64",
|
|
132
|
+
"x64 Node on arm64 hardware must select the arm64 target — Rosetta lies about the machine (naiveKey => RED)",
|
|
133
|
+
);
|
|
134
|
+
assert.equal(
|
|
135
|
+
key("darwin", "x64", no),
|
|
136
|
+
"darwin-x64",
|
|
137
|
+
"a genuine Intel Mac still gets x64 — the hardware probe separates the two",
|
|
138
|
+
);
|
|
139
|
+
const calls: string[] = [];
|
|
140
|
+
const probe = (): boolean => {
|
|
141
|
+
calls.push("called");
|
|
142
|
+
return true;
|
|
143
|
+
};
|
|
144
|
+
key("linux", "x64", probe);
|
|
145
|
+
key("darwin", "arm64", probe);
|
|
146
|
+
key("win32", "x64", probe);
|
|
147
|
+
assert.deepEqual(
|
|
148
|
+
calls,
|
|
149
|
+
[],
|
|
150
|
+
"the hardware probe is consulted ONLY on darwin+x64 — it must not shell out on every platform lookup (probeEverywhere => RED)",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// ③ m1.download-progress-without-resumedir
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
export async function checkDownloadProgressWithoutResumeDir(
|
|
159
|
+
ctx: ToothContext,
|
|
160
|
+
opts: { noProgress?: boolean } = {},
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
const s = await serve(4, 10);
|
|
163
|
+
try {
|
|
164
|
+
const seen: number[] = [];
|
|
165
|
+
const download = opts.noProgress ? arrayBufferDownload : downloadVerified;
|
|
166
|
+
await download(releaseFor(s.url), {
|
|
167
|
+
timeoutMs: 0,
|
|
168
|
+
onProgress: (downloaded) => seen.push(downloaded),
|
|
169
|
+
});
|
|
170
|
+
assert.ok(
|
|
171
|
+
seen.length > 1,
|
|
172
|
+
`expected progress readings with no resumeDir, got ${seen.length} — the bar must move (noProgress => RED)`,
|
|
173
|
+
);
|
|
174
|
+
assert.equal(seen.at(-1), BODY.length, "the last reading must be the full size");
|
|
175
|
+
for (let i = 1; i < seen.length; i += 1) {
|
|
176
|
+
assert.ok(seen[i]! >= seen[i - 1]!, "progress must be monotonic");
|
|
177
|
+
}
|
|
178
|
+
} finally {
|
|
179
|
+
s.close();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// ④ m1.download-empty-body-named
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
export async function checkDownloadEmptyBodyNamed(
|
|
188
|
+
ctx: ToothContext,
|
|
189
|
+
opts: { returnEmptyBytes?: boolean; treatEmptyAsPrefix?: boolean } = {},
|
|
190
|
+
): Promise<void> {
|
|
191
|
+
const s = await serveNoBody();
|
|
192
|
+
try {
|
|
193
|
+
// arm 1: the in-memory path (no resumeDir)
|
|
194
|
+
const download = opts.returnEmptyBytes ? arrayBufferDownload : downloadVerified;
|
|
195
|
+
await assert.rejects(
|
|
196
|
+
download(releaseFor(s.url), { timeoutMs: 0 }),
|
|
197
|
+
/no readable body/,
|
|
198
|
+
"a response with no readable body is a failure to READ, not an empty download — the error must name the cause, never a sha256 mismatch (returnEmptyBytes => RED)",
|
|
199
|
+
);
|
|
200
|
+
// arm 2: the RESUME path must name it too — a no-body response is never
|
|
201
|
+
// an empty prefix to continue from. This arm was added after review
|
|
202
|
+
// proved the first arm does not bite on the resume path.
|
|
203
|
+
const resumeDownload = opts.treatEmptyAsPrefix ? emptyPrefixDownload : downloadVerified;
|
|
204
|
+
await assert.rejects(
|
|
205
|
+
resumeDownload(releaseFor(s.url), { timeoutMs: 0, resumeDir: path.join(ctx.sandboxDir, "resume") }),
|
|
206
|
+
/no readable body/,
|
|
207
|
+
"the resume path must name a no-body response as a read failure, never treat it as an empty prefix (treatEmptyAsPrefix => RED)",
|
|
208
|
+
);
|
|
209
|
+
} finally {
|
|
210
|
+
s.close();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// ⑤ m1.download-stall-bounds-silence (+⑧ the stall timeout exists)
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
export async function checkDownloadStallBoundsSilence(
|
|
219
|
+
ctx: ToothContext,
|
|
220
|
+
opts: { totalTimeout?: boolean; noStall?: boolean } = {},
|
|
221
|
+
): Promise<void> {
|
|
222
|
+
// (a) a download that keeps making progress survives a stall budget
|
|
223
|
+
// shorter than its total time (8 chunks x 40ms ≈ 320ms > 150ms budget).
|
|
224
|
+
const slow = await serve(8, 40);
|
|
225
|
+
try {
|
|
226
|
+
const download = opts.totalTimeout ? totalTimeoutDownload : downloadVerified;
|
|
227
|
+
let completed = false;
|
|
228
|
+
try {
|
|
229
|
+
const bytes = await download(releaseFor(slow.url), { stallTimeoutMs: 150, timeoutMs: 0 });
|
|
230
|
+
completed = bytes.length === BODY.length;
|
|
231
|
+
} catch {
|
|
232
|
+
completed = false;
|
|
233
|
+
}
|
|
234
|
+
assert.ok(
|
|
235
|
+
completed,
|
|
236
|
+
"a slow-but-progressing download must survive a stall budget shorter than its total time — silence is bounded, not total duration (totalTimeout => RED)",
|
|
237
|
+
);
|
|
238
|
+
} finally {
|
|
239
|
+
slow.close();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// (b) a wedged download (headers, then silence) dies on the stall budget
|
|
243
|
+
// and names the stall. The finite total timeout is a backstop so a missing
|
|
244
|
+
// stall timer produces the total-timeout wording instead of a hang.
|
|
245
|
+
const wedged = await serve(1, 0, true);
|
|
246
|
+
try {
|
|
247
|
+
const download = opts.noStall ? noStallDownload : downloadVerified;
|
|
248
|
+
await assert.rejects(
|
|
249
|
+
download(releaseFor(wedged.url), { stallTimeoutMs: 200, timeoutMs: 3_000 }),
|
|
250
|
+
/stalled: nothing received for 200ms/,
|
|
251
|
+
"a wedged download must die on the STALL budget, not the total budget (noStall => RED)",
|
|
252
|
+
);
|
|
253
|
+
} finally {
|
|
254
|
+
wedged.close();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// ⑥ m1.download-errors-classified
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
export async function checkDownloadErrorsClassified(
|
|
263
|
+
ctx: ToothContext,
|
|
264
|
+
opts: { unclassified?: boolean } = {},
|
|
265
|
+
): Promise<void> {
|
|
266
|
+
const wedged = await serve(1, 0, true);
|
|
267
|
+
try {
|
|
268
|
+
// in-memory path (no resumeDir): a stall we caused must come back as a
|
|
269
|
+
// typed DOWNLOAD_FAILED naming the reason, never an anonymous stream error.
|
|
270
|
+
const download = opts.unclassified ? arrayBufferDownload : downloadVerified;
|
|
271
|
+
await assert.rejects(
|
|
272
|
+
download(releaseFor(wedged.url), { stallTimeoutMs: 200, timeoutMs: 3_000 }),
|
|
273
|
+
/stalled|timed out/,
|
|
274
|
+
"a stall the downloader itself caused must be reported as the stall, not as an anonymous transport failure (unclassified => RED)",
|
|
275
|
+
);
|
|
276
|
+
} finally {
|
|
277
|
+
wedged.close();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// ⑦ m1.download-stall-phase-honest
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
export async function checkDownloadStallPhaseHonest(
|
|
286
|
+
ctx: ToothContext,
|
|
287
|
+
opts: { wrongPhase?: boolean } = {},
|
|
288
|
+
): Promise<void> {
|
|
289
|
+
const mid = await serve(2, 0, false, true); // headers + one chunk, then silence
|
|
290
|
+
try {
|
|
291
|
+
const download = opts.wrongPhase ? wrongPhaseDownload : downloadVerified;
|
|
292
|
+
await assert.rejects(
|
|
293
|
+
download(releaseFor(mid.url), { stallTimeoutMs: 200, timeoutMs: 3_000 }),
|
|
294
|
+
/\(mid-body\)/,
|
|
295
|
+
"a stall that happened mid-body must say mid-body, not 'awaiting response' (wrongPhase => RED)",
|
|
296
|
+
);
|
|
297
|
+
} finally {
|
|
298
|
+
mid.close();
|
|
299
|
+
}
|
|
300
|
+
}
|