@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,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M1 download-hole mutation downloads/key-builders — the wrong behaviors
|
|
3
|
+
* each tooth's negative control must catch (the 8 L0 fixes, one tooth
|
|
4
|
+
* per hole). Each stub is the OLD implementation the fix replaced, so a
|
|
5
|
+
* tooth going green on the real core and red under the stub proves the
|
|
6
|
+
* assertion has teeth. Kept separate from downloadHoles.ts for the budget.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { ArtifactError } from "../../../core/src/artifact/errors.ts";
|
|
10
|
+
import type { Release } from "../../../core/src/artifact/source.ts";
|
|
11
|
+
import { type Clock, systemClock } from "../../../core/src/clock.ts";
|
|
12
|
+
|
|
13
|
+
export interface StubDownloadOpts {
|
|
14
|
+
clock?: Clock;
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
stallTimeoutMs?: number;
|
|
17
|
+
fetchImpl?: typeof fetch;
|
|
18
|
+
onProgress?: (downloaded: number, total: number) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Mutation ①: the deadline is only SIGNALLED, never raced — a fetch that
|
|
22
|
+
* ignores AbortSignal outlives every timeout. */
|
|
23
|
+
export async function signalOnlyDownload(release: Release, opts: StubDownloadOpts = {}): Promise<Uint8Array> {
|
|
24
|
+
const clock = opts.clock ?? systemClock;
|
|
25
|
+
const timeoutMs = opts.timeoutMs ?? 10000;
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
const cancel = timeoutMs > 0 ? clock.after(timeoutMs, () => controller.abort()) : undefined;
|
|
28
|
+
try {
|
|
29
|
+
const res = await (opts.fetchImpl ?? fetch)(release.url, { signal: controller.signal });
|
|
30
|
+
if (!res.ok) throw new ArtifactError("DOWNLOAD_FAILED", `GET ${release.url} -> HTTP ${res.status}`);
|
|
31
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
32
|
+
} finally {
|
|
33
|
+
cancel?.();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Mutation ②: naive `${platform}-${arch}` — under Rosetta the process arch
|
|
38
|
+
* lies about the hardware, so the machine is pinned to the wrong target. */
|
|
39
|
+
export function naivePlatformKey(platform: string, arch: string): string {
|
|
40
|
+
return `${platform}-${arch}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Mutation ②: the hardware probe is consulted on EVERY platform lookup,
|
|
44
|
+
* not just darwin+x64. */
|
|
45
|
+
export function probeEverywherePlatformKey(platform: string, arch: string, probe: () => boolean): string {
|
|
46
|
+
return `${platform}-${probe() ? "arm64" : arch}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Mutation ④(resume): a no-body response is treated as an empty prefix —
|
|
50
|
+
* the resume path hides the cause behind the sha256 mismatch. */
|
|
51
|
+
export async function emptyPrefixDownload(release: Release, opts: StubDownloadOpts = {}): Promise<Uint8Array> {
|
|
52
|
+
const res = await (opts.fetchImpl ?? fetch)(release.url);
|
|
53
|
+
if (!res.ok) throw new ArtifactError("DOWNLOAD_FAILED", `GET ${release.url} -> HTTP ${res.status}`);
|
|
54
|
+
if (!res.body) return new Uint8Array(0); // the wrong behavior: an "empty prefix", no error
|
|
55
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Mutation ③④⑥: one opaque `arrayBuffer()` await — no chunk boundaries,
|
|
59
|
+
* so no progress, no stall detection, no error classification. The total
|
|
60
|
+
* timeout is SIGNALLED (the pre-fix behavior): an abort on a wedged server
|
|
61
|
+
* comes back as a bare AbortError, never a classified DOWNLOAD_FAILED. */
|
|
62
|
+
export async function arrayBufferDownload(release: Release, opts: StubDownloadOpts = {}): Promise<Uint8Array> {
|
|
63
|
+
const clock = opts.clock ?? systemClock;
|
|
64
|
+
const timeoutMs = opts.timeoutMs ?? 10000;
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
const cancel = timeoutMs > 0 ? clock.after(timeoutMs, () => controller.abort()) : undefined;
|
|
67
|
+
try {
|
|
68
|
+
const res = await (opts.fetchImpl ?? fetch)(release.url, { signal: controller.signal });
|
|
69
|
+
if (!res.ok) throw new ArtifactError("DOWNLOAD_FAILED", `GET ${release.url} -> HTTP ${res.status}`);
|
|
70
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
71
|
+
} finally {
|
|
72
|
+
cancel?.();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function streamRaw(
|
|
77
|
+
release: Release,
|
|
78
|
+
opts: StubDownloadOpts,
|
|
79
|
+
armOnce: (clock: Clock, fire: () => void) => void,
|
|
80
|
+
): Promise<Uint8Array> {
|
|
81
|
+
const clock = opts.clock ?? systemClock;
|
|
82
|
+
const timeoutMs = opts.timeoutMs ?? 0;
|
|
83
|
+
const controller = new AbortController();
|
|
84
|
+
let fired = false;
|
|
85
|
+
const fire = (): void => {
|
|
86
|
+
fired = true;
|
|
87
|
+
controller.abort();
|
|
88
|
+
};
|
|
89
|
+
if (timeoutMs > 0) clock.after(timeoutMs, fire);
|
|
90
|
+
armOnce(clock, fire);
|
|
91
|
+
const res = await (opts.fetchImpl ?? fetch)(release.url, { signal: controller.signal });
|
|
92
|
+
if (!res.ok) throw new ArtifactError("DOWNLOAD_FAILED", `GET ${release.url} -> HTTP ${res.status}`);
|
|
93
|
+
if (!res.body) throw new ArtifactError("DOWNLOAD_FAILED", `response has no readable body: ${release.url}`);
|
|
94
|
+
const reader = res.body.getReader();
|
|
95
|
+
const parts: Uint8Array[] = [];
|
|
96
|
+
let got = 0;
|
|
97
|
+
try {
|
|
98
|
+
for (;;) {
|
|
99
|
+
const { done, value } = await reader.read();
|
|
100
|
+
if (done) break;
|
|
101
|
+
if (value === undefined) continue;
|
|
102
|
+
parts.push(value);
|
|
103
|
+
got += value.byteLength;
|
|
104
|
+
opts.onProgress?.(got, release.size);
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (fired) throw new ArtifactError("DOWNLOAD_FAILED", `download timed out: ${release.url}`, { cause: err });
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
const all = new Uint8Array(got);
|
|
111
|
+
let at = 0;
|
|
112
|
+
for (const part of parts) {
|
|
113
|
+
all.set(part, at);
|
|
114
|
+
at += part.byteLength;
|
|
115
|
+
}
|
|
116
|
+
return all;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Mutation ⑤a: the stall budget is armed ONCE — a total timeout that kills
|
|
120
|
+
* a slow-but-progressing download. */
|
|
121
|
+
export async function totalTimeoutDownload(release: Release, opts: StubDownloadOpts = {}): Promise<Uint8Array> {
|
|
122
|
+
return streamRaw(release, opts, (clock, fire) => {
|
|
123
|
+
const budget = (opts.stallTimeoutMs ?? 0) > 0 ? opts.stallTimeoutMs! : opts.timeoutMs ?? 0;
|
|
124
|
+
if (budget > 0) clock.after(budget, fire);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Mutation ⑤b: no stall budget at all — a wedged connection holds for the
|
|
129
|
+
* total timeout and the failure never names the stall. */
|
|
130
|
+
export async function noStallDownload(release: Release, opts: StubDownloadOpts = {}): Promise<Uint8Array> {
|
|
131
|
+
return streamRaw(release, opts, () => {});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Mutation ⑦: the stall error always says "awaiting response", even for a
|
|
135
|
+
* stall that happened mid-body — the reader looks at the wrong end. */
|
|
136
|
+
export async function wrongPhaseDownload(release: Release, opts: StubDownloadOpts = {}): Promise<Uint8Array> {
|
|
137
|
+
const clock = opts.clock ?? systemClock;
|
|
138
|
+
const stall = opts.stallTimeoutMs ?? 0;
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
let fired = false;
|
|
141
|
+
const fire = (): void => {
|
|
142
|
+
fired = true;
|
|
143
|
+
controller.abort();
|
|
144
|
+
};
|
|
145
|
+
if (stall > 0) clock.after(stall, fire);
|
|
146
|
+
const res = await (opts.fetchImpl ?? fetch)(release.url, { signal: controller.signal });
|
|
147
|
+
if (!res.ok) throw new ArtifactError("DOWNLOAD_FAILED", `GET ${release.url} -> HTTP ${res.status}`);
|
|
148
|
+
if (!res.body) throw new ArtifactError("DOWNLOAD_FAILED", `response has no readable body: ${release.url}`);
|
|
149
|
+
const reader = res.body.getReader();
|
|
150
|
+
try {
|
|
151
|
+
for (;;) {
|
|
152
|
+
const { done } = await reader.read();
|
|
153
|
+
if (done) break;
|
|
154
|
+
}
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (fired) {
|
|
157
|
+
throw new ArtifactError(
|
|
158
|
+
"DOWNLOAD_FAILED",
|
|
159
|
+
`download stalled: nothing received for ${stall}ms (awaiting response): ${release.url}`,
|
|
160
|
+
{ cause: err },
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
165
|
+
return new Uint8Array(0);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function sha256Hex(data: Uint8Array): string {
|
|
169
|
+
return createHash("sha256").update(data).digest("hex");
|
|
170
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M1 exit checks — the swap-tool demo runs a REAL upgrade through core's
|
|
3
|
+
* Upgrader (createUpgrader facade): end-to-end promotion with state
|
|
4
|
+
* assertions read via upgrader.state() (never internal files), and
|
|
5
|
+
* automatic rollback of a version that fails to start.
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { promises as fs } from "node:fs";
|
|
10
|
+
import { pathToFileURL } from "node:url";
|
|
11
|
+
import { type ToothContext } from "../teeth/registry.ts";
|
|
12
|
+
import type { TxnState } from "../../../core/src/txn/state.ts";
|
|
13
|
+
import { currentPlatformKey } from "../../../core/src/artifact/staticManifestSource.ts";
|
|
14
|
+
import { FakeServer } from "../fake-server/server.ts";
|
|
15
|
+
import { ArtifactFactory } from "../artifact-factory/factory.ts";
|
|
16
|
+
import { runCommand } from "../artifact-factory/run.ts";
|
|
17
|
+
import { CLI_TOOL_SOURCE } from "../../../examples/swap-tool/source.ts";
|
|
18
|
+
|
|
19
|
+
/** The swap-tool demo's @botiverse/k-carrier wiring (createUpgrader module URL). */
|
|
20
|
+
export function coreUpgraderUrl(): string {
|
|
21
|
+
return pathToFileURL(path.join(import.meta.dirname, "../../../core/src/createUpgrader.ts")).href;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function swapToolEnv(ctx: ToothContext, baseUrl: string): Record<string, string> {
|
|
25
|
+
return {
|
|
26
|
+
K_RELEASE_BASE: baseUrl,
|
|
27
|
+
K_STATE_DIR: path.join(ctx.sandboxDir, "state"),
|
|
28
|
+
K_CORE_UPGRADER: coreUpgraderUrl(),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Build the swap-tool demo binary (v1.0.0, ok) into <sandbox>/app. */
|
|
33
|
+
export async function buildSwapTool(ctx: ToothContext): Promise<string> {
|
|
34
|
+
const factory = new ArtifactFactory({
|
|
35
|
+
cacheDir: path.join(ctx.sandboxDir, "cache"),
|
|
36
|
+
demoSource: CLI_TOOL_SOURCE,
|
|
37
|
+
});
|
|
38
|
+
const buildServer = new FakeServer({ storeDir: path.join(ctx.sandboxDir, "build") });
|
|
39
|
+
await buildServer.start();
|
|
40
|
+
try {
|
|
41
|
+
const rel = await factory.makeRelease({
|
|
42
|
+
version: "1.0.0",
|
|
43
|
+
behavior: "ok",
|
|
44
|
+
store: buildServer.store,
|
|
45
|
+
platform: currentPlatformKey(),
|
|
46
|
+
});
|
|
47
|
+
const appDir = path.join(ctx.sandboxDir, "app");
|
|
48
|
+
await fs.mkdir(appDir, { recursive: true });
|
|
49
|
+
const binPath = path.join(appDir, "swap-tool");
|
|
50
|
+
await fs.writeFile(binPath, rel.artifactBytes, { mode: 0o755 });
|
|
51
|
+
await fs.writeFile(
|
|
52
|
+
path.join(appDir, "k.target.ts"),
|
|
53
|
+
`export default { version: ["--version"], selfUpgrade: ["self", "upgrade"] };\n`,
|
|
54
|
+
);
|
|
55
|
+
return binPath;
|
|
56
|
+
} finally {
|
|
57
|
+
await buildServer.stop();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read the transaction state through the PUBLIC API (upgrader.state()). */
|
|
62
|
+
export async function readState(env: Record<string, string>): Promise<TxnState> {
|
|
63
|
+
const coreSrcUrl = new URL(".", env.K_CORE_UPGRADER).href;
|
|
64
|
+
const script = [
|
|
65
|
+
`const { createUpgrader } = await import(${JSON.stringify(env.K_CORE_UPGRADER)});`,
|
|
66
|
+
`const { staticManifestSource } = await import(${JSON.stringify(new URL("artifact/staticManifestSource.ts", coreSrcUrl).href)});`,
|
|
67
|
+
`const u = createUpgrader({`,
|
|
68
|
+
` host: { quiesce: async () => {}, stop: async () => {}, start: async () => {}, healthProbe: async () => ({ version: "x", pid: 0, startId: "x" }), resume: async () => {} },`,
|
|
69
|
+
` source: staticManifestSource({ baseUrl: ${JSON.stringify("http://127.0.0.1:1")} }),`,
|
|
70
|
+
` policy: "auto", notificationSink: async () => {},`,
|
|
71
|
+
` stateDir: ${JSON.stringify(env.K_STATE_DIR)},`,
|
|
72
|
+
`});`,
|
|
73
|
+
`process.stdout.write(JSON.stringify(await u.state()));`,
|
|
74
|
+
].join("\n");
|
|
75
|
+
const r = await runCommand(process.execPath, ["--input-type=module", "-e", script], { timeoutMs: 30000 });
|
|
76
|
+
if (r.code !== 0) throw new Error(`state() read failed: ${r.stderr.trim()}`);
|
|
77
|
+
return JSON.parse(r.stdout) as TxnState;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Serve a release on a fresh fake-server (seed or target). */
|
|
81
|
+
export async function serveRelease(
|
|
82
|
+
ctx: ToothContext,
|
|
83
|
+
opts: {
|
|
84
|
+
version: string;
|
|
85
|
+
behavior: "ok" | "crash-on-start";
|
|
86
|
+
name: string;
|
|
87
|
+
/** The demo source the release is stamped from. Default: swap-tool. */
|
|
88
|
+
source?: string;
|
|
89
|
+
},
|
|
90
|
+
): Promise<FakeServer> {
|
|
91
|
+
const server = new FakeServer({ storeDir: path.join(ctx.sandboxDir, `serve-${opts.name}`) });
|
|
92
|
+
await server.start();
|
|
93
|
+
const factory = new ArtifactFactory({
|
|
94
|
+
cacheDir: path.join(ctx.sandboxDir, `cache-${opts.name}`),
|
|
95
|
+
demoSource: opts.source ?? CLI_TOOL_SOURCE,
|
|
96
|
+
});
|
|
97
|
+
const release = {
|
|
98
|
+
version: opts.version,
|
|
99
|
+
behavior: opts.behavior,
|
|
100
|
+
store: server.store,
|
|
101
|
+
platform: currentPlatformKey(),
|
|
102
|
+
};
|
|
103
|
+
await factory.makeRelease(release);
|
|
104
|
+
return server;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* M1 exit tooth ①: the swap-tool does a REAL upgrade through core's
|
|
109
|
+
* Upgrader — seed stable 1.0.0, serve 2.0.0, self upgrade, next run
|
|
110
|
+
* reports 2.0.0, and upgrader.state() shows the transaction committed
|
|
111
|
+
* (phase promoted, stable 2.0.0, experiment cleared).
|
|
112
|
+
*/
|
|
113
|
+
export async function checkSwapToolUpgradeLoop(
|
|
114
|
+
ctx: ToothContext,
|
|
115
|
+
opts: { serveBadVersion?: boolean } = {},
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
const binPath = await buildSwapTool(ctx);
|
|
118
|
+
const seed = await serveRelease(ctx, { version: "1.0.0", behavior: "ok", name: "seed" });
|
|
119
|
+
const target = await serveRelease(ctx, {
|
|
120
|
+
version: "2.0.0",
|
|
121
|
+
behavior: opts.serveBadVersion ? "crash-on-start" : "ok",
|
|
122
|
+
name: "target",
|
|
123
|
+
});
|
|
124
|
+
try {
|
|
125
|
+
// seed: a real first upgrade lands stable 1.0.0
|
|
126
|
+
const seedEnv = swapToolEnv(ctx, seed.url);
|
|
127
|
+
const seedRun = await runCommand(binPath, ["self", "upgrade"], { env: seedEnv, timeoutMs: 30000 });
|
|
128
|
+
assert.equal(seedRun.code, 0, `seed upgrade must exit 0 (${seedRun.stderr.trim()})`);
|
|
129
|
+
const seeded = await readState(seedEnv);
|
|
130
|
+
assert.equal(seeded.stableVersion, "1.0.0", "seed upgrade must land stable 1.0.0");
|
|
131
|
+
|
|
132
|
+
// the real upgrade to the served version
|
|
133
|
+
const targetEnv = swapToolEnv(ctx, target.url);
|
|
134
|
+
const up = await runCommand(binPath, ["self", "upgrade"], { env: targetEnv, timeoutMs: 30000 });
|
|
135
|
+
assert.equal(
|
|
136
|
+
up.code,
|
|
137
|
+
0,
|
|
138
|
+
`self upgrade must exit 0 (${up.stderr.trim()}; serveBadVersion mutation => RED)`,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const v = await runCommand(binPath, ["--version"], { timeoutMs: 30000 });
|
|
142
|
+
assert.equal(v.code, 0);
|
|
143
|
+
assert.equal(
|
|
144
|
+
v.stdout.trim(),
|
|
145
|
+
"2.0.0",
|
|
146
|
+
"next run must report the served version (serveBadVersion mutation => RED)",
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// transaction committed: read via upgrader.state(), never internal files
|
|
150
|
+
const state = await readState(targetEnv);
|
|
151
|
+
assert.equal(state.stableVersion, "2.0.0", "stable slot must hold the new version");
|
|
152
|
+
assert.equal(state.experimentVersion, null, "experiment slot must be cleared after promote");
|
|
153
|
+
assert.equal(state.phase, "promoted", "journal must end at promoted");
|
|
154
|
+
} finally {
|
|
155
|
+
await seed.stop();
|
|
156
|
+
await target.stop();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* M1 exit tooth ②: a bad version rolls back — serve 2.0.0
|
|
162
|
+
* crash-on-start, self upgrade must end rolled-back, the OLD version stays
|
|
163
|
+
* usable, and the experiment slot is cleared.
|
|
164
|
+
*/
|
|
165
|
+
export async function checkSwapToolRollback(
|
|
166
|
+
ctx: ToothContext,
|
|
167
|
+
opts: { serveGoodVersion?: boolean } = {},
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
const binPath = await buildSwapTool(ctx);
|
|
170
|
+
const seed = await serveRelease(ctx, { version: "1.0.0", behavior: "ok", name: "seed" });
|
|
171
|
+
const target = await serveRelease(ctx, {
|
|
172
|
+
version: "2.0.0",
|
|
173
|
+
behavior: opts.serveGoodVersion ? "ok" : "crash-on-start",
|
|
174
|
+
name: "target",
|
|
175
|
+
});
|
|
176
|
+
try {
|
|
177
|
+
const seedEnv = swapToolEnv(ctx, seed.url);
|
|
178
|
+
const seedRun = await runCommand(binPath, ["self", "upgrade"], { env: seedEnv, timeoutMs: 30000 });
|
|
179
|
+
assert.equal(seedRun.code, 0, `seed upgrade must exit 0 (${seedRun.stderr.trim()})`);
|
|
180
|
+
|
|
181
|
+
const targetEnv = swapToolEnv(ctx, target.url);
|
|
182
|
+
const up = await runCommand(binPath, ["self", "upgrade"], { env: targetEnv, timeoutMs: 30000 });
|
|
183
|
+
|
|
184
|
+
const state = await readState(targetEnv);
|
|
185
|
+
if (opts.serveGoodVersion) {
|
|
186
|
+
// mutation: a GOOD version is served — it promotes, so the rollback
|
|
187
|
+
// expectation must go RED
|
|
188
|
+
assert.equal(state.phase, "rolled-back", "a bad version must roll back (good version => RED)");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
assert.equal(up.code, 1, `upgrade to a bad version must exit non-zero (${up.stderr.trim()})`);
|
|
192
|
+
assert.equal(state.phase, "rolled-back", "journal must end at rolled-back");
|
|
193
|
+
assert.equal(state.stableVersion, "1.0.0", "stable must still hold the old version");
|
|
194
|
+
assert.equal(state.experimentVersion, null, "experiment slot must be cleared");
|
|
195
|
+
|
|
196
|
+
// the old version stays usable
|
|
197
|
+
const v = await runCommand(binPath, ["--version"], { timeoutMs: 30000 });
|
|
198
|
+
assert.equal(v.code, 0);
|
|
199
|
+
assert.equal(v.stdout.trim(), "1.0.0", "the old binary must still run after rollback");
|
|
200
|
+
} finally {
|
|
201
|
+
await seed.stop();
|
|
202
|
+
await target.stop();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m1.download-resumes-after-kill (断点续传): a process that dies
|
|
3
|
+
* mid-download must not restart from zero. The partial prefix survives, the
|
|
4
|
+
* next attempt resumes via a Range request, and the FULL assembled bytes
|
|
5
|
+
* verify. Real kill, real resume, real server.
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { promises as fs } from "node:fs";
|
|
10
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
11
|
+
import { type ToothContext } from "../teeth/registry.ts";
|
|
12
|
+
import { currentPlatformKey, parseManifest } from "../../../core/src/artifact/staticManifestSource.ts";
|
|
13
|
+
import { downloadVerified, partialPathFor } from "../../../core/src/artifact/download.ts";
|
|
14
|
+
import { FakeServer } from "../fake-server/server.ts";
|
|
15
|
+
import { MANIFEST_FILE, sha256Hex } from "../fake-server/manifest.ts";
|
|
16
|
+
import { coreUpgraderUrl } from "./m1.ts";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// m1.download-resumes-after-kill (断点续传)
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A process that dies mid-download must not restart from zero: the partial
|
|
24
|
+
* prefix survives, the next attempt resumes via a Range request, and the
|
|
25
|
+
* FULL assembled bytes verify. Real kill, real resume, real server.
|
|
26
|
+
*/
|
|
27
|
+
export async function checkDownloadResumesAfterKill(
|
|
28
|
+
ctx: ToothContext,
|
|
29
|
+
opts: { skipResume?: boolean } = {},
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
const platform = currentPlatformKey();
|
|
32
|
+
const server = new FakeServer({
|
|
33
|
+
storeDir: path.join(ctx.sandboxDir, "store"),
|
|
34
|
+
// slow enough that a kill lands mid-download deterministically
|
|
35
|
+
throttle: { bytesPerTick: 4096, tickMs: 5 },
|
|
36
|
+
});
|
|
37
|
+
await server.start();
|
|
38
|
+
try {
|
|
39
|
+
// a real-ish sized artifact (2MB) so the kill window is comfortable;
|
|
40
|
+
// published via the store directly (the factory stamps the tiny demo)
|
|
41
|
+
const big = new Uint8Array(2 * 1024 * 1024);
|
|
42
|
+
for (let i = 0; i < big.length; i++) big[i] = i % 251;
|
|
43
|
+
await server.store.publish({
|
|
44
|
+
version: "1.0.0",
|
|
45
|
+
artifacts: { "big-1.0.0.bin": big },
|
|
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);
|
|
53
|
+
const url = `${server.url}/${target.file}`;
|
|
54
|
+
const release = {
|
|
55
|
+
version: "1.0.0",
|
|
56
|
+
url,
|
|
57
|
+
sha256: target.sha256,
|
|
58
|
+
size: target.size,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const resumeDir = path.join(ctx.sandboxDir, "incoming");
|
|
62
|
+
const coreUrl = coreUpgraderUrl();
|
|
63
|
+
const coreSrc = new URL(".", coreUrl).href;
|
|
64
|
+
const dlScript = [
|
|
65
|
+
`const { downloadVerified, partialPathFor } = await import(${JSON.stringify(new URL("artifact/download.ts", coreSrc).href)});`,
|
|
66
|
+
`const { promises: fs } = await import("node:fs");`,
|
|
67
|
+
`const url = ${JSON.stringify(url)};`,
|
|
68
|
+
`const dir = ${JSON.stringify(resumeDir)};`,
|
|
69
|
+
`const release = ${JSON.stringify(release)};`,
|
|
70
|
+
`const bytes = await downloadVerified(release, { resumeDir: dir, timeoutMs: 60000 });`,
|
|
71
|
+
`await fs.writeFile(process.env.K_DL_OUT, bytes);`,
|
|
72
|
+
`process.exit(0);`,
|
|
73
|
+
].join("\n");
|
|
74
|
+
|
|
75
|
+
// First downloader: kill it mid-download (after the partial has bytes).
|
|
76
|
+
const child = spawn(process.execPath, ["--input-type=module", "-e", dlScript], {
|
|
77
|
+
env: { ...process.env, K_DL_OUT: path.join(ctx.sandboxDir, "first.bin") },
|
|
78
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
79
|
+
});
|
|
80
|
+
let childErr = "";
|
|
81
|
+
child.stderr.on("data", (d: Buffer) => {
|
|
82
|
+
childErr += String(d);
|
|
83
|
+
});
|
|
84
|
+
const partialPath = partialPathFor(resumeDir, url);
|
|
85
|
+
await waitForFileGrow(partialPath, 1024 * 1024, 20000).catch((err) => {
|
|
86
|
+
throw new Error(`${(err as Error).message} (child stderr: ${childErr.trim()})`);
|
|
87
|
+
});
|
|
88
|
+
child.kill("SIGKILL");
|
|
89
|
+
await waitForExit(child);
|
|
90
|
+
const partialSize = (await fs.stat(partialPath)).size;
|
|
91
|
+
assert.ok(partialSize > 0 && partialSize < release.size, "the kill must land mid-download");
|
|
92
|
+
|
|
93
|
+
if (opts.skipResume) {
|
|
94
|
+
// mutation: a FRESH resumeDir — no partial, so no Range request; the
|
|
95
|
+
// resume assertion must go RED
|
|
96
|
+
const freshDir = path.join(ctx.sandboxDir, "incoming-fresh");
|
|
97
|
+
const bytes = await downloadVerified(release, { resumeDir: freshDir, timeoutMs: 60000 });
|
|
98
|
+
assert.equal(bytes.length, release.size);
|
|
99
|
+
assert.ok(
|
|
100
|
+
server.requestLog.some((r) => r.url === target.file && r.range !== null),
|
|
101
|
+
"the resume must issue a Range request (skipResume mutation => RED)",
|
|
102
|
+
);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Second downloader (in-process): resumes from the partial via Range.
|
|
107
|
+
const before = server.requestLog.length;
|
|
108
|
+
const bytes = await downloadVerified(release, { resumeDir, timeoutMs: 60000 });
|
|
109
|
+
assert.equal(bytes.length, release.size, "the resumed download must assemble the FULL bytes");
|
|
110
|
+
assert.equal(sha256Hex(bytes), release.sha256, "the assembled bytes must verify");
|
|
111
|
+
const rangeReq = server.requestLog.slice(before).find((r) => r.url === target.file && r.range !== null);
|
|
112
|
+
assert.ok(rangeReq, "the resume must issue a Range request");
|
|
113
|
+
assert.equal(rangeReq!.range, `bytes=${partialSize}-`, "the Range must start at the partial size");
|
|
114
|
+
} finally {
|
|
115
|
+
await server.stop();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function waitForFileGrow(p: string, minBytes: number, timeoutMs: number): Promise<void> {
|
|
120
|
+
const deadline = Date.now() + timeoutMs;
|
|
121
|
+
for (;;) {
|
|
122
|
+
try {
|
|
123
|
+
const size = (await fs.stat(p)).size;
|
|
124
|
+
if (size >= minBytes) return;
|
|
125
|
+
} catch {
|
|
126
|
+
// not yet
|
|
127
|
+
}
|
|
128
|
+
if (Date.now() > deadline) throw new Error(`file ${p} never grew to ${minBytes} bytes`);
|
|
129
|
+
await new Promise((r) => {
|
|
130
|
+
setTimeout(r, 10);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function waitForExit(child: ChildProcess): Promise<void> {
|
|
136
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
137
|
+
await new Promise<void>((resolve) => {
|
|
138
|
+
child.once("exit", () => resolve());
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|