@devfn/processes 0.1.0 → 0.1.1
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/README.md +24 -0
- package/dist/adapters.d.ts +1 -1
- package/dist/adapters.js +6 -0
- package/dist/gated.d.ts +34 -0
- package/dist/gated.js +150 -0
- package/dist/group.d.ts +20 -0
- package/dist/group.js +87 -0
- package/dist/identity.d.ts +1 -1
- package/dist/identity.js +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/readiness.d.ts +4 -0
- package/dist/readiness.js +41 -21
- package/dist/supervisor.d.ts +31 -2
- package/dist/supervisor.js +194 -57
- package/dist/types.d.ts +38 -2
- package/dist/wrapper.js +66 -11
- package/package.json +36 -8
package/README.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
1
|
# @devfn/processes
|
|
2
2
|
|
|
3
3
|
Native process groups, durable logs, HTTP/TCP/command/log readiness, PID-birth ownership checks, and graceful termination. It is deliberately browser- and artifact-model-neutral so ProbeFn can adopt it later without depending on DevFn orchestration.
|
|
4
|
+
|
|
5
|
+
## Gated launches
|
|
6
|
+
|
|
7
|
+
- `runGatedCommand` runs a command that creates outside resources only after its launcher identity has been recorded. The process wrapper never runs its command without the launch gate channel.
|
|
8
|
+
- On POSIX, `gatedLauncherStatus`/`stopGatedLauncher` resolve such a launch only once the launcher and its process group are gone. On Windows they judge and stop only the launcher and its current process tree, so they cannot confirm that a descendant which left that tree is gone.
|
|
9
|
+
- `runGatedCommand` redacts the values of `redactKeys` from the command's output and keeps at most `maxBuffer` bytes of each output stream, stopping the launcher's group as soon as either passes it.
|
|
10
|
+
- Once the command exits, it waits at most a bounded drain for output that a process it left behind still holds open, then stops that process's group, waiting a bounded time for it to exit and forcing it if needed. A finished command therefore never waits indefinitely on a leftover process. A failed stop is reported together with the command's own outcome, never instead of it.
|
|
11
|
+
- `ProcessSupervisor.run` runs a finite command to completion through the same gate, with its output in a private log, a per-run timeout and an abort signal, both of which stop its whole group. The same bounded drain applies, so a command that exits 0 while a descendant holds its output open succeeds and that descendant is stopped with its group. `discardOutput` keeps none of the command's output, for output that may carry values `redactKeys` cannot name.
|
|
12
|
+
|
|
13
|
+
## Process groups
|
|
14
|
+
|
|
15
|
+
- Every recorded process and launcher leads its own process group (POSIX), and `processGroupStatus` judges the whole group: a leader that exited while processes remain in its group is `unverified`, not gone, and a reused leader PID (a readable different start identity) proves the group gone, because a PID is not reused while its group exists.
|
|
16
|
+
- `stop` signals only a verified leader, waits until its whole group is gone (forcing members that ignore termination), and never signals a live PID whose start identity cannot be read or a leaderless group (`DEVFN_PROCESS_IDENTITY_UNVERIFIED`).
|
|
17
|
+
- The group is judged again immediately before each signal. POSIX offers no way to signal a group atomically with that check, but a group ID is not reused while any member remains, so the only exposure is a group that empties and whose ID a new group leader takes in the instant between the check and the signal.
|
|
18
|
+
- On Windows, which has no process groups, only the leader is judged and stopped with its process tree.
|
|
19
|
+
|
|
20
|
+
## Start identities
|
|
21
|
+
|
|
22
|
+
- Start identities do not depend on the caller's environment: on macOS the `ps` start time is read with `TZ=UTC0` and `LC_ALL=C`.
|
|
23
|
+
- A signature recorded by an earlier release in the caller's time zone and locale is never compared as text. It is decided by the record's own UTC time (a receipt process's `startedAt`, a lock's or trust ticket's `createdAt`, a Caddy owner's `startedAt`, a port lease owner's `recordedAt`), which DevFn writes only after reading a live owner's identity:
|
|
24
|
+
- a process whose pinned UTC start is later than that time by more than two seconds is another process reusing the PID;
|
|
25
|
+
- one that started no later than that time is the recorded process;
|
|
26
|
+
- a record without a time, or a start later than it by at most two seconds (the allowance for wall-clock adjustment), is `unverified`.
|
|
27
|
+
- An earlier release's active port lease has no `recordedAt`, but its `updatedAt` was written at activation, just after its owner's identity was read, and stays unchanged while the lease is active, so the registry copies it to the owner's `recordedAt` before any transition; a lease whose PID was reused is then `stale` or `externally-occupied` as in earlier releases. Only a lease that already left `active` under an earlier release has no record time, and its owner stays `unverified`.
|
package/dist/adapters.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ProcessSpec } from "@devfn/config";
|
|
2
2
|
export declare function resolveAdapterCommand(spec: ProcessSpec): string[];
|
|
3
3
|
export declare function createProcessEnvironment(spec: ProcessSpec, generated?: Record<string, string>, source?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
package/dist/adapters.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertEnvironmentKeyCasing } from "@devfn/config";
|
|
1
2
|
export function resolveAdapterCommand(spec) {
|
|
2
3
|
const extra = spec.command ?? [];
|
|
3
4
|
switch (spec.adapter) {
|
|
@@ -16,6 +17,7 @@ export function resolveAdapterCommand(spec) {
|
|
|
16
17
|
}
|
|
17
18
|
const BASE_ENV = ["PATH", "SHELL", "HOME", "USER", "LOGNAME", "TMPDIR", "TMP", "TEMP", "SystemRoot", "ComSpec", "PATHEXT"];
|
|
18
19
|
export function createProcessEnvironment(spec, generated = {}, source = process.env) {
|
|
20
|
+
assertEnvironmentKeyCasing(BASE_ENV, spec.envAllowlist ?? [], Object.keys(spec.env ?? {}), Object.keys(generated), spec.exposure === "public" ? [] : ["HOST", "DEVFN_HOST"], spec.adapter === "pnpm" ? ["COREPACK_ENABLE_NETWORK"] : []);
|
|
19
21
|
const result = {};
|
|
20
22
|
for (const key of [...BASE_ENV, ...(spec.envAllowlist ?? [])])
|
|
21
23
|
if (source[key] !== undefined)
|
|
@@ -27,5 +29,9 @@ export function createProcessEnvironment(spec, generated = {}, source = process.
|
|
|
27
29
|
}
|
|
28
30
|
if (spec.adapter === "pnpm")
|
|
29
31
|
result.COREPACK_ENABLE_NETWORK = "0";
|
|
32
|
+
// Only DevFn's own launcher may ask the process wrapper to discard output.
|
|
33
|
+
for (const key of Object.keys(result))
|
|
34
|
+
if (key.toUpperCase() === "DEVFN_DISCARD_OUTPUT")
|
|
35
|
+
delete result[key];
|
|
30
36
|
return result;
|
|
31
37
|
}
|
package/dist/gated.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type ProcessOwnerIdentity } from "./types.js";
|
|
2
|
+
export interface GatedCommandOptions {
|
|
3
|
+
cwd?: string;
|
|
4
|
+
env?: NodeJS.ProcessEnv;
|
|
5
|
+
timeout?: number;
|
|
6
|
+
maxBuffer?: number;
|
|
7
|
+
/** Environment keys whose values are redacted from the command's output. */
|
|
8
|
+
redactKeys?: readonly string[];
|
|
9
|
+
/** Record the launcher identity; the command runs only after this resolves. */
|
|
10
|
+
onLaunched: (launcher: ProcessOwnerIdentity) => Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export type GatedCommandRunner = (file: string, args: string[], options: GatedCommandOptions) => Promise<{
|
|
13
|
+
stdout: string;
|
|
14
|
+
stderr: string;
|
|
15
|
+
}>;
|
|
16
|
+
/**
|
|
17
|
+
* Whether anything a gated launcher ran may still run, by the process group
|
|
18
|
+
* rule: the launcher leads its own group on POSIX, so a member left after it
|
|
19
|
+
* died is something it started, while a reused launcher PID proves its group
|
|
20
|
+
* gone. "gone" is the only conclusive answer.
|
|
21
|
+
*/
|
|
22
|
+
export declare function gatedLauncherStatus(launcher: ProcessOwnerIdentity): Promise<"running" | "unverified" | "gone">;
|
|
23
|
+
/**
|
|
24
|
+
* Stop a gated launcher and everything in its process group, signalling only
|
|
25
|
+
* a verified launcher identity. Resolves only once nothing it ran remains.
|
|
26
|
+
*/
|
|
27
|
+
export declare function stopGatedLauncher(launcher: ProcessOwnerIdentity, timeoutMs?: number): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Run a command that may create resources outside DevFn through a launcher
|
|
30
|
+
* whose identity is recorded before the command can start. If the recording
|
|
31
|
+
* fails or DevFn dies first, the launcher's gate closes and the command never
|
|
32
|
+
* runs, so an unrecorded launcher proves the command never ran.
|
|
33
|
+
*/
|
|
34
|
+
export declare const runGatedCommand: GatedCommandRunner;
|
package/dist/gated.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { stopProcessGroup } from "./group.js";
|
|
4
|
+
import { processBirthSignature, processGroupStatus } from "./identity.js";
|
|
5
|
+
import { ProcessError } from "./types.js";
|
|
6
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
|
+
// A process the command left behind may hold its output open after it exits;
|
|
8
|
+
// these bound the wait for that output so the leftover can still be stopped.
|
|
9
|
+
const WRAPPER_DRAIN_MS = 1_000;
|
|
10
|
+
const OUTPUT_DRAIN_MS = 2_000;
|
|
11
|
+
/**
|
|
12
|
+
* Whether anything a gated launcher ran may still run, by the process group
|
|
13
|
+
* rule: the launcher leads its own group on POSIX, so a member left after it
|
|
14
|
+
* died is something it started, while a reused launcher PID proves its group
|
|
15
|
+
* gone. "gone" is the only conclusive answer.
|
|
16
|
+
*/
|
|
17
|
+
export async function gatedLauncherStatus(launcher) {
|
|
18
|
+
const status = await processGroupStatus(launcher.pid, launcher.birthSignature);
|
|
19
|
+
return status === "exited" || status === "identity-mismatch" ? "gone" : status;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Stop a gated launcher and everything in its process group, signalling only
|
|
23
|
+
* a verified launcher identity. Resolves only once nothing it ran remains.
|
|
24
|
+
*/
|
|
25
|
+
export async function stopGatedLauncher(launcher, timeoutMs = 10_000) {
|
|
26
|
+
await stopProcessGroup(launcher, "The launcher", timeoutMs);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Run a command that may create resources outside DevFn through a launcher
|
|
30
|
+
* whose identity is recorded before the command can start. If the recording
|
|
31
|
+
* fails or DevFn dies first, the launcher's gate closes and the command never
|
|
32
|
+
* runs, so an unrecorded launcher proves the command never ran.
|
|
33
|
+
*/
|
|
34
|
+
export const runGatedCommand = async (file, args, options) => {
|
|
35
|
+
const wrapperPath = fileURLToPath(new URL("./wrapper.js", import.meta.url));
|
|
36
|
+
// Its output is always kept, whatever the inherited environment asks of the
|
|
37
|
+
// wrapper (in any key casing, as Windows reads it).
|
|
38
|
+
const environment = { ...(options.env ?? process.env) };
|
|
39
|
+
for (const key of Object.keys(environment))
|
|
40
|
+
if (key.toUpperCase() === "DEVFN_DISCARD_OUTPUT")
|
|
41
|
+
delete environment[key];
|
|
42
|
+
const child = spawn(process.execPath, [wrapperPath], {
|
|
43
|
+
...(options.cwd ? { cwd: options.cwd } : {}),
|
|
44
|
+
env: { ...environment, DEVFN_WRAPPED_COMMAND: JSON.stringify([file, ...args]),
|
|
45
|
+
DEVFN_REDACT_KEYS: JSON.stringify(options.redactKeys ?? []), DEVFN_WRAPPER_DRAIN_MS: String(WRAPPER_DRAIN_MS) },
|
|
46
|
+
detached: process.platform !== "win32",
|
|
47
|
+
windowsHide: true,
|
|
48
|
+
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
|
49
|
+
});
|
|
50
|
+
const maxBuffer = options.maxBuffer ?? 1024 * 1024;
|
|
51
|
+
// Output is kept only up to the limit; passing it stops the launcher.
|
|
52
|
+
let overflowed;
|
|
53
|
+
const overflow = new Promise((resolve) => { overflowed = () => resolve("overflow"); });
|
|
54
|
+
let exceeded = false;
|
|
55
|
+
const capture = (stream) => {
|
|
56
|
+
const chunks = [];
|
|
57
|
+
let size = 0;
|
|
58
|
+
stream.on("data", (chunk) => {
|
|
59
|
+
if (exceeded)
|
|
60
|
+
return;
|
|
61
|
+
if (size + chunk.length > maxBuffer) {
|
|
62
|
+
chunks.push(chunk.subarray(0, maxBuffer - size));
|
|
63
|
+
size = maxBuffer;
|
|
64
|
+
exceeded = true;
|
|
65
|
+
overflowed();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
chunks.push(chunk);
|
|
69
|
+
size += chunk.length;
|
|
70
|
+
});
|
|
71
|
+
return () => Buffer.concat(chunks).toString("utf8");
|
|
72
|
+
};
|
|
73
|
+
const stdout = capture(child.stdout);
|
|
74
|
+
const stderr = capture(child.stderr);
|
|
75
|
+
// "close" never follows a disconnected IPC channel, so wait for the exit
|
|
76
|
+
// and the end of the output instead.
|
|
77
|
+
const ended = (stream) => new Promise((resolve) => { stream.once("close", resolve); stream.once("error", () => resolve()); });
|
|
78
|
+
const output = Promise.all([ended(child.stdout), ended(child.stderr)]);
|
|
79
|
+
const closed = new Promise((resolve) => child.once("exit", (code, signal) => resolve({ code, signal })))
|
|
80
|
+
.then(async (result) => { await Promise.race([output, delay(OUTPUT_DRAIN_MS)]); return result; });
|
|
81
|
+
await new Promise((resolve, reject) => {
|
|
82
|
+
child.once("error", reject);
|
|
83
|
+
child.once("spawn", resolve);
|
|
84
|
+
}).catch((error) => { throw new ProcessError("DEVFN_PROCESS_START_FAILED", `Unable to launch ${file}.`, { cause: error instanceof Error ? error.message : String(error) }); });
|
|
85
|
+
const pid = child.pid;
|
|
86
|
+
let birthSignature;
|
|
87
|
+
for (let attempt = 0; attempt < 10 && !birthSignature; attempt += 1) {
|
|
88
|
+
birthSignature = await processBirthSignature(pid);
|
|
89
|
+
if (!birthSignature)
|
|
90
|
+
await delay(20);
|
|
91
|
+
}
|
|
92
|
+
const launcher = { pid, ...(birthSignature ? { birthSignature } : {}) };
|
|
93
|
+
// This launcher is this process's child: its PID is not reused before it is
|
|
94
|
+
// reaped, and afterwards its group ID is not reused while a member remains,
|
|
95
|
+
// so what is left in that group is its own and may be stopped.
|
|
96
|
+
const stopOwnLauncher = async () => await stopProcessGroup(launcher, `The launcher for ${file}`, 10_000, { ownGroup: { leaderUnreaped: () => child.exitCode === null && child.signalCode === null } });
|
|
97
|
+
try {
|
|
98
|
+
if (!birthSignature)
|
|
99
|
+
throw new ProcessError("DEVFN_PROCESS_START_FAILED", `Could not establish a launcher identity for ${file}; refusing to run it unrecorded.`);
|
|
100
|
+
await options.onLaunched(launcher);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
// Closing the channel before the gate opens makes the launcher exit
|
|
104
|
+
// without running the command.
|
|
105
|
+
if (child.connected)
|
|
106
|
+
child.disconnect();
|
|
107
|
+
await closed;
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
await new Promise((resolve, reject) => {
|
|
111
|
+
const timer = setTimeout(() => reject(new ProcessError("DEVFN_PROCESS_START_FAILED", `Launcher for ${file} did not acknowledge its launch gate.`)), 10_000);
|
|
112
|
+
child.once("disconnect", () => { clearTimeout(timer); resolve(); });
|
|
113
|
+
child.send("start", (error) => {
|
|
114
|
+
if (!error)
|
|
115
|
+
return;
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
reject(new ProcessError("DEVFN_PROCESS_START_FAILED", `Unable to open the launch gate for ${file}.`, { cause: error.message }));
|
|
118
|
+
});
|
|
119
|
+
}).catch(async (error) => {
|
|
120
|
+
await stopOwnLauncher().catch(() => undefined);
|
|
121
|
+
throw error;
|
|
122
|
+
});
|
|
123
|
+
let timer;
|
|
124
|
+
const timedOut = options.timeout ? new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), options.timeout); }) : undefined;
|
|
125
|
+
const outcome = await Promise.race([closed, overflow, ...(timedOut ? [timedOut] : [])]);
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
// An exited launcher may have left a member that could still act for it.
|
|
128
|
+
// A failed stop never hides the command's own outcome; it is reported with it.
|
|
129
|
+
let stopError;
|
|
130
|
+
try {
|
|
131
|
+
await stopOwnLauncher();
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
stopError = error;
|
|
135
|
+
}
|
|
136
|
+
const failed = (message, details) => Object.assign(new Error(stopError === undefined ? message
|
|
137
|
+
: `${message} ${stopError instanceof Error ? stopError.message : String(stopError)}`), { stdout: stdout(), stderr: stderr(), ...details,
|
|
138
|
+
...(stopError === undefined ? {} : { cleanupError: stopError }) });
|
|
139
|
+
if (outcome === "timeout")
|
|
140
|
+
throw failed(`Command ${file} ${args.join(" ")} timed out after ${options.timeout}ms.`, { killed: true });
|
|
141
|
+
if (exceeded || outcome === "overflow")
|
|
142
|
+
throw failed(`Command ${file} output exceeded ${maxBuffer} bytes.`, { killed: true });
|
|
143
|
+
if (outcome.code !== 0) {
|
|
144
|
+
const output = stderr() ? `\n${stderr()}` : "";
|
|
145
|
+
throw failed(`Command failed: ${file} ${args.join(" ")}${output}`, { code: outcome.code, signal: outcome.signal });
|
|
146
|
+
}
|
|
147
|
+
if (stopError !== undefined)
|
|
148
|
+
throw stopError;
|
|
149
|
+
return { stdout: stdout(), stderr: stderr() };
|
|
150
|
+
};
|
package/dist/group.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ProcessOwnerIdentity } from "./types.js";
|
|
2
|
+
/** Only an exited leader with an empty group, or a reused leader PID, proves nothing of a group remains. */
|
|
3
|
+
export declare function processGroupGone(owner: ProcessOwnerIdentity): Promise<boolean>;
|
|
4
|
+
/** Signal a group leader and its group (its process tree on Windows). */
|
|
5
|
+
export declare function signalProcessGroup(pid: number, force: boolean): Promise<void>;
|
|
6
|
+
export declare function waitForProcessGroupExit(owner: ProcessOwnerIdentity, timeoutMs: number): Promise<boolean>;
|
|
7
|
+
/**
|
|
8
|
+
* Stop a recorded group leader and everything in its group, resolving only
|
|
9
|
+
* once nothing of it remains. Only a verified leader is signalled. Once it
|
|
10
|
+
* was, its group ID cannot be reused while a member remains, so members that
|
|
11
|
+
* outlive it are forced too; elsewhere only the verified leader is. A caller
|
|
12
|
+
* that spawned the leader during its own run (ownGroup) knows its unreaped
|
|
13
|
+
* child is that leader, and that the members of a leaderless group are what
|
|
14
|
+
* it started.
|
|
15
|
+
*/
|
|
16
|
+
export declare function stopProcessGroup(owner: ProcessOwnerIdentity, label: string, timeoutMs: number, options?: {
|
|
17
|
+
ownGroup?: {
|
|
18
|
+
leaderUnreaped: () => boolean;
|
|
19
|
+
};
|
|
20
|
+
}): Promise<void>;
|
package/dist/group.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { processExists, processGroupStatus, processIdentityStatus } from "./identity.js";
|
|
4
|
+
import { ProcessError } from "./types.js";
|
|
5
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
|
+
/** Only an exited leader with an empty group, or a reused leader PID, proves nothing of a group remains. */
|
|
7
|
+
export async function processGroupGone(owner) {
|
|
8
|
+
const status = await processGroupStatus(owner.pid, owner.birthSignature, owner.startedAt);
|
|
9
|
+
return status === "exited" || status === "identity-mismatch";
|
|
10
|
+
}
|
|
11
|
+
/** Signal a group leader and its group (its process tree on Windows). */
|
|
12
|
+
export async function signalProcessGroup(pid, force) {
|
|
13
|
+
if (process.platform !== "win32") {
|
|
14
|
+
try {
|
|
15
|
+
process.kill(-pid, force ? "SIGKILL" : "SIGTERM");
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error.code !== "ESRCH")
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
// The system copy, never one found through PATH.
|
|
24
|
+
const systemRoot = process.env.SystemRoot;
|
|
25
|
+
const taskkill = path.win32.join(systemRoot && path.win32.isAbsolute(systemRoot) ? systemRoot : "C:\\Windows", "System32", "taskkill.exe");
|
|
26
|
+
await new Promise((resolve, reject) => {
|
|
27
|
+
const child = spawn(taskkill, ["/pid", String(pid), "/T", ...(force ? ["/F"] : [])], { stdio: "ignore", windowsHide: true });
|
|
28
|
+
child.once("error", reject);
|
|
29
|
+
child.once("exit", (code) => code === 0 || !processExists(pid) ? resolve() : reject(new Error(`taskkill exited with ${code}`)));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export async function waitForProcessGroupExit(owner, timeoutMs) {
|
|
33
|
+
const deadline = Date.now() + timeoutMs;
|
|
34
|
+
while (Date.now() < deadline) {
|
|
35
|
+
if (await processGroupGone(owner))
|
|
36
|
+
return true;
|
|
37
|
+
await delay(50);
|
|
38
|
+
}
|
|
39
|
+
return await processGroupGone(owner);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Stop a recorded group leader and everything in its group, resolving only
|
|
43
|
+
* once nothing of it remains. Only a verified leader is signalled. Once it
|
|
44
|
+
* was, its group ID cannot be reused while a member remains, so members that
|
|
45
|
+
* outlive it are forced too; elsewhere only the verified leader is. A caller
|
|
46
|
+
* that spawned the leader during its own run (ownGroup) knows its unreaped
|
|
47
|
+
* child is that leader, and that the members of a leaderless group are what
|
|
48
|
+
* it started.
|
|
49
|
+
*/
|
|
50
|
+
export async function stopProcessGroup(owner, label, timeoutMs, options = {}) {
|
|
51
|
+
const status = await processGroupStatus(owner.pid, owner.birthSignature, owner.startedAt);
|
|
52
|
+
if (status === "exited" || status === "identity-mismatch")
|
|
53
|
+
return;
|
|
54
|
+
const leaderExited = await processIdentityStatus(owner.pid, owner.birthSignature, owner.startedAt) === "exited";
|
|
55
|
+
if (status === "unverified" && !(options.ownGroup && (leaderExited || options.ownGroup.leaderUnreaped()))) {
|
|
56
|
+
throw new ProcessError("DEVFN_PROCESS_IDENTITY_UNVERIFIED", leaderExited
|
|
57
|
+
? `${label} (PID ${owner.pid}) exited, but processes remain in its process group ${owner.pid}; DevFn cannot verify they are its own and will not signal them.`
|
|
58
|
+
: `PID ${owner.pid} may still be ${label}, but its identity cannot be verified; DevFn will not signal it.`, { pid: owner.pid });
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
// A graceful request can be refused (Windows console processes accept
|
|
62
|
+
// only a forced taskkill); the wait and forced stop below decide.
|
|
63
|
+
try {
|
|
64
|
+
await signalProcessGroup(owner.pid, false);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (process.platform !== "win32")
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
if (await waitForProcessGroupExit(owner, timeoutMs))
|
|
71
|
+
return;
|
|
72
|
+
// Judge the group again right before forcing it. POSIX cannot signal a
|
|
73
|
+
// group atomically with that check; a group ID is not reused while any
|
|
74
|
+
// member remains, so only a group that empties in between is exposed.
|
|
75
|
+
const remaining = await processGroupStatus(owner.pid, owner.birthSignature, owner.startedAt);
|
|
76
|
+
if (remaining === "exited" || remaining === "identity-mismatch")
|
|
77
|
+
return;
|
|
78
|
+
if (process.platform !== "win32" || await processIdentityStatus(owner.pid, owner.birthSignature, owner.startedAt) === "running" || options.ownGroup?.leaderUnreaped())
|
|
79
|
+
await signalProcessGroup(owner.pid, true);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
throw new ProcessError("DEVFN_PROCESS_STOP_FAILED", `Unable to stop ${label} (PID ${owner.pid}).`, { pid: owner.pid, cause: error instanceof Error ? error.message : String(error) });
|
|
83
|
+
}
|
|
84
|
+
if (!await waitForProcessGroupExit(owner, 5_000)) {
|
|
85
|
+
throw new ProcessError("DEVFN_PROCESS_STOP_FAILED", `${label} (PID ${owner.pid}) or a process in its group did not exit after forced termination.`, { pid: owner.pid });
|
|
86
|
+
}
|
|
87
|
+
}
|
package/dist/identity.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { matchesProcessIdentity, processBirthSignature, processExists } from "@devfn/config";
|
|
1
|
+
export { classifyProcessIdentity, matchesProcessIdentity, processBirthSignature, processExists, processGroupStatus, processIdentityStatus, startedProcessBirthSignature, type ProcessGroupStatus, type ProcessIdentityStatus } from "@devfn/config";
|
package/dist/identity.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { matchesProcessIdentity, processBirthSignature, processExists } from "@devfn/config";
|
|
1
|
+
export { classifyProcessIdentity, matchesProcessIdentity, processBirthSignature, processExists, processGroupStatus, processIdentityStatus, startedProcessBirthSignature } from "@devfn/config";
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { createProcessEnvironment, resolveAdapterCommand } from "./adapters.js";
|
|
2
|
-
export { matchesProcessIdentity, processBirthSignature, processExists } from "./identity.js";
|
|
3
|
-
export {
|
|
2
|
+
export { classifyProcessIdentity, matchesProcessIdentity, processBirthSignature, processExists, processGroupStatus, processIdentityStatus, startedProcessBirthSignature, type ProcessGroupStatus, type ProcessIdentityStatus } from "./identity.js";
|
|
3
|
+
export { stopProcessGroup } from "./group.js";
|
|
4
|
+
export { checkReadinessNow, resolveHttpReadinessUrl, waitForReadiness, type ReadinessInput } from "./readiness.js";
|
|
5
|
+
export { gatedLauncherStatus, runGatedCommand, stopGatedLauncher, type GatedCommandOptions, type GatedCommandRunner } from "./gated.js";
|
|
4
6
|
export { createStreamingRedactor, type StreamingRedactor } from "./redaction.js";
|
|
5
7
|
export { ProcessSupervisor } from "./supervisor.js";
|
|
6
8
|
export * from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { createProcessEnvironment, resolveAdapterCommand } from "./adapters.js";
|
|
2
|
-
export { matchesProcessIdentity, processBirthSignature, processExists } from "./identity.js";
|
|
3
|
-
export {
|
|
2
|
+
export { classifyProcessIdentity, matchesProcessIdentity, processBirthSignature, processExists, processGroupStatus, processIdentityStatus, startedProcessBirthSignature } from "./identity.js";
|
|
3
|
+
export { stopProcessGroup } from "./group.js";
|
|
4
|
+
export { checkReadinessNow, resolveHttpReadinessUrl, waitForReadiness } from "./readiness.js";
|
|
5
|
+
export { gatedLauncherStatus, runGatedCommand, stopGatedLauncher } from "./gated.js";
|
|
4
6
|
export { createStreamingRedactor } from "./redaction.js";
|
|
5
7
|
export { ProcessSupervisor } from "./supervisor.js";
|
|
6
8
|
export * from "./types.js";
|
package/dist/readiness.d.ts
CHANGED
|
@@ -10,5 +10,9 @@ export type ReadinessInput = {
|
|
|
10
10
|
readLog?: () => Promise<string>;
|
|
11
11
|
previouslyReady?: boolean;
|
|
12
12
|
};
|
|
13
|
+
/** Resolve the endpoint used by startup and later readiness probes. */
|
|
14
|
+
export declare function resolveHttpReadinessUrl(health: Extract<HealthCheck, {
|
|
15
|
+
type: "http";
|
|
16
|
+
}>, ports: Readonly<Record<string, number>>): string;
|
|
13
17
|
export declare function checkReadinessNow(input: ReadinessInput): Promise<boolean>;
|
|
14
18
|
export declare function waitForReadiness(input: ReadinessInput): Promise<void>;
|
package/dist/readiness.js
CHANGED
|
@@ -64,32 +64,52 @@ async function readLogWindow(logPath) {
|
|
|
64
64
|
await handle.close();
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
-
function
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
67
|
+
function appendHttpHealthPath(url, healthPath) {
|
|
68
|
+
const parsed = new URL(url);
|
|
69
|
+
const baseSearch = parsed.search;
|
|
70
|
+
const baseHash = parsed.hash;
|
|
71
|
+
parsed.search = "";
|
|
72
|
+
parsed.hash = "";
|
|
73
|
+
parsed.pathname = `${parsed.pathname.replace(/\/$/, "")}/`;
|
|
74
|
+
const appended = new URL(healthPath.replace(/^\/+/, ""), parsed);
|
|
75
|
+
if (appended.origin !== parsed.origin)
|
|
76
|
+
throw new Error("HTTP readiness path must stay on the configured URL origin.");
|
|
77
|
+
if (appended.username || appended.password)
|
|
78
|
+
throw new Error("HTTP readiness URL credentials must use the secret channel.");
|
|
79
|
+
if (!appended.search)
|
|
80
|
+
appended.search = baseSearch;
|
|
81
|
+
if (!appended.hash)
|
|
82
|
+
appended.hash = baseHash;
|
|
83
|
+
return appended.toString();
|
|
84
|
+
}
|
|
85
|
+
function leasedHttpHealthUrl(url, healthPath, port) {
|
|
86
|
+
const source = url ? new URL(url) : undefined;
|
|
87
|
+
const protocol = source?.protocol ?? "http:";
|
|
88
|
+
const suffix = source ? `${source.pathname}${source.search}${source.hash}` : healthPath ?? "/";
|
|
89
|
+
return `${protocol}//127.0.0.1:${port}${suffix}`;
|
|
90
|
+
}
|
|
91
|
+
/** Resolve the endpoint used by startup and later readiness probes. */
|
|
92
|
+
export function resolveHttpReadinessUrl(health, ports) {
|
|
93
|
+
const port = health.port ? ports[health.port] : undefined;
|
|
94
|
+
const configured = health.url ? new URL(health.url) : undefined;
|
|
95
|
+
if (configured && configured.protocol !== "http:" && configured.protocol !== "https:")
|
|
96
|
+
throw new Error("HTTP readiness URL must use http or https.");
|
|
97
|
+
if (configured && (configured.username || configured.password))
|
|
98
|
+
throw new Error("HTTP readiness URL credentials must use the secret channel.");
|
|
99
|
+
let url = health.url ?? "";
|
|
100
|
+
if (health.url && health.path)
|
|
101
|
+
url = appendHttpHealthPath(url, health.path);
|
|
102
|
+
// Validate an absolute path against the configured origin first. The direct
|
|
103
|
+
// lease replaces the origin only after that relationship is established.
|
|
104
|
+
if (port !== undefined)
|
|
105
|
+
url = leasedHttpHealthUrl(url, health.path, port);
|
|
86
106
|
const parsed = new URL(url);
|
|
87
107
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
88
108
|
throw new Error("HTTP readiness URL must use http or https.");
|
|
89
109
|
return parsed.toString();
|
|
90
110
|
}
|
|
91
111
|
async function httpReady(health, input, timeoutMs) {
|
|
92
|
-
const url =
|
|
112
|
+
const url = resolveHttpReadinessUrl(health, input.ports);
|
|
93
113
|
const controller = new AbortController();
|
|
94
114
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
95
115
|
try {
|
|
@@ -169,7 +189,7 @@ export async function waitForReadiness(input) {
|
|
|
169
189
|
}
|
|
170
190
|
if (input.health.type === "http") {
|
|
171
191
|
try {
|
|
172
|
-
|
|
192
|
+
resolveHttpReadinessUrl(input.health, input.ports);
|
|
173
193
|
}
|
|
174
194
|
catch (error) {
|
|
175
195
|
throw new ProcessError("DEVFN_PROCESS_NOT_READY", "Invalid HTTP readiness configuration.", { cause: error instanceof Error ? error.message : String(error) });
|
package/dist/supervisor.d.ts
CHANGED
|
@@ -1,10 +1,39 @@
|
|
|
1
|
-
import { type ManagedProcess, type StartProcessInput } from "./types.js";
|
|
1
|
+
import { type ManagedProcess, type ProcessRunResult, type RunProcessInput, type StartProcessInput } from "./types.js";
|
|
2
2
|
export declare function prepareProcessLog(logPath: string, resetSensitiveHistory: boolean): Promise<{
|
|
3
3
|
logFd: number;
|
|
4
4
|
logOffset: number;
|
|
5
5
|
}>;
|
|
6
6
|
export declare class ProcessSupervisor {
|
|
7
7
|
start(input: StartProcessInput): Promise<ManagedProcess>;
|
|
8
|
+
/**
|
|
9
|
+
* Run a finite command to completion through the same gated wrapper: it
|
|
10
|
+
* starts only after onStarted recorded its identity (a failed recording
|
|
11
|
+
* means it never ran), and a timeout or abort stops its whole process
|
|
12
|
+
* group. Whatever it left in its group is stopped once it exits. Only exit
|
|
13
|
+
* code 0 succeeds; nothing the command changed is undone. Both limits bound
|
|
14
|
+
* the recording too: a run that throws never ran its command.
|
|
15
|
+
*/
|
|
16
|
+
run(input: RunProcessInput): Promise<ProcessRunResult>;
|
|
17
|
+
/**
|
|
18
|
+
* Await onStarted until it settles or a run limit is reached. Returns why
|
|
19
|
+
* the recording failed; the caller checks the limits as it opens the gate,
|
|
20
|
+
* so a recording that settles after the run gave up never opens it.
|
|
21
|
+
*/
|
|
22
|
+
private recordIdentity;
|
|
23
|
+
/** Spawn the gated wrapper for a command, its identity read and its output going to a private log. */
|
|
24
|
+
private launch;
|
|
25
|
+
/**
|
|
26
|
+
* Stop a process and everything its wrapper's group still runs. A reused
|
|
27
|
+
* PID is reported as a mismatch; an unreadable identity, or a wrapper that
|
|
28
|
+
* exited while its group lives on, is never signalled and never reported
|
|
29
|
+
* gone.
|
|
30
|
+
*/
|
|
8
31
|
stop(managed: ManagedProcess, timeoutMs?: number): Promise<void>;
|
|
9
|
-
|
|
32
|
+
/**
|
|
33
|
+
* "stopped" and "identity-mismatch" prove nothing the process ran remains;
|
|
34
|
+
* "unverified" is a live PID whose identity cannot be read, or a wrapper
|
|
35
|
+
* that exited while processes remain in its group, either of which may
|
|
36
|
+
* still be it and must never be treated as stopped.
|
|
37
|
+
*/
|
|
38
|
+
status(managed: ManagedProcess): Promise<"running" | "stopped" | "identity-mismatch" | "unverified">;
|
|
10
39
|
}
|
package/dist/supervisor.js
CHANGED
|
@@ -5,27 +5,25 @@ import path from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { resolveContainedPath } from "@devfn/config";
|
|
7
7
|
import { createProcessEnvironment, resolveAdapterCommand } from "./adapters.js";
|
|
8
|
-
import {
|
|
8
|
+
import { stopProcessGroup } from "./group.js";
|
|
9
|
+
import { processExists, processGroupStatus, startedProcessBirthSignature } from "./identity.js";
|
|
9
10
|
import { waitForReadiness } from "./readiness.js";
|
|
10
11
|
import { ProcessError } from "./types.js";
|
|
11
|
-
|
|
12
|
-
async function
|
|
13
|
-
if (
|
|
14
|
-
|
|
15
|
-
return;
|
|
16
|
-
}
|
|
12
|
+
/** Let the wrapper run its command and wait until it detached from the channel. */
|
|
13
|
+
async function openLaunchGate(child) {
|
|
14
|
+
if (!child.connected)
|
|
15
|
+
throw new ProcessError("DEVFN_PROCESS_START_FAILED", "Process wrapper exited before its launch gate opened.");
|
|
17
16
|
await new Promise((resolve, reject) => {
|
|
18
|
-
const
|
|
19
|
-
child.once("
|
|
20
|
-
child.
|
|
17
|
+
const timer = setTimeout(() => reject(new ProcessError("DEVFN_PROCESS_START_FAILED", "Process wrapper did not acknowledge its launch gate.")), 10_000);
|
|
18
|
+
child.once("disconnect", () => { clearTimeout(timer); resolve(); });
|
|
19
|
+
child.send("start", (error) => {
|
|
20
|
+
if (!error)
|
|
21
|
+
return;
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
reject(new ProcessError("DEVFN_PROCESS_START_FAILED", "Unable to open the process launch gate.", { cause: error.message }));
|
|
24
|
+
});
|
|
21
25
|
});
|
|
22
26
|
}
|
|
23
|
-
async function waitForProcessExit(pid, timeoutMs) {
|
|
24
|
-
const deadline = Date.now() + timeoutMs;
|
|
25
|
-
while (Date.now() < deadline && processExists(pid))
|
|
26
|
-
await delay(50);
|
|
27
|
-
return !processExists(pid);
|
|
28
|
-
}
|
|
29
27
|
export async function prepareProcessLog(logPath, resetSensitiveHistory) {
|
|
30
28
|
const existing = await lstat(logPath).catch((error) => {
|
|
31
29
|
if (error.code === "ENOENT")
|
|
@@ -50,13 +48,160 @@ export async function prepareProcessLog(logPath, resetSensitiveHistory) {
|
|
|
50
48
|
throw new ProcessError("DEVFN_PROCESS_START_FAILED", `Unable to open process log ${logPath} safely.`, { cause: error instanceof Error ? error.message : String(error) });
|
|
51
49
|
}
|
|
52
50
|
}
|
|
51
|
+
// A process a finite command left behind can hold its output open after the
|
|
52
|
+
// command exits; the wrapper stops waiting for that output after this long,
|
|
53
|
+
// so the run ends with the command and the leftover is stopped with its group.
|
|
54
|
+
const RUN_DRAIN_MS = 1_000;
|
|
55
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
56
|
+
const errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
57
|
+
/** The first of a run's timeout and its abort signal, once either is reached. */
|
|
58
|
+
function runLimit(timeoutMs, signal) {
|
|
59
|
+
let current;
|
|
60
|
+
let timer;
|
|
61
|
+
let onAbort;
|
|
62
|
+
const reached = new Promise((resolve) => {
|
|
63
|
+
const reach = (limit) => { current ??= limit; resolve(current); };
|
|
64
|
+
timer = setTimeout(() => reach("timed-out"), timeoutMs);
|
|
65
|
+
onAbort = () => reach("cancelled");
|
|
66
|
+
if (signal?.aborted)
|
|
67
|
+
onAbort();
|
|
68
|
+
else
|
|
69
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
70
|
+
});
|
|
71
|
+
return { reached, current: () => current, dispose: () => { clearTimeout(timer); if (onAbort)
|
|
72
|
+
signal?.removeEventListener("abort", onAbort); } };
|
|
73
|
+
}
|
|
74
|
+
/** Why a run must never start its command once a limit was reached. */
|
|
75
|
+
function notStarted(input, reached) {
|
|
76
|
+
if (reached === undefined)
|
|
77
|
+
return undefined;
|
|
78
|
+
const why = reached === "timed-out" ? `timed out after ${input.timeoutMs} ms` : "was cancelled";
|
|
79
|
+
return { reason: new ProcessError("DEVFN_PROCESS_START_FAILED", `Task ${input.name} ${why} before it started.`) };
|
|
80
|
+
}
|
|
81
|
+
function runResult(managed, ended, stopError, error) {
|
|
82
|
+
let outcome;
|
|
83
|
+
if (typeof ended === "string")
|
|
84
|
+
outcome = ended;
|
|
85
|
+
else
|
|
86
|
+
outcome = ended.code === 0 ? "succeeded" : "failed";
|
|
87
|
+
const status = typeof ended === "string" ? { code: null, signal: null } : ended;
|
|
88
|
+
return { outcome, exitCode: status.code, signal: status.signal, process: managed, ...(error ? { error } : {}), ...(stopError ? { stopError } : {}) };
|
|
89
|
+
}
|
|
53
90
|
export class ProcessSupervisor {
|
|
54
91
|
async start(input) {
|
|
55
92
|
const command = resolveAdapterCommand(input.spec);
|
|
93
|
+
const { child, managed, logOffset, cwd, environment, exited } = await this.launch({ name: input.name, command, root: input.root, cwd: input.spec.cwd,
|
|
94
|
+
runtimeDir: input.runtimeDir, environment: createProcessEnvironment(input.spec, input.environment), redactKeys: input.spec.secretEnv ?? [],
|
|
95
|
+
...(input.spec.shutdownTimeoutMs === undefined ? {} : { shutdownTimeoutMs: input.spec.shutdownTimeoutMs }) });
|
|
96
|
+
try {
|
|
97
|
+
try {
|
|
98
|
+
await input.onStarted?.(managed);
|
|
99
|
+
await openLaunchGate(child);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
if (child.connected)
|
|
103
|
+
child.disconnect();
|
|
104
|
+
}
|
|
105
|
+
await waitForReadiness({ health: input.spec.health, ports: input.ports ?? {}, logPath: managed.logPath, logOffset, cwd, environment, isAlive: () => !exited() && processExists(managed.pid) });
|
|
106
|
+
managed.readyAt = new Date().toISOString();
|
|
107
|
+
return managed;
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
await stopProcessGroup(managed, managed.name, input.spec.shutdownTimeoutMs ?? 10_000, { ownGroup: { leaderUnreaped: () => !exited() } }).catch(() => undefined);
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Run a finite command to completion through the same gated wrapper: it
|
|
116
|
+
* starts only after onStarted recorded its identity (a failed recording
|
|
117
|
+
* means it never ran), and a timeout or abort stops its whole process
|
|
118
|
+
* group. Whatever it left in its group is stopped once it exits. Only exit
|
|
119
|
+
* code 0 succeeds; nothing the command changed is undone. Both limits bound
|
|
120
|
+
* the recording too: a run that throws never ran its command.
|
|
121
|
+
*/
|
|
122
|
+
async run(input) {
|
|
123
|
+
// A timer clamps anything else to 1 ms, which would end every run at once.
|
|
124
|
+
if (!Number.isInteger(input.timeoutMs) || input.timeoutMs < 1 || input.timeoutMs > MAX_TIMER_MS) {
|
|
125
|
+
throw new ProcessError("DEVFN_PROCESS_START_FAILED", `Task ${input.name} has an invalid timeout of ${input.timeoutMs} ms.`);
|
|
126
|
+
}
|
|
127
|
+
const { child, managed, exited } = await this.launch({ ...input,
|
|
128
|
+
wrapperEnvironment: { DEVFN_WRAPPER_DRAIN_MS: String(RUN_DRAIN_MS), ...(input.discardOutput ? { DEVFN_DISCARD_OUTPUT: "1" } : {}) } });
|
|
129
|
+
let settle;
|
|
130
|
+
const exit = new Promise((resolve) => { settle = resolve; });
|
|
131
|
+
if (exited())
|
|
132
|
+
settle({ code: child.exitCode, signal: child.signalCode });
|
|
133
|
+
else
|
|
134
|
+
child.once("exit", (code, signal) => settle({ code, signal }));
|
|
135
|
+
// Once stopped, or past stopping, the wrapper no longer holds the caller's event loop open.
|
|
136
|
+
// A stop failure never hides the command's own outcome; the caller reports both.
|
|
137
|
+
const stop = async () => {
|
|
138
|
+
try {
|
|
139
|
+
await stopProcessGroup(managed, managed.name, input.shutdownTimeoutMs ?? 10_000, { ownGroup: { leaderUnreaped: () => !exited() } });
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
return errorMessage(error);
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
child.unref();
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
child.ref();
|
|
150
|
+
// The timeout and abort bound the whole run: the identity recording and the gate too.
|
|
151
|
+
const limit = runLimit(input.timeoutMs, input.signal);
|
|
152
|
+
try {
|
|
153
|
+
// Decided in the turn that sends start, so a limit reached any time before the send keeps the gate closed.
|
|
154
|
+
const unstarted = await this.recordIdentity(input, managed, limit) ?? notStarted(input, limit.current());
|
|
155
|
+
if (unstarted) {
|
|
156
|
+
// Closing the channel before the gate opens makes the wrapper exit without running the command.
|
|
157
|
+
if (child.connected)
|
|
158
|
+
child.disconnect();
|
|
159
|
+
const stopError = await stop();
|
|
160
|
+
throw stopError ? new ProcessError("DEVFN_PROCESS_START_FAILED", errorMessage(unstarted.reason), { stopError }) : unstarted.reason;
|
|
161
|
+
}
|
|
162
|
+
const gate = openLaunchGate(child);
|
|
163
|
+
gate.catch(() => undefined);
|
|
164
|
+
let ended;
|
|
165
|
+
let error;
|
|
166
|
+
try {
|
|
167
|
+
ended = await Promise.race([gate.then(() => exit), limit.reached]);
|
|
168
|
+
}
|
|
169
|
+
catch (error_) {
|
|
170
|
+
// The wrapper may have received the gate and started the command, so it ends as a failed run.
|
|
171
|
+
ended = { code: null, signal: null };
|
|
172
|
+
error = errorMessage(error_);
|
|
173
|
+
}
|
|
174
|
+
if (child.connected)
|
|
175
|
+
child.disconnect();
|
|
176
|
+
return runResult(managed, ended, await stop(), error);
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
limit.dispose();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Await onStarted until it settles or a run limit is reached. Returns why
|
|
184
|
+
* the recording failed; the caller checks the limits as it opens the gate,
|
|
185
|
+
* so a recording that settles after the run gave up never opens it.
|
|
186
|
+
*/
|
|
187
|
+
async recordIdentity(input, managed, limit) {
|
|
188
|
+
const recorded = Promise.resolve().then(async () => { await input.onStarted?.(managed); });
|
|
189
|
+
recorded.catch(() => undefined);
|
|
190
|
+
try {
|
|
191
|
+
await Promise.race([recorded, limit.reached]);
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
return { reason: error };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** Spawn the gated wrapper for a command, its identity read and its output going to a private log. */
|
|
199
|
+
async launch(input) {
|
|
200
|
+
const { command } = input;
|
|
56
201
|
if (command.length === 0)
|
|
57
202
|
throw new ProcessError("DEVFN_PROCESS_START_FAILED", `Process ${input.name} has no command.`);
|
|
58
|
-
const cwd = await resolveContainedPath(input.root, input.
|
|
59
|
-
throw new ProcessError("DEVFN_PROCESS_START_FAILED", `Process cwd escapes repository: ${input.
|
|
203
|
+
const cwd = await resolveContainedPath(input.root, input.cwd ?? ".", `processes.${input.name}.cwd`).catch((error) => {
|
|
204
|
+
throw new ProcessError("DEVFN_PROCESS_START_FAILED", `Process cwd escapes repository: ${input.cwd ?? "."}`, { cause: error instanceof Error ? error.message : String(error) });
|
|
60
205
|
});
|
|
61
206
|
const logsDir = path.join(input.runtimeDir, "logs");
|
|
62
207
|
await mkdir(logsDir, { recursive: true, mode: 0o700 });
|
|
@@ -64,15 +209,21 @@ export class ProcessSupervisor {
|
|
|
64
209
|
throw new ProcessError("DEVFN_PROCESS_START_FAILED", `Invalid process name ${input.name}.`);
|
|
65
210
|
const logPath = path.join(logsDir, `${input.name}.log`);
|
|
66
211
|
mkdirSync(path.dirname(logPath), { recursive: true });
|
|
67
|
-
|
|
68
|
-
|
|
212
|
+
// Only discardOutput may ask the wrapper to discard output, never the
|
|
213
|
+
// given environment (in any key casing, as Windows reads it).
|
|
214
|
+
const environment = { ...input.environment };
|
|
215
|
+
for (const key of Object.keys(environment))
|
|
216
|
+
if (key.toUpperCase() === "DEVFN_DISCARD_OUTPUT")
|
|
217
|
+
delete environment[key];
|
|
218
|
+
const { logFd, logOffset } = await prepareProcessLog(logPath, Boolean(input.redactKeys?.length) || input.discardOutput === true);
|
|
69
219
|
const wrapperPath = fileURLToPath(new URL("./wrapper.js", import.meta.url));
|
|
70
220
|
const child = spawn(process.execPath, [wrapperPath], {
|
|
71
221
|
cwd,
|
|
72
|
-
env: { ...environment, DEVFN_WRAPPED_COMMAND: JSON.stringify(command), DEVFN_REDACT_KEYS: JSON.stringify(input.
|
|
222
|
+
env: { ...environment, DEVFN_WRAPPED_COMMAND: JSON.stringify(command), DEVFN_REDACT_KEYS: JSON.stringify(input.redactKeys ?? []), ...input.wrapperEnvironment },
|
|
73
223
|
detached: process.platform !== "win32",
|
|
74
224
|
windowsHide: true,
|
|
75
|
-
|
|
225
|
+
// The IPC channel gates the command until its identity is recorded.
|
|
226
|
+
stdio: ["ignore", logFd, logFd, "ipc"],
|
|
76
227
|
});
|
|
77
228
|
let exited = false;
|
|
78
229
|
child.once("exit", () => { exited = true; });
|
|
@@ -85,13 +236,10 @@ export class ProcessSupervisor {
|
|
|
85
236
|
if (!child.pid)
|
|
86
237
|
throw new ProcessError("DEVFN_PROCESS_START_FAILED", `Process ${input.name} did not receive a PID.`);
|
|
87
238
|
child.unref();
|
|
88
|
-
|
|
89
|
-
for (let attempt = 0; attempt < 10 && !birthSignature; attempt += 1) {
|
|
90
|
-
birthSignature = await processBirthSignature(child.pid);
|
|
91
|
-
if (!birthSignature)
|
|
92
|
-
await delay(20);
|
|
93
|
-
}
|
|
239
|
+
const birthSignature = await startedProcessBirthSignature(child.pid);
|
|
94
240
|
if (!birthSignature) {
|
|
241
|
+
if (child.connected)
|
|
242
|
+
child.disconnect();
|
|
95
243
|
try {
|
|
96
244
|
if (process.platform !== "win32")
|
|
97
245
|
process.kill(-child.pid, "SIGTERM");
|
|
@@ -109,41 +257,30 @@ export class ProcessSupervisor {
|
|
|
109
257
|
cwd,
|
|
110
258
|
logPath,
|
|
111
259
|
startedAt: new Date().toISOString(),
|
|
112
|
-
...(input.
|
|
260
|
+
...(input.shutdownTimeoutMs === undefined ? {} : { shutdownTimeoutMs: input.shutdownTimeoutMs }),
|
|
113
261
|
};
|
|
114
|
-
|
|
115
|
-
await input.onStarted?.(managed);
|
|
116
|
-
await waitForReadiness({ health: input.spec.health, ports: input.ports ?? {}, logPath, logOffset, cwd, environment, isAlive: () => !exited && processExists(managed.pid) });
|
|
117
|
-
managed.readyAt = new Date().toISOString();
|
|
118
|
-
return managed;
|
|
119
|
-
}
|
|
120
|
-
catch (error) {
|
|
121
|
-
await this.stop(managed, input.spec.shutdownTimeoutMs).catch(() => undefined);
|
|
122
|
-
throw error;
|
|
123
|
-
}
|
|
262
|
+
return { child, managed, logOffset, cwd, environment, exited: () => exited };
|
|
124
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Stop a process and everything its wrapper's group still runs. A reused
|
|
266
|
+
* PID is reported as a mismatch; an unreadable identity, or a wrapper that
|
|
267
|
+
* exited while its group lives on, is never signalled and never reported
|
|
268
|
+
* gone.
|
|
269
|
+
*/
|
|
125
270
|
async stop(managed, timeoutMs = managed.shutdownTimeoutMs ?? 10_000) {
|
|
126
|
-
if (
|
|
127
|
-
return;
|
|
128
|
-
if (!await matchesProcessIdentity(managed.pid, managed.birthSignature)) {
|
|
271
|
+
if (await processGroupStatus(managed.pid, managed.birthSignature, managed.startedAt) === "identity-mismatch") {
|
|
129
272
|
throw new ProcessError("DEVFN_PROCESS_OWNERSHIP_MISMATCH", `PID ${managed.pid} no longer matches the DevFn process identity.`, { name: managed.name, pid: managed.pid });
|
|
130
273
|
}
|
|
131
|
-
|
|
132
|
-
await terminateProcess(managed.pid);
|
|
133
|
-
if (!await waitForProcessExit(managed.pid, timeoutMs)) {
|
|
134
|
-
await terminateProcess(managed.pid, true);
|
|
135
|
-
if (!await waitForProcessExit(managed.pid, 5_000))
|
|
136
|
-
throw new ProcessError("DEVFN_PROCESS_STOP_FAILED", `Process ${managed.name} did not exit after forced termination.`);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
catch (error) {
|
|
140
|
-
if (error.code !== "ESRCH")
|
|
141
|
-
throw new ProcessError("DEVFN_PROCESS_STOP_FAILED", `Unable to stop ${managed.name}.`, { cause: error instanceof Error ? error.message : String(error) });
|
|
142
|
-
}
|
|
274
|
+
await stopProcessGroup(managed, managed.name, timeoutMs);
|
|
143
275
|
}
|
|
276
|
+
/**
|
|
277
|
+
* "stopped" and "identity-mismatch" prove nothing the process ran remains;
|
|
278
|
+
* "unverified" is a live PID whose identity cannot be read, or a wrapper
|
|
279
|
+
* that exited while processes remain in its group, either of which may
|
|
280
|
+
* still be it and must never be treated as stopped.
|
|
281
|
+
*/
|
|
144
282
|
async status(managed) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return await matchesProcessIdentity(managed.pid, managed.birthSignature) ? "running" : "identity-mismatch";
|
|
283
|
+
const status = await processGroupStatus(managed.pid, managed.birthSignature, managed.startedAt);
|
|
284
|
+
return status === "exited" ? "stopped" : status;
|
|
148
285
|
}
|
|
149
286
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -10,6 +10,13 @@ export interface ManagedProcess {
|
|
|
10
10
|
readyAt?: string;
|
|
11
11
|
shutdownTimeoutMs?: number;
|
|
12
12
|
}
|
|
13
|
+
/** A recorded process identity; the birth signature tells a reused PID apart. */
|
|
14
|
+
export interface ProcessOwnerIdentity {
|
|
15
|
+
pid: number;
|
|
16
|
+
birthSignature?: string;
|
|
17
|
+
/** When DevFn recorded this identity, after reading it from the live process. */
|
|
18
|
+
startedAt?: string;
|
|
19
|
+
}
|
|
13
20
|
export interface StartProcessInput {
|
|
14
21
|
name: string;
|
|
15
22
|
spec: ProcessSpec;
|
|
@@ -19,8 +26,37 @@ export interface StartProcessInput {
|
|
|
19
26
|
ports?: Record<string, number>;
|
|
20
27
|
onStarted?: (process: ManagedProcess) => Promise<void>;
|
|
21
28
|
}
|
|
29
|
+
export interface RunProcessInput {
|
|
30
|
+
name: string;
|
|
31
|
+
/** The complete command and environment; nothing is added to them. */
|
|
32
|
+
command: string[];
|
|
33
|
+
environment: NodeJS.ProcessEnv;
|
|
34
|
+
root: string;
|
|
35
|
+
/** Repository-relative working directory. */
|
|
36
|
+
cwd?: string;
|
|
37
|
+
runtimeDir: string;
|
|
38
|
+
/** Environment keys whose values are redacted from the log. */
|
|
39
|
+
redactKeys?: readonly string[];
|
|
40
|
+
/** Keep none of the command's output, for output that may carry values redaction cannot know. */
|
|
41
|
+
discardOutput?: boolean;
|
|
42
|
+
timeoutMs: number;
|
|
43
|
+
signal?: AbortSignal;
|
|
44
|
+
shutdownTimeoutMs?: number;
|
|
45
|
+
/** Record the identity; the command runs only after this resolves. */
|
|
46
|
+
onStarted?: (process: ManagedProcess) => Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
export interface ProcessRunResult {
|
|
49
|
+
outcome: "succeeded" | "failed" | "timed-out" | "cancelled";
|
|
50
|
+
exitCode: number | null;
|
|
51
|
+
signal: NodeJS.Signals | null;
|
|
52
|
+
process: ManagedProcess;
|
|
53
|
+
/** Why a run that may have started has no exit status. */
|
|
54
|
+
error?: string;
|
|
55
|
+
/** Something the command left behind could not be stopped. */
|
|
56
|
+
stopError?: string;
|
|
57
|
+
}
|
|
22
58
|
export declare class ProcessError extends Error {
|
|
23
|
-
readonly code: "DEVFN_PROCESS_START_FAILED" | "DEVFN_PROCESS_NOT_READY" | "DEVFN_PROCESS_OWNERSHIP_MISMATCH" | "DEVFN_PROCESS_STOP_FAILED";
|
|
59
|
+
readonly code: "DEVFN_PROCESS_START_FAILED" | "DEVFN_PROCESS_NOT_READY" | "DEVFN_PROCESS_OWNERSHIP_MISMATCH" | "DEVFN_PROCESS_IDENTITY_UNVERIFIED" | "DEVFN_PROCESS_STOP_FAILED";
|
|
24
60
|
readonly details?: Record<string, unknown> | undefined;
|
|
25
|
-
constructor(code: "DEVFN_PROCESS_START_FAILED" | "DEVFN_PROCESS_NOT_READY" | "DEVFN_PROCESS_OWNERSHIP_MISMATCH" | "DEVFN_PROCESS_STOP_FAILED", message: string, details?: Record<string, unknown> | undefined);
|
|
61
|
+
constructor(code: "DEVFN_PROCESS_START_FAILED" | "DEVFN_PROCESS_NOT_READY" | "DEVFN_PROCESS_OWNERSHIP_MISMATCH" | "DEVFN_PROCESS_IDENTITY_UNVERIFIED" | "DEVFN_PROCESS_STOP_FAILED", message: string, details?: Record<string, unknown> | undefined);
|
|
26
62
|
}
|
package/dist/wrapper.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { constants } from "node:os";
|
|
2
3
|
import { createStreamingRedactor } from "./redaction.js";
|
|
3
4
|
function requiredJson(name) {
|
|
4
5
|
const raw = process.env[name];
|
|
@@ -12,27 +13,81 @@ if (!Array.isArray(command) || command.length === 0 || !command.every((item) =>
|
|
|
12
13
|
throw new Error("Invalid wrapped command.");
|
|
13
14
|
if (!Array.isArray(keys) || !keys.every((item) => typeof item === "string"))
|
|
14
15
|
throw new Error("Invalid redaction keys.");
|
|
16
|
+
// The supervisor opens this gate only after it recorded this process's
|
|
17
|
+
// identity. If it dies first, the channel closes and the command never runs,
|
|
18
|
+
// so a launch without a recorded identity proves nothing of it started.
|
|
19
|
+
// Without the channel there is no gate, so the command never runs.
|
|
20
|
+
if (!process.send) {
|
|
21
|
+
process.stderr.write("The DevFn process wrapper requires its launch gate channel.\n");
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
const opened = await new Promise((resolve) => {
|
|
25
|
+
process.once("message", (message) => resolve(message === "start"));
|
|
26
|
+
process.once("disconnect", () => resolve(false));
|
|
27
|
+
});
|
|
28
|
+
process.removeAllListeners("message");
|
|
29
|
+
process.removeAllListeners("disconnect");
|
|
30
|
+
if (!opened)
|
|
31
|
+
process.exit(1);
|
|
32
|
+
process.disconnect();
|
|
33
|
+
const drainMs = Number(process.env.DEVFN_WRAPPER_DRAIN_MS ?? Number.NaN);
|
|
34
|
+
const discard = process.env.DEVFN_DISCARD_OUTPUT === "1";
|
|
15
35
|
const environment = { ...process.env };
|
|
16
36
|
delete environment.DEVFN_WRAPPED_COMMAND;
|
|
17
37
|
delete environment.DEVFN_REDACT_KEYS;
|
|
38
|
+
delete environment.DEVFN_WRAPPER_DRAIN_MS;
|
|
39
|
+
delete environment.DEVFN_DISCARD_OUTPUT;
|
|
18
40
|
const secrets = [...new Set(keys.map((key) => environment[key]).filter((value) => typeof value === "string" && value.length > 0))].sort((a, b) => b.length - a.length);
|
|
19
|
-
|
|
41
|
+
if (discard)
|
|
42
|
+
process.stdout.write("[DevFn] Output of this command is not kept: it may carry secret values DevFn cannot redact.\n");
|
|
43
|
+
const child = spawn(command[0], command.slice(1), { cwd: process.cwd(), env: environment, stdio: ["ignore", discard ? "ignore" : "pipe", discard ? "ignore" : "pipe"], windowsHide: true });
|
|
44
|
+
/** Forward output, redacted; the returned function flushes what is held back. */
|
|
20
45
|
function pipeRedacted(stream, destination) {
|
|
21
46
|
if (secrets.length === 0) {
|
|
22
47
|
stream.pipe(destination, { end: false });
|
|
23
|
-
return;
|
|
48
|
+
return () => undefined;
|
|
24
49
|
}
|
|
25
50
|
const redactor = createStreamingRedactor(secrets, (value) => destination.write(value));
|
|
51
|
+
let ended = false;
|
|
52
|
+
const end = () => { if (!ended) {
|
|
53
|
+
ended = true;
|
|
54
|
+
redactor.end();
|
|
55
|
+
} };
|
|
26
56
|
stream.setEncoding("utf8");
|
|
27
57
|
stream.on("data", (chunk) => redactor.push(chunk));
|
|
28
|
-
stream.on("end",
|
|
58
|
+
stream.on("end", end);
|
|
59
|
+
return end;
|
|
29
60
|
}
|
|
30
|
-
pipeRedacted(child.stdout, process.stdout);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
61
|
+
const flushes = discard ? [] : [pipeRedacted(child.stdout, process.stdout), pipeRedacted(child.stderr, process.stderr)];
|
|
62
|
+
// Node ignores SIGPIPE and SIGXFSZ and opens its inspector on SIGUSR1, so
|
|
63
|
+
// the wrapper never sends itself those. Whatever becomes of a signal it does
|
|
64
|
+
// send itself, its exit code reports a command a signal ended as failed.
|
|
65
|
+
const unmirrored = new Set(["SIGPIPE", "SIGXFSZ", "SIGUSR1"]);
|
|
66
|
+
const finish = (code, signal) => {
|
|
67
|
+
if (!signal) {
|
|
37
68
|
process.exitCode = code ?? 1;
|
|
38
|
-
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
process.exitCode = 128 + (constants.signals[signal] ?? 0);
|
|
72
|
+
if (!unmirrored.has(signal))
|
|
73
|
+
process.kill(process.pid, signal);
|
|
74
|
+
};
|
|
75
|
+
let closed = false;
|
|
76
|
+
child.once("error", (error) => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; });
|
|
77
|
+
child.once("close", (code, signal) => { closed = true; finish(code, signal); });
|
|
78
|
+
// A process the command left behind can hold its output open after the
|
|
79
|
+
// command exits. With a drain limit the wrapper then stops waiting for that
|
|
80
|
+
// output and exits, so its caller can stop what remains of its group.
|
|
81
|
+
if (Number.isInteger(drainMs) && drainMs >= 0) {
|
|
82
|
+
child.once("exit", (code, signal) => {
|
|
83
|
+
setTimeout(() => {
|
|
84
|
+
if (closed)
|
|
85
|
+
return;
|
|
86
|
+
child.stdout?.destroy();
|
|
87
|
+
child.stderr?.destroy();
|
|
88
|
+
for (const flush of flushes)
|
|
89
|
+
flush();
|
|
90
|
+
finish(code, signal);
|
|
91
|
+
}, drainMs).unref();
|
|
92
|
+
});
|
|
93
|
+
}
|
package/package.json
CHANGED
|
@@ -1,15 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devfn/processes",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Browser-neutral native process supervision and readiness for DevFn and ProbeFn consumers",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/21nCo/superfunctions.git",
|
|
8
|
+
"directory": "devfn/processes"
|
|
9
|
+
},
|
|
5
10
|
"type": "module",
|
|
6
11
|
"main": "./dist/index.js",
|
|
7
12
|
"types": "./dist/index.d.ts",
|
|
8
|
-
"exports": {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.json",
|
|
25
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"lint": "echo 'lint not configured'",
|
|
28
|
+
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@devfn/config": "0.1.1"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.0.0",
|
|
35
|
+
"typescript": "^5.6.0",
|
|
36
|
+
"vitest": "^3.2.4"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=22"
|
|
40
|
+
},
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"gitHead": "699d9d09932199d660333c57fe7bc646492ea3bf"
|
|
15
43
|
}
|