Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions apps/server/src/diagnostics/ProcessDiagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(
Expand Down
111 changes: 78 additions & 33 deletions apps/server/src/diagnostics/ProcessDiagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,23 +45,35 @@ export class ProcessDiagnostics extends Context.Service<

class ProcessDiagnosticsQueryTimeoutError extends Schema.TaggedErrorClass<ProcessDiagnosticsQueryTimeoutError>()(
"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}'.`;
}
}

class ProcessDiagnosticsQueryFailedError extends Schema.TaggedErrorClass<ProcessDiagnosticsQueryFailedError>()(
"ProcessDiagnosticsQueryFailedError",
{
command: Schema.String,
stderr: Schema.optional(Schema.String),
argCount: Schema.Number,
cwd: Schema.String,
exitCode: Schema.optional(Schema.Number),
stdoutBytes: Schema.optional(Schema.Number),
stderrBytes: Schema.optional(Schema.Number),
stdoutTruncated: Schema.optional(Schema.Boolean),
stderrTruncated: Schema.optional(Schema.Boolean),
cause: Schema.optional(Schema.Defect()),
},
) {
override get message(): string {
return this.stderr?.trim() || `Failed to query process diagnostics with '${this.command}'.`;
const exitCode = this.exitCode === undefined ? "" : ` with exit code ${this.exitCode}`;
return `Process diagnostics query '${this.command}' failed${exitCode} in '${this.cwd}'.`;
}
}

Expand All @@ -76,7 +88,10 @@ class ProcessDiagnosticsServerProcessSignalError extends Schema.TaggedErrorClass

class ProcessDiagnosticsNotDescendantError extends Schema.TaggedErrorClass<ProcessDiagnosticsNotDescendantError>()(
"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.`;
Expand Down Expand Up @@ -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<string> }) {
const runProcess = Effect.fn("runProcess")(function* (input: {
readonly command: string;
readonly args: ReadonlyArray<string>;
}) {
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(
Expand 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<ProcessRow>,
Expand All @@ -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)),
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -464,6 +508,7 @@ function assertDescendantPid(
: Effect.fail(
new ProcessDiagnosticsNotDescendantError({
pid,
serverPid: process.pid,
}),
);
}),
Expand Down
Loading