Merge commit 'b04c695750e9859e0d8bf79f503279bc4d656925' into brennanb2025/mobile-i18n-audit

This commit is contained in:
Brennan Benson
2026-08-01 12:19:48 -07:00
35 changed files with 3411 additions and 210 deletions
+1
View File
@@ -99,6 +99,7 @@ docs/**
!docs/reference/git-compatibility.md
!docs/reference/headless-linux-server.md
!docs/reference/linux-glibc-compatibility.md
!docs/reference/relay-grace-time-reconfiguration.md
# Stably CLI (only docs/ are tracked)
.stably/*
@@ -32,6 +32,11 @@ import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesyste
import { stat } from 'node:fs/promises'
import { subscribe as subscribeParcelWatcher } from '@parcel/watcher'
import type { Event as WatcherEvent } from '@parcel/watcher'
import type { FsChangedPayload } from '../../shared/types'
import {
WATCH_BATCH_MAX_WAIT_MS,
WATCH_BATCH_TRAILING_MS
} from '../../shared/filesystem-watch-batch-window'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => Promise<unknown> | unknown>
@@ -111,4 +116,61 @@ describe('local filesystem watcher large batches', () => {
await closeAllWatchers()
vi.useRealTimers()
})
it('still coalesces a local burst on the shared trailing window', async () => {
vi.useFakeTimers()
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
let watcherCallback: ((err: Error | null, events: WatcherEvent[]) => void) | undefined
vi.mocked(subscribeParcelWatcher).mockImplementation(async (_root, callback) => {
watcherCallback = callback as typeof watcherCallback
return { unsubscribe: vi.fn() } as never
})
const worktreePath = resolve('/tmp/repo')
const filePath = join(worktreePath, 'a.ts')
const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
await handlers['fs:watchWorktree']({ sender }, { worktreePath })
watcherCallback?.(null, [{ type: 'update', path: filePath }])
watcherCallback?.(null, [{ type: 'update', path: filePath }])
await vi.advanceTimersByTimeAsync(WATCH_BATCH_TRAILING_MS - 1)
expect(sender.send).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(sender.send).toHaveBeenCalledTimes(1)
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath,
events: [{ kind: 'update', absolutePath: filePath, isDirectory: true }]
})
await closeAllWatchers()
vi.useRealTimers()
})
it('still flushes a sustained local stream at the shared max wait', async () => {
vi.useFakeTimers()
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
let watcherCallback: ((err: Error | null, events: WatcherEvent[]) => void) | undefined
vi.mocked(subscribeParcelWatcher).mockImplementation(async (_root, callback) => {
watcherCallback = callback as typeof watcherCallback
return { unsubscribe: vi.fn() } as never
})
const worktreePath = resolve('/tmp/repo')
const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
await handlers['fs:watchWorktree']({ sender }, { worktreePath })
// Step under the trailing window so only the max wait can force a flush.
const step = 100
for (let elapsed = 0; elapsed <= WATCH_BATCH_MAX_WAIT_MS; elapsed += step) {
watcherCallback?.(null, [{ type: 'update', path: join(worktreePath, `f-${elapsed}.ts`) }])
if (elapsed < WATCH_BATCH_MAX_WAIT_MS) {
expect(sender.send).not.toHaveBeenCalled()
}
await vi.advanceTimersByTimeAsync(step)
}
expect(sender.send).toHaveBeenCalledTimes(1)
expect((sender.send.mock.calls[0][1] as FsChangedPayload).events).toHaveLength(6)
await closeAllWatchers()
vi.useRealTimers()
})
})
@@ -0,0 +1,191 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FsChangeEvent } from '../../shared/types'
const { handleMock, getSshFilesystemProviderMock, providerRegistrationListeners } = vi.hoisted(
() => ({
handleMock: vi.fn(),
getSshFilesystemProviderMock: vi.fn(),
providerRegistrationListeners: new Set<(connectionId: string) => void>()
})
)
vi.mock('electron', () => ({ ipcMain: { handle: handleMock } }))
vi.mock('fs/promises', () => ({ stat: vi.fn() }))
vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() }))
vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() }))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: getSshFilesystemProviderMock,
onSshFilesystemProviderRegistered: (listener: (connectionId: string) => void) => {
providerRegistrationListeners.add(listener)
return () => providerRegistrationListeners.delete(listener)
}
}))
import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => Promise<unknown> | unknown>
type WatchCallback = (events: FsChangeEvent[]) => void
const WORKTREE_PATH = '/home/me/repo'
const WATCH_ARGS = { worktreePath: WORKTREE_PATH, connectionId: 'conn-1' }
describe('remote filesystem watcher batching', () => {
const handlers: HandlerMap = {}
const watchCallbacks: WatchCallback[] = []
function makeSender(overrides: Partial<{ isDestroyed: () => boolean }> = {}) {
return { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1, ...overrides }
}
beforeEach(async () => {
vi.useRealTimers()
handleMock.mockReset()
getSshFilesystemProviderMock.mockReset()
watchCallbacks.length = 0
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
handleMock.mockImplementation((channel, handler) => {
handlers[channel] = handler
})
registerFilesystemWatcherHandlers()
await closeAllWatchers()
getSshFilesystemProviderMock.mockReturnValue({
watch: vi.fn(async (_root: string, callback: WatchCallback) => {
watchCallbacks.push(callback)
return vi.fn()
})
})
})
it('coalesces a burst of remote events into a single fs:changed send', async () => {
vi.useFakeTimers()
const sender = makeSender()
await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS)
for (let i = 0; i < 50; i++) {
watchCallbacks[0]([{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }])
}
expect(sender.send).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(150)
expect(sender.send).toHaveBeenCalledTimes(1)
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: WORKTREE_PATH,
events: [{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }]
})
await closeAllWatchers()
vi.useRealTimers()
})
// An atomic replace (delete+create) followed by an rm inside one window must still tombstone the path.
it('sends the net delete when a remote replace is removed in the same window', async () => {
vi.useFakeTimers()
const sender = makeSender()
await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS)
watchCallbacks[0]([
{ kind: 'delete', absolutePath: `${WORKTREE_PATH}/dist/app.js` },
{ kind: 'create', absolutePath: `${WORKTREE_PATH}/dist/app.js` }
])
watchCallbacks[0]([{ kind: 'delete', absolutePath: `${WORKTREE_PATH}/dist/app.js` }])
await vi.advanceTimersByTimeAsync(150)
expect(sender.send).toHaveBeenCalledTimes(1)
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: WORKTREE_PATH,
events: [{ kind: 'delete', absolutePath: `${WORKTREE_PATH}/dist/app.js` }]
})
await closeAllWatchers()
vi.useRealTimers()
})
it('collapses an oversized remote burst into one overflow payload', async () => {
vi.useFakeTimers()
const sender = makeSender()
await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS)
watchCallbacks[0](
Array.from(
{ length: 6_000 },
(_unused, index): FsChangeEvent => ({
kind: 'update',
absolutePath: `${WORKTREE_PATH}/file-${index}.ts`
})
)
)
await vi.advanceTimersByTimeAsync(150)
expect(sender.send).toHaveBeenCalledTimes(1)
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: WORKTREE_PATH,
events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }]
})
await closeAllWatchers()
vi.useRealTimers()
})
it('sends nothing when the worktree is unwatched before the flush deadline', async () => {
vi.useFakeTimers()
const sender = makeSender()
await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS)
watchCallbacks[0]([{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }])
await handlers['fs:unwatchWorktree']({ sender }, WATCH_ARGS)
await vi.advanceTimersByTimeAsync(1_000)
expect(sender.send).not.toHaveBeenCalled()
await closeAllWatchers()
vi.useRealTimers()
})
it('sends nothing when all watchers close before the flush deadline', async () => {
vi.useFakeTimers()
const sender = makeSender()
await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS)
watchCallbacks[0]([{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }])
await closeAllWatchers()
await vi.advanceTimersByTimeAsync(1_000)
expect(sender.send).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('skips a WebContents destroyed while the batch was buffering', async () => {
vi.useFakeTimers()
let destroyed = false
const sender = makeSender({ isDestroyed: () => destroyed })
await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS)
watchCallbacks[0]([{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }])
destroyed = true
await vi.advanceTimersByTimeAsync(1_000)
expect(sender.send).not.toHaveBeenCalled()
await closeAllWatchers()
vi.useRealTimers()
})
it('drops a stale flush once a newer install owns the key', async () => {
vi.useFakeTimers()
const sender = makeSender()
await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS)
for (const listener of providerRegistrationListeners) {
listener('conn-1')
}
await vi.advanceTimersByTimeAsync(1_000)
expect(watchCallbacks).toHaveLength(2)
sender.send.mockClear()
// The replaced transport's callback still holds its own batch; its flush must not reach the renderer.
watchCallbacks[0]([{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }])
await vi.advanceTimersByTimeAsync(1_000)
expect(sender.send).not.toHaveBeenCalled()
await closeAllWatchers()
vi.useRealTimers()
})
})
@@ -113,7 +113,8 @@ describe('remote filesystem watcher cancellation', () => {
await Promise.all([first, second])
expect(watchMock).toHaveBeenCalledTimes(2)
secondCallback?.([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }])
expect(secondSender.send).toHaveBeenCalledTimes(1)
// Why: remote fs:changed rides the shared debounce window, so the send lands a flush later.
await vi.waitFor(() => expect(secondSender.send).toHaveBeenCalledTimes(1))
handlers['fs:unwatchWorktree']({ sender: { id: 2 } }, args)
expect(secondUnwatch).toHaveBeenCalledTimes(1)
+20 -11
View File
@@ -58,9 +58,18 @@ import {
WatcherChildCapacityError
} from './parcel-watcher-child-registry'
import { acquireWatcherRemovalGate } from './watcher-removal-gate'
import { WATCH_BATCH_TRAILING_MS } from '../../shared/filesystem-watch-batch-window'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => Promise<unknown> | unknown>
/** Remote fs:changed rides the shared debounce window, so drain it before asserting sends. */
const emitRemote = async (onEvents: (e: unknown[]) => void, events: unknown[]) => {
onEvents(events)
await (vi.isFakeTimers()
? vi.advanceTimersByTimeAsync(WATCH_BATCH_TRAILING_MS)
: new Promise((resolve) => setTimeout(resolve, WATCH_BATCH_TRAILING_MS + 25)))
}
describe('registerFilesystemWatcherHandlers', () => {
const handlers: HandlerMap = {}
const originalPlatform = process.platform
@@ -234,7 +243,7 @@ describe('registerFilesystemWatcherHandlers', () => {
onTerminalError: expect.any(Function)
})
const onEvents = watchMock.mock.calls[0][1]
onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }])
await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }])
expect(sendMock).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ path: '/home/me/repo/file.txt', type: 'update' }]
@@ -329,7 +338,7 @@ describe('registerFilesystemWatcherHandlers', () => {
expect(watchMock).toHaveBeenCalledTimes(2)
const recoveredEvents = watchMock.mock.calls[1][1] as (events: unknown[]) => void
recoveredEvents([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }])
await emitRemote(recoveredEvents, [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }])
expect(senderOne.send).not.toHaveBeenCalled()
expect(senderTwo.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
@@ -369,7 +378,7 @@ describe('registerFilesystemWatcherHandlers', () => {
)
const reinstalledEvents = watchMock.mock.calls[1][1] as (events: unknown[]) => void
reinstalledEvents([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }])
await emitRemote(reinstalledEvents, [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }])
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]
@@ -552,7 +561,7 @@ describe('registerFilesystemWatcherHandlers', () => {
expect(watchMock).toHaveBeenCalledTimes(1)
const onEvents = watchMock.mock.calls[0][1]
onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }])
await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }])
expect(sendOne).toHaveBeenCalledTimes(1)
expect(sendTwo).toHaveBeenCalledTimes(1)
@@ -621,7 +630,7 @@ describe('registerFilesystemWatcherHandlers', () => {
events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }]
})
const replacementEvents = watchMock.mock.calls[1][1] as (events: unknown[]) => void
replacementEvents([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }])
await emitRemote(replacementEvents, [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }])
expect(sender.send).toHaveBeenLastCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]
@@ -716,7 +725,7 @@ describe('registerFilesystemWatcherHandlers', () => {
'still watched by another client'
)
const onEvents = watchMock.mock.calls[0][1]
onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }])
await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }])
expect(sendOne).toHaveBeenCalledTimes(1)
await handlers['fs:watchWorktree'](
@@ -724,7 +733,7 @@ describe('registerFilesystemWatcherHandlers', () => {
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
)
expect(watchMock).toHaveBeenCalledTimes(1)
onEvents([{ path: '/home/me/repo/file-2.txt', type: 'update' }])
await emitRemote(onEvents, [{ path: '/home/me/repo/file-2.txt', type: 'update' }])
expect(sendOne).toHaveBeenCalledTimes(2)
expect(sendTwo).toHaveBeenCalledTimes(1)
@@ -763,7 +772,7 @@ describe('registerFilesystemWatcherHandlers', () => {
await Promise.all([firstWatch, secondWatch])
const onEvents = watchMock.mock.calls[0][1]
onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }])
await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }])
expect(sendOne).toHaveBeenCalledTimes(1)
expect(sendTwo).toHaveBeenCalledTimes(1)
@@ -809,7 +818,7 @@ describe('registerFilesystemWatcherHandlers', () => {
await Promise.all([firstWatch, secondWatch])
const onEvents = watchMock.mock.calls[0][1]
onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }])
await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }])
expect(sendOne).toHaveBeenCalledTimes(1)
expect(sendTwo).not.toHaveBeenCalled()
@@ -855,7 +864,7 @@ describe('registerFilesystemWatcherHandlers', () => {
expect(unwatchMock).toHaveBeenCalledTimes(1)
const onEvents = watchMock.mock.calls[0][1]
onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }])
await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }])
expect(sender.send).not.toHaveBeenCalled()
})
@@ -886,7 +895,7 @@ describe('registerFilesystemWatcherHandlers', () => {
await Promise.all([firstWatch, secondWatch])
const onEvents = watchMock.mock.calls[0][1]
onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }])
await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }])
expect(senderOne.send).not.toHaveBeenCalled()
expect(senderTwo.send).toHaveBeenCalledTimes(1)
+58 -19
View File
@@ -16,6 +16,14 @@ import {
onSshFilesystemProviderRegistered
} from '../providers/ssh-filesystem-dispatch'
import { MAX_BATCHED_WATCHER_EVENTS, queueWatcherEvents } from './filesystem-watcher-event-batch'
import {
createRemoteWatcherEventBatch,
type RemoteWatcherEventBatch
} from './remote-watcher-event-batch'
import {
WATCH_BATCH_MAX_WAIT_MS,
WATCH_BATCH_TRAILING_MS
} from '../../shared/filesystem-watch-batch-window'
import { disposeWatcherProcess, subscribeViaWatcherProcess } from './parcel-watcher-process'
import { isWatcherProcessFailure } from './parcel-watcher-process-failure'
import {
@@ -32,11 +40,6 @@ import {
// Why: suppress high-churn dirs at the watcher level (separate from the File Explorer display filter, which only hides rows).
import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore'
// ── Debounce helpers ─────────────────────────────────────────────────
const DEBOUNCE_TRAILING_MS = 150
const DEBOUNCE_MAX_WAIT_MS = 500
// ── Per-root watcher state ───────────────────────────────────────────
// WatchedRoot/WatcherSubscription live in filesystem-watcher-wsl.ts so native and WSL watchers share one shape.
@@ -349,7 +352,7 @@ function scheduleBatchFlush(root: WatchedRoot): void {
}
// If we've exceeded the max wait, flush immediately
if (now - root.batch.firstEventAt >= DEBOUNCE_MAX_WAIT_MS) {
if (now - root.batch.firstEventAt >= WATCH_BATCH_MAX_WAIT_MS) {
if (root.batch.timer) {
clearTimeout(root.batch.timer)
}
@@ -361,7 +364,7 @@ function scheduleBatchFlush(root: WatchedRoot): void {
if (root.batch.timer) {
clearTimeout(root.batch.timer)
}
root.batch.timer = setTimeout(() => void flushBatch(root), DEBOUNCE_TRAILING_MS)
root.batch.timer = setTimeout(() => void flushBatch(root), WATCH_BATCH_TRAILING_MS)
}
// ── Watcher creation ─────────────────────────────────────────────────
@@ -944,6 +947,8 @@ type RemoteWatcherState = {
unwatch: () => void
listeners: Map<number, WebContents>
installToken: RemoteWatcherInstallToken
/** Held so every teardown can drop the trailing flush timer instead of letting it fire into a dead watch. */
batch: RemoteWatcherEventBatch
}
type RemoteWatcherInstallToken = {
@@ -1049,6 +1054,7 @@ export async function closeRemoteWatcherForWorktreePath(
await (provider?.closeWatch
? provider.closeWatch(worktreePath)
: Promise.resolve(state?.unwatch()))
state?.batch.close()
remoteWatchers.delete(key)
loggedUnavailableRemoteWatchers.delete(key)
}
@@ -1161,6 +1167,7 @@ function releaseRemoteWatchListener(key: string, senderId: number): void {
return
}
state.unwatch()
state.batch.close()
remoteWatchers.delete(key)
}
@@ -1287,23 +1294,47 @@ async function doInstallRemoteWatcher(
cancelToken: RemoteWatcherInstallToken
): Promise<RemoteWatcherInstallResult> {
let unwatch: () => void
try {
unwatch = await provider.watch(
worktreePath,
(events) => {
const state = remoteWatchers.get(key)
if (!state) {
return
// Why: the abandon paths below close the batch directly; once it is stored on the remoteWatchers
// entry, every later teardown closes it through that handle. deliver's state/installToken guard
// still covers a flush that races a close.
const batch = createRemoteWatcherEventBatch({
rootPath: worktreePath,
trailingMs: WATCH_BATCH_TRAILING_MS,
maxWaitMs: WATCH_BATCH_MAX_WAIT_MS,
maxEvents: MAX_BATCHED_WATCHER_EVENTS,
deliver: (events) => {
const state = remoteWatchers.get(key)
// Why: buffering defers delivery past install, so a bare non-null check would let a stale flush
// land on a different install generation.
if (!state || state.installToken !== cancelToken) {
return
}
for (const listener of state.listeners.values()) {
if (listener.isDestroyed()) {
continue
}
for (const listener of state.listeners.values()) {
if (listener.isDestroyed()) {
continue
}
try {
listener.send('fs:changed', {
worktreePath,
events
} satisfies FsChangedPayload)
} catch (err) {
// Why: batching moved this send out of the SSH mux's notification try/catch into a bare
// timer, so a frame disposed mid-window would escape as a fatal main-process exception.
console.warn(
`[filesystem-watch] failed to deliver remote fs:changed for ${worktreePath}: ${
err instanceof Error ? err.message : String(err)
}`
)
}
}
}
})
try {
unwatch = await provider.watch(
worktreePath,
(events) => {
batch.push(events)
},
{
signal: cancelToken.abortController.signal,
@@ -1312,6 +1343,9 @@ async function doInstallRemoteWatcher(
}
)
} catch (err) {
// Why: the provider can fire its callback before rejecting (an aborted reinstall), so this
// abandon path owes the same close as the two below — nothing else holds the batch.
batch.close()
if (cancelToken.cancelled || cancelToken.abortController.signal.aborted) {
return 'cancelled'
}
@@ -1326,6 +1360,7 @@ async function doInstallRemoteWatcher(
Array.from(cancelToken.listeners.entries()).filter(([, listener]) => !listener.isDestroyed())
)
if (cancelToken.cancelled || liveListeners.size === 0) {
batch.close()
try {
unwatch()
} catch (err) {
@@ -1334,9 +1369,10 @@ async function doInstallRemoteWatcher(
return 'cancelled'
}
if (cancelToken.terminalError) {
batch.close()
return 'unavailable'
}
remoteWatchers.set(key, { unwatch, listeners: liveListeners, installToken: cancelToken })
remoteWatchers.set(key, { unwatch, listeners: liveListeners, installToken: cancelToken, batch })
for (const listener of liveListeners.values()) {
registerSenderCleanup(listener)
}
@@ -1357,6 +1393,7 @@ function handleRemoteWatcherTerminalError(
return
}
remoteWatchers.delete(key)
state.batch.close()
if (remoteWatchersClosed || suspendedRemoteWatcherListeners.has(key)) {
return
}
@@ -1779,6 +1816,7 @@ function reinstallRemoteWatchersForConnection(connectionId: string): void {
const stale = remoteWatchers.get(key)
if (stale) {
remoteWatchers.delete(key)
stale.batch.close()
try {
stale.unwatch()
} catch {
@@ -1912,6 +1950,7 @@ export async function closeAllWatchers(): Promise<void> {
// Why: remote watchers are separate from local @parcel/watcher subs; unwatch here or the relay keeps polling FS after shutdown.
for (const [key, state] of remoteWatchers) {
state.batch.close()
try {
state.unwatch()
} catch (err) {
@@ -0,0 +1,270 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FsChangeEvent } from '../../shared/types'
import { createRemoteWatcherEventBatch } from './remote-watcher-event-batch'
const ROOT = '/home/u/repo'
function makeBatch(deliver: (events: FsChangeEvent[]) => void, maxEvents = 5_000) {
return createRemoteWatcherEventBatch({
rootPath: ROOT,
deliver,
trailingMs: 150,
maxWaitMs: 500,
maxEvents
})
}
describe('createRemoteWatcherEventBatch', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('collapses many pushes inside the trailing window into one delivery', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
for (let i = 0; i < 50; i++) {
batch.push([{ kind: 'update', absolutePath: `${ROOT}/file-${i}.ts` }])
vi.advanceTimersByTime(1)
}
expect(deliver).not.toHaveBeenCalled()
vi.advanceTimersByTime(150)
expect(deliver).toHaveBeenCalledTimes(1)
expect(deliver.mock.calls[0][0]).toHaveLength(50)
})
it('flushes at the max wait when pushes never stop', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
for (let i = 0; i < 10; i++) {
batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }])
vi.advanceTimersByTime(100)
}
expect(deliver).toHaveBeenCalled()
expect(deliver.mock.calls[0][0]).toEqual([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }])
})
it('emits both events for delete then create, delete first', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([
{ kind: 'delete', absolutePath: `${ROOT}/a.ts` },
{ kind: 'create', absolutePath: `${ROOT}/a.ts` }
])
vi.advanceTimersByTime(150)
expect(deliver.mock.calls[0][0]).toEqual([
{ kind: 'delete', absolutePath: `${ROOT}/a.ts` },
{ kind: 'create', absolutePath: `${ROOT}/a.ts` }
])
})
it('drops both events for create then delete', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([
{ kind: 'create', absolutePath: `${ROOT}/tmp.ts` },
{ kind: 'delete', absolutePath: `${ROOT}/tmp.ts` }
])
vi.advanceTimersByTime(150)
expect(deliver).not.toHaveBeenCalled()
})
it('clears a stale delete when a later update is known to be a file', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([
{ kind: 'delete', absolutePath: `${ROOT}/a.ts` },
{ kind: 'create', absolutePath: `${ROOT}/a.ts` },
{ kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false }
])
vi.advanceTimersByTime(150)
expect(deliver.mock.calls[0][0]).toEqual([
{ kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false }
])
})
it('keeps the delete when a later update has unknown isDirectory', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([
{ kind: 'delete', absolutePath: `${ROOT}/dist` },
{ kind: 'create', absolutePath: `${ROOT}/dist` },
{ kind: 'update', absolutePath: `${ROOT}/dist` }
])
vi.advanceTimersByTime(150)
expect(deliver.mock.calls[0][0]).toEqual([
{ kind: 'delete', absolutePath: `${ROOT}/dist` },
{ kind: 'update', absolutePath: `${ROOT}/dist` }
])
})
it('emits the net delete for delete then create then delete', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([
{ kind: 'delete', absolutePath: `${ROOT}/dist/app.js` },
{ kind: 'create', absolutePath: `${ROOT}/dist/app.js` },
{ kind: 'delete', absolutePath: `${ROOT}/dist/app.js` }
])
vi.advanceTimersByTime(150)
expect(deliver).toHaveBeenCalledTimes(1)
expect(deliver.mock.calls[0][0]).toEqual([
{ kind: 'delete', absolutePath: `${ROOT}/dist/app.js` }
])
})
it('re-hoists the delete when the window ends on a recreate', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([
{ kind: 'delete', absolutePath: `${ROOT}/a.ts` },
{ kind: 'create', absolutePath: `${ROOT}/a.ts` },
{ kind: 'delete', absolutePath: `${ROOT}/a.ts` },
{ kind: 'create', absolutePath: `${ROOT}/a.ts` }
])
vi.advanceTimersByTime(150)
expect(deliver.mock.calls[0][0]).toEqual([
{ kind: 'delete', absolutePath: `${ROOT}/a.ts` },
{ kind: 'create', absolutePath: `${ROOT}/a.ts` }
])
})
it('keeps only the last update for one path', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false }])
batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false }])
vi.advanceTimersByTime(150)
expect(deliver).toHaveBeenCalledTimes(1)
expect(deliver.mock.calls[0][0]).toEqual([
{ kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false }
])
})
it('discards the buffer and delivers one overflow when an overflow event arrives', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }])
batch.push([{ kind: 'overflow', absolutePath: ROOT }])
batch.push([{ kind: 'update', absolutePath: `${ROOT}/b.ts` }])
vi.advanceTimersByTime(150)
expect(deliver).toHaveBeenCalledTimes(1)
expect(deliver.mock.calls[0][0]).toEqual([{ kind: 'overflow', absolutePath: ROOT }])
})
it('latches overflow once the accumulated batch exceeds maxEvents', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver, 100)
for (let i = 0; i < 3; i++) {
batch.push(
Array.from(
{ length: 50 },
(_unused, index): FsChangeEvent => ({
kind: 'update',
absolutePath: `${ROOT}/wave-${i}-${index}.ts`
})
)
)
}
vi.advanceTimersByTime(150)
expect(deliver).toHaveBeenCalledTimes(1)
expect(deliver.mock.calls[0][0]).toEqual([{ kind: 'overflow', absolutePath: ROOT }])
})
it('never invents isDirectory for events that arrive without it', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([{ kind: 'create', absolutePath: `${ROOT}/dir` }])
vi.advanceTimersByTime(150)
const [delivered] = deliver.mock.calls[0][0] as FsChangeEvent[]
expect(delivered.isDirectory).toBeUndefined()
expect('isDirectory' in delivered).toBe(false)
})
it('passes an unknown kind through verbatim after the coalesced set', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([
{ kind: 'rename', absolutePath: `${ROOT}/new.ts`, oldAbsolutePath: `${ROOT}/old.ts` },
{ kind: 'update', absolutePath: `${ROOT}/a.ts` }
])
vi.advanceTimersByTime(150)
expect(deliver.mock.calls[0][0]).toEqual([
{ kind: 'update', absolutePath: `${ROOT}/a.ts` },
{ kind: 'rename', absolutePath: `${ROOT}/new.ts`, oldAbsolutePath: `${ROOT}/old.ts` }
])
})
it('delivers nothing after close', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }])
batch.close()
vi.advanceTimersByTime(1_000)
expect(deliver).not.toHaveBeenCalled()
})
it('ignores a push that lands after close instead of arming a timer', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.close()
batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }])
expect(vi.getTimerCount()).toBe(0)
vi.advanceTimersByTime(1_000)
expect(deliver).not.toHaveBeenCalled()
})
// Why: the host platform is irrelevant — the coalesce key must stay shape-based, so a case-sensitive
// remote pair must survive it on any host, and neither path may pick up drive/backslash rewriting.
it('keeps case-distinct remote POSIX paths separate and byte-identical', () => {
const deliver = vi.fn()
const batch = makeBatch(deliver)
batch.push([{ kind: 'update', absolutePath: '/home/u/A' }])
batch.push([{ kind: 'update', absolutePath: '/home/u/a' }])
vi.advanceTimersByTime(150)
const delivered = deliver.mock.calls[0][0] as FsChangeEvent[]
expect(delivered).toEqual([
{ kind: 'update', absolutePath: '/home/u/A' },
{ kind: 'update', absolutePath: '/home/u/a' }
])
for (const event of delivered) {
expect(event.absolutePath).not.toContain('\\')
expect(event.absolutePath).not.toMatch(/^[a-zA-Z]:/)
}
})
})
+147
View File
@@ -0,0 +1,147 @@
import type { FsChangeEvent } from '../../shared/types'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
export type RemoteWatcherEventBatchOptions = {
rootPath: string
deliver: (events: FsChangeEvent[]) => void
trailingMs: number
maxWaitMs: number
maxEvents: number
}
export type RemoteWatcherEventBatch = {
push: (events: FsChangeEvent[]) => void
close: () => void
}
// Mirrors coalesceEvents in filesystem-watcher.ts, with two deliberate divergences: a hoisted delete
// survives a later event of unknown isDirectory, so delete→create→update over-emits a delete; and
// delete→create→delete emits that delete, which local drops — the path is gone, so the renderer needs it.
function coalesceRemoteEvents(raw: FsChangeEvent[]): FsChangeEvent[] {
const lastByKey = new Map<string, FsChangeEvent>()
const deleteBeforeCreate = new Map<string, FsChangeEvent>()
const passthrough: FsChangeEvent[] = []
for (const event of raw) {
if (event.kind !== 'create' && event.kind !== 'update' && event.kind !== 'delete') {
// Why: 'rename' has no producer on this transport; forward it untouched rather than invent a rule.
passthrough.push(event)
continue
}
// Why: key on the comparison form, never node path.resolve — resolving a remote POSIX path on a
// Windows host mints C:\home\u\f, which the renderer's watch canonicalizer then rejects outright.
// Residual cost: its NFC fold merges canonically equivalent byte-distinct names on a POSIX host.
const key = normalizeRuntimePathForComparison(event.absolutePath)
const prev = lastByKey.get(key)
if (prev) {
if (prev.kind === 'delete' && event.kind === 'create') {
deleteBeforeCreate.set(key, prev)
}
if (prev.kind === 'create' && event.kind === 'delete') {
// Why: cancel only a path created and removed entirely inside the window; an earlier delete means
// the file predates it, so the window nets out to a delete the renderer still has to act on.
const netNoOp = !deleteBeforeCreate.delete(key)
if (netNoOp) {
lastByKey.delete(key)
continue
}
}
}
lastByKey.set(key, event)
// Why: drop the hoisted delete only when the later event proves the path is a live file, or repeats the
// delete. Remote events carry no isDirectory, and discarding it there strands a dead dir's cached subtree.
if (event.kind === 'delete' || (event.kind === 'update' && event.isDirectory === false)) {
deleteBeforeCreate.delete(key)
}
}
return [...deleteBeforeCreate.values(), ...lastByKey.values(), ...passthrough]
}
export function createRemoteWatcherEventBatch({
rootPath,
deliver,
trailingMs,
maxWaitMs,
maxEvents
}: RemoteWatcherEventBatchOptions): RemoteWatcherEventBatch {
let buffered: FsChangeEvent[] = []
let overflowed = false
let firstEventAt = 0
let timer: ReturnType<typeof setTimeout> | null = null
let closed = false
function flush(): void {
if (timer) {
clearTimeout(timer)
timer = null
}
const pending = buffered
const latched = overflowed
buffered = []
overflowed = false
firstEventAt = 0
if (latched) {
deliver([{ kind: 'overflow', absolutePath: rootPath }])
return
}
const coalesced = coalesceRemoteEvents(pending)
if (coalesced.length > 0) {
deliver(coalesced)
}
}
function schedule(): void {
const now = Date.now()
if (firstEventAt === 0) {
firstEventAt = now
}
if (now - firstEventAt >= maxWaitMs) {
flush()
return
}
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(flush, trailingMs)
}
return {
push(events) {
// Why: an in-flight provider receive can land after teardown; re-arming there would strand a timer.
if (closed) {
return
}
if (!overflowed) {
if (
buffered.length + events.length > maxEvents ||
events.some((event) => event.kind === 'overflow')
) {
// Why: past the cap precision only burns memory; one overflow buys the same conservative refresh.
buffered = []
overflowed = true
} else {
// Why: a deletion storm can exceed V8's argument limit for push(...events).
for (const event of events) {
buffered.push(event)
}
}
}
schedule()
},
close() {
closed = true
if (timer) {
clearTimeout(timer)
timer = null
}
buffered = []
overflowed = false
firstEventAt = 0
}
}
}
+488 -4
View File
@@ -32411,6 +32411,8 @@ describe('OrcaRuntimeService', () => {
]
})
// No renderer graph on purpose: headless rename attribution must work from
// the persisted session alone.
const { worktrees } = await runtime.getWorktreePs()
const oldSummary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
const renamedSummary = worktrees.find((worktree) => worktree.worktreeId === renamedWorktreeId)
@@ -32502,16 +32504,498 @@ describe('OrcaRuntimeService', () => {
}
]
})
// Why: local rows only project while their tab exists (#6072); freshness
// is what varies here, so keep the tab present in the runtime graph.
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: TEST_WORKTREE_ID,
title: 'Codex',
activeLeafId: '33333333-3333-4333-8333-333333333333',
layout: null
}
],
leaves: []
})
const { worktrees } = await runtime.getWorktreePs()
expect(worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)).toMatchObject({
hasHostSidebarActivity,
status
})
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
expect(summary).toMatchObject({ hasHostSidebarActivity, status })
// Why: inactive must mean "projected but not fresh", never "row dropped".
expect(summary?.agents).toHaveLength(1)
}
)
it('drops a hydrated done hook row after its local tab is closed', async () => {
// Why (#6072): last-status.json hydrates hook rows for days; a closed tab's
// agent must not resurface on mobile as current worktree activity.
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {}
})
const now = Date.now()
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey: 'closed-tab:66666666-6666-4666-8666-666666666666',
worktreeId: TEST_WORKTREE_ID,
tabId: 'closed-tab',
state: 'done',
prompt: 'refactor the parser',
agentType: 'claude',
connectionId: null,
receivedAt: now,
stateStartedAt: now - 60_000
}
]
})
const { worktrees } = await runtime.getWorktreePs()
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
expect(summary).toMatchObject({
liveTerminalCount: 0,
hasHostSidebarActivity: false,
status: 'inactive',
agents: []
})
})
it('keeps a hydrated row while its persisted session tab exists and no renderer graph is attached', async () => {
// Why: headless serve has no renderer graph; session.tabs.list serves this
// tab to mobile as current, so its agent row must stay (desktop parity).
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[TEST_WORKTREE_ID]: [
{
id: 'headless-tab',
ptyId: null,
worktreeId: TEST_WORKTREE_ID,
title: 'Codex',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
}
})
const now = Date.now()
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey: 'headless-tab:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
worktreeId: TEST_WORKTREE_ID,
tabId: 'headless-tab',
state: 'done',
prompt: 'finished while headless',
agentType: 'codex',
connectionId: null,
receivedAt: now,
stateStartedAt: now - 60_000
}
]
})
const { worktrees } = await runtime.getWorktreePs()
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
expect(summary?.agents).toEqual([
expect.objectContaining({ state: 'done', prompt: 'finished while headless' })
])
})
it('resolves legacy numeric pane keys through the stale filter too', async () => {
// Why: non-UUID leaves produce `tabId:paneRuntimeId` keys with no tabId
// field; they still name a real tab and must not bypass the filter.
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[TEST_WORKTREE_ID]: [
{
id: 'open-tab',
ptyId: null,
worktreeId: TEST_WORKTREE_ID,
title: 'Codex',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
}
})
const now = Date.now()
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey: 'closed-tab:7',
worktreeId: TEST_WORKTREE_ID,
state: 'done',
prompt: 'stale legacy pane',
agentType: 'codex',
connectionId: null,
receivedAt: now,
stateStartedAt: now - 60_000
},
{
paneKey: 'open-tab:9',
worktreeId: TEST_WORKTREE_ID,
state: 'working',
prompt: 'live legacy pane',
agentType: 'codex',
connectionId: null,
receivedAt: now,
stateStartedAt: now - 100
}
]
})
const { worktrees } = await runtime.getWorktreePs()
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
expect(summary?.agents).toEqual([
expect.objectContaining({ paneKey: 'open-tab:9', prompt: 'live legacy pane' })
])
})
it('keeps a local hook row while a connected PTY still backs its pane', async () => {
// Why: daemon-held terminals stay live across renderer graph gaps even when
// no session tab records them; a connected PTY is proof the pane exists.
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {}
})
const paneKey = 'daemon-tab:77777777-7777-4777-8777-777777777777'
const now = Date.now()
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey,
worktreeId: TEST_WORKTREE_ID,
tabId: 'daemon-tab',
state: 'working',
prompt: 'long-running daemon agent',
agentType: 'codex',
connectionId: null,
receivedAt: now,
stateStartedAt: now - 100
}
]
})
// paneKey-only record: the tabId rescue must not be what keeps this row.
runtime['recordPtyWorktree']('daemon-pty', TEST_WORKTREE_ID, {
connected: true,
paneKey
})
const { worktrees } = await runtime.getWorktreePs()
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
expect(summary?.agents).toEqual([
expect.objectContaining({ paneKey, state: 'working', prompt: 'long-running daemon agent' })
])
expect(summary).toMatchObject({ hasHostSidebarActivity: true, status: 'working' })
})
it('keeps a local hook row when a connected PTY matches only its tab id', async () => {
// Split-pane sibling: the PTY's paneKey names another leaf of the same tab,
// so only the tabId conjunct can rescue this row.
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {}
})
const now = Date.now()
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey: 'daemon-tab:88888888-8888-4888-8888-888888888887',
worktreeId: TEST_WORKTREE_ID,
tabId: 'daemon-tab',
state: 'working',
prompt: 'sibling pane agent',
agentType: 'codex',
connectionId: null,
receivedAt: now,
stateStartedAt: now - 100
}
]
})
runtime['recordPtyWorktree']('daemon-pty-2', TEST_WORKTREE_ID, {
connected: true,
tabId: 'daemon-tab',
paneKey: 'daemon-tab:99999999-9999-4999-8999-999999999998'
})
const { worktrees } = await runtime.getWorktreePs()
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
expect(summary?.agents).toEqual([
expect.objectContaining({ prompt: 'sibling pane agent', state: 'working' })
])
})
it('keeps a retained OSC row via its connected PTY after the pane binding is cleared', async () => {
// A controller incarnation change nulls pty.tabId/paneKey while the PTY
// stays connected (adoptControllerTerminalHandle); the ptyId conjunct is
// then the only rescue for the retained OSC row.
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {}
})
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime['recordPtyWorktree']('osc-pty', TEST_WORKTREE_ID, {
connected: true,
tabId: 'osc-tab',
paneKey: 'osc-tab:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
})
runtime.onPtyData(
'osc-pty',
'\x1b]9999;{"state":"working","prompt":"osc reporter","agentType":"codex"}\x07',
1
)
const pty = runtime['ptysById'].get('osc-pty')!
pty.tabId = null
pty.paneKey = null
const { worktrees } = await runtime.getWorktreePs()
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
expect(summary?.agents).toEqual([expect.objectContaining({ prompt: 'osc reporter' })])
})
it('keeps the connected-PTY rescue when a hook row outraces the OSC row for the same pane', async () => {
// Hook payloads carry no ptyId; the OSC-observed one must survive the
// hook row winning the freshness race or the ptyId rescue goes dead.
const paneKey = 'race-tab:dddddddd-dddd-4ddd-8ddd-dddddddddddd'
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {}
})
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey,
worktreeId: TEST_WORKTREE_ID,
tabId: 'race-tab',
state: 'working',
prompt: 'hook-fresh agent',
agentType: 'codex',
connectionId: null,
receivedAt: Date.now() + 60_000,
stateStartedAt: Date.now() - 100
}
]
})
runtime['recordPtyWorktree']('race-pty', TEST_WORKTREE_ID, {
connected: true,
tabId: 'race-tab',
paneKey
})
runtime.onPtyData(
'race-pty',
'\x1b]9999;{"state":"working","prompt":"osc ping","agentType":"codex"}\x07',
1
)
const pty = runtime['ptysById'].get('race-pty')!
pty.tabId = null
pty.paneKey = null
const { worktrees } = await runtime.getWorktreePs()
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
expect(summary?.agents).toEqual([expect.objectContaining({ prompt: 'hook-fresh agent' })])
})
it('keeps a retained OSC row from an SSH pane after its PTY disconnects', async () => {
// Why: OSC snapshots must carry the pane transport; hardcoding local would
// strip the SSH exemption off rows whose freshest update arrived via OSC.
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {}
})
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime['recordPtyWorktree']('ssh-osc-pty', TEST_WORKTREE_ID, {
connected: true,
connectionId: 'ssh-osc-1',
tabId: 'ssh-tab',
paneKey: 'ssh-tab:cccccccc-cccc-4ccc-8ccc-cccccccccccc'
})
runtime.onPtyData(
'ssh-osc-pty',
'\x1b]9999;{"state":"working","prompt":"remote osc agent","agentType":"codex"}\x07',
1
)
runtime['recordPtyWorktree']('ssh-osc-pty', TEST_WORKTREE_ID, { connected: false })
const { worktrees } = await runtime.getWorktreePs()
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
expect(summary?.agents).toEqual([expect.objectContaining({ prompt: 'remote osc agent' })])
})
it('keeps a hook row with an unresolvable pane key', async () => {
// Why: no tabId means staleness is unprovable; the filter must pass it.
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {}
})
const now = Date.now()
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey: 'opaque-key-without-structure',
worktreeId: TEST_WORKTREE_ID,
state: 'working',
prompt: 'unattributable pane',
agentType: 'codex',
connectionId: null,
receivedAt: now,
stateStartedAt: now - 100
}
]
})
const { worktrees } = await runtime.getWorktreePs()
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
expect(summary?.agents).toEqual([expect.objectContaining({ prompt: 'unattributable pane' })])
})
it('keeps a WSL hook row while its tab is still in the session', async () => {
// Guards the WSL clause against over-filtering: local-tab evidence must
// rescue WSL rows exactly like null-connection rows.
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[TEST_WORKTREE_ID]: [
{
id: 'open-wsl-tab',
ptyId: null,
worktreeId: TEST_WORKTREE_ID,
title: 'Codex',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
}
})
const now = Date.now()
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey: 'open-wsl-tab:99999999-9999-4999-8999-999999999997',
worktreeId: TEST_WORKTREE_ID,
tabId: 'open-wsl-tab',
state: 'working',
prompt: 'live in WSL',
agentType: 'codex',
connectionId: 'wsl:Ubuntu',
receivedAt: now,
stateStartedAt: now - 100
}
]
})
const { worktrees } = await runtime.getWorktreePs()
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
expect(summary?.agents).toEqual([expect.objectContaining({ prompt: 'live in WSL' })])
})
it('drops a hydrated WSL hook row after its local tab is closed', async () => {
// Why: WSL relay ids are transport provenance; the pane remains local.
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {}
})
const now = Date.now()
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey: 'closed-wsl-tab:99999999-9999-4999-8999-999999999999',
worktreeId: TEST_WORKTREE_ID,
tabId: 'closed-wsl-tab',
state: 'done',
prompt: 'finished in WSL',
agentType: 'codex',
connectionId: 'wsl:Ubuntu',
receivedAt: now,
stateStartedAt: now - 60_000
}
]
})
const { worktrees } = await runtime.getWorktreePs()
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
expect(summary?.agents).toEqual([])
})
it('keeps remote hook rows whose tabs are only tracked on the remote host', async () => {
// Why: the local session partition for an SSH host can be empty while the
// remote host owns the terminals; absence there is not proof of a close.
const remoteRepo = {
id: 'repo-ssh-6072',
path: '/home/me/project',
displayName: 'remote-vm',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-6072'
}
const remoteWorktree = {
path: '/home/me/project/.worktrees/feature-agents',
head: 'def',
branch: 'refs/heads/feature/agents',
isBare: false,
isMainWorktree: false
}
const remoteWorktreeId = `${remoteRepo.id}::${remoteWorktree.path}`
const metaById: Record<string, WorktreeMeta> = {
[remoteWorktreeId]: makeWorktreeMeta({ displayName: 'Remote agents' })
}
const runtimeStore = {
...store,
getRepos: () => [remoteRepo],
getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined),
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
getWorkspaceSession: () => getDefaultWorkspaceSession()
}
registerSshGitProvider('ssh-6072', {
listWorktrees: vi.fn().mockResolvedValue([remoteWorktree])
} as never)
const now = Date.now()
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey: 'remote-tab:88888888-8888-4888-8888-888888888888',
worktreeId: remoteWorktreeId,
tabId: 'remote-tab',
state: 'working',
prompt: 'remote agent without local tab records',
agentType: 'codex',
connectionId: 'ssh-6072',
receivedAt: now,
stateStartedAt: now - 100
}
]
})
const { worktrees } = await runtime.getWorktreePs()
const summary = worktrees.find((worktree) => worktree.worktreeId === remoteWorktreeId)
expect(summary?.agents).toEqual([
expect.objectContaining({ prompt: 'remote agent without local tab records' })
])
})
it('marks the desktop-active worktree as isActive', async () => {
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal()
+76 -6
View File
@@ -368,9 +368,15 @@ import {
} from '../../shared/setup-agent-sequencing'
import { TASK_PROVIDERS } from '../../shared/task-providers'
import { FIRST_PANE_ID } from '../../shared/pane-key'
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id'
import {
isTerminalLeafId,
makePaneKey,
parseLegacyNumericPaneKey,
parsePaneKey
} from '../../shared/stable-pane-id'
import { parseAppSshPtyId } from '../../shared/ssh-pty-id'
import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../shared/terminal-tab-id'
import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract'
import {
applyTerminalQuickCommandMutation,
MAX_QUICK_COMMANDS,
@@ -1449,6 +1455,9 @@ type RuntimeAgentRowSnapshot = {
ptyId: string
worktreeId?: string
tabId?: string
// Transport of the pane's PTY at retain time; null for local. Without it,
// OSC rows for SSH panes would lose the remote exemption in worktree.ps.
connectionId: string | null
payload: ParsedAgentStatusPayload
// When the current payload.state was first observed for this pane (ms).
stateStartedAt: number
@@ -10056,6 +10065,7 @@ export class OrcaRuntimeService {
target.paneKey,
target.worktreeId,
target.tabId,
target.connectionId ?? null,
payload
) || retainedChanged
if (!this.onTerminalAgentStatus) {
@@ -10086,6 +10096,7 @@ export class OrcaRuntimeService {
paneKey: string,
worktreeId: string | undefined,
tabId: string | undefined,
connectionId: string | null,
payload: ParsedAgentStatusPayload
): boolean {
const now = Date.now()
@@ -10100,6 +10111,7 @@ export class OrcaRuntimeService {
ptyId,
worktreeId,
tabId,
connectionId,
payload,
stateStartedAt,
updatedAt: now
@@ -16962,11 +16974,34 @@ export class OrcaRuntimeService {
}
}
// Why: a connected PTY proves a pane is still live even when its tab has
// already left every session record (daemon-held terminals, graph gaps).
// Deliberately trusts the optimistic connected flag (no freshPtyLiveness
// gate, unlike the count loops above): evidence only KEEPS rows.
const connectedPtyEvidence = {
tabIds: new Set<string>(),
paneKeys: new Set<string>(),
ptyIds: new Set<string>()
}
for (const pty of this.ptysById.values()) {
if (!pty.connected) {
continue
}
connectedPtyEvidence.ptyIds.add(pty.ptyId)
if (pty.tabId) {
connectedPtyEvidence.tabIds.add(pty.tabId)
}
if (pty.paneKey) {
connectedPtyEvidence.paneKeys.add(pty.paneKey)
}
}
this.attachAgentRowsToSummaries(
summaries,
runtimeWorktreeSummaryPathIndex,
missingRuntimeWorktreeIds,
mirroredWorktreeIdByTabId
mirroredWorktreeIdByTabId,
connectedPtyEvidence
)
const sorted = [...summaries.values()].sort(compareWorktreePs)
@@ -16985,7 +17020,12 @@ export class OrcaRuntimeService {
summaries: Map<string, RuntimeWorktreePsSummary>,
runtimeWorktreeSummaryPathIndex: RuntimeWorktreeSummaryPathIndex,
missingRuntimeWorktreeIds: Set<string>,
mirroredWorktreeIdByTabId: ReadonlyMap<string, string>
mirroredWorktreeIdByTabId: ReadonlyMap<string, string>,
connectedPtyEvidence: {
tabIds: ReadonlySet<string>
paneKeys: ReadonlySet<string>
ptyIds: ReadonlySet<string>
}
): void {
// Why: most agents report via hooks (agent-hooks/server), not OSC, so the
// hook snapshot is the primary source — same one the desktop sidebar reads.
@@ -16994,8 +17034,10 @@ export class OrcaRuntimeService {
string,
{
paneKey: string
ptyId?: string
tabId?: string
worktreeId?: string
connectionId: string | null
state: ParsedAgentStatusPayload['state']
agentType: string | null
prompt: string
@@ -17011,8 +17053,10 @@ export class OrcaRuntimeService {
const { payload } = snapshot
rowSources.set(snapshot.paneKey, {
paneKey: snapshot.paneKey,
ptyId: snapshot.ptyId,
tabId: snapshot.tabId,
worktreeId: snapshot.worktreeId,
connectionId: snapshot.connectionId,
state: payload.state,
agentType: payload.agentType ?? null,
prompt: payload.prompt,
@@ -17033,8 +17077,12 @@ export class OrcaRuntimeService {
}
rowSources.set(entry.paneKey, {
paneKey: entry.paneKey,
// Hook payloads carry no ptyId; keep the OSC-observed one so the
// connected-PTY rescue survives a hook row winning the freshness race.
ptyId: existing?.ptyId,
tabId: entry.tabId,
worktreeId: entry.worktreeId,
connectionId: entry.connectionId,
state: entry.state,
agentType: entry.agentType ?? null,
prompt: entry.prompt,
@@ -17055,9 +17103,31 @@ export class OrcaRuntimeService {
for (const src of rowSources.values()) {
// Why: hooks retain launch-time attribution across automatic workspace
// renames; the tab's current mirrored owner is authoritative when present.
const tabId = src.tabId ?? parsePaneKey(src.paneKey)?.tabId
const worktreeId =
(tabId ? mirroredWorktreeIdByTabId.get(tabId) : undefined) ?? src.worktreeId
// Legacy numeric pane keys (non-UUID leaves) still name a real tab, so
// parse them too — otherwise their rows would bypass the stale filter.
const tabId =
src.tabId ??
parsePaneKey(src.paneKey)?.tabId ??
parseLegacyNumericPaneKey(src.paneKey)?.tabId
const mirroredWorktreeId = tabId ? mirroredWorktreeIdByTabId.get(tabId) : undefined
if (
tabId !== undefined &&
mirroredWorktreeId === undefined &&
(src.connectionId === null || isWslHookRelayConnectionId(src.connectionId)) &&
!connectedPtyEvidence.tabIds.has(tabId) &&
!connectedPtyEvidence.paneKeys.has(src.paneKey) &&
(src.ptyId === undefined || !connectedPtyEvidence.ptyIds.has(src.ptyId))
) {
// Why: hook snapshots hydrate from last-status.json for days, so a row
// from a local or WSL-relayed pane whose tab left every session and the
// live graph, with no connected PTY, is retained history — surfacing it
// resurrects closed agents on mobile (#6072). SSH rows are exempt (their
// tabs may exist only remotely), as are rows with no resolvable tabId
// (staleness unprovable). Session tabs count as existence: headless
// serve has no renderer graph, and session.tabs.list serves them.
continue
}
const worktreeId = mirroredWorktreeId ?? src.worktreeId
if (!worktreeId) {
continue
}
@@ -1,8 +1,9 @@
import {
WATCH_BATCH_MAX_WAIT_MS,
WATCH_BATCH_TRAILING_MS
} from '../../../../shared/filesystem-watch-batch-window'
import type { FsChangeEvent } from '../../../../shared/types'
const FILE_WATCH_FLUSH_MS = 150
const FILE_WATCH_MAX_WAIT_MS = 500
const MAX_FILE_WATCH_BATCH_EVENTS = 5_000
import { MAX_BATCHED_WATCHER_EVENTS } from '../../../ipc/filesystem-watcher-event-batch'
export function createFileWatchEventBatcher(
worktree: string,
@@ -46,7 +47,7 @@ export function createFileWatchEventBatcher(
if (incomingOverflow) {
events = [incomingOverflow]
overflowed = true
} else if (events.length + nextEvents.length > MAX_FILE_WATCH_BATCH_EVENTS) {
} else if (events.length + nextEvents.length > MAX_BATCHED_WATCHER_EVENTS) {
// Why: once precision is too expensive to retain and stream, one
// overflow asks clients to refresh safely without a huge payload.
events = [
@@ -66,14 +67,14 @@ export function createFileWatchEventBatcher(
if (firstEventAt === 0) {
firstEventAt = now
}
if (now - firstEventAt >= FILE_WATCH_MAX_WAIT_MS) {
if (now - firstEventAt >= WATCH_BATCH_MAX_WAIT_MS) {
flush()
return
}
clearTimer()
// Why: remote file-watch events cross the runtime WebSocket before the
// renderer refreshes the tree. Match local watcher batching here.
timer = setTimeout(flush, FILE_WATCH_FLUSH_MS)
timer = setTimeout(flush, WATCH_BATCH_TRAILING_MS)
if (typeof timer.unref === 'function') {
timer.unref()
}
+189
View File
@@ -3316,6 +3316,195 @@ describe('PtyHandler', () => {
])
expect(handler.activePtyCount).toBe(0)
})
describe('onPtyPoolEmpty', () => {
it('fires once when natural exit drains the last PTY, never while one remains', async () => {
const onExitCallbacks: ((evt: { exitCode: number }) => void)[] = []
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCallbacks.push(cb)
})
})
const poolEmpty = vi.fn()
handler.onPtyPoolEmpty(poolEmpty)
await spawnPty()
await spawnPty()
expect(onExitCallbacks).toHaveLength(2)
onExitCallbacks[0]({ exitCode: 0 })
expect(handler.activePtyCount).toBe(1)
expect(poolEmpty).not.toHaveBeenCalled()
onExitCallbacks[1]({ exitCode: 0 })
expect(handler.activePtyCount).toBe(0)
expect(poolEmpty).toHaveBeenCalledTimes(1)
})
it('fires when the dead-shell reap inside attach removes the last PTY', async () => {
mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() })
const poolEmpty = vi.fn()
handler.onPtyPoolEmpty(poolEmpty)
await spawnPty()
const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false)
try {
await expect(attachPty({ id: 'pty-1', suppressReplayNotification: true })).rejects.toThrow(
'PTY "pty-1" not found'
)
} finally {
aliveSpy.mockRestore()
}
expect(handler.activePtyCount).toBe(0)
expect(poolEmpty).toHaveBeenCalledTimes(1)
})
it('fires when dispose-for-shutdown removes the last PTY', async () => {
mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() })
const poolEmpty = vi.fn()
handler.onPtyPoolEmpty(poolEmpty)
await spawnPty()
await spawnPty()
await handler.dispose({ waitForPhysicalExit: false })
expect(handler.activePtyCount).toBe(0)
expect(poolEmpty).toHaveBeenCalledTimes(1)
})
it('fires when a spawn fails before the PTY ever reaches the pool', async () => {
// Why: removePty can only announce a PTY it stored, so a creation that dies mid-flight
// would otherwise leave the relay believing it is still non-idle forever.
mockPtySpawn.mockImplementation(() => {
throw new Error('posix_spawnp failed')
})
const poolEmpty = vi.fn()
handler.onPtyPoolEmpty(poolEmpty)
await expect(spawnPty()).rejects.toThrow()
expect(handler.activePtyCount).toBe(0)
expect(handler.pendingPtyCreationCount).toBe(0)
expect(poolEmpty).toHaveBeenCalledTimes(1)
})
it('stays silent when a failing creation settles while another is still admitted', async () => {
let spawnCall = 0
mockPtySpawn.mockImplementation(() => {
spawnCall += 1
if (spawnCall === 1) {
throw new Error('posix_spawnp failed')
}
return { ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }
})
const poolEmpty = vi.fn()
handler.onPtyPoolEmpty(poolEmpty)
// Both admissions land before either creation resolves, so the failing one must not
// announce an empty pool while the surviving one still owns a shell.
const failing = spawnPty()
const succeeding = spawnPty()
expect(handler.pendingPtyCreationCount).toBe(2)
await expect(failing).rejects.toThrow()
await succeeding
expect(handler.activePtyCount).toBe(1)
expect(poolEmpty).not.toHaveBeenCalled()
})
it('stops notifying after the returned unsubscribe runs', async () => {
const onExitCallbacks: ((evt: { exitCode: number }) => void)[] = []
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCallbacks.push(cb)
})
})
const poolEmpty = vi.fn()
const unsubscribe = handler.onPtyPoolEmpty(poolEmpty)
await spawnPty()
onExitCallbacks[0]({ exitCode: 0 })
expect(poolEmpty).toHaveBeenCalledTimes(1)
unsubscribe()
await spawnPty()
onExitCallbacks[1]({ exitCode: 0 })
expect(handler.activePtyCount).toBe(0)
expect(poolEmpty).toHaveBeenCalledTimes(1)
})
})
describe('onPtyPoolActive', () => {
it('fires at admission, while the pool is still empty', async () => {
mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() })
const poolCounts: number[] = []
handler.onPtyPoolActive(() => {
poolCounts.push(handler.activePtyCount)
})
const pending = spawnPty()
// The relay must learn it is non-idle here, not after the creation resolves.
expect(poolCounts).toEqual([0])
await pending
expect(poolCounts).toEqual([0, 1])
})
it('fires for a revived PTY whose creation was admitted before the pool was empty', async () => {
await spawnPty({ cols: 80, rows: 24, cwd: '/tmp' })
const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string
await handler.dispose({ waitForPhysicalExit: false })
dispatcher = createMockDispatcher()
handler = new PtyHandler(dispatcher as unknown as RelayDispatcher)
mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() })
const poolActive = vi.fn()
handler.onPtyPoolActive(poolActive)
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await dispatcher.callRequest('pty.revive', { state })
} finally {
killSpy.mockRestore()
}
expect(handler.activePtyCount).toBe(1)
expect(poolActive).toHaveBeenCalled()
})
it('stops notifying after the returned unsubscribe runs', async () => {
mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() })
const poolActive = vi.fn()
const unsubscribe = handler.onPtyPoolActive(poolActive)
await spawnPty()
const callsWhileSubscribed = poolActive.mock.calls.length
expect(callsWhileSubscribed).toBeGreaterThan(0)
unsubscribe()
await spawnPty()
expect(poolActive).toHaveBeenCalledTimes(callsWhileSubscribed)
})
})
it('counts a spawn admitted but not yet pooled as a pending creation', async () => {
mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() })
expect(handler.pendingPtyCreationCount).toBe(0)
const pending = spawnPty()
// The shell is already owned even though activePtyCount still reads zero.
expect(handler.activePtyCount).toBe(0)
expect(handler.pendingPtyCreationCount).toBe(1)
await pending
expect(handler.activePtyCount).toBe(1)
expect(handler.pendingPtyCreationCount).toBe(0)
})
})
describe('attachIdentityMismatches', () => {
+71 -3
View File
@@ -372,6 +372,8 @@ export class PtyHandler {
private reloadPtyModuleFromDisk = false
// Why: single optional slot is intentional — callers compose externally; a throw is swallowed so it can't block cleanup.
private exitListener: PtyExitListener | null = null
private ptyPoolEmptyListener: (() => void) | null = null
private ptyPoolActiveListener: (() => void) | null = null
// Why: env augmenters run on every spawn so each PTY sees live hook coords without the dispatcher knowing about agent hooks.
private envAugmenters: PtyEnvAugmenter[] = []
private readonly agentSessionOwners = new ClaimedAgentPtyOwnerRegistry()
@@ -462,6 +464,10 @@ export class PtyHandler {
}
}
// Why: this value never reaches the grace *timer* — startGraceTimer's only caller always passes an
// explicit timeoutMs — but relay.startGrace reads it back through configuredGraceTimeMs to pick the
// grace branch, so a host-sent change does affect shutdown behavior. Callers and the host-sleep
// consequence: docs/reference/relay-grace-time-reconfiguration.md.
setGraceTimeMs(graceTimeMs: number): void {
this.graceTimeMs = Math.max(0, Math.floor(graceTimeMs))
}
@@ -494,6 +500,53 @@ export class PtyHandler {
this.exitListener = listener
}
/** Notified when the last PTY leaves the pool, so the relay can re-arm its idle grace. */
onPtyPoolEmpty(listener: () => void): () => void {
this.ptyPoolEmptyListener = listener
return () => {
if (this.ptyPoolEmptyListener === listener) {
this.ptyPoolEmptyListener = null
}
}
}
/**
* Notified when a PTY creation is admitted or a PTY joins the pool.
*
* Why: the relay arms its idle cap on an empty pool, and a spawn/revive that lands after that
* decision must disarm it — creation is asynchronous, so the pool-empty edge alone can't see it.
*/
onPtyPoolActive(listener: () => void): () => void {
this.ptyPoolActiveListener = listener
return () => {
if (this.ptyPoolActiveListener === listener) {
this.ptyPoolActiveListener = null
}
}
}
private notifyPoolListener(listener: (() => void) | null, label: string): void {
if (!listener) {
return
}
try {
listener()
} catch (err) {
process.stderr.write(
`[pty-handler] ${label} listener threw: ${err instanceof Error ? err.message : String(err)}\n`
)
}
}
// Why: the sole removal path, so the three exit routes can't drift on who announces an empty pool.
private removePty(id: string): void {
this.ptys.delete(id)
if (this.ptys.size > 0) {
return
}
this.notifyPoolListener(this.ptyPoolEmptyListener, 'pty-pool-empty')
}
/** Register an env augmenter merged into every spawn env *after* process.env and renderer env.
* Used by the relay-hook server to inject ORCA_AGENT_HOOK_* coords. See docs/design/agent-status-over-ssh.md §3. */
addEnvAugmenter(augmenter: PtyEnvAugmenter): () => void {
@@ -617,6 +670,8 @@ export class PtyHandler {
private wireAndStore(managed: ManagedPty): void {
managed.physicalExit = new PhysicalExitTracker()
this.ptys.set(managed.id, managed)
// Why: a second announce covers any store whose admission window has already closed.
this.notifyPoolListener(this.ptyPoolActiveListener, 'pty-pool-active')
const emitIngressData = (emission: PtyIngressEmission): void => {
const rawLength = emission.rawEndSeq - emission.rawStartSeq
this.appendReplayBuffer(managed, emission.data)
@@ -672,7 +727,7 @@ export class PtyHandler {
this.publishPendingExit(managed.id)
this.notifyExitListener(managed)
this.agentSessionOwners.release(managed.id)
this.ptys.delete(managed.id)
this.removePty(managed.id)
this.clearPtyInputState(managed.id)
// Why: release the ptmx fd on natural exit, else the master fd leaks until GC (docs/fix-pty-fd-leak.md).
disposeManagedPty(managed)
@@ -1157,6 +1212,9 @@ export class PtyHandler {
throw error
}
this.pendingSpawnCount++
// Why: announce at admission, not at store — the relay must stop treating itself as idle before
// the creation parks on its first await, or an armed idle timer kills the shell it produces.
this.notifyPoolListener(this.ptyPoolActiveListener, 'pty-pool-active')
let finished = false
return () => {
if (finished) {
@@ -1171,6 +1229,11 @@ export class PtyHandler {
this.pendingCreationDrainResolvers.clear()
}
finishPtyCreationOperations(finishRemovalOperations)
// Why: a creation that failed before wireAndStore still leaves the pool empty, and removePty
// can't announce a PTY that was never stored — without this the relay never re-arms its idle cap.
if (this.ptys.size === 0 && this.pendingSpawnCount === 0) {
this.notifyPoolListener(this.ptyPoolEmptyListener, 'pty-pool-empty')
}
}
}
@@ -1535,7 +1598,7 @@ export class PtyHandler {
this.notifyExitListener(managed)
this.agentSessionOwners.release(managed.id)
disposeManagedPty(managed)
this.ptys.delete(id)
this.removePty(id)
this.clearPtyFlowState(id)
throw new Error(`PTY "${id}" not found`)
}
@@ -2065,7 +2128,7 @@ export class PtyHandler {
this.notifyExitListener(managed)
this.agentSessionOwners.release(managed.id)
disposeManagedPty(managed)
this.ptys.delete(managed.id)
this.removePty(managed.id)
this.clearPtyFlowState(managed.id)
}
}
@@ -2105,6 +2168,11 @@ export class PtyHandler {
return this.ptys.size
}
/** Spawns admitted but not yet in the pool — each already owns a shell the relay must not treat as idle. */
get pendingPtyCreationCount(): number {
return this.pendingSpawnCount
}
get retainedStartupCommandCount(): number {
let count = 0
for (const managed of this.ptys.values()) {
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import { decideRelayGrace, type RelayGraceDecisionInput } from './relay-grace-branch'
const EMPTY_DETACHED_STARTUP_GRACE_MS = 30_000
const IDLE_RELAY_GRACE_MS = 15 * 60_000
function decide(overrides: Partial<RelayGraceDecisionInput> = {}) {
return decideRelayGrace({
configuredGraceMs: 0,
relayIdle: false,
detached: false,
hasAcceptedSocketClient: true,
activePtyCount: 1,
retryDeferredShutdown: false,
emptyDetachedStartupGraceMs: EMPTY_DETACHED_STARTUP_GRACE_MS,
idleRelayGraceMs: IDLE_RELAY_GRACE_MS,
...overrides
})
}
describe('decideRelayGrace', () => {
describe('the zero-only gate', () => {
it('honors a grace raised after launch instead of clamping it to the idle cap', () => {
// Why: the reported bug. startGrace used to read the launch-time argv closure, so a host that
// raised the grace to 24h via relay.configureGraceTime still got the 15-minute idle cap.
expect(decide({ configuredGraceMs: 86_400_000, relayIdle: true, activePtyCount: 0 })).toEqual(
{
branch: 'configured',
timeoutMs: 86_400_000
}
)
})
it('caps an idle relay running the unlimited default at the idle grace', () => {
// Why: prepareForHostSleep notifies graceTimeSeconds:0, which now reaches this selector.
// Deliberate behavior change: a host-sleep relay holding zero PTYs exits after the idle cap
// instead of living forever. Pinned so the revived path stays a decision, not an accident.
expect(decide({ configuredGraceMs: 0, relayIdle: true, activePtyCount: 0 })).toEqual({
branch: 'idle-no-ptys',
timeoutMs: IDLE_RELAY_GRACE_MS
})
})
it('leaves a non-idle relay on the configured branch even at the unlimited default', () => {
// Why: an admitted-but-unpooled creation keeps relayIdle false, so the cap must not apply —
// it would kill the shell that creation is about to produce (#6955).
expect(decide({ configuredGraceMs: 0, relayIdle: false })).toEqual({
branch: 'configured',
timeoutMs: 0
})
})
})
describe('branch precedence', () => {
it('prefers shutdown-deferred over every other branch', () => {
expect(
decide({
retryDeferredShutdown: true,
detached: true,
hasAcceptedSocketClient: false,
relayIdle: true,
activePtyCount: 0
})
).toEqual({ branch: 'shutdown-deferred', timeoutMs: IDLE_RELAY_GRACE_MS })
})
it('prefers startup-empty-detached over the idle cap', () => {
expect(
decide({
detached: true,
hasAcceptedSocketClient: false,
relayIdle: true,
activePtyCount: 0
})
).toEqual({ branch: 'startup-empty-detached', timeoutMs: EMPTY_DETACHED_STARTUP_GRACE_MS })
})
it('bounds a configured grace on the startup-empty-detached branch', () => {
expect(
decide({
configuredGraceMs: 5_000,
detached: true,
hasAcceptedSocketClient: false,
activePtyCount: 0
})
).toEqual({ branch: 'startup-empty-detached', timeoutMs: 5_000 })
})
it('does not treat a detached relay that already accepted a client as startup-empty', () => {
expect(
decide({
detached: true,
hasAcceptedSocketClient: true,
relayIdle: true,
activePtyCount: 0
})
).toEqual({ branch: 'idle-no-ptys', timeoutMs: IDLE_RELAY_GRACE_MS })
})
})
})
+63
View File
@@ -0,0 +1,63 @@
/** Which rule decided the current relay grace window. */
export type RelayGraceBranch =
| 'shutdown-deferred'
| 'startup-empty-detached'
| 'idle-no-ptys'
| 'configured'
export type RelayGraceDecisionInput = {
/** The live configured grace, not the launch-time argv value; 0 means unlimited. */
configuredGraceMs: number
/** Zero pooled PTYs *and* zero admitted-but-unpooled creations. */
relayIdle: boolean
detached: boolean
hasAcceptedSocketClient: boolean
activePtyCount: number
retryDeferredShutdown: boolean
emptyDetachedStartupGraceMs: number
idleRelayGraceMs: number
}
export type RelayGraceDecision = {
branch: RelayGraceBranch
timeoutMs: number
}
/**
* Picks the relay's shutdown grace window.
*
* Why a separate module: relay.ts runs `main()` on import and exports nothing, so the zero-only
* gate below — the property that keeps a configured grace from being clamped to the idle cap —
* could not otherwise be tested.
*/
export function decideRelayGrace(input: RelayGraceDecisionInput): RelayGraceDecision {
// Why: a detached relay that never accepted a client has no PTY state and shouldn't linger forever.
const startupEmptyDetached =
input.detached && !input.hasAcceptedSocketClient && input.activePtyCount === 0
// Why: zero PTYs means nothing left to preserve, so only the unlimited default is capped — an
// explicitly configured grace is honored verbatim rather than clamped down to the idle cap.
// Why: a spawn parked mid-creation is not in the pool yet, so capping on it would kill the live
// shell it is about to produce.
const idleNoPtys = input.relayIdle && input.configuredGraceMs === 0
const branch: RelayGraceBranch = input.retryDeferredShutdown
? 'shutdown-deferred'
: startupEmptyDetached
? 'startup-empty-detached'
: idleNoPtys
? 'idle-no-ptys'
: 'configured'
const timeoutMs =
branch === 'startup-empty-detached'
? input.configuredGraceMs === 0
? input.emptyDetachedStartupGraceMs
: Math.min(input.configuredGraceMs, input.emptyDetachedStartupGraceMs)
: // Why: a refused kill leaves the PTY pooled, so the idle branch can't be reached — this
// branch must supply its own bound or the shipped grace=0 default arms no retry at all.
branch === 'idle-no-ptys' || branch === 'shutdown-deferred'
? input.idleRelayGraceMs
: input.configuredGraceMs
return { branch, timeoutMs }
}
+81 -10
View File
@@ -51,6 +51,7 @@ import {
} from '../shared/pi-agent-kind'
import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing'
import { pickRemoteCliEnv } from './remote-cli-env'
import { decideRelayGrace, type RelayGraceBranch } from './relay-grace-branch'
import { relayLogLine } from './relay-diagnostic-log'
import { remoteCliRequestTimeoutMs } from './remote-cli-timeout'
import { shouldReadRemoteCliStdin } from './remote-cli-stdin'
@@ -68,6 +69,9 @@ const EMPTY_DETACHED_STARTUP_GRACE_MS = parseNonNegativeIntEnv(
'ORCA_RELAY_EMPTY_STARTUP_GRACE_MS',
60_000
)
// Why: a relay holding zero PTYs preserves nothing, so an unlimited grace only accumulates idle daemons.
// The env override is test-only — the remote relay is launched over a non-interactive SSH exec channel that carries no client env.
const IDLE_RELAY_GRACE_MS = parseNonNegativeIntEnv('ORCA_RELAY_IDLE_GRACE_MS', 15 * 60_000)
type SocketIdentity = {
dev: bigint
@@ -679,8 +683,21 @@ async function main(): Promise<void> {
function configureRelayGraceTime(params: Record<string, unknown>): { graceTimeMs: number } {
const seconds = Number(params.graceTimeSeconds)
if (Number.isFinite(seconds) && seconds >= 0) {
const previousGraceMs = ptyHandler.configuredGraceTimeMs
// Why: the host sends 0 before system sleep so live remote PTYs survive longer than the ordinary grace window.
ptyHandler.setGraceTimeMs(Math.floor(seconds) * 1000)
// Why: startGrace samples the configured value at arm time, so a raise that lands while the idle
// timer is already running would still fire at the old deadline. Re-arm only on an actual change
// — the host re-asserts the same value on every establish, and restarting the window on those
// would keep a grace alive indefinitely.
if (
ptyHandler.configuredGraceTimeMs !== previousGraceMs &&
graceDeadlineAt !== null &&
graceReason !== null &&
!shutdownInFlight
) {
startGrace('grace reconfigured')
}
}
return { graceTimeMs: ptyHandler.configuredGraceTimeMs }
}
@@ -829,6 +846,9 @@ async function main(): Promise<void> {
let hasAcceptedSocketClient = false
let graceDeadlineAt: number | null = null
let graceReason: string | null = null
// Why: only the idle branch is a "nothing left to preserve" bet, so only it may be revoked when a
// PTY appears mid-window; the other branches keep their armed deadline.
let graceBranch: RelayGraceBranch | null = null
dispatcher.onRequest('relay.status', async () => ({
pid: process.pid,
@@ -864,6 +884,7 @@ async function main(): Promise<void> {
}
graceDeadlineAt = null
graceReason = null
graceBranch = null
ptyHandler.cancelGraceTimer()
}
@@ -1116,26 +1137,45 @@ async function main(): Promise<void> {
dispatcher.invalidateClient()
})
function startGrace(reason: string): void {
const startupEmptyDetached =
detached && !hasAcceptedSocketClient && ptyHandler.activePtyCount === 0
// Why: a detached relay that never accepted a client has no PTY state and shouldn't linger forever.
const timeoutMs = startupEmptyDetached
? graceTimeMs === 0
? EMPTY_DETACHED_STARTUP_GRACE_MS
: Math.min(graceTimeMs, EMPTY_DETACHED_STARTUP_GRACE_MS)
: graceTimeMs
function startGrace(reason: string, options?: { retryDeferredShutdown?: boolean }): void {
// Why: the live configured value, not the launch-time argv closure — the host can raise the grace
// after launch via relay.configureGraceTime, and a zero-only gate reading a stale zero would be
// zero-at-launch-only, i.e. correct only by coincidence.
const decision = decideRelayGrace({
configuredGraceMs: ptyHandler.configuredGraceTimeMs,
relayIdle: isRelayIdle(),
detached,
hasAcceptedSocketClient,
activePtyCount: ptyHandler.activePtyCount,
retryDeferredShutdown: options?.retryDeferredShutdown === true,
emptyDetachedStartupGraceMs: EMPTY_DETACHED_STARTUP_GRACE_MS,
idleRelayGraceMs: IDLE_RELAY_GRACE_MS
})
graceBranch = decision.branch
const timeoutMs = decision.timeoutMs
graceDeadlineAt = timeoutMs === 0 ? null : Date.now() + timeoutMs
graceReason = reason
relayLogLine(
`[relay] Grace started (${reason}): timeoutMs=${timeoutMs}, startupEmptyDetached=${startupEmptyDetached}, ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}`
`[relay] Grace started (${reason}): timeoutMs=${timeoutMs}, branch=${graceBranch}, ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}`
)
ptyHandler.startGraceTimer(() => {
// Why: last line of defense for the idle cap — a PTY that appeared without announcing itself
// must not be killed by a timer armed while the relay was still empty (#6955).
if (graceBranch === 'idle-no-ptys' && !isRelayIdle()) {
relayLogLine(`[relay] Grace expired (${reason}) but relay is no longer idle; re-evaluating`)
startGrace(reason)
return
}
relayLogLine(`[relay] Grace expired (${reason}); shutting down`)
shutdown()
}, timeoutMs)
}
// Why: a creation admitted but not yet pooled already owns a shell, so it counts as non-idle.
function isRelayIdle(): boolean {
return ptyHandler.activePtyCount === 0 && ptyHandler.pendingPtyCreationCount === 0
}
if (detached) {
// Why: detached stdin is /dev/null, so listening would EOF → grace → shutdown before --connect arrives; use the socket instead.
startGrace('detached startup')
@@ -1176,9 +1216,12 @@ async function main(): Promise<void> {
)
graceDeadlineAt = null
graceReason = null
graceBranch = null
void ptyHandler
.dispose()
.then(() => {
stopPoolWatch()
stopPoolActiveWatch()
dispatcher.dispose()
fsHandler.dispose()
gitHandler.dispose()
@@ -1192,13 +1235,41 @@ async function main(): Promise<void> {
})
.catch((error) => {
// Why: keep owning a PTY whose native kill was rejected so a transient signal failure doesn't orphan a remote shell.
// Why: the pool watches stay registered — the socket server is still listening, so a client can
// reconnect and cancel this grace, and that revived relay still needs both re-evaluations.
shutdownInFlight = false
relayLogLine(
`[relay] Shutdown deferred: ${error instanceof Error ? error.message : String(error)}`
)
// Why: shutdown() already cleared graceReason, so without re-arming a client-less relay
// whose kill was refused would stay resident forever with nothing left to retry it.
if (socketClients.size === 0) {
startGrace('shutdown deferred', { retryDeferredShutdown: true })
}
})
}
// Why: with the shipped unlimited default the grace timer is never armed, so an expiry can't
// notice the pool emptying; re-evaluate on the last exit instead. graceReason (not
// graceTimerActive) is the only signal that a grace window is open in that configuration.
// Why: a null deadline is exactly that unlimited case; re-arming a scheduled one would restart
// an explicitly configured window and double it.
const stopPoolWatch = ptyHandler.onPtyPoolEmpty(() => {
if (graceReason !== null && graceDeadlineAt === null && !shutdownInFlight) {
startGrace('last pty exited')
}
})
// Why: the idle cap is armed on an empty pool, but creation is asynchronous — a spawn or revive
// admitted after that decision (the client dropped mid-`pty.revive`, say) makes the relay non-idle
// again, and re-evaluating disarms the timer instead of letting it kill a live PTY (#6955).
// The grace window itself stays open, so the PTY's own exit still re-arms the cap.
const stopPoolActiveWatch = ptyHandler.onPtyPoolActive(() => {
if (graceBranch === 'idle-no-ptys' && graceReason !== null && !shutdownInFlight) {
startGrace(graceReason)
}
})
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
// Why: default SIGHUP exits immediately, killing PTYs before grace; ignore it so the relay survives SSH disconnect.
+255
View File
@@ -108,6 +108,57 @@ const WORKING_NODE_PTY_MODULE = `module.exports = { spawn() { return {
onData() {}, onExit() {}, write() {}, resize() {}, kill() {}, clear() {}
} } }\n`
// A shell that reports its own exit after a delay, so the relay sees the pool drain on its own.
function selfExitingNodePtyModule(exitAfterMs: number): string {
return `module.exports = { spawn() {
const exitHandlers = []
setTimeout(() => { for (const cb of exitHandlers) { cb({ exitCode: 0, signal: 0 }) } }, ${exitAfterMs})
return {
pid: process.pid,
process: 'mock-shell',
onData() {}, onExit(cb) { exitHandlers.push(cb) }, write() {}, resize() {}, kill() {}, clear() {}
}
} }\n`
}
// A shell whose first dispose is refused, so ptyHandler.dispose() rejects once before a retry can succeed.
const KILL_REJECTS_FIRST_DISPOSE_MODULE = `let killAttempts = 0
module.exports = { spawn() {
const exitHandlers = []
return {
pid: 2147483646,
process: 'mock-shell',
onData() {}, onExit(cb) { exitHandlers.push(cb) }, write() {}, resize() {}, clear() {},
kill() {
killAttempts++
if (killAttempts <= 2) { throw new Error('kill refused') }
setTimeout(() => { for (const cb of exitHandlers) { cb({ exitCode: 0, signal: 0 }) } }, 0)
}
}
} }\n`
// Why: an ESM mock with top-level await parks loadPty() in the window where a spawn is admitted but not yet pooled.
function writeSlowLoadingNodePty(root: string, loadDelayMs: number): void {
const nodePtyDir = path.join(root, 'node_modules', 'node-pty')
mkdirSync(path.join(nodePtyDir, 'lib'), { recursive: true })
writeFileSync(path.join(nodePtyDir, 'package.json'), '{"type":"module","main":"lib/index.js"}\n')
writeFileSync(
path.join(nodePtyDir, 'lib', 'index.js'),
`await new Promise((resolve) => setTimeout(resolve, ${loadDelayMs}))
export function spawn() {
const exitHandlers = []
return {
pid: process.pid,
process: 'mock-shell',
onData() {}, onExit(cb) { exitHandlers.push(cb) }, write() {}, resize() {}, clear() {},
// Report the exit so relay shutdown can complete instead of parking on waitForPhysicalExit.
kill() { setTimeout(() => { for (const cb of exitHandlers) { cb({ exitCode: 0, signal: 0 }) } }, 0) }
}
}
`
)
}
describe('Subprocess: Relay entry point', () => {
let relay: RelayProcess | null = null
let tmpDir: string
@@ -504,6 +555,210 @@ describe('Subprocess: Relay entry point', () => {
10_000
)
// Why: a relay holding zero PTYs preserves nothing, so the unlimited default must still be
// bounded once a client has come and gone — but an explicitly configured grace is never shortened.
function spawnIdleGraceDaemon(
nodePtyModule: string,
graceTimeSeconds: string,
idleGraceMs: string
): { daemon: RelayProcess; sockPath: string } {
const daemonEntry = path.join(tmpDir, 'relay.js')
copyFileSync(relayEntry, daemonEntry)
writeMockNodePty(tmpDir, nodePtyModule)
const sockPath = path.join(tmpDir, 'relay.sock')
const daemon = spawnRelayEntry(
daemonEntry,
['--detached', '--grace-time', graceTimeSeconds, '--sock-path', sockPath],
{ ...process.env, ORCA_RELAY_IDLE_GRACE_MS: idleGraceMs }
)
return { daemon, sockPath }
}
async function connectAndDisconnect(
sockPath: string,
whileConnected?: (bridge: RelayProcess) => Promise<void>
): Promise<void> {
const bridge = spawn(['--connect', '--sock-path', sockPath])
try {
await bridge.sentinelReceived
await whileConnected?.(bridge)
} finally {
bridge.kill('SIGTERM')
await bridge.waitForExit().catch(() => {})
}
}
it.skipIf(process.platform === 'win32')(
'shuts down an idle relay with no PTYs after the idle grace even with --grace-time 0',
async () => {
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-idle-'))
const sockPath = path.join(tmpDir, 'relay.sock')
relay = spawn(['--detached', '--grace-time', '0', '--sock-path', sockPath], {
...process.env,
ORCA_RELAY_IDLE_GRACE_MS: '200'
})
await relay.sentinelReceived
// Accepting a client defeats the startup-empty branch, so only the idle cap can end this relay.
await connectAndDisconnect(sockPath)
await relay.waitForExit(2000)
expect(relay.proc.exitCode).toBe(0)
},
15_000
)
it.skipIf(process.platform === 'win32')(
'keeps a relay with a live PTY alive past the idle grace',
async () => {
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-idle-live-pty-'))
const { daemon, sockPath } = spawnIdleGraceDaemon(WORKING_NODE_PTY_MODULE, '0', '200')
relay = daemon
await relay.sentinelReceived
await connectAndDisconnect(sockPath, async (bridge) => {
const resp = await bridge.waitForResponse(bridge.send('pty.spawn', { cols: 80, rows: 24 }))
expect(resp.error).toBeUndefined()
})
await new Promise((resolve) => setTimeout(resolve, 1200))
expect(relay.proc.exitCode).toBeNull()
},
15_000
)
it.skipIf(process.platform === 'win32')(
're-arms the idle grace when the last PTY exits during grace',
async () => {
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-idle-rearm-'))
const { daemon, sockPath } = spawnIdleGraceDaemon(selfExitingNodePtyModule(1500), '0', '200')
relay = daemon
await relay.sentinelReceived
await connectAndDisconnect(sockPath, async (bridge) => {
const resp = await bridge.waitForResponse(bridge.send('pty.spawn', { cols: 80, rows: 24 }))
expect(resp.error).toBeUndefined()
})
// The live PTY must suppress the idle grace at disconnect; only its own exit re-arms it.
await new Promise((resolve) => setTimeout(resolve, 300))
expect(relay.proc.exitCode).toBeNull()
await relay.waitForExit(5000)
expect(relay.proc.exitCode).toBe(0)
},
20_000
)
it.skipIf(process.platform === 'win32')(
'honors an explicitly configured grace instead of clamping it to the idle cap',
async () => {
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-idle-configured-'))
const sockPath = path.join(tmpDir, 'relay.sock')
relay = spawn(['--detached', '--grace-time', '3600', '--sock-path', sockPath], {
...process.env,
ORCA_RELAY_IDLE_GRACE_MS: '200'
})
await relay.sentinelReceived
await connectAndDisconnect(sockPath)
await new Promise((resolve) => setTimeout(resolve, 1000))
expect(relay.proc.exitCode).toBeNull()
},
15_000
)
it.skipIf(process.platform === 'win32')(
'keeps the relay alive when the client drops while a PTY creation is still in flight',
async () => {
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-idle-inflight-'))
const daemonEntry = path.join(tmpDir, 'relay.js')
copyFileSync(relayEntry, daemonEntry)
writeSlowLoadingNodePty(tmpDir, 1500)
const sockPath = path.join(tmpDir, 'relay.sock')
relay = spawnRelayEntry(
daemonEntry,
['--detached', '--grace-time', '0', '--sock-path', sockPath],
{ ...process.env, ORCA_RELAY_IDLE_GRACE_MS: '200' }
)
await relay.sentinelReceived
const bridge = spawn(['--connect', '--sock-path', sockPath])
await bridge.sentinelReceived
// Revive, not spawn: it has no client-disconnect abort, so it really does produce a live shell
// after the parked module load resolves.
bridge.send('pty.revive', {
state: JSON.stringify([
{ id: 'pty-restored', pid: process.pid, cols: 80, rows: 24, cwd: tmpDir }
])
})
// Let the creation park on the module load: it already owns a shell but is not in the pool yet.
await new Promise((resolve) => setTimeout(resolve, 400))
bridge.kill('SIGTERM')
await bridge.waitForExit().catch(() => {})
// Well past the idle cap and past the point where the parked creation lands in the pool.
await new Promise((resolve) => setTimeout(resolve, 2500))
expect(relay.proc.exitCode).toBeNull()
const probe = spawn(['--connect', '--sock-path', sockPath])
try {
await probe.sentinelReceived
const status = await probe.waitForResponse(probe.send('relay.status'))
expect((status.result as { ptys: { active: number } }).ptys.active).toBe(1)
} finally {
probe.kill('SIGTERM')
await probe.waitForExit().catch(() => {})
}
},
20_000
)
it.skipIf(process.platform === 'win32')(
'does not extend an explicitly configured grace when the last PTY exits mid-window',
async () => {
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-configured-rearm-'))
const { daemon, sockPath } = spawnIdleGraceDaemon(selfExitingNodePtyModule(2500), '3', '200')
relay = daemon
await relay.sentinelReceived
await connectAndDisconnect(sockPath, async (bridge) => {
const resp = await bridge.waitForResponse(bridge.send('pty.spawn', { cols: 80, rows: 24 }))
expect(resp.error).toBeUndefined()
})
// The 3s window keeps governing; re-arming at the ~2.5s PTY exit would push shutdown past 5s.
await relay.waitForExit(4200)
expect(relay.proc.exitCode).toBe(0)
},
20_000
)
it.skipIf(process.platform === 'win32')(
're-arms grace after a shutdown deferred by a rejected kill, so the relay still exits',
async () => {
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-shutdown-deferred-'))
const { daemon, sockPath } = spawnIdleGraceDaemon(
KILL_REJECTS_FIRST_DISPOSE_MODULE,
'2',
'200'
)
relay = daemon
await relay.sentinelReceived
await connectAndDisconnect(sockPath, async (bridge) => {
const resp = await bridge.waitForResponse(bridge.send('pty.spawn', { cols: 80, rows: 24 }))
expect(resp.error).toBeUndefined()
})
// First grace expiry hits the refused kill; only the re-armed window can retry it.
await relay.waitForExit(8000)
expect(relay.proc.exitCode).toBe(0)
},
20_000
)
it('reports relay diagnostics over relay.status', async () => {
relay = spawn()
await relay.sentinelReceived
@@ -42,6 +42,7 @@ import { useFileExplorerDragDrop } from './useFileExplorerDragDrop'
import { useFileExplorerImport } from './useFileExplorerImport'
import { useFileExplorerManualRefresh } from './useFileExplorerManualRefresh'
import { useFileExplorerTree } from './useFileExplorerTree'
import { decideExpandedDirLoad } from './file-explorer-stale-dir-cache'
import { useFileExplorerWatch } from './useFileExplorerWatch'
import {
buildAddProjectFromFolderModalData,
@@ -148,6 +149,7 @@ function FileExplorerFiles(): React.JSX.Element {
markPathAsDirectory,
refreshTree,
refreshDir,
isDirStale,
resetAndLoad
} = useFileExplorerTree(worktreePath, expanded, activeWorktreeId)
const hasNameFilterQuery = nameFilterQuery.trim().length > 0
@@ -347,11 +349,14 @@ function FileExplorerFiles(): React.JSX.Element {
return
}
for (const dirPath of expanded) {
if (!dirCache[dirPath]?.children.length && !dirCache[dirPath]?.loading) {
const depth =
splitPathSegments(dirPath.slice(visibleFilesWorktreePath.length + 1)).length - 1
void loadDir(dirPath, depth)
// Why: a full refresh (watcher overflow) re-reads only root and the dirs expanded at the time,
// so a listing cached while collapsed is unverified — re-read it here instead of trusting it.
const decision = decideExpandedDirLoad(dirCache[dirPath], isDirStale(dirPath))
if (decision === 'skip') {
continue
}
const depth = splitPathSegments(dirPath.slice(visibleFilesWorktreePath.length + 1)).length - 1
void loadDir(dirPath, depth, decision === 'reload' ? { force: true } : undefined)
}
}, [expanded, visibleFilesWorktreePath]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -3,7 +3,7 @@ import type { SetStateAction } from 'react'
import type { DirEntry } from '../../../../shared/types'
import type { DirCache } from './file-explorer-types'
import { createFileExplorerDirLoadTracker } from './file-explorer-dir-load-tracker'
import { refreshFileExplorerExpandedDirs } from './useFileExplorerTree'
import { refreshFileExplorerExpandedDirs } from './file-explorer-expanded-dirs-refresh'
type CacheUpdate = SetStateAction<Record<string, DirCache>>
@@ -44,7 +44,10 @@ describe('refreshFileExplorerExpandedDirs', () => {
worktreePath: '/repo',
dirLoadTracker: createFileExplorerDirLoadTracker(),
setDirCache,
readDirectory
readDirectory,
// A limit at or above the dir count must stay a single wave, i.e. exactly
// today's two batched writes.
maxConcurrentReads: 16
})
expect(refreshed).toBe(true)
@@ -128,7 +131,8 @@ describe('refreshFileExplorerExpandedDirs', () => {
worktreePath: '/repo',
dirLoadTracker: tracker,
setDirCache,
readDirectory
readDirectory,
maxConcurrentReads: 16
})
// Not every dir was still current, so the refresh reports partial completion.
@@ -172,7 +176,8 @@ describe('refreshFileExplorerExpandedDirs', () => {
worktreePath: '/repo',
dirLoadTracker: tracker,
setDirCache,
readDirectory
readDirectory,
maxConcurrentReads: 16
})
// Let /repo/src resolve (and pass its resolve-time token check) while
@@ -207,4 +212,137 @@ describe('refreshFileExplorerExpandedDirs', () => {
children: [{ name: 'guide.md' }]
})
})
it('never exceeds maxConcurrentReads in flight and still commits every directory', async () => {
const dirs = Array.from({ length: 20 }, (_, index) => ({
dirPath: `/repo/d${index}`,
depth: 0
}))
let cache: Record<string, DirCache> = {}
const setDirCache = vi.fn((update: CacheUpdate) => {
cache = typeof update === 'function' ? update(cache) : update
})
let inFlight = 0
let peakInFlight = 0
const readDirectory = vi.fn(async (dirPath: string) => {
inFlight++
peakInFlight = Math.max(peakInFlight, inFlight)
await new Promise((resolve) => setTimeout(resolve, 0))
inFlight--
return {
entries: [entry(`${dirPath.slice('/repo/'.length)}.ts`)],
operationOwner: { kind: 'local' as const }
}
})
const refreshed = await refreshFileExplorerExpandedDirs({
dirs,
worktreePath: '/repo',
dirLoadTracker: createFileExplorerDirLoadTracker(),
setDirCache,
readDirectory,
maxConcurrentReads: 4
})
expect(refreshed).toBe(true)
expect(peakInFlight).toBeLessThanOrEqual(4)
expect(readDirectory).toHaveBeenCalledTimes(20)
// One up-front loading write plus one result write per wave (five waves).
expect(setDirCache).toHaveBeenCalledTimes(6)
for (const { dirPath } of dirs) {
expect(cache[dirPath]).toMatchObject({
loading: false,
children: [{ name: expect.any(String) }]
})
}
})
it('marks queued dirs loading up front so only the first wave reads', async () => {
const dirs = Array.from({ length: 9 }, (_, index) => ({
dirPath: `/repo/d${index}`,
depth: 0
}))
let cache: Record<string, DirCache> = {}
const setDirCache = vi.fn((update: CacheUpdate) => {
cache = typeof update === 'function' ? update(cache) : update
})
let releaseFirstWave!: () => void
const firstWaveGate = new Promise<void>((resolve) => {
releaseFirstWave = resolve
})
const readDirectory = vi.fn(async (dirPath: string) => {
if (['/repo/d0', '/repo/d1', '/repo/d2'].includes(dirPath)) {
await firstWaveGate
}
return { entries: [], operationOwner: { kind: 'local' as const } }
})
const refreshPromise = refreshFileExplorerExpandedDirs({
dirs,
worktreePath: '/repo',
dirLoadTracker: createFileExplorerDirLoadTracker(),
setDirCache,
readDirectory,
maxConcurrentReads: 3
})
await Promise.resolve()
expect(cache['/repo/d0']).toMatchObject({ loading: true })
// A queued dir must already advertise loading:true, or FileExplorer's
// auto-load effect fans out an unbounded loadDir for it on the next
// `expanded` change — the reads this cap exists to bound.
expect(cache['/repo/d6']).toMatchObject({ loading: true })
expect(readDirectory).toHaveBeenCalledTimes(3)
releaseFirstWave()
await refreshPromise
expect(cache['/repo/d6']).toMatchObject({ loading: false })
})
it('drops a later-wave directory superseded while an earlier wave was reading', async () => {
const tracker = createFileExplorerDirLoadTracker()
let cache: Record<string, DirCache> = {}
const setDirCache = vi.fn((update: CacheUpdate) => {
cache = typeof update === 'function' ? update(cache) : update
})
let releaseFirst!: () => void
const firstGate = new Promise<void>((resolve) => {
releaseFirst = resolve
})
const readDirectory = vi.fn(async (dirPath: string) => {
if (dirPath === '/repo/a') {
await firstGate
}
return { entries: [entry('x.ts')], operationOwner: { kind: 'local' as const } }
})
const refreshPromise = refreshFileExplorerExpandedDirs({
dirs: [
{ dirPath: '/repo/a', depth: 0 },
{ dirPath: '/repo/b', depth: 0 }
],
worktreePath: '/repo',
dirLoadTracker: tracker,
setDirCache,
readDirectory,
maxConcurrentReads: 1
})
await Promise.resolve()
// A watcher-driven refreshDir supersedes the queued wave-2 dir before it
// ever starts reading; its token was already taken up front.
tracker.begin('/repo/b')
const newerBCache: DirCache = { loading: false, children: [] }
setDirCache((prev) => ({ ...prev, '/repo/b': newerBCache }))
releaseFirst()
const refreshed = await refreshPromise
expect(refreshed).toBe(false)
expect(cache['/repo/a']).toMatchObject({ loading: false, children: [{ name: 'x.ts' }] })
// Wave 2 must neither read nor commit the superseded dir.
expect(readDirectory).toHaveBeenCalledTimes(1)
expect(cache['/repo/b']).toEqual(newerBCache)
})
})
@@ -0,0 +1,137 @@
import type { Dispatch, SetStateAction } from 'react'
import type { DirCache } from './file-explorer-types'
import type { FileExplorerDirLoadTracker } from './file-explorer-dir-load-tracker'
import {
fileExplorerEntriesToTreeNodes,
type FileExplorerDirectoryListing
} from './file-explorer-directory-listing'
export type RefreshFileExplorerTreeDir = {
dirPath: string
depth: number
}
export type RefreshFileExplorerExpandedDirsParams = {
dirs: RefreshFileExplorerTreeDir[]
worktreePath: string
dirLoadTracker: FileExplorerDirLoadTracker
setDirCache: Dispatch<SetStateAction<Record<string, DirCache>>>
readDirectory: (dirPath: string) => Promise<FileExplorerDirectoryListing>
maxConcurrentReads: number
/** Called per dir whose fresh listing was committed, so callers can clear a staleness mark. */
onDirCommitted?: (dirPath: string) => void
}
export async function refreshFileExplorerExpandedDirs({
dirs,
worktreePath,
dirLoadTracker,
setDirCache,
readDirectory,
maxConcurrentReads,
onDirCommitted
}: RefreshFileExplorerExpandedDirsParams): Promise<boolean> {
if (dirs.length === 0) {
return true
}
const uniqueDirs = Array.from(new Map(dirs.map((dir) => [dir.dirPath, dir])).values())
// Why: begin every token before the first read so a concurrent refreshDir or
// worktree reset supersedes dirs still queued in a later wave, exactly as the
// single-batch version did.
const loadTokens = new Map(
uniqueDirs.map((dir) => [dir.dirPath, dirLoadTracker.begin(dir.dirPath)])
)
const waveSize = Math.max(1, Math.floor(maxConcurrentReads))
let committedDirs = 0
// Why: mark every dir loading up front, not per wave — FileExplorer's auto-load
// effect re-runs on any `expanded` change and fans out an unbounded loadDir per
// dir that is neither cached nor loading, which would defeat the wave cap.
setDirCache((prev) => {
const next = { ...prev }
for (const { dirPath } of uniqueDirs) {
next[dirPath] = {
children: prev[dirPath]?.children ?? [],
loading: true
}
}
return next
})
for (let start = 0; start < uniqueDirs.length; start += waveSize) {
// Why: a dir superseded while an earlier wave was reading is left to its
// newer load — reading it here would only burn a round trip for a result
// that is dropped at commit time.
const wave = uniqueDirs
.slice(start, start + waveSize)
.filter(({ dirPath }) => dirLoadTracker.isCurrent(loadTokens.get(dirPath)!))
if (wave.length === 0) {
continue
}
// Why: batch each wave's results into one setDirCache write, so the whole
// refresh costs 1 + ceil(N / waveSize) cache spreads rather than one per dir.
const results = await Promise.all(
wave.map(async ({ dirPath, depth }) => {
const loadToken = loadTokens.get(dirPath)!
try {
const listing = await readDirectory(dirPath)
if (!dirLoadTracker.isCurrent(loadToken)) {
return { current: false as const }
}
return {
current: true as const,
dirPath,
cache: {
children: fileExplorerEntriesToTreeNodes(
listing.entries,
dirPath,
depth,
worktreePath,
listing.operationOwner
),
loading: false,
operationOwner: listing.operationOwner
}
}
} catch {
if (!dirLoadTracker.isCurrent(loadToken)) {
return { current: false as const }
}
return {
current: true as const,
dirPath,
cache: { children: [], loading: false }
}
}
})
)
// Why: the batch commits only after the slowest read in the wave, so a dir
// can be superseded (watcher refreshDir, worktree reset) after its own read
// resolved. Re-check tokens at commit time so the batched write never
// clobbers a newer load — preserving the old per-dir commit ordering.
const currentResults = results.filter(
(result): result is Extract<typeof result, { current: true }> =>
result.current && dirLoadTracker.isCurrent(loadTokens.get(result.dirPath)!)
)
if (currentResults.length === 0) {
continue
}
setDirCache((prev) => {
const next = { ...prev }
for (const result of currentResults) {
next[result.dirPath] = result.cache
}
return next
})
for (const result of currentResults) {
onDirCommitted?.(result.dirPath)
}
committedDirs += currentResults.length
}
return committedDirs === uniqueDirs.length
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { fileExplorerRefreshConcurrency } from './file-explorer-refresh-concurrency'
describe('fileExplorerRefreshConcurrency', () => {
it('allows the widest fan-out for local disk reads', () => {
expect(fileExplorerRefreshConcurrency({ kind: 'local' })).toBe(16)
})
it('caps SSH-owned worktrees at the remote tier', () => {
expect(fileExplorerRefreshConcurrency({ kind: 'ssh', connectionId: 'target-1' })).toBe(4)
})
it('treats an SSH-backed runtime as remote', () => {
expect(
fileExplorerRefreshConcurrency({
kind: 'runtime',
environmentId: 'env-1',
executionHostId: 'ssh:target-1'
})
).toBe(4)
})
it('uses the runtime tier for a non-SSH runtime host', () => {
expect(
fileExplorerRefreshConcurrency({
kind: 'runtime',
environmentId: 'env-1',
executionHostId: 'runtime:env-1'
})
).toBe(8)
})
it('falls back to the conservative remote tier when ownership is unresolved', () => {
expect(fileExplorerRefreshConcurrency({ kind: 'unresolved' })).toBe(4)
})
})
@@ -0,0 +1,31 @@
import { parseExecutionHostId } from '../../../../shared/execution-host'
import type { FileExplorerOperationOwner } from './file-explorer-types'
export const FILE_EXPLORER_LOCAL_REFRESH_CONCURRENCY = 16
export const FILE_EXPLORER_RUNTIME_REFRESH_CONCURRENCY = 8
export const FILE_EXPLORER_REMOTE_REFRESH_CONCURRENCY = 4
/**
* In-flight directory-read cap for one File Explorer refresh, by how expensive a
* read is on the owning transport.
*
* Why: tiers come from the owner, not from `getFileExplorerOperationRoute`,
* which maps every non-SSH runtime host to `expectedExecutionHostId: 'local'` —
* including an `orca serve` runtime reached over a network socket, whose reads
* are RPC round trips rather than disk reads.
*/
export function fileExplorerRefreshConcurrency(owner: FileExplorerOperationOwner): number {
switch (owner.kind) {
case 'local':
return FILE_EXPLORER_LOCAL_REFRESH_CONCURRENCY
case 'ssh':
return FILE_EXPLORER_REMOTE_REFRESH_CONCURRENCY
case 'runtime':
return parseExecutionHostId(owner.executionHostId)?.kind === 'ssh'
? FILE_EXPLORER_REMOTE_REFRESH_CONCURRENCY
: FILE_EXPLORER_RUNTIME_REFRESH_CONCURRENCY
case 'unresolved':
// Why: unknown ownership could be the slowest transport; stay conservative.
return FILE_EXPLORER_REMOTE_REFRESH_CONCURRENCY
}
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import type { DirCache, TreeNode } from './file-explorer-types'
import { collectStaleDirCachePaths, decideExpandedDirLoad } from './file-explorer-stale-dir-cache'
function cache(...dirPaths: string[]): Record<string, DirCache> {
return Object.fromEntries(dirPaths.map((dirPath) => [dirPath, { children: [], loading: false }]))
}
describe('collectStaleDirCachePaths', () => {
it('marks a cached dir that a full refresh will not re-read', () => {
expect(
collectStaleDirCachePaths(cache('/repo', '/repo/src', '/repo/docs'), '/repo', new Set())
).toEqual(['/repo/src', '/repo/docs'])
})
it('never marks the root or a currently expanded dir', () => {
expect(
collectStaleDirCachePaths(
cache('/repo', '/repo/src', '/repo/docs'),
'/repo',
new Set(['/repo/src'])
)
).toEqual(['/repo/docs'])
})
it('returns nothing when the cache holds only refreshed dirs', () => {
expect(
collectStaleDirCachePaths(cache('/repo', '/repo/src'), '/repo', new Set(['/repo/src']))
).toEqual([])
})
})
describe('decideExpandedDirLoad', () => {
const children = [{ name: 'gone.ts', path: '/repo/src/gone.ts' } as TreeNode]
it('re-reads a cached dir whose listing the last full refresh skipped', () => {
expect(decideExpandedDirLoad({ children, loading: false }, true)).toBe('reload')
})
it('trusts a cached listing that is not stale', () => {
expect(decideExpandedDirLoad({ children, loading: false }, false)).toBe('skip')
})
it('reads a dir that has never been listed', () => {
expect(decideExpandedDirLoad(undefined, false)).toBe('load')
expect(decideExpandedDirLoad({ children: [], loading: false }, false)).toBe('load')
})
it('never stacks a read on one already in flight, stale or not', () => {
expect(decideExpandedDirLoad({ children, loading: true }, true)).toBe('skip')
expect(decideExpandedDirLoad({ children: [], loading: true }, false)).toBe('skip')
})
})
@@ -0,0 +1,34 @@
import type { DirCache } from './file-explorer-types'
/**
* Cached directories a full tree refresh does not re-read.
*
* Why: `refreshTree` reads the root plus the currently expanded dirs, but `toggleDir` drops a dir
* from `expandedDirs` without purging `dirCache`. After a watcher overflow discarded the precise
* events, those collapsed-but-cached listings are unverified and must be re-read on re-expansion —
* otherwise a file deleted while the dir was collapsed stays visible forever, since the expansion
* effect skips any dir that already has children.
*/
export function collectStaleDirCachePaths(
cache: Record<string, DirCache>,
worktreePath: string,
expanded: Set<string>
): string[] {
return Object.keys(cache).filter((dirPath) => dirPath !== worktreePath && !expanded.has(dirPath))
}
export type ExpandedDirLoadDecision = 'skip' | 'load' | 'reload'
/** What the expansion effect owes a newly expanded dir: nothing, a first read, or a forced re-read. */
export function decideExpandedDirLoad(
cached: DirCache | undefined,
stale: boolean
): ExpandedDirLoadDecision {
if (cached?.loading) {
return 'skip'
}
if (!cached?.children.length) {
return 'load'
}
return stale ? 'reload' : 'skip'
}
@@ -22,3 +22,12 @@ export type DirCache = {
loading: boolean
operationOwner?: FileExplorerOperationOwner
}
/**
* How a full tree refresh ended.
*
* Why three states, not a boolean: a caller that dropped its own pending dir refreshes because the
* tree refresh covered them must re-issue on `superseded` (someone newer owns those reads) but NOT
* on `root-unreadable` — there the transport is down, and re-issuing buys one dead timeout per dir.
*/
export type FileExplorerTreeRefreshOutcome = 'refreshed' | 'superseded' | 'root-unreadable'
@@ -439,3 +439,32 @@ describe('processFileExplorerFsPayload update reconciliation', () => {
expect(remainingExpanded).toEqual(new Set())
})
})
describe('processFileExplorerFsPayload overflow reconciliation', () => {
it('routes an overflow event to a single tree refresh and skips per-dir refreshes', () => {
const root = '/repo'
const refreshDir = vi.fn()
const refreshTree = vi.fn()
processFileExplorerFsPayload({
payload: {
worktreePath: root,
events: [
{ kind: 'create', absolutePath: `${root}/a.txt` },
{ kind: 'overflow', absolutePath: root },
{ kind: 'create', absolutePath: `${root}/src/b.txt` }
]
},
currentWorktreePath: root,
worktreeId: 'wt-1',
cache: { [root]: cacheWithChildren([]), [`${root}/src`]: cacheWithChildren([]) },
expanded: new Set([`${root}/src`]),
setDirCache: vi.fn(),
setSelectedPath: vi.fn(),
refreshDir,
refreshTree
})
expect(refreshTree).toHaveBeenCalledOnce()
expect(refreshDir).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,382 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createFileExplorerWatchRefreshScheduler } from './file-explorer-watch-refresh-scheduler'
import type { FileExplorerTreeRefreshOutcome } from './file-explorer-types'
const TRAILING_MS = 150
const MAX_WAIT_MS = 500
function createDeferred(): {
promise: Promise<FileExplorerTreeRefreshOutcome>
resolve: (outcome?: FileExplorerTreeRefreshOutcome) => void
} {
let resolve!: (outcome?: FileExplorerTreeRefreshOutcome) => void
const promise = new Promise<FileExplorerTreeRefreshOutcome>((res) => {
resolve = (outcome = 'refreshed') => res(outcome)
})
return { promise, resolve }
}
function setup(
overrides: Partial<Parameters<typeof createFileExplorerWatchRefreshScheduler>[0]> = {}
) {
const { refreshTree: treeImpl, refreshDir: dirImpl, ...rest } = overrides
const refreshTree = vi.fn(
treeImpl ?? (async (): Promise<FileExplorerTreeRefreshOutcome> => 'refreshed')
)
const refreshDir = vi.fn(dirImpl ?? (async (_dirPath: string) => {}))
const scheduler = createFileExplorerWatchRefreshScheduler({
refreshTree,
refreshDir,
isCoveredByFullRefresh: () => false,
dirConcurrency: 4,
...rest
})
return { refreshTree, refreshDir, scheduler }
}
describe('createFileExplorerWatchRefreshScheduler', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('coalesces a burst of full-refresh requests into one tree refresh', async () => {
const { refreshTree, scheduler } = setup()
for (let index = 0; index < 20; index++) {
scheduler.requestFullRefresh()
}
await vi.advanceTimersByTimeAsync(TRAILING_MS)
expect(refreshTree).toHaveBeenCalledTimes(1)
})
it('flushes at the max wait even when requests keep arriving inside the trailing window', async () => {
const { refreshTree, scheduler } = setup()
scheduler.requestFullRefresh()
for (let index = 0; index < 4; index++) {
await vi.advanceTimersByTimeAsync(100)
scheduler.requestFullRefresh()
}
expect(refreshTree).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(MAX_WAIT_MS - 400)
expect(refreshTree).toHaveBeenCalledTimes(1)
})
it('dedupes dir refreshes that differ only in path spelling', async () => {
const { refreshDir, scheduler } = setup()
// Spellings only the runtime-path normalizer folds: Windows separators and a
// trailing slash. Raw-string keying would issue three reads of one dir.
scheduler.requestDirRefresh('C:/repo/src/')
scheduler.requestDirRefresh('C:\\repo\\src')
scheduler.requestDirRefresh('C:/repo/src')
scheduler.requestDirRefresh('/repo/docs/')
scheduler.requestDirRefresh('/repo/docs')
await vi.advanceTimersByTimeAsync(TRAILING_MS)
expect(refreshDir).toHaveBeenCalledTimes(2)
expect(refreshDir.mock.calls.map(([dirPath]) => dirPath)).toEqual(['C:/repo/src', '/repo/docs'])
})
it('still refreshes a pending dir that a full refresh does not cover', async () => {
// Regression guard: dirCache keys are a strict superset of expanded dirs, so
// a full refresh must not subsume pending dir refreshes.
const { refreshTree, refreshDir, scheduler } = setup()
scheduler.requestDirRefresh('/repo/collapsed')
scheduler.requestFullRefresh()
await vi.advanceTimersByTimeAsync(TRAILING_MS)
expect(refreshTree).toHaveBeenCalledTimes(1)
expect(refreshDir).toHaveBeenCalledTimes(1)
expect(refreshDir).toHaveBeenCalledWith('/repo/collapsed')
})
it('skips a pending dir the full refresh already re-read', async () => {
const { refreshTree, refreshDir, scheduler } = setup({
isCoveredByFullRefresh: (dirPath) => dirPath === '/repo/src'
})
scheduler.requestDirRefresh('/repo/src')
scheduler.requestFullRefresh()
await vi.advanceTimersByTimeAsync(TRAILING_MS)
expect(refreshTree).toHaveBeenCalledTimes(1)
expect(refreshDir).not.toHaveBeenCalled()
})
it('re-issues covered dir refreshes when the tree refresh was superseded', async () => {
// Regression guard: refreshTree bails without re-reading the expanded dirs when its root
// load is superseded. Nothing marks an expanded dir stale, so dropping these would leave
// them stale until a manual refresh — collapse/re-expand does not heal it.
const gate = createDeferred()
const { refreshDir, scheduler } = setup({
refreshTree: () => gate.promise,
isCoveredByFullRefresh: (dirPath) => dirPath === '/repo/src'
})
scheduler.requestDirRefresh('/repo/src')
scheduler.requestFullRefresh()
await vi.advanceTimersByTimeAsync(TRAILING_MS)
gate.resolve('superseded')
await vi.advanceTimersByTimeAsync(TRAILING_MS)
expect(refreshDir).toHaveBeenCalledTimes(1)
expect(refreshDir).toHaveBeenCalledWith('/repo/src')
})
it('drops pending dir refreshes when the tree refresh could not read the root', async () => {
// Regression guard: a failed root read means the transport is down, so re-issuing buys one
// dead timeout per dir and holds inFlight open, blocking every later refresh.
const gate = createDeferred()
const { refreshDir, scheduler } = setup({
refreshTree: () => gate.promise,
isCoveredByFullRefresh: () => false
})
scheduler.requestDirRefresh('/repo/src')
scheduler.requestDirRefresh('/repo/docs')
scheduler.requestFullRefresh()
await vi.advanceTimersByTimeAsync(TRAILING_MS)
gate.resolve('root-unreadable')
await vi.advanceTimersByTimeAsync(MAX_WAIT_MS)
expect(refreshDir).not.toHaveBeenCalled()
})
it('refreshes a covered dir when no full refresh is pending', async () => {
const { refreshTree, refreshDir, scheduler } = setup({
isCoveredByFullRefresh: () => true
})
scheduler.requestDirRefresh('/repo/src')
await vi.advanceTimersByTimeAsync(TRAILING_MS)
expect(refreshTree).not.toHaveBeenCalled()
expect(refreshDir).toHaveBeenCalledTimes(1)
})
it('does not start a second run while the first is still in flight', async () => {
const gate = createDeferred()
const { refreshTree, scheduler } = setup({ refreshTree: () => gate.promise })
scheduler.requestFullRefresh()
await vi.advanceTimersByTimeAsync(TRAILING_MS)
expect(refreshTree).toHaveBeenCalledTimes(1)
scheduler.requestFullRefresh()
await vi.advanceTimersByTimeAsync(MAX_WAIT_MS)
expect(refreshTree).toHaveBeenCalledTimes(1)
gate.resolve()
await vi.advanceTimersByTimeAsync(TRAILING_MS)
expect(refreshTree).toHaveBeenCalledTimes(2)
})
it('flushes a dir request that arrived during an in-flight run', async () => {
const gate = createDeferred()
const { refreshDir, scheduler } = setup({ refreshTree: () => gate.promise })
scheduler.requestFullRefresh()
await vi.advanceTimersByTimeAsync(TRAILING_MS)
scheduler.requestDirRefresh('/repo/late')
await vi.advanceTimersByTimeAsync(MAX_WAIT_MS)
expect(refreshDir).not.toHaveBeenCalled()
gate.resolve()
await vi.advanceTimersByTimeAsync(TRAILING_MS)
expect(refreshDir).toHaveBeenCalledTimes(1)
expect(refreshDir).toHaveBeenCalledWith('/repo/late')
})
it('never runs more dir refreshes at once than dirConcurrency', async () => {
let inFlight = 0
let peakInFlight = 0
const refreshDir = vi.fn(async (_dirPath: string) => {
inFlight++
peakInFlight = Math.max(peakInFlight, inFlight)
await new Promise((resolve) => setTimeout(resolve, 10))
inFlight--
})
const { scheduler } = setup({ refreshDir, dirConcurrency: 4 })
for (let index = 0; index < 20; index++) {
scheduler.requestDirRefresh(`/repo/d${index}`)
}
await vi.advanceTimersByTimeAsync(TRAILING_MS + 20 * 10)
expect(refreshDir).toHaveBeenCalledTimes(20)
expect(peakInFlight).toBeLessThanOrEqual(4)
})
it('cancels a pending flush', async () => {
const { refreshTree, refreshDir, scheduler } = setup()
scheduler.requestFullRefresh()
scheduler.requestDirRefresh('/repo/src')
scheduler.cancel()
await vi.advanceTimersByTimeAsync(MAX_WAIT_MS)
expect(refreshTree).not.toHaveBeenCalled()
expect(refreshDir).not.toHaveBeenCalled()
})
it('stops refreshing queued dirs when cancelled mid-run', async () => {
// Regression guard: refreshDir is bound to a live ref that React reassigns
// before the effect cleanup runs, so a queued path surviving cancel() would
// be read against the NEXT worktree's binding.
const gate = createDeferred()
const refreshDir = vi.fn(async (dirPath: string) => {
if (dirPath === '/repo/d0') {
await gate.promise
}
})
const { scheduler } = setup({ refreshDir, dirConcurrency: 1 })
for (let index = 0; index < 4; index++) {
scheduler.requestDirRefresh(`/repo/d${index}`)
}
await vi.advanceTimersByTimeAsync(TRAILING_MS)
expect(refreshDir).toHaveBeenCalledTimes(1)
scheduler.cancel()
gate.resolve()
await vi.advanceTimersByTimeAsync(MAX_WAIT_MS)
expect(refreshDir).toHaveBeenCalledTimes(1)
})
it('still refreshes a dir first expanded while the tree refresh was in flight', async () => {
const gate = createDeferred()
const expanded = new Set<string>()
const { refreshDir, scheduler } = setup({
refreshTree: () => gate.promise,
isCoveredByFullRefresh: (dirPath) => expanded.has(dirPath)
})
scheduler.requestDirRefresh('/repo/late')
scheduler.requestFullRefresh()
await vi.advanceTimersByTimeAsync(TRAILING_MS)
// refreshTree already ran against the old expanded set, so this dir was
// never re-read and its pending refresh must survive.
expanded.add('/repo/late')
gate.resolve()
await vi.advanceTimersByTimeAsync(TRAILING_MS)
expect(refreshDir).toHaveBeenCalledTimes(1)
expect(refreshDir).toHaveBeenCalledWith('/repo/late')
})
it('drops a dir queued during an in-flight run when cancel lands first', async () => {
const gate = createDeferred()
const { refreshTree, refreshDir, scheduler } = setup({ refreshTree: () => gate.promise })
scheduler.requestFullRefresh()
await vi.advanceTimersByTimeAsync(TRAILING_MS)
scheduler.requestDirRefresh('/repo/src')
scheduler.cancel()
gate.resolve()
await vi.advanceTimersByTimeAsync(MAX_WAIT_MS)
expect(refreshTree).toHaveBeenCalledTimes(1)
expect(refreshDir).not.toHaveBeenCalled()
})
it('uses the injected timer functions', async () => {
const schedule = vi.fn((callback: () => void, delayMs: number) => setTimeout(callback, delayMs))
const clear = vi.fn((timer: ReturnType<typeof setTimeout>) => {
clearTimeout(timer)
})
const { refreshTree, scheduler } = setup({ schedule, clear })
scheduler.requestFullRefresh()
scheduler.requestFullRefresh()
await vi.advanceTimersByTimeAsync(TRAILING_MS)
expect(schedule).toHaveBeenCalledTimes(2)
expect(clear).toHaveBeenCalledTimes(1)
expect(refreshTree).toHaveBeenCalledTimes(1)
})
it('keeps a full refresh ahead of the dir refreshes it does not cover', async () => {
const order: string[] = []
const { scheduler } = setup({
refreshTree: async (): Promise<FileExplorerTreeRefreshOutcome> => {
order.push('tree')
return 'refreshed'
},
refreshDir: async (dirPath) => {
order.push(dirPath)
}
})
scheduler.requestDirRefresh('/repo/collapsed')
scheduler.requestFullRefresh()
await vi.advanceTimersByTimeAsync(TRAILING_MS)
expect(order).toEqual(['tree', '/repo/collapsed'])
})
// Why: local workspaces pass a zero window because the main process already coalesced the burst
// on the same 150/500 timings. Stacking a second debounce there only doubled paint latency.
describe('zero window (local transport)', () => {
const zeroWindow = { trailingMs: 0, maxWaitMs: 0 }
it('flushes on the next tick instead of waiting out a trailing window', async () => {
const { refreshTree, scheduler } = setup(zeroWindow)
scheduler.requestFullRefresh()
await vi.advanceTimersByTimeAsync(0)
expect(refreshTree).toHaveBeenCalledTimes(1)
})
it('still coalesces one payload burst into a single refresh', async () => {
const { refreshTree, refreshDir, scheduler } = setup(zeroWindow)
for (let index = 0; index < 20; index++) {
scheduler.requestFullRefresh()
scheduler.requestDirRefresh('/repo/src')
}
await vi.advanceTimersByTimeAsync(0)
expect(refreshTree).toHaveBeenCalledTimes(1)
expect(refreshDir).toHaveBeenCalledTimes(1)
expect(refreshDir).toHaveBeenCalledWith('/repo/src')
})
it('does not start a second run while one is in flight', async () => {
const deferred = createDeferred()
const { refreshTree, scheduler } = setup({
...zeroWindow,
refreshTree: () => deferred.promise
})
scheduler.requestFullRefresh()
await vi.advanceTimersByTimeAsync(0)
scheduler.requestFullRefresh()
await vi.advanceTimersByTimeAsync(0)
expect(refreshTree).toHaveBeenCalledTimes(1)
deferred.resolve()
await vi.advanceTimersByTimeAsync(0)
expect(refreshTree).toHaveBeenCalledTimes(2)
})
})
})
@@ -0,0 +1,152 @@
import type { FileExplorerTreeRefreshOutcome } from './file-explorer-types'
import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path'
import {
WATCH_BATCH_MAX_WAIT_MS,
WATCH_BATCH_TRAILING_MS
} from '../../../../shared/filesystem-watch-batch-window'
import { forEachWithConcurrency } from '../../../../shared/map-with-concurrency'
type SchedulerTimer = ReturnType<typeof setTimeout>
export type FileExplorerWatchRefreshScheduler = {
requestFullRefresh: () => void
requestDirRefresh: (dirPath: string) => void
cancel: () => void
}
export type CreateFileExplorerWatchRefreshSchedulerParams = {
refreshTree: () => Promise<FileExplorerTreeRefreshOutcome>
refreshDir: (dirPath: string) => Promise<void>
/** Dirs a full tree refresh already re-reads; the scheduler has no view of `expanded`. */
isCoveredByFullRefresh: (dirPath: string) => boolean
dirConcurrency: number
trailingMs?: number
maxWaitMs?: number
schedule?: (callback: () => void, delayMs: number) => SchedulerTimer
clear?: (timer: SchedulerTimer) => void
}
/**
* Coalesces watcher-driven File Explorer refreshes into one debounced run.
*
* Why: an overflow storm otherwise issues K × (1 + N_expanded) readDirectory
* round trips on the same SSH mux that carries PTY output. Transport-agnostic on
* purpose — it sits above both the Electron fs:changed bus and the runtime-RPC
* file-change subscription.
*/
export function createFileExplorerWatchRefreshScheduler({
refreshTree,
refreshDir,
isCoveredByFullRefresh,
dirConcurrency,
trailingMs = WATCH_BATCH_TRAILING_MS,
maxWaitMs = WATCH_BATCH_MAX_WAIT_MS,
schedule = setTimeout,
clear = clearTimeout
}: CreateFileExplorerWatchRefreshSchedulerParams): FileExplorerWatchRefreshScheduler {
let pendingFull = false
const pendingDirs = new Map<string, string>()
let firstRequestAt: number | null = null
let timer: SchedulerTimer | null = null
let inFlight: Promise<void> | null = null
let disposed = false
function clearTimer(): void {
if (timer !== null) {
clear(timer)
timer = null
}
}
function arm(delayMs: number): void {
clearTimer()
timer = schedule(() => {
timer = null
// Why: a run started here would race the in-flight one; the settle
// handler re-arms instead, so nothing pending is dropped.
if (inFlight === null) {
run()
}
}, delayMs)
}
function armForRequest(): void {
firstRequestAt ??= Date.now()
arm(Math.max(0, Math.min(trailingMs, firstRequestAt + maxWaitMs - Date.now())))
}
function run(): void {
const full = pendingFull
const dirs = Array.from(pendingDirs.values())
pendingFull = false
pendingDirs.clear()
firstRequestAt = null
const started = (async () => {
// Why: isCoveredByFullRefresh reads a live `expanded` ref, so resolve it
// against the set refreshTree is about to read — a dir expanded during the
// tree refresh was never re-read and must keep its pending refresh.
const covered = full ? new Set(dirs.filter(isCoveredByFullRefresh)) : null
const outcome = full && !disposed ? await refreshTree() : null
// Why: refreshTree bails without touching the expanded dirs when its root load is
// superseded, so trusting `covered` there would drop those refreshes for good — nothing
// marks an expanded dir stale, so a later re-expansion won't re-read it either.
// Why: 'root-unreadable' is the opposite case. The transport just failed a read, so
// re-issuing buys one dead timeout per dir and holds `inFlight` open the whole time,
// blocking every later refresh. Drop them; a recovered watcher resyncs with an overflow.
const outstanding =
outcome === 'root-unreadable'
? []
: covered && outcome === 'refreshed'
? dirs.filter((dirPath) => !covered.has(dirPath))
: dirs
// Why: forEachWithConcurrency has no cancel hook, and the bound refreshDir
// is a live ref — a queued path would otherwise be read against the next
// worktree's binding after a switch.
await forEachWithConcurrency(outstanding, dirConcurrency, (dirPath) =>
disposed ? Promise.resolve() : refreshDir(dirPath)
)
})()
inFlight = started
void started
.catch(() => {
// Refresh failures are transient; the next watcher event retries.
})
.finally(() => {
if (inFlight === started) {
inFlight = null
}
if (!disposed && (pendingFull || pendingDirs.size > 0)) {
arm(trailingMs)
}
})
}
return {
requestFullRefresh: () => {
if (disposed) {
return
}
// Why: pendingDirs is deliberately NOT cleared. toggleDir drops a dir from
// expandedDirs without purging dirCache, so cache keys are a strict
// superset of what refreshTree re-reads; subsuming them would strand a
// stale listing for a collapsed-but-cached dir.
pendingFull = true
armForRequest()
},
requestDirRefresh: (dirPath: string) => {
if (disposed) {
return
}
pendingDirs.set(normalizeRuntimePathForComparison(dirPath), dirPath)
armForRequest()
},
cancel: () => {
disposed = true
clearTimer()
pendingFull = false
pendingDirs.clear()
firstRequestAt = null
}
}
}
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
const REFRESH_SPINNER_DELAY_MS = 200
export function useFileExplorerManualRefresh(refreshTree: () => Promise<void>): {
export function useFileExplorerManualRefresh(refreshTree: () => Promise<unknown>): {
isRefreshing: boolean
showRefreshSpinner: boolean
handleRefresh: () => void
@@ -0,0 +1,155 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DirEntry } from '../../../../shared/types'
import { useFileExplorerTree } from './useFileExplorerTree'
const readDirectoryMock = vi.hoisted(() => vi.fn())
vi.mock('./file-explorer-directory-listing', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
readFileExplorerDirectory: readDirectoryMock
}))
vi.mock('./file-explorer-operation-owner', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getFileExplorerOperationOwner: () => ({ kind: 'local' as const })
}))
function entry(name: string, isDirectory = false): DirEntry {
return { name, isDirectory } as DirEntry
}
function listing(...entries: DirEntry[]) {
return { entries, operationOwner: { kind: 'local' as const } }
}
describe('useFileExplorerTree stale collapsed dirs', () => {
beforeEach(() => {
readDirectoryMock.mockReset()
readDirectoryMock.mockResolvedValue(listing())
})
afterEach(() => {
cleanup()
})
it('marks a collapsed cached dir the full refresh skipped, and clears it on the next read', async () => {
const { result } = renderHook(() => useFileExplorerTree('/repo', new Set(), 'wt-1'))
readDirectoryMock.mockResolvedValueOnce(listing(entry('gone.ts')))
await act(async () => {
await result.current.loadDir('/repo/src', 0)
})
expect(result.current.dirCache['/repo/src'].children).toHaveLength(1)
expect(result.current.isDirStale('/repo/src')).toBe(false)
// The overflow refresh reads root and the (empty) expanded set — not the collapsed /repo/src.
readDirectoryMock.mockClear()
await act(async () => {
await result.current.refreshTree()
})
expect(readDirectoryMock).not.toHaveBeenCalledWith('wt-1', '/repo', '/repo/src')
expect(result.current.isDirStale('/repo/src')).toBe(true)
// Its cached children stay put until something re-reads them.
expect(result.current.dirCache['/repo/src'].children).toHaveLength(1)
await act(async () => {
await result.current.loadDir('/repo/src', 0, { force: true })
})
expect(result.current.isDirStale('/repo/src')).toBe(false)
expect(result.current.dirCache['/repo/src'].children).toHaveLength(0)
})
it('does not mark the root or a dir the full refresh re-read', async () => {
const expanded = new Set(['/repo/src'])
const { result } = renderHook(() => useFileExplorerTree('/repo', expanded, 'wt-1'))
await act(async () => {
await result.current.loadDir('/repo/src', 0)
})
await act(async () => {
await result.current.refreshTree()
})
expect(result.current.isDirStale('/repo')).toBe(false)
expect(result.current.isDirStale('/repo/src')).toBe(false)
})
it('keeps a mark a superseded refresh never verified, even once the dir is expanded', async () => {
// Regression guard: refreshTree used to REPLACE the mark set. A dir marked while collapsed and
// then expanded was dropped by the replacement (expanded dirs are never marked) — and when that
// refresh bailed on a superseded root load, nothing re-read it and nothing still called it
// unverified, so re-expanding served the pre-overflow listing forever.
const expanded = new Set<string>()
const { result, rerender } = renderHook(() => useFileExplorerTree('/repo', expanded, 'wt-1'))
readDirectoryMock.mockResolvedValueOnce(listing(entry('gone.ts')))
await act(async () => {
await result.current.loadDir('/repo/src', 0)
})
await act(async () => {
await result.current.refreshTree()
})
expect(result.current.isDirStale('/repo/src')).toBe(true)
expanded.add('/repo/src')
rerender()
let releaseRoot!: () => void
const rootGate = new Promise<void>((resolve) => {
releaseRoot = resolve
})
readDirectoryMock.mockImplementationOnce(async () => {
await rootGate
return listing()
})
let refresh!: Promise<string>
act(() => {
refresh = result.current.refreshTree()
})
await act(async () => {
// A concurrent root read supersedes this refresh's token, so it bails before the expanded set.
void result.current.refreshDir('/repo')
releaseRoot()
expect(await refresh).toBe('superseded')
})
expect(result.current.isDirStale('/repo/src')).toBe(true)
})
it('does not read the expanded dirs when the root read failed', async () => {
// Regression guard: loadDir swallows read errors unless failOnError is set, so a dead
// transport used to report a completed root read and fan out one doomed wave per 4 dirs.
const expanded = new Set(['/repo/a', '/repo/b'])
const { result } = renderHook(() => useFileExplorerTree('/repo', expanded, 'wt-1'))
readDirectoryMock.mockReset()
readDirectoryMock.mockRejectedValue(new Error('ETIMEDOUT'))
await act(async () => {
expect(await result.current.refreshTree()).toBe('root-unreadable')
})
// Only the root read was attempted; the expanded dirs were not.
expect(readDirectoryMock).toHaveBeenCalledTimes(1)
expect(readDirectoryMock).toHaveBeenCalledWith('wt-1', '/repo', '/repo')
})
it('drops stale marks when the tree is reset for a new worktree', async () => {
const { result } = renderHook(() => useFileExplorerTree('/repo', new Set(), 'wt-1'))
await act(async () => {
await result.current.loadDir('/repo/src', 0)
})
await act(async () => {
await result.current.refreshTree()
})
expect(result.current.isDirStale('/repo/src')).toBe(true)
await act(async () => {
result.current.resetAndLoad()
})
expect(result.current.isDirStale('/repo/src')).toBe(false)
})
})
@@ -1,12 +1,9 @@
import type { Dispatch, SetStateAction } from 'react'
import { useCallback, useRef, useState } from 'react'
import type { DirCache } from './file-explorer-types'
import type { DirCache, FileExplorerTreeRefreshOutcome } from './file-explorer-types'
import { splitPathSegments } from './path-tree'
import { statRuntimePath } from '@/runtime/runtime-file-client'
import {
createFileExplorerDirLoadTracker,
type FileExplorerDirLoadTracker
} from './file-explorer-dir-load-tracker'
import { createFileExplorerDirLoadTracker } from './file-explorer-dir-load-tracker'
import {
getFileExplorerOperationOwner,
getFileExplorerOwnerUnresolvedMessage,
@@ -14,9 +11,11 @@ import {
} from './file-explorer-operation-owner'
import {
fileExplorerEntriesToTreeNodes,
readFileExplorerDirectory,
type FileExplorerDirectoryListing
readFileExplorerDirectory
} from './file-explorer-directory-listing'
import { refreshFileExplorerExpandedDirs } from './file-explorer-expanded-dirs-refresh'
import { collectStaleDirCachePaths } from './file-explorer-stale-dir-cache'
import { fileExplorerRefreshConcurrency } from './file-explorer-refresh-concurrency'
type UseFileExplorerTreeResult = {
dirCache: Record<string, DirCache>
@@ -30,113 +29,13 @@ type UseFileExplorerTreeResult = {
) => Promise<boolean>
statPath: (path: string) => Promise<{ isDirectory: boolean }>
markPathAsDirectory: (path: string) => void
refreshTree: () => Promise<void>
refreshTree: () => Promise<FileExplorerTreeRefreshOutcome>
refreshDir: (dirPath: string) => Promise<void>
/** True while a dir's cached listing predates the last full refresh that skipped it. */
isDirStale: (dirPath: string) => boolean
resetAndLoad: () => void
}
type RefreshFileExplorerTreeDir = {
dirPath: string
depth: number
}
type RefreshFileExplorerExpandedDirsParams = {
dirs: RefreshFileExplorerTreeDir[]
worktreePath: string
dirLoadTracker: FileExplorerDirLoadTracker
setDirCache: Dispatch<SetStateAction<Record<string, DirCache>>>
readDirectory: (dirPath: string) => Promise<FileExplorerDirectoryListing>
}
export async function refreshFileExplorerExpandedDirs({
dirs,
worktreePath,
dirLoadTracker,
setDirCache,
readDirectory
}: RefreshFileExplorerExpandedDirsParams): Promise<boolean> {
if (dirs.length === 0) {
return true
}
const uniqueDirs = Array.from(new Map(dirs.map((dir) => [dir.dirPath, dir])).values())
const loadTokens = new Map(
uniqueDirs.map((dir) => [dir.dirPath, dirLoadTracker.begin(dir.dirPath)])
)
// Why: expanded refresh can touch many directories; commit the loading and
// result states in two batched setDirCache writes (rather than per-directory)
// so refreshing large worktrees stays O(N) instead of O(N²) cache spreads.
setDirCache((prev) => {
const next = { ...prev }
for (const { dirPath } of uniqueDirs) {
next[dirPath] = {
children: prev[dirPath]?.children ?? [],
loading: true
}
}
return next
})
const results = await Promise.all(
uniqueDirs.map(async ({ dirPath, depth }) => {
const loadToken = loadTokens.get(dirPath)!
try {
const listing = await readDirectory(dirPath)
if (!dirLoadTracker.isCurrent(loadToken)) {
return { current: false as const }
}
return {
current: true as const,
dirPath,
cache: {
children: fileExplorerEntriesToTreeNodes(
listing.entries,
dirPath,
depth,
worktreePath,
listing.operationOwner
),
loading: false,
operationOwner: listing.operationOwner
}
}
} catch {
if (!dirLoadTracker.isCurrent(loadToken)) {
return { current: false as const }
}
return {
current: true as const,
dirPath,
cache: { children: [], loading: false }
}
}
})
)
// Why: the batch commits only after the slowest read, so a dir can be
// superseded (watcher refreshDir, worktree reset) after its own read
// resolved. Re-check tokens at commit time so the batched write never
// clobbers a newer load — preserving the old per-dir commit ordering.
const currentResults = results.filter(
(result): result is Extract<typeof result, { current: true }> =>
result.current && dirLoadTracker.isCurrent(loadTokens.get(result.dirPath)!)
)
if (currentResults.length === 0) {
return false
}
setDirCache((prev) => {
const next = { ...prev }
for (const result of currentResults) {
next[result.dirPath] = result.cache
}
return next
})
return currentResults.length === uniqueDirs.length
}
export function useFileExplorerTree(
worktreePath: string | null,
expanded: Set<string>,
@@ -147,6 +46,11 @@ export function useFileExplorerTree(
const dirCacheRef = useRef(dirCache)
dirCacheRef.current = dirCache
const dirLoadTrackerRef = useRef(createFileExplorerDirLoadTracker())
// Why: a ref, not state — the expansion effect must read the mark set by a refresh that landed
// after the effect's render, and a state write would only be visible one render too late.
const staleDirsRef = useRef(new Set<string>())
// Why: separates a failed root read from a superseded one — loadDir returns false for both.
const rootReadFailedRef = useRef(false)
const loadDir = useCallback(
async (
@@ -159,6 +63,8 @@ export function useFileExplorerTree(
return true
}
const loadToken = dirLoadTrackerRef.current.begin(dirPath)
// Why: this read starts after the refresh that marked the dir, so its result is current.
staleDirsRef.current.delete(dirPath)
// Why: when force-reloading a directory (e.g. after a file is created,
// duplicated, or deleted), keep the previous children visible while the
// fresh listing loads. Clearing to [] would momentarily shrink the
@@ -200,6 +106,7 @@ export function useFileExplorerTree(
// empty worktree. Preserve the message so the UI can distinguish
// "no files" from "could not read this worktree".
setRootError(error instanceof Error ? error.message : String(error))
rootReadFailedRef.current = true
}
setDirCache((prev) => ({ ...prev, [dirPath]: { children: [], loading: false } }))
return !options?.failOnError
@@ -248,17 +155,42 @@ export function useFileExplorerTree(
[activeWorktreeId, worktreePath]
)
const refreshTree = useCallback(async () => {
const refreshTree = useCallback(async (): Promise<FileExplorerTreeRefreshOutcome> => {
if (!worktreePath) {
return
// Why: not 'root-unreadable' — no read was attempted, and that outcome tells callers to DROP
// their pending refreshes. Report the refresh as not-done so they keep them instead.
return 'superseded'
}
// Why: clearing the entire dirCache here would momentarily empty the
// visible projection and jump the virtualizer to the top. Instead we rely
// on force-reload keeping existing children visible until fresh data lands.
// Why: mark before the first read, against the live expanded set — a dir expanded after this
// point loads through the expansion effect, which would otherwise trust a listing this refresh
// never re-read.
// Why: union, not replace. This refresh can bail below without re-reading a single expanded
// dir, so replacing would erase an earlier mark for a dir nothing has verified since. Marks are
// cleared only where a read actually lands: loadDir, and onDirCommitted per committed wave.
// Why: prune first — a mark only means anything while the dir still has a cached listing to
// distrust, and unioning without this would accumulate every path visited in the session.
for (const dirPath of staleDirsRef.current) {
if (dirCacheRef.current[dirPath] === undefined) {
staleDirsRef.current.delete(dirPath)
}
}
// Why: callers use the latest refreshTree identity, so this closure has the live expanded set.
for (const dirPath of collectStaleDirCachePaths(dirCacheRef.current, worktreePath, expanded)) {
staleDirsRef.current.add(dirPath)
}
const refreshSession = dirLoadTrackerRef.current.getSession()
const rootLoadCompleted = await loadDir(worktreePath, -1, { force: true })
rootReadFailedRef.current = false
// Why: failOnError, else a dead transport reports a completed root read and we fan out one
// doomed wave per 4 expanded dirs — 200 dirs is ~50 sequential 15s timeouts, and the watch
// scheduler cannot start another refresh for that entire window.
const rootLoadCompleted = await loadDir(worktreePath, -1, { force: true, failOnError: true })
if (!rootLoadCompleted || !dirLoadTrackerRef.current.isSessionCurrent(refreshSession)) {
return
// Why: the expanded dirs below were never re-read either way, but the two reasons want
// opposite handling from callers — see FileExplorerTreeRefreshOutcome.
return rootReadFailedRef.current ? 'root-unreadable' : 'superseded'
}
// Why: root (worktreePath) was just force-loaded above; exclude it here so
// refreshFileExplorerExpandedDirs doesn't queue a duplicate read of root.
@@ -268,13 +200,19 @@ export function useFileExplorerTree(
dirPath,
depth: splitPathSegments(dirPath.slice(worktreePath.length + 1)).length - 1
}))
await refreshFileExplorerExpandedDirs({
const allDirsCommitted = await refreshFileExplorerExpandedDirs({
dirs: expandedDirs,
worktreePath,
dirLoadTracker: dirLoadTrackerRef.current,
setDirCache,
readDirectory: (dirPath) => readFileExplorerDirectory(activeWorktreeId, worktreePath, dirPath)
readDirectory: (dirPath) =>
readFileExplorerDirectory(activeWorktreeId, worktreePath, dirPath),
maxConcurrentReads: fileExplorerRefreshConcurrency(
getFileExplorerOperationOwner(activeWorktreeId)
),
onDirCommitted: (dirPath) => staleDirsRef.current.delete(dirPath)
})
return allDirsCommitted ? 'refreshed' : 'superseded'
}, [activeWorktreeId, expanded, loadDir, worktreePath])
const refreshDir = useCallback(
@@ -291,12 +229,15 @@ export function useFileExplorerTree(
[worktreePath, loadDir]
)
const isDirStale = useCallback((dirPath: string) => staleDirsRef.current.has(dirPath), [])
const rootCache = worktreePath ? dirCache[worktreePath] : undefined
const resetAndLoad = useCallback(() => {
// Why: stale readDir responses from the previous worktree/reset session
// must not repopulate the explorer after the tree has been cleared.
dirLoadTrackerRef.current.reset()
staleDirsRef.current.clear()
setDirCache({})
setRootError(null)
if (worktreePath) {
@@ -314,6 +255,7 @@ export function useFileExplorerTree(
markPathAsDirectory,
refreshTree,
refreshDir,
isDirStale,
resetAndLoad
}
}
@@ -22,24 +22,28 @@ function useProjection(query: string) {
})
}
function useTreeProjection() {
function treeDirCache(loading: boolean) {
return {
'/repo': {
children: [
{
name: 'src',
path: '/repo/src',
relativePath: 'src',
isDirectory: true,
depth: 0
}
],
loading
}
}
}
function useTreeProjection(dirCache = treeDirCache(false)) {
return useFileExplorerVisibleRowProjection(
'worktree-1',
'/repo',
{
'/repo': {
children: [
{
name: 'src',
path: '/repo/src',
relativePath: 'src',
isDirectory: true,
depth: 0
}
],
loading: false
}
},
dirCache,
new Set(),
true,
true,
@@ -95,4 +99,19 @@ describe('file explorer ignored-path query debounce', () => {
expect(getRuntimeGitIgnoredPathsMock).toHaveBeenCalledTimes(1)
expect(getRuntimeGitIgnoredPathsMock.mock.calls[0]?.[1]).toEqual(['src'])
})
it('does not re-issue the ignored check for a dirCache commit that changes no path', () => {
// A wave-batched tree refresh commits a fresh dirCache object per wave. The
// ignored query is an uncancellable remote git check-ignore over the whole
// visible tree, so identical contents must not re-issue it.
const hook = renderHook(({ dirCache }) => useTreeProjection(dirCache), {
initialProps: { dirCache: treeDirCache(false) }
})
expect(getRuntimeGitIgnoredPathsMock).toHaveBeenCalledTimes(1)
hook.rerender({ dirCache: treeDirCache(true) })
hook.rerender({ dirCache: treeDirCache(false) })
expect(getRuntimeGitIgnoredPathsMock).toHaveBeenCalledTimes(1)
})
})
@@ -109,6 +109,28 @@ export function createVisibleFileExplorerRowProjection(
return createFileExplorerRowProjectionFromParts(visibleFlatRows, rowsByPath)
}
/**
* Holds the array identity while its contents are unchanged.
*
* Why: a tree refresh commits dirCache once per read wave, and every commit
* rebuilds this list. Each new identity would re-issue the uncancellable git
* check-ignore over the whole visible tree — the remote round trips the wave cap
* exists to bound.
*/
function useContentStableRelativePaths(relativePaths: string[], enabled: boolean): string[] {
// Why: filters need fresh identities per keystroke and must not evict the tree signature.
// Why: NUL cannot occur in paths, so the signature can reconstruct the list losslessly.
const signature = useMemo(
() => (enabled ? relativePaths.join('\u0000') : null),
[enabled, relativePaths]
)
const stableTreePaths = useMemo(
() => (signature ? signature.split('\u0000') : EMPTY_RELATIVE_PATHS),
[signature]
)
return enabled ? stableTreePaths : relativePaths
}
export function useFileExplorerVisibleRowProjection(
activeWorktreeId: string | null,
worktreePath: string | null,
@@ -128,7 +150,7 @@ export function useFileExplorerVisibleRowProjection(
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
const showGitIgnoredFiles = settings?.showGitIgnoredFiles ?? true
const relativePaths = useMemo(
const rebuiltRelativePaths = useMemo(
() =>
activeRepoSupportsGit
? nameFilter
@@ -140,6 +162,9 @@ export function useFileExplorerVisibleRowProjection(
: EMPTY_RELATIVE_PATHS,
[activeRepoSupportsGit, dirCache, expanded, nameFilter, showDotfiles, worktreePath]
)
// Why: the name-filter list is debounced per keystroke, so it must keep a fresh
// identity; only the dirCache-derived list needs stability across wave commits.
const relativePaths = useContentStableRelativePaths(rebuiltRelativePaths, !nameFilter)
const canLoadIgnoredPaths =
activeRepoSupportsGit &&
Boolean(activeWorktreeId) &&
@@ -1,13 +1,21 @@
import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
import type { FsChangedPayload } from '../../../../shared/types'
import type { DirCache, FileExplorerOperationOwner } from './file-explorer-types'
import type {
DirCache,
FileExplorerOperationOwner,
FileExplorerTreeRefreshOutcome
} from './file-explorer-types'
import type { InlineInput } from './FileExplorerRow'
import { useAppStore } from '@/store'
import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client'
import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path'
import {
getFileExplorerOperationOwner,
getFileExplorerOperationOwnerFromState,
type FileExplorerOwnerState
} from './file-explorer-operation-owner'
import { fileExplorerRefreshConcurrency } from './file-explorer-refresh-concurrency'
import { createFileExplorerWatchRefreshScheduler } from './file-explorer-watch-refresh-scheduler'
import { processFileExplorerFsPayload } from './file-explorer-watch-reconcile'
export {
@@ -30,7 +38,7 @@ type UseFileExplorerWatchParams = {
expanded: Set<string>
setSelectedPath: Dispatch<SetStateAction<string | null>>
refreshDir: (dirPath: string) => Promise<void>
refreshTree: () => Promise<void>
refreshTree: () => Promise<FileExplorerTreeRefreshOutcome>
inlineInput: InlineInput | null
dragSourcePath: string | null
isNativeDragOver: boolean
@@ -127,6 +135,29 @@ export function useFileExplorerWatch({
const currentWorktreePath = worktreePath
// Why: one scheduler per subscription covers BOTH remote transports (the
// Electron fs:changed bus and the runtime-RPC subscription below), and its
// lifetime is tied to the watched worktree so a switch can't refresh the
// previous root.
const owner = getFileExplorerOperationOwner(worktreeIdRef.current)
const scheduler = createFileExplorerWatchRefreshScheduler({
refreshTree: () => refreshTreeRef.current(),
refreshDir: (dirPath) => refreshDirRef.current(dirPath),
// Why exact-match on `expanded`, not a normalized compare: dirPath already arrives as a
// resolved dirCache key (resolveCachedDirPath), and refreshTree re-reads dirCache under the
// verbatim expanded strings — a differently-spelled key it never wrote would be reported
// covered and left stale. Only the root is normalized: it is keyed by worktreePath.
isCoveredByFullRefresh: (dirPath) =>
normalizeRuntimePathForComparison(dirPath) ===
normalizeRuntimePathForComparison(currentWorktreePath) ||
expandedRef.current.has(dirPath),
dirConcurrency: fileExplorerRefreshConcurrency(owner),
// Why: main already coalesced the burst on this same 150/500 window before it reached the
// fs:changed bus, so a second debounce here only doubles paint latency. Zero still arms a 0ms
// timer, keeping intra-payload dedup and the concurrency cap.
...(owner.kind === 'local' ? { trailingMs: 0, maxWaitMs: 0 } : {})
})
function processPayload(payload: FsChangedPayload): void {
const wtId = worktreeIdRef.current
if (!wtId) {
@@ -140,12 +171,8 @@ export function useFileExplorerWatch({
expanded: expandedRef.current,
setDirCache,
setSelectedPath,
refreshDir: (dirPath) => {
void refreshDirRef.current(dirPath)
},
refreshTree: () => {
void refreshTreeRef.current()
}
refreshDir: scheduler.requestDirRefresh,
refreshTree: scheduler.requestFullRefresh
})
}
@@ -207,6 +234,7 @@ export function useFileExplorerWatch({
return () => {
disposed = true
unsubscribeListener?.()
scheduler.cancel()
deferredRef.current = []
processPayloadRef.current = null
}
@@ -0,0 +1,5 @@
// Why: the main-process watcher batcher and the renderer's Explorer refresh
// scheduler share one flush window so local and remote latency can't drift.
export const WATCH_BATCH_TRAILING_MS = 150
export const WATCH_BATCH_MAX_WAIT_MS = 500