diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index def0434140c32..9a1443d4e2d8e 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -1,6 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as NetService from "@t3tools/shared/Net"; import type * as NodeOS from "node:os"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; @@ -10,12 +13,16 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as TestClock from "effect/testing/TestClock"; import { describe, expect, it } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; import * as DirectEndpoints from "./DirectEndpoints.ts"; import { resolveBoundEndpoints } from "./DirectEndpoints.ts"; +import * as RemoteOpenTargets from "./RemoteOpenTargets.ts"; +import * as TailscaleStatusReader from "./TailscaleStatusReader.ts"; const INTERFACES: ReturnType = { lo0: [ @@ -277,6 +284,7 @@ const resolveWithServe = (probeStatus: number) => Effect.flatMap(DirectEndpoints.DirectEndpoints, (service) => service.resolve()).pipe( Effect.provide( DirectEndpoints.layer.pipe( + Layer.provide(TailscaleStatusReader.layer), Layer.provide( Layer.mergeAll(layerServeConfig(), layerTailscaleUp, layerServeProbe(probeStatus)), ), @@ -302,11 +310,15 @@ describe("DirectEndpoints Tailscale Serve", () => { const probeStarted = yield* Deferred.make(); const revalidationStarted = yield* Deferred.make(); const finishRevalidation = yield* Deferred.make(); + // Built in the test's scope: the reader's shared read outlives `make`, as the server's does. + const reader = yield* Layer.build( + TailscaleStatusReader.layer.pipe(Layer.provide(layerTailscaleUp)), + ); const service = yield* DirectEndpoints.make.pipe( + Effect.provide(reader), Effect.provide( Layer.mergeAll( layerServeConfig("192.168.1.10"), - layerTailscaleUp, Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => @@ -356,3 +368,73 @@ describe("DirectEndpoints Tailscale Serve", () => { }), ); }); + +// A CLI that cannot reach tailscaled: `tailscale status` never answers, so each read waits out its timeout. +const layerTailscaleHung = (onStatusSpawn: () => void) => + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command) && command.args[0] === "status") onStatusSpawn(); + return Effect.succeed( + 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, + }), + ); + }), + ); + +const layerSshdListening = Layer.succeed(NetService.NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: () => Effect.succeed(true), + reserveLoopbackPort: () => Effect.succeed(40_000), + findAvailablePort: (preferred) => Effect.succeed(preferred), +}); + +describe("Tailscale status shared by DirectEndpoints and RemoteOpenTargets", () => { + effectIt.effect("reads `tailscale status` once per connect, not once per service", () => + Effect.gen(function* () { + let statusReads = 0; + const context = yield* Layer.build( + Layer.mergeAll(DirectEndpoints.layer, RemoteOpenTargets.layer).pipe( + Layer.provide(TailscaleStatusReader.layer), + Layer.provide( + Layer.mergeAll( + layerServeConfig(), + layerTailscaleHung(() => statusReads++), + layerServeProbe(200), + layerSshdListening, + ), + ), + ), + ); + const connect = yield* Effect.all( + [ + Context.get(context, RemoteOpenTargets.RemoteOpenTargets).resolveTargets(), + Context.get(context, DirectEndpoints.DirectEndpoints).resolve(), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.provideService(HostProcess.Hostname, "bb-1"), + Effect.provideService(HostProcess.Username, "t3-user"), + Effect.forkChild, + ); + yield* TestClock.adjust("4 seconds"); + expect(yield* Fiber.join(connect)).toEqual([ + [{ kind: "mdns", host: "bb-1.local", username: "t3-user" }], + [], + ]); + expect(statusReads, "tailscale status spawns for one connect").toBe(1); + }), + ); +}); diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index 47e9772e20fe1..614725e95320d 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -1,9 +1,5 @@ import type { ServerDirectEndpoint } from "@t3tools/contracts"; -import { - buildTailscaleHttpsBaseUrl, - probeTailscaleHttpsEndpoint, - readTailscaleStatus, -} from "@t3tools/tailscale"; +import { buildTailscaleHttpsBaseUrl, probeTailscaleHttpsEndpoint } from "@t3tools/tailscale"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -11,7 +7,6 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import { HttpClient } from "effect/http"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as NodeNet from "node:net"; import * as NodeOS from "node:os"; @@ -26,6 +21,7 @@ import { isWildcardHost, VIRTUAL_INTERFACE, } from "../startupAccess.ts"; +import * as TailscaleStatusReader from "./TailscaleStatusReader.ts"; type NetworkInterfacesMap = ReturnType; @@ -119,7 +115,7 @@ const probeCloudMetadata = Effect.callback((resume) => { /** @public */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const tailscaleStatus = yield* TailscaleStatusReader.TailscaleStatusReader; const httpClient = yield* HttpClient.HttpClient; const platform = yield* HostProcess.Platform; // Only the service default listens on every interface without being asked, @@ -149,7 +145,7 @@ export const make = Effect.gen(function* () { const resolve = Effect.gen(function* () { // Serve setup can fail without stopping startup; advertise only after it answers as this server. if (config.tailscaleServeEnabled) { - const servedUrl = yield* readTailscaleStatus.pipe( + const servedUrl = yield* tailscaleStatus.read().pipe( Effect.map((status) => status.magicDnsName), Effect.flatMap((magicDnsName) => { if (magicDnsName === null) return Effect.succeed(null); @@ -164,7 +160,6 @@ export const make = Effect.gen(function* () { Effect.timeoutOption(TAILSCALE_ENDPOINT_TIMEOUT), Effect.map(Option.flatMap(Option.fromNullishOr)), Effect.orElseSucceed(() => Option.none()), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(HttpClient.HttpClient, httpClient), ); yield* Ref.set(servedEndpoint, servedUrl); diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts index 3e66d5f707a0d..c2e103b8f83ef 100644 --- a/apps/server/src/environment/RemoteOpenTargets.test.ts +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -12,6 +12,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { afterEach, describe, expect, vi } from "vite-plus/test"; import * as RemoteOpenTargets from "./RemoteOpenTargets.ts"; +import * as TailscaleStatusReader from "./TailscaleStatusReader.ts"; afterEach(() => { vi.restoreAllMocks(); @@ -74,6 +75,7 @@ const resolveTargets = (input: { Effect.provideService(HostProcess.Hostname, input.hostname), Effect.provide( RemoteOpenTargets.layer.pipe( + Layer.provide(TailscaleStatusReader.layer), Layer.provide(Layer.mergeAll(layerNet(input.sshd), layerSpawner(input.tailscale))), ), ), diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts index fff8a8168437b..69386f8f697e6 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -1,13 +1,14 @@ import { type RemoteOpenTarget } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NetService from "@t3tools/shared/Net"; -import { readTailscaleSshEnabled, readTailscaleStatus } from "@t3tools/tailscale"; +import { readTailscaleSshEnabled } from "@t3tools/tailscale"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { makeStaleWhileRevalidate } from "./staleWhileRevalidate.ts"; +import * as TailscaleStatusReader from "./TailscaleStatusReader.ts"; const SSH_PORT = 22; const TARGETS_CACHE_TTL = "60 seconds"; @@ -23,6 +24,7 @@ export class RemoteOpenTargets extends Context.Service< export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const net = yield* NetService.NetService; + const tailscaleStatus = yield* TailscaleStatusReader.TailscaleStatusReader; const discoverTargets = Effect.gen(function* () { // Check both loopback families: sshd can be bound IPv6-only. @@ -33,10 +35,9 @@ export const make = Effect.gen(function* () { ); const username = yield* HostProcess.Username; - const magicDnsName = yield* readTailscaleStatus.pipe( + const magicDnsName = yield* tailscaleStatus.read().pipe( Effect.map((status) => status.magicDnsName), Effect.orElseSucceed(() => null), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const targets: Array = []; diff --git a/apps/server/src/environment/TailscaleStatusReader.ts b/apps/server/src/environment/TailscaleStatusReader.ts new file mode 100644 index 0000000000000..24edb61cf9329 --- /dev/null +++ b/apps/server/src/environment/TailscaleStatusReader.ts @@ -0,0 +1,57 @@ +import { + readTailscaleStatus, + type TailscaleCommandError, + type TailscaleStatus, + type TailscaleStatusParseError, +} from "@t3tools/tailscale"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +type TailscaleStatusError = TailscaleCommandError | TailscaleStatusParseError; + +export class TailscaleStatusReader extends Context.Service< + TailscaleStatusReader, + { + /** Joins the read already running, so concurrent callers spawn `tailscale status` once. */ + readonly read: () => Effect.Effect; + } +>()("t3/environment/TailscaleStatusReader") {} + +const make = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + // The layer's scope, so a caller that times out never interrupts the read others joined. + const scope = yield* Effect.scope; + let inFlight: Deferred.Deferred | undefined; + + const join = Effect.suspend(() => { + if (inFlight !== undefined) return Effect.succeed(inFlight); + const deferred = Deferred.makeUnsafe(); + inFlight = deferred; + return readTailscaleStatus.pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.onExit((exit) => + Effect.suspend(() => { + inFlight = undefined; + return Deferred.done(deferred, exit); + }), + ), + Effect.interruptible, + Effect.forkIn(scope), + Effect.as(deferred), + ); + }).pipe( + // Claiming `inFlight` and forking are one step: an interrupt between them would strand the claim. + Effect.uninterruptible, + ); + + return TailscaleStatusReader.of({ + read: Effect.fn("TailscaleStatusReader.read")(function* () { + return yield* Deferred.await(yield* join); + }), + }); +}); + +export const layer = Layer.effect(TailscaleStatusReader, make); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 47190f60dcf51..a2519c679ea8f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -142,6 +142,7 @@ import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; +import * as TailscaleStatusReader from "./environment/TailscaleStatusReader.ts"; import * as AuthHttp from "./auth/http.ts"; import * as ReplayMarkers from "./auth/replayMarkers.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; @@ -764,8 +765,11 @@ const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( // Layers memoize by reference, so this is the same policy the settings layer reads. Layer.provideMerge(AnalyticsService.layer.pipe(Layer.provide(ManagedPolicy.layer))), Layer.provideMerge(ExternalLauncher.layer), - Layer.provideMerge(RemoteOpenTargets.layer), - Layer.provideMerge(DirectEndpoints.layer), + Layer.provideMerge( + Layer.mergeAll(RemoteOpenTargets.layer, DirectEndpoints.layer).pipe( + Layer.provide(TailscaleStatusReader.layer), + ), + ), Layer.provideMerge(ServerLifecycleEvents.layer), Layer.provide(NetService.layer), );