authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:30:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:30:17-07:00
loge01b1903de403c720792a896d8c50ebcd8163b44
tree3294c177ec9aa1e5f37d5690ca3dd2d1d6f2770b
parent1dc36cef73962441befca71678d09fbefdd08c9c
signature Signed by PGP key B5690EEEBB952194

[codex] Structure process diagnostics failures (#3389)

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

2 files changed, 117 insertions(+), 33 deletions(-)

apps/server/src/diagnostics/ProcessDiagnostics.test.ts+39
......@@ -6,6 +6,7 @@ import * as Option from "effect/Option";
66import * as Sink from "effect/Sink";
77import * as Stream from "effect/Stream";
88import { ChildProcessSpawner } from "effect/unstable/process";
9import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
910
1011import * as ProcessDiagnostics from "./ProcessDiagnostics.ts";
1112
......@@ -219,6 +220,44 @@ describe("ProcessDiagnostics", () => {
219220 }),
220221 );
221222
223 it.effect("keeps bounded command diagnostics when the process query exits unsuccessfully", () =>
224 Effect.gen(function* () {
225 const spawnerLayer = Layer.succeed(
226 ChildProcessSpawner.ChildProcessSpawner,
227 ChildProcessSpawner.make(() =>
228 Effect.succeed(
229 mockHandle({
230 code: 17,
231 stdout: "partial process output",
232 stderr: "process access denied",
233 }),
234 ),
235 ),
236 );
237
238 const error = yield* ProcessDiagnostics.readProcessRows.pipe(
239 Effect.provide(spawnerLayer),
240 Effect.provideService(HostProcessPlatform, "linux"),
241 Effect.flip,
242 );
243
244 expect(error).toMatchObject({
245 _tag: "ProcessDiagnosticsQueryFailedError",
246 command: "ps",
247 argCount: 2,
248 cwd: process.cwd(),
249 exitCode: 17,
250 stdoutBytes: 22,
251 stderrBytes: 21,
252 stdoutTruncated: false,
253 stderrTruncated: false,
254 });
255 expect(error.message).toBe(
256 `Process diagnostics query 'ps' failed with exit code 17 in '${process.cwd()}'.`,
257 );
258 }),
259 );
260
222261 it.effect("does not allow signaling the diagnostics query process", () =>
223262 Effect.gen(function* () {
224263 const spawnerLayer = Layer.succeed(
apps/server/src/diagnostics/ProcessDiagnostics.ts+78-33
......@@ -45,10 +45,15 @@ export class ProcessDiagnostics extends Context.Service<
4545
4646class ProcessDiagnosticsQueryTimeoutError extends Schema.TaggedErrorClass<ProcessDiagnosticsQueryTimeoutError>()(
4747 "ProcessDiagnosticsQueryTimeoutError",
48 { command: Schema.String },
48 {
49 command: Schema.String,
50 argCount: Schema.Number,
51 cwd: Schema.String,
52 timeoutMillis: Schema.Number,
53 },
4954) {
5055 override get message(): string {
51 return `Process diagnostics query '${this.command}' timed out.`;
56 return `Process diagnostics query '${this.command}' timed out after ${this.timeoutMillis}ms in '${this.cwd}'.`;
5257 }
5358}
5459
......@@ -56,12 +61,19 @@ class ProcessDiagnosticsQueryFailedError extends Schema.TaggedErrorClass<Process
5661 "ProcessDiagnosticsQueryFailedError",
5762 {
5863 command: Schema.String,
59 stderr: Schema.optional(Schema.String),
64 argCount: Schema.Number,
65 cwd: Schema.String,
66 exitCode: Schema.optional(Schema.Number),
67 stdoutBytes: Schema.optional(Schema.Number),
68 stderrBytes: Schema.optional(Schema.Number),
69 stdoutTruncated: Schema.optional(Schema.Boolean),
70 stderrTruncated: Schema.optional(Schema.Boolean),
6071 cause: Schema.optional(Schema.Defect()),
6172 },
6273) {
6374 override get message(): string {
64 return this.stderr?.trim() || `Failed to query process diagnostics with '${this.command}'.`;
75 const exitCode = this.exitCode === undefined ? "" : ` with exit code ${this.exitCode}`;
76 return `Process diagnostics query '${this.command}' failed${exitCode} in '${this.cwd}'.`;
6577 }
6678}
6779
......@@ -76,7 +88,10 @@ class ProcessDiagnosticsServerProcessSignalError extends Schema.TaggedErrorClass
7688
7789class ProcessDiagnosticsNotDescendantError extends Schema.TaggedErrorClass<ProcessDiagnosticsNotDescendantError>()(
7890 "ProcessDiagnosticsNotDescendantError",
79 { pid: Schema.Number },
91 {
92 pid: Schema.Number,
93 serverPid: Schema.Number,
94 },
8095) {
8196 override get message(): string {
8297 return `Process ${this.pid} is not a live descendant of the T3 server.`;
......@@ -312,20 +327,29 @@ function makeResult(input: {
312327}
313328
314329interface ProcessOutput {
330 readonly cwd: string;
315331 readonly exitCode: number;
316332 readonly stdout: string;
333 readonly stdoutBytes: number;
334 readonly stdoutTruncated: boolean;
317335 readonly stderr: string;
336 readonly stderrBytes: number;
337 readonly stderrTruncated: boolean;
318338}
319339
320const runProcess = Effect.fn("runProcess")(
321 function* (input: { readonly command: string; readonly args: ReadonlyArray<string> }) {
340const runProcess = Effect.fn("runProcess")(function* (input: {
341 readonly command: string;
342 readonly args: ReadonlyArray<string>;
343}) {
344 const cwd = process.cwd();
345 return yield* Effect.gen(function* () {
322346 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
323347 // `ps` and `powershell.exe` are real executables; spawning through cmd.exe
324348 // shell mode would re-tokenize the PowerShell `-Command` payload (which
325349 // contains pipes) before PowerShell ever sees it.
326350 const child = yield* spawner.spawn(
327351 ChildProcess.make(input.command, input.args, {
328 cwd: process.cwd(),
352 cwd,
329353 }),
330354 );
331355 const [stdout, stderr, exitCode] = yield* Effect.all(
......@@ -346,36 +370,44 @@ const runProcess = Effect.fn("runProcess")(
346370 );
347371
348372 return {
373 cwd,
349374 exitCode,
350375 stdout: stdout.text,
376 stdoutBytes: stdout.bytes,
377 stdoutTruncated: stdout.truncated,
351378 stderr: stderr.text,
379 stderrBytes: stderr.bytes,
380 stderrTruncated: stderr.truncated,
352381 } satisfies ProcessOutput;
353 },
354 (effect, input) =>
355 effect.pipe(
356 Effect.scoped,
357 Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)),
358 Effect.flatMap((result) =>
359 Option.match(result, {
360 onNone: () =>
361 Effect.fail(
362 new ProcessDiagnosticsQueryTimeoutError({
363 command: input.command,
364 }),
365 ),
366 onSome: Effect.succeed,
367 }),
368 ),
369 Effect.mapError((cause) =>
370 isProcessDiagnosticsError(cause)
371 ? cause
372 : new ProcessDiagnosticsQueryFailedError({
382 }).pipe(
383 Effect.scoped,
384 Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)),
385 Effect.flatMap((result) =>
386 Option.match(result, {
387 onNone: () =>
388 Effect.fail(
389 new ProcessDiagnosticsQueryTimeoutError({
373390 command: input.command,
374 cause,
391 argCount: input.args.length,
392 cwd,
393 timeoutMillis: PROCESS_QUERY_TIMEOUT_MS,
375394 }),
376 ),
395 ),
396 onSome: Effect.succeed,
397 }),
398 ),
399 Effect.mapError((cause) =>
400 isProcessDiagnosticsError(cause)
401 ? cause
402 : new ProcessDiagnosticsQueryFailedError({
403 command: input.command,
404 argCount: input.args.length,
405 cwd,
406 cause,
407 }),
377408 ),
378);
409 );
410});
379411
380412function readPosixProcessRows(): Effect.Effect<
381413 ReadonlyArray<ProcessRow>,
......@@ -391,7 +423,13 @@ function readPosixProcessRows(): Effect.Effect<
391423 ? Effect.fail(
392424 new ProcessDiagnosticsQueryFailedError({
393425 command: "ps",
394 stderr: result.stderr.trim() || "ps failed.",
426 argCount: 2,
427 cwd: result.cwd,
428 exitCode: result.exitCode,
429 stdoutBytes: result.stdoutBytes,
430 stderrBytes: result.stderrBytes,
431 stdoutTruncated: result.stdoutTruncated,
432 stderrTruncated: result.stderrTruncated,
395433 }),
396434 )
397435 : Effect.succeed(parsePosixProcessRows(result.stdout)),
......@@ -421,7 +459,13 @@ function readWindowsProcessRows(): Effect.Effect<
421459 ? Effect.fail(
422460 new ProcessDiagnosticsQueryFailedError({
423461 command: "powershell.exe",
424 stderr: result.stderr.trim() || "PowerShell process query failed.",
462 argCount: 4,
463 cwd: result.cwd,
464 exitCode: result.exitCode,
465 stdoutBytes: result.stdoutBytes,
466 stderrBytes: result.stderrBytes,
467 stdoutTruncated: result.stdoutTruncated,
468 stderrTruncated: result.stderrTruncated,
425469 }),
426470 )
427471 : Effect.succeed(parseWindowsProcessRows(result.stdout)),
......@@ -464,6 +508,7 @@ function assertDescendantPid(
464508 : Effect.fail(
465509 new ProcessDiagnosticsNotDescendantError({
466510 pid,
511 serverPid: process.pid,
467512 }),
468513 );
469514 }),