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", () => {...@@ -138,7 +138,7 @@ describe("resolveServerEnvironmentLabel", () => {
138 Effect.fail(138 Effect.fail(
139 new ProcessRunner.ProcessSpawnError({139 new ProcessRunner.ProcessSpawnError({
140 command: "scutil",140 command: "scutil",
141 args: ["--get", "ComputerName"],141 argumentCount: 2,
142 cause: new Error("spawn scutil ENOENT"),142 cause: new Error("spawn scutil ENOENT"),
143 }),143 }),
144 ),144 ),
apps/server/src/processRunner.test.ts+89-3
...@@ -4,6 +4,7 @@ import * as Duration from "effect/Duration";...@@ -4,6 +4,7 @@ import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";5import * as Fiber from "effect/Fiber";
6import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
7import * as PlatformError from "effect/PlatformError";
7import * as Sink from "effect/Sink";8import * as Sink from "effect/Sink";
8import * as Stream from "effect/Stream";9import * as Stream from "effect/Stream";
9import { TestClock } from "effect/testing";10import { TestClock } from "effect/testing";
...@@ -55,7 +56,9 @@ function makeHandle(input: {...@@ -55,7 +56,9 @@ function makeHandle(input: {
55}56}
5657
57function makeSpawner(58function makeSpawner(
58 f: (command: ChildProcessCommand) => Effect.Effect<ChildProcessSpawner.ChildProcessHandle>,59 f: (
60 command: ChildProcessCommand,
61 ) => Effect.Effect<ChildProcessSpawner.ChildProcessHandle, PlatformError.PlatformError>,
59) {62) {
60 return ChildProcessSpawner.make((command) => f(asChildProcessCommand(command)));63 return ChildProcessSpawner.make((command) => f(asChildProcessCommand(command)));
61}64}
...@@ -159,6 +162,44 @@ describe("runProcess", () => {...@@ -159,6 +162,44 @@ describe("runProcess", () => {
159 );162 );
160 });163 });
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
162 it.effect("fails when output exceeds max buffer in default mode", () =>203 it.effect("fails when output exceeds max buffer in default mode", () =>
163 Effect.gen(function* () {204 Effect.gen(function* () {
164 const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) })));205 const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) })));
...@@ -169,7 +210,39 @@ describe("runProcess", () => {...@@ -169,7 +210,39 @@ describe("runProcess", () => {
169 maxOutputBytes: 128,210 maxOutputBytes: 128,
170 }).pipe(Effect.flip);211 }).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");
173 }),246 }),
174 );247 );
175248
...@@ -272,6 +345,8 @@ describe("runProcess", () => {...@@ -272,6 +345,8 @@ describe("runProcess", () => {
272 const errorFiber = yield* runWith(spawner)({345 const errorFiber = yield* runWith(spawner)({
273 command: "fake",346 command: "fake",
274 args: ["sleep"],347 args: ["sleep"],
348 cwd: "/logical",
349 spawnCwd: "/actual",
275 timeout: "50 millis",350 timeout: "50 millis",
276 }).pipe(Effect.flip, Effect.forkScoped);351 }).pipe(Effect.flip, Effect.forkScoped);
277352
...@@ -279,7 +354,18 @@ describe("runProcess", () => {...@@ -279,7 +354,18 @@ describe("runProcess", () => {
279 yield* TestClock.adjust(Duration.millis(50));354 yield* TestClock.adjust(Duration.millis(50));
280 const error = yield* Fiber.join(errorFiber);355 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");
283 }),369 }),
284 );370 );
285371
apps/server/src/processRunner.ts+45-20
...@@ -45,23 +45,29 @@ export interface ProcessRunOutput {...@@ -45,23 +45,29 @@ export interface ProcessRunOutput {
4545
46const ProcessInvocationFields = {46const ProcessInvocationFields = {
47 command: Schema.String,47 command: Schema.String,
48 args: Schema.Array(Schema.String),48 argumentCount: Schema.Number,
49 cwd: Schema.optional(Schema.String),49 cwd: Schema.optional(Schema.String),
50 spawnCwd: Schema.optional(Schema.String),
50};51};
5152
52const formatProcessInvocation = (input: {53const formatProcessInvocation = (input: {
53 readonly command: string;54 readonly command: string;
54 readonly args: ReadonlyArray<string>;
55 readonly cwd?: string | undefined;55 readonly cwd?: string | undefined;
56 readonly spawnCwd?: string | undefined;
56}): string => {57}): string => {
57 const command = [input.command, ...input.args].join(" ");58 const executionCwd = input.spawnCwd ?? input.cwd;
58 return input.cwd === undefined ? `'${command}'` : `'${command}' in '${input.cwd}'`;59 return executionCwd === undefined
60 ? `'${input.command}'`
61 : `'${input.command}' in '${executionCwd}'`;
59};62};
6063
61export class ProcessSpawnError extends Schema.TaggedErrorClass<ProcessSpawnError>()(64export class ProcessSpawnError extends Schema.TaggedErrorClass<ProcessSpawnError>()(
62 "ProcessSpawnError",65 "ProcessSpawnError",
63 {66 {
64 ...ProcessInvocationFields,67 ...ProcessInvocationFields,
68 resolvedCommand: Schema.optional(Schema.String),
69 resolvedArgumentCount: Schema.optional(Schema.Number),
70 shell: Schema.optional(Schema.Boolean),
65 cause: Schema.Defect(),71 cause: Schema.Defect(),
66 },72 },
67) {73) {
...@@ -74,6 +80,7 @@ export class ProcessStdinError extends Schema.TaggedErrorClass<ProcessStdinError...@@ -74,6 +80,7 @@ export class ProcessStdinError extends Schema.TaggedErrorClass<ProcessStdinError
74 "ProcessStdinError",80 "ProcessStdinError",
75 {81 {
76 ...ProcessInvocationFields,82 ...ProcessInvocationFields,
83 stdinBytes: Schema.Number,
77 cause: Schema.Defect(),84 cause: Schema.Defect(),
78 },85 },
79) {86) {
...@@ -88,10 +95,11 @@ export class ProcessOutputLimitError extends Schema.TaggedErrorClass<ProcessOutp...@@ -88,10 +95,11 @@ export class ProcessOutputLimitError extends Schema.TaggedErrorClass<ProcessOutp
88 ...ProcessInvocationFields,95 ...ProcessInvocationFields,
89 stream: Schema.Literals(["stdout", "stderr"]),96 stream: Schema.Literals(["stdout", "stderr"]),
90 maxBytes: Schema.Number,97 maxBytes: Schema.Number,
98 observedBytes: Schema.Number,
91 },99 },
92) {100) {
93 override get message(): string {101 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`;
95 }103 }
96}104}
97105
...@@ -120,12 +128,14 @@ export class ProcessTimeoutError extends Schema.TaggedErrorClass<ProcessTimeoutE...@@ -120,12 +128,14 @@ export class ProcessTimeoutError extends Schema.TaggedErrorClass<ProcessTimeoutE
120 }128 }
121}129}
122130
123export type ProcessRunError =131export const ProcessRunError = Schema.Union([
124 | ProcessSpawnError132 ProcessSpawnError,
125 | ProcessStdinError133 ProcessStdinError,
126 | ProcessOutputLimitError134 ProcessOutputLimitError,
127 | ProcessReadError135 ProcessReadError,
128 | ProcessTimeoutError;136 ProcessTimeoutError,
137]);
138export type ProcessRunError = typeof ProcessRunError.Type;
129139
130export class ProcessRunner extends Context.Service<140export class ProcessRunner extends Context.Service<
131 ProcessRunner,141 ProcessRunner,
...@@ -163,6 +173,7 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: {...@@ -163,6 +173,7 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: {
163 readonly command: string;173 readonly command: string;
164 readonly args: ReadonlyArray<string>;174 readonly args: ReadonlyArray<string>;
165 readonly cwd?: string | undefined;175 readonly cwd?: string | undefined;
176 readonly spawnCwd?: string | undefined;
166 readonly streamName: "stdout" | "stderr";177 readonly streamName: "stdout" | "stderr";
167 readonly stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>;178 readonly stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>;
168 readonly maxOutputBytes: number;179 readonly maxOutputBytes: number;
...@@ -174,8 +185,9 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: {...@@ -174,8 +185,9 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: {
174 (cause) =>185 (cause) =>
175 new ProcessReadError({186 new ProcessReadError({
176 command: input.command,187 command: input.command,
177 args: input.args,188 argumentCount: input.args.length,
178 cwd: input.cwd,189 cwd: input.cwd,
190 spawnCwd: input.spawnCwd,
179 stream: input.streamName,191 stream: input.streamName,
180 cause,192 cause,
181 }),193 }),
...@@ -203,14 +215,16 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: {...@@ -203,14 +215,16 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: {
203 () => ({ chunks: [], bytes: 0 }),215 () => ({ chunks: [], bytes: 0 }),
204 (state, chunk) => {216 (state, chunk) => {
205 const remainingBytes = input.maxOutputBytes - state.bytes;217 const remainingBytes = input.maxOutputBytes - state.bytes;
206 if (remainingBytes <= 0 || chunk.byteLength > remainingBytes) {218 if (chunk.byteLength > remainingBytes) {
207 return Effect.fail(219 return Effect.fail(
208 new ProcessOutputLimitError({220 new ProcessOutputLimitError({
209 command: input.command,221 command: input.command,
210 args: input.args,222 argumentCount: input.args.length,
211 cwd: input.cwd,223 cwd: input.cwd,
224 spawnCwd: input.spawnCwd,
212 stream: input.streamName,225 stream: input.streamName,
213 maxBytes: input.maxOutputBytes,226 maxBytes: input.maxOutputBytes,
227 observedBytes: state.bytes + chunk.byteLength,
214 }),228 }),
215 );229 );
216 }230 }
...@@ -259,8 +273,9 @@ function finalizeRunProcess<R>(...@@ -259,8 +273,9 @@ function finalizeRunProcess<R>(
259 return Effect.fail(273 return Effect.fail(
260 new ProcessTimeoutError({274 new ProcessTimeoutError({
261 command: input.command,275 command: input.command,
262 args: input.args,276 argumentCount: input.args.length,
263 cwd: input.cwd,277 cwd: input.cwd,
278 spawnCwd: input.spawnCwd,
264 timeoutMs: Duration.toMillis(timeout),279 timeoutMs: Duration.toMillis(timeout),
265 }),280 }),
266 );281 );
...@@ -300,23 +315,30 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* (...@@ -300,23 +315,30 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* (
300 (cause) =>315 (cause) =>
301 new ProcessSpawnError({316 new ProcessSpawnError({
302 command: input.command,317 command: input.command,
303 args: input.args,318 argumentCount: input.args.length,
304 cwd: input.cwd,319 cwd: input.cwd,
320 spawnCwd: input.spawnCwd,
321 resolvedCommand: spawnCommand.command,
322 resolvedArgumentCount: spawnCommand.args.length,
323 shell: spawnCommand.shell,
305 cause,324 cause,
306 }),325 }),
307 ),326 ),
308 );327 );
309328
329 const stdin = input.stdin;
310 const writeStdin =330 const writeStdin =
311 input.stdin === undefined331 stdin === undefined
312 ? Effect.void332 ? 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(
314 Effect.mapError(334 Effect.mapError(
315 (cause) =>335 (cause) =>
316 new ProcessStdinError({336 new ProcessStdinError({
317 command: input.command,337 command: input.command,
318 args: input.args,338 argumentCount: input.args.length,
319 cwd: input.cwd,339 cwd: input.cwd,
340 spawnCwd: input.spawnCwd,
341 stdinBytes: Buffer.byteLength(stdin),
320 cause,342 cause,
321 }),343 }),
322 ),344 ),
...@@ -328,6 +350,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* (...@@ -328,6 +350,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* (
328 command: input.command,350 command: input.command,
329 args: input.args,351 args: input.args,
330 cwd: input.cwd,352 cwd: input.cwd,
353 spawnCwd: input.spawnCwd,
331 streamName: "stdout",354 streamName: "stdout",
332 stream: child.stdout,355 stream: child.stdout,
333 maxOutputBytes,356 maxOutputBytes,
...@@ -338,6 +361,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* (...@@ -338,6 +361,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* (
338 command: input.command,361 command: input.command,
339 args: input.args,362 args: input.args,
340 cwd: input.cwd,363 cwd: input.cwd,
364 spawnCwd: input.spawnCwd,
341 streamName: "stderr",365 streamName: "stderr",
342 stream: child.stderr,366 stream: child.stderr,
343 maxOutputBytes,367 maxOutputBytes,
...@@ -354,8 +378,9 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* (...@@ -354,8 +378,9 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* (
354 (cause) =>378 (cause) =>
355 new ProcessReadError({379 new ProcessReadError({
356 command: input.command,380 command: input.command,
357 args: input.args,381 argumentCount: input.args.length,
358 cwd: input.cwd,382 cwd: input.cwd,
383 spawnCwd: input.spawnCwd,
359 stream: "exitCode",384 stream: "exitCode",
360 cause,385 cause,
361 }),386 }),