@coreplane/switchboard 1.251.0 → 1.252.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/dist/assets/config/config.example.yaml +4 -2
- package/dist/assets/deploy/cloudflare/preflight.mjs +19 -21
- package/dist/assets/deploy/cloudflare/worker.ts +6 -3
- package/dist/assets/deploy/cloudflare-memory/worker.ts +77 -12
- package/dist/assets/deploy/cloudflare-resident/memoryGuard.ts +212 -0
- package/dist/assets/deploy/cloudflare-resident/refresh.ts +1 -1
- package/dist/assets/deploy/cloudflare-resident/worker.ts +317 -56
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +4 -2
- package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.d.mts +31 -0
- package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.mjs +119 -0
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +3 -2
- package/dist/assets/project.json +13 -9
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +5 -5
- package/dist/assets/src/core/budgets.ts +22 -0
- package/dist/assets/src/core/coordinator/contract.ts +42 -0
- package/dist/assets/src/core/coordinator/driver.ts +134 -10
- package/dist/assets/src/core/drain.ts +50 -0
- package/dist/assets/src/core/memory/engine.ts +98 -0
- package/dist/assets/src/core/memory/scorer.ts +12 -4
- package/dist/assets/src/core/memory/types.ts +69 -12
- package/dist/assets/src/core/modelCard.ts +32 -4
- package/dist/assets/src/core/modelPricing.ts +111 -1
- package/dist/assets/src/core/modelProxy/usage.ts +88 -0
- package/dist/assets/src/core/modelRegistry.ts +15 -1
- package/dist/assets/src/core/refusal.ts +4 -7
- package/dist/assets/src/core/reviewVerdict.ts +4 -0
- package/dist/assets/src/core/runEvents.ts +51 -2
- package/dist/assets/src/core/runFriction.ts +7 -2
- package/dist/assets/src/core/runLedger/types.ts +11 -0
- package/dist/assets/src/core/runUsage.ts +67 -13
- package/dist/assets/src/core/schedules.ts +3 -0
- package/dist/assets/src/core/ship/contract.ts +41 -14
- package/dist/assets/src/core/ship/coordinator.ts +380 -53
- package/dist/assets/src/core/ship/renewal.ts +10 -5
- package/dist/assets/src/core/trace/attrs.ts +24 -0
- package/dist/assets/src/core/types.ts +5 -5
- package/dist/assets/src/core/verbosity.ts +48 -0
- package/dist/assets/src/deploy/liveGate.ts +40 -13
- package/dist/assets/src/deploy/restart.ts +11 -12
- package/dist/assets/src/execution/residentDepCache.ts +50 -1
- package/dist/assets/src/execution/residentDepsStore.ts +40 -2
- package/dist/assets/src/execution/residentRefresh.ts +55 -3
- package/dist/assets/src/execution/residentSteps.ts +4 -0
- package/dist/assets/src/execution/sandboxErrors.ts +8 -0
- package/dist/assets/web/dist/.vite/manifest.json +55 -55
- package/dist/assets/web/dist/assets/{DeliveryPage-DUXd-Sl-.js → DeliveryPage-3ELQWM0r.js} +1 -1
- package/dist/assets/web/dist/assets/HomePage-BG_ok-K2.js +2 -0
- package/dist/assets/web/dist/assets/{PendingTurnRow-DDhMhrI7.js → PendingTurnRow-ChCQOLgZ.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-BnEoOnGQ.js → ResidentDetailPage-C9y3nbo8.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-Dxpgf-l-.js → ResidentsIndexPage-i1RG9e7g.js} +1 -1
- package/dist/assets/web/dist/assets/RunFoldRow-D3wVpzBa.js +1 -0
- package/dist/assets/web/dist/assets/{RunRoutePage-9klVWhSF.js → RunRoutePage-B3IirUVi.js} +4 -4
- package/dist/assets/web/dist/assets/RunsIndexPage-DiFmtGaJ.js +1 -0
- package/dist/assets/web/dist/assets/{ScheduledPage-B_GgeJrb.js → ScheduledPage-DvYwM2TE.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-BXX4R113.js → SettingsPage-Bo6yCyXZ.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-BOaw8le9.js → StatusDot-CAfS1AUi.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-DYZZ4l4V.js → Tooltip-tZoum_T-.js} +1 -1
- package/dist/assets/web/dist/assets/{UnitRoutePage-BaSW5Odq.js → UnitRoutePage-BmdOHwNn.js} +1 -1
- package/dist/assets/web/dist/assets/budgets-CbIyPAER.js +1 -0
- package/dist/assets/web/dist/assets/{dist-BCVXeBJ9.js → dist-DfbEpHXR.js} +1 -1
- package/dist/assets/web/dist/assets/indexRow-BT0cPVRw.js +1 -0
- package/dist/assets/web/dist/assets/{main-Dkcbtu3u.js → main-5Gm_1Gv8.js} +2 -2
- package/dist/assets/web/dist/assets/sseReplay-DmyMXfRC.js +11 -0
- package/dist/cli.js +2470 -902
- package/package.json +1 -1
- package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.sh +0 -68
- package/dist/assets/web/dist/assets/HomePage-mSiqEEcN.js +0 -2
- package/dist/assets/web/dist/assets/RunFoldRow-CSo4-vld.js +0 -1
- package/dist/assets/web/dist/assets/RunsIndexPage-BplMIgaw.js +0 -1
- package/dist/assets/web/dist/assets/budgets-BvWYKPsY.js +0 -1
- package/dist/assets/web/dist/assets/indexRow-Bde9OZxG.js +0 -1
- package/dist/assets/web/dist/assets/sseReplay-DPwdsaok.js +0 -9
|
@@ -180,8 +180,11 @@ import {
|
|
|
180
180
|
type StoredTestOverrides,
|
|
181
181
|
} from "./gc";
|
|
182
182
|
import {
|
|
183
|
+
CHECKOUT_FETCH_COMMAND,
|
|
183
184
|
checkoutUpdateCommand,
|
|
184
185
|
classifyRefreshFailure,
|
|
186
|
+
stageCheckoutScript,
|
|
187
|
+
swapCheckoutScript,
|
|
185
188
|
restoreFailureDisposition,
|
|
186
189
|
isRuntimeUnreachableSignal,
|
|
187
190
|
killStaleBuildProcessesCommand,
|
|
@@ -304,6 +307,7 @@ import {
|
|
|
304
307
|
CHECKOUT_SNAPSHOT_EXCLUDES,
|
|
305
308
|
DEPS_BACKUP_KEY_PREFIX,
|
|
306
309
|
DEPS_BACKUP_TTL_S,
|
|
310
|
+
DEPS_ENTRY_BACKUP_EXCLUDES,
|
|
307
311
|
DEPS_STORE_DIR,
|
|
308
312
|
depsBackupStorageKey,
|
|
309
313
|
depsBackupsToDrop,
|
|
@@ -330,6 +334,14 @@ import {
|
|
|
330
334
|
import { buildId, injectedBuildStamp } from "../../src/deploy/buildStamp.js";
|
|
331
335
|
import { createRefreshInstance, createRefreshInstanceNow, type RefreshInstanceParams } from "./refresh";
|
|
332
336
|
import { drainRefusal, liveDrain, parseDrainRequest, type DrainRecord } from "./drain";
|
|
337
|
+
import {
|
|
338
|
+
CGROUP_READ_ARGV,
|
|
339
|
+
MEMORY_PRESSURE_REASON,
|
|
340
|
+
MemoryGuard,
|
|
341
|
+
MemorySampleUnavailable,
|
|
342
|
+
type MemoryGateRoute,
|
|
343
|
+
type MemoryReading,
|
|
344
|
+
} from "./memoryGuard";
|
|
333
345
|
import {
|
|
334
346
|
ControlResetError,
|
|
335
347
|
RuntimeReplacedError,
|
|
@@ -485,6 +497,18 @@ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve,
|
|
|
485
497
|
* DO storage is). Thread worktrees hang off the same mirror; keep these paths stable. */
|
|
486
498
|
const MIRROR_DIR = "/workspace/mirror"; // bare mirror, owned by root
|
|
487
499
|
const CHECKOUT_DIR = "/workspace/checkout"; // default-branch working tree + deps + build, owned by BUILD_USER
|
|
500
|
+
/** The staged rebuild's side trees (resident-repos.md item 47): the refresh
|
|
501
|
+
* builds into `staging` OUTSIDE the mirror lock and swaps it into
|
|
502
|
+
* CHECKOUT_DIR under it (two renames); `retired` holds the replaced checkout
|
|
503
|
+
* between the swap and its off-lock removal. Same filesystem as the
|
|
504
|
+
* checkout, so both moves are renames and `cp -al` hardlinks. */
|
|
505
|
+
const STAGING_CHECKOUT_DIR = "/workspace/checkout.staging";
|
|
506
|
+
const RETIRED_CHECKOUT_DIR = "/workspace/checkout.retired";
|
|
507
|
+
const CHECKOUT_SWAP_DIRS = {
|
|
508
|
+
checkout: CHECKOUT_DIR,
|
|
509
|
+
staging: STAGING_CHECKOUT_DIR,
|
|
510
|
+
retired: RETIRED_CHECKOUT_DIR,
|
|
511
|
+
} as const;
|
|
488
512
|
const RESIDENT_STATE_DIR = "/workspace/.resident"; // mode 700 root:root — worker users cannot traverse
|
|
489
513
|
const CRED_FILE = `${RESIDENT_STATE_DIR}/git-credentials`; // one-shot token file, deleted after each git command
|
|
490
514
|
const READY_MARKER = `${RESIDENT_STATE_DIR}/ready`; // holds the sha the disk was hydrated to
|
|
@@ -607,6 +631,11 @@ const DISK_FULL_RECYCLE_KEY = "resident:diskFullRecycleAt";
|
|
|
607
631
|
* admission projects a new tree's cost from its parts (its free-space term
|
|
608
632
|
* is a live `df` of its own). */
|
|
609
633
|
const DISK_KEY = "resident:disk";
|
|
634
|
+
/** The last memory reading (docs/reference/specs/resident-repos.md item 70; `memoryGuard.ts`):
|
|
635
|
+
* one cgroup v2 read at every /exec and /attach start and on the measure
|
|
636
|
+
* tick, persisted so the gauges (`/status`, `/residents`, the watchdog line)
|
|
637
|
+
* read storage only — the watchdog never touches the container. */
|
|
638
|
+
const MEMORY_KEY = "resident:memory";
|
|
610
639
|
/** The lifecycle row (docs/reference/specs/resident-repos.md item 7): `workflow`,
|
|
611
640
|
* the one scheduler. Kept from the flagged rollout so `/status` and `/debug
|
|
612
641
|
* info` can say so; an `alarm` value a flip left behind reads `workflow`
|
|
@@ -1893,6 +1922,12 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
1893
1922
|
* deadline names the fallback. */
|
|
1894
1923
|
private restoreWaiters = new RestoreWaiters();
|
|
1895
1924
|
|
|
1925
|
+
/** Item 70: the memory guard — one cgroup reading per /exec and /attach
|
|
1926
|
+
* start (and per measure tick), the two-threshold gate over the last one.
|
|
1927
|
+
* In-memory like the waiter ledger: a fresh isolate re-samples at the next
|
|
1928
|
+
* route start, and the persisted copy (MEMORY_KEY) serves the gauges. */
|
|
1929
|
+
private memoryGuard = new MemoryGuard({ read: () => this.readCgroup() }, (line) => console.log(line));
|
|
1930
|
+
|
|
1896
1931
|
private nextHolder(): string {
|
|
1897
1932
|
return `${this.incarnation}:${++this.leaseSeq}`;
|
|
1898
1933
|
}
|
|
@@ -2209,18 +2244,24 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
2209
2244
|
}
|
|
2210
2245
|
|
|
2211
2246
|
/** Run one command-table entry as the unprivileged build user in the warm
|
|
2212
|
-
* checkout. Never root. Never a
|
|
2213
|
-
* is whatever `su` grants the target user, nothing
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2247
|
+
* checkout (or the staged rebuild's tree, item 47). Never root. Never a
|
|
2248
|
+
* GitHub token — the env is whatever `su` grants the target user, nothing
|
|
2249
|
+
* injected. */
|
|
2250
|
+
private async buildUserRun(
|
|
2251
|
+
command: string,
|
|
2252
|
+
step: ResidentStepLabelKey,
|
|
2253
|
+
timeoutMs: number,
|
|
2254
|
+
dir: string = CHECKOUT_DIR,
|
|
2255
|
+
): Promise<string> {
|
|
2256
|
+
// Steps on one tree are sequential, so a live build-user process INSIDE
|
|
2257
|
+
// it here is a leftover (a step whose wait was abandoned, or one
|
|
2217
2258
|
// orphaned by a Worker-only deploy resetting the DO) — and it is writing
|
|
2218
|
-
// into the tree this step is about to clean or build. Scoped to
|
|
2219
|
-
//
|
|
2259
|
+
// into the tree this step is about to clean or build. Scoped to that
|
|
2260
|
+
// tree: the same user's deps-store installs run in their own scratch
|
|
2220
2261
|
// trees, in parallel, and are live work (killStaleBuildProcessesCommand).
|
|
2221
|
-
const swept = await this.runOk(killStaleBuildProcessesCommand(BUILD_USER,
|
|
2262
|
+
const swept = await this.runOk(killStaleBuildProcessesCommand(BUILD_USER, dir), `${step}-stale-sweep`);
|
|
2222
2263
|
if (swept.trim()) console.log(`${step}: ${swept.trim()}`);
|
|
2223
|
-
return this.runOk(["su", "-s", "/bin/bash", BUILD_USER, "-c", `cd ${
|
|
2264
|
+
return this.runOk(["su", "-s", "/bin/bash", BUILD_USER, "-c", `cd ${dir} && ${command}`], step, {
|
|
2224
2265
|
timeoutMs,
|
|
2225
2266
|
});
|
|
2226
2267
|
}
|
|
@@ -2563,9 +2604,10 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
2563
2604
|
}
|
|
2564
2605
|
}
|
|
2565
2606
|
|
|
2566
|
-
/** Bring the checkout to `sha` and build it
|
|
2567
|
-
*
|
|
2568
|
-
* whose HEAD, deps and build markers
|
|
2607
|
+
/** Bring the checkout to `sha` and build it — staged outside the mirror
|
|
2608
|
+
* mutex, swapped in under it (item 47). The disk markers decide (planBuild
|
|
2609
|
+
* over the refresh planner): a checkout whose HEAD, deps and build markers
|
|
2610
|
+
* all name the target is done. */
|
|
2569
2611
|
async runBuild(input: {
|
|
2570
2612
|
sha: string;
|
|
2571
2613
|
factsSha: string;
|
|
@@ -2577,47 +2619,116 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
2577
2619
|
const { sha, lockfileKey } = input;
|
|
2578
2620
|
const plan = planBuild({ sha, factsSha: input.factsSha, lockfileKey, disk: await this.readRefreshDisk() });
|
|
2579
2621
|
if (plan.action === "done") return { done: true, why: plan.why };
|
|
2580
|
-
//
|
|
2581
|
-
//
|
|
2582
|
-
//
|
|
2583
|
-
//
|
|
2584
|
-
//
|
|
2585
|
-
//
|
|
2622
|
+
// The mirror mutex is held only to STAGE and later to SWAP — never for
|
|
2623
|
+
// the build (item 47): a heavy build under the lock pushed concurrent
|
|
2624
|
+
// attaches past ATTACH_MUTEX_WAIT_MS into mirror-busy 503s and cold
|
|
2625
|
+
// fallbacks. The stage lock covers the two reads the mutex actually
|
|
2626
|
+
// protects: a consistent hardlink copy of the warm checkout (exactly
|
|
2627
|
+
// materializeThreadDeps' torn-cache guarantee, which the `cp -al` under
|
|
2628
|
+
// the same lock keeps — CHECKOUT_DIR now changes only by rename under
|
|
2629
|
+
// this mutex) and the fetch from the mirror into the staging tree (an
|
|
2630
|
+
// attach's `fetch --prune` could delete a ref under it). No wait
|
|
2631
|
+
// timeout, exactly like the fetch lock: the background refresh queues
|
|
2632
|
+
// behind an in-flight attach instead of flipping to degraded on
|
|
2586
2633
|
// transient lock contention. Token-free: repo code runs during the build.
|
|
2634
|
+
// Off the lock, before the stage lease: a failed or interrupted off-lock
|
|
2635
|
+
// build leaves a full staging tree — deps view plus partial build output,
|
|
2636
|
+
// hundreds of thousands of inodes — and the retry 30 s later would
|
|
2637
|
+
// otherwise hold the mirror lock through its removal, the contention
|
|
2638
|
+
// class the staged rebuild exists to remove. The tree is private to this
|
|
2639
|
+
// rebuild, so its stale writers are killed and the tree dropped without
|
|
2640
|
+
// the lock; the retired tree's heal-then-remove stays in the staged
|
|
2641
|
+
// script under the lease, where the checkout path's consistency needs it.
|
|
2642
|
+
const swept = await this.runOk(
|
|
2643
|
+
killStaleBuildProcessesCommand(BUILD_USER, STAGING_CHECKOUT_DIR),
|
|
2644
|
+
"checkout-stage-stale-sweep",
|
|
2645
|
+
);
|
|
2646
|
+
if (swept.trim()) console.log(`checkout-stage: ${swept.trim()}`);
|
|
2647
|
+
await this.runOk(["rm", "-rf", STAGING_CHECKOUT_DIR], "checkout-stage-clear", {
|
|
2648
|
+
timeoutMs: GIT_NETWORK_TIMEOUT_MS,
|
|
2649
|
+
});
|
|
2650
|
+
await this.withMirrorLock(
|
|
2651
|
+
async () => {
|
|
2652
|
+
await this.runOk(["sh", "-c", stageCheckoutScript(CHECKOUT_SWAP_DIRS)], "checkout-stage", {
|
|
2653
|
+
timeoutMs: GIT_NETWORK_TIMEOUT_MS,
|
|
2654
|
+
});
|
|
2655
|
+
await this.buildUserRun(
|
|
2656
|
+
CHECKOUT_FETCH_COMMAND,
|
|
2657
|
+
"checkout-update",
|
|
2658
|
+
GIT_NETWORK_TIMEOUT_MS,
|
|
2659
|
+
STAGING_CHECKOUT_DIR,
|
|
2660
|
+
);
|
|
2661
|
+
},
|
|
2662
|
+
0,
|
|
2663
|
+
// The lease is the section's own budget (resident-repos.md item 22) —
|
|
2664
|
+
// the backstop by which a taker judges this holder dead. The section
|
|
2665
|
+
// runs two commands each budgeted GIT_NETWORK_TIMEOUT_MS (the stage
|
|
2666
|
+
// script and the mirror fetch), so the lease is their sum: a lease of
|
|
2667
|
+
// one budget let a taker steal the lock mid-stage and race its
|
|
2668
|
+
// `fetch --prune` against the staging fetch's negotiation.
|
|
2669
|
+
{ step: "checkout-stage", budgetMs: 2 * GIT_NETWORK_TIMEOUT_MS },
|
|
2670
|
+
);
|
|
2671
|
+
// Outside the lock, in the staging tree. Isolation invariant (review 1b):
|
|
2672
|
+
// attached, sha-pinned thread worktrees hold hardlinks to the store
|
|
2673
|
+
// entry's FILE inodes, and so does the staged copy. A build that writes
|
|
2674
|
+
// THROUGH an existing inode — many bundlers do (e.g. .next incremental
|
|
2675
|
+
// manifests open+truncate rather than recreate) — would mutate every
|
|
2676
|
+
// consumer's pinned artifacts. The `-x` clean removes the staged copy's
|
|
2677
|
+
// build output so the build allocates FRESH inodes; the entry's own files
|
|
2678
|
+
// are owner-read-only (deps-harden), so a write through them fails loudly
|
|
2679
|
+
// instead of silently reaching the store; the tool caches inside
|
|
2680
|
+
// node_modules are the checkout's private copies (item 18).
|
|
2681
|
+
//
|
|
2682
|
+
// Install gate: when the committed lockfile key is unchanged, node_modules
|
|
2683
|
+
// (the view) is excluded from the clean and no deps work happens; a
|
|
2684
|
+
// changed key re-links the view to the new entry — which is also what
|
|
2685
|
+
// drops deps the new lockfile no longer has.
|
|
2686
|
+
await this.buildUserRun(
|
|
2687
|
+
checkoutUpdateCommand(sha, plan.clean),
|
|
2688
|
+
"checkout-update",
|
|
2689
|
+
GIT_NETWORK_TIMEOUT_MS,
|
|
2690
|
+
STAGING_CHECKOUT_DIR,
|
|
2691
|
+
);
|
|
2692
|
+
if (plan.install && input.depsEntry) {
|
|
2693
|
+
// The old view (a resumed install's keep-deps clean leaves it in
|
|
2694
|
+
// place, item 57) makes way for the new entry's: hardlinks only,
|
|
2695
|
+
// the entry's inodes are untouched.
|
|
2696
|
+
await this.runOk(["rm", "-rf", `${STAGING_CHECKOUT_DIR}/node_modules`], "unlink-deps-view");
|
|
2697
|
+
await this.linkDepsView(
|
|
2698
|
+
`${input.depsEntry}/node_modules`,
|
|
2699
|
+
STAGING_CHECKOUT_DIR,
|
|
2700
|
+
BUILD_USER,
|
|
2701
|
+
STAGING_CHECKOUT_DIR,
|
|
2702
|
+
);
|
|
2703
|
+
}
|
|
2704
|
+
await this.buildUserRun(input.buildCmd, "build", REFRESH_BUILD_TIMEOUT_MS, STAGING_CHECKOUT_DIR);
|
|
2705
|
+
// The swap: two renames — milliseconds — and the markers written with the
|
|
2706
|
+
// tree they vouch for, under one short lease.
|
|
2587
2707
|
await this.withMirrorLock(
|
|
2588
2708
|
async () => {
|
|
2589
|
-
//
|
|
2590
|
-
//
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
//
|
|
2600
|
-
// Install gate: when the committed lockfile key is unchanged,
|
|
2601
|
-
// node_modules (the view) is excluded from the clean and no deps
|
|
2602
|
-
// work happens; a changed key re-links the view to the new entry —
|
|
2603
|
-
// which is also what drops deps the new lockfile no longer has.
|
|
2604
|
-
await this.buildUserRun(checkoutUpdateCommand(sha, plan.clean), "checkout-update", GIT_NETWORK_TIMEOUT_MS);
|
|
2709
|
+
// A leftover writer inside the old checkout would keep writing into
|
|
2710
|
+
// the retired tree while it is removed.
|
|
2711
|
+
const swept = await this.runOk(
|
|
2712
|
+
killStaleBuildProcessesCommand(BUILD_USER, CHECKOUT_DIR),
|
|
2713
|
+
"checkout-swap-stale-sweep",
|
|
2714
|
+
);
|
|
2715
|
+
if (swept.trim()) console.log(`checkout-swap: ${swept.trim()}`);
|
|
2716
|
+
await this.runOk(["sh", "-c", swapCheckoutScript(CHECKOUT_SWAP_DIRS)], "checkout-swap", {
|
|
2717
|
+
timeoutMs: GIT_NETWORK_TIMEOUT_MS,
|
|
2718
|
+
});
|
|
2605
2719
|
if (plan.install) {
|
|
2606
|
-
// The old view (a resumed install's keep-deps clean leaves it in
|
|
2607
|
-
// place, item 57) makes way for the new entry's: hardlinks only,
|
|
2608
|
-
// the entry's inodes are untouched.
|
|
2609
|
-
if (input.depsEntry) {
|
|
2610
|
-
await this.runOk(["rm", "-rf", `${CHECKOUT_DIR}/node_modules`], "unlink-deps-view");
|
|
2611
|
-
await this.linkDepsView(`${input.depsEntry}/node_modules`, CHECKOUT_DIR, BUILD_USER);
|
|
2612
|
-
}
|
|
2613
2720
|
await this.writeDiskMarkers({ depsKey: lockfileKey });
|
|
2614
2721
|
await this.runOk(["rm", "-f", INSTALLING_MARKER], "clear-installing-marker");
|
|
2615
2722
|
}
|
|
2616
|
-
await this.buildUserRun(input.buildCmd, "build", REFRESH_BUILD_TIMEOUT_MS);
|
|
2617
2723
|
await this.writeDiskMarkers({ builtSha: sha });
|
|
2618
2724
|
},
|
|
2619
2725
|
0,
|
|
2620
|
-
{ step: "
|
|
2726
|
+
{ step: "checkout-swap", budgetMs: GIT_NETWORK_TIMEOUT_MS },
|
|
2727
|
+
);
|
|
2728
|
+
// Off the lock: the replaced tree's removal (hardlinks — mostly link
|
|
2729
|
+
// drops). A failure here is a log line; the next stage's seed sweeps it.
|
|
2730
|
+
await this.runOk(["rm", "-rf", RETIRED_CHECKOUT_DIR], "checkout-retire").catch((err: unknown) =>
|
|
2731
|
+
console.log(`checkout-retire: ${errMsg(err)} — the next staged rebuild sweeps it`),
|
|
2621
2732
|
);
|
|
2622
2733
|
return { done: false, why: plan.why };
|
|
2623
2734
|
}
|
|
@@ -2709,7 +2820,10 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
2709
2820
|
// (item 61: the SDK's presigned restore mounts) — `rm -rf` on a mount
|
|
2710
2821
|
// point is "Device or resource busy". Unmount first, every time.
|
|
2711
2822
|
await this.runOk(["sh", "-c", unmountAllRestoresScript()], "unmount-restores");
|
|
2712
|
-
await this.runOk(
|
|
2823
|
+
await this.runOk(
|
|
2824
|
+
["rm", "-rf", MIRROR_DIR, CHECKOUT_DIR, STAGING_CHECKOUT_DIR, RETIRED_CHECKOUT_DIR, ...DISK_MARKERS],
|
|
2825
|
+
"clean-before-restore",
|
|
2826
|
+
);
|
|
2713
2827
|
try {
|
|
2714
2828
|
// The restore pair IS the cold-wake critical path. Sequential on purpose:
|
|
2715
2829
|
// the SDK serializes backup operations anyway (one queue), so a
|
|
@@ -2962,7 +3076,10 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
2962
3076
|
});
|
|
2963
3077
|
}
|
|
2964
3078
|
await this.runOk(["sh", "-c", unmountAllRestoresScript()], "unmount-restores");
|
|
2965
|
-
await this.runOk(
|
|
3079
|
+
await this.runOk(
|
|
3080
|
+
["rm", "-rf", MIRROR_DIR, CHECKOUT_DIR, STAGING_CHECKOUT_DIR, RETIRED_CHECKOUT_DIR, ...DISK_MARKERS],
|
|
3081
|
+
"clean-workspace",
|
|
3082
|
+
);
|
|
2966
3083
|
await this.ensureGitSetup();
|
|
2967
3084
|
await this.withMirrorLock(() =>
|
|
2968
3085
|
this.gitWithCred(
|
|
@@ -4234,7 +4351,13 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4234
4351
|
cycle.count();
|
|
4235
4352
|
// Same gate as the sweep: a silent control port cannot answer a `df`.
|
|
4236
4353
|
if (await this.runtimeUnreachableRow()) return { status: "stopped", why: "runtime-unreachable" };
|
|
4237
|
-
return this.housekeeping("measure", async () =>
|
|
4354
|
+
return this.housekeeping("measure", async () => {
|
|
4355
|
+
// Item 70: the tick's memory line rides the measure step's cadence —
|
|
4356
|
+
// the instance the watchdog cron creates, never the watchdog's own
|
|
4357
|
+
// pass (which must not touch the container). No polling loop.
|
|
4358
|
+
await this.sampleMemory();
|
|
4359
|
+
return { measured: (await this.measureDisk()) !== null };
|
|
4360
|
+
});
|
|
4238
4361
|
});
|
|
4239
4362
|
}
|
|
4240
4363
|
|
|
@@ -4424,6 +4547,76 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4424
4547
|
return sample;
|
|
4425
4548
|
}
|
|
4426
4549
|
|
|
4550
|
+
// -- memory guard (docs/reference/specs/resident-repos.md item 70) ------------
|
|
4551
|
+
|
|
4552
|
+
/** The one real `CgroupReader`: `CGROUP_READ_ARGV` through the exec choke
|
|
4553
|
+
* point (`run()`), one exec per sample. A throw there (busy, replaced,
|
|
4554
|
+
* reset — the container's moment, not the cgroup) is the transient
|
|
4555
|
+
* `MemorySampleUnavailable`, which never disables the gate — and a typed
|
|
4556
|
+
* runtime replacement `invalidates` the last reading: the container it
|
|
4557
|
+
* measured is gone, and gating on its numbers would answer
|
|
4558
|
+
* `memory-pressure` where the route's own gate owes `runtime-replaced`.
|
|
4559
|
+
* A non-zero exit is the unreadable cgroup that logs once and disables it. */
|
|
4560
|
+
private async readCgroup(): Promise<string> {
|
|
4561
|
+
let r: { stdout: string; stderr: string; exitCode: number };
|
|
4562
|
+
try {
|
|
4563
|
+
r = await this.run([...CGROUP_READ_ARGV]);
|
|
4564
|
+
} catch (err) {
|
|
4565
|
+
throw new MemorySampleUnavailable(errMsg(err), err instanceof RuntimeReplacedError);
|
|
4566
|
+
}
|
|
4567
|
+
if (r.exitCode !== 0) throw new Error(`cgroup read exit ${r.exitCode}: ${(r.stderr || r.stdout).trim()}`);
|
|
4568
|
+
return r.stdout;
|
|
4569
|
+
}
|
|
4570
|
+
|
|
4571
|
+
/** One sample, one structured log line (the guard's). Never on an inactive
|
|
4572
|
+
* runtime — a sleeping container is not woken to be measured (item 55's
|
|
4573
|
+
* rule) — and persisted for the gauges when taken. */
|
|
4574
|
+
private async sampleMemory(): Promise<MemoryReading | null> {
|
|
4575
|
+
if (!(await this.isRuntimeActive().catch(() => false))) {
|
|
4576
|
+
// An inactive container holds no memory: whatever the guard last read
|
|
4577
|
+
// came from a container that no longer runs, and a route hitting the
|
|
4578
|
+
// gate before anything wakes the runtime (a fresh hydration memo skips
|
|
4579
|
+
// the restore) must not be refused on a dead container's percent.
|
|
4580
|
+
this.memoryGuard.invalidate();
|
|
4581
|
+
return null;
|
|
4582
|
+
}
|
|
4583
|
+
const reading = await this.memoryGuard.sample(new Date(systemClock()).toISOString());
|
|
4584
|
+
if (reading) await this.ctx.storage.put(MEMORY_KEY, reading);
|
|
4585
|
+
return reading;
|
|
4586
|
+
}
|
|
4587
|
+
|
|
4588
|
+
/** The last reading, for `/status`, `/residents` and the watchdog line —
|
|
4589
|
+
* this incarnation's when it has one, else the persisted one (storage
|
|
4590
|
+
* only: the watchdog never touches the container). */
|
|
4591
|
+
async memoryGauge(): Promise<MemoryReading | null> {
|
|
4592
|
+
return this.memoryGuard.lastReading ?? (await this.ctx.storage.get<MemoryReading>(MEMORY_KEY)) ?? null;
|
|
4593
|
+
}
|
|
4594
|
+
|
|
4595
|
+
/** The route gate (item 70): one fresh sample, then the pure verdict over
|
|
4596
|
+
* the last reading. A refusal is the same 503 shape as `mirror-busy`, so
|
|
4597
|
+
* the bot falls back or waits legibly — and a command already running is
|
|
4598
|
+
* never touched: the gate sits at the route's start and kills nothing. */
|
|
4599
|
+
private async memoryGate(route: MemoryGateRoute, exemptRegisteredRun = false): Promise<ThreadErr | null> {
|
|
4600
|
+
await this.sampleMemory();
|
|
4601
|
+
// A run already in flight — registered from attach to release (item 44) —
|
|
4602
|
+
// is what the gate holds the resident open FOR: its re-attach (a rolled
|
|
4603
|
+
// container, an evicted worktree, a resume) passes as it passes the drain,
|
|
4604
|
+
// sampled but never refused — refusing it would strand the warm worktree
|
|
4605
|
+
// and lose the very work the gate protects.
|
|
4606
|
+
if (exemptRegisteredRun) return null;
|
|
4607
|
+
const refusal = this.memoryGuard.gate(route);
|
|
4608
|
+
if (!refusal) return null;
|
|
4609
|
+
const s = await this.getStatus();
|
|
4610
|
+
return {
|
|
4611
|
+
error: refusal.message,
|
|
4612
|
+
status: 503,
|
|
4613
|
+
state: s.state,
|
|
4614
|
+
stateReason: s.reason,
|
|
4615
|
+
reason: MEMORY_PRESSURE_REASON,
|
|
4616
|
+
cause: "system",
|
|
4617
|
+
};
|
|
4618
|
+
}
|
|
4619
|
+
|
|
4427
4620
|
/** `{usedKiB, totalKiB, at}` of the last sample for the watchdog line (storage
|
|
4428
4621
|
* only — the watchdog never touches the container). */
|
|
4429
4622
|
private async diskGauge(): Promise<{ usedKiB: number; totalKiB: number; freeKiB: number; at: string } | null> {
|
|
@@ -4683,11 +4876,17 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4683
4876
|
action: "none" | "provision-timed-out" | "auto-rebuilt";
|
|
4684
4877
|
/** Item 55: the last disk sample's gauge, for the watchdog's status line. */
|
|
4685
4878
|
disk: { usedKiB: number; totalKiB: number; freeKiB: number; at: string } | null;
|
|
4879
|
+
/** Item 70: the last memory reading, storage only — the tick's gauge. */
|
|
4880
|
+
memory: MemoryReading | null;
|
|
4686
4881
|
/** Item 7: what the cron's instance-creation decision reads, after the check above settled the state. */
|
|
4687
4882
|
refresh: RefreshRow;
|
|
4688
4883
|
}> {
|
|
4689
|
-
const [check, disk] = await Promise.all([
|
|
4690
|
-
|
|
4884
|
+
const [check, disk, memory] = await Promise.all([
|
|
4885
|
+
this.watchdogCheckLifecycle(),
|
|
4886
|
+
this.diskGauge(),
|
|
4887
|
+
this.memoryGauge(),
|
|
4888
|
+
]);
|
|
4889
|
+
return { ...check, disk, memory, refresh: await this.refreshRow() };
|
|
4691
4890
|
}
|
|
4692
4891
|
|
|
4693
4892
|
private async watchdogCheckLifecycle(): Promise<{
|
|
@@ -4981,10 +5180,18 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4981
5180
|
// closed on the run it is closed for. Read before the image reconcile so
|
|
4982
5181
|
// a refused attach never restarts a container.
|
|
4983
5182
|
const drain = await this.fleetDrain();
|
|
4984
|
-
|
|
5183
|
+
const registered = (await this.ctx.storage.get(runRegKey(threadKey))) !== undefined;
|
|
5184
|
+
if (drain && !registered) {
|
|
4985
5185
|
const refusal: ThreadErr & { draining: DrainRecord } = drainRefusal(drain);
|
|
4986
5186
|
return refusal;
|
|
4987
5187
|
}
|
|
5188
|
+
// Item 70: above the soft memory threshold a NEW attach is refused like
|
|
5189
|
+
// `mirror-busy` (the bot falls back or waits, the card says why) — after
|
|
5190
|
+
// the drain (storage only, cheaper) and before the image reconcile, so a
|
|
5191
|
+
// refused attach never restarts a container. A registered run's
|
|
5192
|
+
// re-attach passes for the same reason it passes the drain above.
|
|
5193
|
+
const memory = await this.memoryGate("attach", registered);
|
|
5194
|
+
if (memory) return memory;
|
|
4988
5195
|
const resourceId = (await this.ctx.storage.get<string>(RESOURCE_KEY)) ?? "";
|
|
4989
5196
|
if (await this.reconcileImage("attach")) {
|
|
4990
5197
|
return {
|
|
@@ -5975,10 +6182,14 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5975
6182
|
const resource = (await this.ctx.storage.get<string>(RESOURCE_KEY)) ?? "";
|
|
5976
6183
|
const backup = await withTimeout(
|
|
5977
6184
|
this.createBackup({
|
|
5978
|
-
dir:
|
|
6185
|
+
// The entry dir itself, not only its top-level node_modules: nested
|
|
6186
|
+
// workspace node_modules are entry content too. The markers are
|
|
6187
|
+
// excluded so a restore's own commit writes `.complete` LAST.
|
|
6188
|
+
dir: depsEntryPath(key),
|
|
5979
6189
|
localBucket: transfer.localBucket,
|
|
5980
6190
|
ttl: DEPS_BACKUP_TTL_S,
|
|
5981
6191
|
name: `${resource} deps ${key.slice(0, 8)}`,
|
|
6192
|
+
excludes: [...DEPS_ENTRY_BACKUP_EXCLUDES],
|
|
5982
6193
|
}),
|
|
5983
6194
|
R2_TRANSFER_TIMEOUT_MS,
|
|
5984
6195
|
"deps backup",
|
|
@@ -6020,9 +6231,16 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6020
6231
|
await this.ensureDepsStoreDir();
|
|
6021
6232
|
console.log(`deps: restoring ${key.slice(0, 8)} from backup ${record.backup.id.slice(0, 8)} into ${scratch}`);
|
|
6022
6233
|
await this.runOk(["mkdir", "-p", scratch], "deps-restore-scratch");
|
|
6234
|
+
// The archive is the entry dir (top-level node_modules PLUS nested
|
|
6235
|
+
// workspace ones at their tree-relative paths), so it extracts into the
|
|
6236
|
+
// scratch root and the commit script moves each node_modules like an
|
|
6237
|
+
// install's. An archive from before nested coverage held only the
|
|
6238
|
+
// node_modules CONTENTS: its extract leaves no scratch/node_modules, the
|
|
6239
|
+
// commit fails, the record is dropped and the installer runs — the
|
|
6240
|
+
// documented fall-through, never a stranded key.
|
|
6023
6241
|
await this.restoreExtracted(
|
|
6024
6242
|
record.backup,
|
|
6025
|
-
|
|
6243
|
+
scratch,
|
|
6026
6244
|
`deps restore ${key.slice(0, 8)}`,
|
|
6027
6245
|
"deps-restore-extract",
|
|
6028
6246
|
deadlineMs,
|
|
@@ -6104,6 +6322,15 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6104
6322
|
parsed.mutableListing,
|
|
6105
6323
|
"deps-seed-swap",
|
|
6106
6324
|
);
|
|
6325
|
+
for (const rel of parsed.nested) {
|
|
6326
|
+
await this.swapMutableCaches(
|
|
6327
|
+
`${depsEntryPath(seed)}/${rel}`,
|
|
6328
|
+
`${scratch}/${rel}`,
|
|
6329
|
+
BUILD_USER,
|
|
6330
|
+
parsed.mutableListing,
|
|
6331
|
+
"deps-seed-swap",
|
|
6332
|
+
);
|
|
6333
|
+
}
|
|
6107
6334
|
console.log(`deps: ${key.slice(0, 8)} seeded from ${seed.slice(0, 8)} (${parsed.mech})`);
|
|
6108
6335
|
}
|
|
6109
6336
|
// Unprivileged and token-free, in a tree only this attempt
|
|
@@ -6194,25 +6421,46 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6194
6421
|
return mech;
|
|
6195
6422
|
}
|
|
6196
6423
|
|
|
6197
|
-
/** The view itself,
|
|
6198
|
-
*
|
|
6424
|
+
/** The view itself, lock-agnostic: provisioning links the CHECKOUT under
|
|
6425
|
+
* the mirror lock it already holds; the staged rebuild links its staging
|
|
6426
|
+
* tree OFF the lock — safe because the tree is private to the rebuild and
|
|
6427
|
+
* the store entry's inodes are immutable (deps-harden). */
|
|
6199
6428
|
private async linkDepsView(
|
|
6200
6429
|
entryNodeModules: string | null,
|
|
6201
6430
|
tree: string,
|
|
6202
6431
|
user: string,
|
|
6432
|
+
/** The build dirs' source. The staged rebuild passes its own tree (its
|
|
6433
|
+
* clean just removed them — nothing to copy; the WARM checkout's build
|
|
6434
|
+
* output must never seed the tree that is about to rebuild it). */
|
|
6435
|
+
from: string = CHECKOUT_DIR,
|
|
6203
6436
|
): Promise<DepCacheMaterialization | "none"> {
|
|
6204
6437
|
const parsed = await this.runDepScript(
|
|
6205
|
-
depCacheScript(
|
|
6438
|
+
depCacheScript(from, tree, user, entryNodeModules ? { nodeModulesSrc: entryNodeModules } : {}),
|
|
6206
6439
|
"deps-materialize",
|
|
6207
6440
|
REFRESH_BUILD_TIMEOUT_MS,
|
|
6208
6441
|
);
|
|
6209
6442
|
await this.swapMutableCaches(
|
|
6210
|
-
entryNodeModules ?? `${
|
|
6443
|
+
entryNodeModules ?? `${from}/node_modules`,
|
|
6211
6444
|
`${tree}/node_modules`,
|
|
6212
6445
|
user,
|
|
6213
6446
|
parsed.mutableListing,
|
|
6214
6447
|
"deps-mutable-swap",
|
|
6215
6448
|
);
|
|
6449
|
+
// Nested workspace node_modules the script hardlinked from the entry get
|
|
6450
|
+
// the same tool-cache swap, each scoped to its own root (mutableCachePaths
|
|
6451
|
+
// keeps only the listing under that root).
|
|
6452
|
+
if (entryNodeModules) {
|
|
6453
|
+
const entryRoot = entryNodeModules.replace(/\/node_modules$/, "");
|
|
6454
|
+
for (const rel of parsed.nested) {
|
|
6455
|
+
await this.swapMutableCaches(
|
|
6456
|
+
`${entryRoot}/${rel}`,
|
|
6457
|
+
`${tree}/${rel}`,
|
|
6458
|
+
user,
|
|
6459
|
+
parsed.mutableListing,
|
|
6460
|
+
"deps-mutable-swap",
|
|
6461
|
+
);
|
|
6462
|
+
}
|
|
6463
|
+
}
|
|
6216
6464
|
return parsed.mech;
|
|
6217
6465
|
}
|
|
6218
6466
|
|
|
@@ -6553,6 +6801,10 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6553
6801
|
timeoutMs: number,
|
|
6554
6802
|
env?: Record<string, string>,
|
|
6555
6803
|
): Promise<{ stdout: string; stderr: string; exitCode: number; truncated: boolean } | ThreadErr> {
|
|
6804
|
+
// Item 70: a new command is refused by name above the hard memory
|
|
6805
|
+
// threshold — before the preflight, so nothing of it starts.
|
|
6806
|
+
const memory = await this.memoryGate("exec");
|
|
6807
|
+
if (memory) return memory;
|
|
6556
6808
|
const pre = await this.threadPreflight(threadKey);
|
|
6557
6809
|
if ("error" in pre) return pre;
|
|
6558
6810
|
const { binding } = pre;
|
|
@@ -7568,6 +7820,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
7568
7820
|
RUNTIME_UNREACHABLE_KEY,
|
|
7569
7821
|
AUTO_REBUILDS_KEY,
|
|
7570
7822
|
INFRA_STREAK_KEY,
|
|
7823
|
+
MEMORY_KEY,
|
|
7571
7824
|
]);
|
|
7572
7825
|
const facts = map.get(FACTS_KEY) as RepoFacts | undefined;
|
|
7573
7826
|
const snap = map.get(SNAPSHOT_KEY) as SnapshotRecord | undefined;
|
|
@@ -7670,6 +7923,9 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
7670
7923
|
// Item 55: the last disk sample (`residentDiskBudget.ts` DiskSample), or
|
|
7671
7924
|
// null before the first measurement of this incarnation.
|
|
7672
7925
|
disk,
|
|
7926
|
+
// Item 70: the last memory reading (used/cap bytes, percent), or null
|
|
7927
|
+
// before the first sample of this incarnation reaches storage.
|
|
7928
|
+
memory: (map.get(MEMORY_KEY) as MemoryReading | undefined) ?? null,
|
|
7673
7929
|
// Item 64: consecutive connects the control port did not answer, with
|
|
7674
7930
|
// the rung that count is on; null while the port answers.
|
|
7675
7931
|
runtimeUnreachable: unreachable ? { ...unreachable, rung: runtimeUnreachableRung(unreachable.count) } : null,
|
|
@@ -8804,12 +9060,13 @@ async function handleStatus(env: Env, url: URL): Promise<Response> {
|
|
|
8804
9060
|
// deploy gate reads /residents. The registry check rides in the same flight
|
|
8805
9061
|
// (its 404 is judged first, the probes' results discarded then).
|
|
8806
9062
|
const stub = residentStub(env, resource.resource);
|
|
8807
|
-
const [record, status, inFlight, refresh, snapshot] = await Promise.all([
|
|
9063
|
+
const [record, status, inFlight, refresh, snapshot, memory] = await Promise.all([
|
|
8808
9064
|
registryStub(env).getRecord(resource.resource),
|
|
8809
9065
|
stub.getStatus(),
|
|
8810
9066
|
stub.getInFlightCount(),
|
|
8811
9067
|
stub.getRefreshView(),
|
|
8812
9068
|
stub.snapshotHandle(),
|
|
9069
|
+
stub.memoryGauge(),
|
|
8813
9070
|
]);
|
|
8814
9071
|
if (!record) return json({ error: `${resource.resource} is not onboarded` }, 404);
|
|
8815
9072
|
// Item 7: which scheduler drives the refresh cycle and, on the Workflow
|
|
@@ -8822,6 +9079,9 @@ async function handleStatus(env: Env, url: URL): Promise<Response> {
|
|
|
8822
9079
|
lifecycle: refresh.lifecycle,
|
|
8823
9080
|
refresh: { instance: refresh.instance, skipped: refresh.skipped },
|
|
8824
9081
|
snapshot,
|
|
9082
|
+
// Item 70: the last memory reading, so the bot and the residents page can
|
|
9083
|
+
// show what the resident's own gate is reading.
|
|
9084
|
+
memory,
|
|
8825
9085
|
});
|
|
8826
9086
|
}
|
|
8827
9087
|
|
|
@@ -9312,6 +9572,7 @@ async function runWatchdog(env: Env, parent?: TraceSpan): Promise<WatchdogSummar
|
|
|
9312
9572
|
reason: s.value.reason,
|
|
9313
9573
|
action: s.value.action,
|
|
9314
9574
|
disk: s.value.disk,
|
|
9575
|
+
memory: s.value.memory,
|
|
9315
9576
|
instance: s.value.instance,
|
|
9316
9577
|
}
|
|
9317
9578
|
: { resource: record.resource, error: errMsg(s.reason) };
|
|
@@ -207,5 +207,7 @@ RUN npm install -g --allow-scripts=@opencode/cli @opencode/cli@2.0.3 \
|
|
|
207
207
|
# stays PID 1 — it reaps orphans and forwards the platform's signals — and
|
|
208
208
|
# runs the supervisor, which starts the server again when it exits.
|
|
209
209
|
# src/deploy/sandboxRuntimeSupervisor.test.ts holds this pair of lines.
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
# The .mjs extension keeps Node reading it as ESM; its `#!/usr/bin/env node`
|
|
211
|
+
# resolves to the Node copied in above.
|
|
212
|
+
COPY --chmod=0755 runtime-supervisor.mjs /usr/local/bin/sandbox-runtime-supervisor.mjs
|
|
213
|
+
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/sandbox-runtime-supervisor.mjs"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Types for runtime-supervisor.mjs (plain-Node ESM: the sandbox image copies
|
|
2
|
+
// the file as-is, no build step). The seams exist so the tests can drive the
|
|
3
|
+
// loop with a fake child, a fake clock and a fake signal source.
|
|
4
|
+
export const DEFAULT_RUNTIME: "/container-server/sandbox";
|
|
5
|
+
export const QUICK_EXIT_SECS: 10;
|
|
6
|
+
export const QUICK_EXIT_LIMIT: 5;
|
|
7
|
+
export const RESTART_PAUSE_MS: 1000;
|
|
8
|
+
|
|
9
|
+
/** The least a spawned child must offer the loop. */
|
|
10
|
+
export type SupervisedChild = {
|
|
11
|
+
kill(signal: "SIGTERM"): unknown;
|
|
12
|
+
once(event: "exit", listener: (code: number | null, signal: string | null) => void): unknown;
|
|
13
|
+
once(event: "error", listener: (err: unknown) => void): unknown;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type SignalSource = {
|
|
17
|
+
on(event: "SIGTERM" | "SIGINT", listener: () => void): unknown;
|
|
18
|
+
removeListener(event: "SIGTERM" | "SIGINT", listener: () => void): unknown;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function endCode(code: number | null, signal: string | null): number;
|
|
22
|
+
|
|
23
|
+
export function supervise(deps: {
|
|
24
|
+
runtime: string;
|
|
25
|
+
args: string[];
|
|
26
|
+
spawn: (runtime: string, args: string[]) => SupervisedChild;
|
|
27
|
+
now: () => number;
|
|
28
|
+
sleep: (ms: number) => Promise<unknown>;
|
|
29
|
+
log: (line: string) => void;
|
|
30
|
+
signals: SignalSource;
|
|
31
|
+
}): Promise<number>;
|