authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:56:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:56:52-07:00
logd2e38c53735718cee17c7aeb58f6c71be24bf435
tree7576d2f81be4b1a09885695a89d4de3e7d61274e
parent90bd5ee0c13782e56ccbeded52b0cc6c50deafad
signature Signed by PGP key B5690EEEBB952194

[codex] Structure Tailscale command failures (#3257)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 256 insertions(+), 132 deletions(-)

packages/tailscale/src/tailscale.test.ts+97-5
...@@ -1,7 +1,9 @@...@@ -1,7 +1,9 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as Cause from "effect/Cause";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Fiber from "effect/Fiber";4import * as Fiber from "effect/Fiber";
4import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as PlatformError from "effect/PlatformError";
5import * as Sink from "effect/Sink";7import * as Sink from "effect/Sink";
6import * as Stream from "effect/Stream";8import * as Stream from "effect/Stream";
7import * as TestClock from "effect/testing/TestClock";9import * as TestClock from "effect/testing/TestClock";
...@@ -16,6 +18,10 @@ import {...@@ -16,6 +18,10 @@ import {
16 parseTailscaleStatus,18 parseTailscaleStatus,
17 readTailscaleStatus,19 readTailscaleStatus,
18 TAILSCALE_STATUS_TIMEOUT,20 TAILSCALE_STATUS_TIMEOUT,
21 TailscaleCommandExitError,
22 TailscaleCommandSpawnError,
23 TailscaleCommandTimeoutError,
24 TailscaleStatusParseError,
19} from "./tailscale.ts";25} from "./tailscale.ts";
2026
21const encoder = new TextEncoder();27const encoder = new TextEncoder();
...@@ -100,6 +106,17 @@ describe("tailscale", () => {...@@ -100,6 +106,17 @@ describe("tailscale", () => {
100 }),106 }),
101 );107 );
102108
109 it.effect("preserves status decoding failures without exposing cause text", () =>
110 Effect.gen(function* () {
111 const error = yield* parseTailscaleStatus("{not-json").pipe(Effect.flip);
112
113 assert.instanceOf(error, TailscaleStatusParseError);
114 assert.equal(error.message, "Failed to decode tailscale status JSON.");
115 assert.isDefined(error.cause);
116 assert.notInclude(error.message, String(error.cause));
117 }),
118 );
119
103 it.effect("builds clean HTTPS base URLs", () =>120 it.effect("builds clean HTTPS base URLs", () =>
104 Effect.sync(() => {121 Effect.sync(() => {
105 assert.equal(122 assert.equal(
...@@ -131,6 +148,55 @@ describe("tailscale", () => {...@@ -131,6 +148,55 @@ describe("tailscale", () => {
131 });148 });
132 });149 });
133150
151 it.effect("preserves tailscale spawn failures as causes", () => {
152 const systemCause = new Error("private executable lookup detail");
153 const cause = PlatformError.systemError({
154 _tag: "NotFound",
155 module: "ChildProcess",
156 method: "spawn",
157 cause: systemCause,
158 });
159 const layer = Layer.succeed(
160 ChildProcessSpawner.ChildProcessSpawner,
161 ChildProcessSpawner.make(() => Effect.fail(cause)),
162 );
163
164 return Effect.gen(function* () {
165 const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer));
166
167 assert.instanceOf(error, TailscaleCommandSpawnError);
168 assert.equal(error.executable, "tailscale");
169 assert.equal(error.subcommand, "status");
170 assert.equal(error.argumentCount, 2);
171 assert.strictEqual(error.cause, cause);
172 assert.equal(error.message, "Failed to spawn tailscale status.");
173 assert.notInclude(error.message, systemCause.message);
174 });
175 });
176
177 it.effect("keeps nonzero exit diagnostics structured", () => {
178 const layer = mockSpawnerLayer(() => ({
179 code: 7,
180 stderr: "not logged in tskey-auth-secret-token-value",
181 }));
182
183 return Effect.gen(function* () {
184 const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer));
185
186 assert.instanceOf(error, TailscaleCommandExitError);
187 assert.equal(error.executable, "tailscale");
188 assert.equal(error.subcommand, "status");
189 assert.equal(error.argumentCount, 2);
190 assert.equal(error.exitCode, 7);
191 assert.equal(error.stdoutLength, 0);
192 assert.equal(error.stderrLength, 43);
193 assert.notProperty(error, "command");
194 assert.notProperty(error, "stderr");
195 assert.notInclude(error.message, "tskey-auth-secret-token-value");
196 assert.equal(error.message, "tailscale status exited with code 7.");
197 });
198 });
199
134 it.effect("times out tailscale status through TestClock", () => {200 it.effect("times out tailscale status through TestClock", () => {
135 const layer = Layer.merge(201 const layer = Layer.merge(
136 TestClock.layer(),202 TestClock.layer(),
...@@ -146,11 +212,13 @@ describe("tailscale", () => {...@@ -146,11 +212,13 @@ describe("tailscale", () => {
146 yield* TestClock.adjust(TAILSCALE_STATUS_TIMEOUT);212 yield* TestClock.adjust(TAILSCALE_STATUS_TIMEOUT);
147 const error = yield* Fiber.join(fiber);213 const error = yield* Fiber.join(fiber);
148214
149 if (error._tag !== "TailscaleCommandError") {215 assert.instanceOf(error, TailscaleCommandTimeoutError);
150 assert.fail(`Expected TailscaleCommandError, received ${error._tag}.`);216 assert.equal(error.executable, "tailscale");
151 }217 assert.equal(error.subcommand, "status");
152 assert.equal(error.message, "Tailscale status timed out.");218 assert.equal(error.argumentCount, 2);
153 assert.equal(error.exitCode, null);219 assert.equal(error.timeoutMs, 1_500);
220 assert.isTrue(Cause.isTimeoutError(error.cause));
221 assert.equal(error.message, "tailscale status timed out after 1500ms.");
154 }).pipe(Effect.provide(layer));222 }).pipe(Effect.provide(layer));
155 });223 });
156224
...@@ -164,6 +232,30 @@ describe("tailscale", () => {...@@ -164,6 +232,30 @@ describe("tailscale", () => {
164 return ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(Effect.provide(layer));232 return ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(Effect.provide(layer));
165 });233 });
166234
235 it.effect("retains tailscale serve exit diagnostics", () => {
236 const layer = mockSpawnerLayer(() => ({
237 code: 1,
238 stderr: "serve permission denied tskey-auth-secret-token-value",
239 }));
240
241 return Effect.gen(function* () {
242 const error = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(
243 Effect.flip,
244 Effect.provide(layer),
245 );
246
247 assert.instanceOf(error, TailscaleCommandExitError);
248 assert.equal(error.executable, "tailscale");
249 assert.equal(error.subcommand, "serve");
250 assert.equal(error.argumentCount, 4);
251 assert.equal(error.exitCode, 1);
252 assert.equal(error.stderrLength, 53);
253 assert.notProperty(error, "command");
254 assert.notProperty(error, "stderr");
255 assert.notInclude(error.message, "tskey-auth-secret-token-value");
256 });
257 });
258
167 it.effect("disables tailscale serve through the process spawner service", () => {259 it.effect("disables tailscale serve through the process spawner service", () => {
168 const commands: {260 const commands: {
169 readonly command: string;261 readonly command: string;
packages/tailscale/src/tailscale.ts+159-127
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import { HostProcessPlatform } from "@t3tools/shared/hostProcess";1import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
2import * as Data from "effect/Data";
3import * as Duration from "effect/Duration";2import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";4import * as Option from "effect/Option";
...@@ -15,23 +14,82 @@ export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500);...@@ -15,23 +14,82 @@ export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500);
1514
16// tailscale is a real executable everywhere (`tailscale.exe` on Windows), so15// tailscale is a real executable everywhere (`tailscale.exe` on Windows), so
17// it is always spawned directly rather than through cmd.exe shell mode.16// it is always spawned directly rather than through cmd.exe shell mode.
18const tailscaleCommandForPlatform = (platform: NodeJS.Platform): string =>17const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | "tailscale.exe" =>
19 platform === "win32" ? "tailscale.exe" : "tailscale";18 platform === "win32" ? "tailscale.exe" : "tailscale";
2019
21export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{20const TailscaleCommandContext = {
22 readonly command: readonly string[];21 executable: Schema.Literals(["tailscale", "tailscale.exe"]),
23 readonly message: string;22 subcommand: Schema.Literals(["status", "serve"]),
24 readonly exitCode: number | null;23 argumentCount: Schema.Number,
25 readonly stderr: string;24};
26}> {}25
26export class TailscaleCommandSpawnError extends Schema.TaggedErrorClass<TailscaleCommandSpawnError>()(
27 "TailscaleCommandSpawnError",
28 {
29 ...TailscaleCommandContext,
30 cause: Schema.Defect(),
31 },
32) {
33 override get message(): string {
34 return `Failed to spawn tailscale ${this.subcommand}.`;
35 }
36}
37
38export class TailscaleCommandOutputError extends Schema.TaggedErrorClass<TailscaleCommandOutputError>()(
39 "TailscaleCommandOutputError",
40 {
41 ...TailscaleCommandContext,
42 cause: Schema.Defect(),
43 },
44) {
45 override get message(): string {
46 return `Failed to read output from tailscale ${this.subcommand}.`;
47 }
48}
49
50export class TailscaleCommandExitError extends Schema.TaggedErrorClass<TailscaleCommandExitError>()(
51 "TailscaleCommandExitError",
52 {
53 ...TailscaleCommandContext,
54 exitCode: Schema.Number,
55 stdoutLength: Schema.optional(Schema.Number),
56 stderrLength: Schema.Number,
57 },
58) {
59 override get message(): string {
60 return `tailscale ${this.subcommand} exited with code ${this.exitCode}.`;
61 }
62}
2763
28export class TailscaleStatusParseError extends Data.TaggedError("TailscaleStatusParseError")<{64export class TailscaleCommandTimeoutError extends Schema.TaggedErrorClass<TailscaleCommandTimeoutError>()(
29 readonly cause: unknown;65 "TailscaleCommandTimeoutError",
30}> {}66 {
67 ...TailscaleCommandContext,
68 timeoutMs: Schema.Number,
69 cause: Schema.Defect(),
70 },
71) {
72 override get message(): string {
73 return `tailscale ${this.subcommand} timed out after ${this.timeoutMs}ms.`;
74 }
75}
3176
32export class TailscaleUnavailableError extends Data.TaggedError("TailscaleUnavailableError")<{77export const TailscaleCommandError = Schema.Union([
33 readonly reason: string;78 TailscaleCommandSpawnError,
34}> {}79 TailscaleCommandOutputError,
80 TailscaleCommandExitError,
81 TailscaleCommandTimeoutError,
82]);
83export type TailscaleCommandError = typeof TailscaleCommandError.Type;
84
85export class TailscaleStatusParseError extends Schema.TaggedErrorClass<TailscaleStatusParseError>()(
86 "TailscaleStatusParseError",
87 { cause: Schema.Defect() },
88) {
89 override get message(): string {
90 return "Failed to decode tailscale status JSON.";
91 }
92}
3593
36const TailscaleStatusSelf = Schema.Struct({94const TailscaleStatusSelf = Schema.Struct({
37 DNSName: Schema.optional(Schema.Unknown),95 DNSName: Schema.optional(Schema.Unknown),
...@@ -61,19 +119,6 @@ const collectStdout = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<s...@@ -61,19 +119,6 @@ const collectStdout = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<s
61119
62const collectStderr = collectStdout;120const collectStderr = collectStdout;
63121
64const tailscaleCommandError = (
65 args: readonly string[],
66 message: string,
67 exitCode: number | null,
68 stderr = "",
69): TailscaleCommandError =>
70 new TailscaleCommandError({
71 command: ["tailscale", ...args],
72 message,
73 exitCode,
74 stderr,
75 });
76
77const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson));122const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson));
78123
79function normalizeMagicDnsName(status: TailscaleStatusJson): string | null {124function normalizeMagicDnsName(status: TailscaleStatusJson): string | null {
...@@ -139,55 +184,52 @@ export const readTailscaleStatus = Effect.gen(function* () {...@@ -139,55 +184,52 @@ export const readTailscaleStatus = Effect.gen(function* () {
139 const args = ["status", "--json"];184 const args = ["status", "--json"];
140 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;185 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
141 const hostPlatform = yield* HostProcessPlatform;186 const hostPlatform = yield* HostProcessPlatform;
142 const child = yield* spawner187 const executable = tailscaleCommandForPlatform(hostPlatform);
143 .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args))188 const commandContext = {
144 .pipe(189 executable,
145 Effect.mapError((cause) =>190 subcommand: "status" as const,
146 tailscaleCommandError(191 argumentCount: args.length,
147 args,192 };
148 cause instanceof Error ? cause.message : "Failed to spawn tailscale status.",193 return yield* Effect.gen(function* () {
149 null,194 const child = yield* spawner
150 ),195 .spawn(ChildProcess.make(executable, args))
151 ),196 .pipe(
152 );197 Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })),
153 const [stdout, stderr, exitCode] = yield* Effect.all(198 );
154 [199 const [stdout, stderr, exitCode] = yield* Effect.all(
155 collectStdout(child.stdout),200 [
156 collectStderr(child.stderr),201 collectStdout(child.stdout),
157 child.exitCode.pipe(Effect.map(Number)),202 collectStderr(child.stderr),
158 ],203 child.exitCode.pipe(Effect.map(Number)),
159 { concurrency: "unbounded" },204 ],
160 ).pipe(205 { concurrency: "unbounded" },
161 Effect.mapError((cause) =>206 ).pipe(
162 tailscaleCommandError(207 Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })),
163 args,
164 cause instanceof Error ? cause.message : "Failed to run tailscale status.",
165 null,
166 ),
167 ),
168 );
169 if (exitCode !== 0) {
170 return yield* tailscaleCommandError(
171 args,
172 `Tailscale status exited with code ${exitCode}.`,
173 exitCode,
174 stderr,
175 );208 );
176 }209 if (exitCode !== 0) {
177 return yield* parseTailscaleStatus(stdout);210 return yield* new TailscaleCommandExitError({
178}).pipe(211 ...commandContext,
179 Effect.scoped,212 exitCode,
180 Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT),213 stdoutLength: stdout.length,
181 Effect.flatMap((result) =>214 stderrLength: stderr.length,
182 Option.match(result, {215 });
183 onNone: () =>216 }
217 return yield* parseTailscaleStatus(stdout);
218 }).pipe(
219 Effect.scoped,
220 Effect.timeout(TAILSCALE_STATUS_TIMEOUT),
221 Effect.catchTags({
222 TimeoutError: (cause) =>
184 Effect.fail(223 Effect.fail(
185 tailscaleCommandError(["status", "--json"], "Tailscale status timed out.", null),224 new TailscaleCommandTimeoutError({
225 ...commandContext,
226 timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT),
227 cause,
228 }),
186 ),229 ),
187 onSome: Effect.succeed,
188 }),230 }),
189 ),231 );
190);232});
191233
192export function buildTailscaleHttpsBaseUrl(input: {234export function buildTailscaleHttpsBaseUrl(input: {
193 readonly magicDnsName: string;235 readonly magicDnsName: string;
...@@ -204,53 +246,52 @@ export function buildTailscaleHttpsBaseUrl(input: {...@@ -204,53 +246,52 @@ export function buildTailscaleHttpsBaseUrl(input: {
204246
205const runTailscaleCommand = (247const runTailscaleCommand = (
206 args: readonly string[],248 args: readonly string[],
207 input: {249 timeoutInput: Duration.Input,
208 readonly spawnMessage: string;
209 readonly runMessage: string;
210 readonly exitMessage: (exitCode: number) => string;
211 readonly timeoutMessage: string;
212 readonly timeout: Duration.Input;
213 },
214): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> =>250): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> =>
215 Effect.gen(function* () {251 Effect.gen(function* () {
216 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;252 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
217 const hostPlatform = yield* HostProcessPlatform;253 const hostPlatform = yield* HostProcessPlatform;
218 const child = yield* spawner254 const executable = tailscaleCommandForPlatform(hostPlatform);
219 .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args))255 const commandContext = {
220 .pipe(256 executable,
221 Effect.mapError((cause) =>257 subcommand: "serve" as const,
222 tailscaleCommandError(258 argumentCount: args.length,
223 args,259 };
224 cause instanceof Error ? cause.message : input.spawnMessage,260 const timeout = Duration.fromInputUnsafe(timeoutInput);
225 null,261 return yield* Effect.gen(function* () {
226 ),262 const child = yield* spawner
227 ),263 .spawn(ChildProcess.make(executable, args))
264 .pipe(
265 Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })),
266 );
267 const [stderr, exitCode] = yield* Effect.all(
268 [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))],
269 { concurrency: "unbounded" },
270 ).pipe(
271 Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })),
228 );272 );
229 const [stderr, exitCode] = yield* Effect.all(273 if (exitCode !== 0) {
230 [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))],274 return yield* new TailscaleCommandExitError({
231 { concurrency: "unbounded" },275 ...commandContext,
232 ).pipe(276 exitCode,
233 Effect.mapError((cause) =>277 stderrLength: stderr.length,
234 tailscaleCommandError(278 });
235 args,279 }
236 cause instanceof Error ? cause.message : input.runMessage,280 }).pipe(
237 null,281 Effect.scoped,
238 ),282 Effect.timeout(timeout),
239 ),283 Effect.catchTags({
240 );284 TimeoutError: (cause) =>
241 if (exitCode !== 0) {285 Effect.fail(
242 return yield* tailscaleCommandError(args, input.exitMessage(exitCode), exitCode, stderr);286 new TailscaleCommandTimeoutError({
243 }287 ...commandContext,
244 }).pipe(288 timeoutMs: Duration.toMillis(timeout),
245 Effect.scoped,289 cause,
246 Effect.timeoutOption(input.timeout),290 }),
247 Effect.flatMap((result) =>291 ),
248 Option.match(result, {
249 onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)),
250 onSome: Effect.succeed,
251 }),292 }),
252 ),293 );
253 );294 });
254295
255export const ensureTailscaleServe = (input: {296export const ensureTailscaleServe = (input: {
256 readonly localPort: number;297 readonly localPort: number;
...@@ -260,13 +301,7 @@ export const ensureTailscaleServe = (input: {...@@ -260,13 +301,7 @@ export const ensureTailscaleServe = (input: {
260 const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT;301 const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT;
261 const localHost = input.localHost ?? "127.0.0.1";302 const localHost = input.localHost ?? "127.0.0.1";
262 const args = ["serve", "--bg", `--https=${servePort}`, `http://${localHost}:${input.localPort}`];303 const args = ["serve", "--bg", `--https=${servePort}`, `http://${localHost}:${input.localPort}`];
263 return runTailscaleCommand(args, {304 return runTailscaleCommand(args, TAILSCALE_SERVE_TIMEOUT);
264 spawnMessage: "Failed to spawn tailscale serve.",
265 runMessage: "Failed to run tailscale serve.",
266 exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`,
267 timeoutMessage: "Tailscale serve timed out.",
268 timeout: TAILSCALE_SERVE_TIMEOUT,
269 });
270};305};
271306
272export const disableTailscaleServe = (307export const disableTailscaleServe = (
...@@ -276,13 +311,10 @@ export const disableTailscaleServe = (...@@ -276,13 +311,10 @@ export const disableTailscaleServe = (
276): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> =>311): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> =>
277 Effect.gen(function* () {312 Effect.gen(function* () {
278 const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT;313 const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT;
279 return yield* runTailscaleCommand(["serve", `--https=${servePort}`, "off"], {314 return yield* runTailscaleCommand(
280 spawnMessage: "Failed to spawn tailscale serve off.",315 ["serve", `--https=${servePort}`, "off"],
281 runMessage: "Failed to run tailscale serve off.",316 TAILSCALE_SERVE_TIMEOUT,
282 exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`,317 );
283 timeoutMessage: "Tailscale serve off timed out.",
284 timeout: TAILSCALE_SERVE_TIMEOUT,
285 });
286 });318 });
287319
288export const probeTailscaleHttpsEndpoint = (input: {320export const probeTailscaleHttpsEndpoint = (input: {