@gtkx/utils 2.0.0-beta.1 → 2.0.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +43 -40
  2. package/dist/process/index.d.ts +3 -1
  3. package/dist/process/index.d.ts.map +1 -1
  4. package/dist/process/index.js +3 -1
  5. package/dist/process/index.js.map +1 -1
  6. package/dist/process/kill-marked-processes.d.ts +3 -2
  7. package/dist/process/kill-marked-processes.d.ts.map +1 -1
  8. package/dist/process/kill-marked-processes.js +39 -13
  9. package/dist/process/kill-marked-processes.js.map +1 -1
  10. package/dist/process/kill-process-group.d.ts +16 -0
  11. package/dist/process/kill-process-group.d.ts.map +1 -0
  12. package/dist/process/kill-process-group.js +70 -0
  13. package/dist/process/kill-process-group.js.map +1 -0
  14. package/dist/process/process-guard.js +192 -6
  15. package/dist/process/process-guard.js.map +1 -1
  16. package/dist/process/process-status.d.ts +5 -0
  17. package/dist/process/process-status.d.ts.map +1 -0
  18. package/dist/process/process-status.js +19 -0
  19. package/dist/process/process-status.js.map +1 -0
  20. package/dist/process/spawn-with-parent-death-signal.d.ts +7 -1
  21. package/dist/process/spawn-with-parent-death-signal.d.ts.map +1 -1
  22. package/dist/process/spawn-with-parent-death-signal.js +315 -8
  23. package/dist/process/spawn-with-parent-death-signal.js.map +1 -1
  24. package/package.json +4 -2
  25. package/src/process/index.ts +14 -1
  26. package/src/process/kill-marked-processes.ts +58 -13
  27. package/src/process/kill-process-group.ts +105 -0
  28. package/src/process/process-guard.ts +261 -6
  29. package/src/process/process-status.ts +25 -0
  30. package/src/process/spawn-with-parent-death-signal.ts +422 -11
@@ -1,25 +1,224 @@
1
1
  import { type ChildProcess, spawn, type StdioOptions } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
