diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts index 18a54326de1..7d16a11c829 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts @@ -6,6 +6,7 @@ import * as Option from "effect/Option"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as ProcessDiagnostics from "./ProcessDiagnostics.ts"; @@ -219,6 +220,44 @@ describe("ProcessDiagnostics", () => { }), ); + it.effect("keeps bounded command diagnostics when the process query exits unsuccessfully", () => + Effect.gen(function* () { + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + mockHandle({ + code: 17, + stdout: "partial process output", + stderr: "process access denied", + }), + ), + ), + ); + + const error = yield* ProcessDiagnostics.readProcessRows.pipe( + Effect.provide(spawnerLayer), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.flip, + ); + + expect(error).toMatchObject({ + _tag: "ProcessDiagnosticsQueryFailedError", + command: "ps", + argCount: 2, + cwd: process.cwd(), + exitCode: 17, + stdoutBytes: 22, + stderrBytes: 21, + stdoutTruncated: false, + stderrTruncated: false, + }); + expect(error.message).toBe( + `Process diagnostics query 'ps' failed with exit code 17 in '${process.cwd()}'.`, + ); + }), + ); + it.effect("does not allow signaling the diagnostics query process", () => Effect.gen(function* () { const spawnerLayer = Layer.succeed( diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index 40e7f347be1..b39d560a228 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -45,10 +45,15 @@ export class ProcessDiagnostics extends Context.Service< class ProcessDiagnosticsQueryTimeoutError extends Schema.TaggedErrorClass()( "ProcessDiagnosticsQueryTimeoutError", - { command: Schema.String }, + { + command: Schema.String, + argCount: Schema.Number, + cwd: Schema.String, + timeoutMillis: Schema.Number, + }, ) { override get message(): string { - return `Process diagnostics query '${this.command}' timed out.`; + return `Process diagnostics query '${this.command}' timed out after ${this.timeoutMillis}ms in '${this.cwd}'.`; } } @@ -56,12 +61,19 @@ class ProcessDiagnosticsQueryFailedError extends Schema.TaggedErrorClass()( "ProcessDiagnosticsNotDescendantError", - { pid: Schema.Number }, + { + pid: Schema.Number, + serverPid: Schema.Number, + }, ) { override get message(): string { return `Process ${this.pid} is not a live descendant of the T3 server.`; @@ -312,20 +327,29 @@ function makeResult(input: { } interface ProcessOutput { + readonly cwd: string; readonly exitCode: number; readonly stdout: string; + readonly stdoutBytes: number; + readonly stdoutTruncated: boolean; readonly stderr: string; + readonly stderrBytes: number; + readonly stderrTruncated: boolean; } -const runProcess = Effect.fn("runProcess")( - function* (input: { readonly command: string; readonly args: ReadonlyArray }) { +const runProcess = Effect.fn("runProcess")(function* (input: { + readonly command: string; + readonly args: ReadonlyArray; +}) { + const cwd = process.cwd(); + return yield* Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; // `ps` and `powershell.exe` are real executables; spawning through cmd.exe // shell mode would re-tokenize the PowerShell `-Command` payload (which // contains pipes) before PowerShell ever sees it. const child = yield* spawner.spawn( ChildProcess.make(input.command, input.args, { - cwd: process.cwd(), + cwd, }), ); const [stdout, stderr, exitCode] = yield* Effect.all( @@ -346,36 +370,44 @@ const runProcess = Effect.fn("runProcess")( ); return { + cwd, exitCode, stdout: stdout.text, + stdoutBytes: stdout.bytes, + stdoutTruncated: stdout.truncated, stderr: stderr.text, + stderrBytes: stderr.bytes, + stderrTruncated: stderr.truncated, } satisfies ProcessOutput; - }, - (effect, input) => - effect.pipe( - Effect.scoped, - Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail( - new ProcessDiagnosticsQueryTimeoutError({ - command: input.command, - }), - ), - onSome: Effect.succeed, - }), - ), - Effect.mapError((cause) => - isProcessDiagnosticsError(cause) - ? cause - : new ProcessDiagnosticsQueryFailedError({ + }).pipe( + Effect.scoped, + Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail( + new ProcessDiagnosticsQueryTimeoutError({ command: input.command, - cause, + argCount: input.args.length, + cwd, + timeoutMillis: PROCESS_QUERY_TIMEOUT_MS, }), - ), + ), + onSome: Effect.succeed, + }), + ), + Effect.mapError((cause) => + isProcessDiagnosticsError(cause) + ? cause + : new ProcessDiagnosticsQueryFailedError({ + command: input.command, + argCount: input.args.length, + cwd, + cause, + }), ), -); + ); +}); function readPosixProcessRows(): Effect.Effect< ReadonlyArray, @@ -391,7 +423,13 @@ function readPosixProcessRows(): Effect.Effect< ? Effect.fail( new ProcessDiagnosticsQueryFailedError({ command: "ps", - stderr: result.stderr.trim() || "ps failed.", + argCount: 2, + cwd: result.cwd, + exitCode: result.exitCode, + stdoutBytes: result.stdoutBytes, + stderrBytes: result.stderrBytes, + stdoutTruncated: result.stdoutTruncated, + stderrTruncated: result.stderrTruncated, }), ) : Effect.succeed(parsePosixProcessRows(result.stdout)), @@ -421,7 +459,13 @@ function readWindowsProcessRows(): Effect.Effect< ? Effect.fail( new ProcessDiagnosticsQueryFailedError({ command: "powershell.exe", - stderr: result.stderr.trim() || "PowerShell process query failed.", + argCount: 4, + cwd: result.cwd, + exitCode: result.exitCode, + stdoutBytes: result.stdoutBytes, + stderrBytes: result.stderrBytes, + stdoutTruncated: result.stdoutTruncated, + stderrTruncated: result.stderrTruncated, }), ) : Effect.succeed(parseWindowsProcessRows(result.stdout)), @@ -464,6 +508,7 @@ function assertDescendantPid( : Effect.fail( new ProcessDiagnosticsNotDescendantError({ pid, + serverPid: process.pid, }), ); }),