@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,44 +1,89 @@
1
1
  import { readdirSync, readFileSync } from "node:fs";
2
+ import { readProcessStatFields } from "./process-status.ts";
3
+
4
+ type ProcessIdentity = {
5
+ pid: number;
6
+ sessionId: number;
7
+ startTime: string;
8
+ };
9
+
10
+ type MarkerMatcher = (assignment: string) => boolean;
2
11
 
3
12
  const PROCESS_MARKER = "GTKX_PROCESS_GUARD";
4
13
  const MAX_KILL_PASSES = 8;
14
+ const JOB_ID_PATTERN = /^[0-9a-f]{8}$/;
5
15
 
6
- const isMarked = (pid: number, prefix: string): boolean => {
16
+ const processIdentity = (pid: number): ProcessIdentity | undefined => {
17
+ const fields = readProcessStatFields(pid);
18
+ const sessionId = Number(fields?.[3]);
19
+ const startTime = fields?.[19];
20
+
21
+ return startTime !== undefined && Number.isSafeInteger(sessionId)
22
+ ? { pid, sessionId, startTime }
23
+ : undefined;
24
+ };
25
+
26
+ const isMarked = (pid: number, isMatch: MarkerMatcher): boolean => {
7
27
  try {
8
28
  return readFileSync(`/proc/${String(pid)}/environ`, "utf8")
9
29
  .split("\0")
10
- .some((assignment) => assignment.startsWith(prefix));
30
+ .some((assignment) => isMatch(assignment));
11
31
  } catch {
12
32
  return false;
13
33
  }
14
34
  };
15
35
 
16
- const markedPids = (prefix: string): number[] =>
36
+ const markedProcesses = (isMatch: MarkerMatcher): ProcessIdentity[] =>
17
37
  readdirSync("/proc")
18
38
  .map(Number)
19
39
  .filter((pid) => Number.isSafeInteger(pid) && pid > 1 && pid !== process.pid)
20
- .filter((pid) => isMarked(pid, prefix));
40
+ .filter((pid) => isMarked(pid, isMatch))
41
+ .map((pid) => processIdentity(pid))
42
+ .filter((identity): identity is ProcessIdentity => identity !== undefined);
43
+
44
+ const killProcess = (identity: ProcessIdentity, isMatch: MarkerMatcher): void => {
45
+ if (!isMarked(identity.pid, isMatch)) {
46
+ return;
47
+ }
48
+
49
+ const current = processIdentity(identity.pid);
50
+
51
+ if (
52
+ current?.sessionId !== identity.sessionId ||
53
+ current.startTime !== identity.startTime
54
+ ) {
55
+ return;
56
+ }
21
57
 
22
- const killPid = (pid: number): void => {
23
58
  try {
24
- process.kill(pid, "SIGKILL");
59
+ process.kill(identity.pid, "SIGKILL");
25
60
  } catch {
26
61
  return;
27
62
  }
28
63
  };
29
64
 
30
- function killMarkedProcesses(prefix: string): void {
65
+ const killMatchingProcesses = (isMatch: MarkerMatcher): void => {
31
66
  for (let pass = 0; pass < MAX_KILL_PASSES; pass += 1) {
32
- const pids = markedPids(prefix);
67
+ const processes = markedProcesses(isMatch);
33
68
 
34
- if (pids.length === 0) {
69
+ if (processes.length === 0) {
35
70
  return;
36
71
  }
37
72
 
38
- for (const pid of pids) {
39
- killPid(pid);
73
+ for (const identity of processes) {
74
+ killProcess(identity, isMatch);
40
75
  }
41
76
  }
42
- }
77
+ };
78
+
79
+ const killMarkedProcesses = (marker: string): void => {
80
+ killMatchingProcesses((assignment) => assignment === marker);
81
+ };
82
+
83
+ const killMarkedProcessRun = (runPrefix: string): void => {
84
+ killMatchingProcesses((assignment) =>
85
+ assignment.startsWith(runPrefix) && JOB_ID_PATTERN.test(assignment.slice(runPrefix.length)),
86
+ );
87
+ };
43
88
 
44
- export { PROCESS_MARKER, killMarkedProcesses };
89
+ export { PROCESS_MARKER, killMarkedProcessRun, killMarkedProcesses };
@@ -0,0 +1,105 @@
1
+ import { lstatSync, rmSync } from "node:fs";
2
+ import { isAbsolute, resolve } from "node:path";
3
+ import { readProcessStatFields } from "./process-status.ts";
4
+
5
+ type ProcessGroupIdentity = {
6
+ processGroupId: number;
7
+ leaderStartTime: string;
8
+ };
9
+
10
+ type CleanupDirectoryIdentity = {
11
+ path: string;
12
+ device: string;
13
+ inode: string;
14
+ userId: string;
15
+ };
16
+
17
+ const isProcessGroupId = (value: number): boolean => Number.isSafeInteger(value) && value > 1;
18
+
19
+ const processGroupIdentity = (processGroupId: number): ProcessGroupIdentity | undefined => {
20
+ if (!isProcessGroupId(processGroupId)) {
21
+ return undefined;
22
+ }
23
+
24
+ const fields = readProcessStatFields(processGroupId);
25
+
26
+ if (fields === undefined) {
27
+ return undefined;
28
+ }
29
+
30
+ const actualProcessGroupId = Number(fields[2]);
31
+ const sessionId = Number(fields[3]);
32
+ const leaderStartTime = fields[19];
33
+
34
+ return actualProcessGroupId === processGroupId && sessionId === processGroupId && leaderStartTime !== undefined
35
+ ? { processGroupId, leaderStartTime }
36
+ : undefined;
37
+ };
38
+
39
+ const isCurrentProcessGroup = (identity: ProcessGroupIdentity): boolean => {
40
+ const current = processGroupIdentity(identity.processGroupId);
41
+
42
+ return current?.leaderStartTime === identity.leaderStartTime;
43
+ };
44
+
45
+ const killProcessGroup = (identity: ProcessGroupIdentity, signal: NodeJS.Signals = "SIGKILL"): void => {
46
+ if (!isCurrentProcessGroup(identity)) {
47
+ return;
48
+ }
49
+
50
+ try {
51
+ process.kill(-identity.processGroupId, signal);
52
+ } catch {
53
+ return;
54
+ }
55
+ };
56
+
57
+ const cleanupDirectoryIdentity = (path: string): CleanupDirectoryIdentity | undefined => {
58
+ const absolutePath = resolve(path);
59
+
60
+ if (absolutePath === "/" || !isAbsolute(path)) {
61
+ return undefined;
62
+ }
63
+
64
+ try {
65
+ const entry = lstatSync(absolutePath, { bigint: true });
66
+
67
+ return entry.isDirectory()
68
+ ? {
69
+ path: absolutePath,
70
+ device: entry.dev.toString(),
71
+ inode: entry.ino.toString(),
72
+ userId: entry.uid.toString(),
73
+ }
74
+ : undefined;
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ };
79
+
80
+ const removeCleanupDirectory = (identity: CleanupDirectoryIdentity): void => {
81
+ const current = cleanupDirectoryIdentity(identity.path);
82
+
83
+ if (
84
+ current?.device !== identity.device ||
85
+ current.inode !== identity.inode ||
86
+ current.userId !== identity.userId
87
+ ) {
88
+ return;
89
+ }
90
+
91
+ try {
92
+ rmSync(identity.path, { recursive: true, force: true });
93
+ } catch {
94
+ return;
95
+ }
96
+ };
97
+
98
+ export {
99
+ type CleanupDirectoryIdentity,
100
+ cleanupDirectoryIdentity,
101
+ killProcessGroup,
102
+ type ProcessGroupIdentity,
103
+ processGroupIdentity,
104
+ removeCleanupDirectory,
105
+ };
@@ -1,20 +1,275 @@
1
- import { killMarkedProcesses } from "./kill-marked-processes.ts";
1
+ import { readFileSync } from "node:fs";
2
+ import { killMarkedProcesses, killMarkedProcessRun } from "./kill-marked-processes.ts";
3
+ import {
4
+ type CleanupDirectoryIdentity,
5
+ killProcessGroup,
6
+ type ProcessGroupIdentity,
7
+ processGroupIdentity,
8
+ removeCleanupDirectory,
9
+ } from "./kill-process-group.ts";
2
10
 
3
11
  const GUARD_PREFIX = process.argv[2] ?? "";
12
+ const PROCESS_WATCH_ARGUMENT = process.argv[3];
4
13
  const WATCHED_SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"] as const satisfies NodeJS.Signals[];
14
+ const OWNER_POLL_INTERVAL_MS = 50;
15
+ const SUPERVISOR_EXIT_TIMEOUT_MS = 2000;
5
16
 
6
- const sweep = (): void => {
17
+ type ProcessIdentity = {
18
+ pid: number;
19
+ startTime: string;
20
+ };
21
+
22
+ type ProcessWatch = {
23
+ owner: ProcessIdentity;
24
+ target: ProcessIdentity;
25
+ };
26
+
27
+ type GuardJob = {
28
+ marker: string;
29
+ processGroup: ProcessGroupIdentity;
30
+ cleanupDirectories: CleanupDirectoryIdentity[];
31
+ signal: NodeJS.Signals;
32
+ };
33
+
34
+ const state: { bufferedCommands: string; isSweeping: boolean; jobs: Map<string, GuardJob> } = {
35
+ bufferedCommands: "",
36
+ isSweeping: false,
37
+ jobs: new Map(),
38
+ };
39
+
40
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
41
+ typeof value === "object" && value !== null && !Array.isArray(value);
42
+
43
+ const isProcessIdentity = (value: unknown): value is ProcessIdentity =>
44
+ isRecord(value) &&
45
+ typeof value.pid === "number" &&
46
+ Number.isSafeInteger(value.pid) &&
47
+ value.pid > 1 &&
48
+ typeof value.startTime === "string" &&
49
+ /^\d+$/.test(value.startTime);
50
+
51
+ const isProcessWatch = (value: unknown): value is ProcessWatch =>
52
+ isRecord(value) && isProcessIdentity(value.owner) && isProcessIdentity(value.target);
53
+
54
+ const isProcessGroupIdentity = (value: unknown): value is ProcessGroupIdentity =>
55
+ isRecord(value) &&
56
+ typeof value.processGroupId === "number" &&
57
+ Number.isSafeInteger(value.processGroupId) &&
58
+ value.processGroupId > 1 &&
59
+ typeof value.leaderStartTime === "string" &&
60
+ /^\d+$/.test(value.leaderStartTime);
61
+
62
+ const isCleanupDirectoryIdentity = (value: unknown): value is CleanupDirectoryIdentity =>
63
+ isRecord(value) &&
64
+ typeof value.path === "string" &&
65
+ typeof value.device === "string" &&
66
+ typeof value.inode === "string" &&
67
+ typeof value.userId === "string";
68
+
69
+ const isGuardJob = (value: unknown): value is GuardJob =>
70
+ isRecord(value) &&
71
+ typeof value.marker === "string" &&
72
+ isProcessGroupIdentity(value.processGroup) &&
73
+ Array.isArray(value.cleanupDirectories) &&
74
+ value.cleanupDirectories.every(isCleanupDirectoryIdentity) &&
75
+ (value.signal === "SIGKILL" || value.signal === "SIGCONT");
76
+
77
+ const parseProcessWatch = (): ProcessWatch | undefined => {
78
+ if (PROCESS_WATCH_ARGUMENT === undefined) {
79
+ return undefined;
80
+ }
81
+
82
+ try {
83
+ const value: unknown = JSON.parse(PROCESS_WATCH_ARGUMENT);
84
+
85
+ return isProcessWatch(value) ? value : undefined;
86
+ } catch {
87
+ return undefined;
88
+ }
89
+ };
90
+
91
+ const currentProcessIdentity = (pid: number): ProcessIdentity | undefined => {
92
+ try {
93
+ const stat = readFileSync(`/proc/${String(pid)}/stat`, "utf8");
94
+ const fields = stat.slice(stat.lastIndexOf(") ") + 2).split(" ", 20);
95
+ const state = fields[0];
96
+ const startTime = fields[19];
97
+
98
+ return startTime !== undefined && state !== undefined && !["Z", "X", "x"].includes(state)
99
+ ? { pid, startTime }
100
+ : undefined;
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ };
105
+
106
+ const isCurrentProcess = (identity: ProcessIdentity): boolean =>
107
+ currentProcessIdentity(identity.pid)?.startTime === identity.startTime;
108
+
109
+ const killProcess = (identity: ProcessIdentity): void => {
110
+ if (!isCurrentProcess(identity)) {
111
+ return;
112
+ }
113
+
114
+ try {
115
+ process.kill(identity.pid, "SIGKILL");
116
+ } catch {
117
+ return;
118
+ }
119
+ };
120
+
121
+ const applyCommand = (command: string): void => {
122
+ const operation = command[0];
123
+ let value: unknown;
124
+
125
+ try {
126
+ value = JSON.parse(command.slice(1));
127
+ } catch {
128
+ return;
129
+ }
130
+
131
+ if (!isGuardJob(value)) {
132
+ return;
133
+ }
134
+
135
+ if (operation === "+") {
136
+ state.jobs.set(value.marker, value);
137
+ } else if (operation === "-") {
138
+ state.jobs.delete(value.marker);
139
+ }
140
+ };
141
+
142
+ const receiveCommands = (chunk: Buffer | string): void => {
143
+ state.bufferedCommands += chunk.toString();
144
+ const commands = state.bufferedCommands.split("\n");
145
+ state.bufferedCommands = commands.pop() ?? "";
146
+
147
+ for (const command of commands) {
148
+ applyCommand(command);
149
+ }
150
+ };
151
+
152
+ const killJobs = (): void => {
153
+ for (const job of state.jobs.values()) {
154
+ killProcessGroup(job.processGroup, job.signal);
155
+
156
+ if (job.signal === "SIGKILL") {
157
+ killMarkedProcesses(job.marker);
158
+ }
159
+ }
160
+ };
161
+
162
+ const removeCleanupDirectories = (): void => {
163
+ const cleanupDirectories: Map<string, CleanupDirectoryIdentity> = new Map();
164
+
165
+ for (const job of state.jobs.values()) {
166
+ for (const identity of job.cleanupDirectories) {
167
+ cleanupDirectories.set(`${identity.device}:${identity.inode}`, identity);
168
+ }
169
+ }
170
+
171
+ for (const identity of cleanupDirectories.values()) {
172
+ removeCleanupDirectory(identity);
173
+ }
174
+ };
175
+
176
+ const hasRunningSupervisor = (): boolean => {
177
+ for (const job of state.jobs.values()) {
178
+ if (job.signal !== "SIGCONT") {
179
+ continue;
180
+ }
181
+
182
+ const current = processGroupIdentity(job.processGroup.processGroupId);
183
+
184
+ if (current?.leaderStartTime === job.processGroup.leaderStartTime) {
185
+ return true;
186
+ }
187
+ }
188
+
189
+ return false;
190
+ };
191
+
192
+ const forceSupervisors = (): void => {
193
+ for (const job of state.jobs.values()) {
194
+ if (job.signal === "SIGCONT") {
195
+ killProcessGroup(job.processGroup);
196
+ }
197
+ }
198
+ };
199
+
200
+ const finishSweep = (): void => {
7
201
  if (GUARD_PREFIX.length > 0) {
8
- killMarkedProcesses(GUARD_PREFIX);
202
+ killMarkedProcessRun(GUARD_PREFIX);
9
203
  }
10
204
 
205
+ removeCleanupDirectories();
11
206
  process.exit(0);
12
207
  };
13
208
 
209
+ const awaitSupervisors = (deadline: number): void => {
210
+ if (hasRunningSupervisor() && Date.now() < deadline) {
211
+ setTimeout(() => {
212
+ awaitSupervisors(deadline);
213
+ }, OWNER_POLL_INTERVAL_MS);
214
+
215
+ return;
216
+ }
217
+
218
+ forceSupervisors();
219
+ finishSweep();
220
+ };
221
+
222
+ const sweep = (target?: ProcessIdentity): void => {
223
+ if (state.isSweeping) {
224
+ return;
225
+ }
226
+
227
+ state.isSweeping = true;
228
+ applyCommand(state.bufferedCommands);
229
+
230
+ if (target !== undefined) {
231
+ killProcess(target);
232
+ }
233
+
234
+ killJobs();
235
+ awaitSupervisors(Date.now() + SUPERVISOR_EXIT_TIMEOUT_MS);
236
+ };
237
+
238
+ const watch = parseProcessWatch();
239
+
240
+ const startOwnerPoll = (): void => {
241
+ if (watch === undefined) {
242
+ return;
243
+ }
244
+
245
+ const ownerPoll = setInterval(() => {
246
+ if (isCurrentProcess(watch.owner)) {
247
+ return;
248
+ }
249
+
250
+ clearInterval(ownerPoll);
251
+ sweep(watch.target);
252
+ }, OWNER_POLL_INTERVAL_MS);
253
+
254
+ if (!isCurrentProcess(watch.owner)) {
255
+ clearInterval(ownerPoll);
256
+ sweep(watch.target);
257
+ }
258
+ };
259
+
260
+ startOwnerPoll();
261
+
14
262
  process.stdin.resume();
15
- process.stdin.on("end", sweep);
16
- process.stdin.on("error", sweep);
263
+ process.stdin.on("data", receiveCommands);
264
+ process.stdin.on("end", () => {
265
+ sweep();
266
+ });
267
+ process.stdin.on("error", () => {
268
+ sweep();
269
+ });
17
270
 
18
271
  for (const signal of WATCHED_SIGNALS) {
19
- process.on(signal, sweep);
272
+ process.on(signal, () => {
273
+ sweep();
274
+ });
20
275
  }
@@ -0,0 +1,25 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ const REAPED_STATES: ReadonlySet<string> = new Set(["Z", "X", "x"]);
4
+
5
+ const readProcessStatFields = (pid: number): string[] | undefined => {
6
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
7
+ return undefined;
8
+ }
9
+
10
+ try {
11
+ const stat = readFileSync(`/proc/${String(pid)}/stat`, "utf8");
12
+ const separator = stat.lastIndexOf(") ");
13
+
14
+ return separator === -1 ? undefined : stat.slice(separator + 2).split(" ");
15
+ } catch {
16
+ return undefined;
17
+ }
18
+ };
19
+
20
+ const isReapedState = (state: string | undefined): boolean => state === undefined || REAPED_STATES.has(state);
21
+
22
+ const isProcessAlive = (pid: number | undefined): boolean =>
23
+ pid !== undefined && pid > 1 && !isReapedState(readProcessStatFields(pid)?.[0]);
24
+
25
+ export { isProcessAlive, isReapedState, readProcessStatFields };