@parcha/pi-durable-disk 0.0.0-stage → 0.1.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +329 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +2 -0
  4. package/README.md +695 -2
  5. package/bin/archil-scoped +165 -0
  6. package/dist/app.d.ts +40 -0
  7. package/dist/app.js +47 -0
  8. package/dist/claim.d.ts +222 -0
  9. package/dist/claim.js +722 -0
  10. package/dist/cli.d.ts +64 -0
  11. package/dist/cli.js +664 -0
  12. package/dist/commands.d.ts +67 -0
  13. package/dist/commands.js +299 -0
  14. package/dist/confine.d.ts +91 -0
  15. package/dist/confine.js +341 -0
  16. package/dist/env.d.ts +129 -0
  17. package/dist/env.js +608 -0
  18. package/dist/errors.d.ts +47 -0
  19. package/dist/errors.js +55 -0
  20. package/dist/fork.d.ts +41 -0
  21. package/dist/fork.js +181 -0
  22. package/dist/hosts/daytona-launch.d.ts +52 -0
  23. package/dist/hosts/daytona-launch.js +270 -0
  24. package/dist/hosts/daytona.d.ts +147 -0
  25. package/dist/hosts/daytona.js +368 -0
  26. package/dist/hosts/docker.d.ts +103 -0
  27. package/dist/hosts/docker.js +362 -0
  28. package/dist/hosts/local-host.d.ts +71 -0
  29. package/dist/hosts/local-host.js +356 -0
  30. package/dist/index.d.ts +16 -0
  31. package/dist/index.js +17 -0
  32. package/dist/lease.d.ts +7 -0
  33. package/dist/lease.js +6 -0
  34. package/dist/local-run.d.ts +10 -0
  35. package/dist/local-run.js +63 -0
  36. package/dist/park.d.ts +92 -0
  37. package/dist/park.js +229 -0
  38. package/dist/run.d.ts +290 -0
  39. package/dist/run.js +785 -0
  40. package/dist/serve.d.ts +101 -0
  41. package/dist/serve.js +393 -0
  42. package/dist/status.d.ts +123 -0
  43. package/dist/status.js +264 -0
  44. package/dist/store.d.ts +103 -0
  45. package/dist/store.js +217 -0
  46. package/dist/supervise.d.ts +340 -0
  47. package/dist/supervise.js +528 -0
  48. package/dist/watchdog.d.ts +39 -0
  49. package/dist/watchdog.js +137 -0
  50. package/package.json +87 -4
