From 7dca0982375c26b5fb3279dcf6eef5fd3d6662e4 Mon Sep 17 00:00:00 2001 From: Simone <185146821+Lucenx9@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:12:42 +0100 Subject: [PATCH] fix(tailscale): cancelled Serve commands no longer hang --- packages/tailscale/package.json | 1 + packages/tailscale/src/tailscale.test.ts | 47 +++++++++++++++++++++++- packages/tailscale/src/tailscale.ts | 7 +++- pnpm-lock.yaml | 3 ++ 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/tailscale/package.json b/packages/tailscale/package.json index e51f077393705..c2edf75de1e48 100644 --- a/packages/tailscale/package.json +++ b/packages/tailscale/package.json @@ -17,6 +17,7 @@ "effect": "catalog:" }, "devDependencies": { + "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", "@types/node": "catalog:", "vite-plus": "catalog:" diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 0b83cb3008d78..5d4d6ef223d0a 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -1,10 +1,13 @@ import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { ChildProcessSpawner } from "effect/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { @@ -245,4 +248,46 @@ describe("tailscale", () => { assert.deepEqual(killGraces, [500, 500]); }); }); + + it.live("cancels Serve even when the CLI ignores SIGTERM", () => + Effect.gen(function* () { + const nativeSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const started = yield* Deferred.make<{ + readonly child: ChildProcessSpawner.ChildProcessHandle; + readonly ready: string; + }>(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) return yield* nativeSpawner.spawn(command); + // Code 91 marks fixture self-release so a failed cancellation cannot leave a child behind. + const child = yield* nativeSpawner.spawn( + ChildProcess.make( + process.execPath, + [ + "-e", + 'process.on("SIGTERM", () => {}); setTimeout(() => process.exit(91), 5000); process.stdout.write("ready");', + ], + command.options, + ), + ); + const ready = yield* Stream.runHead(child.stdout); + yield* Deferred.succeed(started, { + child, + ready: ready._tag === "Some" ? new TextDecoder().decode(ready.value) : "", + }); + return child; + }), + ); + const fiber = yield* ensureTailscaleServe({ localPort: 3773 }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.forkChild, + ); + const { child, ready } = yield* Deferred.await(started); + assert.equal(ready, "ready"); + yield* Fiber.interrupt(fiber); + const result = yield* Effect.result(child.exitCode); + if (result._tag === "Success") assert.notEqual(result.success, 91); + else assert.include(String(result.failure.reason.cause), "SIGKILL"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 1c803b27f1eb3..532d978ef86b2 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -16,7 +16,7 @@ export const DEFAULT_TAILSCALE_SERVE_PORT = 443; */ const TAILSCALE_STATUS_TIMEOUT = Duration.millis(3_500); /** - * How long a probe killed on timeout gets to exit after SIGTERM before its + * How long a command killed on timeout gets to exit after SIGTERM before its * process group is SIGKILLed. Without it the spawner never escalates, so a group * member that ignores SIGTERM outlives the call. Short because the timeout error * only surfaces once cleanup finishes, and a healthy `tailscale` exits at once. @@ -388,8 +388,11 @@ const runTailscaleCommand = Effect.fn("tailscale.runTailscaleCommand")(function* argumentCount: args.length, }; const timeout = Duration.fromInputUnsafe(timeoutInput); + const command = ChildProcess.make(executable, args, { + forceKillAfter: TAILSCALE_KILL_GRACE, + }); return yield* Effect.gen(function* () { - const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe( + const child = yield* spawner.spawn(command).pipe( Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), Effect.catchDefect((cause) => Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 196781c388b03..b7a6b77ee72a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2266,6 +2266,9 @@ importers: specifier: 4.0.5 version: 4.0.5 devDependencies: + '@effect/platform-node': + specifier: 4.0.5 + version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.5 version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)