@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.
@@ -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 | undefined;
38
- cleanupDirectories?: CleanupDirectoryIdentity[] | undefined;
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 startGuard = (): void => {
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, [GUARD_PATH, RUN_PREFIX], {
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
- function spawnWithParentDeathSignal(
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
- args: string[],
130
- options: ParentDeathSpawnOptions = {},
131
- ): ChildProcess {
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, ["--pdeathsig", "SIGKILL", executable, ...args], {
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 cleanupDirectories = (options.cleanupDirectories ?? []).map((path) => cleanupDirectoryIdentity(path));
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
- export { spawnWithParentDeathSignal };
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 };