@nowcrew/daemon 0.5.12 → 0.5.13

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.
@@ -0,0 +1,185 @@
1
+ import spawn from "cross-spawn";
2
+ import { z } from "zod";
3
+ import { pathToFileURL } from "node:url";
4
+ const LaunchSchema = z.object({
5
+ command: z.string().min(1),
6
+ args: z.array(z.string()),
7
+ cwd: z.string().min(1),
8
+ env: z.record(z.string()),
9
+ stdinText: z.string().optional(),
10
+ }).strict();
11
+ function send(message, callback) {
12
+ if (!process.connected) {
13
+ callback?.();
14
+ return;
15
+ }
16
+ process.send?.(message, () => callback?.());
17
+ }
18
+ /**
19
+ * Keep consuming runtime output even when the daemon-side pipe disappears. A direct `pipe()` can
20
+ * crash this supervisor with EPIPE and orphan the runtime. While connected, source pause/resume
21
+ * preserves authoritative final output. Once disconnected, the source remains drained and discarded.
22
+ */
23
+ function forwardWhileWritable(source, destination) {
24
+ if (source === null)
25
+ return { discard: () => undefined };
26
+ let blocked = false;
27
+ let broken = destination.destroyed;
28
+ const discard = () => {
29
+ broken = true;
30
+ blocked = false;
31
+ source.resume();
32
+ };
33
+ destination.on("error", discard);
34
+ destination.on("close", discard);
35
+ destination.on("drain", () => {
36
+ if (broken)
37
+ return;
38
+ blocked = false;
39
+ source.resume();
40
+ });
41
+ source.on("data", (chunk) => {
42
+ if (broken)
43
+ return;
44
+ try {
45
+ if (!destination.write(chunk)) {
46
+ blocked = true;
47
+ source.pause();
48
+ }
49
+ }
50
+ catch {
51
+ discard();
52
+ }
53
+ });
54
+ source.on("error", () => undefined);
55
+ return { discard };
56
+ }
57
+ export function runExecutionSupervisorChild() {
58
+ let launch = null;
59
+ let released = false;
60
+ let runtime = null;
61
+ let settled = false;
62
+ let cleaningTree = false;
63
+ const outputForwarders = [];
64
+ const terminateOwnedTree = () => {
65
+ if (cleaningTree)
66
+ return;
67
+ cleaningTree = true;
68
+ for (const forwarder of outputForwarders)
69
+ forwarder.discard();
70
+ if (process.platform === "win32") {
71
+ process.exit(1);
72
+ return;
73
+ }
74
+ try {
75
+ process.kill(-process.pid, "SIGTERM");
76
+ }
77
+ catch (error) {
78
+ if (error.code !== "ESRCH")
79
+ throw error;
80
+ }
81
+ setTimeout(() => {
82
+ try {
83
+ process.kill(-process.pid, "SIGKILL");
84
+ }
85
+ catch (error) {
86
+ if (error.code !== "ESRCH")
87
+ throw error;
88
+ process.exit();
89
+ }
90
+ }, 250);
91
+ };
92
+ const stopBeforeRelease = () => {
93
+ if (released || settled)
94
+ return;
95
+ settled = true;
96
+ process.exit(0);
97
+ };
98
+ process.on("SIGTERM", () => {
99
+ if (released)
100
+ terminateOwnedTree();
101
+ else
102
+ stopBeforeRelease();
103
+ });
104
+ process.on("SIGINT", () => {
105
+ if (released)
106
+ terminateOwnedTree();
107
+ else
108
+ stopBeforeRelease();
109
+ });
110
+ process.on("disconnect", () => {
111
+ if (released)
112
+ terminateOwnedTree();
113
+ else
114
+ stopBeforeRelease();
115
+ });
116
+ process.on("message", (raw) => {
117
+ if (raw.type === "launch") {
118
+ if (launch !== null) {
119
+ process.exitCode = 2;
120
+ return;
121
+ }
122
+ const parsed = LaunchSchema.safeParse(raw.launch);
123
+ if (!parsed.success) {
124
+ send({ type: "runtime-spawn-error", message: parsed.error.message });
125
+ process.exit(2);
126
+ return;
127
+ }
128
+ launch = parsed.data;
129
+ send({ type: "ready" });
130
+ return;
131
+ }
132
+ if (raw.type === "abort") {
133
+ if (runtime === null)
134
+ stopBeforeRelease();
135
+ else
136
+ runtime.kill("SIGTERM");
137
+ return;
138
+ }
139
+ if (raw.type !== "release" || released || launch === null)
140
+ return;
141
+ released = true;
142
+ const child = spawn(launch.command, launch.args, {
143
+ cwd: launch.cwd,
144
+ env: launch.env,
145
+ detached: false,
146
+ stdio: [launch.stdinText === undefined ? "ignore" : "pipe", "pipe", "pipe"],
147
+ });
148
+ runtime = child;
149
+ outputForwarders.push(forwardWhileWritable(child.stdout, process.stdout), forwardWhileWritable(child.stderr, process.stderr));
150
+ child.once("spawn", () => {
151
+ send({ type: "runtime-started" });
152
+ if (launch?.stdinText !== undefined && child.stdin !== null) {
153
+ child.stdin.on("error", () => undefined);
154
+ child.stdin.end(launch.stdinText);
155
+ }
156
+ });
157
+ child.once("error", (error) => {
158
+ send({ type: "runtime-spawn-error", message: error.message });
159
+ });
160
+ child.once("close", (code, signal) => {
161
+ const exitMessage = {
162
+ type: "runtime-exit",
163
+ exitCode: code ?? 128,
164
+ ...(signal === null ? {} : { terminationSignal: signal }),
165
+ };
166
+ settled = true;
167
+ process.exitCode = code ?? 128;
168
+ let cleanupStarted = false;
169
+ const cleanup = () => {
170
+ if (cleanupStarted)
171
+ return;
172
+ cleanupStarted = true;
173
+ terminateOwnedTree();
174
+ };
175
+ const fallback = setTimeout(cleanup, 50);
176
+ send(exitMessage, () => {
177
+ clearTimeout(fallback);
178
+ cleanup();
179
+ });
180
+ });
181
+ });
182
+ }
183
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
184
+ runExecutionSupervisorChild();
185
+ }
@@ -0,0 +1,209 @@
1
+ import { fork } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ const DEFAULT_ABORT_TIMEOUT_MS = 5_000;
4
+ const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5_000;
5
+ const DEFAULT_TASKKILL_TIMEOUT_MS = 5_000;
6
+ function messageError(error) {
7
+ return error instanceof Error ? error : new Error(String(error));
8
+ }
9
+ async function waitForExit(exit, timeoutMs, pid) {
10
+ let timer;
11
+ try {
12
+ await Promise.race([
13
+ exit.then(() => undefined),
14
+ new Promise((_resolve, reject) => {
15
+ timer = setTimeout(() => reject(new Error(`Supervisor ${pid} did not exit within ${timeoutMs}ms`)), timeoutMs);
16
+ }),
17
+ ]);
18
+ }
19
+ finally {
20
+ if (timer !== undefined)
21
+ clearTimeout(timer);
22
+ }
23
+ }
24
+ async function withTimeout(promise, timeoutMs, phase) {
25
+ let timer;
26
+ try {
27
+ return await Promise.race([
28
+ promise,
29
+ new Promise((_resolve, reject) => {
30
+ timer = setTimeout(() => reject(new Error(`Supervisor ${phase} timed out after ${timeoutMs}ms`)), timeoutMs);
31
+ }),
32
+ ]);
33
+ }
34
+ finally {
35
+ if (timer !== undefined)
36
+ clearTimeout(timer);
37
+ }
38
+ }
39
+ /** Terminate only a supervisor-created process tree. Never use this for arbitrary child PIDs. */
40
+ export async function signalSupervisorTree(pid, signal, platform = process.platform) {
41
+ if (platform === "win32") {
42
+ const { execFile } = await import("node:child_process");
43
+ await new Promise((resolve, reject) => {
44
+ const args = ["/PID", String(pid), "/T", ...(signal === "SIGKILL" ? ["/F"] : [])];
45
+ const abort = new AbortController();
46
+ const timeout = setTimeout(() => abort.abort(), DEFAULT_TASKKILL_TIMEOUT_MS);
47
+ execFile("taskkill.exe", args, { signal: abort.signal }, (error, stdout, stderr) => {
48
+ clearTimeout(timeout);
49
+ if (error === null) {
50
+ resolve();
51
+ return;
52
+ }
53
+ const detail = `${stdout} ${stderr} ${error.message}`;
54
+ if (/not found|no running instance|不存在|找不到/i.test(detail))
55
+ resolve();
56
+ else
57
+ reject(error);
58
+ });
59
+ });
60
+ return;
61
+ }
62
+ process.kill(-pid, signal);
63
+ }
64
+ export async function startDormantSupervisor(launch, options = {}) {
65
+ const platform = options.platform ?? process.platform;
66
+ if (platform === "win32") {
67
+ throw new Error("Execution supervisor is unsupported on Windows without Job Object ownership");
68
+ }
69
+ const childEntry = options.childEntry
70
+ ?? fileURLToPath(new URL("./execution-supervisor-child.js", import.meta.url));
71
+ const abortTimeoutMs = options.abortTimeoutMs ?? DEFAULT_ABORT_TIMEOUT_MS;
72
+ const handshakeTimeoutMs = options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;
73
+ if (!Number.isFinite(handshakeTimeoutMs) || handshakeTimeoutMs <= 0) {
74
+ throw new RangeError("handshakeTimeoutMs must be a positive finite number");
75
+ }
76
+ const env = Object.fromEntries(Object.entries(launch.env).filter((entry) => entry[1] !== undefined));
77
+ const child = fork(childEntry, [], {
78
+ detached: true,
79
+ env: { ...process.env, ...options.childEnv },
80
+ execArgv: options.execArgv === undefined ? process.execArgv : [...options.execArgv],
81
+ stdio: ["ignore", "pipe", "pipe", "ipc"],
82
+ });
83
+ const pid = child.pid;
84
+ if (pid === undefined || child.stdout === null || child.stderr === null) {
85
+ child.kill("SIGKILL");
86
+ throw new Error("Supervisor failed to expose a process identity and output pipes");
87
+ }
88
+ let runtimeResult;
89
+ let supervisorSpawnError;
90
+ const exit = new Promise((resolve) => {
91
+ child.once("error", (error) => { supervisorSpawnError = error.message; });
92
+ child.once("close", (code, signal) => resolve(runtimeResult ?? {
93
+ exitCode: supervisorSpawnError === undefined ? (code ?? 128) : -1,
94
+ ...(supervisorSpawnError === undefined ? {} : { spawnError: supervisorSpawnError }),
95
+ ...(supervisorSpawnError === undefined && signal !== null ? { terminationSignal: signal } : {}),
96
+ }));
97
+ });
98
+ const supervisorClosed = new Promise((resolve) => child.once("close", () => resolve()));
99
+ let readyResolve;
100
+ let readyReject;
101
+ const ready = new Promise((resolve, reject) => {
102
+ readyResolve = resolve;
103
+ readyReject = reject;
104
+ });
105
+ let releaseResolve;
106
+ let releaseReject;
107
+ let released = false;
108
+ child.on("message", (raw) => {
109
+ if (raw.type === "ready")
110
+ readyResolve?.();
111
+ if (raw.type === "runtime-started")
112
+ releaseResolve?.();
113
+ if (raw.type === "runtime-spawn-error") {
114
+ runtimeResult ??= { exitCode: -1, spawnError: raw.message };
115
+ releaseReject?.(new Error(raw.message));
116
+ }
117
+ if (raw.type === "runtime-exit") {
118
+ runtimeResult ??= {
119
+ exitCode: raw.exitCode,
120
+ ...(raw.terminationSignal === undefined ? {} : { terminationSignal: raw.terminationSignal }),
121
+ };
122
+ }
123
+ });
124
+ child.once("error", (error) => {
125
+ readyReject?.(error);
126
+ releaseReject?.(error);
127
+ });
128
+ child.once("close", (code, signal) => {
129
+ const error = new Error(`Supervisor exited before launch: code=${code} signal=${signal}`);
130
+ readyReject?.(error);
131
+ releaseReject?.(error);
132
+ });
133
+ const abort = async () => {
134
+ if (child.exitCode !== null || child.signalCode !== null) {
135
+ await supervisorClosed;
136
+ return;
137
+ }
138
+ try {
139
+ await signalSupervisorTree(pid, "SIGTERM", platform);
140
+ }
141
+ catch (error) {
142
+ const code = error instanceof Error && "code" in error ? error.code : undefined;
143
+ if (code !== "ESRCH")
144
+ throw error;
145
+ }
146
+ try {
147
+ await waitForExit(supervisorClosed.then(() => ({ exitCode: 0 })), abortTimeoutMs, pid);
148
+ }
149
+ catch (error) {
150
+ try {
151
+ await signalSupervisorTree(pid, "SIGKILL", platform);
152
+ }
153
+ catch (killError) {
154
+ const code = killError instanceof Error && "code" in killError ? killError.code : undefined;
155
+ if (code !== "ESRCH")
156
+ throw new AggregateError([error, killError], "Supervisor abort failed");
157
+ }
158
+ await waitForExit(supervisorClosed.then(() => ({ exitCode: 0 })), abortTimeoutMs, pid);
159
+ }
160
+ };
161
+ try {
162
+ await new Promise((resolve, reject) => {
163
+ child.send({ type: "launch", launch: { ...launch, args: [...launch.args], env } }, (error) => {
164
+ if (error === null)
165
+ resolve();
166
+ else
167
+ reject(error);
168
+ });
169
+ });
170
+ await withTimeout(ready, handshakeTimeoutMs, "ready handshake");
171
+ }
172
+ catch (error) {
173
+ await abort().catch((abortError) => {
174
+ throw new AggregateError([messageError(error), messageError(abortError)], "Supervisor start failed");
175
+ });
176
+ throw error;
177
+ }
178
+ return {
179
+ pid,
180
+ parentExitGuard: "pipe-eof",
181
+ stdout: child.stdout,
182
+ stderr: child.stderr,
183
+ exit,
184
+ release: async () => {
185
+ if (released)
186
+ return;
187
+ released = true;
188
+ const acknowledgement = new Promise((resolve, reject) => {
189
+ releaseResolve = resolve;
190
+ releaseReject = reject;
191
+ child.send({ type: "release" }, (error) => {
192
+ if (error !== null)
193
+ reject(error);
194
+ });
195
+ });
196
+ try {
197
+ await withTimeout(acknowledgement, handshakeTimeoutMs, "release handshake");
198
+ }
199
+ catch (error) {
200
+ await abort().catch((abortError) => {
201
+ throw new AggregateError([messageError(error), messageError(abortError)], "Supervisor release failed");
202
+ });
203
+ throw error;
204
+ }
205
+ },
206
+ abort,
207
+ cancel: abort,
208
+ };
209
+ }