+ import { readdirSync, 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";
7
+ import { warn } from "../log/default-logger.ts";
6
8
  import { killMarkedProcesses, PROCESS_MARKER } from "./kill-marked-processes.ts";
9
+ import {
10
+ type CleanupDirectoryIdentity,
11
+ cleanupDirectoryIdentity,
12
+ killProcessGroup,
13
+ type ProcessGroupIdentity,
14
+ processGroupIdentity,
15
+ removeCleanupDirectory,
16
+ } from "./kill-process-group.ts";
17
+ import { isReapedState, readProcessStatFields } from "./process-status.ts";
7
18
  import { resolveExecutable } from "./resolve-executable.ts";
8
19
 
9
20
  type ParentDeathSpawnOptions = {
10
21
  stdio?: StdioOptions;
11
22
  env?: NodeJS.ProcessEnv;
23
+ cleanupDirectories?: string[];
24
+ };
25
+
26
+ type ParentDeathSupervisorOptions = Omit<ParentDeathSpawnOptions, "cleanupDirectories"> & {
27
+ cleanupDirectory: string;
28
+ };
29
+
30
+ type GuardSignal = "SIGKILL" | "SIGCONT";
31
+
32
+ type ProcessIdentity = {
33
+ pid: number;
34
+ startTime: string;
35
+ };
36
+
37
+ type ProcessWatch = {
38
+ owner: ProcessIdentity;
39
+ target: ProcessIdentity;
40
+ };
41
+
42
+ type LaunchArguments = (executable: string, cleanupDirectories: CleanupDirectoryIdentity[]) => string[];
43
+
44
+ type GuardJob = {
45
+ marker: string;
46
+ processGroup: ProcessGroupIdentity;
47
+ cleanupDirectories: CleanupDirectoryIdentity[];
48
+ signal: GuardSignal;
12
49
  };
13
50
 
14
51
  type GuardState = {
15
52
  child?: ChildProcess | undefined;
53
+ jobs: Map<string, GuardJob>;
54
+ watch?: ProcessWatch;
55
+ };
56
+
57
+ type SpawnRollback = {
58
+ child: ChildProcess;
59
+ marker: string;
60
+ processGroup?: ProcessGroupIdentity;
61
+ cleanupDirectories?: CleanupDirectoryIdentity[];
16
62
  };
17
63
 
18
64
  const MODULE_PATH = fileURLToPath(import.meta.url);
19
65
  const GUARD_PATH = join(dirname(MODULE_PATH), `process-guard${extname(MODULE_PATH)}`);
20
66
  const RUN_ID = randomBytes(8).toString("hex");
21
67
  const RUN_PREFIX = `${PROCESS_MARKER}=${RUN_ID}/`;
22
- const guard: GuardState = {};
68
+ const guard: GuardState = { jobs: new Map() };
69
+ const SUPERVISOR_NAME = "gtkx-process-supervisor";
70
+ const SUPERVISOR_CLEANUP_NAME = "gtkx-process-cleanup";
71
+ const SUPERVISOR_WATCH_NAME = "gtkx-process-watch";
72
+ const SUPERVISOR_KILL_GROUP_BODY = [
73
+ "remaining=40",
74
+ 'while [ "$remaining" -gt 0 ] && kill -KILL "-$group" 2>/dev/null; do',
75
+ ' "$sleep_command" 0.025',
76
+ " remaining=$((remaining - 1))",
77
+ "done",
78
+ ];
79
+ const SUPERVISOR_REMOVE_RUNTIME_BODY = [
80
+ "remaining=40",
81
+ 'while [ "$remaining" -gt 0 ] && ' +
82
+ '[ "$("$stat_command" -c "%d:%i:%u" -- "$runtime" 2>/dev/null)" = "$identity" ]; do',
83
+ ' "$rm_command" -rf -- "$runtime"',
84
+ ' "$sleep_command" 0.025',
85
+ " remaining=$((remaining - 1))",
86
+ "done",
87
+ ];
88
+ const SUPERVISOR_CLEANUP_BODY = [...SUPERVISOR_KILL_GROUP_BODY, ...SUPERVISOR_REMOVE_RUNTIME_BODY];
89
+ const SUPERVISOR_CLEANUP_SCRIPT = [
90
+ "runtime=$1",
91
+ "identity=$2",
92
+ "group=$3",
93
+ "stat_command=$4",
94
+ "rm_command=$5",
95
+ "sleep_command=$6",
96
+ ...SUPERVISOR_CLEANUP_BODY,
97
+ ].join("\n");
98
+ const SUPERVISOR_WATCH_SCRIPT = [
99
+ "runtime=$1",
100
+ "identity=$2",
101
+ "group=$3",
102
+ "group_start=$4",
103
+ "expected_parent=$5",
104
+ "expected_parent_start=$6",
105
+ "stat_command=$7",
106
+ "rm_command=$8",
107
+ "sleep_command=$9",
108
+ "matches_process() {",
109
+ " expected_process=$1",
110
+ " expected_start=$2",
111
+ " process_stat=",
112
+ ' IFS= read -r process_stat < "/proc/$expected_process/stat" || return 1',
113
+ " process_tail=${process_stat##*) }",
114
+ " set -- $process_tail",
115
+ ' case "$1" in Z|X|x) return 1 ;; esac',
116
+ ' [ "${20}" = "$expected_start" ]',
117
+ "}",
118
+ "matches_parent() {",
119
+ ' matches_process "$expected_parent" "$expected_parent_start"',
120
+ "}",
121
+ "kill_group() {",
122
+ ...SUPERVISOR_KILL_GROUP_BODY.map((line) => ` ${line}`),
123
+ "}",
124
+ "cleanup() {",
125
+ ' trap "" TERM INT HUP',
126
+ " kill_group",
127
+ ...SUPERVISOR_REMOVE_RUNTIME_BODY.map((line) => ` ${line}`),
128
+ " exit 0",
129
+ "}",
130
+ "trap cleanup TERM INT HUP",
131
+ 'while matches_process "$group" "$group_start" && matches_parent; do',
132
+ ' "$sleep_command" 0.1',
133
+ "done",
134
+ "kill_group",
135
+ "while matches_parent; do",
136
+ ' "$sleep_command" 0.1',
137
+ "done",
138
+ "cleanup",
139
+ ].join("\n");
140
+ const SUPERVISOR_SCRIPT = [
141
+ "runtime=$1",
142
+ "identity=$2",
143
+ "expected_parent=$3",
144
+ "expected_parent_start=$4",
145
+ "stat_command=$5",
146
+ "rm_command=$6",
147
+ "shell_command=$7",
148
+ "setsid_command=$8",
149
+ "sleep_command=$9",
150
+ "shift 9",
151
+ "watch_name=$1",
152
+ "watch_script=$2",
153
+ "cleanup_name=$3",
154
+ "cleanup_script=$4",
155
+ "shift 4",
156
+ "process_start() {",
157
+ " process_stat=",
158
+ ' IFS= read -r process_stat < "/proc/$1/stat" || return 1',
159
+ " process_tail=${process_stat##*) }",
160
+ " set -- $process_tail",
161
+ ' case "$1" in Z|X|x) return 1 ;; esac',
162
+ ' printf "%s" "${20}"',
163
+ "}",
164
+ "matches_parent() {",
165
+ " parent_stat=",
166
+ ' IFS= read -r parent_stat < "/proc/$expected_parent/stat" || return 1',
167
+ " parent_tail=${parent_stat##*) }",
168
+ " set -- $parent_tail",
169
+ ' case "$1" in Z|X|x) return 1 ;; esac',
170
+ ' [ "${20}" = "$expected_parent_start" ]',
171
+ "}",
172
+ "terminate() {",
173
+ ' trap "" CONT TERM INT HUP',
174
+ " while :; do",
175
+ ' "$setsid_command" "$shell_command" -c "$cleanup_script" "$cleanup_name" "$runtime" "$identity" "$$" ' +
176
+ '"$stat_command" "$rm_command" "$sleep_command" </dev/null >/dev/null 2>&1 &',
177
+ " cleanup=$!",
178
+ ' wait "$cleanup" 2>/dev/null',
179
+ ' "$sleep_command" 0.025',
180
+ " done",
181
+ "}",
182
+ "continue_or_terminate() {",
183
+ " remaining=40",
184
+ ' while matches_parent && [ "$remaining" -gt 0 ]; do',
185
+ ' "$sleep_command" 0.025',
186
+ " remaining=$((remaining - 1))",
187
+ " done",
188
+ " if ! matches_parent; then terminate; fi",
189
+ "}",
190
+ "trap continue_or_terminate CONT",
191
+ "trap terminate TERM INT HUP",
192
+ "supervisor_start=$(process_start \"$$\") || terminate",
193
+ '( unset GTKX_PROCESS_GUARD; exec "$setsid_command" "$shell_command" -c "$watch_script" "$watch_name" "$runtime" ' +
194
+ '"$identity" "$$" "$supervisor_start" "$expected_parent" "$expected_parent_start" "$stat_command" ' +
195
+ '"$rm_command" "$sleep_command" ) </dev/null >/dev/null 2>&1 &',
196
+ "child=",
197
+ "if ! matches_parent; then terminate; fi",
198
+ '"$@" &',
199
+ "child=$!",
200
+ "while :; do",
201
+ ' wait "$child"',
202
+ " status=$?",
203
+ ' if kill -0 "$child" 2>/dev/null; then continue; fi',
204
+ ' exit "$status"',
205
+ "done",
206
+ ].join("\n");
207
+
208
+ const processIdentity = (pid: number): ProcessIdentity => {
209
+ const fields = readProcessStatFields(pid);
210
+ const startTime = fields?.[19];
211
+
212
+ if (startTime === undefined || isReapedState(fields?.[0])) {
213
+ throw new Error(`Failed to identify process ${String(pid)}`);
214
+ }
215
+
216
+ return { pid, startTime };
217
+ };
218
+
219
+ const writeGuardCommand = (child: ChildProcess, operation: "+" | "-", job: GuardJob): void => {
220
+ child.stdin?.write(`${operation}${JSON.stringify(job)}\n`);
221
+ };
23
222
 
24
223
  const releaseGuard = (child: ChildProcess): void => {
25
224
  child.unref();
@@ -30,45 +229,257 @@ const releaseGuard = (child: ChildProcess): void => {
30
229
  }
31
230
  };
32
231
 
33
- const startGuard = (): void => {
232
+ const isSameProcessWatch = (left: ProcessWatch, right: ProcessWatch): boolean =>
233
+ left.owner.pid === right.owner.pid &&
234
+ left.owner.startTime === right.owner.startTime &&
235
+ left.target.pid === right.target.pid &&
236
+ left.target.startTime === right.target.startTime;
237
+
238
+ const configureProcessWatch = (watch?: ProcessWatch): void => {
239
+ if (watch === undefined) {
240
+ return;
241
+ }
242
+
243
+ if (guard.watch !== undefined && !isSameProcessWatch(guard.watch, watch)) {
244
+ throw new Error("The process guard is already watching another owner");
245
+ }
246
+
247
+ if (guard.child !== undefined && guard.watch === undefined) {
248
+ throw new Error("The process guard was started before its owner was registered");
249
+ }
250
+
251
+ guard.watch = watch;
252
+ };
253
+
254
+ const guardArguments = (): string[] =>
255
+ guard.watch === undefined
256
+ ? [GUARD_PATH, RUN_PREFIX]
257
+ : [GUARD_PATH, RUN_PREFIX, JSON.stringify(guard.watch)];
258
+
259
+ const startGuard = (watch?: ProcessWatch): void => {
260
+ configureProcessWatch(watch);
261
+
34
262
  if (guard.child) {
35
263
  return;
36
264
  }
37
265
 
38
- const child = spawn(process.execPath, [GUARD_PATH, RUN_PREFIX], {
266
+ const child = spawn(process.execPath, guardArguments(), {
39
267
  detached: true,
40
268
  stdio: ["pipe", "ignore", "ignore"],
41
269
  });
42
270
 
43
271
  child.on("error", () => {
44
- guard.child = undefined;
272
+ if (guard.child === child) {
273
+ guard.child = undefined;
274
+ }
275
+ });
276
+
277
+ child.on("exit", () => {
278
+ if (guard.child === child) {
279
+ guard.child = undefined;
280
+ }
281
+ });
282
+
283
+ child.stdin.on("error", () => {
284
+ if (guard.child === child) {
285
+ guard.child = undefined;
286
+ }
45
287
  });
46
288
 
47
289
  guard.child = child;
290
+
291
+ for (const job of guard.jobs.values()) {
292
+ writeGuardCommand(child, "+", job);
293
+ }
294
+
48
295
  releaseGuard(child);
49
296
  };
50
297
 
51
- function spawnWithParentDeathSignal(
298
+ const watchParentProcess = (): void => {
299
+ const parentId = process.ppid;
300
+ const watch = {
301
+ owner: processIdentity(parentId),
302
+ target: processIdentity(process.pid),
303
+ };
304
+
305
+ if (process.ppid !== parentId) {
306
+ throw new Error("The parent process exited while its guard was starting");
307
+ }
308
+
309
+ startGuard(watch);
310
+ };
311
+
312
+ const registerJob = (job: GuardJob): void => {
313
+ guard.jobs.set(job.marker, job);
314
+
315
+ if (guard.child) {
316
+ writeGuardCommand(guard.child, "+", job);
317
+ }
318
+ };
319
+
320
+ const unregisterJob = (job: GuardJob): void => {
321
+ guard.jobs.delete(job.marker);
322
+
323
+ if (guard.child) {
324
+ writeGuardCommand(guard.child, "-", job);
325
+ }
326
+ };
327
+
328
+ const rollbackSpawn = ({ child, marker, processGroup, cleanupDirectories = [] }: SpawnRollback): void => {
329
+ if (processGroup === undefined) {
330
+ child.kill("SIGKILL");
331
+ } else {
332
+ killProcessGroup(processGroup);
333
+ }
334
+
335
+ killMarkedProcesses(marker);
336
+
337
+ for (const identity of cleanupDirectories) {
338
+ removeCleanupDirectory(identity);
339
+ }
340
+ };
341
+
342
+ const captureCleanupDirectories = (command: string, paths: string[]): CleanupDirectoryIdentity[] => {
343
+ const identities = paths.map((path) => cleanupDirectoryIdentity(path));
344
+
345
+ if (identities.includes(undefined)) {
346
+ throw new Error(`Failed to identify a cleanup directory for ${command}`);
347
+ }
348
+
349
+ return identities.filter((identity): identity is CleanupDirectoryIdentity => identity !== undefined);
350
+ };
351
+
352
+ const readResource = (path: string): string => {
353
+ try {
354
+ return readFileSync(path, "utf8").trim();
355
+ } catch {
356
+ return "unknown";
357
+ }
358
+ };
359
+
360
+ const openDescriptors = (): string => {
361
+ try {
362
+ return String(readdirSync("/proc/self/fd").length);
363
+ } catch {
364
+ return "unknown";
365
+ }
366
+ };
367
+
368
+ const resourceSummary = (): string =>
369
+ `open descriptors ${openDescriptors()}, ` +
370
+ `cgroup pids ${readResource("/sys/fs/cgroup/pids.current")}/${readResource("/sys/fs/cgroup/pids.max")}`;
371
+
372
+ const spawnedProcessGroup = (child: ChildProcess, command: string): ProcessGroupIdentity | undefined => {
373
+ const processGroupId = child.pid;
374
+
375
+ if (processGroupId !== undefined) {
376
+ return processGroupIdentity(processGroupId);
377
+ }
378
+
379
+ child.on("error", (cause: NodeJS.ErrnoException) => {
380
+ warn(`Cannot spawn ${command}: ${cause.code ?? cause.message}`);
381
+ });
382
+
383
+ return undefined;
384
+ };
385
+
386
+ const spawnGuarded = (
52
387
  command: string,
53
- args: string[],
54
- options: ParentDeathSpawnOptions = {},
55
- ): ChildProcess {
388
+ options: ParentDeathSpawnOptions,
389
+ signal: GuardSignal,
390
+ launchArguments: LaunchArguments,
391
+ ): ChildProcess => {
56
392
  const executable = resolveExecutable(command);
57
393
  const setpriv = resolveExecutable("setpriv");
58
394
  const jobValue = `${RUN_ID}/${randomBytes(4).toString("hex")}`;
395
+ const marker = `${PROCESS_MARKER}=${jobValue}`;
396
+ const cleanupDirectories = captureCleanupDirectories(command, options.cleanupDirectories ?? []);
59
397
  startGuard();
60
398
 
61
- const child = spawn(setpriv, ["--pdeathsig", "SIGKILL", executable, ...args], {
399
+ const child = spawn(setpriv, launchArguments(executable, cleanupDirectories), {
62
400
  detached: true,
63
401
  stdio: options.stdio ?? "ignore",
64
402
  env: { ...(options.env ?? process.env), [PROCESS_MARKER]: jobValue },
65
403
  });
66
404
 
405
+ const group = spawnedProcessGroup(child, command);
406
+
407
+ if (group === undefined) {
408
+ rollbackSpawn({ child, marker, cleanupDirectories });
409
+ throw new Error(`Failed to spawn ${command} or identify its process group (${resourceSummary()})`);
410
+ }
411
+
412
+ const job: GuardJob = { marker, processGroup: group, cleanupDirectories, signal };
413
+ registerJob(job);
414
+
67
415
  child.on("exit", () => {
68
- killMarkedProcesses(`${PROCESS_MARKER}=${jobValue}`);
416
+ killMarkedProcesses(job.marker);
417
+ unregisterJob(job);
69
418
  });
70
419
 
71
420
  return child;
421
+ };
422
+
423
+ function spawnWithParentDeathSignal(
424
+ command: string,
425
+ args: string[],
426
+ options: ParentDeathSpawnOptions = {},
427
+ ): ChildProcess {
428
+ return spawnGuarded(
429
+ command,
430
+ options,
431
+ "SIGKILL",
432
+ (executable) => ["--pdeathsig", "SIGKILL", executable, ...args],
433
+ );
72
434
  }
73
435
 
74
- export { spawnWithParentDeathSignal };
436
+ const spawnWithParentDeathSupervisor = (
437
+ command: string,
438
+ args: string[],
439
+ options: ParentDeathSupervisorOptions,
440
+ ): ChildProcess => {
441
+ const shell = resolveExecutable("sh");
442
+ const stat = resolveExecutable("stat");
443
+ const rm = resolveExecutable("rm");
444
+ const setsid = resolveExecutable("setsid");
445
+ const sleep = resolveExecutable("sleep");
446
+
447
+ return spawnGuarded(
448
+ command,
449
+ { ...options, cleanupDirectories: [options.cleanupDirectory] },
450
+ "SIGCONT",
451
+ (executable, cleanupDirectories) => {
452
+ const identity = cleanupDirectories[0];
453
+
454
+ if (identity === undefined) {
455
+ throw new Error(`Failed to identify a cleanup directory for ${command}`);
456
+ }
457
+
458
+ return [
459
+ "--pdeathsig",
460
+ "SIGCONT",
461
+ shell,
462
+ "-c",
463
+ SUPERVISOR_SCRIPT,
464
+ SUPERVISOR_NAME,
465
+ identity.path,
466
+ `${identity.device}:${identity.inode}:${identity.userId}`,
467
+ String(process.pid),
468
+ processIdentity(process.pid).startTime,
469
+ stat,
470
+ rm,
471
+ shell,
472
+ setsid,
473
+ sleep,
474
+ SUPERVISOR_WATCH_NAME,
475
+ SUPERVISOR_WATCH_SCRIPT,
476
+ SUPERVISOR_CLEANUP_NAME,
477
+ SUPERVISOR_CLEANUP_SCRIPT,
478
+ executable,
479
+ ...args,
480
+ ];
481
+ },
482
+ );
483
+ };
484
+
485
+ export { spawnWithParentDeathSignal, spawnWithParentDeathSupervisor, watchParentProcess };