@botbuddy/cli 1.32.3 → 1.33.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/package.json +1 -1
- package/src/stack-isolation.mjs +1032 -0
- package/src/stack.mjs +239 -60
- package/src/wait.mjs +24 -1
|
@@ -0,0 +1,1032 @@
|
|
|
1
|
+
// BOT-1798 — the ISOLATED stack directory a Helper-backed `bb stack run` leases.
|
|
2
|
+
//
|
|
3
|
+
// A lease's `stack_path` is where the BotBuddy Helper runs `supabase start`
|
|
4
|
+
// (`<worktree_root>/<stack_path>`). At the default `"."` that is the worktree
|
|
5
|
+
// ROOT, whose committed `supabase/config.toml` declares the SHARED canonical
|
|
6
|
+
// project (`xymjddoobrfnsmawmtbc` for botbuddy-web, `hhukfannuankqtswiqdq` for
|
|
7
|
+
// supplyspark-ent). The Helper then collides with — and clobbers the edge
|
|
8
|
+
// bind-mount of — the developer's already-running shared stack: `supabase status`
|
|
9
|
+
// reports no local API URL, provisioning fails (`invalidStatus`), and the lease
|
|
10
|
+
// cannot be reaped because its recorded teardown target IS the shared stack.
|
|
11
|
+
// Observed in production 2026-09-21: zero `stack run` leases activated in 36h.
|
|
12
|
+
//
|
|
13
|
+
// BOT-1711 closed the same hole for `stack up --local-exec` by REFUSING `"."`.
|
|
14
|
+
// `run` cannot just refuse: nothing (BotBuddy or Supply Guard) passes
|
|
15
|
+
// `--stack-path` for it. So `run` materializes its own isolated stack instead —
|
|
16
|
+
// a gitignored directory inside the worktree holding the worktree's own
|
|
17
|
+
// `supabase/` tree with a DISTINCT, ticket-derived `project_id` and a fully
|
|
18
|
+
// remapped port block. Same shape as the hermetic deno lane
|
|
19
|
+
// (`scripts/run-deno-hermetic.mjs` + `scripts/lib/hermetic-config.mjs`) and
|
|
20
|
+
// Supply Guard's `scripts/local-supabase-isolated.sh`, ported here because the
|
|
21
|
+
// published CLI cannot import repo scripts and stays Node-stdlib only.
|
|
22
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
23
|
+
import { cpSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { homedir, hostname } from "node:os";
|
|
25
|
+
import { basename, join } from "node:path";
|
|
26
|
+
import { lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
|
|
27
|
+
|
|
28
|
+
/** Where materialized leased stacks live inside the worktree (gitignored). */
|
|
29
|
+
export const LEASED_STACKS_DIR = ".botbuddy/stacks";
|
|
30
|
+
|
|
31
|
+
// Port bands already spoken for on this host (docs/docker-capacity.md → "What
|
|
32
|
+
// counts against the budget"): 41xxx/42xxx per-worktree preview + lighthouse,
|
|
33
|
+
// 43xxx–53xxx hermetic deno lane, 54xxx supplyspark-ent shared, 55xxx another
|
|
34
|
+
// tenant, 56320–56329 + 56683 the BB shared canonical stack, and 56321 + n×10
|
|
35
|
+
// (n ≤ 399, i.e. up to ~60330) Supply Guard's per-ticket blocks. A leased stack
|
|
36
|
+
// therefore gets a contiguous block above all of them, and every block stays a
|
|
37
|
+
// valid TCP port (61000 + 200×20 − 1 = 64999).
|
|
38
|
+
export const LEASED_PORT_BASE = 61000;
|
|
39
|
+
/** Ports reserved per leased stack (the repo declares 10; the rest is headroom). */
|
|
40
|
+
export const LEASED_PORT_STRIDE = 20;
|
|
41
|
+
/** Distinct blocks available. Two worktree+slot pairs CAN hash to one block; as
|
|
42
|
+
* with the hermetic/e2e ports that is a loud `supabase start` port-bind failure,
|
|
43
|
+
* never a silently shared stack. */
|
|
44
|
+
export const LEASED_PORT_SLOTS = 200;
|
|
45
|
+
|
|
46
|
+
/** How long a leased stack directory must have been untouched before another run
|
|
47
|
+
* may collect it. Comfortably above the reaper's default 1800 s idle TTL, so a
|
|
48
|
+
* batch whose owner process died but whose child/containers are still finishing is
|
|
49
|
+
* never collected out from under itself (BOT-1798, Codex #928 R5). */
|
|
50
|
+
export const LEASED_STACK_GC_MIN_AGE_MS = 2 * 60 * 60 * 1000;
|
|
51
|
+
/** Ownership + retention metadata each materialized stack carries (BOT-1798). */
|
|
52
|
+
export const LEASED_STACK_MARKER = ".bb-stack.json";
|
|
53
|
+
/** HOST-scoped port-block reservations (BOT-1798, Codex #928 R13). Worktrees are
|
|
54
|
+
* private to each other, but the ports are the machine's — a reservation visible
|
|
55
|
+
* only inside one worktree lets two worktrees bind the same block. This lives
|
|
56
|
+
* beside `~/.botbuddy/machine-id`, the existing machine-scoped state. */
|
|
57
|
+
export const LEASED_PORT_REGISTRY_DIRNAME = "leased-port-blocks";
|
|
58
|
+
/** The host-scoped mutex that serializes "reclaim a stale claim, then take a
|
|
59
|
+
* block" (BOT-1798, Codex #928 R16). Without it two runs can both read the same
|
|
60
|
+
* expired claim, and the slower one's delete wipes the winner's NEW claim. */
|
|
61
|
+
export const LEASED_ALLOCATION_LOCK = ".allocate.lock";
|
|
62
|
+
/** A held allocation lock older than this is assumed abandoned (the critical
|
|
63
|
+
* section is a few file operations). */
|
|
64
|
+
const LEASED_ALLOCATION_LOCK_STALE_MS = 60 * 1000;
|
|
65
|
+
export function defaultPortRegistryDir() {
|
|
66
|
+
return join(homedir(), ".botbuddy", LEASED_PORT_REGISTRY_DIRNAME);
|
|
67
|
+
}
|
|
68
|
+
/** Grace added to a batch's own idle TTL before its directory may be collected:
|
|
69
|
+
* the reaper stops an idle stack at the TTL, and the teardown itself takes time. */
|
|
70
|
+
const LEASED_STACK_RETENTION_GRACE_MS = 30 * 60 * 1000;
|
|
71
|
+
/** The idle TTL a lease gets when the caller names none (`bb stack up --idle-ttl`). */
|
|
72
|
+
const DEFAULT_IDLE_TTL_SECS = 1800;
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
// TOML decimal integers may carry a leading sign (`+56321`); radix forms may not.
|
|
76
|
+
// A negative value is rejected downstream by tomlIntToDecimal (BOT-1711 Codex R15).
|
|
77
|
+
const TOML_INT_PORT = "(0[xX][0-9A-Fa-f_]+|0[oO][0-7_]+|0[bB][01_]+|[+-]?[0-9][0-9_]*)";
|
|
78
|
+
|
|
79
|
+
/** Normalize any valid TOML integer literal to its decimal string: decimal (with `_`
|
|
80
|
+
* separators), or `0x`/`0o`/`0b` radix forms. Returns null for a non-integer. Without
|
|
81
|
+
* this a hex/octal port (`0xdc01` == 56321) or a separated one (`56_321`) would parse as
|
|
82
|
+
* a truncated value and bypass the port-collision checks while Supabase binds the full
|
|
83
|
+
* port (BOT-1711 Codex R11/R14). */
|
|
84
|
+
export function tomlIntToDecimal(token) {
|
|
85
|
+
const cleaned = String(token).replace(/_/g, "");
|
|
86
|
+
const n = Number(cleaned);
|
|
87
|
+
return Number.isInteger(n) && n >= 0 ? String(n) : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Every port a `config.toml` allocates, as a SECTION-QUALIFIED map `"<section>.<key>" ->
|
|
91
|
+
* "<port>"` (e.g. `api.port`, `db.shadow_port`, `inbucket.pop3_port`). Section-qualified so
|
|
92
|
+
* the SAME `port` key under [api]/[db]/[studio]/[inbucket] stays distinct, which lets a
|
|
93
|
+
* target be checked for BOTH completeness (declares every port the root does) and
|
|
94
|
+
* disjointness (shares no port value). Comments are not configuration; both the
|
|
95
|
+
* `[section]` + bare-key form and the dotted `api.port = N` form are recognized
|
|
96
|
+
* (BOT-1711 R16). */
|
|
97
|
+
export function portMapInConfig(toml) {
|
|
98
|
+
const map = new Map();
|
|
99
|
+
let section = "";
|
|
100
|
+
const bare = new RegExp(`^((?:[A-Za-z0-9]+_)?port)\\s*=\\s*${TOML_INT_PORT}`);
|
|
101
|
+
const dotted = new RegExp(`^([A-Za-z0-9_]+)\\.((?:[A-Za-z0-9]+_)?port)\\s*=\\s*${TOML_INT_PORT}`);
|
|
102
|
+
for (const raw of String(toml).split(/\r?\n/)) {
|
|
103
|
+
const line = raw.trim();
|
|
104
|
+
if (line.startsWith("#")) continue; // comments are not configuration
|
|
105
|
+
const sec = /^\[([^\]]+)\]/.exec(line);
|
|
106
|
+
if (sec) { section = sec[1].trim(); continue; }
|
|
107
|
+
const dot = dotted.exec(line);
|
|
108
|
+
if (dot) {
|
|
109
|
+
const dec = tomlIntToDecimal(dot[3]);
|
|
110
|
+
if (dec != null) map.set(`${dot[1]}.${dot[2]}`, dec);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const m = bare.exec(line);
|
|
114
|
+
if (m) {
|
|
115
|
+
const dec = tomlIntToDecimal(m[2]);
|
|
116
|
+
if (dec != null) map.set(`${section}.${m[1]}`, dec);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return map;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Lowercase-alphanumeric tail of a ticket/slot, e.g. `BOT-1798` → `bot1798`. */
|
|
123
|
+
function identityTag(ticket, slot) {
|
|
124
|
+
const source = String(ticket ?? "").trim() || String(slot ?? "").trim();
|
|
125
|
+
const cleaned = source.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
126
|
+
return cleaned.slice(-10) || "stack";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The identity of one batch's isolated stack:
|
|
131
|
+
* * `projectId` — the Docker/Supabase project name. Ticket-derived so
|
|
132
|
+
* `docker ps` ownership stays decidable (docs/docker-capacity.md rule 3),
|
|
133
|
+
* hash-suffixed so two worktrees on one ticket never share containers, and
|
|
134
|
+
* Supabase-safe (lowercase alphanumeric, leading letter, ≤20 chars).
|
|
135
|
+
* * `portBase` — the first port of this stack's contiguous block.
|
|
136
|
+
*
|
|
137
|
+
* `nonce` scopes the identity to ONE invocation (BOT-1798, Codex #928 R5). A
|
|
138
|
+
* per-slot identity was reachable by a second run as soon as the first run's
|
|
139
|
+
* PROCESS died — its detached child, containers and server lease can outlive it,
|
|
140
|
+
* and the file lock then reads as reclaimable — so the refresh could swap the
|
|
141
|
+
* `supabase/functions` tree under a batch that was still running. A run's lease
|
|
142
|
+
* is released with `destroy`, so a stable identity bought no volume reuse anyway.
|
|
143
|
+
* Omit `nonce` for a deterministic per-slot identity (tests, diagnostics).
|
|
144
|
+
*/
|
|
145
|
+
export function leasedStackIdentity({ worktreeRoot, slot, ticket, nonce } = {}) {
|
|
146
|
+
// JSON-encode the parts so no path/slot/nonce combination can alias another
|
|
147
|
+
// (an unambiguous, escape-free separator).
|
|
148
|
+
const digest = createHash("sha256").update(JSON.stringify([worktreeRoot ?? "", slot ?? "", nonce ?? ""])).digest("hex");
|
|
149
|
+
const projectId = `bb${identityTag(ticket, slot)}${digest.slice(0, 6)}`.slice(0, 20);
|
|
150
|
+
const portBase = LEASED_PORT_BASE + (parseInt(digest.slice(8, 16), 16) % LEASED_PORT_SLOTS) * LEASED_PORT_STRIDE;
|
|
151
|
+
return { projectId, portBase, dirName: projectId };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const PROJECT_ID_RE = /^[a-z][a-z0-9]{0,19}$/;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The worktree's own config with exactly THREE things changed: `project_id`,
|
|
158
|
+
* every declared host port, and the edge runtime's own public origin. Everything
|
|
159
|
+
* else — `[functions.*] verify_jwt`, `[auth.hook.*]`, every other
|
|
160
|
+
* `[edge_runtime.secrets]` env() interpolation, storage limits, comments —
|
|
161
|
+
* survives byte-for-byte, or the leased stack exercises a different contract
|
|
162
|
+
* than the shared one.
|
|
163
|
+
*
|
|
164
|
+
* Ports are assigned by first appearance into the stack's own block, so a port
|
|
165
|
+
* the config adds later is remapped automatically instead of silently falling
|
|
166
|
+
* back to Supabase's default (which would collide with every other stack).
|
|
167
|
+
*
|
|
168
|
+
* The origin pin (BOT-1798, Codex #928 R3 P1) is the leased equivalent of
|
|
169
|
+
* `resolveIsolatedStackApiUrl` on the `--local-exec` path. The committed config
|
|
170
|
+
* resolves the edge runtime's `VITE_SUPABASE_URL` through `env()` because a local
|
|
171
|
+
* `supabase start` exports the stack's Kong origin into the shell (BOT-903) — but
|
|
172
|
+
* the HELPER starts this stack with ITS environment, where that variable is
|
|
173
|
+
* absent, points at production, or points at another stack. The edge runtime
|
|
174
|
+
* would then emit foreign public URLs from inside the leased stack. The
|
|
175
|
+
* materialized config is disposable and is never used for `functions deploy`, so
|
|
176
|
+
* the safe answer is to pin the literal origin of its own remapped `[api] port`.
|
|
177
|
+
*/
|
|
178
|
+
export function rewriteLeasedStackConfig(configText, { projectId, portBase } = {}) {
|
|
179
|
+
if (!PROJECT_ID_RE.test(String(projectId ?? ""))) {
|
|
180
|
+
throw new Error(`rewriteLeasedStackConfig: projectId "${projectId}" must be lowercase alphanumeric, start with a letter, and be ≤20 characters`);
|
|
181
|
+
}
|
|
182
|
+
if (!Number.isInteger(portBase)) throw new Error("rewriteLeasedStackConfig: portBase must be an integer");
|
|
183
|
+
|
|
184
|
+
const bare = new RegExp(`^(\\s*)((?:[A-Za-z0-9]+_)?port)(\\s*=\\s*)${TOML_INT_PORT}(.*)$`);
|
|
185
|
+
const dotted = new RegExp(`^(\\s*)([A-Za-z0-9_]+\\.(?:[A-Za-z0-9]+_)?port)(\\s*=\\s*)${TOML_INT_PORT}(.*)$`);
|
|
186
|
+
const ports = new Map();
|
|
187
|
+
const assign = (key) => {
|
|
188
|
+
if (!ports.has(key)) {
|
|
189
|
+
if (ports.size >= LEASED_PORT_STRIDE) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`refusing to materialize an isolated leased stack: supabase/config.toml declares more than ${LEASED_PORT_STRIDE} host ports ` +
|
|
192
|
+
`(at "${key}") — the leased port block cannot hold them all, and reusing a port would collide inside the stack. ` +
|
|
193
|
+
"Widen LEASED_PORT_STRIDE (and the band in docs/docker-capacity.md) before adding more services.",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
ports.set(key, portBase + ports.size);
|
|
197
|
+
}
|
|
198
|
+
return ports.get(key);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
let section = "";
|
|
202
|
+
let sawProjectId = false;
|
|
203
|
+
const out = [];
|
|
204
|
+
for (const line of String(configText).split(/\r?\n/)) {
|
|
205
|
+
const trimmed = line.trim();
|
|
206
|
+
if (trimmed.startsWith("#")) { out.push(line); continue; }
|
|
207
|
+
const sec = /^\s*\[([^\]]+)\]/.exec(line);
|
|
208
|
+
if (sec) { section = sec[1].trim(); out.push(line); continue; }
|
|
209
|
+
// project_id is a root key: it precedes every section header.
|
|
210
|
+
if (!section && /^\s*project_id\s*=/.test(line)) {
|
|
211
|
+
out.push(`project_id = "${projectId}"`);
|
|
212
|
+
sawProjectId = true;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const dot = dotted.exec(line);
|
|
216
|
+
if (dot && tomlIntToDecimal(dot[4]) != null) {
|
|
217
|
+
out.push(`${dot[1]}${dot[2]}${dot[3]}${assign(dot[2])}${dot[5]}`);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const m = bare.exec(line);
|
|
221
|
+
if (m && tomlIntToDecimal(m[4]) != null) {
|
|
222
|
+
out.push(`${m[1]}${m[2]}${m[3]}${assign(`${section}.${m[2]}`)}${m[5]}`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
out.push(line);
|
|
226
|
+
}
|
|
227
|
+
// Pin the edge origin AFTER the port pass: [edge_runtime.secrets] may precede
|
|
228
|
+
// [api] in the file, so the leased api port is only known once every line is seen.
|
|
229
|
+
const apiPort = ports.get("api.port");
|
|
230
|
+
let pinned = 0;
|
|
231
|
+
let sawEdgeOrigin = false;
|
|
232
|
+
let pinSection = "";
|
|
233
|
+
for (let i = 0; i < out.length; i++) {
|
|
234
|
+
const line = out[i];
|
|
235
|
+
if (line.trim().startsWith("#")) continue;
|
|
236
|
+
const sec = /^\s*\[([^\]]+)\]/.exec(line);
|
|
237
|
+
if (sec) { pinSection = sec[1].trim(); continue; }
|
|
238
|
+
if (pinSection !== "edge_runtime.secrets") continue;
|
|
239
|
+
const m = /^(\s*)VITE_SUPABASE_URL(\s*=\s*).*$/.exec(line);
|
|
240
|
+
if (!m) continue;
|
|
241
|
+
sawEdgeOrigin = true;
|
|
242
|
+
if (apiPort === undefined) break;
|
|
243
|
+
out[i] = `${m[1]}VITE_SUPABASE_URL${m[2]}"http://127.0.0.1:${apiPort}"`;
|
|
244
|
+
pinned++;
|
|
245
|
+
}
|
|
246
|
+
if (sawEdgeOrigin && apiPort === undefined) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
"refusing to materialize an isolated leased stack: its supabase/config.toml sets the edge runtime's " +
|
|
249
|
+
"VITE_SUPABASE_URL but declares no [api] port, so the stack's own origin cannot be pinned — the Helper " +
|
|
250
|
+
"would start an edge runtime that emits another stack's (or production's) public URLs.",
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
if (!sawProjectId) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
"refusing to materialize an isolated leased stack: the worktree's supabase/config.toml declares no root project_id, " +
|
|
256
|
+
"so the leased stack cannot be given an identity distinct from the shared canonical project.",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return { text: out.join("\n"), ports, pinnedEdgeOrigin: pinned > 0 };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The literal `[edge_runtime.secrets] VITE_SUPABASE_URL` a config declares, or
|
|
263
|
+
* null when it declares none. Comments are not configuration. */
|
|
264
|
+
export function edgeOriginInConfig(toml) {
|
|
265
|
+
let section = "";
|
|
266
|
+
for (const raw of String(toml).split(/\r?\n/)) {
|
|
267
|
+
const line = raw.trim();
|
|
268
|
+
if (line.startsWith("#")) continue;
|
|
269
|
+
const sec = /^\[([^\]]+)\]/.exec(line);
|
|
270
|
+
if (sec) { section = sec[1].trim(); continue; }
|
|
271
|
+
if (section !== "edge_runtime.secrets") continue;
|
|
272
|
+
const m = /^VITE_SUPABASE_URL\s*=\s*(.*)$/.exec(line);
|
|
273
|
+
if (m) return m[1].trim().replace(/^["']|["']$/g, "");
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* FAIL CLOSED before anything is leased: prove the written config is actually
|
|
280
|
+
* isolated from the worktree root's. Mirrors the BOT-1711 invariant
|
|
281
|
+
* (`assertIsolatedLocalExecTarget`) so a materializer bug can never hand the
|
|
282
|
+
* Helper the shared canonical stack.
|
|
283
|
+
*/
|
|
284
|
+
export function assertLeasedStackIsolated(rootConfigText, stackConfigText) {
|
|
285
|
+
const rootProject = projectIdFromConfig(rootConfigText);
|
|
286
|
+
const stackProject = projectIdFromConfig(stackConfigText);
|
|
287
|
+
// FAIL CLOSED (Codex #928 R17, mirroring BOT-1711 R6 P1): with no readable root
|
|
288
|
+
// identity, isolation from the shared canonical project cannot be proven — and
|
|
289
|
+
// its containers may well be running.
|
|
290
|
+
if (!rootProject) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
"refusing to lease a stack: the worktree's supabase/config.toml declares no readable project_id, so isolation from the " +
|
|
293
|
+
"shared canonical project cannot be proven (its containers may still be running).",
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
if (!stackProject || stackProject === rootProject) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
`refusing to lease a materialized stack whose project_id ("${stackProject ?? "<none>"}") is not distinct from the worktree's ` +
|
|
299
|
+
"shared canonical project — `supabase start` there would operate the shared dev stack.",
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const rootPorts = portMapInConfig(rootConfigText);
|
|
303
|
+
const stackPorts = portMapInConfig(stackConfigText);
|
|
304
|
+
const missing = [...rootPorts.keys()].filter((k) => !stackPorts.has(k));
|
|
305
|
+
if (missing.length) {
|
|
306
|
+
throw new Error(`refusing to lease a materialized stack that omits port(s) the shared stack allocates (${missing.join(", ")}) — an omitted port falls back to Supabase's default and collides with other stacks.`);
|
|
307
|
+
}
|
|
308
|
+
const values = [...stackPorts.values()];
|
|
309
|
+
const dupes = [...new Set(values.filter((v, i) => values.indexOf(v) !== i))];
|
|
310
|
+
if (dupes.length) {
|
|
311
|
+
throw new Error(`refusing to lease a materialized stack that assigns the same port to multiple services (${dupes.join(", ")}).`);
|
|
312
|
+
}
|
|
313
|
+
const rootValues = new Set(rootPorts.values());
|
|
314
|
+
const shared = [...new Set(values)].filter((v) => rootValues.has(v));
|
|
315
|
+
if (shared.length) {
|
|
316
|
+
throw new Error(`refusing to lease a materialized stack that reuses the shared stack's port(s) ${shared.join(", ")}.`);
|
|
317
|
+
}
|
|
318
|
+
// The edge runtime must emit THIS stack's public URLs (BOT-1798, Codex #928 R9).
|
|
319
|
+
// A config that leaves `env(VITE_SUPABASE_URL)` in place — the repo default,
|
|
320
|
+
// because a local `supabase start` exports the origin into the shell — resolves
|
|
321
|
+
// against the HELPER's environment: missing, production, or another stack.
|
|
322
|
+
const edgeOrigin = edgeOriginInConfig(stackConfigText);
|
|
323
|
+
// Omitting it is not a way out either (Codex #928 R10): this repo's edge runtime
|
|
324
|
+
// is handed its public origin through that section and fails closed without it,
|
|
325
|
+
// so a leased stack must declare it whenever the worktree's own config does.
|
|
326
|
+
if (edgeOrigin === null && edgeOriginInConfig(rootConfigText) !== null) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
"refusing to lease a stack that declares no [edge_runtime.secrets] VITE_SUPABASE_URL while the worktree's config does — " +
|
|
329
|
+
"the leased edge runtime would start without the public origin it fails closed on. Declare it, pinned to that stack's own api port.",
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
if (edgeOrigin !== null) {
|
|
333
|
+
const apiPort = stackPorts.get("api.port");
|
|
334
|
+
const expected = apiPort === undefined ? null : `http://127.0.0.1:${apiPort}`;
|
|
335
|
+
if (expected === null || edgeOrigin !== expected) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`refusing to lease a stack whose edge runtime origin is "${edgeOrigin}": it must be pinned to that stack's OWN api ` +
|
|
338
|
+
`port (${expected ?? "which its config does not declare"}), or the Helper's environment decides — and the leased ` +
|
|
339
|
+
"edge runtime emits another stack's (or production's) public URLs.",
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Supabase CLI scratch state is per-stack: never copied IN from the worktree, and
|
|
346
|
+
// never pruned OUT of the leased stack (it belongs to that stack's own lifecycle).
|
|
347
|
+
const SKIP_ENTRIES = new Set([".branches", ".temp"]);
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Is a stack directory still CLAIMED by its batch? `acquireStackLock` writes its
|
|
351
|
+
* lock file once and never refreshes it (the heartbeat goes to the server), so a
|
|
352
|
+
* batch that runs for hours has an ancient lock file — its age proves nothing
|
|
353
|
+
* (BOT-1798, Codex #928 R6). Only the recorded owner's liveness does. Anything
|
|
354
|
+
* unprovable — an unreadable payload, a claim recorded by another machine — counts
|
|
355
|
+
* as claimed, because wrongly deleting a live batch's tree is far worse than
|
|
356
|
+
* leaving a directory behind.
|
|
357
|
+
*/
|
|
358
|
+
function stackDirIsClaimed(projectId, { read, alive, host, marker = null }) {
|
|
359
|
+
// The marker's own owner is a second, independent claim: lock files live in the
|
|
360
|
+
// system temp directory, which the OS may sweep while a long batch is running.
|
|
361
|
+
if (marker && Number.isInteger(marker.owner_pid) && marker.owner_pid > 0
|
|
362
|
+
&& (!marker.host || marker.host === host()) && alive(marker.owner_pid)) {
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
let raw;
|
|
366
|
+
try {
|
|
367
|
+
raw = read(lockPathForProject(projectId), "utf8");
|
|
368
|
+
} catch {
|
|
369
|
+
return false; // no lock file at all: nothing claims this directory
|
|
370
|
+
}
|
|
371
|
+
let holder;
|
|
372
|
+
try {
|
|
373
|
+
holder = JSON.parse(raw);
|
|
374
|
+
} catch {
|
|
375
|
+
return true; // a corrupt lock is not proof the batch is gone
|
|
376
|
+
}
|
|
377
|
+
if (!holder || !Number.isInteger(holder.pid) || holder.pid <= 0) return true;
|
|
378
|
+
if (holder.host && holder.host !== host()) return true; // another machine's claim
|
|
379
|
+
return alive(holder.pid);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Does this PID exist? EPERM means it exists and is simply not ours to signal. */
|
|
383
|
+
function pidIsAlive(pid) {
|
|
384
|
+
try {
|
|
385
|
+
process.kill(pid, 0);
|
|
386
|
+
return true;
|
|
387
|
+
} catch (error) {
|
|
388
|
+
return error?.code === "EPERM";
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Does `dirName` belong to THIS ticket's family of materialized stacks?
|
|
394
|
+
*
|
|
395
|
+
* A prefix test is not enough (BOT-1798, Codex #928 R7): `BOT-179` prefixes
|
|
396
|
+
* `BOT-1798`, so `bbbot1798<hash>` would read as a `BOT-179` stack and a run for
|
|
397
|
+
* the shorter ticket could collect the longer one's live directory. The name must
|
|
398
|
+
* be the family prefix followed by exactly the generated hex suffix — and when the
|
|
399
|
+
* directory carries an ownership marker, that marker is authoritative.
|
|
400
|
+
*/
|
|
401
|
+
function isOwnStackFamily(dirName, { ticket, slot, marker }) {
|
|
402
|
+
// Compare NORMALIZED ticket keys (Codex #928 R11): `bot-1798` and `BOT-1798` are
|
|
403
|
+
// the same ticket, and the slot a previous batch happened to use is not part of
|
|
404
|
+
// ownership — requiring it left full supabase/ copies permanently uncollectable.
|
|
405
|
+
if (marker && (marker.ticket != null || marker.slot != null)) {
|
|
406
|
+
return identityTag(marker.ticket, marker.slot) === identityTag(ticket, slot);
|
|
407
|
+
}
|
|
408
|
+
return new RegExp(`^bb${identityTag(ticket, slot)}[0-9a-f]{1,6}$`).test(dirName);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Collect this ticket's long-finished stack directories (BOT-1798, Codex #928 R5/R6).
|
|
413
|
+
* Per-invocation directories would otherwise accumulate, but a batch that is still
|
|
414
|
+
* running must never be collected — so a directory is removed only when it is both
|
|
415
|
+
* older than `LEASED_STACK_GC_MIN_AGE_MS` (well past the reaper's idle TTL, so a
|
|
416
|
+
* child that outlived its dead owner cannot still be using it) AND unclaimed by a
|
|
417
|
+
* live lock owner. Best-effort throughout: collection never blocks provisioning.
|
|
418
|
+
*/
|
|
419
|
+
function collectFinishedStacks(stacksDir, { keep, ticket, slot, readdir, remove, stat, now, read, alive, host }) {
|
|
420
|
+
let entries;
|
|
421
|
+
try { entries = readdir(stacksDir); } catch { return []; }
|
|
422
|
+
const collected = [];
|
|
423
|
+
for (const entry of entries) {
|
|
424
|
+
if (entry === keep) continue;
|
|
425
|
+
try {
|
|
426
|
+
const marker = readStackMarker(join(stacksDir, entry), read);
|
|
427
|
+
// A marker states how long its batch must be kept, so an EXPIRED, unclaimed
|
|
428
|
+
// stack is provably finished whoever ran it — and one-off tickets would
|
|
429
|
+
// otherwise leave full supabase/ copies behind forever (Codex #928 R12).
|
|
430
|
+
// Without a marker nothing is provable, so stay inside this ticket's family
|
|
431
|
+
// and fall back to the conservative default age.
|
|
432
|
+
const retainUntil = Number.isFinite(marker?.retain_until) ? marker.retain_until : null;
|
|
433
|
+
if (retainUntil === null && !isOwnStackFamily(entry, { ticket, slot, marker })) continue;
|
|
434
|
+
if (now() < (retainUntil ?? stat(join(stacksDir, entry)).mtimeMs + LEASED_STACK_GC_MIN_AGE_MS)) continue;
|
|
435
|
+
if (stackDirIsClaimed(entry, { read, alive, host, marker })) continue;
|
|
436
|
+
remove(join(stacksDir, entry), { recursive: true, force: true });
|
|
437
|
+
collected.push(entry);
|
|
438
|
+
} catch { /* a racing collector or a permission fault is not fatal */ }
|
|
439
|
+
}
|
|
440
|
+
return collected;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Push this stack's retention deadline forward on a lease heartbeat (BOT-1798,
|
|
445
|
+
* Codex #928 R8). Anchoring retention to creation time was not enough: a batch
|
|
446
|
+
* that outlives its deadline and THEN loses its owner process still holds a live
|
|
447
|
+
* lease, child and containers for another idle-TTL window, and a later run would
|
|
448
|
+
* have collected the tree it is bind-mounting. The deadline therefore tracks the
|
|
449
|
+
* lease's last activity, exactly as the server's reaper does.
|
|
450
|
+
*
|
|
451
|
+
* Returns false (never throws) when there is no marker to refresh — an explicitly
|
|
452
|
+
* prepared `--stack-path` is the caller's to manage, not ours to annotate.
|
|
453
|
+
*/
|
|
454
|
+
export function refreshLeasedStackRetention(stackDir, { idleTtlSecs, now = Date.now, read = readFileSync, write = writeFileSync, rename = renameSync, portRegistryDir = null } = {}) {
|
|
455
|
+
const marker = readStackMarker(stackDir, read);
|
|
456
|
+
if (!marker) return false;
|
|
457
|
+
const ttlMs = Math.max(0, Number.isFinite(idleTtlSecs) ? idleTtlSecs : DEFAULT_IDLE_TTL_SECS) * 1000;
|
|
458
|
+
const at = now();
|
|
459
|
+
const renewed = JSON.stringify({
|
|
460
|
+
...marker,
|
|
461
|
+
last_seen_at: at,
|
|
462
|
+
retain_until: at + Math.max(LEASED_STACK_GC_MIN_AGE_MS, ttlMs + LEASED_STACK_RETENTION_GRACE_MS),
|
|
463
|
+
});
|
|
464
|
+
try {
|
|
465
|
+
atomicWrite(join(stackDir, LEASED_STACK_MARKER), renewed, { write, rename });
|
|
466
|
+
} catch {
|
|
467
|
+
return false; // a best-effort extension must never fence a healthy batch
|
|
468
|
+
}
|
|
469
|
+
// Renew the host-scoped port reservation on the same beat, or another worktree
|
|
470
|
+
// would eventually read this live batch's block as free.
|
|
471
|
+
if (Number.isInteger(marker.port_base)) {
|
|
472
|
+
try {
|
|
473
|
+
// Codex #928 R23 (P2): a heartbeat can still be in flight when its own batch
|
|
474
|
+
// exits — cleanup releases (or an unrelated retry reclaims and re-reserves)
|
|
475
|
+
// this same block before the stale call reaches here. Verify the registry's
|
|
476
|
+
// CURRENT claim is still ours (by invocation, like releaseLeasedStackClaim)
|
|
477
|
+
// before overwriting it; otherwise this recreates a finished batch's claim,
|
|
478
|
+
// or worse, clobbers a different live invocation's claim with our stale
|
|
479
|
+
// metadata, making its fresh reservation look owned by our dead PID.
|
|
480
|
+
const claimPath = portClaimPath(portRegistryDir ?? defaultPortRegistryDir(), marker.port_base);
|
|
481
|
+
const owner = marker.invocation_token ?? marker.project_id;
|
|
482
|
+
let current = null;
|
|
483
|
+
try { current = JSON.parse(read(claimPath, "utf8")); } catch { current = null; }
|
|
484
|
+
if (current && (current.invocation_token ?? current.project_id) === owner) {
|
|
485
|
+
atomicWrite(claimPath, renewed, { write, rename });
|
|
486
|
+
}
|
|
487
|
+
} catch { /* the reservation is advisory; never fence a healthy batch */ }
|
|
488
|
+
}
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** This stack's ownership/retention marker, or null when it has none. */
|
|
493
|
+
function readStackMarker(stackDir, read) {
|
|
494
|
+
try {
|
|
495
|
+
const parsed = JSON.parse(read(join(stackDir, LEASED_STACK_MARKER), "utf8"));
|
|
496
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
497
|
+
} catch {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* The port blocks this worktree's other leased stacks still hold (BOT-1798, Codex
|
|
504
|
+
* #928 R12). A per-invocation nonce spreads identities but says nothing about the
|
|
505
|
+
* 200 available blocks, so two concurrent runs could hash to the SAME ports: the
|
|
506
|
+
* server lease is keyed by the ticket slot, not the block, so nothing serializes
|
|
507
|
+
* them and the second Helper fails on a bind error after provisioning starts.
|
|
508
|
+
* A block counts as taken while its stack is retained or its lock owner is alive.
|
|
509
|
+
*/
|
|
510
|
+
function occupiedPortBlocks(registryDir, { keep, readdir, read, now, alive, host, stale = null }) {
|
|
511
|
+
const taken = new Map(); // portBase -> { projectId, createdAt }
|
|
512
|
+
let entries;
|
|
513
|
+
try { entries = readdir(registryDir); } catch { return taken; }
|
|
514
|
+
for (const entry of entries) {
|
|
515
|
+
if (!entry.endsWith(".json")) continue;
|
|
516
|
+
let claim;
|
|
517
|
+
try { claim = JSON.parse(read(join(registryDir, entry), "utf8")); } catch { continue; }
|
|
518
|
+
const portBase = Number(claim?.port_base);
|
|
519
|
+
if (!Number.isInteger(portBase)) continue;
|
|
520
|
+
const retained = Number.isFinite(claim.retain_until) && now() < claim.retain_until;
|
|
521
|
+
const ownerAlive = Number.isInteger(claim.owner_pid) && claim.owner_pid > 0
|
|
522
|
+
&& (!claim.host || claim.host === host()) && alive(claim.owner_pid);
|
|
523
|
+
if (!retained && !ownerAlive) {
|
|
524
|
+
// A reservation whose batch is provably finished is not a claim — reclaim it
|
|
525
|
+
// even when it shares `keep`'s project id (Codex #928 R23): `keep` exempts a
|
|
526
|
+
// STILL-LIVE claim we already hold from looking like a peer's, not an expired
|
|
527
|
+
// one from a past, dead invocation of the same project. Checked before the
|
|
528
|
+
// `keep` exemption below, or an expired same-project claim is invisible to
|
|
529
|
+
// both `taken` and `stale` forever — never a collision, but never reclaimed
|
|
530
|
+
// either, wedging any caller that needs this EXACT block (reservePortBlocks'
|
|
531
|
+
// explicit --stack-path ports have nowhere else to go). It is NOT deleted
|
|
532
|
+
// here: removal happens under the allocation lock, so a peer's fresh claim can
|
|
533
|
+
// never be wiped by a decision made before it existed.
|
|
534
|
+
stale?.add(portBase);
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (claim.project_id === keep) continue; // a live claim we already hold: not a peer's
|
|
538
|
+
const previous = taken.get(portBase);
|
|
539
|
+
const createdAt = Number.isFinite(claim.created_at) ? claim.created_at : 0;
|
|
540
|
+
if (!previous || createdAt < previous.createdAt) taken.set(portBase, { projectId: claim.project_id ?? entry, createdAt });
|
|
541
|
+
}
|
|
542
|
+
return taken;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Overwrite a file atomically (BOT-1798, Codex #928 R15): a crash mid-write must
|
|
546
|
+
* never leave a truncated marker, because an unparseable marker downgrades a live
|
|
547
|
+
* stack to "no recorded retention" and invites the collector to delete it. */
|
|
548
|
+
function atomicWrite(path, data, { write, rename }) {
|
|
549
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
550
|
+
write(tmp, data);
|
|
551
|
+
rename(tmp, path);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Block this thread for `ms` without async: the allocation critical section is
|
|
555
|
+
* a handful of file operations, and materialization is synchronous. */
|
|
556
|
+
function sleepSync(ms) {
|
|
557
|
+
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* fall through */ }
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Run `fn` holding the host-scoped allocation lock (BOT-1798, Codex #928 R16).
|
|
562
|
+
* Taken by exclusive create; a lock whose owner is dead — or that is older than
|
|
563
|
+
* the critical section could possibly be — is stolen, so a crash never wedges the
|
|
564
|
+
* machine's port allocation.
|
|
565
|
+
*/
|
|
566
|
+
function withAllocationLock(registryDir, { writeExclusive, read, remove, now, alive, host, sleep = sleepSync }, fn) {
|
|
567
|
+
const lockPath = join(registryDir, LEASED_ALLOCATION_LOCK);
|
|
568
|
+
const guardPath = `${lockPath}.reclaim`;
|
|
569
|
+
const token = randomUUID();
|
|
570
|
+
const deadline = now() + 30_000;
|
|
571
|
+
for (;;) {
|
|
572
|
+
try {
|
|
573
|
+
writeExclusive(lockPath, JSON.stringify({ pid: process.pid, host: host(), at: now(), owner_token: token }));
|
|
574
|
+
break;
|
|
575
|
+
} catch (error) {
|
|
576
|
+
if (error?.code !== "EEXIST") throw error;
|
|
577
|
+
let raw = null;
|
|
578
|
+
try { raw = read(lockPath, "utf8"); } catch { raw = null; }
|
|
579
|
+
if (raw === null) continue; // it vanished: try to create again
|
|
580
|
+
let holder = null;
|
|
581
|
+
try { holder = JSON.parse(raw); } catch { /* corrupt: treat as abandoned */ }
|
|
582
|
+
const abandoned = !holder
|
|
583
|
+
|| !Number.isInteger(holder.pid)
|
|
584
|
+
|| (holder.host && holder.host !== host())
|
|
585
|
+
|| !alive(holder.pid)
|
|
586
|
+
|| now() - (Number(holder.at) || 0) > LEASED_ALLOCATION_LOCK_STALE_MS;
|
|
587
|
+
if (abandoned) {
|
|
588
|
+
// Serialize the STEAL itself and delete only the exact record we inspected
|
|
589
|
+
// (Codex #928 R18): two stealers could otherwise both read the abandoned
|
|
590
|
+
// lock and the slower one would delete the winner's replacement, putting
|
|
591
|
+
// both inside the critical section.
|
|
592
|
+
let guarded = false;
|
|
593
|
+
try {
|
|
594
|
+
writeExclusive(guardPath, JSON.stringify({ pid: process.pid, at: now(), owner_token: token }));
|
|
595
|
+
guarded = true;
|
|
596
|
+
} catch (guardError) {
|
|
597
|
+
if (guardError?.code !== "EEXIST") throw guardError;
|
|
598
|
+
// The guard itself can be abandoned (it is held for two file ops), but it
|
|
599
|
+
// is taken over with the same generation check as the lock (Codex #928
|
|
600
|
+
// R22): deleting it unconditionally would remove a peer's REPLACEMENT and
|
|
601
|
+
// admit two allocators at once.
|
|
602
|
+
let guardRaw = null;
|
|
603
|
+
try { guardRaw = read(guardPath, "utf8"); } catch { guardRaw = null; }
|
|
604
|
+
let guardHolder = null;
|
|
605
|
+
if (guardRaw !== null) {
|
|
606
|
+
try { guardHolder = JSON.parse(guardRaw); } catch { /* corrupt */ }
|
|
607
|
+
if (!guardHolder || now() - (Number(guardHolder.at) || 0) > LEASED_ALLOCATION_LOCK_STALE_MS) {
|
|
608
|
+
let current = null;
|
|
609
|
+
try { current = read(guardPath, "utf8"); } catch { current = null; }
|
|
610
|
+
if (current === guardRaw) {
|
|
611
|
+
try { remove(guardPath, { force: true }); } catch { /* a peer may have won */ }
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if (guarded) {
|
|
617
|
+
try {
|
|
618
|
+
let current = null;
|
|
619
|
+
try { current = read(lockPath, "utf8"); } catch { current = null; }
|
|
620
|
+
if (current === raw) {
|
|
621
|
+
try { remove(lockPath, { force: true }); } catch { /* vanished already */ }
|
|
622
|
+
}
|
|
623
|
+
} finally {
|
|
624
|
+
// Only ever unlink OUR OWN guard.
|
|
625
|
+
try {
|
|
626
|
+
const mine = JSON.parse(read(guardPath, "utf8"));
|
|
627
|
+
if (mine?.owner_token === token) remove(guardPath, { force: true });
|
|
628
|
+
} catch { /* gone, corrupt, or superseded */ }
|
|
629
|
+
}
|
|
630
|
+
continue; // we just cleared the lock ourselves: worth an immediate retry
|
|
631
|
+
}
|
|
632
|
+
// Codex #928 R23 (P2): a peer's OWN reclaim guard is live (or merely looked
|
|
633
|
+
// fresh a moment ago) — we made no progress this iteration. Fall through to
|
|
634
|
+
// the SAME deadline check + backoff as the "lock is held" case below instead
|
|
635
|
+
// of looping straight back to the top: skipping both, as this branch used to,
|
|
636
|
+
// spins at full CPU re-reading the guard until it separately goes stale,
|
|
637
|
+
// which can take up to LEASED_ALLOCATION_LOCK_STALE_MS regardless of this
|
|
638
|
+
// call's own 30s deadline.
|
|
639
|
+
}
|
|
640
|
+
if (now() > deadline) {
|
|
641
|
+
throw new Error(
|
|
642
|
+
"refusing to materialize an isolated leased stack: another run has held the host's port-allocation lock for 30s. " +
|
|
643
|
+
"Retry once that batch has started.",
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
sleep(50);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
return fn();
|
|
651
|
+
} finally {
|
|
652
|
+
// Only ever unlink OUR OWN lock: a stealer may already have replaced it.
|
|
653
|
+
try {
|
|
654
|
+
const current = JSON.parse(read(lockPath, "utf8"));
|
|
655
|
+
if (current?.owner_token === token) remove(lockPath, { force: true });
|
|
656
|
+
} catch { /* gone, corrupt, or superseded: never unlink blindly */ }
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* The host blocks a set of declared ports occupies. Blocks are `LEASED_PORT_STRIDE`
|
|
662
|
+
* wide across the WHOLE port space (Codex #928 R19): the leased band is aligned to
|
|
663
|
+
* that stride, so materialized stacks key exactly as before, and a hand-prepared
|
|
664
|
+
* stack outside the band — two of which can just as easily reuse 57000 — is
|
|
665
|
+
* reserved on the same host registry instead of silently unserialized.
|
|
666
|
+
*/
|
|
667
|
+
export function blocksForPorts(ports) {
|
|
668
|
+
const blocks = new Set();
|
|
669
|
+
for (const value of ports) {
|
|
670
|
+
const port = Number(value);
|
|
671
|
+
if (!Number.isInteger(port) || port <= 0) continue;
|
|
672
|
+
blocks.add(Math.floor(port / LEASED_PORT_STRIDE) * LEASED_PORT_STRIDE);
|
|
673
|
+
}
|
|
674
|
+
return [...blocks].sort((a, b) => a - b);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Give back the host port block a materialized batch holds, the moment its stack
|
|
679
|
+
* is provably gone (BOT-1798, Codex #928 R19). Every invocation takes a NEW
|
|
680
|
+
* identity, so holding claims for their full retention after a proven reap would
|
|
681
|
+
* let a couple of hundred successful sequential runs exhaust the machine while
|
|
682
|
+
* nothing is running. Ownership-checked and best-effort: a claim that now belongs
|
|
683
|
+
* to another batch is never unlinked. Returns true when a claim was released.
|
|
684
|
+
*/
|
|
685
|
+
export function releaseLeasedStackClaim(stackDir, { registryDir = defaultPortRegistryDir(), io = {} } = {}) {
|
|
686
|
+
const read = io.readFile ?? readFileSync;
|
|
687
|
+
const remove = io.remove ?? rmSync;
|
|
688
|
+
const marker = readStackMarker(stackDir, read);
|
|
689
|
+
if (!marker || !Number.isInteger(marker.port_base)) return false;
|
|
690
|
+
const path = portClaimPath(registryDir, marker.port_base);
|
|
691
|
+
let claim = null;
|
|
692
|
+
try { claim = JSON.parse(read(path, "utf8")); } catch { return false; }
|
|
693
|
+
const owner = marker.invocation_token ?? marker.project_id;
|
|
694
|
+
if (!claim || (claim.invocation_token ?? claim.project_id) !== owner) return false;
|
|
695
|
+
try {
|
|
696
|
+
remove(path, { force: true });
|
|
697
|
+
return true;
|
|
698
|
+
} catch {
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Reserve the blocks an EXPLICIT `--stack-path` occupies (BOT-1798, Codex #928
|
|
705
|
+
* R18). Its project lock only serializes runs that share a project id; two
|
|
706
|
+
* prepared directories with different ids but overlapping ports — or one
|
|
707
|
+
* overlapping a materialized stack — would still collide on the host. Returns a
|
|
708
|
+
* `release()`; throws (code `stack_ports_busy`) when a live peer holds a block.
|
|
709
|
+
*/
|
|
710
|
+
export function reservePortBlocks(ports, { registryDir = defaultPortRegistryDir(), owner, io = {} } = {}) {
|
|
711
|
+
const read = io.readFile ?? readFileSync;
|
|
712
|
+
const write = io.writeFile ?? writeFileSync;
|
|
713
|
+
const mkdir = io.mkdir ?? mkdirSync;
|
|
714
|
+
const readdir = io.readdir ?? readdirSync;
|
|
715
|
+
const remove = io.remove ?? rmSync;
|
|
716
|
+
const now = io.now ?? Date.now;
|
|
717
|
+
const alive = io.alive ?? pidIsAlive;
|
|
718
|
+
const host = io.hostname ?? hostname;
|
|
719
|
+
const sleep = io.sleep ?? sleepSync;
|
|
720
|
+
const writeExclusive = io.writeExclusive ?? ((path, data) => writeFileSync(path, data, { flag: "wx" }));
|
|
721
|
+
const blocks = blocksForPorts(ports);
|
|
722
|
+
// Identity is not ownership (Codex #928 R21): a RETRY of a prepared path reuses
|
|
723
|
+
// its project id, and a claim retained because the previous attempt's reap was
|
|
724
|
+
// unproven must not be overwritten by it. Claims therefore carry a token unique
|
|
725
|
+
// to one invocation.
|
|
726
|
+
const invocationToken = owner?.invocationToken ?? randomUUID();
|
|
727
|
+
if (blocks.length === 0) return { blocks: [], invocationToken, release: () => {}, renew: () => false };
|
|
728
|
+
|
|
729
|
+
mkdir(registryDir, { recursive: true });
|
|
730
|
+
const claimed = [];
|
|
731
|
+
const record = (block, at = now(), ttlSecs = owner?.idleTtlSecs) => JSON.stringify({
|
|
732
|
+
schema: 1,
|
|
733
|
+
project_id: owner?.projectId ?? null,
|
|
734
|
+
invocation_token: invocationToken,
|
|
735
|
+
ticket: owner?.ticket == null ? null : String(owner.ticket),
|
|
736
|
+
slot: owner?.slot == null ? null : String(owner.slot),
|
|
737
|
+
stack_dir: owner?.stackDir ?? null,
|
|
738
|
+
port_base: block,
|
|
739
|
+
created_at: at,
|
|
740
|
+
retain_until: at + Math.max(LEASED_STACK_GC_MIN_AGE_MS, (Number(ttlSecs) || DEFAULT_IDLE_TTL_SECS) * 1000 + LEASED_STACK_RETENTION_GRACE_MS),
|
|
741
|
+
owner_pid: process.pid,
|
|
742
|
+
host: host(),
|
|
743
|
+
});
|
|
744
|
+
try {
|
|
745
|
+
withAllocationLock(registryDir, { writeExclusive, read, remove, now, alive, host, sleep }, () => {
|
|
746
|
+
const stale = new Set();
|
|
747
|
+
const taken = occupiedPortBlocks(registryDir, { keep: owner?.projectId ?? null, readdir, read, now, alive, host, stale });
|
|
748
|
+
for (const block of stale) {
|
|
749
|
+
try { remove(portClaimPath(registryDir, block), { force: true }); } catch { /* best effort */ }
|
|
750
|
+
}
|
|
751
|
+
for (const block of blocks) {
|
|
752
|
+
if (taken.has(block)) {
|
|
753
|
+
const error = new Error(
|
|
754
|
+
`refusing to lease this stack: its port block ${block}-${block + LEASED_PORT_STRIDE - 1} is already reserved by ` +
|
|
755
|
+
`another live batch (${taken.get(block).projectId}). Wait for it to finish, or prepare the stack on a free block.`,
|
|
756
|
+
);
|
|
757
|
+
error.code = "stack_ports_busy";
|
|
758
|
+
throw error;
|
|
759
|
+
}
|
|
760
|
+
try {
|
|
761
|
+
writeExclusive(portClaimPath(registryDir, block), record(block));
|
|
762
|
+
} catch (error) {
|
|
763
|
+
if (error?.code !== "EEXIST") throw error;
|
|
764
|
+
// Only THIS invocation's own reservation is not a peer.
|
|
765
|
+
let existing = null;
|
|
766
|
+
try { existing = JSON.parse(read(portClaimPath(registryDir, block), "utf8")); } catch { /* unreadable */ }
|
|
767
|
+
if (existing && existing.invocation_token === invocationToken) write(portClaimPath(registryDir, block), record(block));
|
|
768
|
+
else {
|
|
769
|
+
const busy = new Error(`refusing to lease this stack: port block ${block} was claimed by another run.`);
|
|
770
|
+
busy.code = "stack_ports_busy";
|
|
771
|
+
throw busy;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
claimed.push(block);
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
} catch (error) {
|
|
778
|
+
for (const block of claimed) {
|
|
779
|
+
try { remove(portClaimPath(registryDir, block), { force: true }); } catch { /* best effort */ }
|
|
780
|
+
}
|
|
781
|
+
throw error;
|
|
782
|
+
}
|
|
783
|
+
return {
|
|
784
|
+
blocks: claimed,
|
|
785
|
+
invocationToken,
|
|
786
|
+
/** Keep the reservation alive while the batch runs (Codex #928 R21): a
|
|
787
|
+
* prepared `--stack-path` has no directory marker, so nothing else renews it,
|
|
788
|
+
* and an aged-out claim could be reclaimed under a still-live stack. */
|
|
789
|
+
renew: ({ idleTtlSecs, now: at = now } = {}) => {
|
|
790
|
+
let renewed = false;
|
|
791
|
+
for (const block of claimed) {
|
|
792
|
+
try {
|
|
793
|
+
const current = JSON.parse(read(portClaimPath(registryDir, block), "utf8"));
|
|
794
|
+
if (current?.invocation_token !== invocationToken) continue;
|
|
795
|
+
write(portClaimPath(registryDir, block), record(block, at(), idleTtlSecs ?? owner?.idleTtlSecs));
|
|
796
|
+
renewed = true;
|
|
797
|
+
} catch { /* advisory: never fence a healthy batch */ }
|
|
798
|
+
}
|
|
799
|
+
return renewed;
|
|
800
|
+
},
|
|
801
|
+
release: () => {
|
|
802
|
+
for (const block of claimed) {
|
|
803
|
+
try {
|
|
804
|
+
const current = JSON.parse(read(portClaimPath(registryDir, block), "utf8"));
|
|
805
|
+
if (current?.invocation_token === invocationToken) remove(portClaimPath(registryDir, block), { force: true });
|
|
806
|
+
} catch { /* gone or superseded: never unlink blindly */ }
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** The host-scoped reservation file for a block. */
|
|
813
|
+
function portClaimPath(registryDir, portBase) {
|
|
814
|
+
return join(registryDir, `${portBase}.json`);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** The first free block at or after the identity's own hash-derived one. */
|
|
818
|
+
function selectPortBase(preferred, taken) {
|
|
819
|
+
const start = Math.floor((preferred - LEASED_PORT_BASE) / LEASED_PORT_STRIDE);
|
|
820
|
+
for (let i = 0; i < LEASED_PORT_SLOTS; i++) {
|
|
821
|
+
const candidate = LEASED_PORT_BASE + ((start + i) % LEASED_PORT_SLOTS) * LEASED_PORT_STRIDE;
|
|
822
|
+
if (!taken.has(candidate)) return candidate;
|
|
823
|
+
}
|
|
824
|
+
throw new Error(
|
|
825
|
+
`refusing to materialize an isolated leased stack: all ${LEASED_PORT_SLOTS} leased port blocks ` +
|
|
826
|
+
`(${LEASED_PORT_BASE}-${LEASED_PORT_BASE + LEASED_PORT_SLOTS * LEASED_PORT_STRIDE - 1}) are held by live or retained stacks. ` +
|
|
827
|
+
"Finish or reap a batch before starting another.",
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Refuse a materialization target that is not literally inside the worktree
|
|
833
|
+
* (BOT-1798, Codex #928 R4 P1). The refresh prunes and copies BEFORE
|
|
834
|
+
* `resolveStackPath` could notice an escape, so a stale or hostile symlink at
|
|
835
|
+
* `.botbuddy/`, `.botbuddy/stacks/`, `<project_id>/` or its `supabase/` would
|
|
836
|
+
* make `prune()` delete data OUTSIDE the worktree. Every existing component of
|
|
837
|
+
* the path must therefore resolve to itself; the first one that does not is
|
|
838
|
+
* fatal, and a component that does not exist yet ends the walk (nothing below it
|
|
839
|
+
* can exist either). `worktreeRoot` is already canonical (realpath of the cwd).
|
|
840
|
+
*/
|
|
841
|
+
function assertCanonicalTarget(worktreeRoot, relPath, realpath) {
|
|
842
|
+
let expected = worktreeRoot;
|
|
843
|
+
for (const part of relPath.split("/")) {
|
|
844
|
+
expected = join(expected, part);
|
|
845
|
+
let resolved;
|
|
846
|
+
try {
|
|
847
|
+
resolved = realpath(expected);
|
|
848
|
+
} catch (error) {
|
|
849
|
+
if (error?.code === "ENOENT") return; // not created yet
|
|
850
|
+
throw error;
|
|
851
|
+
}
|
|
852
|
+
if (resolved !== expected) {
|
|
853
|
+
throw new Error(
|
|
854
|
+
`refusing to materialize an isolated leased stack: ${expected} is a symlink (it resolves to ${resolved}), so refreshing ` +
|
|
855
|
+
"the stack would delete and rewrite files outside this worktree. Remove that link and re-run.",
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** Delete everything the refresh is about to replace, so a file removed from the
|
|
862
|
+
* worktree cannot linger in the leased stack (and be applied by `supabase start`).
|
|
863
|
+
* The stack's own CLI scratch state (SKIP_ENTRIES) is left alone. A not-yet-created
|
|
864
|
+
* directory is simply nothing to prune. */
|
|
865
|
+
function prune(stackSupabaseDir, { readdir, remove }) {
|
|
866
|
+
let entries;
|
|
867
|
+
try { entries = readdir(stackSupabaseDir); } catch { return; }
|
|
868
|
+
for (const entry of entries) {
|
|
869
|
+
if (SKIP_ENTRIES.has(entry)) continue;
|
|
870
|
+
remove(join(stackSupabaseDir, entry), { recursive: true, force: true });
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Materialize (or refresh) the isolated stack directory for this run and return
|
|
876
|
+
* its path RELATIVE to the worktree root — never `"."`. The directory belongs to
|
|
877
|
+
* ONE invocation (see `nonce` on `leasedStackIdentity`), so a concurrent or
|
|
878
|
+
* crashed batch's tree is never rewritten; this ticket's long-finished
|
|
879
|
+
* directories are collected on the way past.
|
|
880
|
+
*
|
|
881
|
+
* The worktree's whole `supabase/` tree is copied, not symlinked: the Helper's
|
|
882
|
+
* edge-runtime container bind-mounts `<stack_path>/supabase/functions`, and a
|
|
883
|
+
* symlink that resolves on the host does not resolve inside Docker (the same
|
|
884
|
+
* reason Supply Guard's isolated-stack script materializes function sources).
|
|
885
|
+
* Copying every run also keeps the leased stack in step with the worktree — and
|
|
886
|
+
* the copy is a REPLACE (rsync --delete semantics, like the hermetic lane), so a
|
|
887
|
+
* migration or function deleted in the worktree is not resurrected by the leased
|
|
888
|
+
* stack. Only the Supabase CLI's own scratch state survives the refresh.
|
|
889
|
+
*/
|
|
890
|
+
export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs } = {}, io = {}) {
|
|
891
|
+
const read = io.readFile ?? readFileSync;
|
|
892
|
+
const write = io.writeFile ?? writeFileSync;
|
|
893
|
+
const mkdir = io.mkdir ?? mkdirSync;
|
|
894
|
+
const copy = io.copy ?? cpSync;
|
|
895
|
+
const readdir = io.readdir ?? readdirSync;
|
|
896
|
+
const remove = io.remove ?? rmSync;
|
|
897
|
+
const stat = io.stat ?? statSync;
|
|
898
|
+
const now = io.now ?? Date.now;
|
|
899
|
+
const rename = io.rename ?? renameSync;
|
|
900
|
+
const resolveRoot = io.realpath ?? realpathSync;
|
|
901
|
+
|
|
902
|
+
const worktreeRoot = resolveRoot(cwd);
|
|
903
|
+
const sourceDir = join(worktreeRoot, "supabase");
|
|
904
|
+
const configPath = join(sourceDir, "config.toml");
|
|
905
|
+
let rootConfig;
|
|
906
|
+
try {
|
|
907
|
+
rootConfig = read(configPath, "utf8");
|
|
908
|
+
} catch (error) {
|
|
909
|
+
throw new Error(
|
|
910
|
+
`cannot materialize an isolated leased stack: ${configPath} is missing or unreadable (${error?.code ?? error?.message ?? error}). ` +
|
|
911
|
+
"`bb stack run` derives its own stack from the worktree's supabase/config.toml; it never leases the worktree root, " +
|
|
912
|
+
"because the Helper would start the SHARED canonical project there.",
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const alive = io.alive ?? pidIsAlive;
|
|
917
|
+
const host = io.hostname ?? hostname;
|
|
918
|
+
const sleep = io.sleep ?? sleepSync;
|
|
919
|
+
const identity = leasedStackIdentity({ worktreeRoot, slot, ticket, nonce });
|
|
920
|
+
const stackPath = `${LEASED_STACKS_DIR}/${identity.dirName}`;
|
|
921
|
+
const stackDir = join(worktreeRoot, stackPath);
|
|
922
|
+
const stacksDir = join(worktreeRoot, LEASED_STACKS_DIR);
|
|
923
|
+
|
|
924
|
+
collectFinishedStacks(stacksDir, {
|
|
925
|
+
keep: identity.dirName, ticket, slot, readdir, remove, stat, now, read, alive, host,
|
|
926
|
+
});
|
|
927
|
+
// Prove the target is really inside this worktree BEFORE pruning or copying.
|
|
928
|
+
assertCanonicalTarget(worktreeRoot, `${stackPath}/supabase`, resolveRoot);
|
|
929
|
+
let dirExisted = true;
|
|
930
|
+
try { stat(stackDir); } catch { dirExisted = false; }
|
|
931
|
+
mkdir(stackDir, { recursive: true });
|
|
932
|
+
assertCanonicalTarget(worktreeRoot, `${stackPath}/supabase`, resolveRoot);
|
|
933
|
+
|
|
934
|
+
// Claim a port block no live sibling holds, then re-scan: another run can pick
|
|
935
|
+
// the same free block at the same instant, and the tie-break moves exactly one
|
|
936
|
+
// of us. The claim is published as a marker before the copy, so a concurrent
|
|
937
|
+
// scanner can see it.
|
|
938
|
+
const createdAt = now();
|
|
939
|
+
const ttlMs = Math.max(0, Number.isFinite(idleTtlSecs) ? idleTtlSecs : DEFAULT_IDLE_TTL_SECS) * 1000;
|
|
940
|
+
// With a nonce the token IS the invocation; without one the identity is the
|
|
941
|
+
// stable per-slot one, so the project id is exactly as specific (and keeps a
|
|
942
|
+
// refresh idempotent).
|
|
943
|
+
const invocationToken = nonce ? String(nonce) : identity.projectId;
|
|
944
|
+
const marker = (portBase) => JSON.stringify({
|
|
945
|
+
schema: 1,
|
|
946
|
+
project_id: identity.projectId,
|
|
947
|
+
invocation_token: invocationToken,
|
|
948
|
+
ticket: ticket == null ? null : String(ticket),
|
|
949
|
+
slot: slot == null ? null : String(slot),
|
|
950
|
+
port_base: portBase,
|
|
951
|
+
stack_dir: stackDir,
|
|
952
|
+
created_at: createdAt,
|
|
953
|
+
retain_until: createdAt + Math.max(LEASED_STACK_GC_MIN_AGE_MS, ttlMs + LEASED_STACK_RETENTION_GRACE_MS),
|
|
954
|
+
owner_pid: process.pid,
|
|
955
|
+
host: host(),
|
|
956
|
+
});
|
|
957
|
+
const registryDir = io.portRegistryDir ?? defaultPortRegistryDir();
|
|
958
|
+
const writeExclusive = io.writeExclusive ?? ((path, data) => writeFileSync(path, data, { flag: "wx" }));
|
|
959
|
+
mkdir(registryDir, { recursive: true });
|
|
960
|
+
// Claim the block by EXCLUSIVE CREATE (Codex #928 R14) inside the host's
|
|
961
|
+
// allocation lock (Codex #928 R16): the scan only REPORTS stale reservations, and
|
|
962
|
+
// reclaiming one plus taking the block happen as one serialized step, so a
|
|
963
|
+
// slower peer can never delete the winner's fresh claim.
|
|
964
|
+
const portBase = withAllocationLock(registryDir, { writeExclusive, read, remove, now, alive, host, sleep }, () => {
|
|
965
|
+
let preferred = identity.portBase;
|
|
966
|
+
for (let attempt = 0; attempt < LEASED_PORT_SLOTS; attempt++) {
|
|
967
|
+
const stale = new Set();
|
|
968
|
+
const taken = occupiedPortBlocks(registryDir, { keep: identity.projectId, readdir, read, now, alive, host, stale });
|
|
969
|
+
// Every claim creation happens under this lock, so a reservation that read as
|
|
970
|
+
// finished a moment ago cannot have become live since: sweeping them here
|
|
971
|
+
// releases the machine's blocks without ever racing a peer's fresh claim.
|
|
972
|
+
for (const block of stale) {
|
|
973
|
+
try { remove(portClaimPath(registryDir, block), { force: true }); } catch { /* best effort */ }
|
|
974
|
+
}
|
|
975
|
+
const candidate = selectPortBase(preferred, taken);
|
|
976
|
+
try {
|
|
977
|
+
writeExclusive(portClaimPath(registryDir, candidate), marker(candidate));
|
|
978
|
+
return candidate;
|
|
979
|
+
} catch (error) {
|
|
980
|
+
if (error?.code !== "EEXIST") throw error;
|
|
981
|
+
// Our OWN previous claim (a re-materialization of this same batch) is not a
|
|
982
|
+
// peer: take it back rather than drifting onto a new block every refresh.
|
|
983
|
+
let existing = null;
|
|
984
|
+
try { existing = JSON.parse(read(portClaimPath(registryDir, candidate), "utf8")); } catch { /* unreadable: treat as a peer */ }
|
|
985
|
+
// Ownership is the INVOCATION, not the project id (Codex #928 R22): two
|
|
986
|
+
// nonces can in principle truncate to the same project suffix, and the
|
|
987
|
+
// earlier run's Helper may still be using that stack.
|
|
988
|
+
if (existing?.invocation_token === invocationToken) {
|
|
989
|
+
write(portClaimPath(registryDir, candidate), marker(candidate));
|
|
990
|
+
return candidate;
|
|
991
|
+
}
|
|
992
|
+
preferred = candidate + LEASED_PORT_STRIDE; // a peer won this one
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return null;
|
|
996
|
+
});
|
|
997
|
+
if (portBase === null) {
|
|
998
|
+
throw new Error(
|
|
999
|
+
`refusing to materialize an isolated leased stack: every leased port block was claimed by another run while this one ` +
|
|
1000
|
+
"was choosing. Retry once the host has fewer concurrent batches.",
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
// From here on the block is RESERVED: any failure must give it back, or a
|
|
1004
|
+
// retrying start would take another one and eventually exhaust the host
|
|
1005
|
+
// (Codex #928 R15). The half-built directory goes too, unless it predates us.
|
|
1006
|
+
try {
|
|
1007
|
+
atomicWrite(join(stackDir, LEASED_STACK_MARKER), marker(portBase), { write, rename });
|
|
1008
|
+
|
|
1009
|
+
const { text } = rewriteLeasedStackConfig(rootConfig, { projectId: identity.projectId, portBase });
|
|
1010
|
+
assertLeasedStackIsolated(rootConfig, text);
|
|
1011
|
+
|
|
1012
|
+
prune(join(stackDir, "supabase"), { readdir, remove });
|
|
1013
|
+
copy(sourceDir, join(stackDir, "supabase"), {
|
|
1014
|
+
recursive: true,
|
|
1015
|
+
force: true,
|
|
1016
|
+
filter: (source) => !SKIP_ENTRIES.has(basename(source)),
|
|
1017
|
+
});
|
|
1018
|
+
write(join(stackDir, "supabase", "config.toml"), text);
|
|
1019
|
+
// Re-state the claim after the copy: the marker records who owns this directory,
|
|
1020
|
+
// which port block it holds, and how long a later run must keep it (Codex #928
|
|
1021
|
+
// R7/R12). It lives beside `supabase/`, which the refresh replaces wholesale.
|
|
1022
|
+
atomicWrite(join(stackDir, LEASED_STACK_MARKER), marker(portBase), { write, rename });
|
|
1023
|
+
atomicWrite(portClaimPath(registryDir, portBase), marker(portBase), { write, rename });
|
|
1024
|
+
return stackPath;
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
try { remove(portClaimPath(registryDir, portBase), { force: true }); } catch { /* best effort */ }
|
|
1027
|
+
if (!dirExisted) {
|
|
1028
|
+
try { remove(stackDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
1029
|
+
}
|
|
1030
|
+
throw error;
|
|
1031
|
+
}
|
|
1032
|
+
}
|