@hyperdrive.bot/fleet-server 0.3.157 → 0.3.158
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.
- package/dist/server/utils/git-command-cache.d.ts +67 -0
- package/dist/server/utils/git-command-cache.js +436 -0
- package/dist/server/utils/run-git-command.js +14 -4
- package/dist/server/utils/spawn-broker-child.mjs +156 -0
- package/dist/server/utils/spawn-broker.d.ts +120 -0
- package/dist/server/utils/spawn-broker.js +299 -0
- package/dist/server/utils/spawn.d.ts +21 -0
- package/dist/server/utils/spawn.js +144 -10
- package/dist/server/web-ui/_expo/static/js/web/{index-7ca8a9a256b79485a77556fab01588ca.js → index-461552a3716a48e99f87634c93c7b220.js} +4 -4
- package/dist/server/web-ui/_expo/static/js/web/index-461552a3716a48e99f87634c93c7b220.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-461552a3716a48e99f87634c93c7b220.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-7ca8a9a256b79485a77556fab01588ca.js.map.br → index-461552a3716a48e99f87634c93c7b220.js.map.br} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-7ca8a9a256b79485a77556fab01588ca.js.map.gz → index-461552a3716a48e99f87634c93c7b220.js.map.gz} +0 -0
- package/dist/server/web-ui/index.html +1 -1
- package/dist/server/web-ui/index.html.br +0 -0
- package/dist/server/web-ui/index.html.gz +0 -0
- package/dist/src/utils/spawn-broker-child.mjs +156 -0
- package/dist/src/utils/spawn-broker.js +299 -0
- package/dist/src/utils/spawn.js +144 -10
- package/package.json +8 -8
- package/dist/server/web-ui/_expo/static/js/web/index-7ca8a9a256b79485a77556fab01588ca.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-7ca8a9a256b79485a77556fab01588ca.js.gz +0 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Spawn broker helper process. The daemon forks this small Node process once and
|
|
2
|
+
// asks it to run non-interactive subprocesses (git, gh, which, ...) over IPC, so
|
|
3
|
+
// the daemon itself never forks its multi-GB image: on WSL2 every direct spawn()
|
|
4
|
+
// from the daemon blocked its event loop for about 100ms.
|
|
5
|
+
//
|
|
6
|
+
// Protocol (JSON over the IPC channel):
|
|
7
|
+
// parent -> child { type: "spawn", id, command, args, cwd, env, shell }
|
|
8
|
+
// { type: "kill", id, signal }
|
|
9
|
+
// child -> parent { type: "spawned", id, pid }
|
|
10
|
+
// { type: "data", id, fd: 1 | 2, data: base64 }
|
|
11
|
+
// { type: "error", id, error: { message, code, errno, syscall, path } }
|
|
12
|
+
// { type: "close", id, exitCode, signal }
|
|
13
|
+
//
|
|
14
|
+
// Timeouts, output caps and exit-code policy stay in the parent, which kills the
|
|
15
|
+
// child through the "kill" message exactly like it would kill a direct ChildProcess.
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
|
|
18
|
+
const children = new Map();
|
|
19
|
+
|
|
20
|
+
function send(message) {
|
|
21
|
+
// Returns false when the channel is backed up (or gone; the disconnect handler
|
|
22
|
+
// cleans up in that case).
|
|
23
|
+
return sendRaw(message);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function serializeError(error) {
|
|
27
|
+
return {
|
|
28
|
+
message: error?.message ?? String(error),
|
|
29
|
+
code: error?.code,
|
|
30
|
+
errno: error?.errno,
|
|
31
|
+
syscall: error?.syscall,
|
|
32
|
+
path: error?.path,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const CHUNK_BYTES = 64 * 1024;
|
|
37
|
+
|
|
38
|
+
// Forward a stream in bounded chunks, stop forwarding once the caller's cap is
|
|
39
|
+
// reached (the caller decides whether to kill), and pause the source while the
|
|
40
|
+
// IPC channel is backed up so output never piles up in memory on either side.
|
|
41
|
+
function forward(stream, id, fd, maxBytes) {
|
|
42
|
+
const cap = Number.isFinite(maxBytes) && maxBytes >= 0 ? maxBytes : Infinity;
|
|
43
|
+
let forwarded = 0;
|
|
44
|
+
stream.on("data", (chunk) => {
|
|
45
|
+
if (forwarded >= cap) return;
|
|
46
|
+
const slice = chunk.length > cap - forwarded ? chunk.subarray(0, cap - forwarded) : chunk;
|
|
47
|
+
forwarded += slice.length;
|
|
48
|
+
for (let offset = 0; offset < slice.length; offset += CHUNK_BYTES) {
|
|
49
|
+
const part = slice.subarray(offset, offset + CHUNK_BYTES);
|
|
50
|
+
const ok = send({ type: "data", id, fd, data: part.toString("base64") });
|
|
51
|
+
if (!ok) {
|
|
52
|
+
stream.pause();
|
|
53
|
+
waitForDrain(() => stream.resume());
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const drainWaiters = [];
|
|
60
|
+
function waitForDrain(callback) {
|
|
61
|
+
drainWaiters.push(callback);
|
|
62
|
+
if (drainWaiters.length > 1) return;
|
|
63
|
+
// An empty message's send callback fires once everything queued before it
|
|
64
|
+
// has been written to the channel.
|
|
65
|
+
sendRaw({ type: "noop" }, () => {
|
|
66
|
+
const waiters = drainWaiters.splice(0);
|
|
67
|
+
for (const waiter of waiters) waiter();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sendRaw(message, callback) {
|
|
72
|
+
if (!process.connected) return false;
|
|
73
|
+
try {
|
|
74
|
+
return process.send(message, undefined, undefined, callback);
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function handleSpawn(message) {
|
|
81
|
+
const { id } = message;
|
|
82
|
+
let child;
|
|
83
|
+
try {
|
|
84
|
+
child = spawn(message.command, message.args, {
|
|
85
|
+
cwd: message.cwd,
|
|
86
|
+
env: message.env,
|
|
87
|
+
shell: message.shell ?? false,
|
|
88
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
89
|
+
windowsHide: true,
|
|
90
|
+
});
|
|
91
|
+
} catch (error) {
|
|
92
|
+
send({ type: "error", id, error: serializeError(error) });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
children.set(id, child);
|
|
96
|
+
if (child.pid !== undefined) {
|
|
97
|
+
send({ type: "spawned", id, pid: child.pid });
|
|
98
|
+
}
|
|
99
|
+
forward(child.stdout, id, 1, message.maxStdoutBytes);
|
|
100
|
+
forward(child.stderr, id, 2, message.maxStderrBytes);
|
|
101
|
+
child.on("error", (error) => {
|
|
102
|
+
children.delete(id);
|
|
103
|
+
send({ type: "error", id, error: serializeError(error) });
|
|
104
|
+
});
|
|
105
|
+
child.on("close", (exitCode, signal) => {
|
|
106
|
+
children.delete(id);
|
|
107
|
+
send({ type: "close", id, exitCode, signal });
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function killAll() {
|
|
112
|
+
for (const child of children.values()) {
|
|
113
|
+
try {
|
|
114
|
+
child.kill("SIGKILL");
|
|
115
|
+
} catch {
|
|
116
|
+
// already gone
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
children.clear();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
process.on("message", (message) => {
|
|
123
|
+
if (!message || typeof message !== "object") return;
|
|
124
|
+
if (message.type === "spawn") {
|
|
125
|
+
handleSpawn(message);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (message.type === "kill") {
|
|
129
|
+
const child = children.get(message.id);
|
|
130
|
+
if (child) {
|
|
131
|
+
try {
|
|
132
|
+
child.kill(message.signal ?? "SIGTERM");
|
|
133
|
+
} catch {
|
|
134
|
+
// already gone
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (message.type === "crash-for-test" && process.env.PASEO_SPAWN_BROKER_TEST_HOOKS === "1") {
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// The daemon owns us: when its IPC channel closes (clean shutdown or crash) we take
|
|
145
|
+
// our children down and exit, so nothing is orphaned.
|
|
146
|
+
process.on("disconnect", () => {
|
|
147
|
+
killAll();
|
|
148
|
+
process.exit(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
|
|
152
|
+
process.on(signal, () => {
|
|
153
|
+
killAll();
|
|
154
|
+
process.exit(0);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
/**
|
|
3
|
+
* Minimal surface of a ChildProcess that non-interactive callers use: stdout and
|
|
4
|
+
* stderr "data" events, "error" / "close" events, and kill(). Both a real
|
|
5
|
+
* ChildProcess and a BrokeredChildProcess satisfy it.
|
|
6
|
+
*/
|
|
7
|
+
export interface NonInteractiveChildProcess {
|
|
8
|
+
readonly stdout: EventEmitter | null;
|
|
9
|
+
readonly stderr: EventEmitter | null;
|
|
10
|
+
readonly pid?: number;
|
|
11
|
+
kill(signal?: NodeJS.Signals | number): boolean;
|
|
12
|
+
on(event: "error", listener: (error: Error) => void): this;
|
|
13
|
+
on(event: "close" | "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
|
|
14
|
+
once(event: string, listener: (...args: unknown[]) => void): this;
|
|
15
|
+
}
|
|
16
|
+
export interface BrokerSpawnRequest {
|
|
17
|
+
command: string;
|
|
18
|
+
args: string[];
|
|
19
|
+
cwd?: string;
|
|
20
|
+
env: NodeJS.ProcessEnv;
|
|
21
|
+
shell?: boolean | string;
|
|
22
|
+
/** The helper forwards at most this many stdout bytes (callers pass cap + 1 so they still see the overflow). */
|
|
23
|
+
maxStdoutBytes?: number;
|
|
24
|
+
/** The helper forwards at most this many stderr bytes. */
|
|
25
|
+
maxStderrBytes?: number;
|
|
26
|
+
}
|
|
27
|
+
export interface BrokerSpawnPolicy {
|
|
28
|
+
/**
|
|
29
|
+
* Whether a request whose start was never acknowledged may be re-run on a direct
|
|
30
|
+
* spawn when the helper dies. Only safe for read-only commands: the helper may
|
|
31
|
+
* already have started (and SIGKILLed) the process before its ack was lost.
|
|
32
|
+
*/
|
|
33
|
+
retryOnDirect: boolean;
|
|
34
|
+
}
|
|
35
|
+
type DirectSpawn = () => NonInteractiveChildProcess;
|
|
36
|
+
type BrokerMessage = {
|
|
37
|
+
type: "spawned";
|
|
38
|
+
id: number;
|
|
39
|
+
pid: number;
|
|
40
|
+
} | {
|
|
41
|
+
type: "data";
|
|
42
|
+
id: number;
|
|
43
|
+
fd: 1 | 2;
|
|
44
|
+
data: string;
|
|
45
|
+
} | {
|
|
46
|
+
type: "error";
|
|
47
|
+
id: number;
|
|
48
|
+
error: {
|
|
49
|
+
message: string;
|
|
50
|
+
code?: string;
|
|
51
|
+
errno?: number;
|
|
52
|
+
syscall?: string;
|
|
53
|
+
path?: string;
|
|
54
|
+
};
|
|
55
|
+
} | {
|
|
56
|
+
type: "close";
|
|
57
|
+
id: number;
|
|
58
|
+
exitCode: number | null;
|
|
59
|
+
signal: NodeJS.Signals | null;
|
|
60
|
+
};
|
|
61
|
+
export declare function isSpawnBrokerEnabled(): boolean;
|
|
62
|
+
/**
|
|
63
|
+
* A process run by the broker. Emits the same events, in the same order, as a
|
|
64
|
+
* direct ChildProcess with stdio ["ignore", "pipe", "pipe"]. If the broker fails
|
|
65
|
+
* before the process is confirmed started, it transparently falls back to a
|
|
66
|
+
* direct spawn so the caller never sees the difference.
|
|
67
|
+
*/
|
|
68
|
+
export declare class BrokeredChildProcess extends EventEmitter implements NonInteractiveChildProcess {
|
|
69
|
+
readonly stdout: EventEmitter<[never]>;
|
|
70
|
+
readonly stderr: EventEmitter<[never]>;
|
|
71
|
+
pid: number | undefined;
|
|
72
|
+
killed: boolean;
|
|
73
|
+
brokered: boolean;
|
|
74
|
+
private started;
|
|
75
|
+
private closed;
|
|
76
|
+
private fallbackChild;
|
|
77
|
+
private pendingKill;
|
|
78
|
+
readonly id: number;
|
|
79
|
+
private readonly broker;
|
|
80
|
+
private readonly directSpawn;
|
|
81
|
+
private readonly retryOnDirect;
|
|
82
|
+
constructor(id: number, broker: SpawnBroker, directSpawn: DirectSpawn, policy: BrokerSpawnPolicy);
|
|
83
|
+
kill(signal?: NodeJS.Signals | number): boolean;
|
|
84
|
+
/** @internal */
|
|
85
|
+
handleMessage(message: BrokerMessage): void;
|
|
86
|
+
/** @internal Broker died or could not be reached. */
|
|
87
|
+
handleBrokerLost(): void;
|
|
88
|
+
/** @internal */
|
|
89
|
+
startFallback(): void;
|
|
90
|
+
}
|
|
91
|
+
export declare class SpawnBroker {
|
|
92
|
+
private child;
|
|
93
|
+
private readonly pending;
|
|
94
|
+
private nextId;
|
|
95
|
+
private consecutiveFailures;
|
|
96
|
+
private disabledUntil;
|
|
97
|
+
private exitHookInstalled;
|
|
98
|
+
private readonly childPath;
|
|
99
|
+
constructor(childPath?: string);
|
|
100
|
+
get pendingCount(): number;
|
|
101
|
+
get brokerPid(): number | undefined;
|
|
102
|
+
spawn(request: BrokerSpawnRequest, directSpawn: DirectSpawn, policy?: BrokerSpawnPolicy): NonInteractiveChildProcess;
|
|
103
|
+
/** @internal */
|
|
104
|
+
sendKill(id: number, signal: NodeJS.Signals | number): void;
|
|
105
|
+
/** @internal */
|
|
106
|
+
release(id: number): void;
|
|
107
|
+
/** Stop the helper. Pending requests fall back or report SIGKILL. */
|
|
108
|
+
dispose(): void;
|
|
109
|
+
/** Test hook: make the helper exit abruptly. */
|
|
110
|
+
crashForTest(): void;
|
|
111
|
+
private ensureChild;
|
|
112
|
+
private handleChildLost;
|
|
113
|
+
private recordFailure;
|
|
114
|
+
private updateRef;
|
|
115
|
+
private installExitHook;
|
|
116
|
+
}
|
|
117
|
+
export declare function getSharedSpawnBroker(): SpawnBroker;
|
|
118
|
+
export declare function resetSharedSpawnBrokerForTest(): void;
|
|
119
|
+
export {};
|
|
120
|
+
//# sourceMappingURL=spawn-broker.d.ts.map
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { fork } from "node:child_process";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
const FAILURE_COOLDOWN_MS = 60000;
|
|
6
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
7
|
+
export function isSpawnBrokerEnabled() {
|
|
8
|
+
if (process.platform === "win32")
|
|
9
|
+
return false;
|
|
10
|
+
const flag = process.env.PASEO_SPAWN_BROKER;
|
|
11
|
+
return flag !== "0" && flag !== "false";
|
|
12
|
+
}
|
|
13
|
+
function resolveBrokerChildPath() {
|
|
14
|
+
// The helper is a plain .mjs file that sits next to this module both in src/ (tests,
|
|
15
|
+
// tsx dev) and in dist/server/utils/ (build:lib copies it), so the published package
|
|
16
|
+
// ships it at node_modules/@hyperdrive.bot/fleet-server/dist/server/utils/.
|
|
17
|
+
return fileURLToPath(new URL("./spawn-broker-child.mjs", import.meta.url));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A process run by the broker. Emits the same events, in the same order, as a
|
|
21
|
+
* direct ChildProcess with stdio ["ignore", "pipe", "pipe"]. If the broker fails
|
|
22
|
+
* before the process is confirmed started, it transparently falls back to a
|
|
23
|
+
* direct spawn so the caller never sees the difference.
|
|
24
|
+
*/
|
|
25
|
+
export class BrokeredChildProcess extends EventEmitter {
|
|
26
|
+
constructor(id, broker, directSpawn, policy) {
|
|
27
|
+
super();
|
|
28
|
+
this.stdout = new EventEmitter();
|
|
29
|
+
this.stderr = new EventEmitter();
|
|
30
|
+
this.killed = false;
|
|
31
|
+
this.brokered = true;
|
|
32
|
+
this.started = false;
|
|
33
|
+
this.closed = false;
|
|
34
|
+
this.fallbackChild = null;
|
|
35
|
+
this.pendingKill = null;
|
|
36
|
+
this.id = id;
|
|
37
|
+
this.broker = broker;
|
|
38
|
+
this.directSpawn = directSpawn;
|
|
39
|
+
this.retryOnDirect = policy.retryOnDirect;
|
|
40
|
+
}
|
|
41
|
+
kill(signal = "SIGTERM") {
|
|
42
|
+
if (this.closed)
|
|
43
|
+
return false;
|
|
44
|
+
this.killed = true;
|
|
45
|
+
if (this.fallbackChild) {
|
|
46
|
+
return this.fallbackChild.kill(signal);
|
|
47
|
+
}
|
|
48
|
+
this.pendingKill = signal;
|
|
49
|
+
this.broker.sendKill(this.id, signal);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
/** @internal */
|
|
53
|
+
handleMessage(message) {
|
|
54
|
+
if (this.closed || this.fallbackChild)
|
|
55
|
+
return;
|
|
56
|
+
switch (message.type) {
|
|
57
|
+
case "spawned":
|
|
58
|
+
this.started = true;
|
|
59
|
+
this.pid = message.pid;
|
|
60
|
+
this.emit("spawn");
|
|
61
|
+
return;
|
|
62
|
+
case "data": {
|
|
63
|
+
const target = message.fd === 1 ? this.stdout : this.stderr;
|
|
64
|
+
target.emit("data", Buffer.from(message.data, "base64"));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
case "error": {
|
|
68
|
+
const error = Object.assign(new Error(message.error.message), {
|
|
69
|
+
code: message.error.code,
|
|
70
|
+
errno: message.error.errno,
|
|
71
|
+
syscall: message.error.syscall,
|
|
72
|
+
path: message.error.path,
|
|
73
|
+
});
|
|
74
|
+
this.emit("error", error);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
case "close":
|
|
78
|
+
this.closed = true;
|
|
79
|
+
this.broker.release(this.id);
|
|
80
|
+
this.emit("exit", message.exitCode, message.signal);
|
|
81
|
+
this.emit("close", message.exitCode, message.signal);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** @internal Broker died or could not be reached. */
|
|
86
|
+
handleBrokerLost() {
|
|
87
|
+
if (this.closed || this.fallbackChild)
|
|
88
|
+
return;
|
|
89
|
+
if (this.started) {
|
|
90
|
+
// The process really ran (the broker kills its children when it dies), so
|
|
91
|
+
// re-running it could repeat a side effect. Report it as killed instead.
|
|
92
|
+
this.closed = true;
|
|
93
|
+
this.emit("exit", null, "SIGKILL");
|
|
94
|
+
this.emit("close", null, "SIGKILL");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (this.retryOnDirect) {
|
|
98
|
+
this.startFallback();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// The request may already have run (and been killed) inside the helper, so a
|
|
102
|
+
// mutating command must not run a second time: surface the loss instead.
|
|
103
|
+
this.closed = true;
|
|
104
|
+
const error = Object.assign(new Error("Spawn broker exited before confirming the process started"), { code: "ERR_SPAWN_BROKER_LOST" });
|
|
105
|
+
queueMicrotask(() => this.emit("error", error));
|
|
106
|
+
}
|
|
107
|
+
/** @internal */
|
|
108
|
+
startFallback() {
|
|
109
|
+
this.brokered = false;
|
|
110
|
+
let child;
|
|
111
|
+
try {
|
|
112
|
+
child = this.directSpawn();
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
this.closed = true;
|
|
116
|
+
queueMicrotask(() => this.emit("error", error));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
this.fallbackChild = child;
|
|
120
|
+
this.pid = child.pid;
|
|
121
|
+
child.stdout?.on("data", (chunk) => this.stdout.emit("data", chunk));
|
|
122
|
+
child.stderr?.on("data", (chunk) => this.stderr.emit("data", chunk));
|
|
123
|
+
child.on("error", (error) => this.emit("error", error));
|
|
124
|
+
child.on("exit", (code, signal) => this.emit("exit", code, signal));
|
|
125
|
+
child.on("close", (code, signal) => {
|
|
126
|
+
this.closed = true;
|
|
127
|
+
this.emit("close", code, signal);
|
|
128
|
+
});
|
|
129
|
+
if (this.pendingKill !== null) {
|
|
130
|
+
child.kill(this.pendingKill);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export class SpawnBroker {
|
|
135
|
+
constructor(childPath = resolveBrokerChildPath()) {
|
|
136
|
+
this.child = null;
|
|
137
|
+
this.pending = new Map();
|
|
138
|
+
this.nextId = 1;
|
|
139
|
+
this.consecutiveFailures = 0;
|
|
140
|
+
this.disabledUntil = 0;
|
|
141
|
+
this.exitHookInstalled = false;
|
|
142
|
+
this.childPath = childPath;
|
|
143
|
+
}
|
|
144
|
+
get pendingCount() {
|
|
145
|
+
return this.pending.size;
|
|
146
|
+
}
|
|
147
|
+
get brokerPid() {
|
|
148
|
+
return this.child?.pid;
|
|
149
|
+
}
|
|
150
|
+
spawn(request, directSpawn, policy = { retryOnDirect: false }) {
|
|
151
|
+
const proc = new BrokeredChildProcess(this.nextId++, this, directSpawn, policy);
|
|
152
|
+
const child = this.ensureChild();
|
|
153
|
+
if (!child) {
|
|
154
|
+
// The request never left this process, so a direct spawn cannot double-run it.
|
|
155
|
+
proc.startFallback();
|
|
156
|
+
return proc;
|
|
157
|
+
}
|
|
158
|
+
this.pending.set(proc.id, proc);
|
|
159
|
+
this.updateRef();
|
|
160
|
+
try {
|
|
161
|
+
child.send({ type: "spawn", id: proc.id, ...request }, (error) => {
|
|
162
|
+
if (error)
|
|
163
|
+
this.handleChildLost(child);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
this.handleChildLost(child);
|
|
168
|
+
}
|
|
169
|
+
return proc;
|
|
170
|
+
}
|
|
171
|
+
/** @internal */
|
|
172
|
+
sendKill(id, signal) {
|
|
173
|
+
const child = this.child;
|
|
174
|
+
if (!child || !child.connected)
|
|
175
|
+
return;
|
|
176
|
+
try {
|
|
177
|
+
child.send({ type: "kill", id, signal });
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// handled by the disconnect path
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** @internal */
|
|
184
|
+
release(id) {
|
|
185
|
+
this.pending.delete(id);
|
|
186
|
+
this.updateRef();
|
|
187
|
+
}
|
|
188
|
+
/** Stop the helper. Pending requests fall back or report SIGKILL. */
|
|
189
|
+
dispose() {
|
|
190
|
+
const child = this.child;
|
|
191
|
+
if (child) {
|
|
192
|
+
this.handleChildLost(child);
|
|
193
|
+
child.kill("SIGTERM");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Test hook: make the helper exit abruptly. */
|
|
197
|
+
crashForTest() {
|
|
198
|
+
this.child?.send({ type: "crash-for-test" });
|
|
199
|
+
}
|
|
200
|
+
ensureChild() {
|
|
201
|
+
if (this.child)
|
|
202
|
+
return this.child;
|
|
203
|
+
if (Date.now() < this.disabledUntil)
|
|
204
|
+
return null;
|
|
205
|
+
if (!existsSync(this.childPath)) {
|
|
206
|
+
this.recordFailure();
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
let child;
|
|
210
|
+
try {
|
|
211
|
+
child = fork(this.childPath, [], {
|
|
212
|
+
execArgv: [],
|
|
213
|
+
stdio: ["ignore", "ignore", "inherit", "ipc"],
|
|
214
|
+
serialization: "json",
|
|
215
|
+
// Electron desktop: the daemon runs as the Electron binary with
|
|
216
|
+
// ELECTRON_RUN_AS_NODE=1, and Electron's fork re-injects that variable, so a
|
|
217
|
+
// PATH-only env still boots the helper as plain Node.
|
|
218
|
+
env: {
|
|
219
|
+
PATH: process.env.PATH ?? "",
|
|
220
|
+
...(process.env.PASEO_SPAWN_BROKER_TEST_HOOKS === "1"
|
|
221
|
+
? { PASEO_SPAWN_BROKER_TEST_HOOKS: "1" }
|
|
222
|
+
: {}),
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
this.recordFailure();
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
this.child = child;
|
|
231
|
+
child.on("message", (message) => {
|
|
232
|
+
this.consecutiveFailures = 0;
|
|
233
|
+
this.pending.get(message.id)?.handleMessage(message);
|
|
234
|
+
});
|
|
235
|
+
child.on("error", () => this.handleChildLost(child));
|
|
236
|
+
child.on("exit", () => this.handleChildLost(child));
|
|
237
|
+
child.on("disconnect", () => this.handleChildLost(child));
|
|
238
|
+
this.installExitHook();
|
|
239
|
+
this.updateRef();
|
|
240
|
+
return child;
|
|
241
|
+
}
|
|
242
|
+
handleChildLost(child) {
|
|
243
|
+
if (this.child !== child)
|
|
244
|
+
return;
|
|
245
|
+
this.child = null;
|
|
246
|
+
this.recordFailure();
|
|
247
|
+
const lost = [...this.pending.values()];
|
|
248
|
+
this.pending.clear();
|
|
249
|
+
for (const proc of lost) {
|
|
250
|
+
proc.handleBrokerLost();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
recordFailure() {
|
|
254
|
+
this.consecutiveFailures += 1;
|
|
255
|
+
if (this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
256
|
+
this.disabledUntil = Date.now() + FAILURE_COOLDOWN_MS;
|
|
257
|
+
this.consecutiveFailures = 0;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
updateRef() {
|
|
261
|
+
const child = this.child;
|
|
262
|
+
if (!child)
|
|
263
|
+
return;
|
|
264
|
+
// Never keep the daemon alive just for the helper; hold a ref only while a
|
|
265
|
+
// request is in flight so awaiting callers are not cut off.
|
|
266
|
+
const channel = child.channel;
|
|
267
|
+
if (this.pending.size > 0) {
|
|
268
|
+
child.ref();
|
|
269
|
+
channel?.ref?.();
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
child.unref();
|
|
273
|
+
channel?.unref?.();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
installExitHook() {
|
|
277
|
+
if (this.exitHookInstalled)
|
|
278
|
+
return;
|
|
279
|
+
this.exitHookInstalled = true;
|
|
280
|
+
process.once("exit", () => {
|
|
281
|
+
try {
|
|
282
|
+
this.child?.kill("SIGKILL");
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// already gone
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
let sharedBroker = null;
|
|
291
|
+
export function getSharedSpawnBroker() {
|
|
292
|
+
sharedBroker ?? (sharedBroker = new SpawnBroker());
|
|
293
|
+
return sharedBroker;
|
|
294
|
+
}
|
|
295
|
+
export function resetSharedSpawnBrokerForTest() {
|
|
296
|
+
sharedBroker?.dispose();
|
|
297
|
+
sharedBroker = null;
|
|
298
|
+
}
|
|
299
|
+
//# sourceMappingURL=spawn-broker.js.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ChildProcess, type SpawnOptions } from "node:child_process";
|
|
2
2
|
import { type ProcessEnvRecord } from "../server/paseo-env.js";
|
|
3
|
+
import { type NonInteractiveChildProcess } from "./spawn-broker.js";
|
|
3
4
|
interface ExternalEnvOptions {
|
|
4
5
|
baseEnv?: ProcessEnvRecord;
|
|
5
6
|
envMode?: "external" | "internal";
|
|
@@ -20,6 +21,26 @@ interface ExecCommandResult {
|
|
|
20
21
|
stderr: string;
|
|
21
22
|
}
|
|
22
23
|
export declare function spawnProcess(command: string, args: string[], options?: SpawnProcessOptions): ChildProcess;
|
|
24
|
+
export interface NonInteractiveSpawnOptions extends ExternalEnvOptions {
|
|
25
|
+
cwd?: string;
|
|
26
|
+
shell?: boolean | string;
|
|
27
|
+
/** Output the broker forwards at most; set to your own cap + 1. */
|
|
28
|
+
maxStdoutBytes?: number;
|
|
29
|
+
maxStderrBytes?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Re-run on a direct spawn if the broker dies before acknowledging the start.
|
|
32
|
+
* Only for read-only commands; defaults to false.
|
|
33
|
+
*/
|
|
34
|
+
retryOnDirect?: boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Spawn a short-lived command whose stdin is ignored and whose stdout/stderr are
|
|
38
|
+
* piped. Runs through the spawn broker helper process when available (so the
|
|
39
|
+
* daemon does not fork its own large image), and falls back to spawnProcess.
|
|
40
|
+
* Use spawnProcess instead when you need stdin, detached, a pty or a real
|
|
41
|
+
* ChildProcess handle.
|
|
42
|
+
*/
|
|
43
|
+
export declare function spawnNonInteractive(command: string, args: string[], options?: NonInteractiveSpawnOptions): NonInteractiveChildProcess;
|
|
23
44
|
export declare function execCommand(command: string, args: string[], options?: ExecCommandOptions): Promise<ExecCommandResult>;
|
|
24
45
|
export declare function platformShell(): {
|
|
25
46
|
command: string;
|