perf(main): park worktree metadata pollers while the window is hidden (#9849)

* perf(main): park worktree pollers while hidden

The per-repo worktree metadata pollers ran 24/7 with no window-visibility gate
(~50 fs.stat/sec at 20 repos on macOS while the window is hidden). Park the poll
timers while the window is hidden and resume losslessly (immediate fresh-vs-retained
snapshot diff) on reveal, via an injected WorktreePollerWindowVisibility mirroring
the ssh-port-scanner pattern.

Only the poll timers are gated; the darwin native worktrees/ fsevents watch and its
teardown/re-arm lifecycle stay always-on (push-based, ~0 idle cost; #8732 race).

A window is parked only once it has actually been shown and is now hidden: a live
never-shown window (ORCA_E2E_HEADLESS keeps one) and null/destroyed windows stay
always-visible, so a windowless/headless host never permanently parks the poller.

Co-authored-by: Orca <help@stably.ai>

* fix(main): keep start-to-start poller cadence after the interval→timeout change

Parking the pollers moved them from setInterval to a one-shot setTimeout chain, but
scheduling the next tick a full pollIntervalMs AFTER each scan completed turned the
cadence into gap-after-completion — every visible refresh landed ~one scan-duration
late per tick. Measure from tick start and schedule max(0, interval - elapsed) so the
cadence is start-to-start like the original interval, while keeping the one-shot chain
that park/resume needs.

Co-authored-by: Orca <help@stably.ai>

* fix(main): clamp poller reschedule delay to [0, pollIntervalMs]

Date.now() is not monotonic: a backward wall-clock jump (NTP correction) during a scan
makes (now - startedAt) negative, so the start-to-start delay pollIntervalMs - elapsed
would exceed one interval by the adjustment — suppressing visible metadata refreshes for
minutes/hours, unlike the former setInterval. Cap the computed delay at pollIntervalMs
(upper) as well as 0 (lower) in all three pollers.

Co-authored-by: Orca <help@stably.ai>

* refactor(main): drop redundant notifyTimer guards to stay under max-lines

The visibility-parking change pushed worktree-base-directory-watcher.ts to 303 code
lines (limit 300). clearTimeout tolerates null/undefined, so the two truthy guards
around it are redundant — remove them (coalescing null→undefined for the type). No
behavior change; back under the limit without an eslint-disable.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-07-22 20:38:35 -07:00 committed by GitHub
parent 059a80b298
commit 63bd3ff189
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 493 additions and 76 deletions

View File

@ -3,8 +3,10 @@ import { mkdtemp, mkdir, realpath, rm, stat, utimes, writeFile } from 'node:fs/p
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
createWorktreePollerWindowVisibility,
startWorktreeBaseDirectoryPoller,
type WorktreeBasePollEvent
type WorktreeBasePollEvent,
type WorktreePollerWindowVisibility
} from './worktree-base-directory-poller'
import type {
WorktreeBaseRepoWatchConfig,
@ -13,6 +15,37 @@ import type {
const POLL_MS = 25
type VisibilityHarness = {
source: WorktreePollerWindowVisibility
hide: () => void
show: () => void
}
function createVisibilityHarness(initiallyVisible = true): VisibilityHarness {
let visible = initiallyVisible
let listener: (() => void) | null = null
return {
source: {
isWindowVisible: () => visible,
onWindowBecameVisible: (nextListener) => {
listener = nextListener
return () => {
if (listener === nextListener) {
listener = null
}
}
}
},
hide: () => {
visible = false
},
show: () => {
visible = true
listener?.()
}
}
}
function makeTarget(
kind: 'base' | 'git-common',
path: string,
@ -171,6 +204,95 @@ describe('worktree base directory poller', () => {
expect(fullScans.length).toBeGreaterThan(0)
})
it('parks base scans while hidden and losslessly detects changes on resume', async () => {
const root = await makeRoot()
const visibility = createVisibilityHarness()
const received: WorktreeBasePollEvent[][] = []
const fullScans: number[] = []
const target = makeTarget('base', root)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
(events) => received.push(events),
{
pollIntervalMs: POLL_MS,
visibility: visibility.source,
onFullScan: () => fullScans.push(Date.now())
}
)
cleanups.push(() => poller.unsubscribe())
visibility.hide()
await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2))
const worktree = join(root, 'added-while-hidden')
await mkdir(worktree)
await writeFile(join(worktree, '.git'), 'gitdir: elsewhere')
await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2))
expect(received.flat()).toHaveLength(0)
expect(fullScans).toHaveLength(0)
visibility.show()
expect(fullScans).toHaveLength(1)
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'create' && event.path === join(worktree, '.git'))
)
})
it('keeps polling without a main window', async () => {
const root = await makeRoot()
const received: WorktreeBasePollEvent[][] = []
const target = makeTarget('base', root)
const visibility = createWorktreePollerWindowVisibility(() => null)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
(events) => received.push(events),
{ pollIntervalMs: POLL_MS, visibility }
)
cleanups.push(() => poller.unsubscribe())
const worktree = join(root, 'headless-add')
await mkdir(worktree)
await writeFile(join(worktree, '.git'), 'gitdir: elsewhere')
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'create' && event.path === join(worktree, '.git'))
)
expect(visibility.isWindowVisible()).toBe(true)
})
it('treats a destroyed window as absent instead of parking forever', () => {
const visibility = createWorktreePollerWindowVisibility(() => ({ isDestroyed: () => true }))
expect(visibility.isWindowVisible()).toBe(true)
})
it('keeps polling a live window that has never been shown (E2E headless)', () => {
// ORCA_E2E_HEADLESS keeps a live BrowserWindow that is never shown; no show/restore
// signal is coming to resume a parked poller, so a never-shown window must keep polling.
const visibility = createWorktreePollerWindowVisibility(() => ({
isDestroyed: () => false,
isVisible: () => false,
isMinimized: () => false
}))
expect(visibility.isWindowVisible()).toBe(true)
expect(visibility.isWindowVisible()).toBe(true)
})
it('parks only after the window has been shown at least once', () => {
let visible = true
const visibility = createWorktreePollerWindowVisibility(() => ({
isDestroyed: () => false,
isVisible: () => visible,
isMinimized: () => false
}))
// Shown at least once: a later reveal will fire the visibility signal to resume.
expect(visibility.isWindowVisible()).toBe(true)
// Now hidden — a previously-shown window parks (its show/restore will resume it).
visible = false
expect(visibility.isWindowVisible()).toBe(false)
})
it('reports git-common entry creates, allowlisted leaf updates, and removals via polling', async () => {
const commonDir = await makeRoot()
const received: WorktreeBasePollEvent[][] = []
@ -335,6 +457,42 @@ describe('worktree base directory poller', () => {
)
})
it('detects a primary HEAD move immediately after resuming', async () => {
const commonDir = await makeRoot()
const headFile = join(commonDir, 'HEAD')
await writeFile(headFile, 'ref: refs/heads/main')
const visibility = createVisibilityHarness()
const received: WorktreeBasePollEvent[][] = []
const fullScans: number[] = []
const target = makeTarget('git-common', commonDir)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
(events) => received.push(events),
{
pollIntervalMs: POLL_MS,
platform: 'linux',
visibility: visibility.source,
onFullScan: () => fullScans.push(Date.now())
}
)
cleanups.push(() => poller.unsubscribe())
visibility.hide()
await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2))
await writeFile(headFile, 'ref: refs/heads/feature')
await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2))
expect(received.flat()).toHaveLength(0)
expect(fullScans).toHaveLength(0)
visibility.show()
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'update' && event.path === headFile)
)
expect(fullScans).toHaveLength(1)
})
it('emits deletes for all known worktrees when the root vanishes', async () => {
const root = await makeRoot()
const worktree = join(root, 'external-5')

View File

@ -1,6 +1,7 @@
import { readdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility'
import type {
WorktreeBaseRepoWatchConfig,
WorktreeBaseWatchTarget
@ -11,9 +12,53 @@ export type WorktreeBasePollEvent = { type: 'create' | 'update' | 'delete'; path
export type WorktreeBaseSubscription = { unsubscribe: () => Promise<void> }
export type WorktreePollerWindowVisibility = {
isWindowVisible: () => boolean
onWindowBecameVisible: (listener: () => void) => () => void
}
type WorktreePollerWindow = {
isDestroyed: () => boolean
isVisible?: () => boolean
isMinimized?: () => boolean
}
const alwaysVisible: WorktreePollerWindowVisibility = {
isWindowVisible: () => true,
onWindowBecameVisible: () => () => {}
}
export function createWorktreePollerWindowVisibility(
getWindow: () => WorktreePollerWindow | null
): WorktreePollerWindowVisibility {
// Why: only park a window that has actually been shown and is now hidden. A window
// that has NEVER been shown is either headless (ORCA_E2E_HEADLESS keeps a live but
// never-shown BrowserWindow) or still starting up — no show/restore signal is coming
// to resume it, so parking it would starve worktree freshness forever. Treat
// never-shown as visible and keep polling; only start parking once we've observed the
// window visible at least once. null/destroyed (serve/headless, macOS window-recreation
// gap) stay always-visible so a torn-down window never permanently parks the poller.
let hasBeenVisible = false
return {
isWindowVisible: () => {
const window = getWindow()
if (window === null || window.isDestroyed()) {
return true
}
if (isMainWindowVisible(window)) {
hasBeenVisible = true
return true
}
return !hasBeenVisible
},
onWindowBecameVisible: onMainWindowBecameVisible
}
}
export type WorktreeBasePollerOptions = {
pollIntervalMs?: number
platform?: NodeJS.Platform
visibility?: WorktreePollerWindowVisibility
/** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */
onFullScan?: () => void
}
@ -145,6 +190,7 @@ async function startBasePoller(
getRepos: () => ReadonlyMap<string, WorktreeBaseRepoWatchConfig>,
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number,
visibility: WorktreePollerWindowVisibility,
onFullScan?: () => void
): Promise<WorktreeBaseSubscription> {
let disposed = false
@ -152,6 +198,8 @@ async function startBasePoller(
let tickCount = 0
let snapshot = await snapshotBase(target.path, getRepos())
let gateSignatures = await Promise.all(snapshot.gateDirs.map(dirSignature))
let timer: ReturnType<typeof setTimeout> | null = null
let parkedWhileHidden = false
// dir → tick when first seen without a `.git` marker
const pendingMarkers = new Map<string, number>()
for (const [dir, marker] of snapshot.markers) {
@ -201,9 +249,9 @@ async function startBasePoller(
}
}
const tick = async (): Promise<void> => {
const poll = async (forceFullScan = false): Promise<void> => {
tickCount++
if (tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) {
if (forceFullScan || tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) {
await fullScan()
return
}
@ -222,25 +270,62 @@ async function startBasePoller(
}
}
const timer = setInterval(() => {
if (disposed || ticking) {
const tick = async (forceFullScan = false): Promise<void> => {
timer = null
if (disposed) {
return
}
if (!visibility.isWindowVisible()) {
parkedWhileHidden = true
return
}
if (ticking) {
return
}
ticking = true
void tick()
.catch(() => {
// Transient fs error: keep the previous snapshot and retry next tick.
})
.finally(() => {
ticking = false
})
}, pollIntervalMs)
// Why: measure from tick start so the cadence is start-to-start (like the old setInterval), not
// gap-after-completion — otherwise each visible refresh lands a full scan-duration late every tick.
const startedAt = Date.now()
try {
await poll(forceFullScan)
} catch {
// Transient fs error: keep the previous snapshot and retry next tick.
} finally {
ticking = false
}
if (!disposed) {
// Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would
// otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for
// minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative.
const nextDelay = Math.max(
0,
Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt))
)
timer = setTimeout(() => void tick(), nextDelay)
timer.unref?.()
}
}
const unsubscribeVisibility = visibility.onWindowBecameVisible(() => {
if (disposed || !parkedWhileHidden) {
return
}
parkedWhileHidden = false
// Why: the ordinary dir-signature gate can miss same-granule changes made
// while hidden; resume must diff a fresh full snapshot against the baseline.
void tick(true)
})
timer = setTimeout(() => void tick(), pollIntervalMs)
timer.unref?.()
return {
unsubscribe: async () => {
disposed = true
clearInterval(timer)
if (timer) {
clearTimeout(timer)
}
unsubscribeVisibility()
}
}
}
@ -256,8 +341,16 @@ export async function startWorktreeBaseDirectoryPoller(
): Promise<WorktreeBaseSubscription> {
const pollIntervalMs = options.pollIntervalMs ?? WORKTREE_BASE_POLL_INTERVAL_MS
const platform = options.platform ?? process.platform
const visibility = options.visibility ?? alwaysVisible
if (target.kind === 'git-common') {
return startGitCommonWatch(target, onEvents, pollIntervalMs, platform, options.onFullScan)
return startGitCommonWatch(
target,
onEvents,
pollIntervalMs,
platform,
visibility,
options.onFullScan
)
}
return startBasePoller(target, getRepos, onEvents, pollIntervalMs, options.onFullScan)
return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options.onFullScan)
}

View File

@ -10,6 +10,10 @@ vi.mock('fs/promises', () => ({
}))
vi.mock('./worktree-base-directory-poller', () => ({
createWorktreePollerWindowVisibility: vi.fn(() => ({
isWindowVisible: () => true,
onWindowBecameVisible: () => () => {}
})),
startWorktreeBaseDirectoryPoller: vi.fn()
}))

View File

@ -17,7 +17,10 @@ import {
buildWorktreeBaseDirectoryWatchTargets,
clearWorktreeBaseDirectoryWatchTargetWarnings
} from './worktree-base-directory-watch-targets'
import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller'
import {
createWorktreePollerWindowVisibility,
startWorktreeBaseDirectoryPoller
} from './worktree-base-directory-poller'
type ActiveWatch = WorktreeBaseWatchTarget & {
mainWindow: BrowserWindow
@ -58,9 +61,8 @@ function scheduleNotification(watch: ActiveWatch, changes: PendingNotificationIn
for (const repoId of changes.headIdentityRepoIds ?? []) {
watch.pendingHeadIdentityRepoIds.add(repoId)
}
if (watch.notifyTimer) {
clearTimeout(watch.notifyTimer)
}
// clearTimeout tolerates null (no-op), so no guard needed before rescheduling.
clearTimeout(watch.notifyTimer ?? undefined)
watch.notifyTimer = setTimeout(() => {
watch.notifyTimer = null
if (watch.disposed || watch.mainWindow.isDestroyed()) {
@ -192,10 +194,14 @@ async function subscribeTarget(
() => (activeWatches.get(target.key) ?? activeWatch)?.repos ?? target.repos,
(events) => {
const currentWatch = activeWatches.get(target.key) ?? activeWatch
if (!currentWatch || currentWatch.disposed) {
return
if (currentWatch && !currentWatch.disposed) {
handleLocalWatchEvents(currentWatch, null, events)
}
handleLocalWatchEvents(currentWatch, null, events)
},
{
visibility: createWorktreePollerWindowVisibility(
() => (activeWatches.get(target.key) ?? activeWatch)?.mainWindow ?? null
)
}
)
activeWatch = createActiveWatch(target, mainWindow, subscription)
@ -241,9 +247,7 @@ async function removeWatch(key: string): Promise<void> {
}
activeWatches.delete(key)
watch.disposed = true
if (watch.notifyTimer) {
clearTimeout(watch.notifyTimer)
}
clearTimeout(watch.notifyTimer ?? undefined)
clearPendingRepoIds(watch)
await watch.subscription.unsubscribe().catch((error) => {
console.warn(`[worktree-base-watcher] failed to unwatch ${watch.path}:`, error)

View File

@ -2,7 +2,8 @@ import { readdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import type {
WorktreeBasePollEvent,
WorktreeBaseSubscription
WorktreeBaseSubscription,
WorktreePollerWindowVisibility
} from './worktree-base-directory-poller'
// Shared with the darwin primary-metadata poll so the platforms cannot drift
@ -228,6 +229,7 @@ export async function startGitCommonPolling(
commonDirPath: string,
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number,
visibility: WorktreePollerWindowVisibility,
onFullScan?: () => void,
includePrimary = true
): Promise<WorktreeBaseSubscription> {
@ -235,39 +237,81 @@ export async function startGitCommonPolling(
let ticking = false
let tickCount = 0
let snapshot = await snapshotGitCommon(commonDirPath, undefined, includePrimary)
let timer: ReturnType<typeof setTimeout> | null = null
let parkedWhileHidden = false
const timer = setInterval(() => {
if (disposed || ticking) {
const tick = async (forceIndexRead = false): Promise<void> => {
timer = null
if (disposed) {
return
}
if (!visibility.isWindowVisible()) {
parkedWhileHidden = true
return
}
if (ticking) {
return
}
ticking = true
// Why: measure from tick start so cadence is start-to-start, not gap-after-completion (which would
// land each visible refresh a full scan-duration late every tick).
const startedAt = Date.now()
tickCount++
const forceIndexRead = tickCount % INDEX_BACKSTOP_TICKS === 0
const shouldForceIndexRead = forceIndexRead || tickCount % INDEX_BACKSTOP_TICKS === 0
onFullScan?.()
void snapshotGitCommon(commonDirPath, snapshot, includePrimary, forceIndexRead)
.then((next) => {
if (disposed) {
return
}
const events = diffGitCommon(commonDirPath, snapshot, next)
snapshot = next
if (events.length > 0) {
onEvents(events)
}
})
.catch(() => {
// Transient fs error: keep the previous snapshot and retry next tick.
})
.finally(() => {
ticking = false
})
}, pollIntervalMs)
try {
const next = await snapshotGitCommon(
commonDirPath,
snapshot,
includePrimary,
shouldForceIndexRead
)
if (disposed) {
return
}
const events = diffGitCommon(commonDirPath, snapshot, next)
snapshot = next
if (events.length > 0) {
onEvents(events)
}
} catch {
// Transient fs error: keep the previous snapshot and retry next tick.
} finally {
ticking = false
}
if (!disposed) {
// Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would
// otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for
// minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative.
const nextDelay = Math.max(
0,
Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt))
)
timer = setTimeout(() => void tick(), nextDelay)
timer.unref?.()
}
}
const unsubscribeVisibility = visibility.onWindowBecameVisible(() => {
if (disposed || !parkedWhileHidden) {
return
}
parkedWhileHidden = false
// Why: a linked index can change without its parent dir signature moving;
// force the leaf read when diffing the retained pre-hide snapshot.
void tick(true)
})
timer = setTimeout(() => void tick(), pollIntervalMs)
timer.unref?.()
return {
unsubscribe: async () => {
disposed = true
clearInterval(timer)
if (timer) {
clearTimeout(timer)
}
unsubscribeVisibility()
}
}
}

View File

@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, mkdir, realpath, rm } from 'node:fs/promises'
import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { subscribeViaWatcherProcess } from './parcel-watcher-process'
@ -8,7 +8,10 @@ import type {
WatcherProcessHooks
} from './parcel-watcher-process-subscription'
import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter'
import type { WorktreeBasePollEvent } from './worktree-base-directory-poller'
import type {
WorktreeBasePollEvent,
WorktreePollerWindowVisibility
} from './worktree-base-directory-poller'
import { startGitCommonWatch } from './worktree-git-common-watch'
vi.mock('./parcel-watcher-process', () => ({
@ -17,6 +20,40 @@ vi.mock('./parcel-watcher-process', () => ({
const POLL_MS = 25
const alwaysVisible: WorktreePollerWindowVisibility = {
isWindowVisible: () => true,
onWindowBecameVisible: () => () => {}
}
function createVisibilityHarness(): {
source: WorktreePollerWindowVisibility
hide: () => void
show: () => void
} {
let visible = true
let listener: (() => void) | null = null
return {
source: {
isWindowVisible: () => visible,
onWindowBecameVisible: (nextListener) => {
listener = nextListener
return () => {
if (listener === nextListener) {
listener = null
}
}
}
},
hide: () => {
visible = false
},
show: () => {
visible = true
listener?.()
}
}
}
type ChildSubscription = {
dir: string
callback: WatcherProcessCallback
@ -67,7 +104,8 @@ describe('worktree git-common narrow watch (darwin)', () => {
makeTarget(commonDir),
(events) => received.push(events),
POLL_MS,
'darwin'
'darwin',
alwaysVisible
)
cleanups.push(() => watch.unsubscribe())
}
@ -167,6 +205,42 @@ describe('worktree git-common narrow watch (darwin)', () => {
})
})
it('keeps the native stream live while the primary poll is parked', async () => {
installSubscribeMock()
const commonDir = await makeCommonDir(true)
const headFile = join(commonDir, 'HEAD')
await writeFile(headFile, 'ref: refs/heads/main')
const visibility = createVisibilityHarness()
const received: WorktreeBasePollEvent[][] = []
const fullScans: number[] = []
const watch = await startGitCommonWatch(
makeTarget(commonDir),
(events) => received.push(events),
POLL_MS,
'darwin',
visibility.source,
() => fullScans.push(Date.now())
)
cleanups.push(() => watch.unsubscribe())
visibility.hide()
await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2))
await writeFile(headFile, 'ref: refs/heads/feature')
await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2))
expect(fullScans).toHaveLength(0)
const entryPath = join(commonDir, 'worktrees', 'native-while-hidden')
childSubscriptions[0].callback(null, [{ type: 'create', path: entryPath }])
expect(received.flat()).toContainEqual({ type: 'create', path: entryPath })
expect(childSubscriptions[0].unsubscribe).not.toHaveBeenCalled()
visibility.show()
expect(fullScans).toHaveLength(1)
await vi.waitFor(() => {
expect(received.flat()).toContainEqual({ type: 'update', path: headFile })
})
})
it('stops forwarding events and unsubscribes the child on dispose', async () => {
installSubscribeMock()
const commonDir = await makeCommonDir(true)
@ -175,7 +249,8 @@ describe('worktree git-common narrow watch (darwin)', () => {
makeTarget(commonDir),
(events) => received.push(events),
POLL_MS,
'darwin'
'darwin',
alwaysVisible
)
await watch.unsubscribe()
expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1)

View File

@ -4,7 +4,8 @@ import { subscribeViaWatcherProcess } from './parcel-watcher-process'
import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter'
import type {
WorktreeBasePollEvent,
WorktreeBaseSubscription
WorktreeBaseSubscription,
WorktreePollerWindowVisibility
} from './worktree-base-directory-poller'
import {
PRIMARY_CHECKOUT_METADATA_FILES,
@ -69,42 +70,78 @@ async function startSnapshotDiffPoller(
takeSnapshot: () => Promise<Map<string, number>>,
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number,
visibility: WorktreePollerWindowVisibility,
onFullScan?: () => void
): Promise<WorktreeBaseSubscription> {
let disposed = false
let ticking = false
let snapshot = await takeSnapshot()
let timer: ReturnType<typeof setTimeout> | null = null
let parkedWhileHidden = false
const timer = setInterval(() => {
if (disposed || ticking) {
const tick = async (): Promise<void> => {
timer = null
if (disposed) {
return
}
if (!visibility.isWindowVisible()) {
parkedWhileHidden = true
return
}
if (ticking) {
return
}
ticking = true
// Why: measure from tick start so cadence is start-to-start, not gap-after-completion (which would
// land each visible refresh a full scan-duration late every tick).
const startedAt = Date.now()
onFullScan?.()
void takeSnapshot()
.then((next) => {
if (disposed) {
return
}
const events = diffMtimeMap(snapshot, next)
snapshot = next
if (events.length > 0) {
onEvents(events)
}
})
.catch(() => {
// Transient fs error: keep the previous snapshot and retry next tick.
})
.finally(() => {
ticking = false
})
}, pollIntervalMs)
try {
const next = await takeSnapshot()
if (disposed) {
return
}
const events = diffMtimeMap(snapshot, next)
snapshot = next
if (events.length > 0) {
onEvents(events)
}
} catch {
// Transient fs error: keep the previous snapshot and retry next tick.
} finally {
ticking = false
}
if (!disposed) {
// Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would
// otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for
// minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative.
const nextDelay = Math.max(
0,
Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt))
)
timer = setTimeout(() => void tick(), nextDelay)
timer.unref?.()
}
}
const unsubscribeVisibility = visibility.onWindowBecameVisible(() => {
if (disposed || !parkedWhileHidden) {
return
}
parkedWhileHidden = false
void tick()
})
timer = setTimeout(() => void tick(), pollIntervalMs)
timer.unref?.()
return {
unsubscribe: async () => {
disposed = true
clearInterval(timer)
if (timer) {
clearTimeout(timer)
}
unsubscribeVisibility()
}
}
}
@ -246,6 +283,7 @@ export async function startGitCommonWatch(
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number,
platform: NodeJS.Platform,
visibility: WorktreePollerWindowVisibility,
onFullScan?: () => void
): Promise<WorktreeBaseSubscription> {
if (platform === 'darwin') {
@ -255,6 +293,7 @@ export async function startGitCommonWatch(
() => snapshotPrimaryCheckoutMetadata(target.path),
onEvents,
pollIntervalMs,
visibility,
onFullScan
)
])
@ -264,5 +303,5 @@ export async function startGitCommonWatch(
}
}
}
return startGitCommonPolling(target.path, onEvents, pollIntervalMs, onFullScan)
return startGitCommonPolling(target.path, onEvents, pollIntervalMs, visibility, onFullScan)
}