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:
parent
059a80b298
commit
63bd3ff189
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ vi.mock('fs/promises', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('./worktree-base-directory-poller', () => ({
|
||||
createWorktreePollerWindowVisibility: vi.fn(() => ({
|
||||
isWindowVisible: () => true,
|
||||
onWindowBecameVisible: () => () => {}
|
||||
})),
|
||||
startWorktreeBaseDirectoryPoller: vi.fn()
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue