Skip to content
84 changes: 83 additions & 1 deletion apps/server/src/environment/DirectEndpoints.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<typeof NodeOS.networkInterfaces> = {
lo0: [
Expand Down Expand Up @@ -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)),
),
Expand All @@ -302,11 +310,15 @@ describe("DirectEndpoints Tailscale Serve", () => {
const probeStarted = yield* Deferred.make<void>();
const revalidationStarted = yield* Deferred.make<void>();
const finishRevalidation = yield* Deferred.make<void>();
// 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) =>
Expand Down Expand Up @@ -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);
}),
);
});
13 changes: 4 additions & 9 deletions apps/server/src/environment/DirectEndpoints.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
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";
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";

Expand All @@ -26,6 +21,7 @@ import {
isWildcardHost,
VIRTUAL_INTERFACE,
} from "../startupAccess.ts";
import * as TailscaleStatusReader from "./TailscaleStatusReader.ts";

type NetworkInterfacesMap = ReturnType<typeof NodeOS.networkInterfaces>;

Expand Down Expand Up @@ -119,7 +115,7 @@ const probeCloudMetadata = Effect.callback<boolean>((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,
Expand Down Expand Up @@ -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);
Expand All @@ -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<string>()),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.provideService(HttpClient.HttpClient, httpClient),
);
yield* Ref.set(servedEndpoint, servedUrl);
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/environment/RemoteOpenTargets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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))),
),
),
Expand Down
7 changes: 4 additions & 3 deletions apps/server/src/environment/RemoteOpenTargets.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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.
Expand All @@ -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<RemoteOpenTarget> = [];
Expand Down
57 changes: 57 additions & 0 deletions apps/server/src/environment/TailscaleStatusReader.ts
Original file line number Diff line number Diff line change
@@ -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<TailscaleStatus, TailscaleStatusError>;
}
>()("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<TailscaleStatus, TailscaleStatusError> | undefined;

const join = Effect.suspend(() => {
if (inFlight !== undefined) return Effect.succeed(inFlight);
const deferred = Deferred.makeUnsafe<TailscaleStatus, TailscaleStatusError>();
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);
8 changes: 6 additions & 2 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
);
Expand Down
Loading