diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index f1d47ad9d21..853bb1f81c2 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -1,7 +1,9 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -16,6 +18,10 @@ import { parseTailscaleStatus, readTailscaleStatus, TAILSCALE_STATUS_TIMEOUT, + TailscaleCommandExitError, + TailscaleCommandSpawnError, + TailscaleCommandTimeoutError, + TailscaleStatusParseError, } from "./tailscale.ts"; const encoder = new TextEncoder(); @@ -100,6 +106,17 @@ describe("tailscale", () => { }), ); + it.effect("preserves status decoding failures without exposing cause text", () => + Effect.gen(function* () { + const error = yield* parseTailscaleStatus("{not-json").pipe(Effect.flip); + + assert.instanceOf(error, TailscaleStatusParseError); + assert.equal(error.message, "Failed to decode tailscale status JSON."); + assert.isDefined(error.cause); + assert.notInclude(error.message, String(error.cause)); + }), + ); + it.effect("builds clean HTTPS base URLs", () => Effect.sync(() => { assert.equal( @@ -131,6 +148,55 @@ describe("tailscale", () => { }); }); + it.effect("preserves tailscale spawn failures as causes", () => { + const systemCause = new Error("private executable lookup detail"); + const cause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + cause: systemCause, + }); + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(cause)), + ); + + return Effect.gen(function* () { + const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); + + assert.instanceOf(error, TailscaleCommandSpawnError); + assert.equal(error.executable, "tailscale"); + assert.equal(error.subcommand, "status"); + assert.equal(error.argumentCount, 2); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "Failed to spawn tailscale status."); + assert.notInclude(error.message, systemCause.message); + }); + }); + + it.effect("keeps nonzero exit diagnostics structured", () => { + const layer = mockSpawnerLayer(() => ({ + code: 7, + stderr: "not logged in tskey-auth-secret-token-value", + })); + + return Effect.gen(function* () { + const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); + + assert.instanceOf(error, TailscaleCommandExitError); + assert.equal(error.executable, "tailscale"); + assert.equal(error.subcommand, "status"); + assert.equal(error.argumentCount, 2); + assert.equal(error.exitCode, 7); + assert.equal(error.stdoutLength, 0); + assert.equal(error.stderrLength, 43); + assert.notProperty(error, "command"); + assert.notProperty(error, "stderr"); + assert.notInclude(error.message, "tskey-auth-secret-token-value"); + assert.equal(error.message, "tailscale status exited with code 7."); + }); + }); + it.effect("times out tailscale status through TestClock", () => { const layer = Layer.merge( TestClock.layer(), @@ -146,11 +212,13 @@ describe("tailscale", () => { yield* TestClock.adjust(TAILSCALE_STATUS_TIMEOUT); const error = yield* Fiber.join(fiber); - if (error._tag !== "TailscaleCommandError") { - assert.fail(`Expected TailscaleCommandError, received ${error._tag}.`); - } - assert.equal(error.message, "Tailscale status timed out."); - assert.equal(error.exitCode, null); + assert.instanceOf(error, TailscaleCommandTimeoutError); + assert.equal(error.executable, "tailscale"); + assert.equal(error.subcommand, "status"); + assert.equal(error.argumentCount, 2); + assert.equal(error.timeoutMs, 1_500); + assert.isTrue(Cause.isTimeoutError(error.cause)); + assert.equal(error.message, "tailscale status timed out after 1500ms."); }).pipe(Effect.provide(layer)); }); @@ -164,6 +232,30 @@ describe("tailscale", () => { return ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(Effect.provide(layer)); }); + it.effect("retains tailscale serve exit diagnostics", () => { + const layer = mockSpawnerLayer(() => ({ + code: 1, + stderr: "serve permission denied tskey-auth-secret-token-value", + })); + + return Effect.gen(function* () { + const error = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( + Effect.flip, + Effect.provide(layer), + ); + + assert.instanceOf(error, TailscaleCommandExitError); + assert.equal(error.executable, "tailscale"); + assert.equal(error.subcommand, "serve"); + assert.equal(error.argumentCount, 4); + assert.equal(error.exitCode, 1); + assert.equal(error.stderrLength, 53); + assert.notProperty(error, "command"); + assert.notProperty(error, "stderr"); + assert.notInclude(error.message, "tskey-auth-secret-token-value"); + }); + }); + it.effect("disables tailscale serve through the process spawner service", () => { const commands: { readonly command: string; diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index f468dec7294..27761490af0 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -1,5 +1,4 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -15,23 +14,82 @@ export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); // tailscale is a real executable everywhere (`tailscale.exe` on Windows), so // it is always spawned directly rather than through cmd.exe shell mode. -const tailscaleCommandForPlatform = (platform: NodeJS.Platform): string => +const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | "tailscale.exe" => platform === "win32" ? "tailscale.exe" : "tailscale"; -export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{ - readonly command: readonly string[]; - readonly message: string; - readonly exitCode: number | null; - readonly stderr: string; -}> {} +const TailscaleCommandContext = { + executable: Schema.Literals(["tailscale", "tailscale.exe"]), + subcommand: Schema.Literals(["status", "serve"]), + argumentCount: Schema.Number, +}; + +export class TailscaleCommandSpawnError extends Schema.TaggedErrorClass()( + "TailscaleCommandSpawnError", + { + ...TailscaleCommandContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to spawn tailscale ${this.subcommand}.`; + } +} + +export class TailscaleCommandOutputError extends Schema.TaggedErrorClass()( + "TailscaleCommandOutputError", + { + ...TailscaleCommandContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read output from tailscale ${this.subcommand}.`; + } +} + +export class TailscaleCommandExitError extends Schema.TaggedErrorClass()( + "TailscaleCommandExitError", + { + ...TailscaleCommandContext, + exitCode: Schema.Number, + stdoutLength: Schema.optional(Schema.Number), + stderrLength: Schema.Number, + }, +) { + override get message(): string { + return `tailscale ${this.subcommand} exited with code ${this.exitCode}.`; + } +} -export class TailscaleStatusParseError extends Data.TaggedError("TailscaleStatusParseError")<{ - readonly cause: unknown; -}> {} +export class TailscaleCommandTimeoutError extends Schema.TaggedErrorClass()( + "TailscaleCommandTimeoutError", + { + ...TailscaleCommandContext, + timeoutMs: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `tailscale ${this.subcommand} timed out after ${this.timeoutMs}ms.`; + } +} -export class TailscaleUnavailableError extends Data.TaggedError("TailscaleUnavailableError")<{ - readonly reason: string; -}> {} +export const TailscaleCommandError = Schema.Union([ + TailscaleCommandSpawnError, + TailscaleCommandOutputError, + TailscaleCommandExitError, + TailscaleCommandTimeoutError, +]); +export type TailscaleCommandError = typeof TailscaleCommandError.Type; + +export class TailscaleStatusParseError extends Schema.TaggedErrorClass()( + "TailscaleStatusParseError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to decode tailscale status JSON."; + } +} const TailscaleStatusSelf = Schema.Struct({ DNSName: Schema.optional(Schema.Unknown), @@ -61,19 +119,6 @@ const collectStdout = (stream: Stream.Stream): Effect.Effect - new TailscaleCommandError({ - command: ["tailscale", ...args], - message, - exitCode, - stderr, - }); - const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson)); function normalizeMagicDnsName(status: TailscaleStatusJson): string | null { @@ -139,55 +184,52 @@ export const readTailscaleStatus = Effect.gen(function* () { const args = ["status", "--json"]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcessPlatform; - const child = yield* spawner - .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args)) - .pipe( - Effect.mapError((cause) => - tailscaleCommandError( - args, - cause instanceof Error ? cause.message : "Failed to spawn tailscale status.", - null, - ), - ), - ); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStdout(child.stdout), - collectStderr(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ).pipe( - Effect.mapError((cause) => - tailscaleCommandError( - args, - cause instanceof Error ? cause.message : "Failed to run tailscale status.", - null, - ), - ), - ); - if (exitCode !== 0) { - return yield* tailscaleCommandError( - args, - `Tailscale status exited with code ${exitCode}.`, - exitCode, - stderr, + const executable = tailscaleCommandForPlatform(hostPlatform); + const commandContext = { + executable, + subcommand: "status" as const, + argumentCount: args.length, + }; + return yield* Effect.gen(function* () { + const child = yield* spawner + .spawn(ChildProcess.make(executable, args)) + .pipe( + Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStdout(child.stdout), + collectStderr(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), ); - } - return yield* parseTailscaleStatus(stdout); -}).pipe( - Effect.scoped, - Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => + if (exitCode !== 0) { + return yield* new TailscaleCommandExitError({ + ...commandContext, + exitCode, + stdoutLength: stdout.length, + stderrLength: stderr.length, + }); + } + return yield* parseTailscaleStatus(stdout); + }).pipe( + Effect.scoped, + Effect.timeout(TAILSCALE_STATUS_TIMEOUT), + Effect.catchTags({ + TimeoutError: (cause) => Effect.fail( - tailscaleCommandError(["status", "--json"], "Tailscale status timed out.", null), + new TailscaleCommandTimeoutError({ + ...commandContext, + timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT), + cause, + }), ), - onSome: Effect.succeed, }), - ), -); + ); +}); export function buildTailscaleHttpsBaseUrl(input: { readonly magicDnsName: string; @@ -204,53 +246,52 @@ export function buildTailscaleHttpsBaseUrl(input: { const runTailscaleCommand = ( args: readonly string[], - input: { - readonly spawnMessage: string; - readonly runMessage: string; - readonly exitMessage: (exitCode: number) => string; - readonly timeoutMessage: string; - readonly timeout: Duration.Input; - }, + timeoutInput: Duration.Input, ): Effect.Effect => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcessPlatform; - const child = yield* spawner - .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args)) - .pipe( - Effect.mapError((cause) => - tailscaleCommandError( - args, - cause instanceof Error ? cause.message : input.spawnMessage, - null, - ), - ), + const executable = tailscaleCommandForPlatform(hostPlatform); + const commandContext = { + executable, + subcommand: "serve" as const, + argumentCount: args.length, + }; + const timeout = Duration.fromInputUnsafe(timeoutInput); + return yield* Effect.gen(function* () { + const child = yield* spawner + .spawn(ChildProcess.make(executable, args)) + .pipe( + Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), + ); + const [stderr, exitCode] = yield* Effect.all( + [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), ); - const [stderr, exitCode] = yield* Effect.all( - [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], - { concurrency: "unbounded" }, - ).pipe( - Effect.mapError((cause) => - tailscaleCommandError( - args, - cause instanceof Error ? cause.message : input.runMessage, - null, - ), - ), - ); - if (exitCode !== 0) { - return yield* tailscaleCommandError(args, input.exitMessage(exitCode), exitCode, stderr); - } - }).pipe( - Effect.scoped, - Effect.timeoutOption(input.timeout), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)), - onSome: Effect.succeed, + if (exitCode !== 0) { + return yield* new TailscaleCommandExitError({ + ...commandContext, + exitCode, + stderrLength: stderr.length, + }); + } + }).pipe( + Effect.scoped, + Effect.timeout(timeout), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail( + new TailscaleCommandTimeoutError({ + ...commandContext, + timeoutMs: Duration.toMillis(timeout), + cause, + }), + ), }), - ), - ); + ); + }); export const ensureTailscaleServe = (input: { readonly localPort: number; @@ -260,13 +301,7 @@ export const ensureTailscaleServe = (input: { const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; const localHost = input.localHost ?? "127.0.0.1"; const args = ["serve", "--bg", `--https=${servePort}`, `http://${localHost}:${input.localPort}`]; - return runTailscaleCommand(args, { - spawnMessage: "Failed to spawn tailscale serve.", - runMessage: "Failed to run tailscale serve.", - exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`, - timeoutMessage: "Tailscale serve timed out.", - timeout: TAILSCALE_SERVE_TIMEOUT, - }); + return runTailscaleCommand(args, TAILSCALE_SERVE_TIMEOUT); }; export const disableTailscaleServe = ( @@ -276,13 +311,10 @@ export const disableTailscaleServe = ( ): Effect.Effect => Effect.gen(function* () { const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; - return yield* runTailscaleCommand(["serve", `--https=${servePort}`, "off"], { - spawnMessage: "Failed to spawn tailscale serve off.", - runMessage: "Failed to run tailscale serve off.", - exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`, - timeoutMessage: "Tailscale serve off timed out.", - timeout: TAILSCALE_SERVE_TIMEOUT, - }); + return yield* runTailscaleCommand( + ["serve", `--https=${servePort}`, "off"], + TAILSCALE_SERVE_TIMEOUT, + ); }); export const probeTailscaleHttpsEndpoint = (input: {