mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
* fix(runtime): retain watcher and PTY teardown ownership * fix(runtime): restore watchers after interrupted cleanup * fix(runtime): prevent stale watcher revival * test(runtime): cover watcher shutdown ownership * test(daemon): model physical PTY exit * fix(daemon): keep shutdown terminating when disposal cannot prove exit A rejecting host.dispose() (unreapable child past its exit deadline) left the shutdown RPC without its process.nextTick(shutdown) and skipped socket cleanup in shutdown(), stranding the daemon as an unreachable orphan after the stale-daemon replacement flow unlinks its socket. Log and continue: daemon exit reparents the child to init instead of blocking on it. * fix(runtime): keep local watching alive after an idle-kill deadline miss An idle child that outlived the exit deadline set shutdownRequested on the shared desktop supervisor, which has no retire-and-replace path — every later subscribe rejected supervisor_disposed and the roots were cached unwatchable, silently ending local file watching for the session. The idle path owns zero records, so there is no double-watch hazard; the zombie keeps its capacity reservation until physical exit and the next subscribe gets a fresh child. * fix(renderer): resync replayed paired-web file watches Transparent replay removed the implicit resync the old close-and-rebuild path provided: a replayed files.watch only reports changes from its own native setup, so changes during the reconnect gap were silently lost. Deliver a conservative overflow to consumers once the replayed watch is ready, matching the overflow-after-interruption contract everywhere else. * fix(runtime): address teardown review findings * fix(runtime): retry watches after teardown deadlines * Fix PTY descendant leaks on forced teardown * Fix jitter-sensitive terminal lifecycle test
156 lines
5.5 KiB
TypeScript
156 lines
5.5 KiB
TypeScript
export function isWindowsAbsolutePathLike(value: string): boolean {
|
|
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('//')
|
|
}
|
|
|
|
export function normalizeRuntimePathSeparators(value: string): string {
|
|
const normalized = value.replace(/\\/g, '/').replace(/\/+/g, '/')
|
|
if (value.startsWith('\\\\') || value.startsWith('//')) {
|
|
return `//${normalized.replace(/^\/+/, '')}`
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
export function normalizeRuntimePathForComparison(value: string): string {
|
|
const isWindowsPath = isWindowsAbsolutePathLike(value)
|
|
// Why: backslash is a valid POSIX filename character; fold it only when the
|
|
// path itself proves Windows drive/UNC semantics.
|
|
const normalized = trimRuntimePathTrailingSlash(
|
|
isWindowsPath ? normalizeRuntimePathSeparators(value) : value.replace(/\/+/g, '/')
|
|
)
|
|
const wslUnc = normalized.match(/^\/\/(?:wsl\.localhost|wsl\$)\/([^/]+)(\/[\s\S]*)?$/i)
|
|
if (wslUnc) {
|
|
// Why: Windows exposes the same case-sensitive WSL filesystem through two
|
|
// UNC aliases, while the distro/server portion remains case-insensitive.
|
|
return `//wsl/${wslUnc[1].toLowerCase()}${wslUnc[2] ?? ''}`
|
|
}
|
|
return isWindowsPath ? normalized.toLowerCase() : normalized
|
|
}
|
|
|
|
export function isRuntimePathAbsolute(
|
|
value: string,
|
|
pathFlavor: 'posix' | 'windows' = isWindowsPathFlavor(value) ? 'windows' : 'posix'
|
|
): boolean {
|
|
if (pathFlavor === 'windows') {
|
|
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\') || value.startsWith('/')
|
|
}
|
|
return value.startsWith('/')
|
|
}
|
|
|
|
export function resolveRuntimePath(basePath: string, targetPath: string): string {
|
|
const pathFlavor =
|
|
isWindowsPathFlavor(basePath) || isWindowsPathFlavor(targetPath) ? 'windows' : 'posix'
|
|
if (isRuntimePathAbsolute(targetPath, pathFlavor)) {
|
|
return normalizeRuntimePathDots(targetPath, pathFlavor)
|
|
}
|
|
return normalizeRuntimePathDots(
|
|
`${trimRuntimePathTrailingSlash(normalizeRuntimePathSeparators(basePath))}/${targetPath}`,
|
|
pathFlavor
|
|
)
|
|
}
|
|
|
|
export function getRuntimePathBasename(value: string): string {
|
|
const trimmed = value.replace(/[\\/]+$/g, '')
|
|
if (!trimmed) {
|
|
return ''
|
|
}
|
|
return trimmed.split(/[\\/]/).findLast(Boolean) ?? ''
|
|
}
|
|
|
|
export function isPathInsideOrEqual(rootPath: string, candidatePath: string): boolean {
|
|
const root = normalizeRuntimePathForComparison(rootPath)
|
|
const candidate = normalizeRuntimePathForComparison(candidatePath)
|
|
if (candidate === root) {
|
|
return true
|
|
}
|
|
const rootWithBoundary =
|
|
root === '/' || /^[a-z]:\/$/i.test(root) ? root : `${root.replace(/\/+$/, '')}/`
|
|
return candidate.startsWith(rootWithBoundary)
|
|
}
|
|
|
|
export function relativePathInsideRoot(rootPath: string, candidatePath: string): string | null {
|
|
const normalizedCandidate = trimRuntimePathTrailingSlash(
|
|
isWindowsAbsolutePathLike(candidatePath)
|
|
? normalizeRuntimePathSeparators(candidatePath)
|
|
: candidatePath.replace(/\/+/g, '/')
|
|
)
|
|
const comparisonRoot = normalizeRuntimePathForComparison(rootPath)
|
|
const comparisonCandidate = normalizeRuntimePathForComparison(candidatePath)
|
|
|
|
if (comparisonCandidate === comparisonRoot) {
|
|
return ''
|
|
}
|
|
const isRoot = comparisonRoot === '/' || /^[a-z]:\/$/i.test(comparisonRoot)
|
|
const comparisonPrefix = isRoot ? comparisonRoot : `${comparisonRoot}/`
|
|
if (!comparisonCandidate.startsWith(comparisonPrefix)) {
|
|
return null
|
|
}
|
|
// WSL comparison keys fold the UNC alias but preserve Linux path casing, so
|
|
// their suffix is both aligned across aliases and safe to return directly.
|
|
return comparisonRoot.startsWith('//wsl/')
|
|
? comparisonCandidate.slice(comparisonPrefix.length)
|
|
: normalizedCandidate.slice(comparisonPrefix.length)
|
|
}
|
|
|
|
function trimRuntimePathTrailingSlash(value: string): string {
|
|
if (value === '/' || /^[A-Za-z]:\/$/.test(value)) {
|
|
return value
|
|
}
|
|
return value.replace(/\/+$/, '')
|
|
}
|
|
|
|
function isWindowsPathFlavor(value: string): boolean {
|
|
return /^[A-Za-z]:[\\/]/.test(value) || value.includes('\\') || value.startsWith('//')
|
|
}
|
|
|
|
function normalizeRuntimePathDots(value: string, pathFlavor: 'posix' | 'windows'): string {
|
|
const normalized = normalizeRuntimePathSeparators(value)
|
|
const { root, rest } = splitRuntimePathRoot(normalized, pathFlavor)
|
|
const segments: string[] = []
|
|
for (const segment of rest.split('/')) {
|
|
if (!segment || segment === '.') {
|
|
continue
|
|
}
|
|
if (segment === '..') {
|
|
if (segments.length > 0 && segments.at(-1) !== '..') {
|
|
segments.pop()
|
|
} else if (!root) {
|
|
segments.push(segment)
|
|
}
|
|
continue
|
|
}
|
|
segments.push(segment)
|
|
}
|
|
const suffix = segments.join('/')
|
|
if (!root) {
|
|
return suffix || '.'
|
|
}
|
|
return suffix ? `${root}${suffix}` : trimRuntimePathTrailingSlash(root)
|
|
}
|
|
|
|
function splitRuntimePathRoot(
|
|
value: string,
|
|
pathFlavor: 'posix' | 'windows'
|
|
): { root: string; rest: string } {
|
|
if (pathFlavor === 'windows') {
|
|
const drive = value.match(/^([A-Za-z]:)(?:\/|$)/)
|
|
if (drive) {
|
|
return { root: `${drive[1]}/`, rest: value.slice(drive[0].length) }
|
|
}
|
|
if (value.startsWith('//')) {
|
|
const parts = value.slice(2).split('/')
|
|
if (parts.length >= 2 && parts[0] && parts[1]) {
|
|
const root = `//${parts[0]}/${parts[1]}/`
|
|
return { root, rest: parts.slice(2).join('/') }
|
|
}
|
|
return { root: '//', rest: value.slice(2) }
|
|
}
|
|
if (value.startsWith('/')) {
|
|
return { root: '/', rest: value.slice(1) }
|
|
}
|
|
}
|
|
if (value.startsWith('/')) {
|
|
return { root: '/', rest: value.slice(1) }
|
|
}
|
|
return { root: '', rest: value }
|
|
}
|