Skip to content
45 changes: 45 additions & 0 deletions apps/server/src/environment/DirectEndpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import { it as effectIt } from "@effect/vitest";
import type * as NodeOS from "node:os";
import * as Effect from "effect/Effect";
import * as Deferred from "effect/Deferred";
import * as Fiber from "effect/Fiber";
import * as Ref from "effect/Ref";
import * as Layer from "effect/Layer";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
Expand Down Expand Up @@ -288,6 +291,48 @@ describe("DirectEndpoints Tailscale Serve", () => {
}),
);

effectIt.effect(
"returns bound endpoints during validation and reuses only a completed Serve result",
() =>
Effect.gen(function* () {
const status = yield* Ref.make(200);
const finishProbe = yield* Deferred.make<void>();
const probeStarted = yield* Deferred.make<void>();
const service = yield* DirectEndpoints.make.pipe(
Effect.provide(
Layer.mergeAll(
layerServeConfig,
layerTailscaleUp,
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Deferred.succeed(probeStarted, undefined).pipe(
Effect.andThen(Deferred.await(finishProbe)),
Effect.andThen(Ref.get(status)),
Effect.map((status) =>
HttpClientResponse.fromWeb(request, new Response(null, { status })),
),
),
),
),
),
),
);
const resolving = yield* Effect.forkScoped(service.resolve());
yield* Deferred.await(probeStarted);
expect(yield* service.resolveAvailable()).toEqual([]);
yield* Deferred.succeed(finishProbe, undefined);
const validated = yield* Fiber.join(resolving);
expect(validated).toEqual([
{ kind: "tailnet", httpBaseUrl: "https://bb-1.tail1234.ts.net/" },
]);
expect(yield* service.resolveAvailable()).toEqual(validated);
yield* Ref.set(status, 502);
expect(yield* service.resolve()).toEqual([]);
expect(yield* service.resolveAvailable()).toEqual([]);
}).pipe(Effect.scoped),
);

