Skip to content
Draft
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
10 changes: 4 additions & 6 deletions apps/desktop/src/backend/tailscaleEndpointProvider.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import { HttpClient } from "effect/unstable/http";
import { ChildProcessSpawner } from "effect/unstable/process";

Expand All @@ -15,10 +16,7 @@ const unusedTailscaleExternalServicesLayer = Layer.mergeAll(
HttpClient.HttpClient,
HttpClient.make(() => Effect.die("unexpected Tailscale HTTPS probe")),
),
Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() => Effect.die("unexpected tailscale status process")),
),
Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {}),
);

describe("tailscale endpoint provider", () => {
Expand All @@ -34,8 +32,8 @@ describe("tailscale endpoint provider", () => {
const dnsName = yield* parseTailscaleMagicDnsName(
`{"Self":{"DNSName":"desktop.tail.ts.net."}}`,
);
assert.equal(dnsName, "desktop.tail.ts.net");
assert.equal(yield* parseTailscaleMagicDnsName("{}"), null);
assert.deepEqual(dnsName, Option.some("desktop.tail.ts.net"));
assert.deepEqual(yield* parseTailscaleMagicDnsName("{}"), Option.none());
const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json"));
assert.isTrue(malformed._tag === "Failure");
}),
Expand Down
12 changes: 6 additions & 6 deletions apps/desktop/src/backend/tailscaleEndpointProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,17 @@ function resolveTailscaleIpAdvertisedEndpoints(input: {
const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn(
"resolveTailscaleMagicDnsAdvertisedEndpoint",
)(function* (input: {
readonly dnsName: string | null;
readonly dnsName: Option.Option<string>;
readonly serveEnabled: boolean;
readonly servePort?: number;
readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>;
}): Effect.fn.Return<Option.Option<AdvertisedEndpoint>, never, HttpClient.HttpClient> {
if (!input.dnsName) {
if (Option.isNone(input.dnsName)) {
return Option.none();
}

const httpBaseUrl = buildTailscaleHttpsBaseUrl({
magicDnsName: input.dnsName,
magicDnsName: input.dnsName.value,
...(input.servePort === undefined ? {} : { servePort: input.servePort }),
});
const probe =
Expand Down Expand Up @@ -116,13 +116,13 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd
input.statusJson === undefined
? yield* readTailscaleStatus.pipe(
Effect.map((status) => status.magicDnsName),
Effect.catch(() => Effect.succeed(null)),
Effect.catch(() => Effect.succeed(Option.none())),
)
: input.statusJson
? yield* parseTailscaleMagicDnsName(input.statusJson).pipe(
Effect.catch(() => Effect.succeed(null)),
Effect.catch(() => Effect.succeed(Option.none())),
)
: null;
: Option.none();
const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({
dnsName,
serveEnabled: input.serveEnabled === true,
Expand Down
114 changes: 89 additions & 25 deletions apps/server/src/diagnostics/ProcessDiagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "@effect/vitest";
import { assert, describe, it } from "@effect/vitest";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -41,7 +41,7 @@ describe("ProcessDiagnostics", () => {
].join("\n"),
);

expect(rows).toEqual([
assert.deepEqual(rows, [
{
pid: 10,
ppid: 1,
Expand All @@ -66,6 +66,61 @@ describe("ProcessDiagnostics", () => {
}),
);

it.effect("parses Windows process JSON through schema decoding", () =>
Effect.sync(() => {
const rows = ProcessDiagnostics.parseWindowsProcessRows(
JSON.stringify([
{
ProcessId: 10,
ParentProcessId: 1,
Name: "node.exe",
CommandLine: "node server.js",
Status: "Running",
WorkingSetSize: 1024,
PercentProcessorTime: 1.5,
},
{
ProcessId: 11,
ParentProcessId: 10,
Name: "child.exe",
CommandLine: "",
WorkingSetSize: 2048.2,
},
{
ProcessId: "bad",
ParentProcessId: 10,
Name: "ignored.exe",
},
]),
);

assert.deepEqual(rows, [
{
pid: 10,
ppid: 1,
pgid: null,
status: "Running",
cpuPercent: 1.5,
rssBytes: 1024,
elapsed: "",
command: "node server.js",
},
{
pid: 11,
ppid: 10,
pgid: null,
status: "Live",
cpuPercent: 0,
rssBytes: 2048,
elapsed: "",
command: "child.exe",
},
]);

assert.deepEqual(ProcessDiagnostics.parseWindowsProcessRows("not-json"), []);
}),
);

it.effect("aggregates only descendants of the server process", () =>
Effect.sync(() => {
const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({
Expand Down Expand Up @@ -125,15 +180,21 @@ describe("ProcessDiagnostics", () => {
],
});

expect(diagnostics.serverPid).toBe(100);
expect(DateTime.formatIso(diagnostics.readAt)).toBe("2026-05-05T10:00:00.000Z");
expect(diagnostics.processCount).toBe(2);
expect(diagnostics.totalRssBytes).toBe(6_000);
expect(diagnostics.totalCpuPercent).toBe(4.75);
expect(diagnostics.processes.map((process) => process.pid)).toEqual([101, 102]);
expect(diagnostics.processes.map((process) => process.depth)).toEqual([0, 1]);
expect(Option.getOrNull(diagnostics.processes[0]!.pgid)).toBe(100);
expect(diagnostics.processes[0]?.childPids).toEqual([102]);
assert.equal(diagnostics.serverPid, 100);
assert.equal(DateTime.formatIso(diagnostics.readAt), "2026-05-05T10:00:00.000Z");
assert.equal(diagnostics.processCount, 2);
assert.equal(diagnostics.totalRssBytes, 6_000);
assert.equal(diagnostics.totalCpuPercent, 4.75);
assert.deepEqual(
diagnostics.processes.map((process) => process.pid),
[101, 102],
);
assert.deepEqual(
diagnostics.processes.map((process) => process.depth),
[0, 1],
);
assert.equal(Option.getOrNull(diagnostics.processes[0]!.pgid), 100);
assert.deepEqual(diagnostics.processes[0]?.childPids, [102]);
}),
);

Expand Down Expand Up @@ -176,17 +237,19 @@ describe("ProcessDiagnostics", () => {
],
});

expect(diagnostics.processes.map((process) => process.pid)).toEqual([101, 102, 103]);
assert.deepEqual(
diagnostics.processes.map((process) => process.pid),
[101, 102, 103],
);
}),
);

it.effect("queries processes through the ChildProcessSpawner service", () =>
Effect.gen(function* () {
const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> =
[];
const spawnerLayer = Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) => {
const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {
spawn: (command) => {
const childProcess = command as unknown as {
readonly command: string;
readonly args: ReadonlyArray<string>;
Expand All @@ -200,17 +263,20 @@ describe("ProcessDiagnostics", () => {
].join("\n"),
}),
);
}),
);
},
});
const layer = ProcessDiagnostics.layer.pipe(Layer.provide(spawnerLayer));

const diagnostics = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(
Effect.flatMap((pd) => pd.read),
Effect.provide(layer),
);

expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]);
expect(commands).toEqual([
assert.deepEqual(
diagnostics.processes.map((process) => process.pid),
[4242],
);
assert.deepEqual(commands, [
{
command: "ps",
args: ["-axo", "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="],
Expand All @@ -221,9 +287,8 @@ describe("ProcessDiagnostics", () => {

it.effect("does not allow signaling the diagnostics query process", () =>
Effect.gen(function* () {
const spawnerLayer = Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {
spawn: () =>
Effect.succeed(
mockHandle({
stdout: [
Expand All @@ -232,16 +297,15 @@ describe("ProcessDiagnostics", () => {
].join("\n"),
}),
),
),
);
});
const layer = ProcessDiagnostics.layer.pipe(Layer.provide(spawnerLayer));

const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(
Effect.flatMap((pd) => pd.signal({ pid: 4242, signal: "SIGINT" })),
Effect.provide(layer),
);

expect(result).toEqual({
assert.deepEqual(result, {
pid: 4242,
signal: "SIGINT",
signaled: false,
Expand Down
42 changes: 28 additions & 14 deletions apps/server/src/diagnostics/ProcessDiagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export interface ProcessRow {
readonly command: string;
}

const PROCESS_QUERY_TIMEOUT_MS = 1_000;
const PROCESS_QUERY_TIMEOUT = Duration.seconds(1);
const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=";
const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;

Expand Down Expand Up @@ -74,6 +74,22 @@ function parseNumber(value: string): number | null {
return Number.isFinite(parsed) ? parsed : null;
}

const WindowsProcessRecord = Schema.Struct({
CommandLine: Schema.optional(Schema.Unknown),
Name: Schema.optional(Schema.Unknown),
ParentProcessId: Schema.optional(Schema.Unknown),
PercentProcessorTime: Schema.optional(Schema.Unknown),
ProcessId: Schema.optional(Schema.Unknown),
Status: Schema.optional(Schema.Unknown),
WorkingSetSize: Schema.optional(Schema.Unknown),
});
type WindowsProcessRecord = typeof WindowsProcessRecord.Type;

const WindowsProcessOutput = Schema.fromJsonString(
Schema.Union([WindowsProcessRecord, Schema.Array(WindowsProcessRecord)]),
);
const decodeWindowsProcessOutput = Schema.decodeUnknownOption(WindowsProcessOutput);

export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> {
const rows: ProcessRow[] = [];
const rowPattern =
Expand Down Expand Up @@ -139,9 +155,7 @@ export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow>
return rows;
}

function normalizeWindowsProcessRow(value: unknown): ProcessRow | null {
if (typeof value !== "object" || value === null) return null;
const record = value as Record<string, unknown>;
function normalizeWindowsProcessRow(record: WindowsProcessRecord): ProcessRow | null {
const pid = typeof record.ProcessId === "number" ? record.ProcessId : null;
const ppid = typeof record.ParentProcessId === "number" ? record.ParentProcessId : null;
const commandLine =
Expand Down Expand Up @@ -172,18 +186,18 @@ function normalizeWindowsProcessRow(value: unknown): ProcessRow | null {
};
}

function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {
export function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {
if (output.trim().length === 0) return [];
try {
const parsed = JSON.parse(output) as unknown;
const records = Array.isArray(parsed) ? parsed : [parsed];
return records.flatMap((record) => {
const row = normalizeWindowsProcessRow(record);
return row ? [row] : [];
});
} catch {
const parsed = decodeWindowsProcessOutput(output);
if (Option.isNone(parsed)) {
return [];
}

const records = Array.isArray(parsed.value) ? parsed.value : [parsed.value];
return records.flatMap((record) => {
const row = normalizeWindowsProcessRow(record);
return row ? [row] : [];
});
}

export function buildDescendantEntries(
Expand Down Expand Up @@ -309,7 +323,7 @@ const runProcess = Effect.fn("runProcess")(
(effect, input) =>
effect.pipe(
Effect.scoped,
Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)),
Effect.timeoutOption(PROCESS_QUERY_TIMEOUT),
Effect.flatMap((result) =>
Option.match(result, {
onNone: () => Effect.fail(toProcessDiagnosticsError(`${input.errorMessage} timed out.`)),
Expand Down
18 changes: 9 additions & 9 deletions packages/tailscale/src/tailscale.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import { ChildProcessSpawner } from "effect/unstable/process";
Expand Down Expand Up @@ -41,16 +42,15 @@ function mockSpawnerLayer(
args: ReadonlyArray<string>,
) => { stdout?: string; stderr?: string; code?: number },
) {
return Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) => {
return Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {
spawn: (command) => {
const childProcess = command as unknown as {
readonly command: string;
readonly args: ReadonlyArray<string>;
};
return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args)));
}),
);
},
});
}

describe("tailscale", () => {
Expand All @@ -66,16 +66,16 @@ describe("tailscale", () => {
it.effect("parses MagicDNS names from tailscale status", () =>
Effect.gen(function* () {
const dnsName = yield* parseTailscaleMagicDnsName(tailscaleStatusJson);
assert.equal(dnsName, "desktop.tail.ts.net");
assert.equal(yield* parseTailscaleMagicDnsName("{}"), null);
assert.deepEqual(dnsName, Option.some("desktop.tail.ts.net"));
assert.deepEqual(yield* parseTailscaleMagicDnsName("{}"), Option.none());
}),
);

it.effect("parses status facts", () =>
Effect.gen(function* () {
const status = yield* parseTailscaleStatus(tailscaleStatusJson);
assert.deepEqual(status, {
magicDnsName: "desktop.tail.ts.net",
magicDnsName: Option.some("desktop.tail.ts.net"),
tailnetIpv4Addresses: ["100.100.100.100"],
});
}),
Expand Down Expand Up @@ -106,7 +106,7 @@ describe("tailscale", () => {
return Effect.gen(function* () {
const status = yield* readTailscaleStatus.pipe(Effect.provide(layer));
assert.deepEqual(status, {
magicDnsName: "desktop.tail.ts.net",
magicDnsName: Option.some("desktop.tail.ts.net"),
tailnetIpv4Addresses: ["100.90.1.2"],
});
});
Expand Down
Loading
Loading