Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3897689
Reduce background Git ref and port polling
Quicksaver Jul 20, 2026
e1bf7a2
Document background Git ref and port polling behavior
Quicksaver Jul 20, 2026
72c361c
Merge remote-tracking branch 'upstream/main' into fix/reduce-backgrou…
Quicksaver Jul 20, 2026
66bdee8
Merge remote-tracking branch 'upstream/main' into fix/reduce-backgrou…
Quicksaver Jul 20, 2026
7944bea
Merge remote-tracking branch 'upstream/main' into fix/reduce-backgrou…
Quicksaver Jul 20, 2026
5b816e5
Serialize preview snapshot notifications
Quicksaver Jul 20, 2026
8f2fdbb
Merge remote-tracking branch 'upstream/main' into fix/reduce-backgrou…
Quicksaver Jul 21, 2026
d46e13c
Merge remote-tracking branch 'upstream/main' into fix/reduce-backgrou…
Quicksaver Jul 22, 2026
152b294
test(vcs): align ref polling coverage with interval
Quicksaver Jul 22, 2026
7ef36da
Merge remote-tracking branch 'upstream/main' into fix/reduce-backgrou…
Quicksaver Jul 22, 2026
bfdd40a
Merge remote-tracking branch 'upstream/main' into fix/reduce-backgrou…
Quicksaver Jul 23, 2026
a188c8e
Merge remote-tracking branch 'upstream/main' into fix/reduce-backgrou…
Quicksaver Jul 23, 2026
365fca6
Merge remote-tracking branch 'upstream/main' into fix/reduce-backgrou…
Quicksaver Jul 23, 2026
97457be
test(web): cover ref menu refreshes
Quicksaver Jul 23, 2026
c58f8ec
docs: clarify ref refresh ownership
Quicksaver Jul 23, 2026
9b39a60
Clarify ref refresh provenance and test scope
Quicksaver Jul 23, 2026
32a480b
Merge remote-tracking branch 'upstream/main' into fix/reduce-backgrou…
Quicksaver Jul 23, 2026
594bc91
Merge remote-tracking branch 'upstream/main' into fix/reduce-backgrou…
Quicksaver Jul 23, 2026
3b8c967
Merge remote-tracking branch 'upstream/main' into fix/reduce-backgrou…
Quicksaver Jul 23, 2026
62fa974
Merge remote-tracking branch 'upstream/main' into fix/reduce-backgrou…
Quicksaver Jul 23, 2026
50fe890
Merge remote-tracking branch 'upstream/main' into fix/reduce-backgrou…
Quicksaver Jul 23, 2026
bb1446c
Merge upstream/main into fix/reduce-background-git-ref-port-polling
Quicksaver Jul 24, 2026
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
33 changes: 33 additions & 0 deletions BRANCH_DETAILS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Background Git Ref And Port Polling

Frequently mounted Git-ref and preview-discovery surfaces stay fresh without continuously repeating their most expensive subprocess work.

Expected behavior:

- Git ref lists revalidate their first page every 20 seconds instead of every five seconds. Loaded cursor pages are one-shot snapshots, and inactive ref atoms expire after 30 seconds.
- The composer branch selector's open-triggered ref refresh originated upstream. This branch preserves that behavior while routing it through the same branch-owned open-only refresh helper used by the Diff panel comparison-ref menu, which explicitly refreshes both local and remote refs on open.
- Closing either menu resets its query state without triggering another ref refresh, so user interaction stays fresh without adding close-triggered work.
- Preview port discovery performs one immediate scan when the first subscriber retains it. Subscriptions replay the latest snapshot instead of initiating a duplicate scan.
- Subscription replay and concurrent snapshot broadcasts are serialized so a stale replay cannot arrive after a newer scan result.
- Managed terminal process-set changes trigger an immediate port scan; unchanged registrations and redundant removals do not.
- The broad `lsof` safety-net scan runs every 20 seconds when no server is known and every 10 seconds while a listener is present. This preserves discovery for servers started outside T3-managed terminals without a permanent three-second system-wide process sweep.

Primary files:

- `packages/client-runtime/src/state/vcs.ts`
- `apps/web/src/components/BranchToolbarBranchSelector.tsx`
- `apps/web/src/components/DiffPanel.tsx`
- `apps/web/src/components/vcsRefMenuRefresh.ts`
- `apps/server/src/preview/PortScanner.ts`
- `apps/server/src/ws.ts`