effectIt.effect("leaves the tailnet name out when Serve is not forwarding", () =>
Effect.gen(function* () {
expect(yield* resolveWithServe(502)).toEqual([]);
Expand Down
18 changes: 13 additions & 5 deletions apps/server/src/environment/DirectEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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";
Expand All @@ -34,6 +35,7 @@ const TAILSCALE_ENDPOINT_TIMEOUT = Duration.seconds(3);
export class DirectEndpoints extends Context.Service<
DirectEndpoints,
{
readonly resolveAvailable: () => Effect.Effect<ReadonlyArray<ServerDirectEndpoint>>;
readonly resolve: () => Effect.Effect<ReadonlyArray<ServerDirectEndpoint>>;
}
>()("t3/environment/DirectEndpoints") {}
Expand Down Expand Up @@ -128,7 +130,8 @@ export const make = Effect.gen(function* () {
: Effect.succeed(false);
yield* Effect.forkDetach(onCloudVm);

const resolve = Effect.gen(function* () {
const servedEndpoint = yield* Ref.make<Option.Option<string>>(Option.none());
const resolveAvailable = Effect.fn("DirectEndpoints.resolveAvailable")(function* () {
const endpoints = [
...resolveBoundEndpoints({
platform,
Expand All @@ -138,6 +141,12 @@ export const make = Effect.gen(function* () {
onCloudVm: yield* onCloudVm,
}),
];
const servedUrl = yield* Ref.get(servedEndpoint);
if (Option.isSome(servedUrl)) endpoints.push({ kind: "tailnet", httpBaseUrl: servedUrl.value });
return endpoints;
});

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(
Expand All @@ -158,14 +167,13 @@ export const make = Effect.gen(function* () {
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.provideService(HttpClient.HttpClient, httpClient),
);
if (Option.isSome(servedUrl)) {
endpoints.push({ kind: "tailnet", httpBaseUrl: servedUrl.value });
}
yield* Ref.set(servedEndpoint, servedUrl);
}
return endpoints;
return yield* resolveAvailable();
});

return DirectEndpoints.of({
resolveAvailable,
resolve: Effect.fn("DirectEndpoints.resolve")(function () {
return resolve;
}),
Expand Down
82 changes: 76 additions & 6 deletions apps/server/src/ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ it.effect("resends late editors without rolling back updates already sent", () =
Deferred.await(settingsSent).pipe(Effect.as("file-explorer" as const)),
},
{ resolveTargets: () => Effect.succeed([]) },
{ resolve: () => Effect.succeed([]) },
).pipe(
Stream.tap((event) =>
event.type === "settingsUpdated" ? Deferred.succeed(settingsSent, undefined) : Effect.void,
Expand Down Expand Up @@ -185,6 +186,7 @@ it.effect("resends a reveal kind and remote targets that outlast the snapshot ti
resolveFileManagerRevealKind: () => Effect.succeed("file-explorer"),
},
{ resolveTargets: () => Deferred.await(finishDiscovery) },
{ resolve: () => Effect.succeed([]) },
).pipe(Stream.runCollect, Effect.forkScoped({ startImmediately: true }));
yield* TestClock.adjust("6 seconds");
yield* Deferred.succeed(finishDiscovery, targets);
Expand Down Expand Up @@ -618,8 +620,15 @@ const layerConfigSources = Layer.mergeAll(
Layer.provideMerge(NodeServices.layer),
);

it.effect.each(["pending", "cached"] as const)(
"%s remote discovery preserves initial config and updates through discovery and ACK",
it.effect.each([
"pending",
"cached",
"direct-pending",
"direct-cached",
"direct-failed",
"direct-interrupted",
] as const)(
"%s endpoint discovery preserves initial config and updates through discovery and ACK",
(discovery) =>
Effect.gen(function* () {
const keybindings = yield* Keybindings.Keybindings;
Expand All @@ -638,6 +647,15 @@ it.effect.each(["pending", "cached"] as const)(
const remoteDiscoveryStarted = yield* Deferred.make<void>();
const finishRemoteDiscovery = yield* Deferred.make<void>();
const targets = [{ kind: "mdns" as const, host: "test.local" }];
const direct = discovery.startsWith("direct-");
const bound = [{ kind: "lan" as const, httpBaseUrl: "http://192.168.1.10:3773/" }];
const endpoints = [
...bound,
{ kind: "tailnet" as const, httpBaseUrl: "https://test.ts.net/" },
];
const directStarted = yield* Deferred.make<void>();
const finishDirect = yield* Deferred.make<void>();
const directStopped = yield* Deferred.make<void>();
const responses = yield* Queue.unbounded<ServerConfigStreamEvent>();
const configRpcServices = Layer.mergeAll(
layerUnusedConfigRpcServices,
Expand All @@ -659,12 +677,30 @@ it.effect.each(["pending", "cached"] as const)(
resolveTargets: () =>
Deferred.succeed(remoteDiscoveryStarted, undefined).pipe(
Effect.andThen(
discovery === "cached" ? Effect.void : Deferred.await(finishRemoteDiscovery),
discovery === "cached" || direct
? Effect.void
: Deferred.await(finishRemoteDiscovery),
),
Effect.as(targets),
),
}),
Layer.mock(DirectEndpoints.DirectEndpoints)({ resolve: () => Effect.succeed([]) }),
Layer.mock(DirectEndpoints.DirectEndpoints)({
resolveAvailable: () => Effect.succeed(discovery === "direct-cached" ? endpoints : bound),
resolve: () =>
direct
? Deferred.succeed(directStarted, undefined).pipe(
Effect.andThen(
discovery === "direct-cached" ? Effect.void : Deferred.await(finishDirect),
),
Effect.andThen(
discovery === "direct-failed"
? Effect.die("probe failed")
: Effect.succeed(endpoints),
),
Effect.onExit(() => Deferred.succeed(directStopped, undefined)),
)
: Effect.succeed(bound),
}),
Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
scratchRoot: Effect.succeedNone,
namedProjectsRoot: process.cwd(),
Expand Down Expand Up @@ -744,7 +780,14 @@ it.effect.each(["pending", "cached"] as const)(
const snapshot = yield* Queue.take(responses);
assert.equal(snapshot.type, "snapshot");
if (snapshot.type !== "snapshot") return yield* Effect.die("Missing initial snapshot");
assert.deepEqual(snapshot.config.remoteOpenTargets, discovery === "cached" ? targets : []);
assert.deepEqual(
snapshot.config.remoteOpenTargets,
discovery === "cached" || direct ? targets : [],
);
assert.deepEqual(
snapshot.config.directEndpoints,
discovery === "direct-cached" ? endpoints : bound,
);
assert.isFalse(snapshot.config.settings.defaultAutoPull);
assert.deepEqual(snapshot.config.keybindings, initialKeybindings);
const duringAck = yield* keybindings.upsertKeybindingRule({
Expand Down Expand Up @@ -795,7 +838,31 @@ it.effect.each(["pending", "cached"] as const)(
assert.deepEqual(resolved.config.keybindings, latestKeybindings.keybindings);
yield* acknowledge;
}
if (direct) {
yield* Deferred.await(directStarted);
if (discovery === "direct-pending") {
yield* Deferred.succeed(finishDirect, undefined);
const resolved = yield* Queue.take(responses);
assert.equal(resolved.type, "snapshot");
if (resolved.type !== "snapshot") return yield* Effect.die("Missing direct endpoints");
assert.deepEqual(resolved.config.directEndpoints, endpoints);
assert.deepEqual(
resolved.config.settings,
ServerSettings.redactServerSettingsForClient(latestSettings),
);
assert.deepEqual(resolved.config.keybindings, latestKeybindings.keybindings);
yield* acknowledge;
} else if (discovery === "direct-failed") {
yield* Deferred.succeed(finishDirect, undefined);
yield* Deferred.await(directStopped);
yield* settings.updateSettings({ defaultAutoPull: false });
const event = yield* Queue.take(responses);
assert.equal(event.type, "settingsUpdated");
yield* acknowledge;
}
}
yield* server.write(0, { _tag: "Interrupt", requestId, interruptors: [] });
if (direct) yield* Deferred.await(directStopped);
}).pipe(
Effect.provide(
Layer.mergeAll(
Expand Down Expand Up @@ -858,7 +925,10 @@ it.effect("on-demand clients get provider config without workspace snapshots", (
Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({
resolveTargets: () => Effect.succeed([]),
}),
Layer.mock(DirectEndpoints.DirectEndpoints)({ resolve: () => Effect.succeed([]) }),
Layer.mock(DirectEndpoints.DirectEndpoints)({
resolve: () => Effect.succeed([]),
resolveAvailable: () => Effect.succeed([]),
}),
Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
scratchRoot: Effect.succeedNone,
namedProjectsRoot: process.cwd(),
Expand Down
48 changes: 39 additions & 9 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ export const withLateConfigDiscovery = <E, R>(
liveUpdates: Stream.Stream<ServerConfigStreamEvent, E, R>,
launcher: EditorDiscovery,
remoteOpenTargets: RemoteOpenTargets.RemoteOpenTargets["Service"],
directEndpoints: Pick<DirectEndpoints.DirectEndpoints["Service"], "resolve">,
) => {
const lateEditorConfig = Stream.fromEffect(launcher.resolveAvailableEditors()).pipe(
Stream.filter(
Expand Down Expand Up @@ -347,7 +348,15 @@ export const withLateConfigDiscovery = <E, R>(
Stream.map((targets) => ({ type: "remoteOpenTargetsResolved" as const, targets })),
);

return Stream.merge(liveUpdates, Stream.merge(lateEditorConfig, lateRemoteOpenTargets)).pipe(
const lateDirectEndpoints = Stream.fromEffect(directEndpoints.resolve()).pipe(
Stream.catchCause(() => Stream.empty),
Stream.map((endpoints) => ({ type: "directEndpointsResolved" as const, endpoints })),
);

return Stream.merge(
liveUpdates,
Stream.merge(lateEditorConfig, Stream.merge(lateRemoteOpenTargets, lateDirectEndpoints)),
).pipe(
Stream.mapAccum(
(): ClientServerConfig => config,
(current, event): readonly [ClientServerConfig, ReadonlyArray<ServerConfigStreamEvent>] => {
Expand All @@ -366,6 +375,13 @@ export const withLateConfigDiscovery = <E, R>(
const next = { ...current, remoteOpenTargets: event.targets };
return [next, [{ version: 1, type: "snapshot", config: next }]];
}
case "directEndpointsResolved": {
if (JSON.stringify(current.directEndpoints ?? []) === JSON.stringify(event.endpoints)) {
return [current, []];
}
const next = { ...current, directEndpoints: event.endpoints };
return [next, [{ version: 1, type: "snapshot", config: next }]];
}
case "keybindingsUpdated":
return [{ ...current, ...event.payload }, [event]];
case "providerStatuses":
Expand Down Expand Up @@ -1769,7 +1785,7 @@ export const layerWsRpc = (
const loadServerConfig = Effect.fn("ws.loadServerConfig")(function* (options: {
readonly usageLimitsCommand: boolean;
readonly workspaceSnapshotsOnDemand: boolean;
readonly deferRemoteOpenTargets: boolean;
readonly deferNetworkDiscovery: boolean;
}) {
const keybindingsConfig = yield* keybindings.loadConfigState;
const currentProviders = yield* providerRegistry.getProviders;
Expand All @@ -1789,10 +1805,12 @@ export const layerWsRpc = (
const discovery = yield* resolveOpenDiscoveryForConfig({
editors: externalLauncher.resolveAvailableEditors(),
fileManagerRevealKind: externalLauncher.resolveFileManagerRevealKind(),
remoteOpenTargets: options.deferRemoteOpenTargets
remoteOpenTargets: options.deferNetworkDiscovery
? Effect.succeed([])
: remoteOpenTargets.resolveTargets(),
directEndpoints: directEndpoints.resolve(),
directEndpoints: options.deferNetworkDiscovery
? directEndpoints.resolveAvailable()
: directEndpoints.resolve(),
});

return {
Expand Down Expand Up @@ -2229,7 +2247,7 @@ export const layerWsRpc = (
loadServerConfig({
usageLimitsCommand: false,
workspaceSnapshotsOnDemand: false,
deferRemoteOpenTargets: false,
deferNetworkDiscovery: false,
}),
[WS_METHODS.serverSearchAcpRegistry]: (input) =>
acpRegistryCatalog
Expand Down Expand Up @@ -3154,10 +3172,16 @@ export const layerWsRpc = (
const remoteTargets = yield* Effect.forkScoped(remoteOpenTargets.resolveTargets(), {
startImmediately: true,
});
const directResult = yield* Effect.forkScoped(
directEndpoints
.resolve()
.pipe(Effect.catchCause(() => directEndpoints.resolveAvailable())),
{ startImmediately: true },
);
const config = yield* loadServerConfig({
usageLimitsCommand,
workspaceSnapshotsOnDemand,
deferRemoteOpenTargets: true,
deferNetworkDiscovery: true,
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
});
const keybindingsUpdates = keybindingsChanges.pipe(
Stream.map((event) => ({
Expand Down Expand Up @@ -3253,9 +3277,15 @@ export const layerWsRpc = (
rpcInitialItems([
{ version: 1 as const, type: "snapshot" as const, config: initialConfig },
]),
withLateConfigDiscovery(initialConfig, liveUpdates, externalLauncher, {
resolveTargets: () => Fiber.join(remoteTargets),
}),
withLateConfigDiscovery(
initialConfig,
liveUpdates,
externalLauncher,
{
resolveTargets: () => Fiber.join(remoteTargets),
},
{ resolve: () => Fiber.join(directResult) },
),
);
}),
),
Expand Down
Loading