authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:37:50-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:37:50-07:00
logd8bf307d22663a3c458dc521a014fd21bdb60b98
tree55ef7e5882439e621a2835bdb8220f28149f2749
parentd512deac84aecb8ec2e4978599ce31a3185f5350
signature Signed by PGP key B5690EEEBB952194

[codex] Enrich process runner errors (#3268)

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 135 insertions(+), 24 deletions(-)

apps/server/src/environment/ServerEnvironmentLabel.test.ts+1-1
......@@ -138,7 +138,7 @@ describe("resolveServerEnvironmentLabel", () => {
138138 Effect.fail(
139139 new ProcessRunner.ProcessSpawnError({
140140 command: "scutil",
141 args: ["--get", "ComputerName"],
141 argumentCount: 2,
142142 cause: new Error("spawn scutil ENOENT"),
143143 }),
144144 ),
apps/server/src/processRunner.test.ts+89-3
......@@ -4,6 +4,7 @@ import * as Duration from "effect/Duration";
44import * as Effect from "effect/Effect";
55import * as Fiber from "effect/Fiber";
66import * as Layer from "effect/Layer";
7import * as PlatformError from "effect/PlatformError";
78import * as Sink from "effect/Sink";
89import * as Stream from "effect/Stream";
910import { TestClock } from "effect/testing";
......@@ -55,7 +56,9 @@ function makeHandle(input: {
5556}
5657
5758function makeSpawner(
58 f: (command: ChildProcessCommand) => Effect.Effect<ChildProcessSpawner.ChildProcessHandle>,
59 f: (
60 command: ChildProcessCommand,
61 ) => Effect.Effect<ChildProcessSpawner.ChildProcessHandle, PlatformError.PlatformError>,
5962) {
6063 return ChildProcessSpawner.make((command) => f(asChildProcessCommand(command)));
6164}
......@@ -159,6 +162,44 @@ describe("runProcess", () => {
159162 );
160163 });
161164
165 it.effect("preserves resolved spawn context and cause", () =>
166 Effect.gen(function* () {
167 const cause = PlatformError.systemError({
168 _tag: "PermissionDenied",
169 module: "ChildProcessSpawner",
170 method: "spawn",
171 pathOrDescriptor: "/actual/fake",
172 });
173 const spawner = makeSpawner(() => Effect.fail(cause));
174
175 const error = yield* runWith(spawner)({
176 command: "fake",
177 args: ["--flag", "secret-token-value"],
178 cwd: "/logical",
179 spawnCwd: "/actual",
180 }).pipe(Effect.flip);
181
182 expect(error._tag).toBe("ProcessSpawnError");
183 if (error._tag !== "ProcessSpawnError") {
184 return expect.fail("Expected ProcessSpawnError");
185 }
186 expect(error).toMatchObject({
187 command: "fake",
188 argumentCount: 2,
189 cwd: "/logical",
190 spawnCwd: "/actual",
191 resolvedCommand: "fake",
192 resolvedArgumentCount: 2,
193 shell: false,
194 });
195 expect(error.cause).toBe(cause);
196 expect(error.message).toBe("Failed to spawn process 'fake' in '/actual'");
197 expect(error).not.toHaveProperty("args");
198 expect(error).not.toHaveProperty("resolvedArgs");
199 expect(error.message).not.toContain("secret-token-value");
200 }),
201 );
202
162203 it.effect("fails when output exceeds max buffer in default mode", () =>
163204 Effect.gen(function* () {
164205 const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) })));
......@@ -169,7 +210,39 @@ describe("runProcess", () => {
169210 maxOutputBytes: 128,
170211 }).pipe(Effect.flip);
171212
172 expect(error).toBeInstanceOf(ProcessRunner.ProcessOutputLimitError);
213 expect(error._tag).toBe("ProcessOutputLimitError");
214 if (error._tag !== "ProcessOutputLimitError") {
215 return expect.fail("Expected ProcessOutputLimitError");
216 }
217 expect(error).toMatchObject({
218 stream: "stdout",
219 maxBytes: 128,
220 observedBytes: 2048,
221 });
222 expect(error.message).toBe(
223 "Process 'fake' stdout produced 2048 bytes, exceeding the 128 byte limit",
224 );
225 }),
226 );
227
228 it.effect("accepts output at the byte limit followed by an empty chunk", () =>
229 Effect.gen(function* () {
230 const output = new TextEncoder().encode("exactly");
231 const spawner = makeSpawner(() =>
232 Effect.succeed(
233 makeHandle({
234 stdout: Stream.make(output, new Uint8Array()),
235 }),
236 ),
237 );
238
239 const result = yield* runWith(spawner)({
240 command: "fake",
241 args: ["exact-limit"],
242 maxOutputBytes: output.byteLength,
243 });
244
245 expect(result.stdout).toBe("exactly");
173246 }),
174247 );
175248
......@@ -272,6 +345,8 @@ describe("runProcess", () => {
272345 const errorFiber = yield* runWith(spawner)({
273346 command: "fake",
274347 args: ["sleep"],
348 cwd: "/logical",
349 spawnCwd: "/actual",
275350 timeout: "50 millis",
276351 }).pipe(Effect.flip, Effect.forkScoped);
277352
......@@ -279,7 +354,18 @@ describe("runProcess", () => {
279354 yield* TestClock.adjust(Duration.millis(50));
280355 const error = yield* Fiber.join(errorFiber);
281356
282 expect(error).toBeInstanceOf(ProcessRunner.ProcessTimeoutError);
357 expect(error._tag).toBe("ProcessTimeoutError");
358 if (error._tag !== "ProcessTimeoutError") {
359 return expect.fail("Expected ProcessTimeoutError");
360 }
361 expect(error).toMatchObject({
362 command: "fake",
363 argumentCount: 1,
364 cwd: "/logical",
365 spawnCwd: "/actual",
366 timeoutMs: 50,
367 });
368 expect(error.message).toBe("Process 'fake' in '/actual' timed out after 50ms");
283369 }),
284370 );
285371
apps/server/src/processRunner.ts+45-20
......@@ -45,23 +45,29 @@ export interface ProcessRunOutput {
4545
4646const ProcessInvocationFields = {
4747 command: Schema.String,
48 args: Schema.Array(Schema.String),
48 argumentCount: Schema.Number,
4949 cwd: Schema.optional(Schema.String),
50 spawnCwd: Schema.optional(Schema.String),
5051};
5152
5253const formatProcessInvocation = (input: {
5354 readonly command: string;
54 readonly args: ReadonlyArray<string>;
5555 readonly cwd?: string | undefined;
56 readonly spawnCwd?: string | undefined;
5657}): string => {
57 const command = [input.command, ...input.args].join(" ");
58 return input.cwd === undefined ? `'${command}'` : `'${command}' in '${input.cwd}'`;
58 const executionCwd = input.spawnCwd ?? input.cwd;
59 return executionCwd === undefined
60 ? `'${input.command}'`
61 : `'${input.command}' in '${executionCwd}'`;
5962};
6063
6164export class ProcessSpawnError extends Schema.TaggedErrorClass<ProcessSpawnError>()(
6265 "ProcessSpawnError",
6366 {
6467 ...ProcessInvocationFields,
68 resolvedCommand: Schema.optional(Schema.String),
69 resolvedArgumentCount: Schema.optional(Schema.Number),
70 shell: Schema.optional(Schema.Boolean),
6571 cause: Schema.Defect(),
6672 },
6773) {
......@@ -74,6 +80,7 @@ export class ProcessStdinError extends Schema.TaggedErrorClass<ProcessStdinError
7480 "ProcessStdinError",
7581 {
7682 ...ProcessInvocationFields,
83 stdinBytes: Schema.Number,
7784 cause: Schema.Defect(),
7885 },
7986) {
......@@ -88,10 +95,11 @@ export class ProcessOutputLimitError extends Schema.TaggedErrorClass<ProcessOutp
8895 ...ProcessInvocationFields,
8996 stream: Schema.Literals(["stdout", "stderr"]),
9097 maxBytes: Schema.Number,
98 observedBytes: Schema.Number,
9199 },
92100) {
93101 override get message(): string {
94 return `Process ${formatProcessInvocation(this)} ${this.stream} exceeded ${this.maxBytes} bytes`;
102 return `Process ${formatProcessInvocation(this)} ${this.stream} produced ${this.observedBytes} bytes, exceeding the ${this.maxBytes} byte limit`;
95103 }
96104}
97105
......@@ -120,12 +128,14 @@ export class ProcessTimeoutError extends Schema.TaggedErrorClass<ProcessTimeoutE
120128 }
121129}
122130
123export type ProcessRunError =
124 | ProcessSpawnError
125 | ProcessStdinError
126 | ProcessOutputLimitError
127 | ProcessReadError
128 | ProcessTimeoutError;
131export const ProcessRunError = Schema.Union([
132 ProcessSpawnError,
133 ProcessStdinError,
134 ProcessOutputLimitError,
135 ProcessReadError,
136 ProcessTimeoutError,
137]);
138export type ProcessRunError = typeof ProcessRunError.Type;
129139
130140export class ProcessRunner extends Context.Service<
131141 ProcessRunner,
......@@ -163,6 +173,7 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: {
163173 readonly command: string;
164174 readonly args: ReadonlyArray<string>;
165175 readonly cwd?: string | undefined;
176 readonly spawnCwd?: string | undefined;
166177 readonly streamName: "stdout" | "stderr";
167178 readonly stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>;
168179 readonly maxOutputBytes: number;
......@@ -174,8 +185,9 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: {
174185 (cause) =>
175186 new ProcessReadError({
176187 command: input.command,
177 args: input.args,
188 argumentCount: input.args.length,
178189 cwd: input.cwd,
190 spawnCwd: input.spawnCwd,
179191 stream: input.streamName,
180192 cause,
181193 }),
......@@ -203,14 +215,16 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: {
203215 () => ({ chunks: [], bytes: 0 }),
204216 (state, chunk) => {
205217 const remainingBytes = input.maxOutputBytes - state.bytes;
206 if (remainingBytes <= 0 || chunk.byteLength > remainingBytes) {
218 if (chunk.byteLength > remainingBytes) {
207219 return Effect.fail(
208220 new ProcessOutputLimitError({
209221 command: input.command,
210 args: input.args,
222 argumentCount: input.args.length,
211223 cwd: input.cwd,
224 spawnCwd: input.spawnCwd,
212225 stream: input.streamName,
213226 maxBytes: input.maxOutputBytes,
227 observedBytes: state.bytes + chunk.byteLength,
214228 }),
215229 );
216230 }
......@@ -259,8 +273,9 @@ function finalizeRunProcess<R>(
259273 return Effect.fail(
260274 new ProcessTimeoutError({
261275 command: input.command,
262 args: input.args,
276 argumentCount: input.args.length,
263277 cwd: input.cwd,
278 spawnCwd: input.spawnCwd,
264279 timeoutMs: Duration.toMillis(timeout),
265280 }),
266281 );
......@@ -300,23 +315,30 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* (
300315 (cause) =>
301316 new ProcessSpawnError({
302317 command: input.command,
303 args: input.args,
318 argumentCount: input.args.length,
304319 cwd: input.cwd,
320 spawnCwd: input.spawnCwd,
321 resolvedCommand: spawnCommand.command,
322 resolvedArgumentCount: spawnCommand.args.length,
323 shell: spawnCommand.shell,
305324 cause,
306325 }),
307326 ),
308327 );
309328
329 const stdin = input.stdin;
310330 const writeStdin =
311 input.stdin === undefined
331 stdin === undefined
312332 ? Effect.void
313 : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe(
333 : Stream.run(Stream.encodeText(Stream.make(stdin)), child.stdin).pipe(
314334 Effect.mapError(
315335 (cause) =>
316336 new ProcessStdinError({
317337 command: input.command,
318 args: input.args,
338 argumentCount: input.args.length,
319339 cwd: input.cwd,
340 spawnCwd: input.spawnCwd,
341 stdinBytes: Buffer.byteLength(stdin),
320342 cause,
321343 }),
322344 ),
......@@ -328,6 +350,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* (
328350 command: input.command,
329351 args: input.args,
330352 cwd: input.cwd,
353 spawnCwd: input.spawnCwd,
331354 streamName: "stdout",
332355 stream: child.stdout,
333356 maxOutputBytes,
......@@ -338,6 +361,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* (
338361 command: input.command,
339362 args: input.args,
340363 cwd: input.cwd,
364 spawnCwd: input.spawnCwd,
341365 streamName: "stderr",
342366 stream: child.stderr,
343367 maxOutputBytes,
......@@ -354,8 +378,9 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* (
354378 (cause) =>
355379 new ProcessReadError({
356380 command: input.command,
357 args: input.args,
381 argumentCount: input.args.length,
358382 cwd: input.cwd,
383 spawnCwd: input.spawnCwd,
359384 stream: "exitCode",
360385 cause,
361386 }),