From 87d37d64474a8fd8bc2e2f571f6497678d1b4d77 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 30 May 2026 16:06:49 +0000 Subject: [PATCH 1/4] Make Tailscale integration more idiomatic Effect Co-authored-by: Julius Marminge --- .../backend/tailscaleEndpointProvider.test.ts | 5 +- .../src/backend/tailscaleEndpointProvider.ts | 12 +-- packages/tailscale/src/tailscale.test.ts | 57 +++++++++++--- packages/tailscale/src/tailscale.ts | 77 +++++++++++-------- 4 files changed, 100 insertions(+), 51 deletions(-) diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts index 612ef3bd73f..35ab5e9a9de 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts @@ -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"; @@ -34,8 +35,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"); }), diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index bd46e9f03f5..cc844837684 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -61,17 +61,17 @@ function resolveTailscaleIpAdvertisedEndpoints(input: { const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( "resolveTailscaleMagicDnsAdvertisedEndpoint", )(function* (input: { - readonly dnsName: string | null; + readonly dnsName: Option.Option; readonly serveEnabled: boolean; readonly servePort?: number; readonly probe?: (baseUrl: string) => Effect.Effect; }): Effect.fn.Return, 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 = @@ -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, diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index dd2b1772fd6..9c74feff3b9 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -1,8 +1,12 @@ import { assert, describe, it } from "@effect/vitest"; +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 Option from "effect/Option"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { @@ -13,6 +17,7 @@ import { parseTailscaleMagicDnsName, parseTailscaleStatus, readTailscaleStatus, + TAILSCALE_STATUS_TIMEOUT, } from "./tailscale.ts"; const encoder = new TextEncoder(); @@ -35,22 +40,37 @@ function mockHandle(result: { stdout?: string; stderr?: string; code?: number }) }); } +function mockHangingHandle() { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + function mockSpawnerLayer( handler: ( command: string, args: ReadonlyArray, ) => { 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; }; return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args))); - }), - ); + }, + }); } describe("tailscale", () => { @@ -66,8 +86,8 @@ 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()); }), ); @@ -75,7 +95,7 @@ describe("tailscale", () => { 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"], }); }), @@ -106,12 +126,31 @@ 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"], }); }); }); + it.effect("times out tailscale status with TestClock", () => { + const layer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, { + spawn: () => Effect.succeed(mockHangingHandle()), + }); + + return Effect.gen(function* () { + const fiber = yield* readTailscaleStatus.pipe(Effect.provide(layer), Effect.result, Effect.fork); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.sum(TAILSCALE_STATUS_TIMEOUT, Duration.millis(1))); + const result = yield* Fiber.join(fiber); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure._tag, "TailscaleCommandError"); + assert.equal(result.failure.message, "Tailscale status timed out."); + } + }).pipe(Effect.provide(TestClock.layer())); + }); + it.effect("configures tailscale serve through the process spawner service", () => { const layer = mockSpawnerLayer((command, args) => { assert.equal(command, "tailscale"); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index c8d9cab462d..2c6ad887fe8 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -1,4 +1,4 @@ -import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -7,24 +7,33 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export const DEFAULT_TAILSCALE_SERVE_PORT = 443; -export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500; -export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000; -export const TAILSCALE_PROBE_TIMEOUT_MS = 2_500; +export const TAILSCALE_STATUS_TIMEOUT = Duration.millis(1_500); +export const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10); +export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); -export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{ - readonly command: readonly string[]; - readonly message: string; - readonly exitCode: number | null; - readonly stderr: string; -}> {} +export class TailscaleCommandError extends Schema.TaggedErrorClass()( + "TailscaleCommandError", + { + command: Schema.Array(Schema.String), + message: Schema.String, + exitCode: Schema.Union([Schema.Number, Schema.Null]), + stderr: Schema.String, + }, +) {} -export class TailscaleStatusParseError extends Data.TaggedError("TailscaleStatusParseError")<{ - readonly cause: unknown; -}> {} +export class TailscaleStatusParseError extends Schema.TaggedErrorClass()( + "TailscaleStatusParseError", + { + cause: Schema.Defect, + }, +) {} -export class TailscaleUnavailableError extends Data.TaggedError("TailscaleUnavailableError")<{ - readonly reason: string; -}> {} +export class TailscaleUnavailableError extends Schema.TaggedErrorClass()( + "TailscaleUnavailableError", + { + reason: Schema.String, + }, +) {} const TailscaleStatusSelf = Schema.Struct({ DNSName: Schema.optional(Schema.Unknown), @@ -39,7 +48,7 @@ export type TailscaleStatusSelf = typeof TailscaleStatusSelf.Type; export type TailscaleStatusJson = typeof TailscaleStatusJson.Type; export interface TailscaleStatus { - readonly magicDnsName: string | null; + readonly magicDnsName: Option.Option; readonly tailnetIpv4Addresses: readonly string[]; } @@ -69,19 +78,19 @@ const tailscaleCommandError = ( const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson)); -function normalizeMagicDnsName(status: TailscaleStatusJson): string | null { +function normalizeMagicDnsName(status: TailscaleStatusJson): Option.Option { const dnsName = status.Self?.DNSName; if (typeof dnsName !== "string") { - return null; + return Option.none(); } const normalized = dnsName.trim().replace(/\.$/u, ""); - return normalized.length > 0 ? normalized : null; + return normalized.length > 0 ? Option.some(normalized) : Option.none(); } export const parseTailscaleMagicDnsName = ( rawStatusJson: string, -): Effect.Effect => +): Effect.Effect, TailscaleStatusParseError> => decodeTailscaleStatusJson(rawStatusJson).pipe( Effect.mapError((cause) => new TailscaleStatusParseError({ cause })), Effect.map(normalizeMagicDnsName), @@ -177,7 +186,7 @@ export const readTailscaleStatus: Effect.Effect< return yield* parseTailscaleStatus(stdout); }).pipe( Effect.scoped, - Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT_MS), + Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT), Effect.flatMap((result) => Option.match(result, { onNone: () => @@ -209,7 +218,7 @@ const runTailscaleCommand = ( readonly runMessage: string; readonly exitMessage: (exitCode: number) => string; readonly timeoutMessage: string; - readonly timeoutMs: number; + readonly timeout: Duration.Input; }, ): Effect.Effect => Effect.gen(function* () { @@ -246,7 +255,7 @@ const runTailscaleCommand = ( } }).pipe( Effect.scoped, - Effect.timeoutOption(input.timeoutMs), + Effect.timeoutOption(input.timeout), Effect.flatMap((result) => Option.match(result, { onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)), @@ -268,7 +277,7 @@ export const ensureTailscaleServe = (input: { runMessage: "Failed to run tailscale serve.", exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`, timeoutMessage: "Tailscale serve timed out.", - timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS, + timeout: TAILSCALE_SERVE_TIMEOUT, }); }; @@ -284,13 +293,13 @@ export const disableTailscaleServe = ( runMessage: "Failed to run tailscale serve off.", exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`, timeoutMessage: "Tailscale serve off timed out.", - timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS, + timeout: TAILSCALE_SERVE_TIMEOUT, }); }); export const probeTailscaleHttpsEndpoint = (input: { readonly baseUrl: string; - readonly timeoutMs?: number; + readonly timeout?: Duration.Input; }): Effect.Effect => Effect.gen(function* () { const client = yield* HttpClient.HttpClient; @@ -298,7 +307,7 @@ export const probeTailscaleHttpsEndpoint = (input: { const url = new URL("/.well-known/t3/environment", input.baseUrl); const request = HttpClientRequest.get(url.toString()); return yield* client.execute(request); - }).pipe(Effect.timeoutOption(input.timeoutMs ?? TAILSCALE_PROBE_TIMEOUT_MS)); + }).pipe(Effect.timeoutOption(input.timeout ?? TAILSCALE_PROBE_TIMEOUT)); return Option.match(response, { onNone: () => false, @@ -311,17 +320,17 @@ export const resolveTailscaleHttpsBaseUrl = ( readonly servePort?: number; } = {}, ): Effect.Effect< - string | null, + Option.Option, TailscaleCommandError | TailscaleStatusParseError, ChildProcessSpawner.ChildProcessSpawner > => readTailscaleStatus.pipe( Effect.map((status) => - status.magicDnsName - ? buildTailscaleHttpsBaseUrl({ - magicDnsName: status.magicDnsName, + Option.map(status.magicDnsName, (magicDnsName) => + buildTailscaleHttpsBaseUrl({ + magicDnsName, ...(input.servePort === undefined ? {} : { servePort: input.servePort }), - }) - : null, + }), + ), ), ); From 49bfefaa4882ad63a2146352a2def5308061fa87 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 30 May 2026 16:07:13 +0000 Subject: [PATCH 2/4] Format Tailscale Effect updates Co-authored-by: Julius Marminge --- packages/tailscale/src/tailscale.test.ts | 6 +++++- packages/tailscale/src/tailscale.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 9c74feff3b9..f3ccba478e6 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -138,7 +138,11 @@ describe("tailscale", () => { }); return Effect.gen(function* () { - const fiber = yield* readTailscaleStatus.pipe(Effect.provide(layer), Effect.result, Effect.fork); + const fiber = yield* readTailscaleStatus.pipe( + Effect.provide(layer), + Effect.result, + Effect.fork, + ); yield* Effect.yieldNow; yield* TestClock.adjust(Duration.sum(TAILSCALE_STATUS_TIMEOUT, Duration.millis(1))); const result = yield* Fiber.join(fiber); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 2c6ad887fe8..86d04988c77 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -329,7 +329,7 @@ export const resolveTailscaleHttpsBaseUrl = ( Option.map(status.magicDnsName, (magicDnsName) => buildTailscaleHttpsBaseUrl({ magicDnsName, - ...(input.servePort === undefined ? {} : { servePort: input.servePort }), + ...(input.servePort === undefined ? {} : { servePort: input.servePort }), }), ), ), From b5228418cb2d40c9a7bacff441d7484e794c078c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 30 May 2026 16:07:31 +0000 Subject: [PATCH 3/4] Fix Tailscale timeout test fiber setup Co-authored-by: Julius Marminge --- packages/tailscale/src/tailscale.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index f3ccba478e6..1cd460d8394 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -138,10 +138,8 @@ describe("tailscale", () => { }); return Effect.gen(function* () { - const fiber = yield* readTailscaleStatus.pipe( - Effect.provide(layer), - Effect.result, - Effect.fork, + const fiber = yield* Effect.fork( + readTailscaleStatus.pipe(Effect.provide(layer), Effect.result), ); yield* Effect.yieldNow; yield* TestClock.adjust(Duration.sum(TAILSCALE_STATUS_TIMEOUT, Duration.millis(1))); From df8b8802997a6555a2c4c434cd7463a42d51e252 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 30 May 2026 16:07:57 +0000 Subject: [PATCH 4/4] Use forkChild in Tailscale clock test Co-authored-by: Julius Marminge --- packages/tailscale/src/tailscale.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 1cd460d8394..72a3854b9bd 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -138,7 +138,7 @@ describe("tailscale", () => { }); return Effect.gen(function* () { - const fiber = yield* Effect.fork( + const fiber = yield* Effect.forkChild( readTailscaleStatus.pipe(Effect.provide(layer), Effect.result), ); yield* Effect.yieldNow;