@ricsam/r5d-worker 0.0.110 → 0.0.112

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,316 @@
1
+ import { spawn } from "node:child_process";
2
+ class CommandLauncherUnavailableError extends Error {
3
+ constructor(message, cause) {
4
+ super(message, { cause });
5
+ this.name = "CommandLauncherUnavailableError";
6
+ }
7
+ }
8
+ class CommandLaunchCancelledError extends Error {
9
+ constructor() {
10
+ super("Command cancelled before it started");
11
+ this.name = "CommandLaunchCancelledError";
12
+ }
13
+ }
14
+ function parseCommandLauncher(configuration) {
15
+ if (configuration === void 0) return void 0;
16
+ let argv;
17
+ try {
18
+ argv = JSON.parse(configuration);
19
+ } catch {
20
+ throw new Error("R5D_WORKER_COMMAND_LAUNCHER must be a JSON argv array");
21
+ }
22
+ if (!Array.isArray(argv) || argv.length === 0 || argv.some((part) => typeof part !== "string" || part.includes("\0")) || !argv[0])
23
+ throw new Error("R5D_WORKER_COMMAND_LAUNCHER must be a nonempty JSON argv array without NUL characters");
24
+ return argv;
25
+ }
26
+ function commandLauncherEnvironment(source = process.env) {
27
+ const result = {};
28
+ for (const [key, value] of Object.entries(source)) {
29
+ if (/^(PATH|HOME|USER|LOGNAME|TMP|TMPDIR|TEMP|SHELL|SYSTEMROOT|COMSPEC|PATHEXT|LANG|LC_[A-Z_]+)$/i.test(key)) result[key] = value;
30
+ }
31
+ return result;
32
+ }
33
+ class WorkerCommandLauncher {
34
+ constructor(options = {}) {
35
+ this.options = options;
36
+ this.mode = options.argv ? "external" : "direct";
37
+ if (!options.argv) {
38
+ this.exited = Promise.resolve();
39
+ return;
40
+ }
41
+ this.child = spawn(options.argv[0], options.argv.slice(1), {
42
+ env: options.env ?? commandLauncherEnvironment(),
43
+ stdio: ["pipe", "pipe", "pipe"]
44
+ });
45
+ this.exited = new Promise((resolve) => {
46
+ this.child.once("close", (code, signal) => {
47
+ if (!this.closing) this.fail(new Error(`Configured command launcher exited (${signal ?? code ?? "unknown"})`));
48
+ resolve();
49
+ });
50
+ });
51
+ this.child.once("error", (error) => this.fail(error));
52
+ this.child.stdin.on("error", (error) => {
53
+ if (!this.closing) this.fail(error);
54
+ });
55
+ let buffered = "";
56
+ this.child.stdout.setEncoding("utf8");
57
+ this.child.stdout.on("data", (data) => {
58
+ buffered += data;
59
+ if (Buffer.byteLength(buffered) > 1024 * 1024) {
60
+ this.fail(new Error("Command launcher response exceeded the protocol limit"));
61
+ return;
62
+ }
63
+ while (buffered.includes("\n")) {
64
+ const newline = buffered.indexOf("\n");
65
+ const line = buffered.slice(0, newline);
66
+ buffered = buffered.slice(newline + 1);
67
+ try {
68
+ this.receive(JSON.parse(line));
69
+ } catch (error) {
70
+ this.fail(error instanceof Error ? error : new Error(String(error)));
71
+ return;
72
+ }
73
+ }
74
+ });
75
+ this.child.stderr.setEncoding("utf8");
76
+ this.child.stderr.on("data", (data) => options.onStderr?.(data));
77
+ }
78
+ options;
79
+ mode;
80
+ child;
81
+ pending = /* @__PURE__ */ new Map();
82
+ launches = /* @__PURE__ */ new Map();
83
+ exited;
84
+ failure;
85
+ closing = false;
86
+ initialized = false;
87
+ closePromise;
88
+ sequence = 0;
89
+ async initialize() {
90
+ this.assertHealthy();
91
+ if (this.initialized) return;
92
+ if (this.child) {
93
+ try {
94
+ await this.rpc("initialize");
95
+ } catch (error) {
96
+ this.fail(error instanceof Error ? error : new Error(String(error)));
97
+ await this.close();
98
+ throw error;
99
+ }
100
+ }
101
+ this.initialized = true;
102
+ }
103
+ get activeIds() {
104
+ return [...this.launches.keys()];
105
+ }
106
+ async acquire(request) {
107
+ this.assertHealthy();
108
+ if (!this.initialized) throw new Error("Command launcher is not initialized");
109
+ request.assertAdmission();
110
+ if (this.launches.has(request.id)) throw new Error(`Duplicate command launch identity: ${request.id}`);
111
+ let rejectAdmission;
112
+ const cancellation = new Promise((_resolve, reject) => {
113
+ rejectAdmission = reject;
114
+ });
115
+ void cancellation.catch(() => {
116
+ });
117
+ const record = { request, revoked: false, released: false, rejectAdmission };
118
+ this.launches.set(request.id, record);
119
+ let prefix = [];
120
+ let admissionTimer;
121
+ try {
122
+ if (this.child) {
123
+ const { assertAdmission: _assertAdmission, ...fields } = request;
124
+ const acquisition = this.rpc("acquire", fields, this.options.acquisitionTimeoutMs ?? 9e4).then(async (result) => {
125
+ if (record.revoked) {
126
+ await this.releaseRecord(record);
127
+ throw new CommandLaunchCancelledError();
128
+ }
129
+ if (!Array.isArray(result.argvPrefix) || result.argvPrefix.some((part) => typeof part !== "string" || part.includes("\0")))
130
+ throw this.fail(new Error("Command launcher acquire returned an invalid argvPrefix"));
131
+ return result.argvPrefix;
132
+ });
133
+ admissionTimer = setInterval(() => {
134
+ try {
135
+ request.assertAdmission();
136
+ } catch (error) {
137
+ record.revoked = true;
138
+ record.rejectAdmission(error);
139
+ void this.cancel(request.id).catch(() => {
140
+ });
141
+ }
142
+ }, 100);
143
+ prefix = await Promise.race([acquisition, cancellation]);
144
+ }
145
+ request.assertAdmission();
146
+ this.assertHealthy();
147
+ if (record.revoked) throw new CommandLaunchCancelledError();
148
+ } catch (error) {
149
+ if (!this.failure && !this.closing) await this.releaseRecord(record);
150
+ throw error;
151
+ } finally {
152
+ if (admissionTimer) clearInterval(admissionTimer);
153
+ }
154
+ return {
155
+ wrap: (argv) => {
156
+ this.assertHealthy();
157
+ request.assertAdmission();
158
+ if (record.revoked) throw new CommandLaunchCancelledError();
159
+ if (record.released) throw new Error("Command launch lease is already released");
160
+ if (!argv.length) throw new Error("Command argv must not be empty");
161
+ return [...prefix, ...argv];
162
+ },
163
+ cancel: () => this.cancel(request.id),
164
+ diagnose: async () => {
165
+ this.assertHealthy();
166
+ if (!this.child) return void 0;
167
+ try {
168
+ const result = await this.rpc("diagnose", { id: request.id });
169
+ if (result.message !== void 0 && typeof result.message !== "string")
170
+ throw new Error("Command launcher returned an invalid diagnostic");
171
+ return result.message;
172
+ } catch (error) {
173
+ throw this.fail(error instanceof Error ? error : new Error(String(error)));
174
+ }
175
+ },
176
+ release: () => this.releaseRecord(record)
177
+ };
178
+ }
179
+ async cancel(id) {
180
+ const record = this.launches.get(id);
181
+ if (!record || record.released) return;
182
+ record.revoked = true;
183
+ record.rejectAdmission(new CommandLaunchCancelledError());
184
+ if (this.child) {
185
+ try {
186
+ await this.rpc("cancel", { id });
187
+ } catch (error) {
188
+ throw this.fail(error instanceof Error ? error : new Error(String(error)));
189
+ }
190
+ }
191
+ }
192
+ async cancelSession(sessionId) {
193
+ await this.cancelMatching((_id, ownerSessionId) => ownerSessionId === sessionId);
194
+ }
195
+ async cancelMatching(matches) {
196
+ const results = await Promise.allSettled(
197
+ [...this.launches].filter(([id, record]) => matches(id, record.request.sessionId)).map(([id]) => this.cancel(id))
198
+ );
199
+ const errors = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
200
+ if (errors.length) throw new AggregateError(errors, "Command launcher cancellation was incomplete");
201
+ }
202
+ close() {
203
+ return this.closePromise ??= (async () => {
204
+ this.closing = true;
205
+ for (const record of this.launches.values()) {
206
+ record.revoked = true;
207
+ record.rejectAdmission(new CommandLaunchCancelledError());
208
+ }
209
+ this.rejectPending(new CommandLauncherUnavailableError("Command launcher is shutting down"));
210
+ this.child?.stdin.end();
211
+ const exited = await this.waitForExit(6e3);
212
+ if (!exited) {
213
+ this.child?.kill("SIGKILL");
214
+ await this.waitForExit(1e3);
215
+ }
216
+ })();
217
+ }
218
+ async waitForExit(timeoutMs) {
219
+ let timer;
220
+ try {
221
+ return await Promise.race([
222
+ this.exited.then(() => true),
223
+ new Promise((resolve) => {
224
+ timer = setTimeout(() => resolve(false), timeoutMs);
225
+ })
226
+ ]);
227
+ } finally {
228
+ if (timer) clearTimeout(timer);
229
+ }
230
+ }
231
+ releaseRecord(record) {
232
+ return record.release ??= (async () => {
233
+ record.revoked = true;
234
+ if (this.child) {
235
+ try {
236
+ await this.rpc("release", { id: record.request.id });
237
+ } catch (error) {
238
+ throw this.fail(error instanceof Error ? error : new Error(String(error)));
239
+ }
240
+ }
241
+ record.released = true;
242
+ this.launches.delete(record.request.id);
243
+ })();
244
+ }
245
+ assertHealthy() {
246
+ if (this.failure) throw this.failure;
247
+ if (this.closing) throw new CommandLauncherUnavailableError("Command launcher is shutting down");
248
+ }
249
+ rpc(method, fields = {}, timeoutMs = this.options.requestTimeoutMs ?? 1e4) {
250
+ try {
251
+ this.assertHealthy();
252
+ } catch (error) {
253
+ return Promise.reject(error);
254
+ }
255
+ const requestId = String(++this.sequence);
256
+ return new Promise((resolve, reject) => {
257
+ const timer = setTimeout(() => this.fail(new Error(`Command launcher ${method} request timed out`)), timeoutMs);
258
+ this.pending.set(requestId, { resolve, reject, timer });
259
+ this.child.stdin.write(`${JSON.stringify({ requestId, method, ...fields })}
260
+ `, (error) => {
261
+ if (error) this.fail(error);
262
+ });
263
+ });
264
+ }
265
+ receive(value) {
266
+ if (!value || typeof value !== "object") throw new Error("Invalid command launcher response");
267
+ const response = value;
268
+ if (typeof response.requestId !== "string") throw new Error("Command launcher response is missing requestId");
269
+ const request = this.pending.get(response.requestId);
270
+ if (!request) throw new Error("Command launcher returned an unknown requestId");
271
+ this.pending.delete(response.requestId);
272
+ clearTimeout(request.timer);
273
+ if (typeof response.error === "string") {
274
+ request.reject(new Error(response.error));
275
+ return;
276
+ }
277
+ if (!response.result || typeof response.result !== "object" || Array.isArray(response.result)) {
278
+ const error = new Error("Command launcher response is missing result");
279
+ request.reject(error);
280
+ throw error;
281
+ }
282
+ request.resolve(response.result);
283
+ }
284
+ rejectPending(error) {
285
+ for (const request of this.pending.values()) {
286
+ clearTimeout(request.timer);
287
+ request.reject(error);
288
+ }
289
+ this.pending.clear();
290
+ }
291
+ fail(error) {
292
+ if (this.failure || this.closing) return this.failure ?? error;
293
+ this.failure = new CommandLauncherUnavailableError(`Command launcher unavailable: ${error.message}`, error);
294
+ for (const record of this.launches.values()) {
295
+ record.revoked = true;
296
+ record.rejectAdmission(this.failure);
297
+ }
298
+ this.rejectPending(this.failure);
299
+ this.child?.kill("SIGTERM");
300
+ this.options.onFailure?.(this.failure, this.activeIds);
301
+ return this.failure;
302
+ }
303
+ }
304
+ async function createWorkerCommandLauncher(options = {}) {
305
+ const launcher = new WorkerCommandLauncher({ ...options, argv: parseCommandLauncher(options.configuration) });
306
+ await launcher.initialize();
307
+ return launcher;
308
+ }
309
+ export {
310
+ CommandLaunchCancelledError,
311
+ CommandLauncherUnavailableError,
312
+ WorkerCommandLauncher,
313
+ commandLauncherEnvironment,
314
+ createWorkerCommandLauncher,
315
+ parseCommandLauncher
316
+ };
@@ -0,0 +1,37 @@
1
+ const WORKER_INLINE_OUTPUT_MAX_BYTES = 8 * 1024 * 1024;
2
+ async function collectWorkerInlineOutput(stream, maxBytes = WORKER_INLINE_OUTPUT_MAX_BYTES, signal) {
3
+ if (!stream) return "";
4
+ signal?.throwIfAborted();
5
+ const reader = stream.getReader();
6
+ const abort = () => {
7
+ void reader.cancel().catch(() => {
8
+ });
9
+ };
10
+ signal?.addEventListener("abort", abort, { once: true });
11
+ const decoder = new TextDecoder();
12
+ const parts = [];
13
+ let bytes = 0;
14
+ try {
15
+ while (true) {
16
+ const next = await reader.read();
17
+ signal?.throwIfAborted();
18
+ if (next.done) break;
19
+ bytes += next.value.byteLength;
20
+ if (bytes > maxBytes) {
21
+ void reader.cancel().catch(() => {
22
+ });
23
+ throw new Error(`Inline command output exceeded ${maxBytes} bytes. Use streaming execution for large output.`);
24
+ }
25
+ parts.push(decoder.decode(next.value, { stream: true }));
26
+ }
27
+ parts.push(decoder.decode());
28
+ return parts.join("");
29
+ } finally {
30
+ signal?.removeEventListener("abort", abort);
31
+ reader.releaseLock();
32
+ }
33
+ }
34
+ export {
35
+ WORKER_INLINE_OUTPUT_MAX_BYTES,
36
+ collectWorkerInlineOutput
37
+ };