mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 16:02:41 +00:00
fix(worktree-poller): sample the scan gate before its listing (#13772)
A write landing during a full scan was recorded by the post-scan gate stat, so the next tick saw a clean gate and the create/delete waited up to 15 ticks (~30s) for the backstop. Sample each gate dir's signature before its listing so a racing write reads as stale and forces one rescan on the next tick. Also fixes the flaky 'retires stale marker probes' spec: a folder caught mid-rm opened a pending-marker window that the swallowed gate change kept alive for the whole backstop gap (43 probes vs the 30 budget). Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -265,6 +265,44 @@ describe('worktree base directory poller', () => {
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('rescans on the next tick when a write races an in-flight full scan', async () => {
|
||||
const root = await makeRoot()
|
||||
const worktree = join(root, 'raced')
|
||||
const received: WorktreeBasePollEvent[][] = []
|
||||
const target = makeTarget('base', root)
|
||||
const snapshotTicks: number[] = []
|
||||
let raced = false
|
||||
const poller = await startWorktreeBaseDirectoryPoller(
|
||||
target,
|
||||
() => target.repos,
|
||||
(events) => received.push(events),
|
||||
{
|
||||
pollIntervalMs: 0,
|
||||
onSnapshotTaken: (tick) => {
|
||||
snapshotTicks.push(tick)
|
||||
if (raced) {
|
||||
return
|
||||
}
|
||||
raced = true
|
||||
mkdirSync(worktree)
|
||||
writeFileSync(join(worktree, '.git'), 'gitdir: elsewhere')
|
||||
}
|
||||
}
|
||||
)
|
||||
cleanups.push(() => poller.unsubscribe())
|
||||
|
||||
await waitForEvents(received, (flat) =>
|
||||
flat.some((event) => event.type === 'create' && event.path === join(worktree, '.git'))
|
||||
)
|
||||
|
||||
// A write landing after a scan's listings must leave the gate stale, so the
|
||||
// next tick rescans instead of deferring the create to the backstop.
|
||||
expect(snapshotTicks.slice(0, 2)).toEqual([
|
||||
WORKTREE_BASE_BACKSTOP_TICKS,
|
||||
WORKTREE_BASE_BACKSTOP_TICKS + 1
|
||||
])
|
||||
})
|
||||
|
||||
it('parks base scans while hidden and losslessly detects changes on resume', async () => {
|
||||
const root = await makeRoot()
|
||||
const visibility = createVisibilityHarness()
|
||||
|
||||
@@ -65,6 +65,8 @@ export type WorktreeBasePollerOptions = {
|
||||
onFullScan?: () => void
|
||||
/** Test hook: called before a pending `.git` marker stat. */
|
||||
onPendingMarkerProbe?: (path: string) => void
|
||||
/** Test hook: awaited with the tick after a full scan's listings, to land a racing write. */
|
||||
onSnapshotTaken?: (tick: number) => void | Promise<void>
|
||||
/** Test hook: overrides the fast-probe window. */
|
||||
pendingMarkerMaxTicks?: number
|
||||
}
|
||||
@@ -117,6 +119,8 @@ type BaseSnapshot = {
|
||||
// dirs whose listing determines the candidate set: the root plus any
|
||||
// nested repo containers. Their stat signatures gate the next full scan.
|
||||
gateDirs: string[]
|
||||
// index-aligned with gateDirs, each sampled *before* that dir's listing
|
||||
gateSignatures: string[]
|
||||
}
|
||||
|
||||
// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested
|
||||
@@ -128,6 +132,10 @@ async function snapshotBase(
|
||||
): Promise<BaseSnapshot> {
|
||||
const markers = new Map<string, boolean>()
|
||||
const gateDirs = [rootPath]
|
||||
// Why: sampling the signature before the listing makes a write that races the
|
||||
// scan look stale next tick (one redundant rescan) instead of invisible until
|
||||
// the backstop, which is up to 15 ticks of missed creates/deletes.
|
||||
const gateSignatures = [await dirSignature(rootPath)]
|
||||
const configs = [...repos.values()]
|
||||
const includeFlat = configs.some((config) => !config.nestWorkspaces)
|
||||
const nestedRepoNames = new Set(
|
||||
@@ -142,7 +150,7 @@ async function snapshotBase(
|
||||
} catch {
|
||||
// Root vanished: an empty snapshot diffs into delete events for every
|
||||
// previously-known worktree dir, matching the old watcher's error path.
|
||||
return { markers, gateDirs }
|
||||
return { markers, gateDirs, gateSignatures }
|
||||
}
|
||||
|
||||
const candidates: string[] = []
|
||||
@@ -156,6 +164,7 @@ async function snapshotBase(
|
||||
}
|
||||
if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) {
|
||||
gateDirs.push(entryPath)
|
||||
gateSignatures.push(await dirSignature(entryPath))
|
||||
let subEntries
|
||||
try {
|
||||
subEntries = await readdir(entryPath, { withFileTypes: true })
|
||||
@@ -173,7 +182,7 @@ async function snapshotBase(
|
||||
for (const dir of candidates) {
|
||||
markers.set(dir, await hasGitMarker(dir))
|
||||
}
|
||||
return { markers, gateDirs }
|
||||
return { markers, gateDirs, gateSignatures }
|
||||
}
|
||||
|
||||
function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] {
|
||||
@@ -203,7 +212,6 @@ async function startBasePoller(
|
||||
let ticking = false
|
||||
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
|
||||
const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS
|
||||
@@ -218,7 +226,7 @@ async function startBasePoller(
|
||||
const fullScan = async (): Promise<void> => {
|
||||
options.onFullScan?.()
|
||||
const next = await snapshotBase(target.path, getRepos())
|
||||
const nextSignatures = await Promise.all(next.gateDirs.map(dirSignature))
|
||||
await options.onSnapshotTaken?.(tickCount)
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
@@ -236,7 +244,6 @@ async function startBasePoller(
|
||||
}
|
||||
}
|
||||
snapshot = next
|
||||
gateSignatures = nextSignatures
|
||||
if (events.length > 0) {
|
||||
onEvents(events)
|
||||
}
|
||||
@@ -274,8 +281,8 @@ async function startBasePoller(
|
||||
// are untouched, skip the readdir + per-candidate stat fan-out entirely.
|
||||
const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature))
|
||||
const gateChanged =
|
||||
signatures.length !== gateSignatures.length ||
|
||||
signatures.some((sig, index) => sig !== gateSignatures[index])
|
||||
signatures.length !== snapshot.gateSignatures.length ||
|
||||
signatures.some((sig, index) => sig !== snapshot.gateSignatures[index])
|
||||
if (gateChanged) {
|
||||
await fullScan()
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user