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";...@@ -6,6 +6,7 @@ import * as Option from "effect/Option";
6import * as Sink from "effect/Sink";6import * as Sink from "effect/Sink";
7import * as Stream from "effect/Stream";7import * as Stream from "effect/Stream";
8import { ChildProcessSpawner } from "effect/unstable/process";8import { ChildProcessSpawner } from "effect/unstable/process";
9import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
910
10import * as ProcessDiagnostics from "./ProcessDiagnostics.ts";11import * as ProcessDiagnostics from "./ProcessDiagnostics.ts";
1112
...@@ -219,6 +220,44 @@ describe("ProcessDiagnostics", () => {...@@ -219,6 +220,44 @@ describe("ProcessDiagnostics", () => {
219 }),220 }),
220 );221 );
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
222 it.effect("does not allow signaling the diagnostics query process", () =>261 it.effect("does not allow signaling the diagnostics query process", () =>
223 Effect.gen(function* () {262 Effect.gen(function* () {
224 const spawnerLayer = Layer.succeed(263 const spawnerLayer = Layer.succeed(
apps/server/src/diagnostics/ProcessDiagnostics.ts+78-33
...@@ -45,10 +45,15 @@ export class ProcessDiagnostics extends Context.Service<...@@ -45,10 +45,15 @@ export class ProcessDiagnostics extends Context.Service<
4545
46class ProcessDiagnosticsQueryTimeoutError extends Schema.TaggedErrorClass<ProcessDiagnosticsQueryTimeoutError>()(46class ProcessDiagnosticsQueryTimeoutError extends Schema.TaggedErrorClass<ProcessDiagnosticsQueryTimeoutError>()(
47 "ProcessDiagnosticsQueryTimeoutError",47 "ProcessDiagnosticsQueryTimeoutError",
48 { command: Schema.String },48 {
49 command: Schema.String,
50 argCount: Schema.Number,
51 cwd: Schema.String,
52 timeoutMillis: Schema.Number,
53 },
49) {54) {
50 override get message(): string {55 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}'.`;
52 }57 }
53}58}
5459
...@@ -56,12 +61,19 @@ class ProcessDiagnosticsQueryFailedError extends Schema.TaggedErrorClass<Process...@@ -56,12 +61,19 @@ class ProcessDiagnosticsQueryFailedError extends Schema.TaggedErrorClass<Process
56 "ProcessDiagnosticsQueryFailedError",61 "ProcessDiagnosticsQueryFailedError",
57 {62 {
58 command: Schema.String,63 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),
60 cause: Schema.optional(Schema.Defect()),71 cause: Schema.optional(Schema.Defect()),
61 },72 },
62) {73) {
63 override get message(): string {74 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}'.`;
65 }77 }
66}78}
6779
...@@ -76,7 +88,10 @@ class ProcessDiagnosticsServerProcessSignalError extends Schema.TaggedErrorClass...@@ -76,7 +88,10 @@ class ProcessDiagnosticsServerProcessSignalError extends Schema.TaggedErrorClass
7688
77class ProcessDiagnosticsNotDescendantError extends Schema.TaggedErrorClass<ProcessDiagnosticsNotDescendantError>()(89class ProcessDiagnosticsNotDescendantError extends Schema.TaggedErrorClass<ProcessDiagnosticsNotDescendantError>()(
78 "ProcessDiagnosticsNotDescendantError",90 "ProcessDiagnosticsNotDescendantError",
79 { pid: Schema.Number },91 {
92 pid: Schema.Number,
93 serverPid: Schema.Number,
94 },
80) {95) {
81 override get message(): string {96 override get message(): string {
82 return `Process ${this.pid} is not a live descendant of the T3 server.`;97 return `Process ${this.pid} is not a live descendant of the T3 server.`;
...@@ -312,20 +327,29 @@ function makeResult(input: {...@@ -312,20 +327,29 @@ function makeResult(input: {
312}327}
313328
314interface ProcessOutput {329interface ProcessOutput {
330 readonly cwd: string;
315 readonly exitCode: number;331 readonly exitCode: number;
316 readonly stdout: string;332 readonly stdout: string;
333 readonly stdoutBytes: number;
334 readonly stdoutTruncated: boolean;
317 readonly stderr: string;335 readonly stderr: string;
336 readonly stderrBytes: number;
337 readonly stderrTruncated: boolean;
318}338}
319339
320const runProcess = Effect.fn("runProcess")(340const runProcess = Effect.fn("runProcess")(function* (input: {
321 function* (input: { readonly command: string; readonly args: ReadonlyArray<string> }) {341 readonly command: string;
342 readonly args: ReadonlyArray<string>;
343}) {
344 const cwd = process.cwd();
345 return yield* Effect.gen(function* () {
322 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;346 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
323 // `ps` and `powershell.exe` are real executables; spawning through cmd.exe347 // `ps` and `powershell.exe` are real executables; spawning through cmd.exe
324 // shell mode would re-tokenize the PowerShell `-Command` payload (which348 // shell mode would re-tokenize the PowerShell `-Command` payload (which
325 // contains pipes) before PowerShell ever sees it.349 // contains pipes) before PowerShell ever sees it.
326 const child = yield* spawner.spawn(350 const child = yield* spawner.spawn(
327 ChildProcess.make(input.command, input.args, {351 ChildProcess.make(input.command, input.args, {
328 cwd: process.cwd(),352 cwd,
329 }),353 }),
330 );354 );
331 const [stdout, stderr, exitCode] = yield* Effect.all(355 const [stdout, stderr, exitCode] = yield* Effect.all(
...@@ -346,36 +370,44 @@ const runProcess = Effect.fn("runProcess")(...@@ -346,36 +370,44 @@ const runProcess = Effect.fn("runProcess")(
346 );370 );
347371
348 return {372 return {
373 cwd,
349 exitCode,374 exitCode,
350 stdout: stdout.text,375 stdout: stdout.text,
376 stdoutBytes: stdout.bytes,
377 stdoutTruncated: stdout.truncated,
351 stderr: stderr.text,378 stderr: stderr.text,
379 stderrBytes: stderr.bytes,
380 stderrTruncated: stderr.truncated,
352 } satisfies ProcessOutput;381 } satisfies ProcessOutput;
353 },382 }).pipe(
354 (effect, input) =>383 Effect.scoped,
355 effect.pipe(384 Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)),
356 Effect.scoped,385 Effect.flatMap((result) =>
357 Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)),386 Option.match(result, {
358 Effect.flatMap((result) =>387 onNone: () =>
359 Option.match(result, {388 Effect.fail(
360 onNone: () =>389 new ProcessDiagnosticsQueryTimeoutError({
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({
373 command: input.command,390 command: input.command,
374 cause,391 argCount: input.args.length,
392 cwd,
393 timeoutMillis: PROCESS_QUERY_TIMEOUT_MS,
375 }),394 }),
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 }),
377 ),408 ),
378);409 );
410});
379411
380function readPosixProcessRows(): Effect.Effect<412function readPosixProcessRows(): Effect.Effect<
381 ReadonlyArray<ProcessRow>,413 ReadonlyArray<ProcessRow>,
...@@ -391,7 +423,13 @@ function readPosixProcessRows(): Effect.Effect<...@@ -391,7 +423,13 @@ function readPosixProcessRows(): Effect.Effect<
391 ? Effect.fail(423 ? Effect.fail(
392 new ProcessDiagnosticsQueryFailedError({424 new ProcessDiagnosticsQueryFailedError({
393 command: "ps",425 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,
395 }),433 }),
396 )434 )
397 : Effect.succeed(parsePosixProcessRows(result.stdout)),435 : Effect.succeed(parsePosixProcessRows(result.stdout)),
...@@ -421,7 +459,13 @@ function readWindowsProcessRows(): Effect.Effect<...@@ -421,7 +459,13 @@ function readWindowsProcessRows(): Effect.Effect<
421 ? Effect.fail(459 ? Effect.fail(
422 new ProcessDiagnosticsQueryFailedError({460 new ProcessDiagnosticsQueryFailedError({
423 command: "powershell.exe",461 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,
425 }),469 }),
426 )470 )
427 : Effect.succeed(parseWindowsProcessRows(result.stdout)),471 : Effect.succeed(parseWindowsProcessRows(result.stdout)),
...@@ -464,6 +508,7 @@ function assertDescendantPid(...@@ -464,6 +508,7 @@ function assertDescendantPid(
464 : Effect.fail(508 : Effect.fail(
465 new ProcessDiagnosticsNotDescendantError({509 new ProcessDiagnosticsNotDescendantError({
466 pid,510 pid,
511 serverPid: process.pid,
467 }),512 }),
468 );513 );
469 }),514 }),