@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,296 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { type ToothContext } from "../teeth/registry.ts";
|
|
4
|
+
import { PLAIN_DAEMON_SOURCE } from "../../../examples/service-daemon/source.ts";
|
|
5
|
+
import { serveRelease } from "./m1.ts";
|
|
6
|
+
import { runCommand } from "../artifact-factory/run.ts";
|
|
7
|
+
import { processAlive } from "../fake-host/daemon.ts";
|
|
8
|
+
import {
|
|
9
|
+
buildServiceDaemon,
|
|
10
|
+
serviceEnv,
|
|
11
|
+
readIncarnation,
|
|
12
|
+
killIncarnation,
|
|
13
|
+
respawnUntilUp,
|
|
14
|
+
seedService,
|
|
15
|
+
waitDead,
|
|
16
|
+
HOST_SHAPES,
|
|
17
|
+
type HostShape,
|
|
18
|
+
} from "./m3Hosts.ts";
|
|
19
|
+
import { readState } from "./m1.ts";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// m3.service-upgrade (both host shapes)
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export async function checkM3ServiceUpgrade(
|
|
26
|
+
ctx: ToothContext,
|
|
27
|
+
opts: { serveBadVersion?: boolean } = {},
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
for (const shape of HOST_SHAPES) {
|
|
30
|
+
await runUpgradeShape(ctx, shape, opts.serveBadVersion === true);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function runUpgradeShape(
|
|
35
|
+
ctx: ToothContext,
|
|
36
|
+
shape: HostShape,
|
|
37
|
+
serveBadVersion: boolean,
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
const shapeCtx: ToothContext = { profile: ctx.profile, sandboxDir: path.join(ctx.sandboxDir, shape) };
|
|
40
|
+
const binPath = await buildServiceDaemon(shapeCtx);
|
|
41
|
+
const seed = await serveRelease(shapeCtx, {
|
|
42
|
+
version: "1.0.0",
|
|
43
|
+
behavior: "ok",
|
|
44
|
+
name: "seed",
|
|
45
|
+
source: PLAIN_DAEMON_SOURCE,
|
|
46
|
+
});
|
|
47
|
+
const target = await serveRelease(shapeCtx, {
|
|
48
|
+
version: "2.0.0",
|
|
49
|
+
behavior: serveBadVersion ? "crash-on-start" : "ok",
|
|
50
|
+
name: "target",
|
|
51
|
+
source: PLAIN_DAEMON_SOURCE,
|
|
52
|
+
});
|
|
53
|
+
const { seedChild, seedPid } = await seedService(shapeCtx, shape, binPath, seed);
|
|
54
|
+
const env = serviceEnv(shapeCtx, shape, target.url);
|
|
55
|
+
let respawned: Awaited<ReturnType<typeof respawnUntilUp>> | null = null;
|
|
56
|
+
try {
|
|
57
|
+
const before = await readIncarnation(shapeCtx);
|
|
58
|
+
assert.ok(before, "a running incarnation must exist before the upgrade");
|
|
59
|
+
const oldPid = before!.pid;
|
|
60
|
+
const oldStartId = before!.startId;
|
|
61
|
+
assert.ok(processAlive(oldPid), "the pre-upgrade service must be alive");
|
|
62
|
+
|
|
63
|
+
const up = await runCommand(binPath, ["self", "upgrade"], { env, timeoutMs: 30000 });
|
|
64
|
+
assert.equal(
|
|
65
|
+
up.code,
|
|
66
|
+
0,
|
|
67
|
+
`self upgrade must exit 0 (${up.stderr.trim()}; serveBadVersion mutation => RED)`,
|
|
68
|
+
);
|
|
69
|
+
if (shape === "respawn") {
|
|
70
|
+
// Tracked, not discarded: if an assertion below fails, an untracked
|
|
71
|
+
// live service keeps the test runner alive and a red degrades into a
|
|
72
|
+
// hang -- the failure mode is then indistinguishable from a wedge.
|
|
73
|
+
respawned = await respawnUntilUp(shapeCtx, env);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// signal-sent ≠ dead: the OLD pid must be VERIFIED gone.
|
|
77
|
+
assert.ok(!processAlive(oldPid), `[${shape}] the old incarnation must be verified dead`);
|
|
78
|
+
|
|
79
|
+
// the new incarnation runs the new version, with a FRESH startId.
|
|
80
|
+
const after = await readIncarnation(shapeCtx);
|
|
81
|
+
assert.ok(after, `[${shape}] a new service must be running after the upgrade`);
|
|
82
|
+
assert.equal(after!.version, "2.0.0", `[${shape}] the running service must be the new version`);
|
|
83
|
+
assert.ok(processAlive(after!.pid), `[${shape}] the new service must be alive`);
|
|
84
|
+
assert.notEqual(
|
|
85
|
+
after!.startId,
|
|
86
|
+
oldStartId,
|
|
87
|
+
`[${shape}] the new incarnation must have a fresh startId (not the old one)`,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const state = await readState(env);
|
|
91
|
+
assert.equal(state.stableVersion, "2.0.0", `[${shape}] stable slot must hold the new version`);
|
|
92
|
+
assert.equal(state.phase, "promoted", `[${shape}] journal must end at promoted`);
|
|
93
|
+
} finally {
|
|
94
|
+
if (respawned && respawned.child.pid && processAlive(respawned.child.pid)) {
|
|
95
|
+
respawned.child.kill("SIGKILL");
|
|
96
|
+
}
|
|
97
|
+
await killIncarnation(shapeCtx);
|
|
98
|
+
if (processAlive(seedPid)) seedChild.kill("SIGKILL");
|
|
99
|
+
await seed.stop();
|
|
100
|
+
await target.stop();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// m3.service-rollback (both host shapes)
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
export async function checkM3ServiceRollback(
|
|
109
|
+
ctx: ToothContext,
|
|
110
|
+
opts: { serveGoodVersion?: boolean } = {},
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
for (const shape of HOST_SHAPES) {
|
|
113
|
+
await runRollbackShape(ctx, shape, opts.serveGoodVersion === true);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function runRollbackShape(
|
|
118
|
+
ctx: ToothContext,
|
|
119
|
+
shape: HostShape,
|
|
120
|
+
serveGoodVersion: boolean,
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
const shapeCtx: ToothContext = { profile: ctx.profile, sandboxDir: path.join(ctx.sandboxDir, shape) };
|
|
123
|
+
const binPath = await buildServiceDaemon(shapeCtx);
|
|
124
|
+
const seed = await serveRelease(shapeCtx, {
|
|
125
|
+
version: "1.0.0",
|
|
126
|
+
behavior: "ok",
|
|
127
|
+
name: "seed",
|
|
128
|
+
source: PLAIN_DAEMON_SOURCE,
|
|
129
|
+
});
|
|
130
|
+
const target = await serveRelease(shapeCtx, {
|
|
131
|
+
version: "2.0.0",
|
|
132
|
+
behavior: serveGoodVersion ? "ok" : "crash-on-start",
|
|
133
|
+
name: "target",
|
|
134
|
+
source: PLAIN_DAEMON_SOURCE,
|
|
135
|
+
});
|
|
136
|
+
const { seedChild, seedPid } = await seedService(shapeCtx, shape, binPath, seed);
|
|
137
|
+
const env = serviceEnv(shapeCtx, shape, target.url);
|
|
138
|
+
let respawned: Awaited<ReturnType<typeof respawnUntilUp>> | null = null;
|
|
139
|
+
try {
|
|
140
|
+
const before = await readIncarnation(shapeCtx);
|
|
141
|
+
assert.ok(before, "a running incarnation must exist before the upgrade");
|
|
142
|
+
const oldPid = before!.pid;
|
|
143
|
+
|
|
144
|
+
const up = await runCommand(binPath, ["self", "upgrade"], { env, timeoutMs: 30000 });
|
|
145
|
+
if (shape === "respawn") {
|
|
146
|
+
// Tracked, not discarded: if an assertion below fails, an untracked
|
|
147
|
+
// live service keeps the test runner alive and a red degrades into a
|
|
148
|
+
// hang -- the failure mode is then indistinguishable from a wedge.
|
|
149
|
+
respawned = await respawnUntilUp(shapeCtx, env);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const state = await readState(env);
|
|
153
|
+
if (serveGoodVersion) {
|
|
154
|
+
// mutation: a GOOD version is served — it promotes, so the rollback
|
|
155
|
+
// expectation must go RED
|
|
156
|
+
assert.equal(
|
|
157
|
+
state.phase,
|
|
158
|
+
"rolled-back",
|
|
159
|
+
`[${shape}] a bad version must roll back (good => RED)`,
|
|
160
|
+
);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (shape === "spawn") {
|
|
164
|
+
// the spawn-shape driver survives and returns the outcome itself
|
|
165
|
+
assert.equal(
|
|
166
|
+
up.code,
|
|
167
|
+
1,
|
|
168
|
+
`[${shape}] upgrade to a bad version must exit non-zero (${up.stderr.trim()})`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
// (the respawn-shape driver exits 0 to request the successor; the
|
|
172
|
+
// outcome is decided by the successor's recovery below)
|
|
173
|
+
assert.equal(state.phase, "rolled-back", `[${shape}] journal must end at rolled-back`);
|
|
174
|
+
assert.equal(state.stableVersion, "1.0.0", `[${shape}] stable slot must hold the old version`);
|
|
175
|
+
assert.equal(state.experimentVersion, null, `[${shape}] experiment slot must be cleared`);
|
|
176
|
+
|
|
177
|
+
// The old version must be PULLED BACK AND ACTUALLY RUNNING — verified
|
|
178
|
+
// alive, not just the slots reverted.
|
|
179
|
+
const running = await readIncarnation(shapeCtx);
|
|
180
|
+
assert.ok(running, `[${shape}] a service must be running after the rollback`);
|
|
181
|
+
assert.equal(running!.version, "1.0.0", `[${shape}] the pulled-back service must be the old version`);
|
|
182
|
+
assert.ok(processAlive(running!.pid), `[${shape}] the pulled-back service must be ACTUALLY running`);
|
|
183
|
+
assert.notEqual(
|
|
184
|
+
running!.startId,
|
|
185
|
+
before!.startId,
|
|
186
|
+
`[${shape}] the pulled-back service is a fresh incarnation (new startId)`,
|
|
187
|
+
);
|
|
188
|
+
assert.ok(!processAlive(oldPid), `[${shape}] the pre-upgrade incarnation must stay dead`);
|
|
189
|
+
} finally {
|
|
190
|
+
if (respawned && respawned.child.pid && processAlive(respawned.child.pid)) {
|
|
191
|
+
respawned.child.kill("SIGKILL");
|
|
192
|
+
}
|
|
193
|
+
await killIncarnation(shapeCtx);
|
|
194
|
+
if (processAlive(seedPid)) seedChild.kill("SIGKILL");
|
|
195
|
+
await seed.stop();
|
|
196
|
+
await target.stop();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// m3.stuck-driver-evidence-recovery (respawn shape)
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A wedged driver (host.stop() never returns) must not wedge the updater:
|
|
206
|
+
* the host-call budget times the driver out, the upgrade lock is released,
|
|
207
|
+
* the owner respawns the successor, and the successor's recovery decides by
|
|
208
|
+
* EVIDENCE (a live v2 with a fresh startId) — never by a "this restart was
|
|
209
|
+
* planned" flag. `skipRecovery` simulates the must-red mutation (the
|
|
210
|
+
* successor never recovers, so the transaction stays pending).
|
|
211
|
+
*/
|
|
212
|
+
export async function checkM3StuckDriverEvidence(
|
|
213
|
+
ctx: ToothContext,
|
|
214
|
+
opts: { skipRecovery?: boolean } = {},
|
|
215
|
+
): Promise<void> {
|
|
216
|
+
const shape: HostShape = "respawn";
|
|
217
|
+
const shapeCtx: ToothContext = { profile: ctx.profile, sandboxDir: path.join(ctx.sandboxDir, shape) };
|
|
218
|
+
const binPath = await buildServiceDaemon(shapeCtx);
|
|
219
|
+
const seed = await serveRelease(shapeCtx, {
|
|
220
|
+
version: "1.0.0",
|
|
221
|
+
behavior: "ok",
|
|
222
|
+
name: "seed",
|
|
223
|
+
source: PLAIN_DAEMON_SOURCE,
|
|
224
|
+
});
|
|
225
|
+
const target = await serveRelease(shapeCtx, {
|
|
226
|
+
version: "2.0.0",
|
|
227
|
+
behavior: "ok",
|
|
228
|
+
name: "target",
|
|
229
|
+
source: PLAIN_DAEMON_SOURCE,
|
|
230
|
+
});
|
|
231
|
+
const { seedChild, seedPid } = await seedService(shapeCtx, shape, binPath, seed);
|
|
232
|
+
const env = serviceEnv(shapeCtx, shape, target.url);
|
|
233
|
+
let oldPid: number | null = null;
|
|
234
|
+
try {
|
|
235
|
+
const before = await readIncarnation(shapeCtx);
|
|
236
|
+
assert.ok(before, "a running incarnation must exist before the upgrade");
|
|
237
|
+
oldPid = before!.pid;
|
|
238
|
+
|
|
239
|
+
// The driver is wedged at stop(): it must time out (host-call budget),
|
|
240
|
+
// release the lock, and exit — never wedge the tooth.
|
|
241
|
+
const stuckEnv = { ...env, K_STUCK_DRIVER: "1" };
|
|
242
|
+
const up = await runCommand(binPath, ["self", "upgrade"], {
|
|
243
|
+
env: stuckEnv,
|
|
244
|
+
timeoutMs: 30000,
|
|
245
|
+
});
|
|
246
|
+
assert.notEqual(up.code, 0, "the wedged driver must fail (budget timeout), not hang");
|
|
247
|
+
|
|
248
|
+
// The owner respawns the successor from the new bytes; its recovery
|
|
249
|
+
// acquires the (released) lock and decides by evidence.
|
|
250
|
+
const respawnEnv = { ...env, ...(opts.skipRecovery === true ? { K_SKIP_RECOVERY: "1" } : {}) };
|
|
251
|
+
const { info: successor, child } = await respawnUntilUp(shapeCtx, respawnEnv);
|
|
252
|
+
try {
|
|
253
|
+
if (opts.skipRecovery) {
|
|
254
|
+
// mutation: the successor never recovers — the transaction stays
|
|
255
|
+
// pending at handing-over, so the promoted assertion goes RED
|
|
256
|
+
const st = await readState(env);
|
|
257
|
+
assert.equal(st.phase, "promoted", "the successor's recovery must finish the transaction (skipRecovery => RED)");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ② the successor is up (respawned from the experiment bytes)
|
|
262
|
+
assert.equal(successor.version, "2.0.0", "the successor must run the new version");
|
|
263
|
+
assert.ok(processAlive(successor.pid), "the successor must be alive");
|
|
264
|
+
assert.notEqual(successor.startId, before!.startId, "the successor is a fresh incarnation");
|
|
265
|
+
|
|
266
|
+
// ③ the recovery's judgment matched the evidence (v2 + fresh startId):
|
|
267
|
+
// the transaction reached promoted, so the lock WAS released (the
|
|
268
|
+
// recovery acquired it) and the evidence said handover succeeded.
|
|
269
|
+
const state = await readState(env);
|
|
270
|
+
assert.equal(state.phase, "promoted", "recovery must finish at promoted (evidence, not a flag)");
|
|
271
|
+
assert.equal(state.stableVersion, "2.0.0", "stable must hold the new version");
|
|
272
|
+
} finally {
|
|
273
|
+
if (processAlive(successor.pid)) {
|
|
274
|
+
try {
|
|
275
|
+
process.kill(successor.pid, "SIGKILL");
|
|
276
|
+
} catch {
|
|
277
|
+
// already gone
|
|
278
|
+
}
|
|
279
|
+
await waitDead(successor.pid, 5000);
|
|
280
|
+
}
|
|
281
|
+
void child;
|
|
282
|
+
}
|
|
283
|
+
} finally {
|
|
284
|
+
await killIncarnation(shapeCtx);
|
|
285
|
+
if (oldPid !== null && processAlive(oldPid)) {
|
|
286
|
+
try {
|
|
287
|
+
process.kill(oldPid, "SIGKILL"); // the wedged stop never killed it
|
|
288
|
+
} catch {
|
|
289
|
+
// already gone
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (processAlive(seedPid)) seedChild.kill("SIGKILL");
|
|
293
|
+
await seed.stop();
|
|
294
|
+
await target.stop();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { sandboxMarkerFor } from "../scenario/sandbox.ts";
|
|
4
|
+
import { promises as fs } from "node:fs";
|
|
5
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
6
|
+
import { type ToothContext } from "../teeth/registry.ts";
|
|
7
|
+
import { currentPlatformKey } from "../../../core/src/artifact/staticManifestSource.ts";
|
|
8
|
+
import { FakeServer } from "../fake-server/server.ts";
|
|
9
|
+
import { ArtifactFactory } from "../artifact-factory/factory.ts";
|
|
10
|
+
import { processAlive } from "../fake-host/daemon.ts";
|
|
11
|
+
import { commandForArtifact, runCommand } from "../artifact-factory/run.ts";
|
|
12
|
+
import { PLAIN_DAEMON_SOURCE } from "../../../examples/service-daemon/source.ts";
|
|
13
|
+
import { coreUpgraderUrl, readState } from "./m1.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* M3 host orchestration helpers — shared by both service-profile teeth
|
|
17
|
+
* (m3.service-upgrade / m3.service-rollback), each of which runs on BOTH
|
|
18
|
+
* host shapes:
|
|
19
|
+
* - spawn: self-starting — the driver spawns the successor and finishes
|
|
20
|
+
* the transaction itself.
|
|
21
|
+
* - respawn: computer's shape — the driver DIES mid-handover (it cannot
|
|
22
|
+
* start itself; the owner respawns it from the new bytes), and
|
|
23
|
+
* the SUCCESSOR finishes the transaction via recovery, proving
|
|
24
|
+
* the handover by a fresh startId (a flag could be set by the
|
|
25
|
+
* crash path; a different incarnation is something that
|
|
26
|
+
* happened).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export type HostShape = "spawn" | "respawn";
|
|
30
|
+
export const HOST_SHAPES: HostShape[] = ["spawn", "respawn"];
|
|
31
|
+
|
|
32
|
+
const INCARNATION_FILE = "incarnation.json";
|
|
33
|
+
|
|
34
|
+
/** Build the service-daemon demo binary (v1.0.0, ok) into <sandbox>/app. */
|
|
35
|
+
export async function buildServiceDaemon(ctx: ToothContext): Promise<string> {
|
|
36
|
+
const factory = new ArtifactFactory({
|
|
37
|
+
cacheDir: path.join(ctx.sandboxDir, "cache"),
|
|
38
|
+
demoSource: PLAIN_DAEMON_SOURCE,
|
|
39
|
+
});
|
|
40
|
+
const buildServer = new FakeServer({ storeDir: path.join(ctx.sandboxDir, "build") });
|
|
41
|
+
await buildServer.start();
|
|
42
|
+
try {
|
|
43
|
+
const rel = await factory.makeRelease({
|
|
44
|
+
version: "1.0.0",
|
|
45
|
+
behavior: "ok",
|
|
46
|
+
store: buildServer.store,
|
|
47
|
+
platform: currentPlatformKey(),
|
|
48
|
+
});
|
|
49
|
+
const appDir = path.join(ctx.sandboxDir, "app");
|
|
50
|
+
await fs.mkdir(appDir, { recursive: true });
|
|
51
|
+
const binPath = path.join(appDir, "service-daemon");
|
|
52
|
+
await fs.writeFile(binPath, rel.artifactBytes, { mode: 0o755 });
|
|
53
|
+
return binPath;
|
|
54
|
+
} finally {
|
|
55
|
+
await buildServer.stop();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function stateDir(ctx: ToothContext): string {
|
|
60
|
+
return path.join(ctx.sandboxDir, "state");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function serviceEnv(
|
|
64
|
+
ctx: ToothContext,
|
|
65
|
+
shape: HostShape,
|
|
66
|
+
baseUrl: string,
|
|
67
|
+
): Record<string, string> {
|
|
68
|
+
return {
|
|
69
|
+
K_RELEASE_BASE: baseUrl,
|
|
70
|
+
K_STATE_DIR: stateDir(ctx),
|
|
71
|
+
K_HOST_SHAPE: shape,
|
|
72
|
+
K_CORE_UPGRADER: coreUpgraderUrl(),
|
|
73
|
+
// Every process this tooth spawns — and, via inheritance, every process
|
|
74
|
+
// the driver spawns — carries the sandbox marker, so the sandbox
|
|
75
|
+
// teardown's verifyProcessTreeDead is the backstop for any leak: a "red"
|
|
76
|
+
// can never degrade into a hang.
|
|
77
|
+
// Resolved back to the SANDBOX id: ctx here is a per-shape NESTED context,
|
|
78
|
+
// and basename() of that is "spawn"/"respawn" -- a marker no teardown scan
|
|
79
|
+
// matches, which turns the backstop into a scan that always finds zero.
|
|
80
|
+
K_SANDBOX_MARKER: sandboxMarkerFor(ctx.sandboxDir),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface Incarnation {
|
|
85
|
+
version: string;
|
|
86
|
+
pid: number;
|
|
87
|
+
startId: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function readIncarnation(ctx: ToothContext): Promise<Incarnation | null> {
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(
|
|
93
|
+
await fs.readFile(path.join(stateDir(ctx), INCARNATION_FILE), "utf8"),
|
|
94
|
+
) as Incarnation;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function spawnService(binPath: string, env: Record<string, string>): { child: ChildProcess; pid: number } {
|
|
101
|
+
// Windows cannot spawn a shebang artifact directly (see commandForArtifact).
|
|
102
|
+
const routed = commandForArtifact(binPath, []);
|
|
103
|
+
const child = spawn(routed.cmd, routed.args, {
|
|
104
|
+
env: { ...process.env, ...env },
|
|
105
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
106
|
+
});
|
|
107
|
+
if (!child.pid) throw new Error("service did not get a pid");
|
|
108
|
+
return { child, pid: child.pid };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readLine(child: ChildProcess, prefix: string, timeoutMs = 5000): Promise<string> {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
let buffer = "";
|
|
114
|
+
const timer = setTimeout(() => {
|
|
115
|
+
cleanup();
|
|
116
|
+
reject(new Error(`timed out waiting for "${prefix}" from service`));
|
|
117
|
+
}, timeoutMs);
|
|
118
|
+
const onData = (chunk: Buffer): void => {
|
|
119
|
+
buffer += chunk.toString("utf8");
|
|
120
|
+
// Only COMPLETE lines (newline-terminated) are protocol messages.
|
|
121
|
+
for (;;) {
|
|
122
|
+
const nl = buffer.indexOf("\n");
|
|
123
|
+
if (nl === -1) break;
|
|
124
|
+
const line = buffer.slice(0, nl);
|
|
125
|
+
buffer = buffer.slice(nl + 1);
|
|
126
|
+
if (line.startsWith(`${prefix} `)) {
|
|
127
|
+
cleanup();
|
|
128
|
+
resolve(line.slice(prefix.length + 1));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const cleanup = (): void => {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
child.stdout?.off("data", onData);
|
|
136
|
+
};
|
|
137
|
+
child.stdout?.on("data", onData);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Ready line, or null fast when the child exits / times out. */
|
|
142
|
+
export function tryReady(child: ChildProcess, timeoutMs: number): Promise<Incarnation | null> {
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
let done = false;
|
|
145
|
+
const finish = (v: Incarnation | null): void => {
|
|
146
|
+
if (!done) {
|
|
147
|
+
done = true;
|
|
148
|
+
resolve(v);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
readLine(child, "ready", timeoutMs)
|
|
152
|
+
.then((line) => finish(JSON.parse(line) as Incarnation))
|
|
153
|
+
.catch(() => finish(null));
|
|
154
|
+
child.once("exit", () => finish(null));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function waitDead(pid: number, timeoutMs = 5000): Promise<void> {
|
|
159
|
+
const deadline = Date.now() + timeoutMs;
|
|
160
|
+
while (processAlive(pid)) {
|
|
161
|
+
if (Date.now() > deadline) throw new Error(`pid ${pid} still alive`);
|
|
162
|
+
await new Promise((r) => {
|
|
163
|
+
setTimeout(r, 10);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Kill the running incarnation and prove it is gone (cleanup discipline). */
|
|
169
|
+
export async function killIncarnation(ctx: ToothContext): Promise<void> {
|
|
170
|
+
const inc = await readIncarnation(ctx);
|
|
171
|
+
if (inc && processAlive(inc.pid)) {
|
|
172
|
+
try {
|
|
173
|
+
process.kill(inc.pid, "SIGKILL");
|
|
174
|
+
} catch {
|
|
175
|
+
// already gone
|
|
176
|
+
}
|
|
177
|
+
await waitDead(inc.pid, 5000);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The OWNER (respawn shape): the driver exits mid-handover; the owner
|
|
183
|
+
* respawns the service from the new bytes (experiment first), falling back
|
|
184
|
+
* to stable when the new version cannot start, and respawning again when a
|
|
185
|
+
* recovery asks for a successor by exiting. A bounded loop: the service
|
|
186
|
+
* eventually stays up or the owner gives up.
|
|
187
|
+
*/
|
|
188
|
+
export async function respawnUntilUp(
|
|
189
|
+
ctx: ToothContext,
|
|
190
|
+
env: Record<string, string>,
|
|
191
|
+
): Promise<{ info: Incarnation; child: ChildProcess }> {
|
|
192
|
+
const dir = stateDir(ctx);
|
|
193
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
194
|
+
const slot: "experiment" | "stable" = attempt === 0 ? "experiment" : "stable";
|
|
195
|
+
const artifact = path.join(dir, "slots", slot, "artifact.bin");
|
|
196
|
+
try {
|
|
197
|
+
await fs.access(artifact);
|
|
198
|
+
} catch {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const child = spawn(process.execPath, [artifact], {
|
|
202
|
+
env: { ...process.env, ...env },
|
|
203
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
204
|
+
});
|
|
205
|
+
const info = await tryReady(child, 2000);
|
|
206
|
+
if (info !== null) return { info, child };
|
|
207
|
+
if (child.pid && processAlive(child.pid)) child.kill("SIGKILL");
|
|
208
|
+
}
|
|
209
|
+
throw new Error("owner: the service never came up");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Seed the service world: spawn the v1 service (registers), then a real
|
|
214
|
+
* driver upgrade to 1.0.0 lands stable v1 with a running incarnation
|
|
215
|
+
* (driver-completed on spawn, successor-completed on respawn).
|
|
216
|
+
*/
|
|
217
|
+
export async function seedService(
|
|
218
|
+
ctx: ToothContext,
|
|
219
|
+
shape: HostShape,
|
|
220
|
+
binPath: string,
|
|
221
|
+
seedServer: FakeServer,
|
|
222
|
+
): Promise<{ seedChild: ChildProcess; seedPid: number }> {
|
|
223
|
+
const env = serviceEnv(ctx, shape, seedServer.url);
|
|
224
|
+
const { child, pid } = spawnService(binPath, env);
|
|
225
|
+
const ready = JSON.parse(await readLine(child, "ready")) as Incarnation;
|
|
226
|
+
assert.equal(ready.version, "1.0.0", "seed service must run v1");
|
|
227
|
+
assert.ok(processAlive(ready.pid), "seed service must be alive");
|
|
228
|
+
|
|
229
|
+
const up = await runCommand(binPath, ["self", "upgrade"], { env, timeoutMs: 30000 });
|
|
230
|
+
assert.equal(up.code, 0, `seed upgrade must exit 0 (${up.stderr.trim()})`);
|
|
231
|
+
if (shape === "respawn") {
|
|
232
|
+
await respawnUntilUp(ctx, env); // the driver died; the owner brings the successor up
|
|
233
|
+
}
|
|
234
|
+
const seeded = await readState(env);
|
|
235
|
+
assert.equal(seeded.stableVersion, "1.0.0", "seed upgrade must land stable 1.0.0");
|
|
236
|
+
const running = await readIncarnation(ctx);
|
|
237
|
+
assert.ok(running, "a service must be running after the seed upgrade");
|
|
238
|
+
assert.equal(running.version, "1.0.0");
|
|
239
|
+
assert.ok(processAlive(running.pid), "the seeded service must be alive");
|
|
240
|
+
return { seedChild: child, seedPid: pid };
|
|
241
|
+
}
|