From 3897689313528d92578ff0c5936f92387bdf6f88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 20 Jul 2026 12:44:42 +0100 Subject: [PATCH 1/9] Reduce background Git ref and port polling - Poll first ref pages every 20 seconds and refresh menus on open - Avoid duplicate port scans and adapt lsof polling to activity - Document the retained freshness guarantees --- apps/server/src/preview/PortScanner.test.ts | 42 ++++++++++++++++ apps/server/src/preview/PortScanner.ts | 55 +++++++++++++++------ apps/server/src/ws.ts | 9 ++-- apps/web/src/components/DiffPanel.tsx | 7 ++- packages/client-runtime/src/state/vcs.ts | 10 ++-- 5 files changed, 99 insertions(+), 24 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 69b5729164d..5cb8ca4d9e4 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -155,3 +155,45 @@ 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)); +}); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index c306fca2b33..3011a381132 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -21,7 +21,6 @@ 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 Scope from "effect/Scope"; import * as ProcessRunner from "../processRunner.ts"; @@ -50,7 +49,8 @@ export const COMMON_DEV_PORTS: ReadonlyArray = 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; @@ -79,6 +79,9 @@ const terminalOwnerKey = (owner: { readonly terminalId: string; }): string => `${owner.threadId}\u0000${owner.terminalId}`; +const processIdsEqual = (left: ReadonlySet, right: ReadonlySet): boolean => + left.size === right.size && [...left].every((processId) => right.has(processId)); + const parseLsofOutput = ( raw: string, terminalByProcessId: ReadonlyMap = new Map(), @@ -308,9 +311,23 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ), ); - // 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) => [ @@ -334,10 +351,13 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const subscribe: PortDiscovery["Service"]["subscribe"] = Effect.fn("PortDiscovery.subscribe")( (listener) => Effect.acquireRelease( - Ref.update(stateRef, (state) => ({ - ...state, - listeners: new Set([...state.listeners, listener]), - })), + Ref.modify(stateRef, (state) => [ + state.lastSnapshot, + { + ...state, + listeners: new Set([...state.listeners, listener]), + }, + ]).pipe(Effect.tap(listener)), () => Ref.update(stateRef, (state) => { const listeners = new Set(state.listeners); @@ -356,26 +376,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({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 41c29fc3388..acef3519d52 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1791,13 +1791,10 @@ const makeWsRpcLayer = ( WS_METHODS.subscribeDiscoveredLocalServers, Stream.callback((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); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 5cb2a1cfdea..7a9f283da73 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -581,7 +581,12 @@ export default function DiffPanel({ filteredItems={filteredBaseRefItems} value={selectedBaseRef ?? AUTOMATIC_BASE_REF} onOpenChange={(open) => { - if (!open) setBaseRefQuery(""); + if (!open) { + setBaseRefQuery(""); + return; + } + localBranchRefs.refresh(); + remoteBranchRefs.refresh(); }} onValueChange={(value) => { if (!value) return; diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 4ef94c2619f..9a286463a4e 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -23,7 +23,8 @@ import { followStreamInEnvironment } from "./runtime.ts"; import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; const OFFLINE_BRANCH_LIST_LIMIT = 100; -const VCS_REFS_REVALIDATE_INTERVAL = "5 seconds"; +const VCS_REFS_REVALIDATE_INTERVAL = "20 seconds"; +const VCS_REFS_IDLE_TTL_MS = 30_000; function canUseVcsRefsCache(input: VcsListRefsInput): boolean { return ( @@ -107,7 +108,10 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange Stream.switchMap((generation) => generation === null ? Stream.empty - : Stream.tick(VCS_REFS_REVALIDATE_INTERVAL).pipe( + : (input.cursor === undefined + ? Stream.tick(VCS_REFS_REVALIDATE_INTERVAL) + : Stream.succeed(undefined) + ).pipe( Stream.mapEffect( () => refresh().pipe( @@ -151,7 +155,7 @@ export function createVcsEnvironmentAtoms( return runtime .atom(cachedVcsRefsChanges(environmentId, input)) .pipe( - Atom.setIdleTTL(5 * 60_000), + Atom.setIdleTTL(VCS_REFS_IDLE_TTL_MS), Atom.withLabel(`environment-data:vcs:list-refs:${environmentId}:${inputKey}`), ); }), From e1bf7a25f01ee23b649a2a838dd309edcc708f3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 20 Jul 2026 13:12:47 +0100 Subject: [PATCH 2/9] Document background Git ref and port polling behavior --- BRANCH_DETAILS.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 BRANCH_DETAILS.md diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md new file mode 100644 index 00000000000..775a15eb939 --- /dev/null +++ b/BRANCH_DETAILS.md @@ -0,0 +1,25 @@ +# 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. +- Opening the composer branch selector or Diff panel comparison-ref menu explicitly refreshes local and remote refs, so user interaction does not wait for the background interval. +- Preview port discovery performs one immediate scan when the first subscriber retains it. Subscriptions replay the latest snapshot instead of initiating a duplicate scan. +- 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/DiffPanel.tsx` +- `apps/server/src/preview/PortScanner.ts` +- `apps/server/src/ws.ts` + +Focused regression coverage lives in `apps/server/src/preview/PortScanner.test.ts`. Keep coverage for snapshot replay without rescanning and for unchanged terminal registrations avoiding redundant probes. + +## Development Ports + +- Web: `5740` +- Server/WebSocket: `13780` From 5b816e5fce668d361ec417431535fb9500c51cb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 20 Jul 2026 17:10:43 +0100 Subject: [PATCH 3/9] Serialize preview snapshot notifications - Order subscription replay with concurrent snapshot broadcasts - Cover replay ordering with a focused concurrent regression test - Document the preview notification ordering guarantee --- BRANCH_DETAILS.md | 3 +- apps/server/src/preview/PortScanner.test.ts | 68 +++++++++++++++++++++ apps/server/src/preview/PortScanner.ts | 52 ++++++++++------ 3 files changed, 104 insertions(+), 19 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 775a15eb939..e00cf781368 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -7,6 +7,7 @@ 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. - Opening the composer branch selector or Diff panel comparison-ref menu explicitly refreshes local and remote refs, so user interaction does not wait for the background interval. - 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. @@ -17,7 +18,7 @@ Primary files: - `apps/server/src/preview/PortScanner.ts` - `apps/server/src/ws.ts` -Focused regression coverage lives in `apps/server/src/preview/PortScanner.test.ts`. Keep coverage for snapshot replay without rescanning and for unchanged terminal registrations avoiding redundant probes. +Focused regression coverage lives in `apps/server/src/preview/PortScanner.test.ts`. Keep coverage for snapshot replay without rescanning, ordered replay during concurrent broadcasts, and unchanged terminal registrations avoiding redundant probes. ## Development Ports diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 5cb8ca4d9e4..fb0ac2acbc2 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -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"; @@ -197,3 +199,69 @@ effectIt("replays snapshots without rescanning unchanged terminal registrations" expect(replayCount).toBe(1); }).pipe(Effect.provide(layer)); }); + +effectIt("serializes snapshot replay with concurrent broadcasts", () => + Effect.gen(function* () { + const replayStarted = yield* Deferred.make(); + const releaseReplay = yield* Deferred.make(); + const secondProbeCompleted = yield* Deferred.make(); + const secondDeliveryStarted = yield* Deferred.make(); + const deliveries: Array> = []; + 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)); + }), +); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 3011a381132..a0c3bf3a1ec 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -21,6 +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 Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as ProcessRunner from "../processRunner.ts"; @@ -193,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({ lastSnapshot: [], listeners: new Set(), @@ -295,16 +297,26 @@ 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, + ) { + 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) => Effect.logWarning("preview port scan failed", Cause.pretty(cause)), @@ -351,19 +363,23 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const subscribe: PortDiscovery["Service"]["subscribe"] = Effect.fn("PortDiscovery.subscribe")( (listener) => Effect.acquireRelease( - Ref.modify(stateRef, (state) => [ - state.lastSnapshot, - { - ...state, - listeners: new Set([...state.listeners, listener]), - }, - ]).pipe(Effect.tap(listener)), + notificationLock.withPermit( + Ref.modify(stateRef, (state) => [ + state.lastSnapshot, + { + ...state, + listeners: new Set([...state.listeners, listener]), + }, + ]).pipe(Effect.tap(listener)), + ), () => - 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 }; + }), + ), ), ); From 152b294d994cb5bbea9899b009d3b0bb1c3e694b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 22 Jul 2026 11:36:06 +0100 Subject: [PATCH 4/9] test(vcs): align ref polling coverage with interval --- packages/client-runtime/src/state/vcs.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index 60a128a5a75..0031444404a 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -155,13 +155,13 @@ describe("cached VCS refs", () => { } expect(yield* Ref.get(calls)).toBe(1); - yield* TestClock.adjust("5 seconds"); + yield* TestClock.adjust("20 seconds"); expect(Option.getOrThrow(yield* Fiber.join(fiber))).toEqual(LIVE_REFS); }).pipe(Effect.provide(TestClock.layer())), ), ); - it.effect("revalidates connected refs every five seconds", () => + it.effect("revalidates connected refs every twenty seconds", () => Effect.scoped( Effect.gen(function* () { const calls = yield* Ref.make(0); @@ -193,7 +193,7 @@ describe("cached VCS refs", () => { } expect(yield* Ref.get(calls)).toBe(1); - yield* TestClock.adjust("5 seconds"); + yield* TestClock.adjust("20 seconds"); expect(Array.from(yield* Fiber.join(fiber))).toEqual([CACHED_REFS, LIVE_REFS]); }).pipe(Effect.provide(TestClock.layer())), ), From 97457be621d20deddf4fb752b939d459a0199401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 23 Jul 2026 14:51:47 +0100 Subject: [PATCH 5/9] test(web): cover ref menu refreshes --- .../BranchToolbarBranchSelector.tsx | 4 +-- apps/web/src/components/DiffPanel.tsx | 5 ++- .../src/components/vcsRefMenuRefresh.test.ts | 33 +++++++++++++++++++ apps/web/src/components/vcsRefMenuRefresh.ts | 9 +++++ 4 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/vcsRefMenuRefresh.test.ts create mode 100644 apps/web/src/components/vcsRefMenuRefresh.ts diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 08ee713a932..a003de06696 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -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; @@ -500,9 +501,8 @@ export function BranchToolbarBranchSelector({ setIsBranchMenuOpen(open); if (!open) { setBranchQuery(""); - return; } - branchRefState.refresh(); + refreshVcsRefsOnMenuOpen(open, branchRefState.refresh); }, [branchRefState.refresh], ); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 1b897d4f92f..c67783f0b87 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -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"; @@ -595,10 +596,8 @@ export default function DiffPanel({ onOpenChange={(open) => { if (!open) { setBaseRefQuery(""); - return; } - localBranchRefs.refresh(); - remoteBranchRefs.refresh(); + refreshVcsRefsOnMenuOpen(open, localBranchRefs.refresh, remoteBranchRefs.refresh); }} onValueChange={(value) => { if (!value) return; diff --git a/apps/web/src/components/vcsRefMenuRefresh.test.ts b/apps/web/src/components/vcsRefMenuRefresh.test.ts new file mode 100644 index 00000000000..8e12d35aaea --- /dev/null +++ b/apps/web/src/components/vcsRefMenuRefresh.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { refreshVcsRefsOnMenuOpen } from "./vcsRefMenuRefresh"; + +describe("ref menu open refreshes", () => { + it("refreshes the composer branch-selector refs when its menu opens", () => { + const refreshBranchRefs = vi.fn(); + + refreshVcsRefsOnMenuOpen(true, refreshBranchRefs); + + expect(refreshBranchRefs).toHaveBeenCalledOnce(); + }); + + it("refreshes both local and remote Diff comparison refs when its menu opens", () => { + const refreshLocalRefs = vi.fn(); + const refreshRemoteRefs = vi.fn(); + + refreshVcsRefsOnMenuOpen(true, refreshLocalRefs, refreshRemoteRefs); + + expect(refreshLocalRefs).toHaveBeenCalledOnce(); + expect(refreshRemoteRefs).toHaveBeenCalledOnce(); + }); + + it("does not refresh refs when either menu closes", () => { + const refreshLocalRefs = vi.fn(); + const refreshRemoteRefs = vi.fn(); + + refreshVcsRefsOnMenuOpen(false, refreshLocalRefs, refreshRemoteRefs); + + expect(refreshLocalRefs).not.toHaveBeenCalled(); + expect(refreshRemoteRefs).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/vcsRefMenuRefresh.ts b/apps/web/src/components/vcsRefMenuRefresh.ts new file mode 100644 index 00000000000..36be5d5807d --- /dev/null +++ b/apps/web/src/components/vcsRefMenuRefresh.ts @@ -0,0 +1,9 @@ +type RefreshVcsRefs = () => void; + +export function refreshVcsRefsOnMenuOpen( + open: boolean, + ...refreshes: ReadonlyArray +): void { + if (!open) return; + for (const refresh of refreshes) refresh(); +} From c58f8ecf45237ded5a4c3206cbbe6a9a96145fe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 23 Jul 2026 14:53:31 +0100 Subject: [PATCH 6/9] docs: clarify ref refresh ownership --- BRANCH_DETAILS.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index e00cf781368..07bc2164227 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -5,7 +5,8 @@ Frequently mounted Git-ref and preview-discovery surfaces stay fresh without con 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. -- Opening the composer branch selector or Diff panel comparison-ref menu explicitly refreshes local and remote refs, so user interaction does not wait for the background interval. +- The composer branch selector's open-triggered ref refresh is provided by upstream. The branch-owned Diff panel comparison-ref menu explicitly refreshes both local and remote refs on open, so user interaction does not wait for the background interval. +- Both menus use the branch's shared open-only refresh helper. Closing either menu resets its query state without triggering another ref refresh. - 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. @@ -14,11 +15,17 @@ Expected behavior: 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 lives in `apps/server/src/preview/PortScanner.test.ts`. Keep coverage for snapshot replay without rescanning, ordered replay during concurrent broadcasts, and unchanged terminal registrations avoiding redundant probes. +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 one composer-selector refresh, local and remote Diff comparison-ref refreshes, and no refresh on menu close. +- `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 From 9b39a60f6dc00496feb14f135dc775b5794918e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 23 Jul 2026 15:06:00 +0100 Subject: [PATCH 7/9] Clarify ref refresh provenance and test scope - Distinguish upstream behavior origin from branch helper ownership - Describe helper unit coverage without implying component integration --- BRANCH_DETAILS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 07bc2164227..8f58b76cb18 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -5,8 +5,8 @@ Frequently mounted Git-ref and preview-discovery surfaces stay fresh without con 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 is provided by upstream. The branch-owned Diff panel comparison-ref menu explicitly refreshes both local and remote refs on open, so user interaction does not wait for the background interval. -- Both menus use the branch's shared open-only refresh helper. Closing either menu resets its query state without triggering another ref refresh. +- 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. @@ -24,7 +24,7 @@ Primary files: 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 one composer-selector refresh, local and remote Diff comparison-ref refreshes, and no refresh on menu close. +- `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 From 33063a2f2a30539abc3685f6980248e7f16db5e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 24 Jul 2026 17:38:25 +0100 Subject: [PATCH 8/9] Roll back failed preview listener subscriptions - Remove listeners immediately when initial snapshot replay fails - Cover continued broadcasts after a defective replay --- BRANCH_DETAILS.md | 3 +- apps/server/src/preview/PortScanner.test.ts | 52 +++++++++++++++++++++ apps/server/src/preview/PortScanner.ts | 21 +++++---- 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 8f58b76cb18..80847cf52d5 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -9,6 +9,7 @@ Expected behavior: - 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. +- A failed initial snapshot replay rolls back listener registration before releasing the notification lock, so the failed subscriber cannot block later broadcasts to healthy subscribers. - 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. @@ -25,7 +26,7 @@ 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. +- `apps/server/src/preview/PortScanner.test.ts` covers snapshot replay without rescanning, ordered replay during concurrent broadcasts, failed-replay listener cleanup, and unchanged terminal registrations avoiding redundant probes. ## Development Ports diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index fb0ac2acbc2..fbe3c186cf0 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -265,3 +265,55 @@ effectIt("serializes snapshot replay with concurrent broadcasts", () => }).pipe(Effect.provide(layer)); }), ); + +effectIt("removes listeners when initial snapshot replay fails", () => { + const defect = new Error("snapshot replay failed"); + const healthyDeliveries: Array> = []; + let failedListenerCalls = 0; + let probeCount = 0; + const layer = makeProbeFailureLayer(() => + Effect.sync(() => { + probeCount += 1; + return { + stdout: probeCount === 1 ? "p100\ncnode\nn*:3000\n" : "p101\ncnode\nn*:3001\n", + stderr: "", + code: null, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + ); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + yield* scanner.retain; + + const failedSubscription = yield* scanner + .subscribe(() => + Effect.sync(() => { + failedListenerCalls += 1; + throw defect; + }), + ) + .pipe(Effect.exit); + expect(Exit.isFailure(failedSubscription)).toBe(true); + if (Exit.isFailure(failedSubscription)) { + expect(Cause.squash(failedSubscription.cause)).toBe(defect); + } + + yield* scanner.subscribe((servers) => + Effect.sync(() => { + healthyDeliveries.push(servers.map((server) => server.port)); + }), + ); + yield* scanner.registerTerminalProcesses({ + threadId: "thread-1", + terminalId: "terminal-1", + processIds: [101], + }); + + expect(failedListenerCalls).toBe(1); + expect(healthyDeliveries).toEqual([[3000], [3001]]); + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index a0c3bf3a1ec..e272d47e4af 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -360,6 +360,13 @@ export const make = Effect.gen(function* PortDiscoveryMake() { })), ); + const removeListener = (listener: Listener) => + Ref.update(stateRef, (state) => { + const listeners = new Set(state.listeners); + listeners.delete(listener); + return { ...state, listeners }; + }); + const subscribe: PortDiscovery["Service"]["subscribe"] = Effect.fn("PortDiscovery.subscribe")( (listener) => Effect.acquireRelease( @@ -370,16 +377,12 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ...state, listeners: new Set([...state.listeners, listener]), }, - ]).pipe(Effect.tap(listener)), - ), - () => - notificationLock.withPermit( - Ref.update(stateRef, (state) => { - const listeners = new Set(state.listeners); - listeners.delete(listener); - return { ...state, listeners }; - }), + ]).pipe( + Effect.tap(listener), + Effect.onError(() => removeListener(listener)), ), + ), + () => notificationLock.withPermit(removeListener(listener)), ), ); From 1863288e682f8ba4d5d48c2125f87d3373451ba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 24 Jul 2026 17:44:43 +0100 Subject: [PATCH 9/9] Serialize preview port scans - Prevent slow scans from overwriting newer snapshots - Cover concurrent scan ordering with a deterministic regression --- BRANCH_DETAILS.md | 3 +- apps/server/src/preview/PortScanner.test.ts | 65 +++++++++++++++++++++ apps/server/src/preview/PortScanner.ts | 11 +++- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 80847cf52d5..b81af628c90 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -10,6 +10,7 @@ Expected behavior: - 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. - A failed initial snapshot replay rolls back listener registration before releasing the notification lock, so the failed subscriber cannot block later broadcasts to healthy subscribers. +- Port scans are serialized before publication, so an older slow scan cannot finish after a newer scan and overwrite its snapshot. - 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. @@ -26,7 +27,7 @@ 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, failed-replay listener cleanup, and unchanged terminal registrations avoiding redundant probes. +- `apps/server/src/preview/PortScanner.test.ts` covers snapshot replay without rescanning, ordered replay during concurrent broadcasts, failed-replay listener cleanup, serialized concurrent scans, and unchanged terminal registrations avoiding redundant probes. ## Development Ports diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index fbe3c186cf0..c56482154ec 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -317,3 +317,68 @@ effectIt("removes listeners when initial snapshot replay fails", () => { expect(healthyDeliveries).toEqual([[3000], [3001]]); }).pipe(Effect.provide(layer)); }); + +effectIt("serializes concurrent scans before publishing snapshots", () => + Effect.gen(function* () { + const secondProbeStarted = yield* Deferred.make(); + const releaseSecondProbe = yield* Deferred.make(); + const thirdProbeStarted = yield* Deferred.make(); + const deliveries: Array> = []; + let probeCount = 0; + const layer = makeProbeFailureLayer(() => + Effect.gen(function* () { + probeCount += 1; + if (probeCount === 2) { + yield* Deferred.succeed(secondProbeStarted, undefined).pipe(Effect.ignore); + yield* Deferred.await(releaseSecondProbe); + } + if (probeCount === 3) { + yield* Deferred.succeed(thirdProbeStarted, undefined).pipe(Effect.ignore); + } + return { + stdout: `p${100 + probeCount}\ncnode\nn*:${2999 + probeCount}\n`, + stderr: "", + code: null, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + ); + + yield* Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + yield* scanner.retain; + yield* scanner.subscribe((servers) => + Effect.sync(() => { + deliveries.push(servers.map((server) => server.port)); + }), + ); + + const firstRegistration = yield* scanner + .registerTerminalProcesses({ + threadId: "thread-1", + terminalId: "terminal-1", + processIds: [101], + }) + .pipe(Effect.forkScoped); + yield* Deferred.await(secondProbeStarted); + + const secondRegistration = yield* scanner + .registerTerminalProcesses({ + threadId: "thread-1", + terminalId: "terminal-2", + processIds: [102], + }) + .pipe(Effect.forkScoped); + yield* Effect.yieldNow; + expect(yield* Deferred.isDone(thirdProbeStarted)).toBe(false); + + yield* Deferred.succeed(releaseSecondProbe, undefined); + yield* Fiber.join(firstRegistration); + yield* Fiber.join(secondRegistration); + + expect(deliveries).toEqual([[3000], [3001], [3002]]); + }).pipe(Effect.provide(layer)); + }), +); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index e272d47e4af..ecb58a38eb7 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -194,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 scanLock = yield* Semaphore.make(1); const notificationLock = yield* Semaphore.make(1); const stateRef = yield* Ref.make({ lastSnapshot: [], @@ -314,9 +315,13 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const pollTick = Effect.fn("PortDiscovery.pollTick")( function* () { - if ((yield* Ref.get(stateRef)).retainCount <= 0) return; - const next = yield* scanOnce(); - yield* publishSnapshot(next); + yield* scanLock.withPermit( + Effect.gen(function* () { + if ((yield* Ref.get(stateRef)).retainCount <= 0) return; + const next = yield* scanOnce(); + yield* publishSnapshot(next); + }), + ); }, Effect.catchCause((cause: Cause.Cause) => Effect.logWarning("preview port scan failed", Cause.pretty(cause)),