@vincemakes/kiso-tools-node 0.45.3 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/index.d.ts +99 -2
- package/dist/index.js +360 -206
- package/dist/process-backend.d.ts +26 -0
- package/dist/process-backend.js +70 -0
- package/dist/process.d.ts +81 -0
- package/dist/process.js +375 -0
- package/dist/task-runner.d.ts +32 -0
- package/dist/task-runner.js +189 -0
- package/package.json +2 -2
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-0058 §6 — the process task backend: it spawns a task's runner.
|
|
3
|
+
*
|
|
4
|
+
* It satisfies the runtime's `TaskBackend` structurally — tools-node takes
|
|
5
|
+
* no dependency on the runtime; the host hands this backend to the
|
|
6
|
+
* runtime's TaskManager, and the compiler checks the fit there.
|
|
7
|
+
*
|
|
8
|
+
* The runner is detached (its own session and process group) and unref'd:
|
|
9
|
+
* kiso's exit or death does not take it along. A clean exit stops tasks
|
|
10
|
+
* through the manager, deliberately.
|
|
11
|
+
*/
|
|
12
|
+
export interface ProcessTaskBackendOptions {
|
|
13
|
+
/** The runner script. Default: the one shipped beside this module. */
|
|
14
|
+
readonly runnerPath?: string;
|
|
15
|
+
/** How long the runner has to record itself. Default 10 s. */
|
|
16
|
+
readonly startTimeoutMs?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface ProcessTaskBackend {
|
|
19
|
+
spawn(spec: {
|
|
20
|
+
readonly dir: string;
|
|
21
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
22
|
+
}): Promise<void>;
|
|
23
|
+
identify(pid: number, startedAt: string): "verified" | "gone" | "unverifiable";
|
|
24
|
+
signalStop(pid: number): void;
|
|
25
|
+
}
|
|
26
|
+
export declare function processTaskBackend(options?: ProcessTaskBackendOptions): ProcessTaskBackend;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-0058 §6 — the process task backend: it spawns a task's runner.
|
|
3
|
+
*
|
|
4
|
+
* It satisfies the runtime's `TaskBackend` structurally — tools-node takes
|
|
5
|
+
* no dependency on the runtime; the host hands this backend to the
|
|
6
|
+
* runtime's TaskManager, and the compiler checks the fit there.
|
|
7
|
+
*
|
|
8
|
+
* The runner is detached (its own session and process group) and unref'd:
|
|
9
|
+
* kiso's exit or death does not take it along. A clean exit stops tasks
|
|
10
|
+
* through the manager, deliberately.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { processStartTime } from "./process.js";
|
|
17
|
+
export function processTaskBackend(options = {}) {
|
|
18
|
+
const runnerPath = options.runnerPath ?? fileURLToPath(new URL("./task-runner.js", import.meta.url));
|
|
19
|
+
return {
|
|
20
|
+
async spawn({ dir, env }) {
|
|
21
|
+
const clean = {};
|
|
22
|
+
for (const [k, v] of Object.entries(env))
|
|
23
|
+
if (v !== undefined)
|
|
24
|
+
clean[k] = v;
|
|
25
|
+
const child = spawn(process.execPath, [runnerPath, dir], { detached: true, windowsHide: true, stdio: "ignore", env: clean });
|
|
26
|
+
child.unref();
|
|
27
|
+
const journal = join(dir, "journal.jsonl");
|
|
28
|
+
const deadline = Date.now() + (options.startTimeoutMs ?? 10_000);
|
|
29
|
+
while (Date.now() < deadline) {
|
|
30
|
+
if (existsSync(journal) && readFileSync(journal, "utf8").includes('"type":"runner_started"'))
|
|
31
|
+
return;
|
|
32
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`the task runner did not record itself within ${options.startTimeoutMs ?? 10_000} ms (${dir})`);
|
|
35
|
+
},
|
|
36
|
+
identify(pid, startedAt) {
|
|
37
|
+
try {
|
|
38
|
+
process.kill(pid, 0);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
if (err.code !== "EPERM")
|
|
42
|
+
return "gone";
|
|
43
|
+
}
|
|
44
|
+
// The pid is live. Only its OS start time says whose it is: equal to
|
|
45
|
+
// the recorded one — the runner; another — a stranger holds the
|
|
46
|
+
// pid; none readable (now, or recorded as "") — unverifiable,
|
|
47
|
+
// which is never taken for the runner (ADR-0058 §6: "when identity
|
|
48
|
+
// cannot be verified, the verdict is the gone row").
|
|
49
|
+
const id = processStartTime(pid);
|
|
50
|
+
if (id.kind === "gone")
|
|
51
|
+
return "gone";
|
|
52
|
+
if (id.kind === "unknown" || startedAt === "")
|
|
53
|
+
return "unverifiable";
|
|
54
|
+
return id.startedAt === startedAt ? "verified" : "gone";
|
|
55
|
+
},
|
|
56
|
+
signalStop(pid) {
|
|
57
|
+
// win32: process.kill is TerminateProcess — the runner would die
|
|
58
|
+
// before its stop runs. The journal's stop_requested record, written
|
|
59
|
+
// before this call, is the channel there; the runner watches it.
|
|
60
|
+
if (process.platform === "win32")
|
|
61
|
+
return;
|
|
62
|
+
try {
|
|
63
|
+
process.kill(pid, "SIGTERM");
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// already gone
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a command starts, how a process tree dies, and a process's start
|
|
3
|
+
* time — the one place the shell tool and the task runner (ADR-0058) both
|
|
4
|
+
* stand on. The win32 branches sit behind the same contracts: kiso's shell
|
|
5
|
+
* commands use POSIX shell syntax, and on Windows they run through Git
|
|
6
|
+
* Bash, so the shell safety checks keep reading the language that runs.
|
|
7
|
+
*/
|
|
8
|
+
import { type ChildProcess, type StdioOptions } from "node:child_process";
|
|
9
|
+
/** The code on the error `startCommand` throws when win32 has no usable bash. */
|
|
10
|
+
export declare const NO_BASH = "KISO_NO_BASH";
|
|
11
|
+
/**
|
|
12
|
+
* Start `command` through the shell. `detached`: the command gets its OWN
|
|
13
|
+
* process group, so a stop can kill the WHOLE TREE (children included), not
|
|
14
|
+
* just the outer shell (Area 4). On win32: `bash -c` with no shell of
|
|
15
|
+
* Node's own and no process group (`killTree` walks the tree by parent);
|
|
16
|
+
* no usable bash throws an error whose `code` is `NO_BASH`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function startCommand(command: string, opts: {
|
|
19
|
+
readonly cwd: string;
|
|
20
|
+
readonly env: NodeJS.ProcessEnv;
|
|
21
|
+
readonly stdio?: StdioOptions;
|
|
22
|
+
}): ChildProcess;
|
|
23
|
+
/**
|
|
24
|
+
* Start `file` with exactly `args` — no shell, so nothing is split, quoted
|
|
25
|
+
* or expanded (ADR-0058 3d: a child kiso is launched this way). Its own
|
|
26
|
+
* process group, like `startCommand`. On win32 it stays detached (a
|
|
27
|
+
* background child outlives its parent) and opens no console window; it
|
|
28
|
+
* never goes through bash or cmd.exe.
|
|
29
|
+
*/
|
|
30
|
+
export declare function startExec(file: string, args: readonly string[], opts: {
|
|
31
|
+
readonly cwd: string;
|
|
32
|
+
readonly env: NodeJS.ProcessEnv;
|
|
33
|
+
readonly stdio?: StdioOptions;
|
|
34
|
+
}): ChildProcess;
|
|
35
|
+
/**
|
|
36
|
+
* Kill the whole tree and CONFIRM it exited (rounds 8/11):
|
|
37
|
+
*
|
|
38
|
+
* 1. FREEZE the root (SIGSTOP) FIRST — a stopped shell cannot fork new
|
|
39
|
+
* descendants while we enumerate;
|
|
40
|
+
* 2. repeatedly discover AND freeze descendants (pid-table sweep — the only
|
|
41
|
+
* way to see a setsid()-escaped process) until the set is STABLE (two
|
|
42
|
+
* identical scans), so the enumeration cannot miss a mid-sweep fork;
|
|
43
|
+
* 3. SIGKILL the process group and every tracked pid;
|
|
44
|
+
* 4. poll every tracked pid to death. Any tracked pid still alive at the
|
|
45
|
+
* deadline comes back in `unconfirmed`: the caller must not report the
|
|
46
|
+
* tree gone — the side effect may have outlived the stop.
|
|
47
|
+
*
|
|
48
|
+
* `graceMs` (ADR-0058, a task's stop): first send SIGTERM to the group and
|
|
49
|
+
* to every descendant seen now, and give the root that long to exit — a
|
|
50
|
+
* server cleans up — then run the same sweep on whatever is left. The
|
|
51
|
+
* descendants seen before the TERM stay tracked, so a child reparented when
|
|
52
|
+
* its parent exits is still found.
|
|
53
|
+
*/
|
|
54
|
+
export declare function killTree(child: ChildProcess, opts?: {
|
|
55
|
+
readonly graceMs?: number;
|
|
56
|
+
}): Promise<{
|
|
57
|
+
unconfirmed: number[];
|
|
58
|
+
}>;
|
|
59
|
+
/**
|
|
60
|
+
* ADR-0058 §6 — a process's identity is its pid AND the time the OS started
|
|
61
|
+
* it: after a runner dies its pid can be handed to an unrelated process, and
|
|
62
|
+
* a live pid alone would then "prove" a task is running. `ps -o lstart=`
|
|
63
|
+
* gives the start time on macOS and Linux alike; on win32 a CIM query does,
|
|
64
|
+
* and says `gone` in so many words. "Cannot ask" is `unknown`, never
|
|
65
|
+
* `gone`: a failed query is not evidence of death.
|
|
66
|
+
*/
|
|
67
|
+
export declare function processStartTime(pid: number): {
|
|
68
|
+
kind: "running";
|
|
69
|
+
startedAt: string;
|
|
70
|
+
} | {
|
|
71
|
+
kind: "gone";
|
|
72
|
+
} | {
|
|
73
|
+
kind: "unknown";
|
|
74
|
+
};
|
|
75
|
+
/** The output file, rotated at the cap so its tail is always kept. */
|
|
76
|
+
export declare class RotatingOutput {
|
|
77
|
+
#private;
|
|
78
|
+
constructor(path: string, cap: number);
|
|
79
|
+
write(chunk: Buffer): void;
|
|
80
|
+
close(): void;
|
|
81
|
+
}
|
package/dist/process.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a command starts, how a process tree dies, and a process's start
|
|
3
|
+
* time — the one place the shell tool and the task runner (ADR-0058) both
|
|
4
|
+
* stand on. The win32 branches sit behind the same contracts: kiso's shell
|
|
5
|
+
* commands use POSIX shell syntax, and on Windows they run through Git
|
|
6
|
+
* Bash, so the shell safety checks keep reading the language that runs.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
9
|
+
import { closeSync, existsSync, openSync, renameSync, writeSync } from "node:fs";
|
|
10
|
+
import { win32 } from "node:path";
|
|
11
|
+
/** Children whose output has closed — the shell tool's `exited`, kept here
|
|
12
|
+
* so `killTree` reads the same moment the tool always did. */
|
|
13
|
+
const closed = new WeakSet();
|
|
14
|
+
/** The code on the error `startCommand` throws when win32 has no usable bash. */
|
|
15
|
+
export const NO_BASH = "KISO_NO_BASH";
|
|
16
|
+
/**
|
|
17
|
+
* Start `command` through the shell. `detached`: the command gets its OWN
|
|
18
|
+
* process group, so a stop can kill the WHOLE TREE (children included), not
|
|
19
|
+
* just the outer shell (Area 4). On win32: `bash -c` with no shell of
|
|
20
|
+
* Node's own and no process group (`killTree` walks the tree by parent);
|
|
21
|
+
* no usable bash throws an error whose `code` is `NO_BASH`.
|
|
22
|
+
*/
|
|
23
|
+
export function startCommand(command, opts) {
|
|
24
|
+
const stdio = opts.stdio ?? ["ignore", "pipe", "pipe"];
|
|
25
|
+
const child = process.platform === "win32"
|
|
26
|
+
? spawn(resolveBash(process.env), ["-c", command], { shell: false, windowsHide: true, cwd: opts.cwd, stdio, env: opts.env })
|
|
27
|
+
: spawn(command, { shell: true, detached: true, cwd: opts.cwd, stdio, env: opts.env });
|
|
28
|
+
child.once("close", () => closed.add(child));
|
|
29
|
+
return child;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The bash a command runs through on win32: `KISO_BASH` (it must name a
|
|
33
|
+
* bash — never a door to PowerShell or cmd — and must exist: no silent
|
|
34
|
+
* fallback), then Git for Windows in Program Files and its x86 twin, then
|
|
35
|
+
* `bash.exe` on PATH, skipping the legacy WSL launcher in System32.
|
|
36
|
+
*/
|
|
37
|
+
function resolveBash(env) {
|
|
38
|
+
const fail = (message) => {
|
|
39
|
+
throw Object.assign(new Error(message), { code: NO_BASH });
|
|
40
|
+
};
|
|
41
|
+
const override = env.KISO_BASH;
|
|
42
|
+
if (override !== undefined && override !== "") {
|
|
43
|
+
if (!/^bash(\.exe)?$/i.test(win32.basename(override))) {
|
|
44
|
+
fail(`KISO_BASH must name a bash executable (bash.exe), not ${win32.basename(override)}: kiso's shell commands are POSIX shell`);
|
|
45
|
+
}
|
|
46
|
+
if (!existsSync(override))
|
|
47
|
+
fail(`KISO_BASH is set to ${override}, which does not exist`);
|
|
48
|
+
return override;
|
|
49
|
+
}
|
|
50
|
+
for (const root of [env.ProgramFiles ?? "C:\\Program Files", env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"]) {
|
|
51
|
+
const candidate = win32.join(root, "Git", "bin", "bash.exe");
|
|
52
|
+
if (existsSync(candidate))
|
|
53
|
+
return candidate;
|
|
54
|
+
}
|
|
55
|
+
const system32 = win32.join(env.SystemRoot ?? "C:\\Windows", "System32").toLowerCase();
|
|
56
|
+
for (const dir of (env.PATH ?? "").split(win32.delimiter)) {
|
|
57
|
+
if (dir === "" || win32.join(dir, ".").toLowerCase() === system32)
|
|
58
|
+
continue;
|
|
59
|
+
const candidate = win32.join(dir, "bash.exe");
|
|
60
|
+
if (existsSync(candidate))
|
|
61
|
+
return candidate;
|
|
62
|
+
}
|
|
63
|
+
return fail("no bash found — kiso runs shell commands through Git Bash on Windows: install Git for Windows, or set KISO_BASH to the path of bash.exe");
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Start `file` with exactly `args` — no shell, so nothing is split, quoted
|
|
67
|
+
* or expanded (ADR-0058 3d: a child kiso is launched this way). Its own
|
|
68
|
+
* process group, like `startCommand`. On win32 it stays detached (a
|
|
69
|
+
* background child outlives its parent) and opens no console window; it
|
|
70
|
+
* never goes through bash or cmd.exe.
|
|
71
|
+
*/
|
|
72
|
+
export function startExec(file, args, opts) {
|
|
73
|
+
const child = spawn(file, [...args], {
|
|
74
|
+
shell: false,
|
|
75
|
+
detached: true,
|
|
76
|
+
...(process.platform === "win32" ? { windowsHide: true } : {}),
|
|
77
|
+
cwd: opts.cwd,
|
|
78
|
+
stdio: opts.stdio ?? ["ignore", "pipe", "pipe"],
|
|
79
|
+
env: opts.env,
|
|
80
|
+
});
|
|
81
|
+
child.once("close", () => closed.add(child));
|
|
82
|
+
return child;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Kill the whole tree and CONFIRM it exited (rounds 8/11):
|
|
86
|
+
*
|
|
87
|
+
* 1. FREEZE the root (SIGSTOP) FIRST — a stopped shell cannot fork new
|
|
88
|
+
* descendants while we enumerate;
|
|
89
|
+
* 2. repeatedly discover AND freeze descendants (pid-table sweep — the only
|
|
90
|
+
* way to see a setsid()-escaped process) until the set is STABLE (two
|
|
91
|
+
* identical scans), so the enumeration cannot miss a mid-sweep fork;
|
|
92
|
+
* 3. SIGKILL the process group and every tracked pid;
|
|
93
|
+
* 4. poll every tracked pid to death. Any tracked pid still alive at the
|
|
94
|
+
* deadline comes back in `unconfirmed`: the caller must not report the
|
|
95
|
+
* tree gone — the side effect may have outlived the stop.
|
|
96
|
+
*
|
|
97
|
+
* `graceMs` (ADR-0058, a task's stop): first send SIGTERM to the group and
|
|
98
|
+
* to every descendant seen now, and give the root that long to exit — a
|
|
99
|
+
* server cleans up — then run the same sweep on whatever is left. The
|
|
100
|
+
* descendants seen before the TERM stay tracked, so a child reparented when
|
|
101
|
+
* its parent exits is still found.
|
|
102
|
+
*/
|
|
103
|
+
export function killTree(child, opts = {}) {
|
|
104
|
+
if (process.platform === "win32")
|
|
105
|
+
return killTreeWin32(child);
|
|
106
|
+
const grace = opts.graceMs ?? 0;
|
|
107
|
+
const pid = child.pid;
|
|
108
|
+
if (grace <= 0 || pid === undefined || pid <= 0 || closed.has(child) || child.exitCode !== null || child.signalCode !== null) {
|
|
109
|
+
return sweep(child, new Set());
|
|
110
|
+
}
|
|
111
|
+
const seen = new Set(descendantsOf(pid));
|
|
112
|
+
try {
|
|
113
|
+
process.kill(-pid, "SIGTERM");
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// the group is already gone
|
|
117
|
+
}
|
|
118
|
+
for (const p of seen) {
|
|
119
|
+
try {
|
|
120
|
+
process.kill(p, "SIGTERM");
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// already gone
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return new Promise((resolve) => {
|
|
127
|
+
const timer = setTimeout(() => resolve(sweep(child, seen)), grace);
|
|
128
|
+
child.once("exit", () => {
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
resolve(sweep(child, seen));
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
function sweep(child, seen) {
|
|
135
|
+
return new Promise((resolveKill) => {
|
|
136
|
+
const tracked = new Set(seen);
|
|
137
|
+
// round 11 (adversarial): the ROOT itself is tracked too — the
|
|
138
|
+
// verdict must not read "aborted" while the root survives.
|
|
139
|
+
// DOCUMENTED LIMITS: (1) a process that forks between SIGSTOP
|
|
140
|
+
// delivery and the next scan, setsids, and is then reparented when
|
|
141
|
+
// its parent is killed can escape the enumeration entirely — it is
|
|
142
|
+
// untracked and unknowable from the pid table; the platform cannot
|
|
143
|
+
// confirm it. (2) if THIS process is killed between the first
|
|
144
|
+
// SIGSTOP and the SIGKILL sweep, the stopped descendants stay
|
|
145
|
+
// permanently stopped (nobody SIGCONTs orphans) — the inherent cost
|
|
146
|
+
// of freeze-first. Both limits are recorded here so no claim of "the
|
|
147
|
+
// whole tree is gone" is ever stronger than what the platform can
|
|
148
|
+
// prove.
|
|
149
|
+
if (child.pid !== undefined && child.pid > 0) {
|
|
150
|
+
tracked.add(child.pid);
|
|
151
|
+
try {
|
|
152
|
+
process.kill(child.pid, "SIGSTOP"); // freeze the root
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// already gone
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (const pid of seen) {
|
|
159
|
+
try {
|
|
160
|
+
process.kill(pid, "SIGSTOP");
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// already gone
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Stable discovery: freeze as we go; stop when two consecutive scans
|
|
167
|
+
// are identical.
|
|
168
|
+
let previous = new Set();
|
|
169
|
+
for (let i = 0; i < 10; i++) {
|
|
170
|
+
const current = new Set(descendantsOf(child.pid ?? 0));
|
|
171
|
+
for (const pid of current) {
|
|
172
|
+
tracked.add(pid);
|
|
173
|
+
try {
|
|
174
|
+
process.kill(pid, "SIGSTOP"); // freeze each descendant
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// already gone
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (current.size === previous.size && [...current].every((pid) => previous.has(pid))) {
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
previous = current;
|
|
184
|
+
}
|
|
185
|
+
// The process group (E group: never kill an undefined/0 pid), which
|
|
186
|
+
// also takes the frozen root down.
|
|
187
|
+
if (child.pid !== undefined && child.pid > 0) {
|
|
188
|
+
try {
|
|
189
|
+
process.kill(-child.pid, "SIGKILL");
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
try {
|
|
193
|
+
child.kill("SIGKILL");
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// already gone
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
for (const pid of tracked) {
|
|
201
|
+
try {
|
|
202
|
+
process.kill(pid, "SIGKILL");
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// already gone
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const confirm = () => {
|
|
209
|
+
void waitAllDead([...tracked]).then((unconfirmed) => resolveKill({ unconfirmed }));
|
|
210
|
+
};
|
|
211
|
+
if (closed.has(child)) {
|
|
212
|
+
confirm();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const fallback = setTimeout(confirm, 2000);
|
|
216
|
+
child.once("close", () => {
|
|
217
|
+
clearTimeout(fallback);
|
|
218
|
+
confirm();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* win32: no process groups, no SIGSTOP, and no TERM a console tree hears —
|
|
224
|
+
* so no grace: `taskkill /T /F` walks the tree from the root by parent and
|
|
225
|
+
* forces it. The root is `unconfirmed` when taskkill cannot start, exits
|
|
226
|
+
* non-zero (some process in the tree survived it), or the root is still
|
|
227
|
+
* alive afterwards.
|
|
228
|
+
*/
|
|
229
|
+
function killTreeWin32(child) {
|
|
230
|
+
const pid = child.pid;
|
|
231
|
+
if (pid === undefined || pid <= 0)
|
|
232
|
+
return Promise.resolve({ unconfirmed: [] });
|
|
233
|
+
let killed = true;
|
|
234
|
+
try {
|
|
235
|
+
execFileSync("taskkill", ["/T", "/F", "/PID", String(pid)], { stdio: "ignore", windowsHide: true });
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
killed = false;
|
|
239
|
+
}
|
|
240
|
+
return waitAllDead([pid]).then((alive) => ({ unconfirmed: killed && alive.length === 0 ? [] : [pid] }));
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* All live pids whose ancestor chain includes `pid`, from the pid table
|
|
244
|
+
* (round 8: `ps -axo pid=,ppid=` — the ONLY way to see a setsid()-escaped
|
|
245
|
+
* process, which is in its own group and invisible to a group kill).
|
|
246
|
+
*/
|
|
247
|
+
function descendantsOf(pid) {
|
|
248
|
+
if (pid <= 0)
|
|
249
|
+
return [];
|
|
250
|
+
let table;
|
|
251
|
+
try {
|
|
252
|
+
table = execFileSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8", maxBuffer: 1 << 20 });
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return [];
|
|
256
|
+
}
|
|
257
|
+
const children = new Map();
|
|
258
|
+
for (const line of table.split("\n")) {
|
|
259
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)$/);
|
|
260
|
+
if (m === null)
|
|
261
|
+
continue;
|
|
262
|
+
const child = Number(m[1]);
|
|
263
|
+
const parent = Number(m[2]);
|
|
264
|
+
if (!children.has(parent))
|
|
265
|
+
children.set(parent, []);
|
|
266
|
+
children.get(parent).push(child);
|
|
267
|
+
}
|
|
268
|
+
const out = [];
|
|
269
|
+
const queue = [pid];
|
|
270
|
+
while (queue.length > 0) {
|
|
271
|
+
const current = queue.shift();
|
|
272
|
+
for (const c of children.get(current) ?? []) {
|
|
273
|
+
out.push(c);
|
|
274
|
+
queue.push(c);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Poll the pid table until NONE of the tracked pids is alive (bounded).
|
|
281
|
+
* Returns the pids still alive at the deadline — the caller MUST NOT
|
|
282
|
+
* report "aborted"/"timed out" while any tracked pid survives (round 11).
|
|
283
|
+
*/
|
|
284
|
+
function waitAllDead(pids) {
|
|
285
|
+
if (pids.length === 0)
|
|
286
|
+
return Promise.resolve([]);
|
|
287
|
+
return new Promise((resolve) => {
|
|
288
|
+
const deadline = Date.now() + 2000;
|
|
289
|
+
const poll = () => {
|
|
290
|
+
const alive = [];
|
|
291
|
+
for (const pid of pids) {
|
|
292
|
+
try {
|
|
293
|
+
process.kill(pid, 0);
|
|
294
|
+
alive.push(pid);
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
if (err.code === "EPERM")
|
|
298
|
+
alive.push(pid);
|
|
299
|
+
// ESRCH — gone
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (alive.length === 0 || Date.now() > deadline)
|
|
303
|
+
return resolve(alive);
|
|
304
|
+
setTimeout(poll, 50);
|
|
305
|
+
};
|
|
306
|
+
poll();
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* ADR-0058 §6 — a process's identity is its pid AND the time the OS started
|
|
311
|
+
* it: after a runner dies its pid can be handed to an unrelated process, and
|
|
312
|
+
* a live pid alone would then "prove" a task is running. `ps -o lstart=`
|
|
313
|
+
* gives the start time on macOS and Linux alike; on win32 a CIM query does,
|
|
314
|
+
* and says `gone` in so many words. "Cannot ask" is `unknown`, never
|
|
315
|
+
* `gone`: a failed query is not evidence of death.
|
|
316
|
+
*/
|
|
317
|
+
export function processStartTime(pid) {
|
|
318
|
+
if (process.platform === "win32") {
|
|
319
|
+
const query = `$p = Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}' -ErrorAction Stop; if ($p) { $p.CreationDate.ToUniversalTime().ToString('o') } else { 'gone' }`;
|
|
320
|
+
try {
|
|
321
|
+
const out = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", query], {
|
|
322
|
+
encoding: "utf8",
|
|
323
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
324
|
+
windowsHide: true,
|
|
325
|
+
}).trim();
|
|
326
|
+
if (out === "gone")
|
|
327
|
+
return { kind: "gone" };
|
|
328
|
+
return /^\d{4}-\d\d-\d\dT[\d:.]+Z$/.test(out) ? { kind: "running", startedAt: out } : { kind: "unknown" };
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return { kind: "unknown" };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
const startedAt = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
336
|
+
return startedAt === "" ? { kind: "gone" } : { kind: "running", startedAt };
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
const e = err;
|
|
340
|
+
// `ps -p` exits 1 with nothing printed when no such process exists;
|
|
341
|
+
// anything else (no `ps`, a signal, other output) could not tell
|
|
342
|
+
if (e.status === 1 && String(e.stdout ?? "").trim() === "")
|
|
343
|
+
return { kind: "gone" };
|
|
344
|
+
return { kind: "unknown" };
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/** The output file, rotated at the cap so its tail is always kept. */
|
|
348
|
+
export class RotatingOutput {
|
|
349
|
+
#path;
|
|
350
|
+
#cap;
|
|
351
|
+
#fd;
|
|
352
|
+
#size = 0;
|
|
353
|
+
constructor(path, cap) {
|
|
354
|
+
this.#path = path;
|
|
355
|
+
this.#cap = cap;
|
|
356
|
+
this.#fd = openSync(path, "a");
|
|
357
|
+
}
|
|
358
|
+
write(chunk) {
|
|
359
|
+
if (this.#size > 0 && this.#size + chunk.length > this.#cap)
|
|
360
|
+
this.#rotate();
|
|
361
|
+
writeSync(this.#fd, chunk);
|
|
362
|
+
this.#size += chunk.length;
|
|
363
|
+
}
|
|
364
|
+
#rotate() {
|
|
365
|
+
closeSync(this.#fd);
|
|
366
|
+
renameSync(this.#path, this.#path.replace(/\.log$/, ".1.log"));
|
|
367
|
+
this.#fd = openSync(this.#path, "a");
|
|
368
|
+
const marker = Buffer.from(`[kiso: output rotated after ${this.#size} bytes — the part before is in output.1.log]\n`);
|
|
369
|
+
writeSync(this.#fd, marker);
|
|
370
|
+
this.#size = marker.length;
|
|
371
|
+
}
|
|
372
|
+
close() {
|
|
373
|
+
closeSync(this.#fd);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-0058 §6 — the task runner: `node task-runner.js <task dir>`.
|
|
3
|
+
*
|
|
4
|
+
* A small process of its own, detached from kiso, that owns one task's
|
|
5
|
+
* command, its output file and its terminal record — and outlives kiso,
|
|
6
|
+
* so a task survives the agent's death and its outcome is still written.
|
|
7
|
+
*
|
|
8
|
+
* Its records, each written and fsynced BEFORE the step it gates:
|
|
9
|
+
* runner_started its pid and OS start time — its verifiable identity
|
|
10
|
+
* command_started then, and only then, the command is spawned
|
|
11
|
+
* ready the first time the output contains `readyWhen`
|
|
12
|
+
* terminal the exit code or signal, once the command's output closed
|
|
13
|
+
*
|
|
14
|
+
* The command runs in its own process group. A stop is asked for by the
|
|
15
|
+
* journal's `stop_requested` record — durable before any signal, and the
|
|
16
|
+
* one channel every platform has (on win32 a signal to the runner is
|
|
17
|
+
* TerminateProcess): the runner watches its journal and stops the whole
|
|
18
|
+
* tree (TERM, a grace, then the confirmed sweep of the process module).
|
|
19
|
+
* SIGTERM is the same stop, sooner, where signals exist.
|
|
20
|
+
*
|
|
21
|
+
* A command that never starts (no shell, a missing cwd) ends with a
|
|
22
|
+
* `terminal` carrying `error` and no exit code — none is invented. A stop that cannot confirm every process dead writes NO
|
|
23
|
+
* terminal — `stop_unconfirmed` names the survivors and the runner exits,
|
|
24
|
+
* so the journal reads `unknown`, never ended. The output passes
|
|
25
|
+
* through the runner so it can rotate at the cap (64 MiB: the file moves to
|
|
26
|
+
* output.1.log and a fresh output.log starts with a marker — the tail,
|
|
27
|
+
* where errors live, is always kept) and match `readyWhen`.
|
|
28
|
+
*
|
|
29
|
+
* Nothing secret is written down: the environment arrives with the process
|
|
30
|
+
* and is handed to the command, never recorded.
|
|
31
|
+
*/
|
|
32
|
+
export {};
|