@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 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`.
@@ -1,3 +1,3 @@
1
- import type { ProcessSpec } from "@devfn/config";
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
  }
@@ -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
+ };
@@ -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
+ }
@@ -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 { checkReadinessNow, waitForReadiness, type ReadinessInput } from "./readiness.js";
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 { checkReadinessNow, waitForReadiness } from "./readiness.js";
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";
@@ -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 resolveHttpUrl(health, input) {
68
- const port = health.port ? input.ports[health.port] : undefined;
69
- let url = health.url ?? `http://127.0.0.1:${port}${health.path ?? "/"}`;
70
- if (health.url && health.path) {
71
- const parsed = new URL(health.url);
72
- const baseSearch = parsed.search;
73
- const baseHash = parsed.hash;
74
- parsed.search = "";
75
- parsed.hash = "";
76
- parsed.pathname = `${parsed.pathname.replace(/\/$/, "")}/`;
77
- const appended = new URL(health.path.replace(/^\/+/, ""), parsed);
78
- if (appended.origin !== parsed.origin)
79
- throw new Error("HTTP readiness path must stay on the configured URL origin.");
80
- if (!appended.search)
81
- appended.search = baseSearch;
82
- if (!appended.hash)
83
- appended.hash = baseHash;
84
- url = appended.toString();
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 = resolveHttpUrl(health, input);
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
- resolveHttpUrl(input.health, input);
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) });
@@ -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
- status(managed: ManagedProcess): Promise<"running" | "stopped" | "identity-mismatch">;
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
  }
@@ -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 { matchesProcessIdentity, processBirthSignature, processExists } from "./identity.js";
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
- const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
12
- async function terminateProcess(pid, force = false) {
13
- if (process.platform !== "win32") {
14
- process.kill(-pid, force ? "SIGKILL" : "SIGTERM");
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 child = spawn("taskkill", ["/pid", String(pid), "/T", ...(force ? ["/F"] : [])], { stdio: "ignore", windowsHide: true });
19
- child.once("error", reject);
20
- child.once("exit", (code) => code === 0 || !processExists(pid) ? resolve() : reject(new Error(`taskkill exited with ${code}`)));
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.spec.cwd ?? ".", `processes.${input.name}.cwd`).catch((error) => {
59
- throw new ProcessError("DEVFN_PROCESS_START_FAILED", `Process cwd escapes repository: ${input.spec.cwd ?? "."}`, { cause: error instanceof Error ? error.message : String(error) });
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
- const { logFd, logOffset } = await prepareProcessLog(logPath, Boolean(input.spec.secretEnv?.length));
68
- const environment = createProcessEnvironment(input.spec, input.environment);
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.spec.secretEnv ?? []) },
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
- stdio: ["ignore", logFd, logFd],
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
- let birthSignature;
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.spec.shutdownTimeoutMs === undefined ? {} : { shutdownTimeoutMs: input.spec.shutdownTimeoutMs }),
260
+ ...(input.shutdownTimeoutMs === undefined ? {} : { shutdownTimeoutMs: input.shutdownTimeoutMs }),
113
261
  };
114
- try {
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 (!processExists(managed.pid))
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
- try {
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
- if (!processExists(managed.pid))
146
- return "stopped";
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
- const child = spawn(command[0], command.slice(1), { cwd: process.cwd(), env: environment, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
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", () => redactor.end());
58
+ stream.on("end", end);
59
+ return end;
29
60
  }
30
- pipeRedacted(child.stdout, process.stdout);
31
- pipeRedacted(child.stderr, process.stderr);
32
- child.once("error", (error) => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; });
33
- child.once("close", (code, signal) => {
34
- if (signal)
35
- process.kill(process.pid, signal);
36
- else
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.0",
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": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
9
- "files": ["dist", "README.md"],
10
- "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json", "test": "vitest run", "lint": "echo 'lint not configured'", "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"" },
11
- "dependencies": { "@devfn/config": "0.1.0" },
12
- "devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0", "vitest": "^3.2.4" },
13
- "engines": { "node": ">=22" },
14
- "license": "MIT"
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
  }