mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
On Windows with a WSL distro configured, `prepareWslLinkedWorktreeGitRouting` caches for 30s which Git owns a drive-letter checkout, by reading that checkout's `.git` marker. `git worktree add/move/remove` rewrites exactly that marker, so the verdict could stay authoritative for up to 30s after it stopped being true. - Add `invalidateWslLinkedWorktreeGitRouting(cwd)`: drops the cached route and the probe retry backoff for that path and for anything under it (a submodule inside the worktree derived its route from the same marker walk). Eight calls at six sites: `worktree add`, `worktree move` (both paths), `worktree remove`, the prepared checkout's add, the finalize move (both paths), and the prepared-worktree discard. Five sites invalidate from a `finally`, because a Git failure can still have rewritten the marker; the prepared checkout's add invalidates on the success path only, since its failure path runs the discard, which has its own `finally`. - Split the parent-directory marker walk into `wsl-linked-worktree-git-route-probe.ts` (the routing module was at the `max-lines` ceiling) and have it report whether the walk settled. A `.git` file with no `gitdir:` line is a half-written marker mid `worktree add`: it is now retried under the existing backoff instead of cached for 30s. The route it yields is unchanged (distro); only the number of parent walks changes. An invalidation only drops cached state; a probe already in flight is left to finish and cache normally, so a mutation landing mid-probe is no worse off than main's 30s TTL. The cost is that a failed mutation also drops a still-correct route: `gitExecFileAsync` and `gitStreamStdout` re-resolve the route after their own `prepareWslLinkedWorktreeGitRouting`, and `gitSpawn` resolves again after the git-admission wait, so a command already in flight for that path can take the empty-cache default and run a host-owned checkout under `wsl.exe git`. It fails once and self-heals on the next command. Reachable only on win32 + configured WSL distro + drive-letter cwd; every other platform and configuration never populates this cache, so the new calls scan two empty maps.
294 lines
8.9 KiB
TypeScript
294 lines
8.9 KiB
TypeScript
import { win32 } from 'node:path'
|
|
import {
|
|
defaultWslLinkedWorktreeRoutingFileSystem,
|
|
probeWslLinkedWorktreeGitRoute,
|
|
type WslLinkedWorktreeRoutingFileSystem
|
|
} from './wsl-linked-worktree-git-route-probe'
|
|
|
|
export {
|
|
parseWindowsLinkedGitdir,
|
|
type WslLinkedWorktreeRoutingFileSystem
|
|
} from './wsl-linked-worktree-git-route-probe'
|
|
|
|
type CachedRoute = { usesHostGit: boolean; expiresAt: number }
|
|
type TransientRouteFailure = { count: number; retryAfter: number }
|
|
|
|
// Bounds stale routing after delete/recreate while amortizing async parent walks on slow storage.
|
|
export const WSL_LINKED_WORKTREE_ROUTE_TTL_MS = 30_000
|
|
export const WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD = 512
|
|
export const WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS = 5_000
|
|
export const WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD = 2
|
|
export const WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL = 32
|
|
export const WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS = 1_000
|
|
const WSL_LINKED_WORKTREE_ROUTE_RETRY_MAX_MS = 30_000
|
|
|
|
const routeByCwd = new Map<string, CachedRoute>()
|
|
const pendingRoutes = new Map<string, Promise<boolean>>()
|
|
const transientFailuresByCwd = new Map<string, TransientRouteFailure>()
|
|
const activeProbeCountByCwd = new Map<string, number>()
|
|
let activeProbeCount = 0
|
|
let routeProbeGeneration = 0
|
|
let nextRoutePruneAt = 0
|
|
|
|
export function isWslLinkedWorktreeGitRoutingCandidate(
|
|
cwd: string,
|
|
wslDistro: string | undefined,
|
|
platform: NodeJS.Platform = process.platform
|
|
): boolean {
|
|
return platform === 'win32' && Boolean(wslDistro) && /^[A-Za-z]:[/\\]/.test(cwd)
|
|
}
|
|
|
|
function normalize(path: string): string {
|
|
return win32.resolve(path)
|
|
}
|
|
|
|
function cachedRoute(cwd: string, now: number): boolean | undefined {
|
|
const exact = routeByCwd.get(cwd)
|
|
if (!exact) {
|
|
return undefined
|
|
}
|
|
if (exact.expiresAt <= now) {
|
|
routeByCwd.delete(cwd)
|
|
return undefined
|
|
}
|
|
return exact.usesHostGit
|
|
}
|
|
|
|
export type WslLinkedWorktreeRoutingOptions = {
|
|
platform?: NodeJS.Platform
|
|
fileSystem?: WslLinkedWorktreeRoutingFileSystem
|
|
now?: () => number
|
|
signal?: AbortSignal
|
|
}
|
|
|
|
function rememberRoute(cwd: string, usesHostGit: boolean, now: number): boolean {
|
|
const expiresAt = now + WSL_LINKED_WORKTREE_ROUTE_TTL_MS
|
|
if (
|
|
routeByCwd.size >= WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD &&
|
|
now >= nextRoutePruneAt
|
|
) {
|
|
for (const [cachedCwd, route] of routeByCwd) {
|
|
if (route.expiresAt <= now) {
|
|
routeByCwd.delete(cachedCwd)
|
|
}
|
|
}
|
|
nextRoutePruneAt = now + WSL_LINKED_WORKTREE_ROUTE_TTL_MS
|
|
}
|
|
routeByCwd.set(cwd, { usesHostGit, expiresAt })
|
|
return usesHostGit
|
|
}
|
|
|
|
function routingAbortError(): Error {
|
|
return Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
|
|
}
|
|
|
|
function forgetPathAndDescendants<T>(entries: Map<string, T>, root: string): void {
|
|
const prefix = root.endsWith(win32.sep) ? root : root + win32.sep
|
|
for (const path of entries.keys()) {
|
|
if (path === root || path.startsWith(prefix)) {
|
|
entries.delete(path)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drop the cached route and retry backoff after Git adds, moves, or removes a
|
|
* worktree at `cwd`, so the next command re-reads the rewritten `.git` marker.
|
|
* Descendants go too: a submodule under the worktree resolved its route from the
|
|
* same marker walk.
|
|
*
|
|
* A probe already in flight is deliberately left alone: it still answers and still
|
|
* caches, so a mutation that lands mid-probe is no worse off than the 30s TTL.
|
|
*/
|
|
export function invalidateWslLinkedWorktreeGitRouting(cwd: string): void {
|
|
const normalizedCwd = normalize(cwd)
|
|
forgetPathAndDescendants(routeByCwd, normalizedCwd)
|
|
forgetPathAndDescendants(transientFailuresByCwd, normalizedCwd)
|
|
}
|
|
|
|
function rememberTransientFailure(cwd: string, now: number): void {
|
|
const count = (transientFailuresByCwd.get(cwd)?.count ?? 0) + 1
|
|
const retryDelay =
|
|
count === 1
|
|
? 0
|
|
: Math.min(
|
|
WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS * 2 ** Math.min(count - 2, 10),
|
|
WSL_LINKED_WORKTREE_ROUTE_RETRY_MAX_MS
|
|
)
|
|
if (
|
|
!transientFailuresByCwd.has(cwd) &&
|
|
transientFailuresByCwd.size >= WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD
|
|
) {
|
|
const oldest = transientFailuresByCwd.keys().next().value
|
|
if (oldest !== undefined) {
|
|
transientFailuresByCwd.delete(oldest)
|
|
}
|
|
}
|
|
transientFailuresByCwd.delete(cwd)
|
|
transientFailuresByCwd.set(cwd, { count, retryAfter: now + retryDelay })
|
|
}
|
|
|
|
function canStartRouteProbe(cwd: string, now: number): boolean {
|
|
const failure = transientFailuresByCwd.get(cwd)
|
|
return Boolean(
|
|
(!failure || failure.retryAfter <= now) &&
|
|
(activeProbeCountByCwd.get(cwd) ?? 0) < WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD &&
|
|
activeProbeCount < WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL
|
|
)
|
|
}
|
|
|
|
function trackRouteProbe(cwd: string): () => void {
|
|
const generation = routeProbeGeneration
|
|
activeProbeCount += 1
|
|
activeProbeCountByCwd.set(cwd, (activeProbeCountByCwd.get(cwd) ?? 0) + 1)
|
|
return () => {
|
|
if (generation !== routeProbeGeneration) {
|
|
return
|
|
}
|
|
activeProbeCount -= 1
|
|
const remaining = (activeProbeCountByCwd.get(cwd) ?? 1) - 1
|
|
if (remaining === 0) {
|
|
activeProbeCountByCwd.delete(cwd)
|
|
} else {
|
|
activeProbeCountByCwd.set(cwd, remaining)
|
|
}
|
|
}
|
|
}
|
|
|
|
function waitForRouteUnlessAborted(
|
|
route: Promise<boolean>,
|
|
signal?: AbortSignal
|
|
): Promise<boolean> {
|
|
if (!signal) {
|
|
return route
|
|
}
|
|
if (signal.aborted) {
|
|
return Promise.reject(routingAbortError())
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const onAbort = (): void => {
|
|
signal.removeEventListener('abort', onAbort)
|
|
reject(routingAbortError())
|
|
}
|
|
signal.addEventListener('abort', onAbort, { once: true })
|
|
route.then(
|
|
(result) => {
|
|
signal.removeEventListener('abort', onAbort)
|
|
resolve(result)
|
|
},
|
|
(error) => {
|
|
signal.removeEventListener('abort', onAbort)
|
|
reject(error)
|
|
}
|
|
)
|
|
})
|
|
}
|
|
|
|
function discoverRoute(
|
|
cwd: string,
|
|
fileSystem: WslLinkedWorktreeRoutingFileSystem,
|
|
now: () => number
|
|
): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const generation = routeProbeGeneration
|
|
let settled = false
|
|
const finish = (usesHostGit: boolean, known: boolean): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
clearTimeout(timer)
|
|
if (generation !== routeProbeGeneration) {
|
|
resolve(false)
|
|
return
|
|
}
|
|
if (known) {
|
|
transientFailuresByCwd.delete(cwd)
|
|
resolve(rememberRoute(cwd, usesHostGit, now()))
|
|
} else {
|
|
rememberTransientFailure(cwd, now())
|
|
resolve(usesHostGit)
|
|
}
|
|
}
|
|
const timer = setTimeout(() => finish(false, false), WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS)
|
|
timer.unref()
|
|
const releaseProbe = trackRouteProbe(cwd)
|
|
void probeWslLinkedWorktreeGitRoute(cwd, fileSystem).then(
|
|
({ usesHostGit, known }) => {
|
|
releaseProbe()
|
|
finish(usesHostGit, known)
|
|
},
|
|
() => {
|
|
releaseProbe()
|
|
finish(false, false)
|
|
}
|
|
)
|
|
})
|
|
}
|
|
|
|
export async function prepareWslLinkedWorktreeGitRouting(
|
|
cwd: string,
|
|
wslDistro: string | undefined,
|
|
options: WslLinkedWorktreeRoutingOptions = {}
|
|
): Promise<boolean> {
|
|
const platform = options.platform ?? process.platform
|
|
const fileSystem = options.fileSystem ?? defaultWslLinkedWorktreeRoutingFileSystem
|
|
const now = options.now ?? Date.now
|
|
if (!isWslLinkedWorktreeGitRoutingCandidate(cwd, wslDistro, platform)) {
|
|
return false
|
|
}
|
|
if (options.signal?.aborted) {
|
|
throw routingAbortError()
|
|
}
|
|
const normalizedCwd = normalize(cwd)
|
|
const cached = cachedRoute(normalizedCwd, now())
|
|
if (cached !== undefined) {
|
|
return cached
|
|
}
|
|
const pending = pendingRoutes.get(normalizedCwd)
|
|
if (pending) {
|
|
return waitForRouteUnlessAborted(pending, options.signal)
|
|
}
|
|
if (!canStartRouteProbe(normalizedCwd, now())) {
|
|
return false
|
|
}
|
|
const discovery = discoverRoute(normalizedCwd, fileSystem, now)
|
|
const route = discovery.finally(() => {
|
|
if (pendingRoutes.get(normalizedCwd) === route) {
|
|
pendingRoutes.delete(normalizedCwd)
|
|
}
|
|
})
|
|
pendingRoutes.set(normalizedCwd, route)
|
|
return waitForRouteUnlessAborted(route, options.signal)
|
|
}
|
|
|
|
export function usesHostGitForWslLinkedWorktree(
|
|
cwd: string,
|
|
wslDistro: string | undefined,
|
|
platform: NodeJS.Platform = process.platform,
|
|
now: () => number = Date.now
|
|
): boolean {
|
|
return (
|
|
isWslLinkedWorktreeGitRoutingCandidate(cwd, wslDistro, platform) &&
|
|
cachedRoute(normalize(cwd), now()) === true
|
|
)
|
|
}
|
|
|
|
export function resetWslLinkedWorktreeGitRoutingForTests(): void {
|
|
routeByCwd.clear()
|
|
pendingRoutes.clear()
|
|
transientFailuresByCwd.clear()
|
|
activeProbeCountByCwd.clear()
|
|
activeProbeCount = 0
|
|
routeProbeGeneration += 1
|
|
nextRoutePruneAt = 0
|
|
}
|
|
|
|
export function seedWslLinkedWorktreeGitRoutingForTests(root: string): void {
|
|
const normalizedRoot = normalize(root)
|
|
routeByCwd.set(normalizedRoot, {
|
|
usesHostGit: true,
|
|
expiresAt: Number.POSITIVE_INFINITY
|
|
})
|
|
}
|