Skip to content
Draft
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
12 changes: 12 additions & 0 deletions apps/server/src/preview/PortScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,13 @@ effectIt.effect("discovers owned listeners without sending HTTP or TLS probes",
expect(requests).toEqual([]);

stdout = owned;
expect(yield* scanner.scan()).toHaveLength(0);
stdout = unowned;
yield* TestClock.adjust(Duration.seconds(3));
expect(yield* scanner.scan()).toHaveLength(0);
stdout = owned;
expect(yield* scanner.scan()).toHaveLength(0);
yield* TestClock.adjust(Duration.seconds(3));
expect(yield* scanner.scan()).toHaveLength(1);
expect(requests).toEqual([]);

Expand Down Expand Up @@ -521,6 +528,8 @@ effectIt.effect(

procFiles["/proc/net/tcp"] = PROC_NET_TCP;
procFiles["/proc/net/tcp6"] = PROC_NET_TCP6;
expect(yield* scanner.scan()).toEqual([]);
yield* TestClock.adjust(Duration.seconds(3));
expect((yield* scanner.scan()).map(({ pid }) => pid)).toEqual([4242, 4242]);
yield* scanner.retain.pipe(Effect.scoped);
procFds["77"] = procFds["4242"]!;
Expand Down Expand Up @@ -730,6 +739,7 @@ effectIt.effect("rechecks the socket owner before stopping a Linux server withou
.pipe(Effect.match({ onFailure: (error) => error.reason, onSuccess: () => null }));
expect(signal).not.toHaveBeenCalled();
expect(result).toBe("not-found");
yield* TestClock.adjust(Duration.seconds(3));
expect(yield* scanner.scan()).toMatchObject([{ pid: 5000, port: 8765 }]);
yield* scanner.stopServer({ pid: 5000, port: 8765 });
expect(signal).toHaveBeenCalledExactlyOnceWith(5300, "SIGTERM");
Expand Down Expand Up @@ -893,6 +903,8 @@ effectIt.effect.each(["linux", "darwin"] as const)(
expect(yield* scanner.scan()).toEqual([]);
environmentFails = false;
}
expect(yield* scanner.scan()).toEqual([]);
yield* TestClock.adjust(Duration.seconds(3));
expect(yield* scanner.scan()).toMatchObject([
{ port: agentPort, pid: 4242, terminal: null, agent: { threadId: "thread-agent" } },
]);
Expand Down
40 changes: 32 additions & 8 deletions apps/server/src/preview/PortScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export const AGENT_THREAD_ENV = "T3CODE_THREAD_ID";
const PORT_DISCOVERY_ENV = "T3CODE_PORT_DISCOVERY";

const POLL_INTERVAL = Duration.seconds(3);
const LISTENER_MIN_AGE_MS = Duration.toMillis(POLL_INTERVAL);
const LSOF_TIMEOUT_MS = 5_000;
const PROC_FD_WALK_LIMIT = 50_000;
const SOCKET_OWNER_ATTEMPTS = 3;
Expand Down Expand Up @@ -463,6 +464,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
retainCount: 0,
});
const webProbeCacheRef = yield* Ref.make<ReadonlyMap<string, WebProbeCacheEntry>>(new Map());
// The first successful read after idle shows existing listeners immediately.
const listenerFirstSeenRef = yield* Ref.make<ReadonlyMap<string, number> | null>(null);
const scanSemaphore = yield* Semaphore.make(1);
const lsofMissingRef = yield* Ref.make(false);
const windowsProcessNamesRef = yield* Ref.make<
Expand Down Expand Up @@ -701,12 +704,12 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
// binary ones such as Unity's test runner or Steam's IPC.
// Idle polls omit this span; requested scans and uncached probes retain their own spans.
const probeWebServers = Effect.fnUntraced(function* (
servers: ReadonlyArray<DiscoveredLocalServer>,
servers: ReadonlyArray<DiscoveredLocalServer> | null,
configuredUrls: ReadonlyArray<string>,
) {
const nowMillis = yield* Clock.currentTimeMillis;
const cached = yield* Ref.get(webProbeCacheRef);
const groups = makeWebProbeGroups(servers, configuredUrls);
const groups = makeWebProbeGroups(servers ?? [], configuredUrls);
const probed = yield* Effect.forEach(
groups,
(group) => {
Expand Down Expand Up @@ -737,7 +740,28 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
if (probe.isWeb) configured.set(group.configuredKey, { ...group.server, url: group.url });
}
yield* Ref.set(webProbeCacheRef, nextCache);
return { discovered: servers.filter(isOwned), configured } satisfies WebProbeSnapshot;
let discovered = servers?.filter(isOwned) ?? [];
// A failed listener read cannot establish when listeners first appeared.
if (servers !== null) {
const known = yield* Ref.get(listenerFirstSeenRef);
const firstSeen = new Map(
discovered.map((server) => {
const key = `${server.pid}:${server.port}`;
return [
key,
known === null ? Number.NEGATIVE_INFINITY : (known.get(key) ?? completedAtMillis),
] as const;
}),
);
yield* Ref.set(listenerFirstSeenRef, firstSeen);
discovered = discovered.filter(
(server) =>
completedAtMillis -
(firstSeen.get(`${server.pid}:${server.port}`) ?? completedAtMillis) >=
LISTENER_MIN_AGE_MS,
);
}
return { discovered, configured } satisfies WebProbeSnapshot;
});

// tasklist costs 200-300ms per PID; query visible servers with bounded concurrency and cache names.
Expand Down Expand Up @@ -836,14 +860,12 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
ProcessTimeoutError: recoverWindowsProbeFailure,
}),
);
return yield* withWindowsProcessNames(
yield* probeWebServers(listeners ?? [], configuredUrls),
);
return yield* withWindowsProcessNames(yield* probeWebServers(listeners, configuredUrls));
}
const fromProc = yield* scanProcListeners(resolveOwners);
if (fromProc !== null) return yield* probeWebServers(fromProc, configuredUrls);
const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof");
if (yield* Ref.get(lsofMissingRef)) return yield* probeWebServers([], configuredUrls);
if (yield* Ref.get(lsofMissingRef)) return yield* probeWebServers(null, configuredUrls);
const lsofResult = yield* processRunner
.run({
command: "lsof",
Expand Down Expand Up @@ -874,7 +896,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
const withoutLsof = (yield* Ref.get(lsofMissingRef))
? yield* scanProcListeners(resolveOwners)
: null;
return yield* probeWebServers(withoutLsof ?? [], configuredUrls);
return yield* probeWebServers(withoutLsof, configuredUrls);
});

const scanSnapshot = (configuredUrls: ReadonlyArray<string>) =>
Expand Down Expand Up @@ -929,6 +951,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
{ ...state, retainCount: state.retainCount + 1 },
]);
if (wasIdle) {
yield* scanSemaphore.withPermits(1)(Ref.set(listenerFirstSeenRef, null));
yield* pollTick();
}
});
Expand All @@ -941,6 +964,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
if (!idle) return;
yield* scanSemaphore.withPermits(1)(
Effect.gen(function* () {
yield* Ref.set(listenerFirstSeenRef, null);
yield* Ref.set(socketOwnersRef, new Map());
yield* Ref.set(socketOwnerMissesRef, new Map());
const threads = yield* Ref.getAndSet(agentThreadsRef, new Map());
Expand Down
3 changes: 2 additions & 1 deletion docs/user/remote-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,8 @@ apps.

### Local servers

The browser lists ports held by processes your terminals and agents started.
The browser lists ports held by processes your terminals and agents started,
once a port has stayed open for a few seconds.
T3 Code reads which process holds each port and never connects to it, so a
listed port opens as `http://localhost:<port>` and might not be a web page. For
an HTTPS server or a specific path, set the **Preview URL** on the project
Expand Down