authorgravatar for 154429365+Rishet11@users.noreply.github.comRishet11 <154429365+Rishet11@users.noreply.github.com> 2026-08-23 01:37:26+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-22 16:07:26-04:00
logd9c1732b2791f762bce06e4898cdb6679ce24961
treed37ae9fb1b6f84dde7ad265d069de91c8ba6a697
parent49c2b4471c59b21759eb2e254e9c4fa2579888a3
signature Signed by PGP key B5690EEEBB952194

fix(desktop): keep tailscale spawn defects from breaking advertised endpoints (#7116)


2 files changed, 54 insertions(+), 10 deletions(-)

packages/tailscale/src/tailscale.test.ts+39
......@@ -212,6 +212,45 @@ describe("tailscale", () => {
212212 });
213213 });
214214
215 it.effect("turns spawn defects into typed spawn failures", () => {
216 // A non-directory entry on PATH makes node's spawn throw ENOTDIR
217 // synchronously. The platform spawner calls `NodeChildProcess.spawn` from
218 // inside an `Effect.callback` registration, so that throw arrives as a
219 // defect rather than a typed error - the shape reproduced here.
220 const defect = Object.assign(new Error("spawn tailscale ENOTDIR"), { code: "ENOTDIR" });
221 const layer = Layer.succeed(
222 ChildProcessSpawner.ChildProcessSpawner,
223 ChildProcessSpawner.make(() =>
224 Effect.callback<never, never>(() => {
225 throw defect;
226 }),
227 ),
228 );
229
230 return Effect.gen(function* () {
231 const statusError = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer));
232 assert.instanceOf(statusError, TailscaleCommandSpawnError);
233 assert.equal(statusError.subcommand, "status");
234 assert.strictEqual(statusError.cause, defect);
235
236 const serveError = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(
237 Effect.flip,
238 Effect.provide(layer),
239 );
240 assert.instanceOf(serveError, TailscaleCommandSpawnError);
241 assert.equal(serveError.subcommand, "serve");
242 assert.strictEqual(serveError.cause, defect);
243
244 // What callers actually rely on: the desktop endpoint providers recover
245 // with `Effect.orElseSucceed`, which only sees the typed error channel.
246 const degraded = yield* readTailscaleStatus.pipe(
247 Effect.orElseSucceed(() => null),
248 Effect.provide(layer),
249 );
250 assert.equal(degraded, null);
251 });
252 });
253
215254 it.effect("keeps nonzero exit diagnostics structured", () => {
216255 const layer = mockSpawnerLayer(() => ({
217256 code: 7,
packages/tailscale/src/tailscale.ts+15-10
......@@ -228,11 +228,15 @@ export const readTailscaleStatus = Effect.gen(function* () {
228228 argumentCount: args.length,
229229 };
230230 return yield* Effect.gen(function* () {
231 const child = yield* spawner
232 .spawn(ChildProcess.make(executable, args))
233 .pipe(
234 Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })),
235 );
231 const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe(
232 Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })),
233 // Spawning can also fail as a defect rather than a typed error - a
234 // non-directory entry on PATH makes node throw ENOTDIR synchronously.
235 // `mapError` never sees that, so it would escape as an uncaught error.
236 Effect.catchDefect((cause) =>
237 Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })),
238 ),
239 );
236240 const [stdout, stderr, exitCode] = yield* Effect.all(
237241 [
238242 collectStdout(child.stdout),
......@@ -299,11 +303,12 @@ const runTailscaleCommand = (
299303 };
300304 const timeout = Duration.fromInputUnsafe(timeoutInput);
301305 return yield* Effect.gen(function* () {
302 const child = yield* spawner
303 .spawn(ChildProcess.make(executable, args))
304 .pipe(
305 Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })),
306 );
306 const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe(
307 Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })),
308 Effect.catchDefect((cause) =>
309 Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })),
310 ),
311 );
307312 const [stderr, exitCode] = yield* Effect.all(
308313 [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))],
309314 { concurrency: "unbounded" },