Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 56 additions & 3 deletions apps/desktop/src/backend/DesktopServerExposure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,21 @@ const tailnetNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {
],
};

function mockSpawnerLayer(statusJson = "{}") {
function mockSpawnerLayer(statusJson: string | (() => string) = "{}") {
return Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.succeed(
Effect.sync(() =>
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(1),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
unref: Effect.succeed(Effect.void),
stdin: Sink.drain,
stdout: Stream.make(encoder.encode(statusJson)),
stdout: Stream.make(
encoder.encode(typeof statusJson === "string" ? statusJson : statusJson()),
),
stderr: Stream.empty,
all: Stream.empty,
getInputFd: () => Sink.drain,
Expand Down Expand Up @@ -348,6 +350,57 @@ describe("DesktopServerExposure", () => {
),
);

it.effect("re-reads the MagicDNS name when Tailscale connects after a cached status read", () => {
// `read` hands back this object, so mutating it simulates an interface change.
const networkInterfaces: Record<
string,
readonly DesktopNetworkInterfaces.DesktopNetworkInterfaceInfo[] | undefined
> = { ...lanNetworkInterfaces };
let tailscaleConnected = false;
let statusReads = 0;
const spawnerLayer = mockSpawnerLayer(() => {
statusReads += 1;
return tailscaleConnected ? `{"Self":{"DNSName":"desktop.tail.ts.net."}}` : "{}";
});

return withHarness(
networkInterfaces,
Effect.gen(function* () {
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
yield* serverExposure.configureFromSettings({ port: 4173 });
yield* serverExposure.setMode("network-accessible");

const beforeConnect = yield* serverExposure.getAdvertisedEndpoints;
assert.deepEqual(
beforeConnect.map((endpoint) => endpoint.httpBaseUrl),
["http://127.0.0.1:4173/", "http://192.168.1.20:4173/"],
);
assert.equal(statusReads, 1);

Object.assign(networkInterfaces, tailnetNetworkInterfaces);
tailscaleConnected = true;

const afterConnect = yield* serverExposure.getAdvertisedEndpoints;
assert.deepEqual(
afterConnect.map((endpoint) => endpoint.httpBaseUrl),
[
"http://127.0.0.1:4173/",
"http://192.168.1.20:4173/",
"http://100.90.1.2:4173/",
"https://desktop.tail.ts.net/",
],
);
assert.equal(statusReads, 2);

// Unchanged interfaces keep serving the cached status.
yield* serverExposure.getAdvertisedEndpoints;
assert.equal(statusReads, 2);
}),
{},
spawnerLayer,
);
});

it.effect("does not spawn the tailscale CLI while server exposure is local-only", () =>
withHarness(
lanNetworkInterfaces,
Expand Down
28 changes: 27 additions & 1 deletion apps/desktop/src/backend/DesktopServerExposure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,19 @@ function resolveRuntimeState(input: {
};
}

const tailscaleIpv4AddressKey = (
networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces,
): string =>
Object.values(networkInterfaces)
.flatMap((addresses) => addresses ?? [])
.filter(
(address) =>
!address.internal && address.family === "IPv4" && isTailscaleIpv4Address(address.address),
)
.map((address) => address.address)
.toSorted()
.join(",");

const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): boolean =>
previous.port !== next.port ||
previous.bindHost !== next.bindHost ||
Expand All @@ -423,7 +436,7 @@ export const make = Effect.gen(function* () {
// Cache the `tailscale status` spawn for the TTL. On macOS, the Mac App
// Store Tailscale CLI lives inside Tailscale's sandbox container, so each
// spawn re-triggers the "Other apps" TCC prompt.
const cachedReadMagicDnsName = yield* Effect.cachedWithTTL(
const [cachedReadMagicDnsName, invalidateMagicDnsName] = yield* Effect.cachedInvalidateWithTTL(
readTailscaleStatus.pipe(
Effect.map((status) => status.magicDnsName),
Effect.orElseSucceed(() => null),
Expand All @@ -432,6 +445,10 @@ export const make = Effect.gen(function* () {
TAILSCALE_STATUS_CACHE_TTL,
);

// Tailscale connecting or disconnecting changes its interface addresses, which
// makes a status read cached before the change stale.
const lastTailscaleAddressesRef = yield* Ref.make<string | null>(null);

const readNetworkInterfaces = networkInterfaces.read;

const getState = Ref.get(stateRef).pipe(Effect.map(toContractState));
Expand Down Expand Up @@ -545,6 +562,15 @@ export const make = Effect.gen(function* () {
return coreEndpoints;
}

const tailscaleAddresses = tailscaleIpv4AddressKey(currentNetworkInterfaces);
const previousTailscaleAddresses = yield* Ref.getAndSet(
lastTailscaleAddressesRef,
tailscaleAddresses,
);
if (previousTailscaleAddresses !== tailscaleAddresses) {
yield* invalidateMagicDnsName;
}

const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({
port: state.port,
serveEnabled: state.tailscaleServeEnabled,
Expand Down
129 changes: 129 additions & 0 deletions apps/web/src/state/desktopNetworkAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ const serverExposureState: DesktopServerExposureState = {
};

const advertisedEndpoints: ReadonlyArray<AdvertisedEndpoint> = [];
const tailscaleEndpoint: AdvertisedEndpoint = {
id: "tailscale-ip:37737",
label: "Tailscale IP",
provider: { id: "tailscale", label: "Tailscale", kind: "private-network", isAddon: true },
httpBaseUrl: "http://100.64.0.10:37737",
wsBaseUrl: "ws://100.64.0.10:37737",
reachability: "private-network",
compatibility: { hostedHttpsApp: "unknown", desktopApp: "compatible" },
source: "desktop-addon",
status: "available",
};
const serverExposureLoadCause = new Error("exposure failed");
const advertisedEndpointsLoadCause = new Error("endpoints failed");

Expand Down Expand Up @@ -51,6 +62,124 @@ describe("desktopNetworkAccessState", () => {
registry.dispose();
});

it("revalidates when the settings screen remounts after the snapshot is stale", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
try {
const getServerExposureState = vi.fn(async () => serverExposureState);
// A Tailscale address appears after the first load.
const getAdvertisedEndpoints = vi
.fn<() => Promise<ReadonlyArray<AdvertisedEndpoint>>>()
.mockResolvedValueOnce(advertisedEndpoints)
.mockResolvedValue([tailscaleEndpoint]);
const atom = createDesktopNetworkAccessStateAtom(() => ({
getAdvertisedEndpoints,
getServerExposureState,
}));
const registry = AtomRegistry.make();

const unmount = registry.mount(atom);
await vi.waitFor(() => {
expect(AsyncResult.value(registry.get(atom))).toEqual(
expect.objectContaining({ _tag: "Some" }),
);
});
unmount();
// Leaving the screen releases the SWR wrapper; the loaded snapshot stays alive.
await vi.waitFor(() => expect(registry.getNodes().has(atom)).toBe(false));

vi.advanceTimersByTime(30_001);

const remount = registry.mount(atom);
expect(AsyncResult.value(registry.get(atom))).toEqual(
expect.objectContaining({
_tag: "Some",
value: { advertisedEndpoints, serverExposureState },
}),
);
await vi.waitFor(() => {
expect(getServerExposureState).toHaveBeenCalledTimes(2);
expect(getAdvertisedEndpoints).toHaveBeenCalledTimes(2);
expect(AsyncResult.value(registry.get(atom))).toEqual(
expect.objectContaining({
_tag: "Some",
value: { advertisedEndpoints: [tailscaleEndpoint], serverExposureState },
}),
);
});

remount();
registry.dispose();
} finally {
vi.useRealTimers();
}
});

it("does not refetch when the settings screen reopens within the stale period", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
try {
const getServerExposureState = vi.fn(async () => serverExposureState);
const getAdvertisedEndpoints = vi.fn(async () => advertisedEndpoints);
const atom = createDesktopNetworkAccessStateAtom(() => ({
getAdvertisedEndpoints,
getServerExposureState,
}));
const registry = AtomRegistry.make();

const unmount = registry.mount(atom);
await vi.waitFor(() => {
expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true);
});
unmount();
await vi.waitFor(() => expect(registry.getNodes().has(atom)).toBe(false));

vi.advanceTimersByTime(10_000);

const remount = registry.mount(atom);
const result = registry.get(atom);
expect(AsyncResult.isSuccess(result) && !result.waiting).toBe(true);
expect(getServerExposureState).toHaveBeenCalledTimes(1);
expect(getAdvertisedEndpoints).toHaveBeenCalledTimes(1);

remount();
registry.dispose();
} finally {
vi.useRealTimers();
}
});

it("refetches on an explicit refresh within the stale period", async () => {
const getServerExposureState = vi.fn(async () => serverExposureState);
const getAdvertisedEndpoints = vi
.fn<() => Promise<ReadonlyArray<AdvertisedEndpoint>>>()
.mockResolvedValueOnce(advertisedEndpoints)
.mockResolvedValue([tailscaleEndpoint]);
const atom = createDesktopNetworkAccessStateAtom(() => ({
getAdvertisedEndpoints,
getServerExposureState,
}));
const registry = AtomRegistry.make();

const unmount = registry.mount(atom);
await vi.waitFor(() => {
expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true);
});

registry.refresh(atom);

await vi.waitFor(() => {
expect(getAdvertisedEndpoints).toHaveBeenCalledTimes(2);
expect(AsyncResult.value(registry.get(atom))).toEqual(
expect.objectContaining({
_tag: "Some",
value: { advertisedEndpoints: [tailscaleEndpoint], serverExposureState },
}),
);
});

unmount();
registry.dispose();
});

it.each([
{
cause: serverExposureLoadCause,
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/state/desktopNetworkAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,14 @@ export function createDesktopNetworkAccessStateAtom(
} satisfies DesktopNetworkAccessSnapshot;
});

// Keep the loaded snapshot alive, but let the SWR wrapper unmount so reopening
// Connections after the stale period revalidates while showing the old snapshot.
return Atom.make(loadDesktopNetworkAccess()).pipe(
Atom.keepAlive,
Atom.swr({
staleTime: DESKTOP_NETWORK_ACCESS_STALE_TIME_MS,
revalidateOnMount: true,
}),
Atom.keepAlive,
Atom.withLabel("desktop:network-access"),
);
}
Expand Down
Loading