mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
* perf(worktrees): converge the trash sweep instead of re-walking doomed trees
`transientLockRemovalOptions()` only asked for `maxRetries` on Windows, and
`removeHostTree`'s retry ladder was gated on `process.platform === 'win32'`.
A concurrent writer is not Windows-specific: Spotlight/`mds`, a scanner, or a
live process writing under the tree surface the same EBUSY/ENOTEMPTY/EPERM on
macOS and Linux. So on POSIX the startup sweep got exactly one attempt per
entry, failed, and re-issued the same guaranteed-to-fail walk on every launch.
- Extend the retry policy to every platform. Windows keeps its error set,
its message fallback, and its delays; the message fallback stays
Windows-only because POSIX always sets a code.
- Persist a per-entry failure ledger in the trash root so a repeatedly
failing entry is retried on a 15m/1h/6h ladder rather than on every launch.
Nothing is abandoned: the ladder clamps, records are pruned when the entry
goes, and a torn ledger fails open to a full sweep.
- Defer the sweep behind first paint, so its recursive readdir/rm no longer
competes with window creation and worktree-catalog hydration.
* fix(worktrees): keep Node's per-level rm retries Windows-only
Node's rimraf hands every child back to the retrying entry point
(`_rmchildren` -> `rimraf`), so `maxRetries` is applied once per directory
level and compounds: a permanently-failing leaf at depth d costs roughly
`retryDelay * 36 * 9^(d-1)`. Measured on macOS against one `chflags uchg`
file at depth 2, `{recursive, force}` rejected in 1 ms while
`{maxRetries: 8, retryDelay: 150}` had not settled after 5 minutes.
Handing those options to POSIX removals turned every `removeHostTree` on a
worktree residue (`node_modules/.pnpm/...`, a dozen levels deep) into a
promise that never settles -- wedging the serialized trash-deletion queue,
hanging the sweep on its first failing entry so no backoff is ever recorded,
and leaving the unregistered-worktree removal IPC pending forever.
Keep the cross-platform retry where this PR put it -- the bounded outer
ladders that re-issue one whole `rm` against the same already-chosen path --
and restore `transientLockRemovalOptions()` to Windows-only `maxRetries`.
Also guard the deferred first-window task: off whenReady's promise chain a
synchronous throw is an uncaughtException, which the pipe-error guard
re-throws fatally.
* fix(worktrees): make host tree removal see through Electron's asar shim
The 267 stranded trash entries were not a concurrent-writer race. Electron
patches `fs` so a `*.asar` file reports `isDirectory() === true`, so Node's
recursive `rm` descends into the archive, `rmdir`s a real file, and fails the
parent with ENOTEMPTY — deterministically, on every attempt. Every worktree
that has run `pnpm install` carries a `default_app.asar`, which is why every
residue stopped at the same path.
Route `removeHostTree` through `original-fs` (Electron's unpatched `fs`, with a
`node:fs/promises` fallback outside Electron) instead of retrying a failure that
can never succeed. `removalPath`, `rmOptions` and the Windows retry ladder are
byte-identical to `main`.
Reverts the POSIX retry ladder, the `isTransientRemovalError` widening, the
sweep backoff ledger and the inverted `does not retry host removal failures
outside Windows` ratchet — none of them were fixing the actual failure.
* fix(worktrees): drop the stray orchestration test and bundle the asar guard like production
`orchestration-statement-compilation.test.ts` belongs to #18420 and was swept
into this branch by accident. It imports `./prepared-statement-cache`, which
does not exist here, so `tsc -p config/tsconfig.node.json` failed on this
branch. Removed; typecheck is clean again.
The Electron asar guard pre-externalized `original-fs` in its own Vite build,
which is not what the shipped bundle does. Mirror `isExternalMainModule` from
electron.vite.config.ts instead, so the guard also proves the production
bundler leaves `createRequire(__filename)('original-fs')` as a runtime require
— if that ever became a static import or got folded, production would silently
degrade to the shimmed `fs` while the old test kept passing.
69 lines
3.0 KiB
TypeScript
69 lines
3.0 KiB
TypeScript
// Why: every recursive host delete Orca performs (worktrees, terminal history, quarantined recovery
|
|
// generations) hits the same two hazards, so one helper exists so no call site forgets either.
|
|
// Windows stickiness — AV/indexers/late handle releases surface transient EBUSY/ENOTEMPTY/EPERM on a
|
|
// tree Node just emptied — and Electron's asar shim, which strands any tree holding a `*.asar`
|
|
// (see `asar-transparent-fs`).
|
|
|
|
import { win32 } from 'node:path'
|
|
import { setTimeout as delay } from 'node:timers/promises'
|
|
import { rm } from './asar-transparent-fs'
|
|
import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path'
|
|
import { isWslUncPath } from '../shared/wsl-paths'
|
|
import { transientLockRemovalOptions } from '../shared/windows-transient-lock-removal'
|
|
|
|
const WINDOWS_REMOVE_RETRY_DELAYS_MS = [250, 500, 1_000, 2_000]
|
|
|
|
/** Convert a native host filesystem path to the Win32 long-path namespace. */
|
|
export function toHostFilesystemPath(targetPath: string): string {
|
|
// POSIX paths are used by WSL callers even while the Electron process runs
|
|
// on Windows; do not reinterpret those as drive-relative Win32 paths.
|
|
return process.platform === 'win32' &&
|
|
isWindowsAbsolutePathLike(targetPath) &&
|
|
!isWslUncPath(targetPath)
|
|
? win32.toNamespacedPath(targetPath)
|
|
: targetPath
|
|
}
|
|
|
|
export function toHostRemovalPath(targetPath: string): string {
|
|
// Why: Git for Windows can fail long recursive deletes even after Orca has
|
|
// proven the worktree target; Node's host deletion should use Win32 long paths.
|
|
return toHostFilesystemPath(targetPath)
|
|
}
|
|
|
|
function isTransientWindowsRemovalError(error: unknown): boolean {
|
|
if (process.platform !== 'win32' || typeof error !== 'object' || error === null) {
|
|
return false
|
|
}
|
|
const code = 'code' in error && typeof error.code === 'string' ? error.code : undefined
|
|
if (code && ['EBUSY', 'ENOTEMPTY', 'EPERM'].includes(code)) {
|
|
return true
|
|
}
|
|
const message = 'message' in error && typeof error.message === 'string' ? error.message : ''
|
|
return /directory not empty|resource busy|operation not permitted/i.test(message)
|
|
}
|
|
|
|
/** Recursively remove a host directory tree, retrying the transient Windows failures. */
|
|
export async function removeHostTree(targetPath: string): Promise<void> {
|
|
const removalPath = toHostRemovalPath(targetPath)
|
|
const retryDelays = process.platform === 'win32' ? WINDOWS_REMOVE_RETRY_DELAYS_MS : []
|
|
// Why: large Windows trees commonly surface transient ENOTEMPTY/EPERM while Node walks and
|
|
// removes nested directories; Node's own retries absorb that before the loop below has to.
|
|
const rmOptions = transientLockRemovalOptions()
|
|
let attempt = 0
|
|
|
|
while (true) {
|
|
try {
|
|
await rm(removalPath, rmOptions)
|
|
return
|
|
} catch (error) {
|
|
if (attempt >= retryDelays.length || !isTransientWindowsRemovalError(error)) {
|
|
throw error
|
|
}
|
|
// Why: Git/Node recursive deletes on Windows can observe a just-emptied
|
|
// directory before antivirus/indexers/handles release it.
|
|
await delay(retryDelays[attempt])
|
|
attempt += 1
|
|
}
|
|
}
|
|
}
|