Focused regression coverage:

- `packages/client-runtime/src/state/vcs.test.ts` covers the 20-second first-page revalidation interval.
- `apps/web/src/components/vcsRefMenuRefresh.test.ts` covers the shared helper's one-callback and multiple-callback open paths plus its no-refresh-on-close path; component wiring remains visible in the two primary component files above.
- `apps/server/src/preview/PortScanner.test.ts` covers snapshot replay without rescanning, ordered replay during concurrent broadcasts, and unchanged terminal registrations avoiding redundant probes.

## Development Ports

- Web: `5740`
- Server/WebSocket: `13780`
110 changes: 110 additions & 0 deletions apps/server/src/preview/PortScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { it as effectIt } from "@effect/vitest";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Net from "@t3tools/shared/Net";
import * as Cause from "effect/Cause";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as PlatformError from "effect/PlatformError";
import { expect } from "vite-plus/test";
Expand Down Expand Up @@ -155,3 +157,111 @@ effectIt("does not swallow process probe interruption", () =>
}
}),
);

effectIt("replays snapshots without rescanning unchanged terminal registrations", () => {
let probeCount = 0;
let replayCount = 0;
const layer = makeProbeFailureLayer(() =>
Effect.sync(() => {
probeCount += 1;
return {
stdout: "",
stderr: "",
code: null,
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
};
}),
);

return Effect.gen(function* () {
const scanner = yield* PortScanner.PortDiscovery;
yield* scanner.subscribe(() => Effect.void);
yield* scanner.retain;
yield* scanner.registerTerminalProcesses({
threadId: "thread-1",
terminalId: "terminal-1",
processIds: [42],
});
yield* scanner.registerTerminalProcesses({
threadId: "thread-1",
terminalId: "terminal-1",
processIds: [42],
});
yield* scanner.subscribe(() =>
Effect.sync(() => {
replayCount += 1;
}),
);

expect(probeCount).toBe(2);
expect(replayCount).toBe(1);
}).pipe(Effect.provide(layer));
});

effectIt("serializes snapshot replay with concurrent broadcasts", () =>
Effect.gen(function* () {
const replayStarted = yield* Deferred.make<void>();
const releaseReplay = yield* Deferred.make<void>();
const secondProbeCompleted = yield* Deferred.make<void>();
const secondDeliveryStarted = yield* Deferred.make<void>();
const deliveries: Array<ReadonlyArray<number>> = [];
let probeCount = 0;
let deliveryCount = 0;
const layer = makeProbeFailureLayer(() =>
Effect.gen(function* () {
probeCount += 1;
if (probeCount === 2) {
yield* Deferred.succeed(secondProbeCompleted, undefined).pipe(Effect.ignore);
}
return {
stdout: probeCount === 1 ? "p100\ncnode\nn*:3000\n" : "p101\ncnode\nn*:3001\n",
stderr: "",
code: null,
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
};
}),
);

yield* Effect.gen(function* () {
const scanner = yield* PortScanner.PortDiscovery;
yield* scanner.retain;

const subscription = yield* scanner
.subscribe((servers) =>
Effect.gen(function* () {
deliveryCount += 1;
if (deliveryCount === 1) {
yield* Deferred.succeed(replayStarted, undefined).pipe(Effect.ignore);
yield* Deferred.await(releaseReplay);
} else {
yield* Deferred.succeed(secondDeliveryStarted, undefined).pipe(Effect.ignore);
}
deliveries.push(servers.map((server) => server.port));
}),
)
.pipe(Effect.forkScoped);
yield* Deferred.await(replayStarted);

const registration = yield* scanner
.registerTerminalProcesses({
threadId: "thread-1",
terminalId: "terminal-1",
processIds: [101],
})
.pipe(Effect.forkScoped);
yield* Deferred.await(secondProbeCompleted);
yield* Effect.yieldNow;
expect(yield* Deferred.isDone(secondDeliveryStarted)).toBe(false);

yield* Deferred.succeed(releaseReplay, undefined);
yield* Fiber.join(subscription);
yield* Fiber.join(registration);

expect(deliveries).toEqual([[3000], [3001]]);
}).pipe(Effect.provide(layer));
}),
);
93 changes: 68 additions & 25 deletions apps/server/src/preview/PortScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Ref from "effect/Ref";
import * as Schedule from "effect/Schedule";
import * as Semaphore from "effect/Semaphore";
import * as Scope from "effect/Scope";