package/dist/errors.js ADDED
@@ -0,0 +1,55 @@
1
+ // Typed errors and exit codes shared by every module. An instance that loses its run exits 75 (fenced) or 76 (held by
2
+ // another); both are terminal for the host's process supervisor, which must not restart into a run it lost.
3
+ // A module's own errors extend FencedError or HeldError (overriding `code`) so one `instanceof` or `exitCodeFor` covers
4
+ // them all, e.g. `class StoreFencedError extends FencedError` with `{ code: "STORE_FENCED" }`.
5
+ /** EX_TEMPFAIL: the claim was revoked or its mount failed; nothing this process writes can become durable. */
6
+ export const EXIT_FENCED = 75;
7
+ /**
8
+ * Someone else holds the run: another client's delegation, another process's owner lock, another store connection. A
9
+ * held delegation is live or orphaned (its client gone); the supervisor revokes orphans.
10
+ */
11
+ export const EXIT_HELD = 76;
12
+ export class PdaError extends Error {
13
+ code;
14
+ exitCode;
15
+ constructor(code, message, options = {}) {
16
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
17
+ this.name = new.target.name;
18
+ this.code = code;
19
+ this.exitCode = options.exitCode ?? 1;
20
+ }
21
+ }
22
+ /** The claim is lost or its mount failed. Never retried on the same handle; the process exits 75. */
23
+ export class FencedError extends PdaError {
24
+ constructor(message, options = {}) {
25
+ super(options.code ?? "FENCED", message, { cause: options.cause, exitCode: EXIT_FENCED });
26
+ }
27
+ }
28
+ const HELD_CODES = { claim: "CLAIM_HELD", "owner-lock": "OWNER_LOCK_HELD", store: "STORE_BUSY", commands: "COMMANDS_ALIVE" };
29
+ /** Someone else holds the run (see `Holder`). The process exits 76. */
30
+ export class HeldError extends PdaError {
31
+ holder;
32
+ constructor(holder, message, options = {}) {
33
+ super(options.code ?? HELD_CODES[holder], message, { cause: options.cause, exitCode: EXIT_HELD });
34
+ this.holder = holder;
35
+ }
36
+ }
37
+ /** Any other claim failure (bad input, a refused or unverifiable mount, a CLI or control API failure). Exit 1. */
38
+ export class ClaimError extends PdaError {
39
+ constructor(code, message, options = {}) {
40
+ super(code, message, options);
41
+ }
42
+ }
43
+ export function exitCodeFor(err) {
44
+ return err instanceof PdaError ? err.exitCode : 1;
45
+ }
46
+ /**
47
+ * Any error writing a file the claim owns (its probe, `run.json`, `owner.lock`, the run directory itself) is a fence. A
48
+ * revoked mount answers such writes with EIO, EROFS, ENOENT, SQLITE_CANTOPEN or a success into its local cache,
49
+ * so no errno is evidence that the claim is still held. Taking the owner lock is not a write: a lock
50
+ * another process holds is `HeldError("owner-lock")`.
51
+ */
52
+ export function ownWriteFenced(what, cause) {
53
+ const reason = cause instanceof Error ? cause.message : String(cause);
54
+ return new FencedError(`writing ${what} failed, so the claim cannot be trusted: ${reason}`, { cause });
55
+ }
package/dist/fork.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { type AcquireOptions, type ArchilHost, type Claim, type RunRef } from "./claim.js";
2
+ import { PdaError } from "./errors.js";
3
+ import { type CheckControl, type CheckOptions, type SupervisorControl } from "./supervise.js";
4
+ export type ForkErrorCode = "INVALID_ARGUMENT" | "SOURCE_NOT_RELEASED" | "SOURCE_HELD" | "TARGET_EXISTS" | "COPY_FAILED";
5
+ export declare class ForkError extends PdaError {
6
+ constructor(code: ForkErrorCode, message: string, options?: {
7
+ cause?: unknown;
8
+ });
9
+ }
10
+ export interface ForkOptions {
11
+ readonly control: CheckControl & Pick<SupervisorControl, "getObject" | "headObject">;
12
+ /** The copy's two mounts go under `<mountRoot>/.fork-<stamp>/{source,target}`. */
13
+ readonly mountRoot: string;
14
+ readonly host?: ArchilHost;
15
+ /** Prefix of the two short-lived token users' nicknames. Default "pda-". */
16
+ readonly tokenPrefix?: string;
17
+ readonly controlTimeoutMs?: number;
18
+ readonly onResource?: CheckOptions["onResource"];
19
+ /** Replaceable for tests: how each side is mounted (default `acquire`). */
20
+ readonly acquire?: (options: AcquireOptions) => Promise<Claim>;
21
+ }
22
+ export type ForkResult = {
23
+ readonly run: string;
24
+ readonly from: string;
25
+ readonly sealedSeq: number;
26
+ readonly sourceGeneration: number;
27
+ readonly files: number;
28
+ readonly bytes: number;
29
+ /** Owners are preserved only when the caller is root; otherwise every copied entry belongs to the caller. */
30
+ readonly owners: "preserved" | "caller";
31
+ readonly ms: number;
32
+ };
33
+ /**
34
+ * Fork run `ref` into run `newId`. The source must be released and sealed (run.json paused, sleeping, done
35
+ * or failed with a `sealedSeq`, and no delegation); `newId` must not exist. A fork owns the new run's directory only
36
+ * while it holds that directory's exclusive mount. A fork that fails after that empties the directory through its own
37
+ * mount and then removes the empty directory, so a half copy never looks like a run. One that never held it (another
38
+ * fork or a start mounted it first) touches nothing there; it may leave the empty directory it created. Every token
39
+ * and mount of its own is removed either way.
40
+ */
41
+ export declare function fork(ref: RunRef, newId: string, options: ForkOptions): Promise<ForkResult>;
package/dist/fork.js ADDED
@@ -0,0 +1,181 @@
1
+ // Fork: copy a released, sealed run into a new run directory. S3 CopyObject is not supported, so both runs
2
+ // are mounted exclusively for the copy, which also keeps either from starting meanwhile; the mounts go under a mount
3
+ // root of their own, never where an instance mounts. The source is only read (its claim probe aside, which every mount
4
+ // writes). The new run's run.json starts at generation 0 and carries the source's seal: its first open is generation 1
5
+ // and refuses a copy that lost a committed sequence (STORE_BEHIND_SEAL). Supervisor side: it needs the API key.
6
+ import { chmod, copyFile, lchown, lstat, mkdir, readdir, readlink, rm, rmdir, symlink, utimes } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { acquire, createRunDir, findDelegations, mintMountToken, removeMountToken, runPath } from "./claim.js";
9
+ import { HeldError, PdaError } from "./errors.js";
10
+ import { persistRecord, readRunRecord, RUN_JSON, RUN_JSON_TEMP } from "./status.js";
11
+ import { CONTROL_TIMEOUT_MS, readRunStatus, START_MARK, withTimeouts } from "./supervise.js";
12
+ import { OWNER_LOCK } from "./run.js";
13
+ /** A run is released, and so can be forked, in these states once its release sealed it. */
14
+ const RELEASED = ["paused", "sleeping", "done", "failed"];
15
+ /**
16
+ * Not copied: the run's own lifecycle files (the fork writes its own run.json), the supervisor's start mark (copied, it
17
+ * would hold the new run in a start grace for a start that never happened), and the scratch directory.
18
+ */
19
+ const SKIP = new Set([RUN_JSON, RUN_JSON_TEMP, OWNER_LOCK, `${OWNER_LOCK}-journal`, ".claim", START_MARK, "tmp"]);
20
+ export class ForkError extends PdaError {
21
+ constructor(code, message, options = {}) {
22
+ super(code, message, options);
23
+ }
24
+ }
25
+ /** Copy `from` into `to` entry by entry: modes, times and symlinks kept, owners when root. */
26
+ async function copyTree(from, to, top, asRoot, count) {
27
+ for (const entry of await readdir(from, { withFileTypes: true })) {
28
+ if (top && SKIP.has(entry.name))
29
+ continue;
30
+ const src = join(from, entry.name);
31
+ const dst = join(to, entry.name);
32
+ const st = await lstat(src);
33
+ if (st.isDirectory()) {
34
+ await mkdir(dst, { mode: st.mode & 0o7777 });
35
+ await copyTree(src, dst, false, asRoot, count);
36
+ }
37
+ else if (st.isSymbolicLink()) {
38
+ await symlink(await readlink(src), dst);
39
+ }
40
+ else if (st.isFile()) {
41
+ await copyFile(src, dst);
42
+ count.files++;
43
+ count.bytes += st.size;
44
+ }
45
+ else {
46
+ continue;
47
+ }
48
+ if (asRoot)
49
+ await lchown(dst, st.uid, st.gid);
50
+ if (st.isSymbolicLink())
51
+ continue;
52
+ await chmod(dst, st.mode & 0o7777);
53
+ await utimes(dst, st.atime, st.mtime);
54
+ }
55
+ }
56
+ /** Remove every entry under `root`, keeping `root`. */
57
+ async function emptyDir(root) {
58
+ for (const name of await readdir(root))
59
+ await rm(join(root, name), { recursive: true, force: true });
60
+ }
61
+ /**
62
+ * Fork run `ref` into run `newId`. The source must be released and sealed (run.json paused, sleeping, done
63
+ * or failed with a `sealedSeq`, and no delegation); `newId` must not exist. A fork owns the new run's directory only
64
+ * while it holds that directory's exclusive mount. A fork that fails after that empties the directory through its own
65
+ * mount and then removes the empty directory, so a half copy never looks like a run. One that never held it (another
66
+ * fork or a start mounted it first) touches nothing there; it may leave the empty directory it created. Every token
67
+ * and mount of its own is removed either way.
68
+ */
69
+ export async function fork(ref, newId, options) {
70
+ const t0 = performance.now();
71
+ const control = withTimeouts(options.control, options.controlTimeoutMs ?? CONTROL_TIMEOUT_MS);
72
+ runPath(newId);
73
+ if (newId === ref.id)
74
+ throw new ForkError("INVALID_ARGUMENT", "a fork needs a new run id");
75
+ const before = await readRunStatus(control, ref.id);
76
+ const released = (r) => !!r && RELEASED.includes(r.status) && r.sealedSeq !== null;
77
+ if (!released(before))
78
+ throw new ForkError("SOURCE_NOT_RELEASED", `run ${ref.id} is not released and sealed (${before ? `${before.status}, sealedSeq ${before.sealedSeq}` : "no run.json"})`);
79
+ if ((await findDelegations(control, ref.id)).length > 0)
80
+ throw new ForkError("SOURCE_HELD", `run ${ref.id} is mounted somewhere`);
81
+ const target = `${runPath(newId)}/`;
82
+ if ((await control.headObject(target)) || (await control.listObjects(target, { recursive: true })).objects.length > 0) {
83
+ throw new ForkError("TARGET_EXISTS", `${target} already exists`);
84
+ }
85
+ const note = options.onResource ?? (() => { });
86
+ const base = join(options.mountRoot, `.fork-${Date.now().toString(36)}`);
87
+ const tokens = [];
88
+ const claims = [];
89
+ // Set once this fork holds the new run's directory exclusively; until then the directory may be another operation's.
90
+ let copy;
91
+ let done = false;
92
+ const mount = async (run, side) => {
93
+ const t = await mintMountToken(control, { nickname: `${options.tokenPrefix ?? "pda-"}fork-${side}-${run.id}`.slice(0, 200), ttl: "1h" });
94
+ tokens.push(t.identifier);
95
+ note("token", t.identifier, side);
96
+ const claim = await (options.acquire ?? acquire)({ ref: run, token: t.token, mountRoot: join(base, side), host: options.host });
97
+ claims.push(claim);
98
+ note("mount", claim.root, `${run.disk}:/${runPath(run.id)}`);
99
+ return claim;
100
+ };
101
+ try {
102
+ const source = await mount(ref, "source").catch((error) => {
103
+ throw error instanceof HeldError ? new ForkError("SOURCE_HELD", `run ${ref.id} was mounted while forking`, { cause: error }) : error;
104
+ });
105
+ // The mounted record is authoritative: the source cannot change while this claim holds it.
106
+ const record = await readRunRecord(source.root);
107
+ if (!released(record))
108
+ throw new ForkError("SOURCE_NOT_RELEASED", `run ${ref.id} changed before it was mounted (${record?.status})`);
109
+ const owner = await lstat(source.root);
110
+ await createRunDir(control, newId, { uid: owner.uid, gid: owner.gid, mode: owner.mode & 0o7777 });
111
+ note("subdir", target);
112
+ copy = await mount({ ...ref, id: newId }, "target").catch((error) => {
113
+ throw error instanceof HeldError ? new ForkError("TARGET_EXISTS", `${target} was mounted by another fork or start`, { cause: error }) : error;
114
+ });
115
+ const count = { files: 0, bytes: 0 };
116
+ const asRoot = process.getuid?.() === 0;
117
+ try {
118
+ await copyTree(source.root, copy.root, true, asRoot, count);
119
+ }
120
+ catch (error) {
121
+ throw new ForkError("COPY_FAILED", `copying ${runPath(ref.id)} to ${target} failed: ${error.message}`, { cause: error });
122
+ }
123
+ const now = new Date().toISOString();
124
+ const forked = {
125
+ run: newId,
126
+ status: "paused",
127
+ generation: 0,
128
+ sealedSeq: record.sealedSeq,
129
+ wakeAt: null,
130
+ holder: null,
131
+ heartbeatAt: null,
132
+ updatedAt: now,
133
+ detail: { forkedFrom: { run: ref.id, generation: record.generation, sealedSeq: record.sealedSeq } },
134
+ };
135
+ await persistRecord(copy.root, `${JSON.stringify(forked, null, 2)}\n`);
136
+ // Each release runs the barrier first: every copied byte is durable before the target's claim goes.
137
+ while (claims.length > 0) {
138
+ const c = claims.at(-1);
139
+ const r = await c.release();
140
+ claims.pop();
141
+ note("unmount", c.root, r.via);
142
+ }
143
+ done = true;
144
+ return {
145
+ run: newId,
146
+ from: ref.id,
147
+ sealedSeq: record.sealedSeq,
148
+ sourceGeneration: record.generation,
149
+ ...count,
150
+ owners: asRoot ? "preserved" : "caller",
151
+ ms: Math.round(performance.now() - t0),
152
+ };
153
+ }
154
+ catch (error) {
155
+ // Undo through this fork's own mount while it still holds it; nothing is deleted over S3, where a revoke would
156
+ // take another operation's claim on the same id.
157
+ if (copy && !copy.fenced)
158
+ await emptyDir(copy.root).catch(() => undefined);
159
+ throw error;
160
+ }
161
+ finally {
162
+ for (const c of claims.reverse()) {
163
+ await c.release().then((r) => note("unmount", c.root, r.via), () => note("unmount", c.root, "failed"));
164
+ }
165
+ // The emptied directory's own marker. S3 refuses to delete a directory with entries and deletes nothing under a
166
+ // delegation, so this never removes a run another fork or start has taken since this fork released it.
167
+ if (copy && !done) {
168
+ await control.deleteObjects([target], { quiet: true }).catch(() => undefined);
169
+ if (!(await control.headObject(target).catch(() => true)))
170
+ note("subdir-deleted", target);
171
+ }
172
+ for (const t of tokens) {
173
+ await removeMountToken(control, t).then(() => note("token-removed", t), () => { });
174
+ }
175
+ for (const side of ["source", "target"]) {
176
+ for (const dir of [join(base, side, "runs"), join(base, side)])
177
+ await rmdir(dir).catch(() => { });
178
+ }
179
+ await rmdir(base).catch(() => { });
180
+ }
181
+ }
@@ -0,0 +1,52 @@
1
+ export declare const LAUNCH_DIR = "/run/pda";
2
+ export declare const LAUNCH_SCRIPT: string;
3
+ export interface LaunchSpec {
4
+ /** The instance command and its arguments (absolute program path). */
5
+ argv: string[];
6
+ /** The instance's whole environment besides PATH, LANG and HOME (the holder handle, an app's settings). */
7
+ env: Record<string, string>;
8
+ /** The run user (name or uid). Required when the launcher runs as root, which never runs the instance as root. */
9
+ user?: string;
10
+ group?: string;
11
+ /** Restart after a non-terminal exit (default true). */
12
+ restart?: boolean;
13
+ restartDelayMs?: number;
14
+ /** More than `burst` restarts within `intervalMs` ends the launcher (systemd's start limit). Default 5 in 60 s. */
15
+ restartLimit?: {
16
+ burst: number;
17
+ intervalMs: number;
18
+ };
19
+ terminalExits?: number[];
20
+ }
21
+ export type LaunchPhase = "running" | "restarting" | "stopping" | "exited";
22
+ export type LaunchState = {
23
+ launcher: number;
24
+ launcherTicks: number | null;
25
+ phase: LaunchPhase;
26
+ instance: number | null;
27
+ instanceTicks: number | null;
28
+ /** Incarnations spawned so far; 0 means the instance never ran (refused spec, failed spawn). */
29
+ spawned: number;
30
+ restarts: number;
31
+ /** The last incarnation's exit code (128 + signal number for a signal), null while one runs. */
32
+ exit: number | null;
33
+ signal: string | null;
34
+ /** Why the launcher stopped restarting: terminal exit, clean exit, stopped, restart limit, spawn failure. */
35
+ reason: string | null;
36
+ at: string;
37
+ };
38
+ export type LaunchStatus = {
39
+ status: "running" | "stopped" | "failed";
40
+ state: LaunchState | null;
41
+ launcherAlive: boolean;
42
+ instanceAlive: boolean;
43
+ };
44
+ export declare function readState(dir: string, name: string): LaunchState | null;
45
+ /** running while either process lives; otherwise stopped after a clean exit, failed after any other end. */
46
+ export declare function launchStatus(dir: string, name: string): LaunchStatus;
47
+ /** The loop `serve` runs; resolves with the final state. */
48
+ export declare function serve(dir: string, name: string): Promise<LaunchState>;
49
+ /** Spawn `serve` in its own session and wait until its first incarnation is spawned (or the launcher ended without one). */
50
+ export declare function start(dir: string, name: string, timeoutMs?: number, stage?: string): Promise<LaunchState | null>;
51
+ /** SIGTERM the launcher (it drains the instance); past the timeout SIGKILL the instance's group and the launcher. */
52
+ export declare function stop(dir: string, name: string, timeoutMs: number): Promise<LaunchStatus>;
@@ -0,0 +1,270 @@
1
+ // The in-box launcher for `daytonaHost`: a sandbox has no systemd (the Daytona daemon is PID 1), so this keeps the instance
2
+ // alive in place, as a systemd unit does on a host.
3
+ // It runs as root, started through the toolbox, and keeps one instance of a run alive in its box:
4
+ // start <name> move the spec and token from the toolbox user's staging directory (`--stage`), spawn `serve` detached (its own session, so the toolbox's process-group kill never reaches it), wait
5
+ // until the first incarnation is spawned, print the state
6
+ // serve <name> read the spec and the mount token, unlink the token file, run the instance as the run user with the
7
+ // token on a pipe to its stdin, restart it after a non-terminal exit (never after 0, 65, 70, 75 or 76,
8
+ // and not past the restart limit), forward SIGTERM to it, write `<name>.state` on every change
9
+ // status <name> print running, stopped or failed from the state file and whether the processes still live
10
+ // stop <name> SIGTERM the launcher (it drains the instance), SIGKILL both after the timeout, print the state
11
+ // Files live in a root-only directory (default /run/pda, 0700), which the run user can never traverse: `<name>.json`
12
+ // (the spec), `<name>.token` (gone once read), `<name>.state`, `<name>.log` (the instance's output). Only the first
13
+ // incarnation gets the token; a restart in place reads an empty stdin and reuses its live mount, as under localHost.
14
+ // No cgroup per incarnation: commands a crashed instance detached can outlive it until the box is deleted.
15
+ import { spawn } from "node:child_process";
16
+ import { closeSync, existsSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
17
+ import { join, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { procStartTicks, TERMINAL_EXITS } from "./local-host.js";
20
+ export const LAUNCH_DIR = "/run/pda";
21
+ export const LAUNCH_SCRIPT = fileURLToPath(import.meta.url);
22
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
23
+ const SAFE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
24
+ const files = (dir, name) => {
25
+ if (!NAME.test(name))
26
+ throw new Error(`launch name ${JSON.stringify(name)} is not one safe path segment`);
27
+ const base = join(dir, name);
28
+ return { spec: `${base}.json`, token: `${base}.token`, state: `${base}.state`, log: `${base}.log` };
29
+ };
30
+ export function readState(dir, name) {
31
+ const path = files(dir, name).state;
32
+ try {
33
+ return JSON.parse(readFileSync(path, "utf8"));
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ function writeState(path, state) {
40
+ writeFileSync(`${path}.tmp`, `${JSON.stringify(state)}\n`, { mode: 0o600 });
41
+ renameSync(`${path}.tmp`, path);
42
+ }
43
+ // Only a real process with its start ticks: `stop` signals these pids, the instance's as a group, and a pid of 1 or below
44
+ // names a group (`kill(-1)` is every process this user may signal). The state file holds whatever was written there.
45
+ const alive = (pid, ticks) => Number.isInteger(pid) && pid > 1 && typeof ticks === "number" && procStartTicks(pid) === ticks;
46
+ /** running while either process lives; otherwise stopped after a clean exit, failed after any other end. */
47
+ export function launchStatus(dir, name) {
48
+ const state = readState(dir, name);
49
+ if (!state)
50
+ return { status: "failed", state: null, launcherAlive: false, instanceAlive: false };
51
+ const launcherAlive = alive(state.launcher, state.launcherTicks);
52
+ const instanceAlive = alive(state.instance, state.instanceTicks);
53
+ if (launcherAlive || instanceAlive)
54
+ return { status: "running", state, launcherAlive, instanceAlive };
55
+ const clean = state.phase === "exited" && state.exit === 0;
56
+ return { status: clean ? "stopped" : "failed", state, launcherAlive, instanceAlive };
57
+ }
58
+ /** /etc/passwd or /etc/group field lookup: a name or a numeric id to the numeric id (and the passwd home). */
59
+ function lookup(file, key) {
60
+ for (const line of readFileSync(file, "utf8").split("\n")) {
61
+ const f = line.split(":");
62
+ if (f.length >= 3 && (f[0] === key || f[2] === key))
63
+ return { id: Number(f[2]), home: file === "/etc/passwd" ? f[5] : undefined };
64
+ }
65
+ throw new Error(`${key} is not in ${file}`);
66
+ }
67
+ const SIGNALS = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGABRT: 6, SIGKILL: 9, SIGSEGV: 11, SIGPIPE: 13, SIGTERM: 15 };
68
+ /** Who the instance runs as: the spec's user (numeric ids for spawn) or, with no user, this process. */
69
+ function runAs(spec) {
70
+ const isRoot = process.getuid?.() === 0;
71
+ if (spec.user === undefined) {
72
+ if (isRoot)
73
+ throw new Error("the launcher runs as root and the spec names no run user");
74
+ return { home: process.env.HOME ?? "/" };
75
+ }
76
+ const u = lookup("/etc/passwd", spec.user);
77
+ const gid = lookup("/etc/group", spec.group ?? spec.user).id;
78
+ if (u.id === 0)
79
+ throw new Error("the instance never runs as root");
80
+ if (isRoot)
81
+ return { uid: u.id, gid, home: u.home ?? "/" };
82
+ if (u.id !== process.getuid?.())
83
+ throw new Error("switching to the run user needs root");
84
+ return { home: u.home ?? "/" };
85
+ }
86
+ /** The loop `serve` runs; resolves with the final state. */
87
+ export async function serve(dir, name) {
88
+ const f = files(dir, name);
89
+ const state = { launcher: process.pid, launcherTicks: procStartTicks(process.pid), phase: "running", instance: null, instanceTicks: null, spawned: 0, restarts: 0, exit: null, signal: null, reason: null, at: new Date().toISOString() };
90
+ const save = (patch) => writeState(f.state, Object.assign(state, patch, { at: new Date().toISOString() }));
91
+ let spec;
92
+ let token;
93
+ let ids;
94
+ try {
95
+ spec = JSON.parse(readFileSync(f.spec, "utf8"));
96
+ // No token file: a launcher started again after a crash runs its instance with an empty stdin.
97
+ token = existsSync(f.token) ? readFileSync(f.token, "utf8").trim() : "";
98
+ if (existsSync(f.token))
99
+ unlinkSync(f.token);
100
+ ids = runAs(spec);
101
+ }
102
+ catch (err) {
103
+ save({ phase: "exited", exit: 1, reason: `refused: ${err.message}` });
104
+ return state;
105
+ }
106
+ const restart = spec.restart !== false;
107
+ const delay = spec.restartDelayMs ?? 1_000;
108
+ const limit = spec.restartLimit ?? { burst: 5, intervalMs: 60_000 };
109
+ const terminal = spec.terminalExits ?? [...TERMINAL_EXITS];
110
+ let stopping = false;
111
+ let child = null;
112
+ const onSignal = () => {
113
+ stopping = true;
114
+ if (state.phase === "restarting")
115
+ save({ phase: "stopping" });
116
+ else if (child?.exitCode === null) {
117
+ save({ phase: "stopping" });
118
+ child.kill("SIGTERM");
119
+ }
120
+ };
121
+ process.on("SIGTERM", onSignal);
122
+ process.on("SIGINT", onSignal);
123
+ const log = openSync(f.log, "a", 0o600);
124
+ const recent = [];
125
+ try {
126
+ for (;;) {
127
+ const env = { PATH: SAFE_PATH, LANG: "C.UTF-8", HOME: ids.home, ...spec.env };
128
+ const first = token !== null;
129
+ try {
130
+ child = spawn(spec.argv[0], spec.argv.slice(1), { uid: ids.uid, gid: ids.gid, env, cwd: "/", stdio: [first ? "pipe" : "ignore", log, log], detached: true });
131
+ await new Promise((resolve, reject) => {
132
+ child.once("spawn", resolve);
133
+ child.once("error", reject);
134
+ });
135
+ }
136
+ catch (err) {
137
+ save({ phase: "exited", instance: null, instanceTicks: null, exit: 1, reason: `spawn failed: ${err.message}` });
138
+ return state;
139
+ }
140
+ if (first) {
141
+ child.stdin.on("error", () => { });
142
+ child.stdin.end(token ? `${token}\n` : "");
143
+ token = null;
144
+ }
145
+ save({ phase: stopping ? "stopping" : "running", instance: child.pid, instanceTicks: procStartTicks(child.pid), spawned: state.spawned + 1, exit: null, signal: null });
146
+ if (stopping)
147
+ child.kill("SIGTERM");
148
+ const [code, signal] = await new Promise((resolve) => child.once("exit", (c, s) => resolve([c, s])));
149
+ const exit = code ?? 128 + (SIGNALS[signal ?? ""] ?? 0);
150
+ save({ instance: null, instanceTicks: null, exit, signal });
151
+ const now = Date.now();
152
+ recent.push(now);
153
+ while (recent.length && recent[0] < now - limit.intervalMs)
154
+ recent.shift();
155
+ const end = stopping ? "stopped"
156
+ : exit === 0 ? "clean exit"
157
+ : terminal.includes(exit) ? `terminal exit ${exit}`
158
+ : !restart ? "restart disabled"
159
+ : recent.length > limit.burst ? `restart limit (${limit.burst} in ${limit.intervalMs} ms)`
160
+ : null;
161
+ if (end) {
162
+ save({ phase: "exited", reason: end });
163
+ return state;
164
+ }
165
+ save({ phase: "restarting", restarts: state.restarts + 1 });
166
+ await new Promise((r) => setTimeout(r, delay));
167
+ if (stopping) {
168
+ save({ phase: "exited", reason: "stopped" });
169
+ return state;
170
+ }
171
+ }
172
+ }
173
+ finally {
174
+ closeSync(log);
175
+ process.off("SIGTERM", onSignal);
176
+ process.off("SIGINT", onSignal);
177
+ }
178
+ }
179
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
180
+ /** Spawn `serve` in its own session and wait until its first incarnation is spawned (or the launcher ended without one). */
181
+ export async function start(dir, name, timeoutMs = 30_000, stage) {
182
+ const f = files(dir, name);
183
+ if (readState(dir, name))
184
+ throw new Error(`${name} was already launched in this box`);
185
+ if (stage) {
186
+ // The toolbox's user uploaded into its own 0700 staging directory; move both files into the root-only directory.
187
+ const from = files(stage, name);
188
+ for (const [src, dst] of [[from.spec, f.spec], [from.token, f.token]]) {
189
+ if (!existsSync(src))
190
+ continue;
191
+ writeFileSync(dst, readFileSync(src), { mode: 0o600 });
192
+ unlinkSync(src);
193
+ }
194
+ }
195
+ if (!existsSync(f.spec))
196
+ throw new Error(`no launch spec at ${f.spec}`);
197
+ const child = spawn(process.execPath, [LAUNCH_SCRIPT, "serve", name, "--dir", dir], { detached: true, stdio: "ignore", env: { PATH: SAFE_PATH, LANG: "C.UTF-8" } });
198
+ child.unref();
199
+ for (const t0 = Date.now(); Date.now() - t0 < timeoutMs; await sleep(25)) {
200
+ const s = readState(dir, name);
201
+ if (s && (s.phase === "exited" || s.spawned > 0))
202
+ return s;
203
+ }
204
+ return readState(dir, name);
205
+ }
206
+ /** SIGTERM the launcher (it drains the instance); past the timeout SIGKILL the instance's group and the launcher. */
207
+ export async function stop(dir, name, timeoutMs) {
208
+ const kill = (pid, ticks, signal, group = false) => {
209
+ if (!alive(pid, ticks))
210
+ return;
211
+ try {
212
+ process.kill(group ? -pid : pid, signal);
213
+ }
214
+ catch {
215
+ // gone between the check and the signal
216
+ }
217
+ };
218
+ let s = launchStatus(dir, name);
219
+ if (s.status !== "running")
220
+ return s;
221
+ if (s.launcherAlive)
222
+ kill(s.state.launcher, s.state.launcherTicks, "SIGTERM");
223
+ else
224
+ kill(s.state.instance, s.state.instanceTicks, "SIGTERM");
225
+ for (const t0 = Date.now(); Date.now() - t0 < timeoutMs; await sleep(50)) {
226
+ s = launchStatus(dir, name);
227
+ if (s.status !== "running")
228
+ return s;
229
+ }
230
+ kill(s.state.instance, s.state.instanceTicks, "SIGKILL", true);
231
+ kill(s.state.launcher, s.state.launcherTicks, "SIGKILL");
232
+ for (let i = 0; i < 40 && launchStatus(dir, name).status === "running"; i++)
233
+ await sleep(50);
234
+ return launchStatus(dir, name);
235
+ }
236
+ async function main(argv) {
237
+ const [verb, name, ...rest] = argv;
238
+ const opt = (flag) => {
239
+ const i = rest.indexOf(flag);
240
+ return i >= 0 ? rest[i + 1] : undefined;
241
+ };
242
+ const dir = opt("--dir") ?? LAUNCH_DIR;
243
+ const timeoutMs = Number(opt("--timeout-ms") ?? 30_000);
244
+ const print = (v) => process.stdout.write(`${JSON.stringify(v)}\n`);
245
+ switch (verb) {
246
+ case "serve":
247
+ await serve(dir, name);
248
+ return 0;
249
+ case "start": {
250
+ const s = await start(dir, name, timeoutMs, opt("--stage"));
251
+ print(s);
252
+ return s && s.spawned > 0 ? 0 : 1;
253
+ }
254
+ case "status":
255
+ print(launchStatus(dir, name));
256
+ return 0;
257
+ case "stop":
258
+ print(await stop(dir, name, timeoutMs));
259
+ return 0;
260
+ default:
261
+ process.stderr.write("usage: daytona-launch.ts start|serve|status|stop NAME [--dir DIR] [--stage DIR] [--timeout-ms MS]\n");
262
+ return 2;
263
+ }
264
+ }
265
+ if (process.argv[1] && resolve(process.argv[1]) === LAUNCH_SCRIPT) {
266
+ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
267
+ process.stderr.write(`${err.message}\n`);
268
+ process.exit(1);
269
+ });
270
+ }