@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.
Files changed (115) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +24 -0
  3. package/README.md +62 -0
  4. package/core/src/artifact/README.md +1 -0
  5. package/core/src/artifact/collectStream.ts +49 -0
  6. package/core/src/artifact/download.ts +300 -0
  7. package/core/src/artifact/errors.ts +24 -0
  8. package/core/src/artifact/partialPath.ts +18 -0
  9. package/core/src/artifact/source.ts +59 -0
  10. package/core/src/artifact/staticManifestSource.ts +185 -0
  11. package/core/src/artifact/swap.ts +21 -0
  12. package/core/src/clock.ts +22 -0
  13. package/core/src/converge/lifecycle.ts +114 -0
  14. package/core/src/converge/predicates.ts +57 -0
  15. package/core/src/converge/report.ts +38 -0
  16. package/core/src/createUpgrader.ts +300 -0
  17. package/core/src/drive/README.md +1 -0
  18. package/core/src/index.ts +17 -0
  19. package/core/src/invariants.ts +243 -0
  20. package/core/src/lifecycle/hostAdapter.ts +77 -0
  21. package/core/src/liveness.ts +80 -0
  22. package/core/src/platform/README.md +1 -0
  23. package/core/src/platform/index.ts +11 -0
  24. package/core/src/platform/ops.ts +53 -0
  25. package/core/src/platform/posix.ts +99 -0
  26. package/core/src/platform/windows.ts +73 -0
  27. package/core/src/policy/README.md +1 -0
  28. package/core/src/progress.ts +54 -0
  29. package/core/src/provenance/journal.ts +216 -0
  30. package/core/src/status/report.ts +76 -0
  31. package/core/src/status/reportStore.ts +67 -0
  32. package/core/src/txn/effects.ts +40 -0
  33. package/core/src/txn/engine.ts +275 -0
  34. package/core/src/txn/fileEffects.ts +129 -0
  35. package/core/src/txn/hostCallBudget.ts +28 -0
  36. package/core/src/txn/lock.ts +124 -0
  37. package/core/src/txn/state.ts +77 -0
  38. package/core/src/txn/transitions.ts +49 -0
  39. package/core/src/upgrade/outcome.ts +53 -0
  40. package/core/src/upgrade/retire.ts +44 -0
  41. package/core/src/upgrader.ts +124 -0
  42. package/docs/design-v1.md +246 -0
  43. package/docs/harness-design.md +170 -0
  44. package/docs/integration.md +326 -0
  45. package/docs/prior-art.md +150 -0
  46. package/docs/test-plan.md +100 -0
  47. package/harness/README.md +20 -0
  48. package/harness/src/adapter/probeChecks.ts +93 -0
  49. package/harness/src/adapter/releaseKnob.ts +65 -0
  50. package/harness/src/adapter/serviceChecks.ts +260 -0
  51. package/harness/src/artifact/checks.ts +232 -0
  52. package/harness/src/artifact/downloadHoles.ts +300 -0
  53. package/harness/src/artifact/downloadHolesMutations.ts +170 -0
  54. package/harness/src/artifact/m1.ts +204 -0
  55. package/harness/src/artifact/m1Resume.ts +141 -0
  56. package/harness/src/artifact/m3.ts +296 -0
  57. package/harness/src/artifact/m3Hosts.ts +241 -0
  58. package/harness/src/artifact/m4.ts +201 -0
  59. package/harness/src/artifact/m5.ts +239 -0
  60. package/harness/src/artifact/m6.ts +274 -0
  61. package/harness/src/artifact/m6Drive.ts +296 -0
  62. package/harness/src/artifact/m6DriveMutations.ts +70 -0
  63. package/harness/src/artifact/m6Mutations.ts +136 -0
  64. package/harness/src/artifact/m6Status.ts +224 -0
  65. package/harness/src/artifact-factory/checks.ts +68 -0
  66. package/harness/src/artifact-factory/demo.ts +70 -0
  67. package/harness/src/artifact-factory/factory.ts +134 -0
  68. package/harness/src/artifact-factory/run.ts +107 -0
  69. package/harness/src/blackbox.ts +284 -0
  70. package/harness/src/cli.ts +281 -0
  71. package/harness/src/crash/enumerate.ts +66 -0
  72. package/harness/src/crash/runner.ts +162 -0
  73. package/harness/src/examples/checks.ts +286 -0
  74. package/harness/src/fake-host/checks.ts +188 -0
  75. package/harness/src/fake-host/daemon-entry.ts +39 -0
  76. package/harness/src/fake-host/daemon.ts +159 -0
  77. package/harness/src/fake-host/inproc.ts +256 -0
  78. package/harness/src/fake-server/manifest.ts +68 -0
  79. package/harness/src/fake-server/server.ts +205 -0
  80. package/harness/src/fake-server/store.ts +208 -0
  81. package/harness/src/fixtures/minimal-adapter.ts +108 -0
  82. package/harness/src/fixtures/service-adapter.ts +187 -0
  83. package/harness/src/index.ts +11 -0
  84. package/harness/src/receipt.ts +83 -0
  85. package/harness/src/runner.ts +268 -0
  86. package/harness/src/scenario/processScan.ts +55 -0
  87. package/harness/src/scenario/sandbox.ts +259 -0
  88. package/harness/src/scenario/virtualClock.ts +56 -0
  89. package/harness/src/selfverify/selfVerify.ts +136 -0
  90. package/harness/src/sim/checks.ts +46 -0
  91. package/harness/src/sim/corpus.ts +36 -0
  92. package/harness/src/sim/effectRuntime.ts +113 -0
  93. package/harness/src/sim/error.ts +14 -0
  94. package/harness/src/sim/prng.ts +29 -0
  95. package/harness/src/sim/record.ts +76 -0
  96. package/harness/src/sim/run.ts +148 -0
  97. package/harness/src/sim/scheduler.ts +72 -0
  98. package/harness/src/sim/world.ts +284 -0
  99. package/harness/src/target.ts +86 -0
  100. package/harness/src/targetCheck.ts +47 -0
  101. package/harness/src/teeth/artifact.ts +215 -0
  102. package/harness/src/teeth/artifactDownload.ts +136 -0
  103. package/harness/src/teeth/artifactFactory.ts +41 -0
  104. package/harness/src/teeth/artifactM5.ts +68 -0
  105. package/harness/src/teeth/artifactM6.ts +264 -0
  106. package/harness/src/teeth/blackbox.ts +22 -0
  107. package/harness/src/teeth/checks.ts +278 -0
  108. package/harness/src/teeth/examples.ts +57 -0
  109. package/harness/src/teeth/fakeHost.ts +120 -0
  110. package/harness/src/teeth/index.ts +16 -0
  111. package/harness/src/teeth/m0.ts +135 -0
  112. package/harness/src/teeth/registry.ts +267 -0
  113. package/harness/src/teeth/selfCheck.ts +45 -0
  114. package/harness/src/teeth/sim.ts +61 -0
  115. 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
+ }