import * as ProcessRunner from "../processRunner.ts";
Expand Down Expand Up @@ -50,7 +50,8 @@ export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([
3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000,
]);

const POLL_INTERVAL = Duration.seconds(3);
const ACTIVE_POLL_INTERVAL = Duration.seconds(10);
const IDLE_POLL_INTERVAL = Duration.seconds(20);
const LSOF_TIMEOUT_MS = 5_000;
const WINDOWS_LISTENER_TIMEOUT_MS = 5_000;

Expand Down Expand Up @@ -79,6 +80,9 @@ const terminalOwnerKey = (owner: {
readonly terminalId: string;
}): string => `${owner.threadId}\u0000${owner.terminalId}`;

const processIdsEqual = (left: ReadonlySet<number>, right: ReadonlySet<number>): boolean =>
left.size === right.size && [...left].every((processId) => right.has(processId));

const parseLsofOutput = (
raw: string,
terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(),
Expand Down Expand Up @@ -190,6 +194,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
const net = yield* Net.NetService;
const processRunner = yield* ProcessRunner.ProcessRunner;
const hostPlatform = yield* HostProcessPlatform;
const notificationLock = yield* Semaphore.make(1);
const stateRef = yield* Ref.make<ScannerState>({
lastSnapshot: [],
listeners: new Set(),
Expand Down Expand Up @@ -292,25 +297,49 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
yield* Effect.forEach(listeners, (listener) => listener(servers), { discard: true });
});

const publishSnapshot = Effect.fn("PortDiscovery.publishSnapshot")(function* (
next: ReadonlyArray<DiscoveredLocalServer>,
) {
yield* notificationLock.withPermit(
Effect.gen(function* () {
const changed = yield* Ref.modify(stateRef, (state) =>
serversEqual(state.lastSnapshot, next)
? [false, state]
: [true, { ...state, lastSnapshot: next }],
);
if (changed) yield* broadcast(next);
}),
);
});

const pollTick = Effect.fn("PortDiscovery.pollTick")(
function* () {
if ((yield* Ref.get(stateRef)).retainCount <= 0) return;
const next = yield* scanOnce();
const changed = yield* Ref.modify(stateRef, (state) =>
serversEqual(state.lastSnapshot, next)
? [false, state]
: [true, { ...state, lastSnapshot: next }],
);
if (changed) yield* broadcast(next);
yield* publishSnapshot(next);
},
Effect.catchCause((cause: Cause.Cause<never>) =>
Effect.logWarning("preview port scan failed", Cause.pretty(cause)),
),
);

// Single layer-scoped polling fiber. Ticks are no-ops when no client is
// currently retained, so the cost is one Ref.get every POLL_INTERVAL.
yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL))));
// Keep broad listener discovery as a fallback, but avoid a system-wide lsof
// process every three seconds while the app is otherwise idle. Terminal PID
// changes trigger immediate scans below; the periodic loop is only the
// safety net for listeners started outside a managed terminal.
yield* Effect.forkScoped(
Effect.gen(function* () {
while (true) {
const state = yield* Ref.get(stateRef);
yield* Effect.sleep(
state.retainCount > 0 && state.lastSnapshot.length > 0
? ACTIVE_POLL_INTERVAL
: IDLE_POLL_INTERVAL,
);
yield* pollTick();
}
}),
);

