@gtkx/utils 1.6.0 → 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 (55) hide show
  1. package/README.md +47 -44
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/path.d.ts +5 -0
  7. package/dist/path.d.ts.map +1 -0
  8. package/dist/path.js +10 -0
  9. package/dist/path.js.map +1 -0
  10. package/dist/process/index.d.ts +3 -1
  11. package/dist/process/index.d.ts.map +1 -1
  12. package/dist/process/index.js +3 -1
  13. package/dist/process/index.js.map +1 -1
  14. package/dist/process/kill-marked-processes.d.ts +3 -2
  15. package/dist/process/kill-marked-processes.d.ts.map +1 -1
  16. package/dist/process/kill-marked-processes.js +39 -13
  17. package/dist/process/kill-marked-processes.js.map +1 -1
  18. package/dist/process/kill-process-group.d.ts +16 -0
  19. package/dist/process/kill-process-group.d.ts.map +1 -0
  20. package/dist/process/kill-process-group.js +70 -0
  21. package/dist/process/kill-process-group.js.map +1 -0
  22. package/dist/process/process-guard.js +192 -6
  23. package/dist/process/process-guard.js.map +1 -1
  24. package/dist/process/process-status.d.ts +5 -0
  25. package/dist/process/process-status.d.ts.map +1 -0
  26. package/dist/process/process-status.js +19 -0
  27. package/dist/process/process-status.js.map +1 -0
  28. package/dist/process/resolve-executable.d.ts +2 -2
  29. package/dist/process/resolve-executable.d.ts.map +1 -1
  30. package/dist/process/resolve-executable.js +3 -37
  31. package/dist/process/resolve-executable.js.map +1 -1
  32. package/dist/process/spawn-with-parent-death-signal.d.ts +7 -1
  33. package/dist/process/spawn-with-parent-death-signal.d.ts.map +1 -1
  34. package/dist/process/spawn-with-parent-death-signal.js +315 -8
  35. package/dist/process/spawn-with-parent-death-signal.js.map +1 -1
  36. package/package.json +12 -4
  37. package/src/index.ts +1 -1
  38. package/src/path.ts +17 -0
  39. package/src/process/index.ts +14 -1
  40. package/src/process/kill-marked-processes.ts +58 -13
  41. package/src/process/kill-process-group.ts +105 -0
  42. package/src/process/process-guard.ts +261 -6
  43. package/src/process/process-status.ts +25 -0
  44. package/src/process/resolve-executable.ts +4 -46
  45. package/src/process/spawn-with-parent-death-signal.ts +422 -11
  46. package/dist/map/get-or-insert.d.ts +0 -8
  47. package/dist/map/get-or-insert.d.ts.map +0 -1
  48. package/dist/map/get-or-insert.js +0 -10
  49. package/dist/map/get-or-insert.js.map +0 -1
  50. package/dist/map/index.d.ts +0 -2
  51. package/dist/map/index.d.ts.map +0 -1
  52. package/dist/map/index.js +0 -2
  53. package/dist/map/index.js.map +0 -1
  54. package/src/map/get-or-insert.ts +0 -18
  55. package/src/map/index.ts +0 -1
@@ -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 };
@@ -1,50 +1,8 @@
1
- import { accessSync, constants, statSync } from "node:fs";
2
- import { delimiter, isAbsolute, join, resolve } from "node:path";
1
+ import which from "which";
3
2
 
4
- const isExecutable = (path: string): boolean => {
5
- try {
6
- accessSync(path, constants.X_OK);
3
+ const tryResolveExecutable = (command: string): string | undefined =>
4
+ which.sync(command, { nothrow: true }) ?? undefined;
7
5
 
8
- return statSync(path).isFile();
9
- } catch {
10
- return false;
11
- }
12
- };
13
-
14
- const findOnPath = (command: string): string | undefined => {
15
- const searchPaths = (process.env.PATH ?? "").split(delimiter).filter((entry) => entry.length > 0);
16
-
17
- for (const directory of searchPaths) {
18
- const candidate = join(directory, command);
19
-
20
- if (isExecutable(candidate)) {
21
- return candidate;
22
- }
23
- }
24
-
25
- return undefined;
26
- };
27
-
28
- function tryResolveExecutable(command: string): string | undefined {
29
- if (isAbsolute(command)) {
30
- return command;
31
- }
32
-
33
- if (command.includes("/")) {
34
- return resolve(command);
35
- }
36
-
37
- return findOnPath(command);
38
- }
39
-
40
- function resolveExecutable(command: string): string {
41
- const found = tryResolveExecutable(command);
42
-
43
- if (found === undefined) {
44
- throw new Error(`Cannot find the "${command}" executable on PATH`);
45
- }
46
-
47
- return found;
48
- }
6
+ const resolveExecutable = (command: string): string => which.sync(command);
49
7
 
50
8
  export { resolveExecutable, tryResolveExecutable };