@gtkx/utils 2.0.0-beta.2 → 2.0.0-beta.4
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/dist/process/index.d.ts +2 -2
- package/dist/process/index.d.ts.map +1 -1
- package/dist/process/index.js +2 -2
- package/dist/process/index.js.map +1 -1
- package/dist/process/kill-process-group.d.ts +1 -1
- package/dist/process/kill-process-group.d.ts.map +1 -1
- package/dist/process/kill-process-group.js +2 -2
- package/dist/process/kill-process-group.js.map +1 -1
- package/dist/process/process-guard.js +123 -12
- package/dist/process/process-guard.js.map +1 -1
- package/dist/process/spawn-with-parent-death-signal.d.ts +6 -1
- package/dist/process/spawn-with-parent-death-signal.d.ts.map +1 -1
- package/dist/process/spawn-with-parent-death-signal.js +221 -17
- package/dist/process/spawn-with-parent-death-signal.js.map +1 -1
- package/package.json +1 -1
- package/src/process/index.ts +8 -1
- package/src/process/kill-process-group.ts +2 -2
- package/src/process/process-guard.ts +162 -11
- package/src/process/spawn-with-parent-death-signal.ts +288 -26
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ChildProcess, spawn, type StdioOptions } from "node:child_process";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
3
4
|
import { Socket } from "node:net";
|
|
4
5
|
import { dirname, extname, join } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
@@ -20,22 +21,42 @@ type ParentDeathSpawnOptions = {
|
|
|
20
21
|
cleanupDirectories?: string[];
|
|
21
22
|
};
|
|
22
23
|
|
|
24
|
+
type ParentDeathSupervisorOptions = Omit<ParentDeathSpawnOptions, "cleanupDirectories"> & {
|
|
25
|
+
cleanupDirectory: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type GuardSignal = "SIGKILL" | "SIGCONT";
|
|
29
|
+
|
|
30
|
+
type ProcessIdentity = {
|
|
31
|
+
pid: number;
|
|
32
|
+
startTime: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type ProcessWatch = {
|
|
36
|
+
owner: ProcessIdentity;
|
|
37
|
+
target: ProcessIdentity;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type LaunchArguments = (executable: string, cleanupDirectories: CleanupDirectoryIdentity[]) => string[];
|
|
41
|
+
|
|
23
42
|
type GuardJob = {
|
|
24
43
|
marker: string;
|
|
25
44
|
processGroup: ProcessGroupIdentity;
|
|
26
45
|
cleanupDirectories: CleanupDirectoryIdentity[];
|
|
46
|
+
signal: GuardSignal;
|
|
27
47
|
};
|
|
28
48
|
|
|
29
49
|
type GuardState = {
|
|
30
50
|
child?: ChildProcess | undefined;
|
|
31
51
|
jobs: Map<string, GuardJob>;
|
|
52
|
+
watch?: ProcessWatch;
|
|
32
53
|
};
|
|
33
54
|
|
|
34
55
|
type SpawnRollback = {
|
|
35
56
|
child: ChildProcess;
|
|
36
57
|
marker: string;
|
|
37
|
-
processGroup?: ProcessGroupIdentity
|
|
38
|
-
cleanupDirectories?: CleanupDirectoryIdentity[]
|
|
58
|
+
processGroup?: ProcessGroupIdentity;
|
|
59
|
+
cleanupDirectories?: CleanupDirectoryIdentity[];
|
|
39
60
|
};
|
|
40
61
|
|
|
41
62
|
const MODULE_PATH = fileURLToPath(import.meta.url);
|
|
@@ -43,6 +64,144 @@ const GUARD_PATH = join(dirname(MODULE_PATH), `process-guard${extname(MODULE_PAT
|
|
|
43
64
|
const RUN_ID = randomBytes(8).toString("hex");
|
|
44
65
|
const RUN_PREFIX = `${PROCESS_MARKER}=${RUN_ID}/`;
|
|
45
66
|
const guard: GuardState = { jobs: new Map() };
|
|
67
|
+
const SUPERVISOR_NAME = "gtkx-process-supervisor";
|
|
68
|
+
const SUPERVISOR_CLEANUP_NAME = "gtkx-process-cleanup";
|
|
69
|
+
const SUPERVISOR_WATCH_NAME = "gtkx-process-watch";
|
|
70
|
+
const SUPERVISOR_CLEANUP_BODY = [
|
|
71
|
+
"remaining=40",
|
|
72
|
+
'while [ "$remaining" -gt 0 ] && kill -KILL "-$group" 2>/dev/null; do',
|
|
73
|
+
' "$sleep_command" 0.025',
|
|
74
|
+
" remaining=$((remaining - 1))",
|
|
75
|
+
"done",
|
|
76
|
+
"remaining=40",
|
|
77
|
+
'while [ "$remaining" -gt 0 ] && ' +
|
|
78
|
+
'[ "$("$stat_command" -c "%d:%i:%u" -- "$runtime" 2>/dev/null)" = "$identity" ]; do',
|
|
79
|
+
' "$rm_command" -rf -- "$runtime"',
|
|
80
|
+
' "$sleep_command" 0.025',
|
|
81
|
+
" remaining=$((remaining - 1))",
|
|
82
|
+
"done",
|
|
83
|
+
];
|
|
84
|
+
const SUPERVISOR_CLEANUP_SCRIPT = [
|
|
85
|
+
"runtime=$1",
|
|
86
|
+
"identity=$2",
|
|
87
|
+
"group=$3",
|
|
88
|
+
"stat_command=$4",
|
|
89
|
+
"rm_command=$5",
|
|
90
|
+
"sleep_command=$6",
|
|
91
|
+
...SUPERVISOR_CLEANUP_BODY,
|
|
92
|
+
].join("\n");
|
|
93
|
+
const SUPERVISOR_WATCH_SCRIPT = [
|
|
94
|
+
"runtime=$1",
|
|
95
|
+
"identity=$2",
|
|
96
|
+
"group=$3",
|
|
97
|
+
"group_start=$4",
|
|
98
|
+
"expected_parent=$5",
|
|
99
|
+
"expected_parent_start=$6",
|
|
100
|
+
"stat_command=$7",
|
|
101
|
+
"rm_command=$8",
|
|
102
|
+
"sleep_command=$9",
|
|
103
|
+
"matches_process() {",
|
|
104
|
+
" expected_process=$1",
|
|
105
|
+
" expected_start=$2",
|
|
106
|
+
" process_stat=",
|
|
107
|
+
' IFS= read -r process_stat < "/proc/$expected_process/stat" || return 1',
|
|
108
|
+
" process_tail=${process_stat##*) }",
|
|
109
|
+
" set -- $process_tail",
|
|
110
|
+
' case "$1" in Z|X|x) return 1 ;; esac',
|
|
111
|
+
' [ "${20}" = "$expected_start" ]',
|
|
112
|
+
"}",
|
|
113
|
+
"cleanup() {",
|
|
114
|
+
' trap "" TERM INT HUP',
|
|
115
|
+
...SUPERVISOR_CLEANUP_BODY.map((line) => ` ${line}`),
|
|
116
|
+
" exit 0",
|
|
117
|
+
"}",
|
|
118
|
+
"trap cleanup TERM INT HUP",
|
|
119
|
+
'while matches_process "$group" "$group_start" && ' +
|
|
120
|
+
'matches_process "$expected_parent" "$expected_parent_start"; do',
|
|
121
|
+
' "$sleep_command" 0.1',
|
|
122
|
+
"done",
|
|
123
|
+
"cleanup",
|
|
124
|
+
].join("\n");
|
|
125
|
+
const SUPERVISOR_SCRIPT = [
|
|
126
|
+
"runtime=$1",
|
|
127
|
+
"identity=$2",
|
|
128
|
+
"expected_parent=$3",
|
|
129
|
+
"expected_parent_start=$4",
|
|
130
|
+
"stat_command=$5",
|
|
131
|
+
"rm_command=$6",
|
|
132
|
+
"shell_command=$7",
|
|
133
|
+
"setsid_command=$8",
|
|
134
|
+
"sleep_command=$9",
|
|
135
|
+
"shift 9",
|
|
136
|
+
"watch_name=$1",
|
|
137
|
+
"watch_script=$2",
|
|
138
|
+
"cleanup_name=$3",
|
|
139
|
+
"cleanup_script=$4",
|
|
140
|
+
"shift 4",
|
|
141
|
+
"process_start() {",
|
|
142
|
+
" process_stat=",
|
|
143
|
+
' IFS= read -r process_stat < "/proc/$1/stat" || return 1',
|
|
144
|
+
" process_tail=${process_stat##*) }",
|
|
145
|
+
" set -- $process_tail",
|
|
146
|
+
' case "$1" in Z|X|x) return 1 ;; esac',
|
|
147
|
+
' printf "%s" "${20}"',
|
|
148
|
+
"}",
|
|
149
|
+
"matches_parent() {",
|
|
150
|
+
" parent_stat=",
|
|
151
|
+
' IFS= read -r parent_stat < "/proc/$expected_parent/stat" || return 1',
|
|
152
|
+
" parent_tail=${parent_stat##*) }",
|
|
153
|
+
" set -- $parent_tail",
|
|
154
|
+
' case "$1" in Z|X|x) return 1 ;; esac',
|
|
155
|
+
' [ "${20}" = "$expected_parent_start" ]',
|
|
156
|
+
"}",
|
|
157
|
+
"terminate() {",
|
|
158
|
+
' trap "" CONT TERM INT HUP',
|
|
159
|
+
" while :; do",
|
|
160
|
+
' "$setsid_command" "$shell_command" -c "$cleanup_script" "$cleanup_name" "$runtime" "$identity" "$$" ' +
|
|
161
|
+
'"$stat_command" "$rm_command" "$sleep_command" </dev/null >/dev/null 2>&1 &',
|
|
162
|
+
" cleanup=$!",
|
|
163
|
+
' wait "$cleanup" 2>/dev/null',
|
|
164
|
+
' "$sleep_command" 0.025',
|
|
165
|
+
" done",
|
|
166
|
+
"}",
|
|
167
|
+
"continue_or_terminate() {",
|
|
168
|
+
" remaining=40",
|
|
169
|
+
' while matches_parent && [ "$remaining" -gt 0 ]; do',
|
|
170
|
+
' "$sleep_command" 0.025',
|
|
171
|
+
" remaining=$((remaining - 1))",
|
|
172
|
+
" done",
|
|
173
|
+
" if ! matches_parent; then terminate; fi",
|
|
174
|
+
"}",
|
|
175
|
+
"trap continue_or_terminate CONT",
|
|
176
|
+
"trap terminate TERM INT HUP",
|
|
177
|
+
"supervisor_start=$(process_start \"$$\") || terminate",
|
|
178
|
+
'( unset GTKX_PROCESS_GUARD; exec "$setsid_command" "$shell_command" -c "$watch_script" "$watch_name" "$runtime" ' +
|
|
179
|
+
'"$identity" "$$" "$supervisor_start" "$expected_parent" "$expected_parent_start" "$stat_command" ' +
|
|
180
|
+
'"$rm_command" "$sleep_command" ) </dev/null >/dev/null 2>&1 &',
|
|
181
|
+
"child=",
|
|
182
|
+
"if ! matches_parent; then terminate; fi",
|
|
183
|
+
'"$@" &',
|
|
184
|
+
"child=$!",
|
|
185
|
+
"while :; do",
|
|
186
|
+
' wait "$child"',
|
|
187
|
+
" status=$?",
|
|
188
|
+
' if kill -0 "$child" 2>/dev/null; then continue; fi',
|
|
189
|
+
' exit "$status"',
|
|
190
|
+
"done",
|
|
191
|
+
].join("\n");
|
|
192
|
+
|
|
193
|
+
const processIdentity = (pid: number): ProcessIdentity => {
|
|
194
|
+
const stat = readFileSync(`/proc/${String(pid)}/stat`, "utf8");
|
|
195
|
+
const fields = stat.slice(stat.lastIndexOf(") ") + 2).split(" ", 20);
|
|
196
|
+
const state = fields[0];
|
|
197
|
+
const startTime = fields[19];
|
|
198
|
+
|
|
199
|
+
if (startTime === undefined || state === undefined || ["Z", "X", "x"].includes(state)) {
|
|
200
|
+
throw new Error(`Failed to identify process ${String(pid)}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { pid, startTime };
|
|
204
|
+
};
|
|
46
205
|
|
|
47
206
|
const writeGuardCommand = (child: ChildProcess, operation: "+" | "-", job: GuardJob): void => {
|
|
48
207
|
child.stdin?.write(`${operation}${JSON.stringify(job)}\n`);
|
|
@@ -57,12 +216,41 @@ const releaseGuard = (child: ChildProcess): void => {
|
|
|
57
216
|
}
|
|
58
217
|
};
|
|
59
218
|
|
|
60
|
-
const
|
|
219
|
+
const isSameProcessWatch = (left: ProcessWatch, right: ProcessWatch): boolean =>
|
|
220
|
+
left.owner.pid === right.owner.pid &&
|
|
221
|
+
left.owner.startTime === right.owner.startTime &&
|
|
222
|
+
left.target.pid === right.target.pid &&
|
|
223
|
+
left.target.startTime === right.target.startTime;
|
|
224
|
+
|
|
225
|
+
const configureProcessWatch = (watch?: ProcessWatch): void => {
|
|
226
|
+
if (watch === undefined) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (guard.watch !== undefined && !isSameProcessWatch(guard.watch, watch)) {
|
|
231
|
+
throw new Error("The process guard is already watching another owner");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (guard.child !== undefined && guard.watch === undefined) {
|
|
235
|
+
throw new Error("The process guard was started before its owner was registered");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
guard.watch = watch;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const guardArguments = (): string[] =>
|
|
242
|
+
guard.watch === undefined
|
|
243
|
+
? [GUARD_PATH, RUN_PREFIX]
|
|
244
|
+
: [GUARD_PATH, RUN_PREFIX, JSON.stringify(guard.watch)];
|
|
245
|
+
|
|
246
|
+
const startGuard = (watch?: ProcessWatch): void => {
|
|
247
|
+
configureProcessWatch(watch);
|
|
248
|
+
|
|
61
249
|
if (guard.child) {
|
|
62
250
|
return;
|
|
63
251
|
}
|
|
64
252
|
|
|
65
|
-
const child = spawn(process.execPath,
|
|
253
|
+
const child = spawn(process.execPath, guardArguments(), {
|
|
66
254
|
detached: true,
|
|
67
255
|
stdio: ["pipe", "ignore", "ignore"],
|
|
68
256
|
});
|
|
@@ -94,6 +282,20 @@ const startGuard = (): void => {
|
|
|
94
282
|
releaseGuard(child);
|
|
95
283
|
};
|
|
96
284
|
|
|
285
|
+
const watchParentProcess = (): void => {
|
|
286
|
+
const parentId = process.ppid;
|
|
287
|
+
const watch = {
|
|
288
|
+
owner: processIdentity(parentId),
|
|
289
|
+
target: processIdentity(process.pid),
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
if (process.ppid !== parentId) {
|
|
293
|
+
throw new Error("The parent process exited while its guard was starting");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
startGuard(watch);
|
|
297
|
+
};
|
|
298
|
+
|
|
97
299
|
const registerJob = (job: GuardJob): void => {
|
|
98
300
|
guard.jobs.set(job.marker, job);
|
|
99
301
|
|
|
@@ -124,18 +326,30 @@ const rollbackSpawn = ({ child, marker, processGroup, cleanupDirectories = [] }:
|
|
|
124
326
|
}
|
|
125
327
|
};
|
|
126
328
|
|
|
127
|
-
|
|
329
|
+
const captureCleanupDirectories = (command: string, paths: string[]): CleanupDirectoryIdentity[] => {
|
|
330
|
+
const identities = paths.map((path) => cleanupDirectoryIdentity(path));
|
|
331
|
+
|
|
332
|
+
if (identities.includes(undefined)) {
|
|
333
|
+
throw new Error(`Failed to identify a cleanup directory for ${command}`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return identities.filter((identity): identity is CleanupDirectoryIdentity => identity !== undefined);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const spawnGuarded = (
|
|
128
340
|
command: string,
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
341
|
+
options: ParentDeathSpawnOptions,
|
|
342
|
+
signal: GuardSignal,
|
|
343
|
+
launchArguments: LaunchArguments,
|
|
344
|
+
): ChildProcess => {
|
|
132
345
|
const executable = resolveExecutable(command);
|
|
133
346
|
const setpriv = resolveExecutable("setpriv");
|
|
134
347
|
const jobValue = `${RUN_ID}/${randomBytes(4).toString("hex")}`;
|
|
135
348
|
const marker = `${PROCESS_MARKER}=${jobValue}`;
|
|
349
|
+
const cleanupDirectories = captureCleanupDirectories(command, options.cleanupDirectories ?? []);
|
|
136
350
|
startGuard();
|
|
137
351
|
|
|
138
|
-
const child = spawn(setpriv,
|
|
352
|
+
const child = spawn(setpriv, launchArguments(executable, cleanupDirectories), {
|
|
139
353
|
detached: true,
|
|
140
354
|
stdio: options.stdio ?? "ignore",
|
|
141
355
|
env: { ...(options.env ?? process.env), [PROCESS_MARKER]: jobValue },
|
|
@@ -145,25 +359,11 @@ function spawnWithParentDeathSignal(
|
|
|
145
359
|
const group = processGroupId === undefined ? undefined : processGroupIdentity(processGroupId);
|
|
146
360
|
|
|
147
361
|
if (group === undefined) {
|
|
148
|
-
rollbackSpawn({ child, marker });
|
|
362
|
+
rollbackSpawn({ child, marker, cleanupDirectories });
|
|
149
363
|
throw new Error(`Failed to identify process group for ${command}`);
|
|
150
364
|
}
|
|
151
365
|
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
if (cleanupDirectories.includes(undefined)) {
|
|
155
|
-
const captured = cleanupDirectories.filter(
|
|
156
|
-
(identity): identity is CleanupDirectoryIdentity => identity !== undefined,
|
|
157
|
-
);
|
|
158
|
-
rollbackSpawn({ child, marker, processGroup: group, cleanupDirectories: captured });
|
|
159
|
-
throw new Error(`Failed to identify a cleanup directory for ${command}`);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const job: GuardJob = {
|
|
163
|
-
marker,
|
|
164
|
-
processGroup: group,
|
|
165
|
-
cleanupDirectories: cleanupDirectories.filter((identity) => identity !== undefined),
|
|
166
|
-
};
|
|
366
|
+
const job: GuardJob = { marker, processGroup: group, cleanupDirectories, signal };
|
|
167
367
|
registerJob(job);
|
|
168
368
|
|
|
169
369
|
child.on("exit", () => {
|
|
@@ -172,6 +372,68 @@ function spawnWithParentDeathSignal(
|
|
|
172
372
|
});
|
|
173
373
|
|
|
174
374
|
return child;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
function spawnWithParentDeathSignal(
|
|
378
|
+
command: string,
|
|
379
|
+
args: string[],
|
|
380
|
+
options: ParentDeathSpawnOptions = {},
|
|
381
|
+
): ChildProcess {
|
|
382
|
+
return spawnGuarded(
|
|
383
|
+
command,
|
|
384
|
+
options,
|
|
385
|
+
"SIGKILL",
|
|
386
|
+
(executable) => ["--pdeathsig", "SIGKILL", executable, ...args],
|
|
387
|
+
);
|
|
175
388
|
}
|
|
176
389
|
|
|
177
|
-
|
|
390
|
+
const spawnWithParentDeathSupervisor = (
|
|
391
|
+
command: string,
|
|
392
|
+
args: string[],
|
|
393
|
+
options: ParentDeathSupervisorOptions,
|
|
394
|
+
): ChildProcess => {
|
|
395
|
+
const shell = resolveExecutable("sh");
|
|
396
|
+
const stat = resolveExecutable("stat");
|
|
397
|
+
const rm = resolveExecutable("rm");
|
|
398
|
+
const setsid = resolveExecutable("setsid");
|
|
399
|
+
const sleep = resolveExecutable("sleep");
|
|
400
|
+
|
|
401
|
+
return spawnGuarded(
|
|
402
|
+
command,
|
|
403
|
+
{ ...options, cleanupDirectories: [options.cleanupDirectory] },
|
|
404
|
+
"SIGCONT",
|
|
405
|
+
(executable, cleanupDirectories) => {
|
|
406
|
+
const identity = cleanupDirectories[0];
|
|
407
|
+
|
|
408
|
+
if (identity === undefined) {
|
|
409
|
+
throw new Error(`Failed to identify a cleanup directory for ${command}`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return [
|
|
413
|
+
"--pdeathsig",
|
|
414
|
+
"SIGCONT",
|
|
415
|
+
shell,
|
|
416
|
+
"-c",
|
|
417
|
+
SUPERVISOR_SCRIPT,
|
|
418
|
+
SUPERVISOR_NAME,
|
|
419
|
+
identity.path,
|
|
420
|
+
`${identity.device}:${identity.inode}:${identity.userId}`,
|
|
421
|
+
String(process.pid),
|
|
422
|
+
processIdentity(process.pid).startTime,
|
|
423
|
+
stat,
|
|
424
|
+
rm,
|
|
425
|
+
shell,
|
|
426
|
+
setsid,
|
|
427
|
+
sleep,
|
|
428
|
+
SUPERVISOR_WATCH_NAME,
|
|
429
|
+
SUPERVISOR_WATCH_SCRIPT,
|
|
430
|
+
SUPERVISOR_CLEANUP_NAME,
|
|
431
|
+
SUPERVISOR_CLEANUP_SCRIPT,
|
|
432
|
+
executable,
|
|
433
|
+
...args,
|
|
434
|
+
];
|
|
435
|
+
},
|
|
436
|
+
);
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
export { spawnWithParentDeathSignal, spawnWithParentDeathSupervisor, watchParentProcess };
|