const acquireRetention = Effect.fn("PortDiscovery.retain")(function* () {
const wasIdle = yield* Ref.modify(stateRef, (state) => [
Expand All @@ -334,16 +363,23 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
const subscribe: PortDiscovery["Service"]["subscribe"] = Effect.fn("PortDiscovery.subscribe")(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
(listener) =>
Effect.acquireRelease(
Ref.update(stateRef, (state) => ({
...state,
listeners: new Set([...state.listeners, listener]),
})),
notificationLock.withPermit(
Ref.modify(stateRef, (state) => [
state.lastSnapshot,
{
...state,
listeners: new Set([...state.listeners, listener]),
},
]).pipe(Effect.tap(listener)),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaked listener breaks port broadcasts

Medium Severity

PortDiscovery.subscribe adds the listener to scanner state and then replays the snapshot in the same acquire step. If that replay effect fails, Effect.acquireRelease skips release, so the listener stays registered even though the subscription never completed. Later broadcast runs every listener with Effect.forEach, so one failing replay listener can abort the whole broadcast and stop live port updates for other subscribers.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bb1446c. Configure here.

() =>
Ref.update(stateRef, (state) => {
const listeners = new Set(state.listeners);
listeners.delete(listener);
return { ...state, listeners };
}),
notificationLock.withPermit(
Ref.update(stateRef, (state) => {
const listeners = new Set(state.listeners);
listeners.delete(listener);
return { ...state, listeners };
}),
),
),
);

Expand All @@ -356,26 +392,33 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
const processIds = new Set(
input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0),
);
yield* Ref.update(stateRef, (state) => {
const changed = yield* Ref.modify(stateRef, (state) => {
const terminalProcesses = new Map(state.terminalProcesses);
const key = terminalOwnerKey(owner);
const existing = terminalProcesses.get(key);
if (existing && processIdsEqual(existing.processIds, processIds)) {
return [false, state] as const;
}
if (processIds.size === 0) {
if (!existing) return [false, state] as const;
terminalProcesses.delete(key);
} else {
terminalProcesses.set(key, { owner, processIds });
}
return { ...state, terminalProcesses };
return [true, { ...state, terminalProcesses }] as const;
});
if (changed) yield* pollTick();
});

const unregisterTerminal: PortDiscovery["Service"]["unregisterTerminal"] = Effect.fn(
"PortDiscovery.unregisterTerminal",
)(function* (input) {
yield* Ref.update(stateRef, (state) => {
const changed = yield* Ref.modify(stateRef, (state) => {
const terminalProcesses = new Map(state.terminalProcesses);
terminalProcesses.delete(terminalOwnerKey(input));
return { ...state, terminalProcesses };
const removed = terminalProcesses.delete(terminalOwnerKey(input));
return [removed, removed ? { ...state, terminalProcesses } : state] as const;
});
if (changed) yield* pollTick();
});

return PortDiscovery.of({
Expand Down
9 changes: 3 additions & 6 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1964,13 +1964,10 @@ const makeWsRpcLayer = (
WS_METHODS.subscribeDiscoveredLocalServers,
Stream.callback<DiscoveredLocalServerList>((queue) =>
Effect.gen(function* () {
// Retention performs one immediate scan when discovery was
// idle. Subscribe replays that snapshot to every connection,
// including connections that join an already-retained scanner.
yield* portDiscovery.retain;
const initial = yield* portDiscovery.scan();
const initialScannedAt = DateTime.formatIso(yield* DateTime.now);
yield* Queue.offer(queue, {
servers: initial,
scannedAt: initialScannedAt,
});
yield* portDiscovery.subscribe((servers) =>
Effect.gen(function* () {
const scannedAt = DateTime.formatIso(yield* DateTime.now);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
} from "./ui/combobox";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { refreshVcsRefsOnMenuOpen } from "./vcsRefMenuRefresh";

interface BranchToolbarBranchSelectorProps {
className?: string;
Expand Down Expand Up @@ -500,9 +501,8 @@ export function BranchToolbarBranchSelector({
setIsBranchMenuOpen(open);
if (!open) {
setBranchQuery("");
return;
}
branchRefState.refresh();
refreshVcsRefsOnMenuOpen(open, branchRefState.refresh);
},
[branchRefState.refresh],
);
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { serverEnvironment } from "../state/server";
import { reviewEnvironment } from "../state/review";
import { vcsEnvironment } from "../state/vcs";
import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices";
import { refreshVcsRefsOnMenuOpen } from "./vcsRefMenuRefresh";

type DiffRenderMode = "stacked" | "split";
type DiffThemeType = "light" | "dark";
Expand Down Expand Up @@ -593,7 +594,10 @@ export default function DiffPanel({
filteredItems={filteredBaseRefItems}
value={selectedBaseRef ?? AUTOMATIC_BASE_REF}
onOpenChange={(open) => {
if (!open) setBaseRefQuery("");
if (!open) {
setBaseRefQuery("");
}
refreshVcsRefsOnMenuOpen(open, localBranchRefs.refresh, remoteBranchRefs.refresh);
}}
onValueChange={(value) => {
if (!value) return;
Expand Down
Loading
Loading