Merge branch 'main' of https://github.com/stablyai/orca into native-chat-still-shows-loading-when-ai-done

This commit is contained in:
Jinjing
2026-07-17 15:16:40 -07:00
181 changed files with 12018 additions and 1515 deletions
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.9m">
<title>downloads: 5.9m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 6.0m">
<title>downloads: 6.0m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.9m</text>
<text x="93.5" y="14">5.9m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">6.0m</text>
<text x="93.5" y="14">6.0m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

+1
View File
@@ -184,6 +184,7 @@ export default function RootLayout() {
/>
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
<Stack.Screen name="native-chat-settings" options={{ headerShown: false }} />
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
<Stack.Screen name="notifications" options={{ headerShown: false }} />
@@ -1199,7 +1199,7 @@ export default function SessionScreen() {
nativeChatInputLeaseReady,
onSendError: showNativeChatSendError
})
const { chatTabIds, toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController
const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController
const dictation = useMobileDictation({
client,
@@ -5334,7 +5334,7 @@ export default function SessionScreen() {
actions={getMobileTerminalActionSheetActions({
target: actionTarget,
tabs: sessionTabs.filter((tab) => tab.type === 'terminal'),
chatTabIds,
isTabChatView: nativeChatController.isTabChatView,
nativeChatTranscriptIsLocalReadable,
onDismiss: () => setActionTarget(null),
onToggleChat: toggleTabChatView,
+126
View File
@@ -0,0 +1,126 @@
import { View, Text, StyleSheet, Pressable, ScrollView, Switch } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { useMobileDefaultSessionViewPreference } from '../src/session/use-mobile-default-session-view-preference'
export default function NativeChatSettingsScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { defaultView, setDefaultView } = useMobileDefaultSessionViewPreference()
const chatDefault = defaultView === 'chat'
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Back"
style={styles.backButton}
onPress={() => router.back()}
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Native chat</Text>
</View>
<ScrollView
contentContainerStyle={{ paddingBottom: insets.bottom + spacing.lg }}
showsVerticalScrollIndicator={false}
>
<Text style={styles.groupHeading}>DEFAULT VIEW</Text>
<Text style={styles.groupDescription}>
Choose how supported agent sessions (Claude, Codex, and other chat-capable agents) open on
this device. Terminal shows the raw CLI; native chat shows a chat interface like the
desktop app. You can still switch any individual session from its long-press menu.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Open sessions in native chat</Text>
<Text style={styles.rowSublabel}>{chatDefault ? 'On' : 'Off'}</Text>
</View>
<Switch
accessibilityLabel="Open sessions in native chat"
value={chatDefault}
onValueChange={(next) => setDefaultView(next ? 'chat' : 'terminal')}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
</View>
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
paddingHorizontal: spacing.lg
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.sm,
marginBottom: spacing.lg
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.sm
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
groupHeading: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
letterSpacing: 0.5,
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
groupDescription: {
fontSize: typography.bodySize - 1,
color: colors.textSecondary,
lineHeight: 20,
paddingHorizontal: spacing.xs
},
section: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden'
},
sectionTopGap: {
marginTop: spacing.sm
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowContent: {
flex: 1
},
rowLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowSublabel: {
fontSize: typography.bodySize - 2,
color: colors.textSecondary,
marginTop: 2
}
})
+10
View File
@@ -20,6 +20,7 @@ import {
LifeBuoy,
Mic,
Globe,
MessageSquare,
Terminal as TerminalIcon,
KeyRound
} from 'lucide-react-native'
@@ -114,6 +115,15 @@ export default function SettingsScreen() {
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/native-chat-settings')}
>
<MessageSquare size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Native chat</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/browser-settings')}
@@ -11,19 +11,19 @@ type ToggleTab = MobileNativeChatTab & {
export function getMobileNativeChatToggleActions(args: {
terminalHandle: string | null
tabs: readonly ToggleTab[]
chatTabIds: ReadonlySet<string>
isTabChatView: (tabId: string) => boolean
nativeChatTranscriptIsLocalReadable: boolean
onClose: () => void
onToggle: (tabId: string) => void
}): ActionSheetAction[] {
const { terminalHandle, tabs, chatTabIds, onClose, onToggle } = args
const { terminalHandle, tabs, isTabChatView, onClose, onToggle } = args
const tab = terminalHandle
? tabs.find((candidate) => candidate.terminal === terminalHandle)
: null
if (!tab || !resolveMobileNativeChat(tab, args.nativeChatTranscriptIsLocalReadable)) {
return []
}
const isChat = chatTabIds.has(tab.id)
const isChat = isTabChatView(tab.id)
return [
{
label: isChat ? 'Switch to terminal view' : 'Switch to chat view',
@@ -10,7 +10,7 @@ type TerminalTab = MobileNativeChatTab & { id: string; terminal: string | null }
export function getMobileTerminalActionSheetActions<Target extends { handle: string }>(args: {
target: Target | null
tabs: readonly TerminalTab[]
chatTabIds: ReadonlySet<string>
isTabChatView: (tabId: string) => boolean
nativeChatTranscriptIsLocalReadable: boolean
onDismiss: () => void
onToggleChat: (tabId: string) => void
@@ -29,7 +29,7 @@ export function getMobileTerminalActionSheetActions<Target extends { handle: str
...getMobileNativeChatToggleActions({
terminalHandle: target.handle,
tabs: args.tabs,
chatTabIds: args.chatTabIds,
isTabChatView: args.isTabChatView,
nativeChatTranscriptIsLocalReadable: args.nativeChatTranscriptIsLocalReadable,
onClose: args.onDismiss,
onToggle: args.onToggleChat
@@ -0,0 +1,167 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
loadDefaultSessionView,
saveDefaultSessionView,
type MobileSessionView
} from '../storage/session-view-preferences'
import {
useMobileDefaultSessionViewPreference,
type MobileDefaultSessionViewPreference
} from './use-mobile-default-session-view-preference'
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
vi.mock('../storage/session-view-preferences', () => ({
DEFAULT_SESSION_VIEW: 'terminal',
loadDefaultSessionView: vi.fn(),
saveDefaultSessionView: vi.fn()
}))
describe('useMobileDefaultSessionViewPreference', () => {
let renderer: ReactTestRenderer | null = null
let preference: MobileDefaultSessionViewPreference | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
vi.mocked(loadDefaultSessionView).mockReset().mockResolvedValue('terminal')
vi.mocked(saveDefaultSessionView).mockReset().mockResolvedValue(undefined)
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
preference = null
})
async function mount(): Promise<void> {
function Harness(): null {
preference = useMobileDefaultSessionViewPreference()
return null
}
await act(async () => {
renderer = create(createElement(Harness))
await Promise.resolve()
})
}
it('keeps a fast toggle authoritative over the initial read', async () => {
const initialLoad = deferred<MobileSessionView>()
vi.mocked(loadDefaultSessionView).mockReturnValue(initialLoad.promise)
await mount()
act(() => preference?.setDefaultView('chat'))
expect(preference?.defaultView).toBe('chat')
await act(async () => {
initialLoad.resolve('terminal')
await initialLoad.promise
})
expect(preference?.defaultView).toBe('chat')
expect(saveDefaultSessionView).toHaveBeenCalledWith('chat')
})
it('submits every change immediately so shared persistence preserves event order', async () => {
const firstSave = deferred<void>()
vi.mocked(saveDefaultSessionView)
.mockImplementationOnce(() => firstSave.promise)
.mockResolvedValue(undefined)
await mount()
act(() => preference?.setDefaultView('chat'))
await act(async () => {
await Promise.resolve()
})
act(() => preference?.setDefaultView('terminal'))
act(() => preference?.setDefaultView('chat'))
await act(async () => {
await Promise.resolve()
})
expect(saveDefaultSessionView).toHaveBeenCalledTimes(3)
expect(saveDefaultSessionView).toHaveBeenNthCalledWith(2, 'terminal')
expect(saveDefaultSessionView).toHaveBeenNthCalledWith(3, 'chat')
await act(async () => {
firstSave.resolve()
await firstSave.promise
await Promise.resolve()
})
expect(saveDefaultSessionView).toHaveBeenCalledTimes(3)
})
it('reloads the persisted value when the latest write fails', async () => {
vi.mocked(loadDefaultSessionView)
.mockResolvedValueOnce('terminal')
.mockResolvedValueOnce('terminal')
vi.mocked(saveDefaultSessionView).mockRejectedValue(new Error('storage unavailable'))
await mount()
await act(async () => {
preference?.setDefaultView('chat')
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
expect(preference?.defaultView).toBe('terminal')
expect(loadDefaultSessionView).toHaveBeenCalledTimes(2)
})
it('does not let an older failed write roll back a newer choice', async () => {
const recoveryLoad = deferred<MobileSessionView>()
vi.mocked(loadDefaultSessionView)
.mockResolvedValueOnce('terminal')
.mockReturnValueOnce(recoveryLoad.promise)
vi.mocked(saveDefaultSessionView)
.mockRejectedValueOnce(new Error('storage unavailable'))
.mockResolvedValueOnce(undefined)
await mount()
act(() => preference?.setDefaultView('chat'))
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
act(() => preference?.setDefaultView('terminal'))
await act(async () => {
recoveryLoad.resolve('chat')
await recoveryLoad.promise
await Promise.resolve()
})
expect(preference?.defaultView).toBe('terminal')
expect(saveDefaultSessionView).toHaveBeenNthCalledWith(2, 'terminal')
})
it('submits the latest write before the Settings route unmounts', async () => {
const firstSave = deferred<void>()
vi.mocked(saveDefaultSessionView)
.mockImplementationOnce(() => firstSave.promise)
.mockResolvedValueOnce(undefined)
await mount()
act(() => preference?.setDefaultView('chat'))
await act(async () => {
await Promise.resolve()
})
act(() => preference?.setDefaultView('terminal'))
expect(saveDefaultSessionView).toHaveBeenNthCalledWith(2, 'terminal')
act(() => renderer?.unmount())
renderer = null
await act(async () => {
firstSave.resolve()
await firstSave.promise
await Promise.resolve()
})
})
})
@@ -0,0 +1,51 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
DEFAULT_SESSION_VIEW,
loadDefaultSessionView,
saveDefaultSessionView,
type MobileSessionView
} from '../storage/session-view-preferences'
export type MobileDefaultSessionViewPreference = {
defaultView: MobileSessionView
setDefaultView: (view: MobileSessionView) => void
}
/** Owns the optimistic Settings value while keeping AsyncStorage writes ordered. */
export function useMobileDefaultSessionViewPreference(): MobileDefaultSessionViewPreference {
const [defaultView, setDefaultViewState] = useState<MobileSessionView>(DEFAULT_SESSION_VIEW)
const mountedRef = useRef(false)
const mutationRevisionRef = useRef(0)
useEffect(() => {
mountedRef.current = true
const loadRevision = mutationRevisionRef.current
let stale = false
void loadDefaultSessionView().then((view) => {
// Why: a fast toggle is authoritative over the older storage read.
if (!stale && mutationRevisionRef.current === loadRevision) {
setDefaultViewState(view)
}
})
return () => {
stale = true
mountedRef.current = false
}
}, [])
const setDefaultView = useCallback((view: MobileSessionView) => {
const revision = mutationRevisionRef.current + 1
mutationRevisionRef.current = revision
setDefaultViewState(view)
// Why: persistence owns a shared queue, so invoking it at event time preserves
// mutation order even when this route unmounts and a new instance takes over.
void saveDefaultSessionView(view).catch(async () => {
const persisted = await loadDefaultSessionView()
if (mountedRef.current && mutationRevisionRef.current === revision) {
setDefaultViewState(persisted)
}
})
}, [])
return { defaultView, setDefaultView }
}
@@ -1,13 +1,11 @@
import {
useCallback,
useEffect,
useRef,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction
} from 'react'
import { loadNativeChatTabIds, saveNativeChatTabIds } from '../storage/preferences'
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
import type { RpcClient } from '../transport/rpc-client'
import {
parseAskFromStatus,
@@ -31,7 +29,8 @@ import { useThrottledLatestValue } from './use-throttled-latest-value'
const NATIVE_CHAT_STREAM_THROTTLE_MS = 50
export type MobileNativeChatController = {
chatTabIds: Set<string>
/** Whether a tab's effective view is chat (per-tab override, else the default). */
isTabChatView: (tabId: string) => boolean
toggleTabChatView: (tabId: string) => void
showNativeChat: boolean
showNativeChatRef: MutableRefObject<boolean>
@@ -85,46 +84,10 @@ export function useMobileNativeChatController(args: {
nativeChatInputLeaseReady,
onSendError
} = args
const [chatTabIds, setChatTabIds] = useState<Set<string>>(new Set())
const chatTabIdsToggledRef = useRef(false)
useEffect(() => {
let active = true
// Re-arm per host/worktree so the fresh load can seed the new selection.
chatTabIdsToggledRef.current = false
void loadNativeChatTabIds(hostId, worktreeId).then((ids) => {
// Why: a toggle before this load resolves is authoritative; the load must
// not revert the user's in-memory choice back to the persisted state.
if (active && !chatTabIdsToggledRef.current) {
// Why: route reuse must clear the prior worktree's selection even when
// the newly loaded worktree has no saved chat tabs.
setChatTabIds(new Set(ids))
}
})
return () => {
active = false
}
}, [hostId, worktreeId])
const toggleTabChatView = useCallback(
(tabId: string) => {
chatTabIdsToggledRef.current = true
setChatTabIds((previous) => {
const next = new Set(previous)
if (next.has(tabId)) {
next.delete(tabId)
} else {
next.add(tabId)
}
void saveNativeChatTabIds(hostId, worktreeId, [...next])
return next
})
},
[hostId, worktreeId]
)
const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId })
const activeChatResolution =
activeSessionTab && activeSessionTabId && chatTabIds.has(activeSessionTabId)
activeSessionTab && activeSessionTabId && isTabChatView(activeSessionTabId)
? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable)
: null
const showNativeChat = activeChatResolution != null
@@ -288,7 +251,7 @@ export function useMobileNativeChatController(args: {
)
return {
chatTabIds,
isTabChatView,
toggleTabChatView,
showNativeChat,
showNativeChatRef,
@@ -0,0 +1,384 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
loadDefaultSessionView,
readSessionViewOverridesPreference,
updateSessionViewOverride,
type MobileSessionView
} from '../storage/session-view-preferences'
import {
useMobileSessionViewMode,
type MobileSessionViewModeController
} from './use-mobile-session-view-mode'
const focusEffectRuntime = vi.hoisted(() => ({
callback: null as null | (() => undefined | (() => void))
}))
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason?: unknown) => void
} {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
vi.mock('expo-router', async () => {
const react = await import('react')
return {
// Run the focus callback once on mount, mirroring a focus.
useFocusEffect: (cb: () => undefined | (() => void)) => {
focusEffectRuntime.callback = cb
react.useEffect(() => cb(), [cb])
}
}
})
vi.mock('../storage/session-view-preferences', () => ({
loadDefaultSessionView: vi.fn(),
readSessionViewOverridesPreference: vi.fn(),
updateSessionViewOverride: vi.fn()
}))
describe('useMobileSessionViewMode', () => {
let renderer: ReactTestRenderer | null = null
let controller: MobileSessionViewModeController | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
vi.mocked(loadDefaultSessionView).mockReset().mockResolvedValue('terminal')
vi.mocked(readSessionViewOverridesPreference)
.mockReset()
.mockResolvedValue({ overrides: new Map(), loaded: true })
vi.mocked(updateSessionViewOverride).mockReset().mockResolvedValue(undefined)
focusEffectRuntime.callback = null
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
controller = null
})
async function mount(args: {
defaultView: MobileSessionView
overrides?: Map<string, MobileSessionView>
}): Promise<void> {
vi.mocked(loadDefaultSessionView).mockResolvedValue(args.defaultView)
vi.mocked(readSessionViewOverridesPreference).mockResolvedValue({
overrides: args.overrides ?? new Map(),
loaded: true
})
function Harness(): null {
controller = useMobileSessionViewMode({ hostId: 'h', worktreeId: 'w' })
return null
}
await act(async () => {
renderer = create(createElement(Harness))
await Promise.resolve()
await Promise.resolve()
})
}
it('follows the default when a tab has no override', async () => {
await mount({ defaultView: 'terminal' })
expect(controller?.isTabChatView('t1')).toBe(false)
act(() => renderer?.unmount())
renderer = null
await mount({ defaultView: 'chat' })
expect(controller?.isTabChatView('t1')).toBe(true)
})
it('lets a per-tab override win over the default', async () => {
await mount({
defaultView: 'chat',
overrides: new Map<string, MobileSessionView>([['t1', 'terminal']])
})
expect(controller?.isTabChatView('t1')).toBe(false)
expect(controller?.isTabChatView('t2')).toBe(true)
})
it('reloads the default on refocus after Settings changes it', async () => {
await mount({ defaultView: 'terminal' })
expect(controller?.isTabChatView('t1')).toBe(false)
vi.mocked(loadDefaultSessionView).mockResolvedValue('chat')
await act(async () => {
focusEffectRuntime.callback?.()
await Promise.resolve()
})
expect(controller?.isTabChatView('t1')).toBe(true)
})
it('toggles from the effective view and persists the override', async () => {
await mount({ defaultView: 'chat' })
await act(async () => {
controller?.toggleTabChatView('t1')
await Promise.resolve()
})
expect(updateSessionViewOverride).toHaveBeenLastCalledWith('h', 'w', 't1', 'terminal')
expect(controller?.isTabChatView('t1')).toBe(false)
expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(1)
})
it('toggles a terminal-default tab into chat', async () => {
await mount({ defaultView: 'terminal' })
await act(async () => {
controller?.toggleTabChatView('t1')
await Promise.resolve()
})
expect(updateSessionViewOverride).toHaveBeenLastCalledWith('h', 'w', 't1', 'chat')
expect(controller?.isTabChatView('t1')).toBe(true)
})
it('does not expose overrides from the previous host while the next scope loads', async () => {
const nextScopeLoad = deferred<Map<string, MobileSessionView>>()
vi.mocked(readSessionViewOverridesPreference).mockImplementation((hostId) =>
hostId === 'h1'
? Promise.resolve({
overrides: new Map([['same-tab-id', 'chat' as const]]),
loaded: true
})
: nextScopeLoad.promise.then((overrides) => ({ overrides, loaded: true }))
)
function Harness(props: { hostId: string; worktreeId: string }): null {
controller = useMobileSessionViewMode(props)
return null
}
await act(async () => {
renderer = create(createElement(Harness, { hostId: 'h1', worktreeId: 'w1' }))
await Promise.resolve()
await Promise.resolve()
})
expect(controller?.isTabChatView('same-tab-id')).toBe(true)
await act(async () => {
renderer?.update(createElement(Harness, { hostId: 'h2', worktreeId: 'w2' }))
await Promise.resolve()
})
expect(controller?.isTabChatView('same-tab-id')).toBe(false)
await act(async () => {
nextScopeLoad.resolve(new Map())
await Promise.resolve()
})
})
it('merges a toggle made during load with the other persisted overrides', async () => {
const overridesLoad = deferred<Map<string, MobileSessionView>>()
vi.mocked(readSessionViewOverridesPreference).mockReturnValue(
overridesLoad.promise.then((overrides) => ({ overrides, loaded: true }))
)
function Harness(): null {
controller = useMobileSessionViewMode({ hostId: 'h', worktreeId: 'w' })
return null
}
await act(async () => {
renderer = create(createElement(Harness))
await Promise.resolve()
})
act(() => controller?.toggleTabChatView('new-tab'))
expect(controller?.isTabChatView('new-tab')).toBe(true)
expect(updateSessionViewOverride).toHaveBeenCalledWith('h', 'w', 'new-tab', 'chat')
await act(async () => {
overridesLoad.resolve(new Map([['saved-tab', 'chat']]))
await Promise.resolve()
await Promise.resolve()
})
expect(controller?.isTabChatView('saved-tab')).toBe(true)
expect(controller?.isTabChatView('new-tab')).toBe(true)
})
it('toggles from the fail-closed view while overrides load under a chat default', async () => {
const overridesLoad = deferred<Map<string, MobileSessionView>>()
vi.mocked(loadDefaultSessionView).mockResolvedValue('chat')
vi.mocked(readSessionViewOverridesPreference).mockReturnValue(
overridesLoad.promise.then((overrides) => ({ overrides, loaded: true }))
)
function Harness(): null {
controller = useMobileSessionViewMode({ hostId: 'h', worktreeId: 'w' })
return null
}
await act(async () => {
renderer = create(createElement(Harness))
await Promise.resolve()
await Promise.resolve()
})
expect(controller?.isTabChatView('new-tab')).toBe(false)
act(() => controller?.toggleTabChatView('new-tab'))
expect(updateSessionViewOverride).toHaveBeenCalledWith('h', 'w', 'new-tab', 'chat')
expect(controller?.isTabChatView('new-tab')).toBe(true)
await act(async () => {
overridesLoad.resolve(new Map())
await overridesLoad.promise
await Promise.resolve()
})
})
it('submits rapid mutations in event order', async () => {
await mount({ defaultView: 'terminal' })
act(() => controller?.toggleTabChatView('t1'))
act(() => controller?.toggleTabChatView('t2'))
expect(updateSessionViewOverride).toHaveBeenNthCalledWith(1, 'h', 'w', 't1', 'chat')
expect(updateSessionViewOverride).toHaveBeenNthCalledWith(2, 'h', 'w', 't2', 'chat')
})
it('reconciles the latest optimistic override when persistence fails', async () => {
vi.mocked(updateSessionViewOverride).mockRejectedValue(new Error('storage unavailable'))
vi.mocked(readSessionViewOverridesPreference)
.mockResolvedValueOnce({
overrides: new Map<string, MobileSessionView>([['t1', 'terminal']]),
loaded: true
})
.mockResolvedValueOnce({
overrides: new Map<string, MobileSessionView>([['t1', 'terminal']]),
loaded: true
})
await mount({
defaultView: 'terminal',
overrides: new Map<string, MobileSessionView>([['t1', 'terminal']])
})
await act(async () => {
controller?.toggleTabChatView('t1')
await Promise.resolve()
await Promise.resolve()
})
expect(controller?.isTabChatView('t1')).toBe(false)
expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(2)
})
it('does not let an older failed override roll back a newer choice', async () => {
const recoveryLoad = deferred<Map<string, MobileSessionView>>()
vi.mocked(updateSessionViewOverride)
.mockRejectedValueOnce(new Error('storage unavailable'))
.mockResolvedValueOnce(undefined)
await mount({ defaultView: 'terminal' })
vi.mocked(readSessionViewOverridesPreference).mockReturnValueOnce(
recoveryLoad.promise.then((overrides) => ({ overrides, loaded: true }))
)
await act(async () => {
controller?.toggleTabChatView('t1')
await Promise.resolve()
})
act(() => controller?.toggleTabChatView('t1'))
await act(async () => {
recoveryLoad.resolve(new Map([['t1', 'chat']]))
await recoveryLoad.promise
await Promise.resolve()
})
expect(controller?.isTabChatView('t1')).toBe(false)
expect(updateSessionViewOverride).toHaveBeenNthCalledWith(2, 'h', 'w', 't1', 'terminal')
})
it('fails closed when both an override write and its recovery read fail', async () => {
vi.mocked(loadDefaultSessionView).mockResolvedValue('chat')
vi.mocked(updateSessionViewOverride).mockRejectedValue(new Error('storage unavailable'))
vi.mocked(readSessionViewOverridesPreference)
.mockResolvedValueOnce({
overrides: new Map<string, MobileSessionView>([['t1', 'terminal']]),
loaded: true
})
.mockResolvedValueOnce({ overrides: new Map(), loaded: false })
await mount({
defaultView: 'chat',
overrides: new Map<string, MobileSessionView>([['t1', 'terminal']])
})
await act(async () => {
controller?.toggleTabChatView('t1')
await Promise.resolve()
await Promise.resolve()
})
expect(controller?.isTabChatView('t1')).toBe(false)
expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(2)
})
it('finishes an early toggle save after the route unmounts', async () => {
const overridesLoad = deferred<Map<string, MobileSessionView>>()
vi.mocked(readSessionViewOverridesPreference).mockReturnValue(
overridesLoad.promise.then((overrides) => ({ overrides, loaded: true }))
)
function Harness(): null {
controller = useMobileSessionViewMode({ hostId: 'h', worktreeId: 'w' })
return null
}
await act(async () => {
renderer = create(createElement(Harness))
await Promise.resolve()
})
act(() => controller?.toggleTabChatView('new-tab'))
act(() => renderer?.unmount())
renderer = null
await act(async () => {
overridesLoad.resolve(new Map([['saved-tab', 'terminal']]))
await Promise.resolve()
await Promise.resolve()
})
expect(updateSessionViewOverride).toHaveBeenCalledWith('h', 'w', 'new-tab', 'chat')
})
it('does not reload a failed override after the route unmounts', async () => {
const failedWrite = deferred<void>()
vi.mocked(updateSessionViewOverride).mockReturnValue(failedWrite.promise)
await mount({ defaultView: 'terminal' })
act(() => controller?.toggleTabChatView('t1'))
act(() => renderer?.unmount())
renderer = null
await act(async () => {
failedWrite.reject(new Error('storage unavailable'))
await Promise.resolve()
await Promise.resolve()
})
expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(1)
})
it('fails closed to terminal when overrides cannot be read under a chat default', async () => {
vi.mocked(loadDefaultSessionView).mockResolvedValue('chat')
vi.mocked(readSessionViewOverridesPreference).mockResolvedValue({
overrides: new Map(),
loaded: false
})
function Harness(): null {
controller = useMobileSessionViewMode({ hostId: 'h', worktreeId: 'w' })
return null
}
await act(async () => {
renderer = create(createElement(Harness))
await Promise.resolve()
await Promise.resolve()
})
expect(controller?.isTabChatView('t1')).toBe(false)
})
})
@@ -0,0 +1,208 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useFocusEffect } from 'expo-router'
import {
loadDefaultSessionView,
readSessionViewOverridesPreference,
updateSessionViewOverride,
type MobileSessionView,
type SessionViewOverridesPreference
} from '../storage/session-view-preferences'
type ViewOverridesState = {
hostId: string
worktreeId: string
overrides: Map<string, MobileSessionView>
loaded: boolean
}
type ViewOverridesRuntime = {
hostId: string
worktreeId: string
loadPromise: Promise<SessionViewOverridesPreference>
currentOverrides: Map<string, MobileSessionView>
mutationRevisions: Map<string, number>
}
function isOverrideScope(state: ViewOverridesState, hostId: string, worktreeId: string): boolean {
return state.hostId === hostId && state.worktreeId === worktreeId
}
function mergeOverrides(
persisted: ReadonlyMap<string, MobileSessionView>,
current: ReadonlyMap<string, MobileSessionView>
): Map<string, MobileSessionView> {
const merged = new Map(persisted)
for (const [tabId, view] of current) {
merged.set(tabId, view)
}
return merged
}
export type MobileSessionViewModeController = {
/** Whether a tab's effective view is chat (per-tab override, else the default). */
isTabChatView: (tabId: string) => boolean
toggleTabChatView: (tabId: string) => void
}
/** Resolves each tab's terminal/chat view: a per-device default (reloaded on focus
* so a Settings change applies without remounting the route) overlaid by persisted
* per-tab overrides that pin a session regardless of what the default later becomes. */
export function useMobileSessionViewMode(args: {
hostId: string
worktreeId: string
}): MobileSessionViewModeController {
const { hostId, worktreeId } = args
const [viewOverridesState, setViewOverridesState] = useState<ViewOverridesState>(() => ({
hostId,
worktreeId,
overrides: new Map(),
loaded: false
}))
const viewOverridesStateRef = useRef(viewOverridesState)
viewOverridesStateRef.current = viewOverridesState
const viewOverridesRuntimeRef = useRef<ViewOverridesRuntime | null>(null)
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
const ensureViewOverridesRuntime = useCallback((scopeHostId: string, scopeWorktreeId: string) => {
const current = viewOverridesRuntimeRef.current
if (current?.hostId === scopeHostId && current.worktreeId === scopeWorktreeId) {
return current
}
const next: ViewOverridesRuntime = {
hostId: scopeHostId,
worktreeId: scopeWorktreeId,
loadPromise: readSessionViewOverridesPreference(scopeHostId, scopeWorktreeId),
currentOverrides: new Map(),
mutationRevisions: new Map()
}
viewOverridesRuntimeRef.current = next
return next
}, [])
const [defaultView, setDefaultView] = useState<MobileSessionView>('terminal')
// Why: the toggle callback reads the live default without depending on it, so
// its identity stays stable and it never captures a stale default.
const defaultViewRef = useRef(defaultView)
defaultViewRef.current = defaultView
useEffect(() => {
let active = true
const runtime = ensureViewOverridesRuntime(hostId, worktreeId)
void runtime.loadPromise.then((preference) => {
if (!active) {
return
}
// Why: toggles made during the read are authoritative, but must not
// discard unrelated persisted overrides from the same worktree.
const merged = mergeOverrides(preference.overrides, runtime.currentOverrides)
runtime.currentOverrides = merged
// Why: an unreadable override store cannot safely be treated as empty when
// the default is chat; fail closed to terminal until a user toggles.
const next = { hostId, worktreeId, overrides: merged, loaded: preference.loaded }
viewOverridesStateRef.current = next
setViewOverridesState(next)
})
return () => {
active = false
}
}, [ensureViewOverridesRuntime, hostId, worktreeId])
// Why: reload on focus so returning from Settings picks up a changed default.
useFocusEffect(
useCallback(() => {
let active = true
void loadDefaultSessionView().then((view) => {
if (active) {
setDefaultView(view)
}
})
return () => {
active = false
}
}, [])
)
const isTabChatView = useCallback(
(tabId: string): boolean => {
if (!isOverrideScope(viewOverridesState, hostId, worktreeId)) {
return false
}
const override = viewOverridesState.overrides.get(tabId)
// Until this scope loads, only an immediate user toggle is authoritative;
// defaulting other tabs to terminal avoids activating stale cross-host chat.
return (override ?? (viewOverridesState.loaded ? defaultView : 'terminal')) === 'chat'
},
[defaultView, hostId, viewOverridesState, worktreeId]
)
const toggleTabChatView = useCallback(
(tabId: string) => {
const current = viewOverridesStateRef.current
const currentScope = isOverrideScope(current, hostId, worktreeId)
? current
: {
hostId,
worktreeId,
overrides: new Map<string, MobileSessionView>(),
loaded: false
}
const overrides = new Map(currentScope.overrides)
// Flip from the tab's effective view (its override, else the default), so
// a tab following a chat default can still be pinned back to terminal.
const fallbackView = currentScope.loaded ? defaultViewRef.current : 'terminal'
const currentlyChat = (overrides.get(tabId) ?? fallbackView) === 'chat'
const nextView = currentlyChat ? 'terminal' : 'chat'
overrides.set(tabId, nextView)
const next = { ...currentScope, overrides }
viewOverridesStateRef.current = next
setViewOverridesState(next)
const runtime = ensureViewOverridesRuntime(hostId, worktreeId)
runtime.currentOverrides = overrides
const revision = (runtime.mutationRevisions.get(tabId) ?? 0) + 1
runtime.mutationRevisions.set(tabId, revision)
// Why: enqueue the individual mutation immediately so a remounted route
// cannot reorder it or replace unrelated overrides with a stale snapshot.
void updateSessionViewOverride(hostId, worktreeId, tabId, nextView).catch(async () => {
if (!mountedRef.current || viewOverridesRuntimeRef.current !== runtime) {
return
}
const preference = await readSessionViewOverridesPreference(hostId, worktreeId)
// Why: a failed older write must not roll back a newer choice for this tab.
if (
!mountedRef.current ||
viewOverridesRuntimeRef.current !== runtime ||
runtime.mutationRevisions.get(tabId) !== revision
) {
return
}
// Why: if recovery is also unreadable, fail closed instead of treating an
// unknown store as empty or restoring an earlier optimistic mutation.
const reconciled = preference.loaded
? mergeOverrides(preference.overrides, runtime.currentOverrides)
: new Map(runtime.currentOverrides)
const recoveredOverride = preference.loaded ? preference.overrides.get(tabId) : 'terminal'
if (recoveredOverride) {
reconciled.set(tabId, recoveredOverride)
} else {
reconciled.delete(tabId)
}
runtime.currentOverrides = reconciled
const latest = viewOverridesStateRef.current
if (!isOverrideScope(latest, hostId, worktreeId)) {
return
}
const reconciledState = { ...latest, overrides: reconciled, loaded: preference.loaded }
viewOverridesStateRef.current = reconciledState
setViewOverridesState(reconciledState)
})
},
[ensureViewOverridesRuntime, hostId, worktreeId]
)
return { isTabChatView, toggleTabChatView }
}
+192 -10
View File
@@ -8,7 +8,6 @@ import {
HOST_SIDEBAR_MIN_WIDTH,
clampHostDockWidth,
clampHostSidebarWidth,
loadNativeChatTabIds,
loadDisabledTerminalLiveInputHandles,
loadHostSidebarWidth,
loadPushNotificationsEnabled,
@@ -18,11 +17,17 @@ import {
readDisabledTerminalLiveInputHandlesPreference,
saveDisabledTerminalLiveInputHandles,
saveHostSidebarWidth,
saveNativeChatTabIds,
savePushNotificationsEnabled,
saveTerminalAutocompleteEnabled,
saveTerminalLinkOpenMode
} from './preferences'
import {
loadDefaultSessionView,
loadSessionViewOverrides,
readSessionViewOverridesPreference,
saveDefaultSessionView,
updateSessionViewOverride
} from './session-view-preferences'
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
@@ -31,27 +36,204 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
}
}))
describe('native chat tab preference', () => {
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
describe('session view preference', () => {
beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockReset()
})
it('loads and saves tab ids under a host-and-worktree scoped key', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['tab-1', 42, 'tab-2']))
it('defaults to terminal and persists the chat default', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
await expect(loadDefaultSessionView()).resolves.toBe('terminal')
expect(AsyncStorage.getItem).toHaveBeenCalledWith('orca:defaultSessionView')
await expect(loadNativeChatTabIds('host/one', 'folder:C:\\repo')).resolves.toEqual([
'tab-1',
'tab-2'
await saveDefaultSessionView('chat')
expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:defaultSessionView', 'chat')
vi.mocked(AsyncStorage.getItem).mockResolvedValue('bogus')
await expect(loadDefaultSessionView()).resolves.toBe('terminal')
})
it('loads and updates per-tab overrides under a host-and-worktree scoped key', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(
JSON.stringify({ 'tab-1': 'chat', 'tab-2': 'terminal', 'tab-3': 'bogus' })
)
const loaded = await loadSessionViewOverrides('host/one', 'folder:C:\\repo')
expect([...loaded.entries()]).toEqual([
['tab-1', 'chat'],
['tab-2', 'terminal']
])
expect(AsyncStorage.getItem).toHaveBeenCalledWith(
'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo'
)
await saveNativeChatTabIds('host/one', 'folder:C:\\repo', ['tab-2'])
await updateSessionViewOverride('host/one', 'folder:C:\\repo', 'tab-2', 'chat')
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo',
JSON.stringify(['tab-2'])
JSON.stringify({ 'tab-1': 'chat', 'tab-2': 'chat' })
)
})
it('migrates the legacy array format to chat overrides', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['tab-1', 42, 'tab-2']))
const loaded = await loadSessionViewOverrides('host', 'wt')
expect([...loaded.entries()]).toEqual([
['tab-1', 'chat'],
['tab-2', 'chat']
])
})
it('serializes default writes across callers and makes reads wait for the latest', async () => {
let stored = 'terminal'
const firstWrite = deferred<void>()
vi.mocked(AsyncStorage.getItem).mockImplementation(async () => stored)
vi.mocked(AsyncStorage.setItem)
.mockImplementationOnce(async (_key, value) => {
await firstWrite.promise
stored = value
})
.mockImplementation(async (_key, value) => {
stored = value
})
const older = saveDefaultSessionView('chat')
await Promise.resolve()
const newer = saveDefaultSessionView('terminal')
const reloaded = loadDefaultSessionView()
await Promise.resolve()
expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1)
firstWrite.resolve()
await Promise.all([older, newer])
await expect(reloaded).resolves.toBe('terminal')
expect(AsyncStorage.setItem).toHaveBeenNthCalledWith(1, 'orca:defaultSessionView', 'chat')
expect(AsyncStorage.setItem).toHaveBeenNthCalledWith(2, 'orca:defaultSessionView', 'terminal')
})
it('continues the shared default queue after a failed write', async () => {
let stored = 'terminal'
vi.mocked(AsyncStorage.getItem).mockImplementation(async () => stored)
vi.mocked(AsyncStorage.setItem)
.mockRejectedValueOnce(new Error('storage unavailable'))
.mockImplementation(async (_key, value) => {
stored = value
})
const failed = expect(saveDefaultSessionView('terminal')).rejects.toThrow('storage unavailable')
const latest = saveDefaultSessionView('chat')
await failed
await latest
await expect(loadDefaultSessionView()).resolves.toBe('chat')
expect(AsyncStorage.setItem).toHaveBeenCalledTimes(2)
})
it('orders per-tab mutations across callers without losing saved siblings', async () => {
let stored = JSON.stringify({ saved: 'chat' })
const firstWrite = deferred<void>()
vi.mocked(AsyncStorage.getItem).mockImplementation(async () => stored)
vi.mocked(AsyncStorage.setItem)
.mockImplementationOnce(async (_key, value) => {
await firstWrite.promise
stored = value
})
.mockImplementation(async (_key, value) => {
stored = value
})
const first = updateSessionViewOverride('host', 'worktree', 'first', 'terminal')
await vi.waitFor(() => expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1))
const second = updateSessionViewOverride('host', 'worktree', 'second', 'chat')
const reloaded = loadSessionViewOverrides('host', 'worktree')
expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1)
firstWrite.resolve()
await Promise.all([first, second])
await expect(reloaded).resolves.toEqual(
new Map([
['saved', 'chat'],
['first', 'terminal'],
['second', 'chat']
])
)
})
it('does not globally block updates for a different host and worktree', async () => {
const blockedWrite = deferred<void>()
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
vi.mocked(AsyncStorage.setItem).mockImplementation(async (key) => {
if (key.includes('blocked-host')) {
await blockedWrite.promise
}
})
const blocked = updateSessionViewOverride('blocked-host', 'worktree', 'tab', 'chat')
await Promise.resolve()
await Promise.resolve()
await updateSessionViewOverride('other-host', 'worktree', 'tab', 'terminal')
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'orca:nativeChatTabs:other-host:worktree',
JSON.stringify({ tab: 'terminal' })
)
blockedWrite.resolve()
await blocked
})
it('continues a scoped override queue after a failed write', async () => {
let stored: string | null = null
vi.mocked(AsyncStorage.getItem).mockImplementation(async () => stored)
vi.mocked(AsyncStorage.setItem)
.mockRejectedValueOnce(new Error('storage unavailable'))
.mockImplementation(async (_key, value) => {
stored = value
})
const failed = updateSessionViewOverride('host', 'worktree', 'first', 'chat')
const latest = updateSessionViewOverride('host', 'worktree', 'second', 'terminal')
await expect(failed).rejects.toThrow('storage unavailable')
await latest
await expect(loadSessionViewOverrides('host', 'worktree')).resolves.toEqual(
new Map([['second', 'terminal']])
)
})
it('does not replace saved overrides after a transient read failure', async () => {
vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable'))
await expect(readSessionViewOverridesPreference('host', 'worktree')).resolves.toEqual({
overrides: new Map(),
loaded: false
})
await expect(updateSessionViewOverride('host', 'worktree', 'tab', 'chat')).rejects.toThrow(
'Session view overrides could not be read'
)
expect(AsyncStorage.setItem).not.toHaveBeenCalled()
})
it('repairs invalid preference data on the next user mutation', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue('not-json')
await updateSessionViewOverride('host', 'worktree', 'tab', 'chat')
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'orca:nativeChatTabs:host:worktree',
JSON.stringify({ tab: 'chat' })
)
})
})
-29
View File
@@ -2,35 +2,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
const PINS_PREFIX = 'orca:pins:'
const NOTIF_KEY = 'orca:pushNotificationsEnabled'
const NATIVE_CHAT_TABS_PREFIX = 'orca:nativeChatTabs:'
function nativeChatTabIdsKey(hostId: string, worktreeId: string): string {
return `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}`
}
/** Tab ids currently showing native chat, scoped to the paired host and worktree
* so colliding remote ids cannot activate a transcript watcher on another host. */
export async function loadNativeChatTabIds(hostId: string, worktreeId: string): Promise<string[]> {
try {
const raw = await AsyncStorage.getItem(nativeChatTabIdsKey(hostId, worktreeId))
const parsed = raw ? (JSON.parse(raw) as unknown) : null
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
} catch {
return []
}
}
export async function saveNativeChatTabIds(
hostId: string,
worktreeId: string,
ids: string[]
): Promise<void> {
try {
await AsyncStorage.setItem(nativeChatTabIdsKey(hostId, worktreeId), JSON.stringify(ids))
} catch {
// Best-effort persistence; a failed write just forgets the view choice.
}
}
export type PushNotificationsPreference = {
readonly value: boolean | null
@@ -0,0 +1,141 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
/** How a supported agent session opens: the raw terminal or the native chat view. */
export type MobileSessionView = 'terminal' | 'chat'
const DEFAULT_SESSION_VIEW_KEY = 'orca:defaultSessionView'
const NATIVE_CHAT_TABS_PREFIX = 'orca:nativeChatTabs:'
// Why: default stays terminal so native chat remains strictly opt-in.
export const DEFAULT_SESSION_VIEW: MobileSessionView = 'terminal'
let defaultViewWriteBarrier: Promise<void> | null = null
const overrideUpdateBarriers = new Map<string, Promise<void>>()
function sessionViewOverridesKey(hostId: string, worktreeId: string): string {
return `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}`
}
function clearDefaultViewWriteBarrier(barrier: Promise<void>): void {
if (defaultViewWriteBarrier === barrier) {
defaultViewWriteBarrier = null
}
}
/** Global (per-device) default for how supported agent sessions open. */
export async function loadDefaultSessionView(): Promise<MobileSessionView> {
await defaultViewWriteBarrier
try {
const raw = await AsyncStorage.getItem(DEFAULT_SESSION_VIEW_KEY)
return raw === 'chat' || raw === 'terminal' ? raw : DEFAULT_SESSION_VIEW
} catch {
return DEFAULT_SESSION_VIEW
}
}
export function saveDefaultSessionView(view: MobileSessionView): Promise<void> {
// Why: callers can outlive their route; a shared barrier keeps remounted
// Settings screens from letting an older write land after a newer choice.
const write = (defaultViewWriteBarrier ?? Promise.resolve()).then(() =>
AsyncStorage.setItem(DEFAULT_SESSION_VIEW_KEY, view)
)
const barrier = write.catch(() => undefined)
defaultViewWriteBarrier = barrier
void barrier.then(() => clearDefaultViewWriteBarrier(barrier))
return write
}
export type SessionViewOverridesPreference = {
overrides: Map<string, MobileSessionView>
loaded: boolean
}
async function readSessionViewOverridesStorage(
key: string
): Promise<SessionViewOverridesPreference> {
let raw: string | null
try {
raw = await AsyncStorage.getItem(key)
} catch {
return { overrides: new Map(), loaded: false }
}
if (!raw) {
return { overrides: new Map(), loaded: true }
}
let parsed: unknown
try {
parsed = JSON.parse(raw) as unknown
} catch {
// Invalid preference data is safe to replace on the next user mutation.
return { overrides: new Map(), loaded: true }
}
// Legacy format: an array of tab ids that were showing native chat.
if (Array.isArray(parsed)) {
return {
overrides: new Map(
parsed
.filter((id): id is string => typeof id === 'string')
.map((id) => [id, 'chat' as const])
),
loaded: true
}
}
if (parsed && typeof parsed === 'object') {
const entries = Object.entries(parsed as Record<string, unknown>).filter(
(entry): entry is [string, MobileSessionView] =>
entry[1] === 'terminal' || entry[1] === 'chat'
)
return { overrides: new Map(entries), loaded: true }
}
return { overrides: new Map(), loaded: true }
}
/** Per-tab session-view overrides that win over the global default, scoped to the
* paired host and worktree so colliding remote ids cannot activate a transcript
* watcher on another host. */
export async function loadSessionViewOverrides(
hostId: string,
worktreeId: string
): Promise<Map<string, MobileSessionView>> {
return (await readSessionViewOverridesPreference(hostId, worktreeId)).overrides
}
/** Reads overrides without conflating an empty preference with unavailable storage. */
export async function readSessionViewOverridesPreference(
hostId: string,
worktreeId: string
): Promise<SessionViewOverridesPreference> {
const key = sessionViewOverridesKey(hostId, worktreeId)
await overrideUpdateBarriers.get(key)
return readSessionViewOverridesStorage(key)
}
/** Persists one user mutation without replacing sibling overrides from another mount. */
export async function updateSessionViewOverride(
hostId: string,
worktreeId: string,
tabId: string,
view: MobileSessionView
): Promise<void> {
const key = sessionViewOverridesKey(hostId, worktreeId)
const previous = overrideUpdateBarriers.get(key) ?? Promise.resolve()
const update = previous.then(async () => {
const current = await readSessionViewOverridesStorage(key)
// Why: a transient read failure must not replace valid saved siblings with
// a partial map containing only the latest tab.
if (!current.loaded) {
throw new Error('Session view overrides could not be read')
}
current.overrides.set(tabId, view)
await AsyncStorage.setItem(key, JSON.stringify(Object.fromEntries(current.overrides)))
})
const barrier = update.catch(() => undefined)
overrideUpdateBarriers.set(key, barrier)
try {
await update
} finally {
if (overrideUpdateBarriers.get(key) === barrier) {
overrideUpdateBarriers.delete(key)
}
}
}
@@ -57,6 +57,34 @@ describe('Claude interactive-question status transitions', () => {
])
})
it('clears a PermissionRequest-shaped AskUserQuestion wait when later tool work starts', () => {
// Why: newer Claude reports the AskUserQuestion wait as PermissionRequest;
// the question must not inherit real-permission stickiness from that shape.
const server = new AgentHookServer()
ingestClaudeStatus(server, {
state: 'waiting',
hookEventName: 'PermissionRequest',
toolName: 'AskUserQuestion',
toolUseId: 'tool-question'
})
ingestClaudeStatus(server, {
state: 'working',
hookEventName: 'PreToolUse',
toolName: 'Read',
toolUseId: 'tool-after-answer'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE_KEY,
state: 'working',
agentType: 'claude',
toolName: 'Read'
})
])
})
it('keeps an actual permission request sticky during unrelated tool work', () => {
const server = new AgentHookServer()
@@ -83,3 +111,102 @@ describe('Claude interactive-question status transitions', () => {
])
})
})
function answeredRequestFromSnapshot(
server: AgentHookServer
): Parameters<AgentHookServer['inferQuestionAnswered']>[0] {
const [entry] = server.getStatusSnapshot()
return {
paneKey: entry.paneKey,
baselineUpdatedAt: entry.receivedAt,
baselineStateStartedAt: entry.stateStartedAt,
// Why: mirror the renderer, which echoes the entry's prompt verbatim —
// these ingests carry none, and the server must strict-match that.
baselinePrompt: entry.prompt as string,
baselineAgentType: entry.agentType
}
}
describe('inferQuestionAnswered', () => {
it('clears an AskUserQuestion wait when the submit keystroke is reported', () => {
const server = new AgentHookServer()
ingestClaudeStatus(server, {
state: 'waiting',
hookEventName: 'PreToolUse',
toolName: 'AskUserQuestion',
toolUseId: 'tool-question'
})
expect(server.inferQuestionAnswered(answeredRequestFromSnapshot(server))).toBe(true)
// Why: the answered question must also drop the tool identity so the
// question card cannot linger on the working row.
const [entry] = server.getStatusSnapshot()
expect(entry).toMatchObject({ paneKey: PANE_KEY, state: 'working', agentType: 'claude' })
expect(entry.toolName).toBeUndefined()
expect(entry.interactivePrompt).toBeUndefined()
})
it('clears a PermissionRequest-shaped AskUserQuestion wait (newer Claude)', () => {
const server = new AgentHookServer()
ingestClaudeStatus(server, {
state: 'waiting',
hookEventName: 'PermissionRequest',
toolName: 'AskUserQuestion',
toolUseId: 'tool-question'
})
expect(server.inferQuestionAnswered(answeredRequestFromSnapshot(server))).toBe(true)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: PANE_KEY, state: 'working', agentType: 'claude' })
])
})
it('refuses when the cached status changed since the baseline was captured', () => {
const server = new AgentHookServer()
ingestClaudeStatus(server, {
state: 'waiting',
hookEventName: 'PreToolUse',
toolName: 'AskUserQuestion',
toolUseId: 'tool-question'
})
const staleRequest = {
...answeredRequestFromSnapshot(server),
baselineUpdatedAt: 1
}
expect(server.inferQuestionAnswered(staleRequest)).toBe(false)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ state: 'waiting', toolName: 'AskUserQuestion' })
])
})
it('never clears a real permission request', () => {
const server = new AgentHookServer()
ingestClaudeStatus(server, {
state: 'waiting',
hookEventName: 'PermissionRequest',
toolName: 'Bash',
toolUseId: 'tool-needs-permission'
})
expect(server.inferQuestionAnswered(answeredRequestFromSnapshot(server))).toBe(false)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ state: 'waiting', toolName: 'Bash' })
])
})
it('ignores panes that are not waiting on a question', () => {
const server = new AgentHookServer()
ingestClaudeStatus(server, {
state: 'working',
hookEventName: 'PreToolUse',
toolName: 'Read',
toolUseId: 'tool-working'
})
expect(server.inferQuestionAnswered(answeredRequestFromSnapshot(server))).toBe(false)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ state: 'working', toolName: 'Read' })
])
})
})
+74 -1
View File
@@ -22,6 +22,7 @@ import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types'
import {
clearAllListenerCaches,
clearPaneCacheState,
clearClaudeAnsweredQuestionWait,
createHookListenerState,
getEndpointFileName,
hasPendingAgentResultText,
@@ -57,6 +58,10 @@ import {
isAgentInterruptInputIntent,
type AgentInterruptInferenceRequest
} from '../../shared/agent-interrupt-intent'
import {
isAskUserQuestionTool,
type AgentQuestionAnsweredInferenceRequest
} from '../../shared/agent-question-answered-intent'
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id'
import type { LegacyPaneKeyAliasEntry } from '../../shared/types'
import { normalizeAgentProviderSession } from '../../shared/agent-session-resume'
@@ -353,7 +358,12 @@ function shouldKeepClaudePermissionVisible(
return false
}
// Why: only real permission requests stay sticky across concurrent subagent
// activity; interactive questions clear on the next working hook.
// activity; interactive questions clear on the next working hook. Newer
// Claude reports the AskUserQuestion wait AS a PermissionRequest, so the
// tool name — not the event name — decides which rule applies.
if (isAskUserQuestionTool(previous.payload.toolName)) {
return false
}
return true
}
@@ -626,6 +636,69 @@ export class AgentHookServer {
return true
}
/** Guarded fallback for a hook Claude never sends: answering AskUserQuestion
* produces no event, so the amber wait would otherwise linger until the
* agent's next tool or turn end. The renderer reports the submit keystroke;
* this re-validates its baseline against the cached status (a racing real
* hook wins) and synthesizes the post-answer state. */
inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean {
if (!isValidPaneKey(request.paneKey)) {
return false
}
const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as
| EnrichedAgentHookEventPayload
| undefined
if (!existing) {
return false
}
const payload = existing.payload
// Why: only Claude's interactive question may clear on typed input. The
// tool name is the discriminator, not the hook event — Claude versions
// differ on whether the AskUserQuestion wait arrives as PreToolUse or
// PermissionRequest. Real permission waits (other tools) stay sticky until
// the approved tool resumes — a denied or ignored permission must keep
// demanding attention even though approving is also a keystroke.
if (
payload.agentType !== 'claude' ||
payload.state !== 'waiting' ||
!isAskUserQuestionTool(payload.toolName)
) {
return false
}
if (
payload.agentType !== request.baselineAgentType ||
payload.prompt !== request.baselinePrompt ||
existing.receivedAt !== request.baselineUpdatedAt ||
existing.stateStartedAt !== request.baselineStateStartedAt ||
Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS
) {
return false
}
// Why: sync the listener's lead-turn record too — a later child lifecycle
// event would otherwise re-emit the stale waiting state and resurrect the
// dismissed question card.
const restored = clearClaudeAnsweredQuestionWait(this.state, existing.paneKey)
const inferred = this.applyNormalizedStatus({
paneKey: existing.paneKey,
tabId: existing.tabId,
worktreeId: existing.worktreeId,
connectionId: existing.connectionId,
providerSession: existing.providerSession,
payload: {
state: restored.state,
prompt: payload.prompt,
agentType: payload.agentType,
...(restored.state === 'done' && restored.interrupted ? { interrupted: true } : {}),
...(payload.subagents ? { subagents: payload.subagents } : {})
}
})
console.debug('[agent-hooks] inferred answered question status', {
paneKey: inferred.paneKey,
state: inferred.payload.state
})
return true
}
getStatusChangeSnapshot(): AgentHookStatusChangeEntry[] {
return Array.from(this.state.lastStatusByPaneKey.entries(), ([paneKey, entry]) => {
const enriched = entry as EnrichedAgentHookEventPayload
@@ -1,5 +1,6 @@
import { Buffer } from 'node:buffer'
import type { AzureDevOpsRepoRef } from './repository-ref'
import { cancelUnreadResponseBody } from '../lib/unread-response-body'
const REQUEST_TIMEOUT_MS = 5000
@@ -84,6 +85,7 @@ export async function requestAzureDevOpsJsonAtBase<T>(
signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS)
})
if (!response.ok) {
await cancelUnreadResponseBody(response)
return null
}
return (await response.json()) as T
+19
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures'
import {
getAzureDevOpsAuthStatus,
getAzureDevOpsPullRequestForBranch,
@@ -176,4 +177,22 @@ describe('Azure DevOps client', () => {
headSha: 'newsha'
})
})
it('cancels unread error-response bodies so bundled undici cannot crash on socket close', async () => {
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'https://dev.azure.com/acme/Project/_git/repo\n'
})
let cancelledBodies = 0
const fetchMock = vi.fn(async () =>
cancelTrackingResponse(502, () => {
cancelledBodies += 1
})
)
globalThis.fetch = fetchMock as never
await getAzureDevOpsPullRequestForBranch('/repo', 'refs/heads/feature/azure')
expect(fetchMock).toHaveBeenCalled()
expect(cancelledBodies).toBe(fetchMock.mock.calls.length)
})
})
+16
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures'
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
@@ -122,4 +123,19 @@ describe('Bitbucket client', () => {
account: 'bitbucket-user'
})
})
it('cancels unread error-response bodies so bundled undici cannot crash on socket close', async () => {
let cancelledBodies = 0
const fetchMock = vi.fn(async () =>
cancelTrackingResponse(502, () => {
cancelledBodies += 1
})
)
vi.stubGlobal('fetch', fetchMock)
await getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/bitbucket')
expect(fetchMock).toHaveBeenCalled()
expect(cancelledBodies).toBe(fetchMock.mock.calls.length)
})
})
+2
View File
@@ -12,6 +12,7 @@ import {
getHostedReviewLocalGitOptions,
type HostedReviewExecutionOptions
} from '../source-control/hosted-review-git-options'
import { cancelUnreadResponseBody } from '../lib/unread-response-body'
const DEFAULT_API_BASE_URL = 'https://api.bitbucket.org/2.0'
const REQUEST_TIMEOUT_MS = 5000
@@ -97,6 +98,7 @@ async function requestJson<T>(path: string, options: RequestOptions = {}): Promi
signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS)
})
if (!response.ok) {
await cancelUnreadResponseBody(response)
return null
}
return (await response.json()) as T
+16
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures'
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
@@ -301,4 +302,19 @@ describe('Gitea client', () => {
})
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('https://git.example.com/api/v1/user')
})
it('cancels unread error-response bodies so bundled undici cannot crash on socket close', async () => {
let cancelledBodies = 0
const fetchMock = vi.fn(async () =>
cancelTrackingResponse(502, () => {
cancelledBodies += 1
})
)
vi.stubGlobal('fetch', fetchMock)
await getGiteaPullRequestForBranch('/repo', 'refs/heads/feature/gitea')
expect(fetchMock).toHaveBeenCalled()
expect(cancelledBodies).toBe(fetchMock.mock.calls.length)
})
})
+2
View File
@@ -11,6 +11,7 @@ import {
getHostedReviewLocalGitOptions,
type HostedReviewExecutionOptions
} from '../source-control/hosted-review-git-options'
import { cancelUnreadResponseBody } from '../lib/unread-response-body'
const REQUEST_TIMEOUT_MS = 5000
// Why: self-hosted Forgejo can take ~5s to serve one /pulls page (it loads
@@ -89,6 +90,7 @@ async function requestJsonAtBase<T>(
signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS)
})
if (!response.ok) {
await cancelUnreadResponseBody(response)
return null
}
return (await response.json()) as T
@@ -0,0 +1,97 @@
import { readdirSync, readFileSync } from 'node:fs'
import { join, relative, sep } from 'node:path'
import { describe, expect, it } from 'vitest'
// Global fetch (bare or via globalThis/global — unlike Electron's net.fetch)
// goes through Node's bundled undici, which can crash the whole process when
// an unread response body pauses the HTTP/1 parser and the peer closes the
// socket (nodejs/undici#5360, orca#8695). This applies to every Node process
// we ship: Electron main, the CLI, and the SSH relay.
//
// Each entry below maps an audited file to its expected number of matching
// lines. Real call sites must consume or cancel the body on every path,
// including !response.ok (see main/lib/unread-response-body.ts). A count
// change means a call site was added, removed, or moved: re-audit the file
// and update the count.
const AUDITED_GLOBAL_FETCH_LINES = new Map<string, number>([
// HTTP call sites — body consumed or cancelled on every path, including !ok
['main/azure-devops/azure-devops-api-request.ts', 1],
['main/bitbucket/client.ts', 1],
['main/gitea/client.ts', 1],
['main/orca-profiles/profile-cloud-client.ts', 1],
['main/orca-profiles/profile-cloud-org-members-client.ts', 1],
['main/rate-limits/codex-fetcher.ts', 3],
['main/runtime/relay/relay-http-client.ts', 2],
['main/source-control/hosted-review-api-request.ts', 1],
['main/speech/openai-transcription-client.ts', 1],
// fetch appears only inside injected-page script source strings, not as a
// call this process makes
['main/amp/hook-service.ts', 1],
['main/opencode/hook-service.ts', 1],
['main/pi/agent-status-extension-source.ts', 1],
// local identifiers named `fetch` (git fetch), not HTTP
['main/ipc/worktree-remote.ts', 2],
['relay/git-handler.ts', 1],
// fetch mentioned only in a comment
['main/ipc/feedback.ts', 1]
])
// A line is a hit when it calls bare `fetch(` or touches `globalThis.fetch` /
// `global.fetch` in any way (call, alias, fallback like `input.fetch ??
// globalThis.fetch`). `typeof globalThis.fetch` type annotations are exempt.
const GLOBAL_FETCH_LINE = /(^|[^.\w])fetch\(|(?<!typeof )\bglobal(This)?\.fetch\b/
const SCANNED_ROOTS = ['main', 'cli', 'relay']
function globalFetchLineCounts(srcRoot: string): Map<string, number> {
const counts = new Map<string, number>()
for (const root of SCANNED_ROOTS) {
for (const entry of readdirSync(join(srcRoot, root), {
recursive: true,
withFileTypes: true
})) {
if (!entry.isFile() || !entry.name.endsWith('.ts')) {
continue
}
if (
entry.name.endsWith('.test.ts') ||
entry.name.endsWith('.test-fixtures.ts') ||
entry.name.endsWith('.d.ts')
) {
continue
}
const filePath = join(entry.parentPath, entry.name)
const content = readFileSync(filePath, 'utf8')
if (!GLOBAL_FETCH_LINE.test(content)) {
continue
}
const hits = content.split('\n').filter((line) => GLOBAL_FETCH_LINE.test(line)).length
if (hits > 0) {
counts.set(relative(srcRoot, filePath).split(sep).join('/'), hits)
}
}
}
return counts
}
describe('global fetch call-site audit (main, cli, relay)', () => {
it('keeps every global-fetch line audited with its expected count', () => {
const found = globalFetchLineCounts(join(__dirname, '..'))
const drifted = [...found]
.filter(([file, count]) => AUDITED_GLOBAL_FETCH_LINES.get(file) !== count)
.map(([file, count]) => `${file}: found ${count} line(s)`)
.sort()
expect(
drifted,
'Global fetch (bare, globalThis.fetch, or global.fetch) uses undici, ' +
'where an unread response body can crash the whole process (orca#8695). ' +
'New or moved call sites must either use Electron net.fetch or consume/' +
'cancel the response body on ALL paths (cancelUnreadResponseBody in ' +
'main/lib/unread-response-body.ts), then update AUDITED_GLOBAL_FETCH_LINES.'
).toEqual([])
const stale = [...AUDITED_GLOBAL_FETCH_LINES.keys()].filter((file) => !found.has(file)).sort()
expect(stale, 'Remove audited entries whose global-fetch lines are gone.').toEqual([])
})
})
+8
View File
@@ -5,6 +5,7 @@ import type {
MigrationUnsupportedPtyEntry
} from '../../shared/agent-status-types'
import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent'
import type { AgentQuestionAnsweredInferenceRequest } from '../../shared/agent-question-answered-intent'
import { agentHookServer, isValidPaneKey } from '../agent-hooks/server'
import { ampHookService } from '../amp/hook-service'
import {
@@ -67,6 +68,7 @@ export function registerAgentHookHandlers(
ipcMain.removeHandler('agentHooks:kimiStatus')
ipcMain.removeHandler('agentStatus:getSnapshot')
ipcMain.removeHandler('agentStatus:inferInterrupt')
ipcMain.removeHandler('agentStatus:inferQuestionAnswered')
ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot')
// Why: agentStatus:drop is sent fire-and-forget from the renderer via
// ipcRenderer.send(); we listen with ipcMain.on (not handle) so we don't
@@ -121,6 +123,12 @@ export function registerAgentHookHandlers(
}
return agentHookServer.inferInterrupt(request as AgentInterruptInferenceRequest)
})
ipcMain.handle('agentStatus:inferQuestionAnswered', (_event, request: unknown): boolean => {
if (typeof request !== 'object' || request === null) {
return false
}
return agentHookServer.inferQuestionAnswered(request as AgentQuestionAnsweredInferenceRequest)
})
ipcMain.handle(
'agentStatus:getMigrationUnsupportedSnapshot',
(): MigrationUnsupportedPtyEntry[] => getMigrationUnsupportedPtySnapshot()
+35
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { applyCodexSpriteTimingDefaults } from '../../shared/codex-pet-sprite-defaults'
import {
CODEX_PET_ANIMATIONS,
CODEX_PET_FRAME,
@@ -63,6 +64,40 @@ describe('applyCodexPetDefaults', () => {
expect(manifest.animations).toEqual(CODEX_PET_ANIMATIONS)
})
it('bakes uniform per-frame durations when a Codex-layout bundle pins an explicit fps', () => {
// The requested fps is baked as durations so it is honored rather than
// overridden by the timed table, and so the sprite never enters the
// no-durations legacy retiming path.
const manifest = applyCodexPetDefaults({ id: 'zippy', displayName: 'Zippy', fps: 4 })
expect(manifest.fps).toBe(4)
// 4 fps → 250ms per frame; idle keeps its six frames.
expect(manifest.animations?.idle).toEqual({
row: 0,
frames: 6,
frameDurationsMs: [250, 250, 250, 250, 250, 250]
})
})
it('keeps an explicit fps=8 bundle out of the legacy retiming path', () => {
// fps=8 matches the Codex default and the legacy geometry, but baking
// durations makes it a non-match so the uniform pacing is preserved.
const manifest = applyCodexPetDefaults({ id: 'octo', displayName: 'Octo', fps: 8 })
const sprite = {
frameWidth: 192,
frameHeight: 208,
columns: 8,
rows: 9,
sheetWidth: 1536,
sheetHeight: 1872,
fps: 8,
defaultAnimation: 'idle',
animations: manifest.animations
}
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
expect(sprite.animations?.idle.frameDurationsMs).toEqual([125, 125, 125, 125, 125, 125])
})
it('does not override explicit Orca bundle sprite metadata', () => {
const manifest = applyCodexPetDefaults({
spritesheetPath: 'custom.png',
+26 -18
View File
@@ -1,4 +1,22 @@
import type { SpriteAnimation } from '../../shared/types'
import {
CODEX_PET_ANIMATIONS,
CODEX_PET_DEFAULT_ANIMATION,
CODEX_PET_DEFAULT_FPS,
CODEX_PET_FRAME,
CODEX_PET_SPRITESHEET_PATH,
codexAnimationsAtUniformFps
} from '../../shared/codex-pet-sprite-defaults'
// Re-exported so main-side consumers keep their import path. The tables live
// in shared/ so the renderer can reuse the same fingerprint for upgrades.
export {
CODEX_PET_ANIMATIONS,
CODEX_PET_DEFAULT_ANIMATION,
CODEX_PET_DEFAULT_FPS,
CODEX_PET_FRAME,
CODEX_PET_SPRITESHEET_PATH
}
export type PetManifestLike = {
id?: string
@@ -19,23 +37,6 @@ export type ResolvedPetManifest<T extends PetManifestLike = PetManifestLike> = T
spritesheetPath: string
}
export const CODEX_PET_SPRITESHEET_PATH = 'spritesheet.webp'
export const CODEX_PET_FRAME = { width: 192, height: 208 } as const
export const CODEX_PET_DEFAULT_ANIMATION = 'idle'
export const CODEX_PET_DEFAULT_FPS = 8
export const CODEX_PET_ANIMATIONS: Record<string, SpriteAnimation> = {
idle: { row: 0, frames: 6 },
'running-right': { row: 1, frames: 8 },
'running-left': { row: 2, frames: 8 },
waving: { row: 3, frames: 4 },
jumping: { row: 4, frames: 5 },
failed: { row: 5, frames: 8 },
waiting: { row: 6, frames: 6 },
running: { row: 7, frames: 6 },
review: { row: 8, frames: 6 }
}
function isCodexPetSpritePath(spritesheetPath: string | undefined): boolean {
return spritesheetPath === undefined || /(^|[/\\])spritesheet\.webp$/i.test(spritesheetPath)
}
@@ -61,7 +62,14 @@ export function applyCodexPetDefaults<T extends PetManifestLike>(
frame: manifest.frame ?? CODEX_PET_FRAME,
fps: manifest.fps ?? CODEX_PET_DEFAULT_FPS,
defaultAnimation: manifest.defaultAnimation ?? CODEX_PET_DEFAULT_ANIMATION,
animations: manifest.animations ?? CODEX_PET_ANIMATIONS
// Why: with no fps, bake Codex's intended uneven pacing; with an explicit
// fps, bake that as uniform durations so it is honored instead of being
// overridden by the timed table (and stays out of the legacy retiming path).
animations:
manifest.animations ??
(manifest.fps === undefined
? CODEX_PET_ANIMATIONS
: codexAnimationsAtUniformFps(manifest.fps))
}
}
+63
View File
@@ -109,4 +109,67 @@ describe('registerPetHandlers', () => {
readFile(join(userDataDir, 'sidekicks', 'custom', result.id, 'spritesheet.png'))
).resolves.toEqual(sheetBytes)
})
function webpVp8x(width: number, height: number): Buffer {
const u24 = (value: number): Buffer =>
Buffer.from([value & 0xff, (value >> 8) & 0xff, (value >> 16) & 0xff])
const payload = Buffer.concat([Buffer.from([0, 0, 0, 0]), u24(width - 1), u24(height - 1)])
const size = Buffer.alloc(4)
size.writeUInt32LE(payload.byteLength, 0)
const riffSize = Buffer.alloc(4)
riffSize.writeUInt32LE(4 + 8 + payload.byteLength, 0)
return Buffer.concat([
Buffer.from('RIFF'),
riffSize,
Buffer.from('WEBP'),
Buffer.from('VP8X'),
size,
payload
])
}
async function writeSpriteBundle(
animations: Record<string, { row: number; frames: number; frameDurationsMs?: number[] }>
): Promise<string> {
const bundleDir = join(tempDir, 'durations.codex-pet')
await mkdir(bundleDir, { recursive: true })
await writeFile(
join(bundleDir, 'pet.json'),
JSON.stringify({
id: 'durations',
displayName: 'Durations',
spritesheetPath: 'sheet.webp',
frame: { width: 2, height: 2 },
animations
})
)
await writeFile(join(bundleDir, 'sheet.webp'), webpVp8x(4, 2))
return bundleDir
}
it('imports a bundle whose animations declare per-frame durations', async () => {
const bundleDir = await writeSpriteBundle({
idle: { row: 0, frames: 2, frameDurationsMs: [1680, 1920] }
})
showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [bundleDir] })
const result = (await getHandler('pet:importPetBundle')({ sender: {} })) as CustomPet
expect(result.sprite?.animations?.idle).toEqual({
row: 0,
frames: 2,
frameDurationsMs: [1680, 1920]
})
})
it('rejects a bundle whose frame durations do not match the frame count', async () => {
const bundleDir = await writeSpriteBundle({
idle: { row: 0, frames: 2, frameDurationsMs: [1680] }
})
showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [bundleDir] })
await expect(getHandler('pet:importPetBundle')({ sender: {} })).rejects.toThrow(
'declares 1 frame durations but 2 frames'
)
})
})
+8 -1
View File
@@ -112,7 +112,9 @@ const PetManifestSchema = z
z.string().min(1).max(64),
z.object({
row: z.number().int().min(0).max(256),
frames: z.number().int().positive().max(512)
frames: z.number().int().positive().max(512),
// Why: cap each hold at 60s so a bad manifest can't freeze the overlay.
frameDurationsMs: z.array(z.number().positive().max(60_000)).max(512).optional()
})
)
.optional()
@@ -394,6 +396,11 @@ export function registerPetHandlers(): void {
`Animation "${name}" has ${anim.frames} frames but sheet only has ${columns} columns.`
)
}
if (anim.frameDurationsMs && anim.frameDurationsMs.length !== anim.frames) {
throw new Error(
`Animation "${name}" declares ${anim.frameDurationsMs.length} frame durations but ${anim.frames} frames.`
)
}
}
if (manifest.defaultAnimation && !manifest.animations[manifest.defaultAnimation]) {
throw new Error(`defaultAnimation "${manifest.defaultAnimation}" not in animations.`)
@@ -0,0 +1,13 @@
/** Response whose body reports cancellation, for asserting that error paths
* cancel unread bodies (see unread-response-body.ts). */
export function cancelTrackingResponse(status: number, onCancel: () => void): Response {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('<html>unread error body</html>'))
},
cancel() {
onCancel()
}
})
return new Response(body, { status })
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { cancelUnreadResponseBody } from './unread-response-body'
import { cancelTrackingResponse } from './unread-response-body.test-fixtures'
describe('cancelUnreadResponseBody', () => {
it('cancels an unread body stream', async () => {
let cancelled = false
await cancelUnreadResponseBody(
cancelTrackingResponse(500, () => {
cancelled = true
})
)
expect(cancelled).toBe(true)
})
it('no-ops on a body-less response', async () => {
await expect(
cancelUnreadResponseBody(new Response(null, { status: 500 }))
).resolves.toBeUndefined()
})
it('swallows cancellation failures on a locked stream', async () => {
const response = cancelTrackingResponse(500, () => {})
response.body?.getReader()
await expect(cancelUnreadResponseBody(response)).resolves.toBeUndefined()
})
})
+12
View File
@@ -0,0 +1,12 @@
/**
* Cancel a fetch Response body that no code path will read. Why: leaving it
* unread can crash the whole process from inside Node's bundled undici
* (nodejs/undici#5360, orca#8695); see global-fetch-call-site-audit.test.ts.
*/
export async function cancelUnreadResponseBody(response: Response): Promise<void> {
try {
await response.body?.cancel()
} catch {
// Cancelling an already-errored, locked, or closed stream is harmless.
}
}
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures'
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
import type { OrcaCloudSession } from './profile-cloud-session-store'
import {
@@ -248,4 +249,16 @@ describe('Orca cloud client', () => {
}
})
})
it('cancels the unread error-response body so bundled undici cannot crash on socket close', async () => {
let cancelledBodies = 0
fetchMock.mockResolvedValue(
cancelTrackingResponse(502, () => {
cancelledBodies += 1
})
)
await expect(refreshOrcaCloudCapabilities(config, session)).rejects.toThrow()
expect(cancelledBodies).toBe(1)
})
})
@@ -6,6 +6,7 @@ import type {
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
import type { OrcaCloudSession } from './profile-cloud-session-store'
import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange'
import { cancelUnreadResponseBody } from '../lib/unread-response-body'
type ExchangeCodeArgs = {
code: string
@@ -173,6 +174,7 @@ async function postJson<T>(url: string, body: unknown, accessToken?: string): Pr
signal: AbortSignal.timeout(CLOUD_REQUEST_TIMEOUT_MS)
})
if (!response.ok) {
await cancelUnreadResponseBody(response)
throw new OrcaCloudRequestError(response.status)
}
return (await response.json()) as T
@@ -1,4 +1,5 @@
import { join } from 'node:path'
import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { childSpawnMock, readFileMock, ptySpawnMock } = vi.hoisted(() => ({
@@ -193,4 +194,26 @@ describe('Codex backend rate-limit requests', () => {
})
)
})
it('cancels the unread error-response body so bundled undici cannot crash on socket close', async () => {
readFileMock.mockResolvedValue(
JSON.stringify({
tokens: { access_token: 'access-token', account_id: 'account-id' }
})
)
let cancelledBodies = 0
vi.mocked(fetch).mockResolvedValue(
cancelTrackingResponse(429, () => {
cancelledBodies += 1
})
)
await expect(
consumeCodexRateLimitResetCredit({
codexHomePath: '/managed/codex-home',
idempotencyKey: 'redeem-429'
})
).rejects.toThrow('Codex reset failed: HTTP 429')
expect(cancelledBodies).toBe(1)
})
})
+4
View File
@@ -9,6 +9,7 @@ import type {
import { spawn } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { cancelUnreadResponseBody } from '../lib/unread-response-body'
import { join } from 'node:path'
import { probeCodexAuthPresence } from './codex-auth-presence'
import { resolveCodexCommand } from '../codex-cli/command'
@@ -381,6 +382,7 @@ async function fetchBackendRateLimitResetCredits(
signal
})
if (!response.ok) {
await cancelUnreadResponseBody(response)
return null
}
const payload = (await response.json()) as BackendRateLimitResetCreditsResponse
@@ -448,6 +450,7 @@ export async function consumeCodexRateLimitResetCredit(options: {
}
)
if (!response.ok) {
await cancelUnreadResponseBody(response)
throw new Error(`Codex reset failed: HTTP ${response.status}`)
}
const payload = (await response.json()) as BackendConsumeRateLimitResetCreditResponse
@@ -531,6 +534,7 @@ async function fetchViaBackend(
signal
})
if (!response.ok) {
await cancelUnreadResponseBody(response)
return null
}
const payload = (await response.json()) as BackendUsageResponse
+27 -1
View File
@@ -100,6 +100,31 @@ describe('fetchGrokRateLimits', () => {
)
})
it('maps an omitted protobuf percentage as zero for a weekly credits period', async () => {
authState.file = freshAuthJson()
netFetchMock.mockResolvedValueOnce(
jsonResponse({
config: {
currentPeriod: {
type: 'USAGE_PERIOD_TYPE_WEEKLY',
start: '2026-07-17T19:38:56.948570+00:00',
end: '2026-07-24T19:38:56.948570+00:00'
},
billingPeriodStart: '2026-07-17T19:38:56.948570+00:00',
billingPeriodEnd: '2026-07-24T19:38:56.948570+00:00',
isUnifiedBillingUser: true
}
})
)
const result = await fetchGrokRateLimits()
expect(result.status).toBe('ok')
expect(result.weekly?.usedPercent).toBe(0)
expect(result.weekly?.resetsAt).toBe(Date.parse('2026-07-24T19:38:56.948570+00:00'))
expect(result.monthly).toBeUndefined()
expect(netFetchMock).toHaveBeenCalledTimes(1)
})
it('returns unavailable when not signed in even if a token-less auth file exists', async () => {
authState.file = JSON.stringify({})
const result = await fetchGrokRateLimits()
@@ -119,7 +144,7 @@ describe('fetchGrokRateLimits', () => {
expect(result.monthly).toBeUndefined()
})
it('maps monthly included usage for unified-billing accounts without weekly credits', async () => {
it('maps monthly included usage when a unified-billing response has an ambiguous weekly period', async () => {
authState.file = freshAuthJson()
netFetchMock
.mockResolvedValueOnce(
@@ -162,6 +187,7 @@ describe('fetchGrokRateLimits', () => {
headers: expect.objectContaining({ Authorization: 'Bearer access-token' })
})
)
expect(netFetchMock).toHaveBeenCalledTimes(2)
})
// Why: 'unavailable' would make applyStalePolicy discard the last good
+25 -6
View File
@@ -16,8 +16,8 @@ const GROK_CLI_PROXY_BASE =
process.env.GROK_CLI_CHAT_PROXY_BASE_URL?.trim().replace(/\/$/, '') ||
'https://cli-chat-proxy.grok.com/v1'
const BILLING_CREDITS_URL = `${GROK_CLI_PROXY_BASE}/billing?format=credits`
// Why: unified-billing accounts have no weekly credits; their included monthly
// budget is only present in the default (format-less) billing view.
// Why: some unified-billing accounts expose only a monthly included budget,
// which is present in the default (format-less) billing view.
const BILLING_DEFAULT_URL = `${GROK_CLI_PROXY_BASE}/billing`
const API_TIMEOUT_MS = 10_000
const WEEKLY_WINDOW_MINUTES = 10_080
@@ -81,8 +81,28 @@ function parseResetDescription(isoString: string | undefined): string | null {
: date.toLocaleDateString(undefined, { weekday: 'short', hour: 'numeric', minute: '2-digit' })
}
function timestampsMatch(left: string | undefined, right: string | undefined): boolean {
const leftTimestamp = left ? Date.parse(left) : Number.NaN
const rightTimestamp = right ? Date.parse(right) : Number.NaN
return Number.isFinite(leftTimestamp) && leftTimestamp === rightTimestamp
}
function hasConfirmedWeeklyPeriod(config: GrokBillingConfig): boolean {
const period = config.currentPeriod
// Why: monthly unified-billing responses can also carry a weekly currentPeriod;
// matching billing bounds identify Grok's omitted protobuf zero unambiguously.
return (
period?.type === 'USAGE_PERIOD_TYPE_WEEKLY' &&
timestampsMatch(period.start, config.billingPeriodStart) &&
timestampsMatch(period.end, config.billingPeriodEnd)
)
}
function mapWeeklyCredits(config: GrokBillingConfig): RateLimitWindow | null {
const usedPercent = config.creditUsagePercent
const usedPercent =
config.creditUsagePercent === undefined && hasConfirmedWeeklyPeriod(config)
? 0
: config.creditUsagePercent
if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) {
return null
}
@@ -257,9 +277,8 @@ export async function fetchGrokRateLimits(
if (weekly) {
return billingUsageResult({ weekly }, config, session)
}
// Why: unified-billing accounts report a monthly included-usage budget
// instead of weekly credits; the credits view omits creditUsagePercent
// for them, so read the default billing view before giving up.
// Why: some unified-billing accounts expose only a monthly included budget;
// their credits view omits creditUsagePercent, so read the default view.
const fallback = await fetchMonthlyUsageFallback(session, options.signal)
if (fallback.kind === 'result') {
return fallback.result
@@ -0,0 +1,19 @@
import { afterEach, describe, expect, it } from 'vitest'
import { OrchestrationDb } from './db'
describe('orchestration message timestamps', () => {
let db: OrchestrationDb | undefined
afterEach(() => db?.close())
it('exposes SQLite timestamps with an explicit UTC designator', () => {
db = new OrchestrationDb(':memory:')
const message = db.insertMessage({ from: 'a', to: 'b', subject: 'timestamped' })
expect(message.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/)
db.markAsDelivered([message.id])
expect(db.getMessageById(message.id)?.delivered_at).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/
)
})
})
+92 -42
View File
@@ -62,6 +62,29 @@ function addLifecycleRejectionMarker(payload: string | null, reason: string): st
})
}
const SQLITE_UTC_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/
function exposeUtcTimestamp(timestamp: string | null): string | null {
if (!timestamp || !SQLITE_UTC_TIMESTAMP_RE.test(timestamp)) {
return timestamp
}
return `${timestamp.replace(' ', 'T')}Z`
}
function exposeMessageTimestamps(message: MessageRow): MessageRow {
// Why: SQLite stores UTC as a timezone-less space format for internal SQL
// ordering, but RPC/CLI consumers need an explicit offset to interpret it.
return {
...message,
created_at: exposeUtcTimestamp(message.created_at) ?? message.created_at,
delivered_at: exposeUtcTimestamp(message.delivered_at)
}
}
function exposeMessageListTimestamps(messages: MessageRow[]): MessageRow[] {
return messages.map(exposeMessageTimestamps)
}
// Why: v1 → v2 added `'heartbeat'` to messages.type CHECK + `last_heartbeat_at`
// column (preamble-hardening PR). v2 → v3 adds `delivered_at` column so
// push-on-idle can distinguish queued-but-undelivered from user-acknowledged
@@ -353,21 +376,27 @@ export class OrchestrationDb {
msg.payload ?? null,
msg.senderPaneKey ?? null
)
return this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as MessageRow
return exposeMessageTimestamps(
this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as MessageRow
)
}
getUnreadMessages(toHandle: string, types?: MessageType[]): MessageRow[] {
if (types && types.length > 0) {
const placeholders = types.map(() => '?').join(',')
return this.db
.prepare(
`SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND type IN (${placeholders}) ORDER BY sequence`
)
.all(toHandle, ...types) as MessageRow[]
return exposeMessageListTimestamps(
this.db
.prepare(
`SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND type IN (${placeholders}) ORDER BY sequence`
)
.all(toHandle, ...types) as MessageRow[]
)
}
return this.db
.prepare('SELECT * FROM messages WHERE to_handle = ? AND read = 0 ORDER BY sequence')
.all(toHandle) as MessageRow[]
return exposeMessageListTimestamps(
this.db
.prepare('SELECT * FROM messages WHERE to_handle = ? AND read = 0 ORDER BY sequence')
.all(toHandle) as MessageRow[]
)
}
convertLifecycleMessageToRejection(messageId: string, reason: string): MessageRow | undefined {
@@ -400,27 +429,36 @@ export class OrchestrationDb {
getUndeliveredUnreadMessages(toHandle: string, types?: MessageType[]): MessageRow[] {
if (types && types.length > 0) {
const placeholders = types.map(() => '?').join(',')
return this.db
.prepare(
`SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND delivered_at IS NULL AND type IN (${placeholders}) ORDER BY sequence`
)
.all(toHandle, ...types) as MessageRow[]
}
return this.db
.prepare(
'SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND delivered_at IS NULL ORDER BY sequence'
return exposeMessageListTimestamps(
this.db
.prepare(
`SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND delivered_at IS NULL AND type IN (${placeholders}) ORDER BY sequence`
)
.all(toHandle, ...types) as MessageRow[]
)
.all(toHandle) as MessageRow[]
}
return exposeMessageListTimestamps(
this.db
.prepare(
'SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND delivered_at IS NULL ORDER BY sequence'
)
.all(toHandle) as MessageRow[]
)
}
getAllMessages(toHandle: string, limit = 20): MessageRow[] {
return this.db
.prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?')
.all(toHandle, limit) as MessageRow[]
return exposeMessageListTimestamps(
this.db
.prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?')
.all(toHandle, limit) as MessageRow[]
)
}
getMessageById(id: string): MessageRow | undefined {
return this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as MessageRow | undefined
const message = this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as
| MessageRow
| undefined
return message ? exposeMessageTimestamps(message) : undefined
}
markAsRead(ids: string[]): void {
@@ -461,9 +499,11 @@ export class OrchestrationDb {
}
getInbox(limit = 20): MessageRow[] {
return this.db
.prepare('SELECT * FROM messages ORDER BY sequence DESC LIMIT ?')
.all(limit) as MessageRow[]
return exposeMessageListTimestamps(
this.db
.prepare('SELECT * FROM messages ORDER BY sequence DESC LIMIT ?')
.all(limit) as MessageRow[]
)
}
// Why: used by `check --all` and `inbox --terminal <handle>` — returns every
@@ -473,15 +513,19 @@ export class OrchestrationDb {
getAllMessagesForHandle(toHandle: string, limit = 100, types?: MessageType[]): MessageRow[] {
if (types && types.length > 0) {
const placeholders = types.map(() => '?').join(',')
return this.db
.prepare(
`SELECT * FROM messages WHERE to_handle = ? AND type IN (${placeholders}) ORDER BY sequence DESC LIMIT ?`
)
.all(toHandle, ...types, limit) as MessageRow[]
return exposeMessageListTimestamps(
this.db
.prepare(
`SELECT * FROM messages WHERE to_handle = ? AND type IN (${placeholders}) ORDER BY sequence DESC LIMIT ?`
)
.all(toHandle, ...types, limit) as MessageRow[]
)
}
return this.db
.prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?')
.all(toHandle, limit) as MessageRow[]
return exposeMessageListTimestamps(
this.db
.prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?')
.all(toHandle, limit) as MessageRow[]
)
}
// Why: thread-scoped read for the `orchestration.ask` wait loop. Filtered
@@ -492,15 +536,21 @@ export class OrchestrationDb {
// the existing idx_thread index (see createTables) — no new index.
getThreadMessagesFor(threadId: string, toHandle: string, afterSequence?: number): MessageRow[] {
if (afterSequence !== undefined) {
return this.db
.prepare(
'SELECT * FROM messages WHERE thread_id = ? AND to_handle = ? AND sequence > ? ORDER BY sequence ASC'
)
.all(threadId, toHandle, afterSequence) as MessageRow[]
return exposeMessageListTimestamps(
this.db
.prepare(
'SELECT * FROM messages WHERE thread_id = ? AND to_handle = ? AND sequence > ? ORDER BY sequence ASC'
)
.all(threadId, toHandle, afterSequence) as MessageRow[]
)
}
return this.db
.prepare('SELECT * FROM messages WHERE thread_id = ? AND to_handle = ? ORDER BY sequence ASC')
.all(threadId, toHandle) as MessageRow[]
return exposeMessageListTimestamps(
this.db
.prepare(
'SELECT * FROM messages WHERE thread_id = ? AND to_handle = ? ORDER BY sequence ASC'
)
.all(threadId, toHandle) as MessageRow[]
)
}
// ── Tasks ──
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import nacl from 'tweetnacl'
import { cancelTrackingResponse } from '../../lib/unread-response-body.test-fixtures'
import { exchangeRelayAuthorization, requestRelayAssignment } from './relay-http-client'
describe('relay HTTP client', () => {
@@ -72,4 +73,35 @@ describe('relay HTTP client', () => {
})
).rejects.toThrow('relay_assignment_failed_502')
})
it('cancels unread error-response bodies so bundled undici cannot crash on socket close', async () => {
const keypair = nacl.box.keyPair()
let cancelledBodies = 0
const fetch = vi.fn<typeof globalThis.fetch>(async () =>
cancelTrackingResponse(503, () => {
cancelledBodies += 1
})
)
await expect(
exchangeRelayAuthorization({
endpoint: 'https://auth.example/v1/desktop/auth/relay-token',
accessToken: 'ordinary-access-token',
keypair: {
...keypair,
publicKeyB64: Buffer.from(keypair.publicKey).toString('base64')
},
fetch
})
).rejects.toThrow()
await expect(
requestRelayAssignment({
directorUrl: 'https://relay.example',
relayToken: 'scoped-token',
relayHostId: 'AbCdEf0123_-xyZ9',
fetch
})
).rejects.toThrow()
expect(cancelledBodies).toBe(2)
})
})
@@ -1,6 +1,7 @@
import { createHash } from 'node:crypto'
import { z } from 'zod'
import type { E2EEKeypair } from '../e2ee-keypair'
import { cancelUnreadResponseBody } from '../../lib/unread-response-body'
const RelayTokenResponseSchema = z
.object({
@@ -69,6 +70,7 @@ export async function exchangeRelayAuthorization(input: {
body: JSON.stringify({ relayHostId, hostPublicKeyB64: input.keypair.publicKeyB64 })
})
if (!response.ok) {
await cancelUnreadResponseBody(response)
throw new RelayHttpError('token-exchange', response.status)
}
const parsed = RelayTokenResponseSchema.safeParse(await response.json())
@@ -96,6 +98,7 @@ export async function requestRelayAssignment(input: {
body: JSON.stringify({ v: 1, relayHostId: input.relayHostId })
})
if (!response.ok) {
await cancelUnreadResponseBody(response)
throw new RelayHttpError('assignment', response.status)
}
const parsed = AssignmentResponseSchema.safeParse(await response.json())
+10 -31
View File
@@ -37,6 +37,7 @@ import {
MOBILE_SNAPSHOT_BYTE_BUDGET,
MOBILE_SUBSCRIBE_SCROLLBACK_ROWS
} from '../../scrollback-limits'
import { assertTerminalAgentSendable } from '../terminal-agent-send-guard'
const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024
const TERMINAL_STREAM_CHUNK_BYTES = 48 * 1024
@@ -406,16 +407,6 @@ function isTerminalSendGuardNotWritable(error: unknown): boolean {
return message.includes('terminal_guard_not_writable')
}
function isTerminalAgentStatusNotWritable(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return [
'terminal_not_writable',
'terminal_handle_stale',
'terminal_gone',
'terminal_exited'
].some((code) => message.includes(code))
}
function assertTerminalSendExactPtyBinding(
runtime: OrcaRuntimeService,
handle: string,
@@ -1270,28 +1261,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const assertSendPreconditions =
params.requireAgentStatus === 'sendable'
? async (ptyId?: string): Promise<void> => {
assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId)
if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) {
throw new Error('terminal_guard_not_writable')
}
let agentStatus
try {
agentStatus = await runtime.getTerminalAgentStatus(params.terminal)
} catch (error) {
if (isTerminalAgentStatusNotWritable(error)) {
throw new Error('terminal_guard_not_writable')
await assertTerminalAgentSendable({
runtime,
handle: params.terminal,
assertWritable: () => {
assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId)
if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) {
throw new Error('terminal_guard_not_writable')
}
}
throw error
}
// Why: a send callback can race a pane reconnect; status evidence
// must never authorize the callback's replacement PTY.
assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId)
if (!agentStatus.isRunningAgent) {
throw new Error('terminal_guard_no_agent')
}
if (agentStatus.status === 'permission') {
throw new Error('terminal_guard_permission')
}
})
}
: undefined
if (params.requireAgentStatus === 'sendable') {
@@ -0,0 +1,127 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../orca-runtime'
import type { RpcRequest } from './core'
import { RpcDispatcher } from './dispatcher'
import { TERMINAL_METHODS } from './methods/terminal'
function stubRuntime(overrides: Partial<OrcaRuntimeService>): OrcaRuntimeService {
return {
getRuntimeId: () => 'test-runtime',
...overrides
} as OrcaRuntimeService
}
function guardedSendRequest(): RpcRequest {
return {
id: 'req-1',
authToken: 'tok',
method: 'terminal.send',
params: {
terminal: 'terminal-1',
enter: true,
requireAgentStatus: 'sendable',
client: { id: 'desktop-1', type: 'desktop' }
}
}
}
describe('terminal agent send guard', () => {
afterEach(() => {
vi.useRealTimers()
})
it('rechecks a transient no-agent result before refusing a guarded send', async () => {
vi.useFakeTimers()
const getTerminalAgentStatus = vi
.fn()
.mockResolvedValueOnce({ handle: 'terminal-1', isRunningAgent: false, status: null })
.mockResolvedValue({ handle: 'terminal-1', isRunningAgent: true, status: 'working' })
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
getTerminalAgentStatus,
sendTerminal: vi.fn().mockResolvedValue({
handle: 'terminal-1',
accepted: true,
bytesWritten: 1
})
})
const responsePromise = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }).dispatch(
guardedSendRequest()
)
await vi.advanceTimersByTimeAsync(150)
await expect(responsePromise).resolves.toMatchObject({
ok: true,
result: { send: { accepted: true, bytesWritten: 1 } }
})
expect(getTerminalAgentStatus).toHaveBeenCalledTimes(2)
})
it('rechecks a transient no-agent result immediately before the PTY write', async () => {
vi.useFakeTimers()
const getTerminalAgentStatus = vi
.fn()
.mockResolvedValueOnce({ handle: 'terminal-1', isRunningAgent: true, status: 'working' })
.mockResolvedValueOnce({ handle: 'terminal-1', isRunningAgent: false, status: null })
.mockResolvedValue({ handle: 'terminal-1', isRunningAgent: true, status: 'working' })
const write = vi.fn()
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
getTerminalAgentStatus,
sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => {
await options.beforeWrite('pty-1')
write()
return { handle: 'terminal-1', accepted: true, bytesWritten: 1 }
})
})
const responsePromise = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }).dispatch(
guardedSendRequest()
)
await vi.advanceTimersByTimeAsync(150)
await expect(responsePromise).resolves.toMatchObject({
ok: true,
result: { send: { accepted: true, bytesWritten: 1 } }
})
expect(getTerminalAgentStatus).toHaveBeenCalledTimes(3)
expect(write).toHaveBeenCalledOnce()
})
it('still refuses after the bounded recheck window without positive evidence', async () => {
vi.useFakeTimers()
const getTerminalAgentStatus = vi.fn().mockResolvedValue({
handle: 'terminal-1',
isRunningAgent: false,
status: null
})
const sendTerminal = vi.fn()
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
getTerminalAgentStatus,
sendTerminal
})
const responsePromise = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }).dispatch(
guardedSendRequest()
)
await vi.advanceTimersByTimeAsync(1_050)
await expect(responsePromise).resolves.toMatchObject({
ok: true,
result: {
send: {
accepted: false,
bytesWritten: 0,
refusedReason: 'no-agent'
}
}
})
expect(getTerminalAgentStatus).toHaveBeenCalledTimes(8)
expect(sendTerminal).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,54 @@
import type { OrcaRuntimeService } from '../orca-runtime'
const AGENT_STATUS_RECHECK_INTERVAL_MS = 150
const AGENT_STATUS_RECHECK_TIMEOUT_MS = 1_050
type AssertTerminalAgentSendableOptions = {
runtime: OrcaRuntimeService
handle: string
assertWritable: () => void
}
export async function assertTerminalAgentSendable(
options: AssertTerminalAgentSendableOptions
): Promise<void> {
const deadline = Date.now() + AGENT_STATUS_RECHECK_TIMEOUT_MS
while (true) {
options.assertWritable()
let agentStatus
try {
agentStatus = await options.runtime.getTerminalAgentStatus(options.handle)
} catch (error) {
if (isTerminalAgentStatusNotWritable(error)) {
throw new Error('terminal_guard_not_writable')
}
throw error
}
options.assertWritable()
if (agentStatus.isRunningAgent) {
if (agentStatus.status === 'permission') {
throw new Error('terminal_guard_permission')
}
return
}
const remainingMs = deadline - Date.now()
if (remainingMs <= 0) {
throw new Error('terminal_guard_no_agent')
}
// Why: title and foreground caches refresh asynchronously; require fresh
// positive evidence within a wall-clock bound so slow SSH reads cannot multiply it.
await new Promise((resolve) =>
setTimeout(resolve, Math.min(AGENT_STATUS_RECHECK_INTERVAL_MS, remainingMs))
)
}
}
function isTerminalAgentStatusNotWritable(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return [
'terminal_not_writable',
'terminal_handle_stale',
'terminal_gone',
'terminal_exited'
].some((code) => message.includes(code))
}
+137 -14
View File
@@ -23,6 +23,7 @@ type MockSshClient = {
lastExecCommand?: string
lastConnectConfig?: unknown
exec: (cmd: string, cb: (err: Error | undefined, channel: unknown) => void) => void
sftp: (cb: (err: Error | undefined, channel: unknown) => void) => void
}
let clientInstances: MockSshClient[] = []
@@ -690,15 +691,15 @@ describe('SshConnection', () => {
vi.useFakeTimers()
try {
const outcomePromise = conn
.exec('printf ready')
.then(() => 'opened')
.catch((error: Error) => error.message)
const outcomePromise = conn.exec('printf ready').catch((error: Error) => error)
await vi.advanceTimersByTimeAsync(30_000)
const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')])
expect(outcome).toBe('SSH exec channel timed out')
expect(outcome).toMatchObject({
message: 'SSH exec channel timed out',
sshChannelCloseConfirmed: false
})
} finally {
vi.useRealTimers()
}
@@ -784,15 +785,54 @@ describe('SshConnection', () => {
try {
const outcomePromise = conn
.exec('printf ready', { signal: controller.signal })
.then(() => 'opened')
.catch((error: Error) => error.name)
.catch((error: Error) => error)
controller.abort()
// Why: a hung socket must not pin the aborted caller for the full 30s
// connect timeout — the abort settles at the 5s grace bound instead.
await vi.advanceTimersByTimeAsync(5_000)
await expect(outcomePromise).resolves.toBe('AbortError')
await expect(outcomePromise).resolves.toMatchObject({
name: 'AbortError',
sshChannelCloseConfirmed: false
})
} finally {
vi.useRealTimers()
}
})
it('drains an exec channel that opens after the abort grace has settled', async () => {
const conn = new SshConnection(createTarget(), createCallbacks())
await conn.connect()
execBehavior = 'pending'
const controller = new AbortController()
const lateChannel = Object.assign(new EventEmitter(), {
close: vi.fn(),
resume: vi.fn(),
stderr: { resume: vi.fn() }
})
vi.useFakeTimers()
try {
const outcomePromise = conn
.exec('printf ready', { signal: controller.signal })
.catch((error: Error) => error)
controller.abort()
await vi.advanceTimersByTimeAsync(5_000)
const outcome = await outcomePromise
expect(outcome).toMatchObject({
name: 'AbortError',
sshChannelCloseConfirmed: false
})
pendingExecCallback?.(undefined, lateChannel)
expect(lateChannel.resume).toHaveBeenCalledTimes(1)
expect(lateChannel.stderr.resume).toHaveBeenCalledTimes(1)
expect(lateChannel.close).toHaveBeenCalledTimes(1)
lateChannel.emit('close')
expect(outcome).toMatchObject({ sshChannelCloseConfirmed: true })
} finally {
vi.useRealTimers()
}
@@ -837,7 +877,7 @@ describe('SshConnection', () => {
const lateChannel = Object.assign(new EventEmitter(), {
close: vi.fn(),
resume: vi.fn(),
stderr: { resume: vi.fn() }
stderr: Object.assign(new EventEmitter(), { resume: vi.fn() })
})
const outcomePromise = conn
@@ -855,11 +895,41 @@ describe('SshConnection', () => {
expect(early).toBe('pending')
expect(lateChannel.close).toHaveBeenCalledTimes(1)
expect(lateChannel.resume).toHaveBeenCalled()
expect(() => lateChannel.emit('error', new Error('late channel teardown'))).not.toThrow()
expect(() => lateChannel.stderr.emit('error', new Error('late stderr teardown'))).not.toThrow()
lateChannel.emit('close')
await expect(outcomePromise).resolves.toBe('AbortError')
})
it('removes the late-channel close listener when abort grace expires', async () => {
const conn = new SshConnection(createTarget(), createCallbacks())
await conn.connect()
vi.useFakeTimers()
try {
sftpBehavior = 'pending'
const controller = new AbortController()
const lateSftp = Object.assign(new EventEmitter(), { end: vi.fn() })
const outcomePromise = conn
.sftp(controller.signal)
.then(() => 'opened')
.catch((error: Error) => error.name)
await vi.advanceTimersByTimeAsync(0)
controller.abort()
pendingSftpCallback?.(undefined, lateSftp)
expect(lateSftp.listenerCount('close')).toBe(1)
expect(() => lateSftp.emit('error', new Error('late SFTP teardown'))).not.toThrow()
await vi.advanceTimersByTimeAsync(5_000)
await expect(outcomePromise).resolves.toBe('AbortError')
expect(lateSftp.listenerCount('close')).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('times out when ssh2 never opens an SFTP channel', async () => {
const conn = new SshConnection(createTarget(), createCallbacks())
await conn.connect()
@@ -867,15 +937,13 @@ describe('SshConnection', () => {
vi.useFakeTimers()
try {
const outcomePromise = conn
.sftp()
.then(() => 'opened')
.catch((error: Error) => error.message)
const outcomePromise = conn.sftp().catch((error: Error) => error)
await vi.advanceTimersByTimeAsync(30_000)
const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')])
expect(outcome).toBe('SSH SFTP channel timed out')
expect(outcome).toMatchObject({ message: 'SSH SFTP channel timed out' })
expect(outcome).not.toHaveProperty('sshChannelCloseConfirmed')
} finally {
vi.useRealTimers()
}
@@ -1368,6 +1436,61 @@ describe('SshConnection', () => {
)
})
it('composes a caller abort into system SSH relay uploads', async () => {
vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig())
const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks())
const controller = new AbortController()
let transferSignal: AbortSignal | undefined
vi.mocked(uploadDirectoryViaSystemSsh).mockImplementationOnce(
(_target, _localDir, _remoteDir, options) => {
transferSignal = options?.signal
return new Promise((_resolve, reject) => {
transferSignal?.addEventListener('abort', () => reject(transferSignal?.reason), {
once: true
})
})
}
)
await conn.connect()
const upload = conn.uploadDirectory('/tmp/local-relay', '/remote/relay', {
signal: controller.signal
})
await vi.waitFor(() => expect(transferSignal).toBeDefined())
controller.abort()
await expect(upload).rejects.toMatchObject({ name: 'AbortError' })
expect(transferSignal?.aborted).toBe(true)
})
it('keeps connection disconnect cancellation linked to caller-scoped relay writes', async () => {
vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig())
const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks())
const controller = new AbortController()
let transferSignal: AbortSignal | undefined
vi.mocked(writeFileViaSystemSsh).mockImplementationOnce(
(_target, _remotePath, _contents, options) => {
transferSignal = options?.signal
return new Promise((_resolve, reject) => {
transferSignal?.addEventListener('abort', () => reject(transferSignal?.reason), {
once: true
})
})
}
)
await conn.connect()
const write = conn.writeFile('/remote/relay/.version', '0.1.0', {
signal: controller.signal
})
await vi.waitFor(() => expect(transferSignal).toBeDefined())
await conn.disconnect()
await expect(write).rejects.toMatchObject({ name: 'AbortError' })
expect(controller.signal.aborted).toBe(false)
expect(transferSignal?.aborted).toBe(true)
})
it('keeps an upload session cancelled after the connection disconnects', async () => {
const conn = new SshConnection(
createTarget({ proxyCommand: 'ssh -W %h:%p bastion.example.com' }),
+173 -82
View File
@@ -41,6 +41,10 @@ import {
import type { RemoteHostPlatform } from './ssh-remote-platform'
import type { FileUploadSession } from '../providers/types'
import { isSshSessionLimitError } from './ssh-session-limit-error'
import {
createLinkedSshFileTransferSignal,
raceSftpFileTransferWithAbort
} from './ssh-file-transfer-abort'
export type { SshConnectionCallbacks } from './ssh-connection-utils'
type SshRemoteFileOptions = {
@@ -157,13 +161,14 @@ export class SshConnection {
'SSH exec channel timed out',
(callback) => client.exec(remoteCommand, callback),
(channel) => channel.close(),
options?.signal
options?.signal,
true
),
options?.signal
)
}
async sftp(): Promise<SFTPWrapper> {
async sftp(signal?: AbortSignal): Promise<SFTPWrapper> {
if (this.useSystemSshTransport) {
throw new Error('SFTP is not available when using system SSH transport')
}
@@ -171,12 +176,15 @@ export class SshConnection {
throw new Error('Not connected')
}
const client = this.client
return this.openSessionChannelWithRetry(() =>
this.waitForSshCallback(
'SSH SFTP channel timed out',
(callback) => client.sftp(callback),
(sftp) => sftp.end()
)
return this.openSessionChannelWithRetry(
() =>
this.waitForSshCallback(
'SSH SFTP channel timed out',
(callback) => client.sftp(callback),
(sftp) => sftp.end(),
signal
),
signal
)
}
@@ -219,10 +227,20 @@ export class SshConnection {
timeoutMessage: string,
register: (callback: (error: Error | undefined, value: T) => void) => void,
cleanupLateValue?: (value: T) => void,
signal?: AbortSignal
signal?: AbortSignal,
trackRemoteCommandTermination = false
): Promise<T> {
return new Promise((resolve, reject) => {
type ChannelOpenTerminationError = Error & { sshChannelCloseConfirmed: boolean }
let settled = false
let unconfirmedOpenError: ChannelOpenTerminationError | null = null
const markOpenUnconfirmed = (error: Error): Error => {
if (!trackRemoteCommandTermination) {
return error
}
unconfirmedOpenError = Object.assign(error, { sshChannelCloseConfirmed: false })
return unconfirmedOpenError
}
// Why: rejecting the instant the signal aborts lets the caller proceed
// while the in-flight channel open completes in the background and holds
// a server-side session slot (MaxSessions). Mark the abort and settle
@@ -241,16 +259,41 @@ export class SshConnection {
abortDeadlineTimer = setTimeout(() => {
settled = true
cleanup()
reject(createSshOperationAbortError())
reject(markOpenUnconfirmed(createSshOperationAbortError()))
}, ABORTED_CHANNEL_CLOSE_GRACE_MS)
}
const timer = setTimeout(() => {
settled = true
cleanup()
reject(abortRequested ? createSshOperationAbortError() : new Error(timeoutMessage))
reject(
markOpenUnconfirmed(
abortRequested ? createSshOperationAbortError() : new Error(timeoutMessage)
)
)
}, CONNECT_TIMEOUT_MS)
const discardLateValue = (value: T, onClose?: () => void): void => {
const emitter = value as Partial<NodeJS.EventEmitter> & {
resume?: () => void
stderr?: Partial<NodeJS.EventEmitter> & { resume?: () => void }
}
const swallowLateError = (): void => {}
emitter.on?.('error', swallowLateError)
emitter.stderr?.on?.('error', swallowLateError)
if (onClose) {
emitter.once?.('close', onClose)
}
// Why: ssh2 can withhold CHANNEL_CLOSE while discarded exec streams
// remain unread, and teardown errors have no other owner.
emitter.resume?.()
emitter.stderr?.resume?.()
try {
cleanupLateValue?.(value)
} catch {
/* best effort */
}
}
const rejectAfterClose = (value: T): void => {
const abortError = createSshOperationAbortError()
const abortError = markOpenUnconfirmed(createSshOperationAbortError())
const emitter = value as Partial<NodeJS.EventEmitter> & {
resume?: () => void
stderr?: { resume?: () => void }
@@ -262,23 +305,24 @@ export class SshConnection {
}
finished = true
clearTimeout(closeGraceTimer)
emitter.removeListener?.('close', confirmAndDone)
reject(abortError)
}
const confirmAndDone = (): void => {
if (unconfirmedOpenError === abortError) {
unconfirmedOpenError.sshChannelCloseConfirmed = true
}
done()
}
// Why: bounded — a remote that never confirms the close must not hang
// the aborted operation forever.
const closeGraceTimer = setTimeout(done, ABORTED_CHANNEL_CLOSE_GRACE_MS)
if (typeof emitter.once === 'function') {
emitter.once('close', done)
emitter.once('close', confirmAndDone)
}
// Why: ssh2 withholds the 'close' event until the channel's streams
// are drained; nobody else will ever read this discarded channel.
emitter.resume?.()
emitter.stderr?.resume?.()
try {
cleanupLateValue?.(value)
} catch {
/* best effort */
}
discardLateValue(value)
if (typeof emitter.once !== 'function') {
done()
}
@@ -289,11 +333,11 @@ export class SshConnection {
// rejected. Close that late resource so the remote channel is not
// left open with no owner.
if (!error && value !== undefined) {
try {
cleanupLateValue?.(value)
} catch {
/* best effort */
}
discardLateValue(value, () => {
if (unconfirmedOpenError) {
unconfirmedOpenError.sshChannelCloseConfirmed = true
}
})
}
return
}
@@ -334,23 +378,51 @@ export class SshConnection {
async uploadDirectory(
localDir: string,
remoteDir: string,
options?: SshRemoteFileOptions
options?: SshRemoteFileOptions & { signal?: AbortSignal }
): Promise<void> {
if (!this.useSystemSshTransport) {
const sftp = await this.sftp()
try {
const { uploadDirectory } = await import('./ssh-relay-deploy-helpers')
await uploadDirectory(sftp, localDir, remoteDir)
} finally {
sftp.end()
// Why: relay deployment timeout and connection teardown are independent;
// either owner must stop a transfer that can otherwise outlive its lock.
const linkedSignal = createLinkedSshFileTransferSignal(
[this.systemOperationAbortController.signal, options?.signal].filter(
(signal): signal is AbortSignal => signal !== undefined
)
)
try {
if (!this.useSystemSshTransport) {
const sftp = await this.sftp(linkedSignal.signal)
const swallowLateSftpError = (): void => {}
let sftpEndRequested = false
const endSftp = (): void => {
if (!sftpEndRequested) {
sftpEndRequested = true
sftp.end()
}
}
sftp.on('error', swallowLateSftpError)
sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError))
try {
const { uploadDirectory } = await import('./ssh-relay-deploy-helpers')
await raceSftpFileTransferWithAbort(
uploadDirectory(sftp, localDir, remoteDir),
linkedSignal.signal,
(onClose) => {
sftp.once('close', onClose)
endSftp()
}
)
} finally {
endSftp()
}
return
}
return
await uploadDirectoryViaSystemSsh(this.target, localDir, remoteDir, {
signal: linkedSignal.signal,
hostPlatform: options?.hostPlatform,
...this.getSystemSshBuildArgsOptions()
})
} finally {
linkedSignal.dispose()
}
await uploadDirectoryViaSystemSsh(this.target, localDir, remoteDir, {
signal: this.systemOperationAbortController.signal,
hostPlatform: options?.hostPlatform,
...this.getSystemSshBuildArgsOptions()
})
}
async downloadFile(
@@ -404,55 +476,74 @@ export class SshConnection {
async writeFile(
remotePath: string,
contents: string,
options?: SshRemoteFileOptions
options?: SshRemoteFileOptions & { signal?: AbortSignal }
): Promise<void> {
if (!this.useSystemSshTransport) {
const sftp = await this.sftp()
const swallowLateSftpError = (): void => {}
sftp.on('error', swallowLateSftpError)
try {
await new Promise<void>((resolve, reject) => {
const ws = sftp.createWriteStream(remotePath)
let settled = false
const cleanup = (): void => {
ws.removeListener('close', onClose)
ws.removeListener('error', onError)
// Keep package/version writes under the same dual cancellation contract as uploads.
const linkedSignal = createLinkedSshFileTransferSignal(
[this.systemOperationAbortController.signal, options?.signal].filter(
(signal): signal is AbortSignal => signal !== undefined
)
)
try {
if (!this.useSystemSshTransport) {
const sftp = await this.sftp(linkedSignal.signal)
const swallowLateSftpError = (): void => {}
let sftpEndRequested = false
const endSftp = (): void => {
if (!sftpEndRequested) {
sftpEndRequested = true
sftp.end()
}
const onClose = (): void => {
if (settled) {
return
}
sftp.on('error', swallowLateSftpError)
sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError))
try {
const write = new Promise<void>((resolve, reject) => {
const ws = sftp.createWriteStream(remotePath)
let settled = false
const cleanup = (): void => {
sftp.removeListener('error', onError)
ws.removeListener('close', onClose)
ws.removeListener('error', onError)
}
settled = true
cleanup()
resolve()
}
const onError = (err: Error): void => {
sftp.removeListener('error', onError)
if (settled) {
return
const onClose = (): void => {
if (settled) {
return
}
settled = true
cleanup()
resolve()
}
settled = true
cleanup()
reject(err)
}
sftp.prependOnceListener('error', onError)
ws.once('close', onClose)
ws.once('error', onError)
ws.end(contents)
})
} finally {
sftp.end()
setImmediate(() => {
sftp.removeListener('error', swallowLateSftpError)
})
const onError = (err: Error): void => {
if (settled) {
return
}
settled = true
cleanup()
reject(err)
}
sftp.prependOnceListener('error', onError)
ws.once('close', onClose)
ws.once('error', onError)
ws.end(contents)
})
await raceSftpFileTransferWithAbort(write, linkedSignal.signal, (onClose) => {
sftp.once('close', onClose)
endSftp()
})
} finally {
endSftp()
}
return
}
return
await writeFileViaSystemSsh(this.target, remotePath, contents, {
signal: linkedSignal.signal,
hostPlatform: options?.hostPlatform,
...this.getSystemSshBuildArgsOptions()
})
} finally {
linkedSignal.dispose()
}
await writeFileViaSystemSsh(this.target, remotePath, contents, {
signal: this.systemOperationAbortController.signal,
hostPlatform: options?.hostPlatform,
...this.getSystemSshBuildArgsOptions()
})
}
async writeBuffer(
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest'
import { raceSftpFileTransferWithAbort } from './ssh-file-transfer-abort'
describe('raceSftpFileTransferWithAbort', () => {
it('waits for confirmed SFTP close before rejecting an abort', async () => {
const controller = new AbortController()
let confirmClose: () => void = () => {}
const promise = raceSftpFileTransferWithAbort(
new Promise<void>(() => {}),
controller.signal,
(onClose) => {
confirmClose = onClose
}
)
controller.abort()
const pending = await Promise.race([
promise.then(
() => 'settled',
() => 'settled'
),
Promise.resolve('pending')
])
expect(pending).toBe('pending')
confirmClose()
await expect(promise).rejects.toMatchObject({
name: 'AbortError',
sshChannelCloseConfirmed: true
})
})
it('marks teardown unconfirmed when SFTP never closes', async () => {
vi.useFakeTimers()
try {
const controller = new AbortController()
const promise = raceSftpFileTransferWithAbort(
new Promise<void>(() => {}),
controller.signal,
() => {}
)
const outcome = promise.catch((error: Error) => error)
controller.abort()
await vi.advanceTimersByTimeAsync(5_000)
await expect(outcome).resolves.toMatchObject({
name: 'AbortError',
sshChannelCloseConfirmed: false
})
} finally {
vi.useRealTimers()
}
})
})
+76
View File
@@ -0,0 +1,76 @@
import { createSshOperationAbortError } from './ssh-connection-utils'
export function createLinkedSshFileTransferSignal(signals: readonly AbortSignal[]): {
signal: AbortSignal
dispose: () => void
} {
const controller = new AbortController()
const listeners = signals.map((signal) => {
const listener = (): void => controller.abort(signal.reason)
if (signal.aborted) {
listener()
} else {
signal.addEventListener('abort', listener, { once: true })
}
return { signal, listener }
})
return {
signal: controller.signal,
dispose: () => {
for (const { signal, listener } of listeners) {
signal.removeEventListener('abort', listener)
}
}
}
}
export function raceSftpFileTransferWithAbort<T>(
operation: Promise<T>,
signal: AbortSignal,
closeSftp: (onClose: () => void) => void
): Promise<T> {
return new Promise((resolve, reject) => {
let settled = false
let abortError: (Error & { sshChannelCloseConfirmed: boolean }) | null = null
let closeGraceTimer: ReturnType<typeof setTimeout> | null = null
const settle = (fn: typeof resolve | typeof reject, value: T | Error): void => {
if (settled) {
return
}
settled = true
if (closeGraceTimer) {
clearTimeout(closeGraceTimer)
}
signal.removeEventListener('abort', onAbort)
fn(value as never)
}
const onAbort = (): void => {
// Why: rejecting the caller is insufficient; ending SFTP stops the
// abandoned transfer from mutating a successor relay install.
abortError = Object.assign(createSshOperationAbortError(), {
sshChannelCloseConfirmed: false
})
closeGraceTimer = setTimeout(() => settle(reject, abortError!), 5_000)
closeSftp(() => {
abortError!.sshChannelCloseConfirmed = true
settle(reject, abortError!)
})
}
signal.addEventListener('abort', onAbort, { once: true })
void operation.then(
(value) => {
if (!abortError) {
settle(resolve, value)
}
},
(error: unknown) => {
if (!abortError) {
settle(reject, error instanceof Error ? error : new Error(String(error)))
}
}
)
if (signal.aborted) {
onAbort()
}
})
}
+7 -2
View File
@@ -141,15 +141,20 @@ export function formatMissingToolchainError(
// callers fall back to the original install error in those cases.
export async function probeBuildToolchain(
conn: SshConnection,
hostPlatform: RemoteHostPlatform
hostPlatform: RemoteHostPlatform,
signal?: AbortSignal
): Promise<BuildToolchainStatus | null> {
if (isWindowsRemoteHost(hostPlatform)) {
return null
}
try {
const output = await execCommand(conn, buildToolchainProbeCommand(), { wrapCommand: true })
const output = await execCommand(conn, buildToolchainProbeCommand(), {
wrapCommand: true,
signal
})
return parseBuildToolchainProbe(output)
} catch {
signal?.throwIfAborted()
return null
}
}
@@ -33,6 +33,9 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({
onData: vi.fn(),
onClose: vi.fn()
}),
isUnconfirmedSshCommandTermination: (error: unknown) =>
error instanceof Error &&
(error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false,
execCommand: vi.fn()
}))
@@ -101,8 +104,10 @@ describe('cross-version isolation', () => {
'__ORCA_REMOTE_PLATFORM__ Linux x86_64', // tagged POSIX platform probe
'/home/u', // echo $HOME
'MISSING', // isRelayAlreadyInstalled (v2 dir doesn't exist)
'OPEN', // no sibling GC claim
'', // mkdir -p remoteRelayDir (v2)
'OK', // mkdir lock OK
'OPEN', // GC did not claim while the install lock was acquired
'MISSING', // re-probe after lock → still missing → proceed with install
'', // mkdir remoteDir (uploadRelay)
'', // chmod +x node
@@ -111,9 +116,9 @@ describe('cross-version isolation', () => {
'ORCA-NPTY-PROBE-OK\n', // node -e require() load-test (post-install verify)
'', // rm -f probe-stderr (best-effort cleanup after probe resolved)
'', // touch .install-complete (finalizeInstall)
'', // rm -rf .install-lock
'DEAD', // launch socket probe
'READY', // socket poll
'', // release .install-lock after relay liveness is observable
// GC scan begins here
'relay-0.1.0+v1hash\nrelay-0.1.0+v2hash\n', // ls listing
'OPEN', // v1 lock probe (siblings only — current dir is v2)
+143 -4
View File
@@ -11,9 +11,10 @@ import {
function createMockChannel(): ClientChannel {
return Object.assign(new EventEmitter(), {
stderr: new EventEmitter(),
stderr: Object.assign(new EventEmitter(), { resume: vi.fn() }),
stdin: { write: vi.fn() },
close: vi.fn()
close: vi.fn(),
resume: vi.fn()
}) as unknown as ClientChannel
}
@@ -29,6 +30,44 @@ async function execCommandRejection(promise: Promise<string>): Promise<Error> {
}
describe('waitForSentinel', () => {
it('closes and rejects with AbortError while clearing startup resources on abort', async () => {
vi.useFakeTimers()
try {
const channel = createMockChannel()
const controller = new AbortController()
const removeListener = vi.spyOn(controller.signal, 'removeEventListener')
const transportPromise = waitForSentinel(channel, controller.signal)
expect(vi.getTimerCount()).toBe(1)
controller.abort()
await expect(transportPromise).rejects.toMatchObject({ name: 'AbortError' })
expect(channel.close).toHaveBeenCalledTimes(1)
expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function))
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('removes the abort listener and timers when startup fails before the sentinel', async () => {
vi.useFakeTimers()
try {
const channel = createMockChannel()
const controller = new AbortController()
const removeListener = vi.spyOn(controller.signal, 'removeEventListener')
const transportPromise = waitForSentinel(channel, controller.signal)
channel.emit('error', new Error('remote host rebooted'))
await expect(transportPromise).rejects.toThrow('remote host rebooted')
expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function))
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('buffers post-sentinel chunks until the transport subscribes', async () => {
const channel = createMockChannel()
const transportPromise = waitForSentinel(channel)
@@ -168,6 +207,32 @@ describe('waitForSentinel', () => {
})
describe('execCommand', () => {
it('waits for channel close before rejecting a timed-out remote command', async () => {
vi.useFakeTimers()
try {
const channel = createMockChannel()
const conn = { exec: vi.fn().mockResolvedValue(channel) }
const commandPromise = execCommand(conn as never, 'npm rebuild', { timeoutMs: 1_000 })
await vi.advanceTimersByTimeAsync(1_000)
expect(channel.close).toHaveBeenCalledTimes(1)
expect(
await Promise.race([
commandPromise.then(
() => 'settled',
() => 'settled'
),
Promise.resolve('pending')
])
).toBe('pending')
channel.emit('close', 0)
await expect(commandPromise).rejects.toThrow('timed out after 1s')
} finally {
vi.useRealTimers()
}
})
it('rejects on command channel errors instead of emitting uncaught errors', async () => {
const channel = createMockChannel()
const conn = {
@@ -177,6 +242,8 @@ describe('execCommand', () => {
await Promise.resolve()
expect(() => channel.emit('error', new Error('remote host rebooted'))).not.toThrow()
expect(channel.close).toHaveBeenCalledOnce()
channel.emit('close', 1)
await expect(commandPromise).rejects.toThrow('remote host rebooted')
expect(channel.listenerCount('error')).toBe(0)
expect(channel.listenerCount('data')).toBe(0)
@@ -224,6 +291,22 @@ describe('execCommand', () => {
expect(error.message).toContain('gnu++20')
})
it('bounds command output while preserving the actionable tail', async () => {
const channel = createMockChannel()
const conn = { exec: vi.fn().mockResolvedValue(channel) }
const commandPromise = execCommand(conn as never, 'npm rebuild')
await Promise.resolve()
channel.emit('data', Buffer.from(`old-prefix-${'x'.repeat(1024 * 1024)}`))
channel.emit('data', Buffer.from('gyp ERR! tail diagnosis'))
channel.emit('close', 1)
const error = await execCommandRejection(commandPromise)
expect(error.message).not.toContain('old-prefix')
expect(error.message).toContain('gyp ERR! tail diagnosis')
expect(error.message.length).toBeLessThan(1024 * 1024 + 200)
})
it('keeps the merged error message greppable by the build-toolchain probe', async () => {
const channel = createMockChannel()
const conn = {
@@ -260,14 +343,26 @@ describe('execCommand', () => {
await Promise.resolve()
const rejection = expect(commandPromise).rejects.toThrow('timed out')
await vi.advanceTimersByTimeAsync(30_000)
expect(channel.close).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(5_000)
await rejection
expect(channel.close).toHaveBeenCalledOnce()
expect(channel.listenerCount('error')).toBe(0)
expect(channel.resume).toHaveBeenCalledOnce()
expect(channel.stderr.resume).toHaveBeenCalledOnce()
expect(() => channel.emit('error', new Error('late channel error'))).not.toThrow()
expect(() => channel.stderr.emit('error', new Error('late stderr error'))).not.toThrow()
channel.emit('data', Buffer.from('late stdout'))
channel.stderr.emit('data', Buffer.from('late stderr'))
expect(channel.listenerCount('error')).toBe(1)
expect(channel.listenerCount('data')).toBe(0)
expect(channel.listenerCount('close')).toBe(1)
expect(channel.stderr.listenerCount('error')).toBe(1)
expect(channel.stderr.listenerCount('data')).toBe(0)
channel.emit('close', 0)
expect(channel.listenerCount('error')).toBe(0)
expect(channel.listenerCount('close')).toBe(0)
expect(channel.stderr.listenerCount('error')).toBe(0)
expect(channel.stderr.listenerCount('data')).toBe(0)
} finally {
vi.useRealTimers()
}
@@ -309,6 +404,48 @@ describe('execCommand', () => {
expect(channel.stderr.listenerCount('data')).toBe(0)
})
it('keeps system-SSH command termination unconfirmed after the local child closes', async () => {
const channel = createMockChannel()
const controller = new AbortController()
const conn = {
exec: vi.fn().mockResolvedValue(channel),
usesSystemSshTransport: vi.fn().mockReturnValueOnce(true).mockReturnValue(false)
}
const commandPromise = execCommand(conn as never, 'npm install', {
signal: controller.signal
})
await Promise.resolve()
controller.abort()
channel.emit('close', 0)
await expect(commandPromise).rejects.toMatchObject({
name: 'AbortError',
sshChannelCloseConfirmed: false
})
})
it('tags an externally closed system-SSH command as unconfirmed', async () => {
const channel = createMockChannel() as ClientChannel & { _closeRequested?: boolean }
channel.close = vi.fn(() => {
channel._closeRequested = true
})
const conn = {
exec: vi.fn().mockResolvedValue(channel),
usesSystemSshTransport: vi.fn().mockReturnValue(true)
}
const commandPromise = execCommand(conn as never, 'npm install')
await Promise.resolve()
channel.close()
channel.emit('close', 1)
await expect(commandPromise).rejects.toMatchObject({
name: 'AbortError',
sshChannelCloseConfirmed: false
})
})
it('handles aborts that happen while the SSH exec channel is still opening', async () => {
const channel = createMockChannel()
const controller = new AbortController()
@@ -357,6 +494,8 @@ describe('execCommand', () => {
expect(conn.exec).toHaveBeenCalledWith('npm install', { wrapCommand: false })
const rejection = expect(commandPromise).rejects.toThrow('timed out after 240s')
await vi.advanceTimersByTimeAsync(240_000)
expect(channel.close).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(5_000)
await rejection
expect(channel.close).toHaveBeenCalledOnce()
+41 -117
View File
@@ -1,18 +1,21 @@
import type { ClientChannel } from 'ssh2'
import type { SshConnection } from './ssh-connection'
import { createSshOperationAbortError, type SshExecOptions } from './ssh-connection-utils'
import { createSshOperationAbortError } from './ssh-connection-utils'
import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from './relay-protocol'
import type { MultiplexerTransport } from './ssh-channel-multiplexer'
import { buildRelayVersionMismatchError } from './ssh-relay-handshake-mismatch'
export { uploadFile, uploadDirectory, mkdirSftp } from './sftp-upload'
export { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command'
// ── Sentinel detection ────────────────────────────────────────────────
const MAX_RELAY_STARTUP_BUFFER_BYTES = 64 * 1024
const RELAY_SENTINEL_BUFFER = Buffer.from(RELAY_SENTINEL, 'utf-8')
export function waitForSentinel(channel: ClientChannel): Promise<MultiplexerTransport> {
export function waitForSentinel(
channel: ClientChannel,
signal?: AbortSignal
): Promise<MultiplexerTransport> {
return new Promise<MultiplexerTransport>((resolve, reject) => {
let sentinelReceived = false
let settled = false
@@ -39,17 +42,14 @@ export function waitForSentinel(channel: ClientChannel): Promise<MultiplexerTran
const timeout = setTimeout(() => {
timeoutFired = true
channel.close()
timeoutGraceTimer = setTimeout(() => {
if (!settled) {
settled = true
reject(
new Error(
`Relay failed to start within ${RELAY_SENTINEL_TIMEOUT_MS / 1000}s.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}`
)
rejectStartup(
new Error(
`Relay failed to start within ${RELAY_SENTINEL_TIMEOUT_MS / 1000}s.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}`
)
}
)
}, TIMEOUT_GRACE_MS)
channel.close()
}, RELAY_SENTINEL_TIMEOUT_MS)
const cancelTimers = (): void => {
@@ -59,6 +59,31 @@ export function waitForSentinel(channel: ClientChannel): Promise<MultiplexerTran
timeoutGraceTimer = null
}
}
const cleanupStartup = (): void => {
cancelTimers()
signal?.removeEventListener('abort', onAbort)
}
const rejectStartup = (err: Error): void => {
if (settled) {
return
}
settled = true
cleanupStartup()
reject(err)
}
const onAbort = (): void => {
if (settled) {
return
}
settled = true
cleanupStartup()
channel.close()
reject(createSshOperationAbortError())
}
signal?.addEventListener('abort', onAbort, { once: true })
if (signal?.aborted) {
onAbort()
}
channel.on('exit', (code: number | null) => {
if (typeof code === 'number') {
@@ -87,12 +112,8 @@ export function waitForSentinel(channel: ClientChannel): Promise<MultiplexerTran
}
const failOrClose = (err: Error): void => {
cancelTimers()
if (!sentinelReceived) {
if (!settled) {
settled = true
reject(err)
}
rejectStartup(err)
return
}
notifyClosed()
@@ -113,9 +134,7 @@ export function waitForSentinel(channel: ClientChannel): Promise<MultiplexerTran
channel.on('close', () => {
if (!sentinelReceived) {
cancelTimers()
if (!settled) {
settled = true
// Why: a wire-handshake mismatch on the daemon side closes the
// socket; --connect prints the mismatch detail to stderr and exits
// with code 42 BEFORE writing the sentinel. Translate that into a
@@ -126,13 +145,13 @@ export function waitForSentinel(channel: ClientChannel): Promise<MultiplexerTran
// grace window so the close handler can deliver the exit code.
const versionMismatchError = buildRelayVersionMismatchError(lastExitCode, stderrOutput)
if (versionMismatchError) {
reject(versionMismatchError)
rejectStartup(versionMismatchError)
return
}
const timeoutSuffix = timeoutFired
? ` (after ${RELAY_SENTINEL_TIMEOUT_MS / 1000}s sentinel timeout)`
: ''
reject(
rejectStartup(
new Error(
`Relay process exited before ready${timeoutSuffix}.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}`
)
@@ -184,7 +203,8 @@ export function waitForSentinel(channel: ClientChannel): Promise<MultiplexerTran
if (sentinelIdx !== -1) {
sentinelReceived = true
cancelTimers()
settled = true
cleanupStartup()
const afterSentinelOffset =
sentinelIdx + RELAY_SENTINEL_BUFFER.length - bufferedStdout.length
@@ -193,8 +213,6 @@ export function waitForSentinel(channel: ClientChannel): Promise<MultiplexerTran
if (afterSentinel.length > 0) {
pendingAfterSentinel = afterSentinel
}
settled = true
const transport: MultiplexerTransport = {
write: (buf: Buffer) => channel.stdin.write(buf),
onData: (cb) => {
@@ -233,97 +251,3 @@ export function waitForSentinel(channel: ClientChannel): Promise<MultiplexerTran
})
})
}
// ── Remote command execution ──────────────────────────────────────────
const EXEC_TIMEOUT_MS = 30_000
type ExecCommandOptions = SshExecOptions & {
timeoutMs?: number
}
export async function execCommand(
conn: SshConnection,
command: string,
options?: ExecCommandOptions
): Promise<string> {
const { timeoutMs = EXEC_TIMEOUT_MS, ...execOptions } = options ?? {}
const signal = options?.signal
if (signal?.aborted) {
throw createSshOperationAbortError()
}
const channel = await conn.exec(command, execOptions)
return new Promise((resolve, reject) => {
let stdout = ''
let stderr = ''
let settled = false
const cleanup = (): void => {
clearTimeout(timeout)
signal?.removeEventListener('abort', onAbort)
channel.off('error', fail)
channel.stderr.off('error', fail)
channel.off('data', onStdoutData)
channel.stderr.off('data', onStderrData)
channel.off('close', onClose)
}
const settle = (fn: typeof resolve | typeof reject, val: string | Error): void => {
if (settled) {
return
}
settled = true
cleanup()
fn(val as never)
}
const fail = (err: Error): void => {
settle(reject, abortRequested ? createSshOperationAbortError() : err)
}
// Why: sshd counts the session against MaxSessions until CHANNEL_CLOSE
// completes. Settling on abort before the channel actually closes lets the
// concurrent-bootstrap sequential fallback reissue an exec while the slot
// is still held, so it gets refused again. Close and settle from onClose.
let abortRequested = false
const onAbort = (): void => {
abortRequested = true
channel.close()
}
const onStdoutData = (data: Buffer): void => {
stdout += data.toString('utf-8')
}
const onStderrData = (data: Buffer): void => {
stderr += data.toString('utf-8')
}
const onClose = (code: number): void => {
if (abortRequested) {
settle(reject, createSshOperationAbortError())
} else if (code !== 0) {
// Why: on the system-ssh transport channel.stderr carries local OpenSSH
// client noise; preferring it masks the real failure in stdout (2>&1).
const output = [stderr.trim(), stdout.trim()].filter(Boolean).join('\n')
settle(reject, new Error(`Command "${command}" failed (exit ${code}): ${output}`))
} else {
settle(resolve, stdout)
}
}
const timeout = setTimeout(() => {
channel.close()
settle(
reject,
abortRequested
? createSshOperationAbortError()
: new Error(`Command "${command}" timed out after ${timeoutMs / 1000}s`)
)
}, timeoutMs)
// Why: remote reboot tears down exec channels with stream errors. Without
// scoped listeners, Node treats those as uncaught exceptions.
signal?.addEventListener('abort', onAbort, { once: true })
channel.on('error', fail)
channel.stderr.on('error', fail)
channel.on('data', onStdoutData)
channel.stderr.on('data', onStderrData)
channel.on('close', onClose)
if (signal?.aborted) {
onAbort()
}
})
}
+13
View File
@@ -0,0 +1,13 @@
// npm install on a cold Windows cache plus antivirus scanning can exceed the
// default 30s exec timeout.
export const NATIVE_DEPS_COMMAND_TIMEOUT_MS = 240_000
// Why: a missing binding can require both install and rebuild while the same
// install lock is held. Concurrent first installs must wait for that valid
// holder instead of failing halfway through its bounded work.
const NATIVE_DEPS_REPAIR_BUDGET_MS = 2 * NATIVE_DEPS_COMMAND_TIMEOUT_MS
// Why: native repair also runs separately bounded chmod/probe/diagnostic/
// finalize commands around install + rebuild. Keep the outer bound above that
// valid worst path while remaining below the 20-minute stale-lock threshold.
export const RELAY_DEPLOY_TIMEOUT_MS = NATIVE_DEPS_REPAIR_BUDGET_MS + 7 * 60_000
+198 -12
View File
@@ -42,6 +42,9 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({
onData: vi.fn(),
onClose: vi.fn()
}),
isUnconfirmedSshCommandTermination: (error: unknown) =>
error instanceof Error &&
(error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false,
execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
}))
@@ -49,27 +52,38 @@ vi.mock('./ssh-remote-node-resolution', () => ({
resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node')
}))
// Why: the versioned-install module shells out to the remote for install
// state, lock acquisition, and GC. Tests stub these to no-ops so the deploy
// happy-path is exercised without a real SSH connection.
// Why: the versioned-install modules shell out for install state, locking,
// and GC. Stub them so deploy tests need no real SSH connection.
vi.mock('./ssh-relay-versioned-install', () => ({
readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'),
computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`,
isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true),
acquireInstallLock: vi.fn().mockResolvedValue(undefined),
finalizeInstall: vi.fn().mockResolvedValue(undefined),
abandonInstall: vi.fn().mockResolvedValue(undefined),
gcOldRelayVersions: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('./ssh-relay-install-lock', () => ({
acquireInstallLock: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('./ssh-relay-repair-lock', () => ({
tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired')
}))
vi.mock('./ssh-connection-utils', () => ({
shellEscape: (s: string) => `'${s}'`
shellEscape: (s: string) => `'${s}'`,
createSshOperationAbortError: () =>
Object.assign(new Error('SSH operation was cancelled'), {
name: 'AbortError'
})
}))
import { deployAndLaunchRelay } from './ssh-relay-deploy'
import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers'
import { resolveRemoteNodePath } from './ssh-remote-node-resolution'
import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install'
import { acquireInstallLock } from './ssh-relay-install-lock'
import type { SshConnection } from './ssh-connection'
import type * as SshRemoteNodeResolution from './ssh-remote-node-resolution'
import {
@@ -133,7 +147,8 @@ describe('deployAndLaunchRelay', () => {
expect(mockExecCommand).toHaveBeenCalledWith(
conn,
"printf '\\n%s ' '__ORCA_REMOTE_PLATFORM__'; uname -sm"
"printf '\\n%s ' '__ORCA_REMOTE_PLATFORM__'; uname -sm",
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
})
@@ -153,6 +168,26 @@ describe('deployAndLaunchRelay', () => {
expect(progress).toContain('Starting relay...')
})
it('does not launch fresh after unconfirmed stale-socket cleanup', async () => {
const conn = makeMockConnection()
const unconfirmedCleanup = Object.assign(new Error('socket cleanup still running'), {
sshChannelCloseConfirmed: false
})
vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('stale relay reconnect failed'))
vi.mocked(execCommand)
.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
.mockResolvedValueOnce('/home/user')
.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK')
.mockResolvedValueOnce('ALIVE')
.mockRejectedValueOnce(unconfirmedCleanup)
await expect(deployAndLaunchRelay(conn)).rejects.toBe(unconfirmedCleanup)
const commands = vi.mocked(conn.exec).mock.calls.map(([command]) => command)
expect(commands).toHaveLength(1)
expect(commands.some((command) => command.includes('--detached'))).toBe(false)
})
it('resolves the remote node path once per deploy', async () => {
const conn = makeMockConnection()
const mockExecCommand = vi.mocked(execCommand)
@@ -202,7 +237,7 @@ describe('deployAndLaunchRelay', () => {
assertionError = err
} finally {
// Drain the rest of the happy path so a failed assertion does not leave
// the deploy promise pending until its 300s timeout.
// the deploy promise pending until the overall deploy timeout.
mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe
mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe
mockExecCommand.mockResolvedValueOnce('READY') // socket poll
@@ -287,7 +322,10 @@ describe('deployAndLaunchRelay', () => {
await deployAndLaunchRelay(conn)
expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(2)
expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(3)
expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[2]?.[3]).toMatchObject({
rethrowSessionLimitErrors: true
})
expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2)
})
@@ -314,11 +352,17 @@ describe('deployAndLaunchRelay', () => {
await deployAndLaunchRelay(conn)
expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(2)
expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(3)
expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[0]?.[3]).toMatchObject({
rethrowSessionLimitErrors: true
})
expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[1]?.[3]).toBeUndefined()
expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[1]?.[3]).toMatchObject({
rethrowSessionLimitErrors: undefined,
signal: expect.any(AbortSignal)
})
expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[2]?.[3]).toMatchObject({
rethrowSessionLimitErrors: true
})
expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2)
})
@@ -499,7 +543,10 @@ describe('deployAndLaunchRelay', () => {
expect(sawLegacyDir).toBe(false)
})
it('has a 300-second overall timeout', async () => {
it('bounds the overall deploy so install + rebuild both fit under the timeout', async () => {
// Why: the outer bound must exceed the worst-case sequential native-deps
// work — a first install (240s) AND a follow-up rebuild (240s) — so a
// legitimate install-then-rebuild is not falsely timed out mid-repair.
const conn = makeMockConnection()
const mockExecCommand = vi.mocked(execCommand)
@@ -511,15 +558,140 @@ describe('deployAndLaunchRelay', () => {
// Catch the rejection immediately to avoid unhandled rejection warning
const promise = deployAndLaunchRelay(conn).catch((err: Error) => err)
// Not timed out yet at the old 300s bound (install + rebuild need more).
await vi.advanceTimersByTimeAsync(301_000)
expect(await Promise.race([promise, Promise.resolve('pending')])).toBe('pending')
await vi.advanceTimersByTimeAsync(600_000)
const result = await promise
expect(result).toBeInstanceOf(Error)
expect((result as Error).message).toBe('Relay deployment timed out after 300s')
expect((result as Error).message).toBe('Relay deployment timed out after 900s')
vi.useRealTimers()
})
it('aborts a contended install-lock wait at the overall deploy timeout', async () => {
vi.useFakeTimers()
try {
const conn = makeMockConnection()
vi.mocked(execCommand)
.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
.mockResolvedValueOnce('/home/user')
vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false)
let lockSignal: AbortSignal | undefined
vi.mocked(acquireInstallLock).mockImplementationOnce((_conn, _dir, _host, options) => {
lockSignal = options?.signal
return new Promise<void>((_resolve, reject) => {
lockSignal?.addEventListener('abort', () => reject(lockSignal?.reason), { once: true })
})
})
const promise = deployAndLaunchRelay(conn).catch((err: Error) => err)
await vi.advanceTimersByTimeAsync(0)
expect(acquireInstallLock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(900_000)
const result = await promise
expect(result).toBeInstanceOf(Error)
expect((result as Error).message).toBe('Relay deployment timed out after 900s')
expect(lockSignal?.aborted).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('aborts an in-progress relay upload at the overall deploy timeout', async () => {
vi.useFakeTimers()
try {
const conn = makeMockConnection()
vi.mocked(execCommand)
.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
.mockResolvedValueOnce('/home/user')
.mockResolvedValueOnce('') // mkdir remote relay dir
vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false).mockResolvedValueOnce(false)
let uploadSignal: AbortSignal | undefined
conn.uploadDirectory = vi.fn((_localDir, _remoteDir, options) => {
uploadSignal = options?.signal
return new Promise<void>((_resolve, reject) => {
uploadSignal?.addEventListener('abort', () => reject(uploadSignal?.reason), {
once: true
})
})
})
const promise = deployAndLaunchRelay(conn).catch((err: Error) => err)
await vi.advanceTimersByTimeAsync(0)
expect(conn.uploadDirectory).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(900_000)
const result = await promise
expect(result).toBeInstanceOf(Error)
expect((result as Error).message).toBe('Relay deployment timed out after 900s')
expect(uploadSignal?.aborted).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('aborts a launch started near the deploy deadline and closes its channel once', async () => {
vi.useFakeTimers()
try {
const launchChannel = {
on: vi.fn(),
stderr: { on: vi.fn() },
stdin: {},
stdout: { on: vi.fn() },
close: vi.fn()
}
const conn = makeMockConnection()
vi.mocked(conn.exec).mockResolvedValue(launchChannel as never)
const mockExecCommand = vi.mocked(execCommand)
mockExecCommand
.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
.mockResolvedValueOnce('/home/user')
.mockImplementationOnce(
() =>
new Promise<string>((resolve) =>
setTimeout(() => resolve('ORCA-NATIVE-DEPS-OK'), 899_900)
)
)
.mockResolvedValueOnce('DEAD')
.mockImplementationOnce((_conn, _command, options) => {
return new Promise<string>((_resolve, reject) => {
options?.signal?.addEventListener(
'abort',
() => {
const error = new Error('SSH operation was cancelled')
error.name = 'AbortError'
reject(error)
},
{ once: true }
)
})
})
const promise = deployAndLaunchRelay(conn).catch((err: Error) => err)
await vi.advanceTimersByTimeAsync(899_900)
expect(conn.exec).toHaveBeenCalledTimes(1)
expect(launchChannel.close).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(100)
const result = await promise
expect(result).toBeInstanceOf(Error)
expect((result as Error).message).toBe('Relay deployment timed out after 900s')
expect(launchChannel.close).toHaveBeenCalledTimes(1)
expect(mockExecCommand).toHaveBeenCalledTimes(5)
await vi.advanceTimersByTimeAsync(10_000)
expect(mockExecCommand).toHaveBeenCalledTimes(5)
} finally {
vi.useRealTimers()
}
})
it('uses distinct target-specific relay socket paths', async () => {
const connA = makeMockConnection()
const connB = makeMockConnection()
@@ -596,6 +768,20 @@ describe('deployAndLaunchRelay', () => {
expect(launchScript).not.toContain('\\\\.\\pipe\\agent-hooks')
const waitScript = decodedScripts.find((script) => script.includes('deadline=Date.now()')) ?? ''
expect(waitScript).toContain('setTimeout(attempt,intervalMs)')
const windowsLaunchCalls = mockExecCommand.mock.calls.filter(([, command]) => {
const script = decodePowerShellCommand(command)
return (
script?.includes('.windows-active-pipe') ||
script?.includes('Invoke-CimMethod') ||
script?.includes('deadline=Date.now()')
)
})
expect(windowsLaunchCalls.length).toBeGreaterThan(0)
expect(
windowsLaunchCalls.every(([, , options]) => options?.signal instanceof AbortSignal)
).toBe(true)
expect(vi.mocked(conn.exec).mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal)
expect(vi.mocked(waitForSentinel).mock.calls[0]?.[1]).toBeInstanceOf(AbortSignal)
})
it('relaunches Windows remotes on a fallback pipe when reconnecting the occupied pipe fails', async () => {
File diff suppressed because it is too large Load Diff
+156
View File
@@ -0,0 +1,156 @@
import type { SshConnection } from './ssh-connection'
import { createSshOperationAbortError, type SshExecOptions } from './ssh-connection-utils'
import type { SystemSshCommandChannel } from './system-ssh-command'
const EXEC_TIMEOUT_MS = 30_000
const COMMAND_CLOSE_GRACE_MS = 5_000
const MAX_EXEC_OUTPUT_CHARS = 1024 * 1024
type ExecCommandOptions = SshExecOptions & {
timeoutMs?: number
}
type SshCommandTerminationError = Error & {
sshChannelCloseConfirmed: boolean
}
export function isUnconfirmedSshCommandTermination(
error: unknown
): error is SshCommandTerminationError {
return (
error instanceof Error &&
(error as Partial<SshCommandTerminationError>).sshChannelCloseConfirmed === false
)
}
export async function execCommand(
conn: SshConnection,
command: string,
options?: ExecCommandOptions
): Promise<string> {
const { timeoutMs = EXEC_TIMEOUT_MS, ...execOptions } = options ?? {}
const signal = options?.signal
if (signal?.aborted) {
throw createSshOperationAbortError()
}
// Why: reconnect/disconnect can flip the connection back to ssh2 before a
// killed local OpenSSH child emits close; the channel's transport is immutable.
const openedWithSystemSsh = conn.usesSystemSshTransport?.() === true
const channel = await conn.exec(command, execOptions)
return new Promise((resolve, reject) => {
let stdout = ''
let stderr = ''
let settled = false
let terminationError: SshCommandTerminationError | null = null
let closeGraceTimer: ReturnType<typeof setTimeout> | null = null
const cleanup = (): void => {
clearTimeout(timeout)
if (closeGraceTimer) {
clearTimeout(closeGraceTimer)
}
signal?.removeEventListener('abort', onAbort)
channel.off('error', fail)
channel.stderr.off('error', fail)
channel.off('data', onStdoutData)
channel.stderr.off('data', onStderrData)
channel.off('close', onClose)
}
const settle = (fn: typeof resolve | typeof reject, val: string | Error): void => {
if (settled) {
return
}
settled = true
cleanup()
fn(val as never)
}
const guardUnconfirmedTeardown = (): void => {
const swallowLateError = (): void => {}
const cleanupGuards = (): void => {
channel.off('error', swallowLateError)
channel.stderr.off('error', swallowLateError)
channel.off('close', cleanupGuards)
}
channel.on('error', swallowLateError)
channel.stderr.on('error', swallowLateError)
channel.once('close', cleanupGuards)
// Why: an unconfirmed close can arrive after the caller's bounded wait;
// keep draining discarded streams so ssh2 can finish CHANNEL_CLOSE.
channel.resume()
channel.stderr.resume()
}
// Why: sshd counts the session against MaxSessions until CHANNEL_CLOSE
// completes. Settling on abort before the channel actually closes lets the
// concurrent-bootstrap sequential fallback reissue an exec while the slot
// is still held, so it gets refused again. Close and settle from onClose.
const requestTermination = (error: Error): void => {
if (terminationError) {
return
}
terminationError = Object.assign(error, { sshChannelCloseConfirmed: false })
clearTimeout(timeout)
// Why: callers must not release an install lock while its remote npm
// process can still mutate node_modules. Prefer confirmed channel close,
// but bound a broken transport's teardown wait.
closeGraceTimer = setTimeout(() => {
guardUnconfirmedTeardown()
settle(reject, error)
}, COMMAND_CLOSE_GRACE_MS)
channel.close()
}
const fail = (err: Error): void => requestTermination(err)
const onAbort = (): void => requestTermination(createSshOperationAbortError())
const onStdoutData = (data: Buffer): void => {
stdout = appendExecOutputTail(stdout, data.toString('utf-8'))
}
const onStderrData = (data: Buffer): void => {
stderr = appendExecOutputTail(stderr, data.toString('utf-8'))
}
const onClose = (code: number): void => {
if (
!terminationError &&
openedWithSystemSsh &&
(channel as SystemSshCommandChannel)._closeRequested
) {
terminationError = Object.assign(createSshOperationAbortError(), {
sshChannelCloseConfirmed: false
})
}
if (terminationError) {
// Why: a system-SSH channel closes when the local OpenSSH child exits;
// that does not prove the remote command stopped, especially with a ControlMaster.
if (!openedWithSystemSsh) {
terminationError.sshChannelCloseConfirmed = true
}
settle(reject, terminationError)
} else if (code !== 0) {
// Why: on the system-ssh transport channel.stderr carries local OpenSSH
// client noise; preferring it masks the real failure in stdout (2>&1).
const output = [stderr.trim(), stdout.trim()].filter(Boolean).join('\n')
settle(reject, new Error(`Command "${command}" failed (exit ${code}): ${output}`))
} else {
settle(resolve, stdout)
}
}
const timeout = setTimeout(() => {
requestTermination(new Error(`Command "${command}" timed out after ${timeoutMs / 1000}s`))
}, timeoutMs)
// Why: remote reboot tears down exec channels with stream errors. Without
// scoped listeners, Node treats those as uncaught exceptions.
signal?.addEventListener('abort', onAbort, { once: true })
channel.on('error', fail)
channel.stderr.on('error', fail)
channel.on('data', onStdoutData)
channel.stderr.on('data', onStderrData)
channel.on('close', onClose)
if (signal?.aborted) {
onAbort()
}
})
}
function appendExecOutputTail(existing: string, chunk: string): string {
const combined = existing + chunk
return combined.length > MAX_EXEC_OUTPUT_CHARS ? combined.slice(-MAX_EXEC_OUTPUT_CHARS) : combined
}
+236
View File
@@ -0,0 +1,236 @@
import { randomUUID } from 'node:crypto'
import type { SshConnection } from './ssh-connection'
import { shellEscape } from './ssh-connection-utils'
import { execCommand } from './ssh-relay-deploy-helpers'
import {
probeInstallLockExistsCommand,
tryCreateInstallLockCommand,
tryStealInstallLockCommand
} from './ssh-relay-install-lock-commands'
import { removeRemoteTreeCommand } from './ssh-remote-commands'
import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell'
import {
getRemoteHostPlatform,
isWindowsRemoteHost,
joinRemotePath,
type RemoteHostPlatform
} from './ssh-remote-platform'
const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64')
const RELAY_GC_CLAIM_SUFFIX = '.gc-claim'
const RELAY_GC_OWNER_NAME = '.gc-owner'
// Why: the claim guards only a bounded sibling rename or launch handoff, not
// npm or deletion. Ten minutes bounds crashes while exceeding either sequence.
const RELAY_GC_CLAIM_STALE_SECONDS = 10 * 60
function execHostCommand(
conn: SshConnection,
host: RemoteHostPlatform,
command: string,
signal?: AbortSignal
): Promise<string> {
return execCommand(conn, command, {
wrapCommand: !isWindowsRemoteHost(host),
signal
})
}
export function relayGcClaimPath(remoteRelayDir: string): string {
return `${remoteRelayDir}${RELAY_GC_CLAIM_SUFFIX}`
}
export async function isRelayGcClaimed(
conn: SshConnection,
remoteRelayDir: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST,
signal?: AbortSignal
): Promise<boolean> {
const claimPath = relayGcClaimPath(remoteRelayDir)
const output = await execHostCommand(
conn,
host,
probeInstallLockExistsCommand(host, claimPath),
signal
)
const markers = new Set(
output
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line === 'LOCKED' || line === 'OPEN')
)
if (markers.size !== 1) {
throw new Error(`Inconclusive relay GC claim probe at ${claimPath}`)
}
return markers.has('LOCKED')
}
export async function tryAcquireRelayGcClaim(
conn: SshConnection,
remoteRelayDir: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST,
signal?: AbortSignal
): Promise<string | null> {
const claimPath = relayGcClaimPath(remoteRelayDir)
try {
const created = await execHostCommand(
conn,
host,
tryCreateInstallLockCommand(host, claimPath),
signal
)
if (created.trim().endsWith('OK')) {
return writeRelayGcClaimOwner(conn, remoteRelayDir, host, signal)
}
const stolen = await execHostCommand(
conn,
host,
tryStealInstallLockCommand(host, claimPath, RELAY_GC_CLAIM_STALE_SECONDS),
signal
)
if (!stolen.trim().endsWith('OK')) {
return null
}
return writeRelayGcClaimOwner(conn, remoteRelayDir, host, signal)
} catch {
signal?.throwIfAborted()
return null
}
}
async function writeRelayGcClaimOwner(
conn: SshConnection,
remoteRelayDir: string,
host: RemoteHostPlatform,
signal?: AbortSignal
): Promise<string | null> {
const token = `${process.pid}-${Date.now()}-${randomUUID()}`
const claimPath = relayGcClaimPath(remoteRelayDir)
const ownerPath = joinRemotePath(host, claimPath, RELAY_GC_OWNER_NAME)
const command = isWindowsRemoteHost(host)
? powerShellCommand(
`Set-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Value ${powerShellLiteral(token)} -NoNewline -ErrorAction Stop`
)
: `printf %s ${shellEscape(token)} > ${shellEscape(ownerPath)}`
try {
await execHostCommand(conn, host, command, signal)
return token
} catch {
// Why: the write may have succeeded remotely before SSH lost its reply.
// A conditional release removes only the claim generation with our token.
await releaseRelayGcClaim(conn, remoteRelayDir, token, host)
return null
}
}
export async function isRelayGcClaimOwned(
conn: SshConnection,
remoteRelayDir: string,
token: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST
): Promise<boolean> {
const ownerPath = joinRemotePath(host, relayGcClaimPath(remoteRelayDir), RELAY_GC_OWNER_NAME)
const command = isWindowsRemoteHost(host)
? powerShellCommand(
`if ((Get-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Raw -ErrorAction SilentlyContinue) -ceq ${powerShellLiteral(token)}) { 'OWNED' } else { 'LOST' }`
)
: `test "$(cat ${shellEscape(ownerPath)} 2>/dev/null)" = ${shellEscape(token)} && echo OWNED || echo LOST`
const output = await execHostCommand(conn, host, command).catch(() => 'LOST')
return output.trim() === 'OWNED'
}
export type RelayGcClaimReleaseResult = 'released' | 'lost' | 'unknown'
export async function releaseRelayGcClaim(
conn: SshConnection,
remoteRelayDir: string,
token: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST
): Promise<RelayGcClaimReleaseResult> {
const claimPath = relayGcClaimPath(remoteRelayDir)
const ownerPath = joinRemotePath(host, claimPath, RELAY_GC_OWNER_NAME)
const command = isWindowsRemoteHost(host)
? powerShellCommand(
`$claim = ${powerShellLiteral(claimPath)}; ` +
`if (-not (Test-Path -LiteralPath $claim)) { 'RELEASED' } ` +
`elseif ((Get-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Raw -ErrorAction SilentlyContinue) -cne ${powerShellLiteral(token)}) { 'LOST' } ` +
'else { try { Remove-Item -LiteralPath $claim -Recurse -Force -ErrorAction Stop } catch {}; ' +
"if (Test-Path -LiteralPath $claim) { 'UNKNOWN' } else { 'RELEASED' } }"
)
: [
`if ! test -e ${shellEscape(claimPath)}; then echo RELEASED;`,
`elif test "$(cat ${shellEscape(ownerPath)} 2>/dev/null)" != ${shellEscape(token)}; then echo LOST;`,
`else ${removeRemoteTreeCommand(host, claimPath)} 2>/dev/null;`,
`if test -e ${shellEscape(claimPath)}; then echo UNKNOWN; else echo RELEASED; fi; fi`
].join(' ')
const output = await execHostCommand(conn, host, command).catch(() => 'UNKNOWN')
switch (output.trim()) {
case 'RELEASED':
return 'released'
case 'LOST':
return 'lost'
default:
return 'unknown'
}
}
export async function releaseRelayGcClaimWithRetry(
conn: SshConnection,
remoteRelayDir: string,
token: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST
): Promise<RelayGcClaimReleaseResult> {
let result: RelayGcClaimReleaseResult = 'unknown'
for (let attempt = 0; attempt < 3 && result === 'unknown'; attempt++) {
result = await releaseRelayGcClaim(conn, remoteRelayDir, token, host)
}
return result
}
export async function waitForRelayGcClaimRelease(
conn: SshConnection,
remoteRelayDir: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST,
signal?: AbortSignal
): Promise<void> {
while (true) {
const claimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch(() => true)
signal?.throwIfAborted()
if (!claimed) {
return
}
const claimPath = relayGcClaimPath(remoteRelayDir)
const recovered = await execHostCommand(
conn,
host,
tryStealInstallLockCommand(host, claimPath, RELAY_GC_CLAIM_STALE_SECONDS),
signal
).catch(() => 'BUSY')
signal?.throwIfAborted()
if (recovered.trim().endsWith('OK')) {
const token = await writeRelayGcClaimOwner(conn, remoteRelayDir, host, signal)
if (token) {
const release = await releaseRelayGcClaim(conn, remoteRelayDir, token, host)
if (release === 'released') {
return
}
}
// A lost or uncertain release can mean a successor owns the stable path.
// Probe again instead of treating recovery as complete.
continue
}
await new Promise<void>((resolve, reject) => {
const onAbort = (): void => {
clearTimeout(timer)
reject(signal?.reason)
}
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve()
}, 1_000)
signal?.addEventListener('abort', onAbort, { once: true })
if (signal?.aborted) {
onAbort()
}
})
}
}
+275
View File
@@ -0,0 +1,275 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } }))
vi.mock('fs', () => ({
existsSync: vi.fn().mockReturnValue(true),
readFileSync: vi.fn().mockReturnValue('0.1.0+gc-retry')
}))
vi.mock('./relay-protocol', () => ({
RELAY_VERSION: '0.1.0',
RELAY_REMOTE_DIR: '.orca-remote',
parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'),
RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n',
RELAY_SENTINEL_TIMEOUT_MS: 10_000
}))
vi.mock('./ssh-relay-deploy-helpers', () => ({
uploadDirectory: vi.fn(),
waitForSentinel: vi.fn().mockResolvedValue({
write: vi.fn(),
onData: vi.fn(),
onClose: vi.fn()
}),
isUnconfirmedSshCommandTermination: () => false,
execCommand: vi.fn()
}))
vi.mock('./ssh-remote-node-resolution', () => ({
resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node')
}))
vi.mock('./ssh-relay-versioned-install', () => ({
readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+gc-retry'),
computeRemoteRelayDir: (home: string, version: string) => `${home}/.orca-remote/relay-${version}`,
isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true),
finalizeInstall: vi.fn(),
abandonInstall: vi.fn(),
gcOldRelayVersions: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('./ssh-relay-install-lock', () => ({ acquireInstallLock: vi.fn() }))
vi.mock('./ssh-relay-repair-lock', () => ({
tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired')
}))
vi.mock('./ssh-relay-gc-claim', () => ({
releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'),
tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'),
waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('./ssh-connection-utils', () => ({
shellEscape: (value: string) => `'${value}'`,
createSshOperationAbortError: () => Object.assign(new Error('cancelled'), { name: 'AbortError' })
}))
import { deployAndLaunchRelay } from './ssh-relay-deploy'
import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers'
import {
releaseRelayGcClaimWithRetry,
tryAcquireRelayGcClaim,
waitForRelayGcClaimRelease
} from './ssh-relay-gc-claim'
import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock'
import { abandonInstall, isRelayAlreadyInstalled } from './ssh-relay-versioned-install'
import type { SshConnection } from './ssh-connection'
function makeConnection(): SshConnection {
const channel = {
on: vi.fn(),
stderr: { on: vi.fn() },
close: vi.fn()
}
return {
canRunConcurrentExecCommands: vi.fn().mockReturnValue(true),
exec: vi.fn().mockResolvedValue(channel)
} as unknown as SshConnection
}
describe('relay GC deploy retry', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('recomputes install state when GC wins before a healthy relay launch', async () => {
const conn = makeConnection()
vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce('gc')
vi.mocked(execCommand).mockImplementation(async (_conn, command) => {
if (command.includes('__ORCA_REMOTE_PLATFORM__')) {
return '__ORCA_REMOTE_PLATFORM__ Linux x86_64'
}
if (command === 'echo $HOME') {
return '/home/user'
}
if (command.includes('node-pty')) {
return 'ORCA-NATIVE-DEPS-OK'
}
if (command.includes('var s=require("net").connect')) {
return 'READY'
}
if (command.includes('test -S')) {
return 'DEAD'
}
return ''
})
await deployAndLaunchRelay(conn)
expect(waitForRelayGcClaimRelease).toHaveBeenCalledTimes(1)
expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(3)
expect(conn.exec).toHaveBeenCalledTimes(2)
})
it('recomputes state when GC finishes between the install probe and lock acquisition', async () => {
const conn = makeConnection()
vi.mocked(isRelayAlreadyInstalled)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValue(true)
vi.mocked(execCommand).mockImplementation(async (_conn, command) => {
if (command.includes('__ORCA_REMOTE_PLATFORM__')) {
return '__ORCA_REMOTE_PLATFORM__ Linux x86_64'
}
if (command === 'echo $HOME') {
return '/home/user'
}
if (command.includes('node-pty')) {
return 'ORCA-NATIVE-DEPS-OK'
}
if (command.includes('var s=require("net").connect')) {
return 'READY'
}
if (command.includes('test -S')) {
return 'DEAD'
}
return ''
})
await deployAndLaunchRelay(conn)
expect(abandonInstall).toHaveBeenCalledTimes(2)
expect(waitForRelayGcClaimRelease).toHaveBeenCalledTimes(1)
expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(4)
expect(tryAcquireRelayRepairLock).toHaveBeenCalledTimes(2)
})
it('owns a GC claim through launch when the install-lock state is indeterminate', async () => {
const conn = makeConnection()
vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce('error')
let resolveReady: ((value: Awaited<ReturnType<typeof waitForSentinel>>) => void) | undefined
vi.mocked(waitForSentinel).mockImplementationOnce(
async () =>
new Promise<Awaited<ReturnType<typeof waitForSentinel>>>((resolve) => {
resolveReady = resolve
})
)
vi.mocked(execCommand).mockImplementation(async (_conn, command) => {
if (command.includes('__ORCA_REMOTE_PLATFORM__')) {
return '__ORCA_REMOTE_PLATFORM__ Linux x86_64'
}
if (command === 'echo $HOME') {
return '/home/user'
}
if (command.includes('node-pty')) {
return 'ORCA-NATIVE-DEPS-OK'
}
if (command.includes('var s=require("net").connect')) {
return 'READY'
}
if (command.includes('test -S')) {
return 'DEAD'
}
return ''
})
const deploy = deployAndLaunchRelay(conn)
await vi.waitFor(() => expect(resolveReady).toBeDefined())
expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled()
resolveReady?.({
write: vi.fn(),
onData: vi.fn(),
onClose: vi.fn()
})
await deploy
expect(waitForRelayGcClaimRelease).not.toHaveBeenCalled()
expect(tryAcquireRelayRepairLock).toHaveBeenCalledTimes(1)
expect(tryAcquireRelayGcClaim).toHaveBeenCalledTimes(1)
expect(releaseRelayGcClaimWithRetry).toHaveBeenCalledTimes(1)
expect(conn.exec).toHaveBeenCalledTimes(2)
})
it('retains an owned install lock when launch never becomes live', async () => {
const conn = makeConnection()
vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('launch failed'))
vi.mocked(execCommand).mockImplementation(async (_conn, command) => {
if (command.includes('__ORCA_REMOTE_PLATFORM__')) {
return '__ORCA_REMOTE_PLATFORM__ Linux x86_64'
}
if (command === 'echo $HOME') {
return '/home/user'
}
if (command.includes('node-pty')) {
return 'ORCA-NATIVE-DEPS-OK'
}
if (command.includes('var s=require("net").connect')) {
return 'READY'
}
if (command.includes('test -S')) {
return 'DEAD'
}
return ''
})
await expect(deployAndLaunchRelay(conn)).rejects.toThrow('launch failed')
expect(abandonInstall).not.toHaveBeenCalled()
expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled()
})
it('retains an owned launch claim when launch never becomes live', async () => {
const conn = makeConnection()
vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce('busy')
vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('launch failed'))
vi.mocked(execCommand).mockImplementation(async (_conn, command) => {
if (command.includes('__ORCA_REMOTE_PLATFORM__')) {
return '__ORCA_REMOTE_PLATFORM__ Linux x86_64'
}
if (command === 'echo $HOME') {
return '/home/user'
}
if (command.includes('node-pty')) {
return 'ORCA-NATIVE-DEPS-OK'
}
if (command.includes('var s=require("net").connect')) {
return 'READY'
}
if (command.includes('test -S')) {
return 'DEAD'
}
return ''
})
await expect(deployAndLaunchRelay(conn)).rejects.toThrow('launch failed')
expect(abandonInstall).not.toHaveBeenCalled()
expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled()
})
it('keeps retrying repeated launch-claim contention within the deploy bound', async () => {
const conn = makeConnection()
vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('busy')
vi.mocked(tryAcquireRelayGcClaim)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValue('launch-token')
vi.mocked(execCommand).mockImplementation(async (_conn, command) => {
if (command.includes('__ORCA_REMOTE_PLATFORM__')) {
return '__ORCA_REMOTE_PLATFORM__ Linux x86_64'
}
if (command === 'echo $HOME') {
return '/home/user'
}
if (command.includes('node-pty')) {
return 'ORCA-NATIVE-DEPS-OK'
}
if (command.includes('var s=require("net").connect')) {
return 'READY'
}
if (command.includes('test -S')) {
return 'DEAD'
}
return ''
})
await deployAndLaunchRelay(conn)
expect(waitForRelayGcClaimRelease).toHaveBeenCalledTimes(2)
expect(tryAcquireRelayGcClaim).toHaveBeenCalledTimes(3)
expect(releaseRelayGcClaimWithRetry).toHaveBeenCalledTimes(1)
})
})
+33
View File
@@ -0,0 +1,33 @@
import type { SshConnection } from './ssh-connection'
import { execCommand } from './ssh-relay-deploy-helpers'
import { removeRemoteTreeCommand } from './ssh-remote-commands'
import {
getRemoteHostPlatform,
isWindowsRemoteHost,
joinRemotePath,
type RemoteHostPlatform
} from './ssh-remote-platform'
const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64')
const RELAY_GC_TOMBSTONE_REGEX =
/^relay-(?:v?\d+\.\d+\.\d+(?:\+[0-9a-f]+)?)\.gc-tombstone\.\d+\.\d+$/
export async function cleanupRelayGcTombstones(
conn: SshConnection,
baseDir: string,
entries: string[],
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST
): Promise<void> {
// Why: a confirmed rename isolates these paths from any recreated install.
// Strictly named leftovers are safe to retry after an interrupted GC pass.
for (const name of entries.filter((entry) => RELAY_GC_TOMBSTONE_REGEX.test(entry))) {
const tombstone = joinRemotePath(host, baseDir, name)
await execCommand(conn, removeRemoteTreeCommand(host, tombstone), {
wrapCommand: !isWindowsRemoteHost(host)
}).catch((err) => {
console.warn(
`[ssh-relay] GC failed to remove tombstone ${tombstone}: ${err instanceof Error ? err.message : String(err)}`
)
})
}
}
@@ -0,0 +1,176 @@
import { shellEscape } from './ssh-connection-utils'
import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell'
import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform'
export function acquireInstallLockParentCommand(
host: RemoteHostPlatform,
remoteRelayDir: string
): string {
if (!isWindowsRemoteHost(host)) {
return `mkdir -p ${shellEscape(remoteRelayDir)}`
}
return powerShellCommand(
`$null = New-Item -ItemType Directory -Force -Path ${powerShellLiteral(remoteRelayDir)}`
)
}
export function tryCreateInstallLockCommand(host: RemoteHostPlatform, lockDir: string): string {
if (!isWindowsRemoteHost(host)) {
return `mkdir ${shellEscape(lockDir)} 2>&1 && echo OK || echo BUSY`
}
// Why: old Orca clients recognize only a directory at `.install-lock`, while
// concurrent New-Item calls can both report success in PowerShell 5.1. Keep
// that directory marker and arbitrate ownership with an atomic child file.
return powerShellCommand(
[
`$lock = ${powerShellLiteral(lockDir)}`,
'$stream = $null',
'try {',
"if (Test-Path -LiteralPath $lock) { 'BUSY' } else {",
'$null = New-Item -ItemType Directory -Path $lock -ErrorAction Stop',
"$owner = Join-Path $lock '.owner'",
'$stream = [System.IO.File]::Open($owner, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)',
"'OK'",
'}',
`} catch { 'BUSY' } finally { if ($null -ne $stream) { $stream.Dispose() } }`
].join('; ')
)
}
export function probeInstallLockExistsCommand(host: RemoteHostPlatform, lockPath: string): string {
if (!isWindowsRemoteHost(host)) {
return `test -e ${shellEscape(lockPath)} && echo LOCKED || echo OPEN`
}
// Why: one prerelease briefly wrote file locks; accept both shapes so those
// hosts remain recoverable after upgrading to directory-plus-owner locks.
return powerShellCommand(
`if (Test-Path -LiteralPath ${powerShellLiteral(lockPath)}) { 'LOCKED' } else { 'OPEN' }`
)
}
export function lockAgeSecondsCommand(host: RemoteHostPlatform, lockDir: string): string {
if (!isWindowsRemoteHost(host)) {
return `${posixLockAgeSecondsAssignment(lockDir)} && echo "$age" || echo`
}
return powerShellCommand(
[
`$item = Get-Item -LiteralPath ${powerShellLiteral(lockDir)} -ErrorAction Stop`,
'$mtime = ([DateTimeOffset]$item.LastWriteTimeUtc).ToUnixTimeSeconds()',
'$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()',
'Write-Output ($now - $mtime)'
].join('; ')
)
}
export function tryStealInstallLockCommand(
host: RemoteHostPlatform,
lockDir: string,
staleAfterSeconds: number
): string {
if (!isWindowsRemoteHost(host)) {
return posixStealInstallLockCommand(lockDir, staleAfterSeconds)
}
return windowsStealInstallLockCommand(lockDir, staleAfterSeconds)
}
function posixStealInstallLockCommand(lockDir: string, staleAfterSeconds: number): string {
const escapedLockDir = shellEscape(lockDir)
const escapedStealLockPrefix = shellEscape(`${lockDir}.steal`)
return [
`${posixLockIdentityAssignment(lockDir, 'lock_key')} && mtime=\${lock_key%%:*} && now=$(date +%s) && age=$((now - mtime)) || age=0;`,
`if [ "\${age:-0}" -le ${staleAfterSeconds} ] 2>/dev/null; then echo BUSY; else`,
`steal_root=${escapedStealLockPrefix};`,
'steal_generation=0;',
'steal="$steal_root.$steal_generation";',
'owns_steal=0;',
'lock_tombstone=;',
'while [ "$owns_steal" != 1 ]; do',
'if mkdir "$steal" 2>/dev/null; then owns_steal=1; break; fi;',
`steal_mtime=$(stat -c %Y "$steal" 2>/dev/null || stat -f %m "$steal" 2>/dev/null) && steal_now=$(date +%s) && steal_age=$((steal_now - steal_mtime)) || break;`,
'if [ "${steal_age:-0}" -le 120 ] 2>/dev/null; then break; fi;',
'steal_generation=$((steal_generation + 1));',
'steal="$steal_root.$steal_generation";',
'done;',
'if [ "$owns_steal" = 1 ]; then',
`trap 'rm -rf "$steal_root".* 2>/dev/null || true; rm -rf "$lock_tombstone" 2>/dev/null || true' EXIT;`,
`${posixLockIdentityAssignment(lockDir, 'current_key')} && current_mtime=\${current_key%%:*} && current_now=$(date +%s) && current_age=$((current_now - current_mtime)) || current_age=0;`,
`if [ "$current_key" = "$lock_key" ] && [ "\${current_age:-0}" -gt ${staleAfterSeconds} ] 2>/dev/null; then`,
`lock_tombstone=${escapedLockDir}.tombstone.$$.$(date +%s);`,
`if [ ! -e "$lock_tombstone" ] && mv ${escapedLockDir} "$lock_tombstone" 2>/dev/null; then mkdir ${escapedLockDir} 2>&1 && echo OK || echo BUSY; else echo BUSY; fi;`,
'else echo BUSY; fi;',
'else echo BUSY; fi; fi'
].join(' ')
}
function windowsStealInstallLockCommand(lockDir: string, staleAfterSeconds: number): string {
return powerShellCommand(
[
`$lock = ${powerShellLiteral(lockDir)}`,
'try {',
'$item = Get-Item -LiteralPath $lock -ErrorAction Stop',
'$mtime = ([DateTimeOffset]$item.LastWriteTimeUtc).ToUnixTimeSeconds()',
'$lockIdentity = "${mtime}:$($item.CreationTimeUtc.Ticks)"',
'$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()',
`if (($now - $mtime) -le ${staleAfterSeconds}) { 'BUSY' } else {`,
'$stealRoot = "$lock.steal"',
'$stealGeneration = 0',
'$steal = "$stealRoot.$stealGeneration"',
'$ownsSteal = $false',
'$lockTombstone = $null',
'try {',
'while (-not $ownsSteal) {',
'try {',
'$stealStream = [System.IO.File]::Open($steal, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)',
'$stealStream.Dispose()',
'$ownsSteal = $true',
'break',
'} catch {',
'try {',
'$stealItem = Get-Item -LiteralPath $steal -ErrorAction Stop',
'$stealMtime = ([DateTimeOffset]$stealItem.LastWriteTimeUtc).ToUnixTimeSeconds()',
'$stealAge = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $stealMtime',
'if ($stealAge -le 120) { break }',
'$stealGeneration++',
'$steal = "$stealRoot.$stealGeneration"',
'} catch { break }',
'}',
'}',
"if (-not $ownsSteal) { 'BUSY' } else {",
'$current = Get-Item -LiteralPath $lock -ErrorAction Stop',
'$currentMtime = ([DateTimeOffset]$current.LastWriteTimeUtc).ToUnixTimeSeconds()',
'$currentIdentity = "${currentMtime}:$($current.CreationTimeUtc.Ticks)"',
'$currentNow = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()',
`if (($currentIdentity -eq $lockIdentity) -and (($currentNow - $currentMtime) -gt ${staleAfterSeconds})) {`,
'$lockTombstone = "$lock.tombstone.$PID.$([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())"',
'Move-Item -LiteralPath $lock -Destination $lockTombstone -ErrorAction Stop',
'$successorStream = $null',
"try { $null = New-Item -ItemType Directory -Path $lock -ErrorAction Stop; $successorOwner = Join-Path $lock '.owner'; $successorStream = [System.IO.File]::Open($successorOwner, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None); 'OK' } catch { 'BUSY' } finally { if ($null -ne $successorStream) { $successorStream.Dispose() } }",
"} else { 'BUSY' }",
'}',
"} catch { 'BUSY' } finally {",
'if ($ownsSteal) {',
'$stealParent = Split-Path -Parent $stealRoot',
'$stealLeaf = Split-Path -Leaf $stealRoot',
'Get-ChildItem -LiteralPath $stealParent -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name.StartsWith($stealLeaf + ".", [StringComparison]::Ordinal) } | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue',
'}',
'if ($null -ne $lockTombstone) { Remove-Item -LiteralPath $lockTombstone -Recurse -Force -ErrorAction SilentlyContinue }',
'}',
'}',
"} catch { 'BUSY' }"
].join('; ')
)
}
function posixLockAgeSecondsAssignment(lockDir: string): string {
return `${posixLockMtimeSecondsAssignment(lockDir, 'mtime')} && now=$(date +%s) && age=$((now - mtime))`
}
function posixLockIdentityAssignment(lockDir: string, variableName: string): string {
const escapedLockDir = shellEscape(lockDir)
return `${variableName}=$(stat -c %Y:%i ${escapedLockDir} 2>/dev/null || stat -f %m:%i ${escapedLockDir} 2>/dev/null)`
}
function posixLockMtimeSecondsAssignment(lockDir: string, variableName: string): string {
const escapedLockDir = shellEscape(lockDir)
return `${variableName}=$(stat -c %Y ${escapedLockDir} 2>/dev/null || stat -f %m ${escapedLockDir} 2>/dev/null)`
}
+162
View File
@@ -0,0 +1,162 @@
import type { SshConnection } from './ssh-connection'
import { execCommand } from './ssh-relay-deploy-helpers'
import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing'
import { isRelayGcClaimed, waitForRelayGcClaimRelease } from './ssh-relay-gc-claim'
import {
acquireInstallLockParentCommand,
lockAgeSecondsCommand,
tryCreateInstallLockCommand,
tryStealInstallLockCommand
} from './ssh-relay-install-lock-commands'
import {
getRemoteHostPlatform,
joinRemotePath,
type RemoteHostPlatform
} from './ssh-remote-platform'
import { removeRemoteTreeCommand } from './ssh-remote-commands'
export const RELAY_INSTALL_LOCK_NAME = '.install-lock'
const INSTALL_LOCK_POLL_MS = 1_000
// Why: a fresh lock can cross the stale threshold during our bounded wait.
// Recheck infrequently so it becomes recoverable without adding an exec per poll.
const INSTALL_LOCK_STALE_RECHECK_MS = 60_000
// Why: the lock holder can legitimately use the full deploy bound for upload,
// native install/rebuild, probes, and finalization. A concurrent first install
// must not fail earlier; completed-relay repair uses a separate one-shot path.
const INSTALL_LOCK_TIMEOUT_MS = RELAY_DEPLOY_TIMEOUT_MS
// Why: native-deps repair can keep running after the deploy backstop wins its
// Promise.race, so stale takeover must leave room for that bounded work.
export const INSTALL_LOCK_STALE_MS = 20 * 60_000
export const INSTALL_LOCK_STALE_SECONDS = INSTALL_LOCK_STALE_MS / 1000
const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64')
function execHostCommand(
conn: SshConnection,
host: RemoteHostPlatform,
command: string,
options?: { signal?: AbortSignal }
): Promise<string> {
return execCommand(conn, command, {
wrapCommand: host.commandDialect !== 'powershell',
signal: options?.signal
})
}
export async function isRelayInstallLockStale(
conn: SshConnection,
lockDir: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST
): Promise<boolean> {
try {
// Why: remote time avoids clock skew between Orca clients making a live
// repair lock look old enough for GC or another installer to recover.
const out = await execHostCommand(conn, host, lockAgeSecondsCommand(host, lockDir))
const ageSec = Number.parseInt(out.trim(), 10)
return Number.isFinite(ageSec) && ageSec >= 0 && ageSec * 1000 > INSTALL_LOCK_STALE_MS
} catch {
return false
}
}
/**
* Acquire the per-version install lock via a host-native exclusive create.
* Why: POSIX mkdir and a Windows directory plus atomic owner file each give
* one winner while keeping the marker visible to older Windows Orca clients.
*/
export async function acquireInstallLock(
conn: SshConnection,
remoteRelayDir: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST,
options?: { signal?: AbortSignal }
): Promise<void> {
const lockDir = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME)
const start = Date.now()
let lastStaleCheckAt = Number.NEGATIVE_INFINITY
while (true) {
// Why: a crashed GC can leave the stable sibling claim behind. The shared
// waiter recovers stale claims instead of polling that orphan forever.
await waitForRelayGcClaimRelease(conn, remoteRelayDir, host, options?.signal)
options?.signal?.throwIfAborted()
await execHostCommand(conn, host, acquireInstallLockParentCommand(host, remoteRelayDir), {
signal: options?.signal
})
try {
const result = await execHostCommand(conn, host, tryCreateInstallLockCommand(host, lockDir), {
signal: options?.signal
})
if (result.trim().endsWith('OK')) {
// Why: GC may claim the sibling path between our first probe and lock
// creation. Recheck while holding the in-tree lock; one side backs off.
const claimedAfterAcquire = await isRelayGcClaimed(
conn,
remoteRelayDir,
host,
options?.signal
).catch(() => true)
if (!claimedAfterAcquire && !options?.signal?.aborted) {
return
}
await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {})
options?.signal?.throwIfAborted()
}
} catch {
options?.signal?.throwIfAborted()
// A failed mkdir is lock contention; keep the connection-specific error
// out of the user path until the bounded wait expires.
}
if (Date.now() - lastStaleCheckAt >= INSTALL_LOCK_STALE_RECHECK_MS) {
lastStaleCheckAt = Date.now()
// Why: recover an already-stale lock immediately, then keep checking in
// case a fresh holder crosses the stale threshold while we are waiting.
const steal = await execHostCommand(
conn,
host,
tryStealInstallLockCommand(host, lockDir, INSTALL_LOCK_STALE_SECONDS),
{ signal: options?.signal }
).catch(() => 'BUSY')
options?.signal?.throwIfAborted()
if (steal.trim().endsWith('OK')) {
console.warn(`[ssh-relay] Stealing stale install lock at ${lockDir}`)
const claimedAfterSteal = await isRelayGcClaimed(
conn,
remoteRelayDir,
host,
options?.signal
).catch(() => true)
if (!claimedAfterSteal && !options?.signal?.aborted) {
return
}
await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {})
options?.signal?.throwIfAborted()
}
}
if (Date.now() - start >= INSTALL_LOCK_TIMEOUT_MS) {
throw new Error(
`Could not acquire relay install lock at ${lockDir} after ${
INSTALL_LOCK_TIMEOUT_MS / 1000
}s; another install is still in progress.`
)
}
await waitForInstallLockPoll(options?.signal)
}
}
function waitForInstallLockPoll(signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const handleAbort = (): void => {
clearTimeout(timeout)
signal?.removeEventListener('abort', handleAbort)
reject(signal?.reason)
}
const timeout = setTimeout(() => {
signal?.removeEventListener('abort', handleAbort)
resolve()
}, INSTALL_LOCK_POLL_MS)
signal?.addEventListener('abort', handleAbort, { once: true })
if (signal?.aborted) {
handleAbort()
}
})
}
@@ -1,6 +1,6 @@
// Why: regression coverage for the install-probe contract. The original
// "node-pty is not available" bug shipped because every layer that should
// have caught it (chained shell, swallowing catch, dir-only probe) was
// have caught it (chained shell, swallowing catch, resolve-only probe) was
// silent. Tests below pin the parts that, individually, would have caught
// it.
@@ -30,6 +30,9 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({
onData: vi.fn(),
onClose: vi.fn()
}),
isUnconfirmedSshCommandTermination: (error: unknown) =>
error instanceof Error &&
(error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false,
execCommand: vi.fn()
}))
@@ -41,26 +44,41 @@ vi.mock('./ssh-relay-versioned-install', () => ({
readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'),
computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`,
isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false),
acquireInstallLock: vi.fn().mockResolvedValue(undefined),
finalizeInstall: vi.fn().mockResolvedValue(undefined),
abandonInstall: vi.fn().mockResolvedValue(undefined),
gcOldRelayVersions: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('./ssh-relay-install-lock', () => ({
acquireInstallLock: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('./ssh-relay-repair-lock', () => ({
tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired')
}))
vi.mock('./ssh-relay-gc-claim', () => ({
releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'),
tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'),
waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('./ssh-connection-utils', () => ({
shellEscape: (s: string) => `'${s}'`
}))
import { deployAndLaunchRelay } from './ssh-relay-deploy'
import { execCommand } from './ssh-relay-deploy-helpers'
import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers'
import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing'
import { parseUnameToRelayPlatform } from './relay-protocol'
import { resolveRemoteNodePath } from './ssh-remote-node-resolution'
import {
acquireInstallLock,
abandonInstall,
finalizeInstall,
isRelayAlreadyInstalled
} from './ssh-relay-versioned-install'
import { acquireInstallLock } from './ssh-relay-install-lock'
import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock'
import type { SshConnection } from './ssh-connection'
type SftpWriteCapture = {
@@ -129,6 +147,7 @@ function decodePowerShellCommand(command: string): string | null {
// 7: probe (cd && node -e require)
// [8: cat stderr — only when probe stdout is MISSING (graceful path)]
// 8 or 9: rm probe-stderr (best-effort cleanup; runs whenever probe resolved)
// [next: npm rebuild → chmod → second probe when the first probe is MISSING]
// next: socket DEAD next: socket READY
//
// When the probe rejects (SSH channel close or cd-failure when the install
@@ -143,6 +162,9 @@ function makeExecResponses(opts: {
// Override probe stdout for shell-noise pressure tests. If set, replaces
// the load-test stdout entirely (useful for testing pollution prefixes).
probeStdoutOverride?: string
// Result after the automatic rebuild. Defaults to missing so legacy tests
// continue to exercise the final degraded-mode warning.
repairProbe?: 'ok' | 'missing'
// Raw stdout for the build-toolchain probe that runs in installNativeDeps'
// catch when `npm install` rejects on Linux. Defaults to a fully-present
// toolchain so the original npm error propagates unchanged.
@@ -187,6 +209,16 @@ function makeExecResponses(opts: {
slots.push('') // cat stderr (graceful failure path captures detail)
}
slots.push('') // rm -f stderr (best-effort cleanup)
if (!probeOk) {
slots.push('') // npm rebuild with lifecycle scripts explicitly enabled
slots.push('') // chmod prebuilds after rebuild
const repairProbe = opts.repairProbe === 'ok' ? 'ORCA-NPTY-PROBE-OK\n' : 'MISSING\n'
slots.push(repairProbe)
if (!repairProbe.includes('ORCA-NPTY-PROBE-OK')) {
slots.push('') // cat stderr after unsuccessful rebuild
}
slots.push('') // rm -f stderr after rebuild probe
}
}
slots.push('DEAD', 'READY')
return slots
@@ -207,6 +239,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => {
// a leaked response. clearAllMocks doesn't drop the queue (it only clears
// .mock.calls), so we explicitly mockReset.
vi.mocked(execCommand).mockReset()
vi.mocked(uploadDirectory).mockResolvedValue(undefined)
sftpCapture.paths.length = 0
for (const k of Object.keys(sftpCapture.contents)) {
delete sftpCapture.contents[k]
@@ -256,12 +289,17 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => {
// break `require('node-pty')`.
expect(parsed.type).toBe('commonjs')
expect(parsed.dependencies).toEqual({ '@parcel/watcher': '2.5.6', 'node-pty': '1.1.0' })
expect(parsed.allowScripts).toEqual({
'@parcel/watcher@2.5.6': true,
'node-pty@1.1.0': true
})
const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c)
const npmInstallIdx = execCalls.findIndex(
(c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher')
)
expect(npmInstallIdx).toBeGreaterThanOrEqual(0)
expect(execCalls[npmInstallIdx]).toContain('--ignore-scripts=false')
// Pin actual ordering: number of execCommand calls observed at the moment
// ws.end() ran for package.json must be < the index of `npm install`.
// Catches a future refactor that fires SFTP-write and npm install via
@@ -376,7 +414,96 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => {
expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true)
expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1)
expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled()
expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1)
})
it('rebuilds unloadable native deps and recovers before first relay launch', async () => {
const conn = makeMockConnection(sftpCapture)
feed(makeExecResponses({ npmInstall: 'ok', probe: 'missing', repairProbe: 'ok' }))
await deployAndLaunchRelay(conn)
const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c)
const failedProbeIdx = execCalls.findIndex((c) => c.includes('require("node-pty")'))
const rebuildIdx = execCalls.findIndex((c) => c.includes('npm rebuild'))
const repairedProbeIdx = execCalls.findIndex(
(c, index) => index > rebuildIdx && c.includes('require("node-pty")')
)
expect(rebuildIdx).toBeGreaterThan(failedProbeIdx)
expect(execCalls[rebuildIdx]).toContain('--ignore-scripts=false')
expect(repairedProbeIdx).toBeGreaterThan(rebuildIdx)
const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? ''))
expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false)
expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1)
})
it('propagates an SSH-channel failure from the post-rebuild re-probe', async () => {
// Why: the rebuild itself degrades gracefully, but the verification probe
// after it must still surface transport death — conflating a dead channel
// with "native deps missing" would finalize a half-repaired install.
const conn = makeMockConnection(sftpCapture)
feed([
'__ORCA_REMOTE_PLATFORM__ Linux x86_64', // uname
'/home/u', // $HOME
'', // mkdir remoteDir (uploadRelay)
'', // chmod +x node
'', // npm install native deps
'', // chmod prebuilds
'MISSING\n', // first probe: require() fails
'', // cat probe stderr
'', // rm probe stderr
'', // npm rebuild native deps
'', // chmod prebuilds after rebuild
{ reject: 'SSH channel closed during native deps re-probe' } // re-probe rejects
])
await expect(deployAndLaunchRelay(conn)).rejects.toThrow(/SSH channel closed/)
// Rebuild failure is swallowed; a re-probe transport failure must not be.
const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? ''))
expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false)
expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled()
expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1)
})
it('aborts an in-progress native install and releases its lock at deploy timeout', async () => {
vi.useFakeTimers()
try {
const conn = makeMockConnection(sftpCapture)
feed([
'__ORCA_REMOTE_PLATFORM__ Linux x86_64',
'/home/u',
'', // mkdir remoteDir
'' // chmod +x node
])
let installSignal: AbortSignal | undefined
vi.mocked(execCommand).mockImplementationOnce((_conn, command, options) => {
expect(command).toContain('npm install')
installSignal = options?.signal
return new Promise<string>((_resolve, reject) => {
installSignal?.addEventListener('abort', () => reject(installSignal?.reason), {
once: true
})
})
})
const promise = deployAndLaunchRelay(conn).catch((err: Error) => err)
await vi.waitFor(() => expect(installSignal).toBeDefined())
await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS)
const result = await promise
expect(result).toBeInstanceOf(Error)
expect((result as Error).message).toBe(
`Relay deployment timed out after ${RELAY_DEPLOY_TIMEOUT_MS / 1000}s`
)
expect(installSignal?.aborted).toBe(true)
expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1)
expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('lets a probe SSH-channel failure bubble up rather than silently mapping to MISSING', async () => {
@@ -479,9 +606,9 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => {
expect(chmodPrebuildsIdx).toBeGreaterThan(npmIdx)
expect(probeIdx).toBeGreaterThan(chmodPrebuildsIdx)
// Happy path: finalize exactly once, abandon never.
// Hold the install lock through launch, then release it exactly once.
expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1)
expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled()
expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1)
})
it('matches the sentinel even with bashrc/MOTD noise prefixed to probe stdout', async () => {
@@ -522,7 +649,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => {
const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? ''))
expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true)
expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1)
expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled()
expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1)
})
it('keeps Windows node-pty probe failures non-fatal by checking LASTEXITCODE', async () => {
@@ -535,7 +662,8 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => {
'', // mkdir remoteDir
'', // npm install native deps
'MISSING\n', // native process exit normalized by PowerShell command
'', // remove probe stderr file
'', // npm rebuild native deps
'MISSING\n', // rebuilt native process still cannot load
'', // no persisted active pipe marker
'WAITING',
'', // WMI relay launch
@@ -554,11 +682,29 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => {
const probeScript = decodePowerShellCommand(probeCommand) ?? ''
expect(probeScript).toContain('$LASTEXITCODE -ne 0')
expect(probeScript).toContain("'MISSING'")
expect(probeScript).toContain('loadNativeModule')
const npmScripts = vi
.mocked(execCommand)
.mock.calls.map(([, command]) => decodePowerShellCommand(command) ?? '')
.filter((script) => script.includes('npm install') || script.includes('npm rebuild'))
expect(npmScripts).toHaveLength(2)
expect(npmScripts.every((script) => script.includes('--ignore-scripts=false'))).toBe(true)
expect(
npmScripts.every((script) =>
script.includes('if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }')
)
).toBe(true)
expect(
vi
.mocked(execCommand)
.mock.calls.some(([, command]) => command.includes('.npty-probe.stderr'))
).toBe(false)
const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? ''))
expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true)
expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1)
expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled()
expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1)
})
it('includes the platform tuple in NPTY-MISSING and native install failure logs', async () => {
@@ -607,8 +753,8 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => {
feed([
'__ORCA_REMOTE_PLATFORM__ Linux x86_64',
'/home/u',
'MISSING', // first native-deps probe before lock
'MISSING', // re-probe after lock
'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // first probe before lock
'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // re-probe after lock
'', // npm install native deps
'', // chmod prebuilds
'ORCA-NPTY-PROBE-OK\n',
@@ -619,7 +765,10 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => {
await deployAndLaunchRelay(conn)
expect(vi.mocked(acquireInstallLock)).toHaveBeenCalledTimes(1)
expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1)
expect(vi.mocked(tryAcquireRelayRepairLock).mock.calls[0]?.[3]?.signal).toBeInstanceOf(
AbortSignal
)
expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1)
const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c)
expect(
@@ -627,6 +776,228 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => {
(c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher')
)
).toBe(true)
const installCommand = execCalls.find((c) => c.includes('npm install')) ?? ''
expect(installCommand).toContain('node_modules/@parcel/watcher')
expect(installCommand).toContain("-name 'watcher-*'")
expect(installCommand).not.toContain("rm -rf 'node_modules/node-pty'")
})
it('launches an already-installed relay in degraded mode when repair throws', async () => {
// Why: a repair failure on a completed dir (e.g. offline/proxy-locked npm)
// must not block the connection — the relay still serves fs/git/preflight.
// Pre-fix this rethrew and aborted the whole deploy. The next reconnect
// retries, so a transient failure self-heals.
vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true)
const conn = makeMockConnection(sftpCapture)
feed([
'__ORCA_REMOTE_PLATFORM__ Linux x86_64',
'/home/u',
'MISSING', // health probe: require() fails
'MISSING', // re-probe after lock
{ reject: 'npm ERR! network ETIMEDOUT' }, // npm install fails (offline)
'DEAD',
'READY'
])
// Deploy must resolve (degraded), not reject.
await deployAndLaunchRelay(conn)
expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled()
expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1)
const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? ''))
expect(warnMessages.some((m) => m.includes('launching degraded'))).toBe(true)
})
it('retains the repair lock when remote command termination is unconfirmed', async () => {
vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true)
const conn = makeMockConnection(sftpCapture)
vi.mocked(execCommand)
.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
.mockResolvedValueOnce('/home/u')
.mockResolvedValueOnce('MISSING')
.mockResolvedValueOnce('MISSING')
.mockRejectedValueOnce(
Object.assign(new Error('npm termination was not confirmed'), {
sshChannelCloseConfirmed: false
})
)
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('READY')
await deployAndLaunchRelay(conn)
expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled()
const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? ''))
expect(warnMessages.some((message) => message.includes('launching degraded'))).toBe(true)
})
it('retains the first-install lock when an aborted npm install has unconfirmed teardown', async () => {
vi.useFakeTimers()
try {
const conn = makeMockConnection(sftpCapture)
feed([
'__ORCA_REMOTE_PLATFORM__ Linux x86_64',
'/home/u',
'', // mkdir remoteDir
'' // chmod +x node
])
let installSignal: AbortSignal | undefined
vi.mocked(execCommand).mockImplementationOnce((_conn, command, options) => {
expect(command).toContain('npm install')
installSignal = options?.signal
return new Promise<string>((_resolve, reject) => {
installSignal?.addEventListener(
'abort',
() => {
// Mirrors execCommand's bounded close grace when ssh2 never
// confirms that the remote npm process stopped.
setTimeout(
() =>
reject(
Object.assign(new Error('npm teardown remained unconfirmed'), {
sshChannelCloseConfirmed: false
})
),
5_000
)
},
{ once: true }
)
})
})
const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err)
await vi.waitFor(() => expect(installSignal).toBeDefined())
await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS)
const result = await deploy
expect(result).toBeInstanceOf(Error)
expect((result as Error).message).toContain('Relay deployment timed out')
await vi.advanceTimersByTimeAsync(5_000)
expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled()
expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('does not finalize or release a first-install lock after unconfirmed rebuild teardown', async () => {
const conn = makeMockConnection(sftpCapture)
vi.mocked(execCommand)
.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
.mockResolvedValueOnce('/home/u')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('MISSING')
.mockResolvedValueOnce('rebuild diagnostics')
.mockResolvedValueOnce('')
.mockRejectedValueOnce(
Object.assign(new Error('rebuild termination was not confirmed'), {
sshChannelCloseConfirmed: false
})
)
await expect(deployAndLaunchRelay(conn)).rejects.toThrow(
'rebuild termination was not confirmed'
)
expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled()
expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled()
})
it('retains the first-install lock when an aborted rebuild has unconfirmed teardown', async () => {
vi.useFakeTimers()
try {
const conn = makeMockConnection(sftpCapture)
feed([
'__ORCA_REMOTE_PLATFORM__ Linux x86_64',
'/home/u',
'', // mkdir remoteDir
'', // chmod +x node
'', // npm install
'', // chmod prebuilds
'MISSING',
'rebuild diagnostics',
'' // remove probe diagnostics
])
let rebuildSignal: AbortSignal | undefined
vi.mocked(execCommand).mockImplementationOnce((_conn, command, options) => {
expect(command).toContain('npm rebuild')
rebuildSignal = options?.signal
return new Promise<string>((_resolve, reject) => {
rebuildSignal?.addEventListener(
'abort',
() => {
setTimeout(
() =>
reject(
Object.assign(new Error('rebuild teardown remained unconfirmed'), {
sshChannelCloseConfirmed: false
})
),
5_000
)
},
{ once: true }
)
})
})
const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err)
await vi.waitFor(() => expect(rebuildSignal).toBeDefined())
await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS)
const result = await deploy
expect(result).toBeInstanceOf(Error)
expect((result as Error).message).toContain('Relay deployment timed out')
await vi.advanceTimersByTimeAsync(5_000)
expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled()
expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it.each(['busy', 'error'] as const)('launches degraded when lock is %s', async (lockResult) => {
// Why: lock contention/wedge must not block a completed relay from launching
// in degraded mode — repair is best-effort and we hold no lock to release.
vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true)
vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce(lockResult)
const conn = makeMockConnection(sftpCapture)
feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', 'MISSING', 'DEAD', 'READY'])
await deployAndLaunchRelay(conn)
expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled()
expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled()
const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c)
expect(execCalls.some((c) => c.includes('npm install'))).toBe(false)
const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? ''))
expect(warnMessages.some((m) => m.includes(`repair lock is ${lockResult}`))).toBe(true)
})
it('loads native bindings when checking whether a completed relay needs repair', async () => {
vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true)
const conn = makeMockConnection(sftpCapture)
feed([
'__ORCA_REMOTE_PLATFORM__ Linux x86_64',
'/home/u',
'ORCA-NATIVE-DEPS-OK',
'DEAD',
'READY'
])
await deployAndLaunchRelay(conn)
const healthProbe = vi
.mocked(execCommand)
.mock.calls.map(([, c]) => c)
.find((c) => c.includes('ORCA-NATIVE-DEPS-OK'))
expect(healthProbe).toContain('require("node-pty")')
expect(healthProbe).toContain('loadNativeModule')
expect(healthProbe).toContain('require("@parcel/watcher")')
expect(healthProbe).not.toContain('require.resolve')
})
it('does not mutate an existing relay dir when required native deps are present', async () => {
@@ -643,7 +1014,9 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => {
await deployAndLaunchRelay(conn)
expect(vi.mocked(acquireInstallLock)).not.toHaveBeenCalled()
expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1)
expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled()
expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1)
const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c)
expect(execCalls.some((c) => c.includes('npm install'))).toBe(false)
})
+153
View File
@@ -0,0 +1,153 @@
import type { SshConnection } from './ssh-connection'
import { execCommand } from './ssh-relay-deploy-helpers'
import {
acquireInstallLockParentCommand,
lockAgeSecondsCommand,
probeInstallLockExistsCommand,
tryCreateInstallLockCommand,
tryStealInstallLockCommand
} from './ssh-relay-install-lock-commands'
import { isRelayGcClaimed } from './ssh-relay-gc-claim'
import {
getRemoteHostPlatform,
isWindowsRemoteHost,
joinRemotePath,
type RemoteHostPlatform
} from './ssh-remote-platform'
import { INSTALL_LOCK_STALE_SECONDS, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock'
import { removeRemoteTreeCommand } from './ssh-remote-commands'
const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64')
export type RelayRepairLockResult = 'acquired' | 'busy' | 'gc' | 'error'
function execHostCommand(
conn: SshConnection,
host: RemoteHostPlatform,
command: string,
signal?: AbortSignal
): Promise<string> {
return execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(host), signal })
}
/**
* Try once to acquire the install lock for best-effort repair work.
*
* Why: a completed relay can launch in degraded mode, so repair must not wait
* behind another installer. An already-stale lock is still recovered now.
*/
export async function tryAcquireRelayRepairLock(
conn: SshConnection,
remoteRelayDir: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST,
options?: { signal?: AbortSignal }
): Promise<RelayRepairLockResult> {
const lockDir = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME)
try {
const gcClaimedBeforeAcquire = await isRelayGcClaimed(
conn,
remoteRelayDir,
host,
options?.signal
).catch(() => undefined)
options?.signal?.throwIfAborted()
if (gcClaimedBeforeAcquire === true) {
return 'gc'
}
if (gcClaimedBeforeAcquire !== false) {
return 'error'
}
await execHostCommand(
conn,
host,
acquireInstallLockParentCommand(host, remoteRelayDir),
options?.signal
)
const firstAttempt = await execHostCommand(
conn,
host,
tryCreateInstallLockCommand(host, lockDir),
options?.signal
)
if (firstAttempt.trim().endsWith('OK')) {
return finishRepairLockAcquire(conn, remoteRelayDir, lockDir, host, options?.signal)
}
const steal = await execHostCommand(
conn,
host,
tryStealInstallLockCommand(host, lockDir, INSTALL_LOCK_STALE_SECONDS),
options?.signal
)
if (steal.trim().endsWith('OK')) {
console.warn(`[ssh-relay] Stealing stale install lock at ${lockDir}`)
return finishRepairLockAcquire(conn, remoteRelayDir, lockDir, host, options?.signal)
}
return classifyRepairLockContention(conn, remoteRelayDir, lockDir, host, options?.signal)
} catch {
options?.signal?.throwIfAborted()
return classifyRepairLockContention(conn, remoteRelayDir, lockDir, host, options?.signal)
}
}
async function classifyRepairLockContention(
conn: SshConnection,
remoteRelayDir: string,
lockDir: string,
host: RemoteHostPlatform,
signal?: AbortSignal
): Promise<RelayRepairLockResult> {
const gcClaimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch(
() => undefined
)
signal?.throwIfAborted()
if (gcClaimed === true) {
return 'gc'
}
if (gcClaimed !== false) {
return 'error'
}
const lockProbe = await execHostCommand(
conn,
host,
probeInstallLockExistsCommand(host, lockDir),
signal
).catch(() => '')
signal?.throwIfAborted()
if (lockProbe.trim() !== 'LOCKED') {
return 'error'
}
const ageOutput = await execHostCommand(
conn,
host,
lockAgeSecondsCommand(host, lockDir),
signal
).catch(() => '')
signal?.throwIfAborted()
const ageSeconds = Number.parseInt(ageOutput.trim(), 10)
// Why: GC may remove stale locks, so only a positively observed fresh lock
// proves that another launch/repair owner is fencing this directory.
return Number.isFinite(ageSeconds) && ageSeconds >= 0 && ageSeconds <= INSTALL_LOCK_STALE_SECONDS
? 'busy'
: 'error'
}
async function finishRepairLockAcquire(
conn: SshConnection,
remoteRelayDir: string,
lockDir: string,
host: RemoteHostPlatform,
signal?: AbortSignal
): Promise<RelayRepairLockResult> {
const gcClaimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch(
() => undefined
)
if (gcClaimed === false && !signal?.aborted) {
return 'acquired'
}
// Why: GC may win its stable sibling claim while this command creates the
// in-tree lock. Back out before npm can mutate a directory being renamed.
await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {})
signal?.throwIfAborted()
return gcClaimed ? 'gc' : 'error'
}
+585 -47
View File
@@ -18,11 +18,19 @@ import {
readLocalFullVersion,
computeRemoteRelayDir,
isRelayAlreadyInstalled,
acquireInstallLock,
finalizeInstall,
abandonInstall,
gcOldRelayVersions
} from './ssh-relay-versioned-install'
import { acquireInstallLock } from './ssh-relay-install-lock'
import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock'
import {
isRelayGcClaimed,
relayGcClaimPath,
releaseRelayGcClaim,
tryAcquireRelayGcClaim,
waitForRelayGcClaimRelease
} from './ssh-relay-gc-claim'
import { execCommand } from './ssh-relay-deploy-helpers'
import { getRemoteHostPlatform } from './ssh-remote-platform'
import type { SshConnection } from './ssh-connection'
@@ -88,6 +96,18 @@ describe('isRelayAlreadyInstalled', () => {
expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(false)
})
it('does not convert an aborted install probe into a missing install', async () => {
const controller = new AbortController()
mockExec.mockImplementationOnce(async () => {
controller.abort()
throw Object.assign(new Error('cancelled'), { name: 'AbortError' })
})
await expect(
isRelayAlreadyInstalled(conn, '/r', undefined, { signal: controller.signal })
).rejects.toMatchObject({ name: 'AbortError' })
})
it('keeps default probe failures as not installed for SSH session-limit-shaped errors', async () => {
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
reason: 4
@@ -124,26 +144,130 @@ describe('acquireInstallLock', () => {
})
it('returns when mkdir reports OK', async () => {
// 1st call: mkdir -p remoteRelayDir
// 2nd call: mkdir lockDir → OK
mockExec.mockResolvedValueOnce('').mockResolvedValueOnce('OK')
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('OPEN')
await acquireInstallLock(conn, '/r')
expect(mockExec).toHaveBeenCalledTimes(2)
expect(mockExec).toHaveBeenCalledTimes(4)
})
it('recovers a stale sibling GC claim before first-install lock acquisition', async () => {
mockExec
.mockResolvedValueOnce('LOCKED')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('RELEASED')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('OPEN')
await expect(acquireInstallLock(conn, '/r')).resolves.toBeUndefined()
const commands = mockExec.mock.calls.map(([, command]) => command)
expect(commands[1]).toContain('lock_tombstone')
expect(commands[4]).toBe("mkdir -p '/r'")
})
it('returns immediately without deleting a live repair lock', async () => {
// Why: repair can run npm install plus rebuild under the same lock; a
// second reconnect must launch degraded rather than corrupt node_modules.
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('BUSY')
.mockResolvedValueOnce('BUSY')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('LOCKED')
.mockResolvedValueOnce('0')
await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('busy')
const commands = mockExec.mock.calls.map(([, command]) => command)
expect(commands.some((command) => command.includes('lock_tombstone'))).toBe(true)
expect(commands.filter((command) => command.startsWith('rm -rf'))).toHaveLength(0)
})
it('does not report stale or indeterminate contention as a launch fence', async () => {
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('BUSY')
.mockResolvedValueOnce('BUSY')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('LOCKED')
.mockResolvedValueOnce(`${21 * 60}`)
await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('error')
mockExec.mockReset().mockRejectedValueOnce(new Error('claim probe failed'))
await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('error')
})
it('recovers a stale best-effort repair lock without polling', async () => {
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('BUSY')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('OPEN')
await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('acquired')
const commands = mockExec.mock.calls.map(([, command]) => command)
expect(commands.some((command) => command.includes('lock_tombstone'))).toBe(true)
expect(commands.filter((command) => command.includes('lock_tombstone'))).toHaveLength(1)
})
it('backs out when GC claims the sibling path during repair lock acquisition', async () => {
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('LOCKED')
.mockResolvedValueOnce('')
await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('gc')
const lastCommand = mockExec.mock.calls.at(-1)?.[1] ?? ''
expect(lastCommand).toBe("rm -rf '/r/.install-lock'")
})
it('propagates cancellation through best-effort repair lock commands', async () => {
const abortController = new AbortController()
const abortError = Object.assign(new Error('cancelled'), { name: 'AbortError' })
mockExec.mockRejectedValueOnce(abortError)
const promise = tryAcquireRelayRepairLock(conn, '/r', undefined, {
signal: abortController.signal
})
abortController.abort()
await expect(promise).rejects.toMatchObject({ name: 'AbortError' })
expect(mockExec.mock.calls[0]?.[2]?.signal).toBe(abortController.signal)
})
it('polls until the lock becomes available (concurrent installer wins, then we acquire)', async () => {
vi.useFakeTimers()
try {
// Sequence:
// 1. mkdir -p (parent dir prep)
// 2. mkdir lockDir → BUSY (someone else holds it)
// 3. mkdir lockDir → BUSY again
// 4. mkdir lockDir → OK (concurrent installer released)
mockExec
.mockResolvedValueOnce('')
.mockResolvedValueOnce('BUSY')
.mockResolvedValueOnce('BUSY')
.mockResolvedValueOnce('OK')
let createAttempts = 0
mockExec.mockImplementation(async (_conn: unknown, command: string) => {
if (command.includes('.gc-claim')) {
return 'OPEN'
}
if (command.startsWith('mkdir -p')) {
return ''
}
if (command.includes('lock_tombstone')) {
return 'BUSY'
}
if (command.includes('.install-lock')) {
createAttempts++
return createAttempts >= 3 ? 'OK' : 'BUSY'
}
return ''
})
const promise = acquireInstallLock(conn, '/r')
// Drive the polling loop: each iteration awaits a 1s timer.
@@ -159,36 +283,48 @@ describe('acquireInstallLock', () => {
}
})
it('steals a stale lock and retries with a reset timeout window', async () => {
it('immediately tries to steal an already-stale lock', async () => {
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('BUSY')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('OPEN')
await expect(acquireInstallLock(conn, '/r')).resolves.toBeUndefined()
const cmds = mockExec.mock.calls.map(([, c]) => c)
expect(cmds).toHaveLength(5)
expect(cmds[3]).toContain('lock_tombstone')
})
it('retries stale takeover when a fresh lock ages out during the wait', async () => {
vi.useFakeTimers({ now: 1_700_000_000_000 })
try {
let mkdirCalls = 0
const recoverableAt = Date.now() + 60_000
mockExec.mockImplementation(async (_conn: unknown, cmd: string) => {
if (cmd.includes('.gc-claim')) {
return 'OPEN'
}
if (cmd.startsWith('mkdir -p')) {
return ''
}
if (cmd.includes('lock_tombstone')) {
return Date.now() >= recoverableAt ? 'OK' : 'BUSY'
}
if (cmd.includes('mkdir') && cmd.includes('.install-lock')) {
mkdirCalls++
return mkdirCalls > 200 ? 'OK' : 'BUSY'
}
if (cmd.includes('stat')) {
return `${Math.floor((Date.now() - 10 * 60 * 1000) / 1000)}\n`
}
if (cmd.startsWith('rm -rf')) {
mkdirCalls = 1000
return ''
return 'BUSY'
}
return ''
})
const promise = acquireInstallLock(conn, '/r')
// Drive through the full timeout (120s) so the stale-recovery branch
// fires, then drive a few more seconds for the post-recovery retry.
await vi.advanceTimersByTimeAsync(125_000)
await promise
await vi.advanceTimersByTimeAsync(61_000)
const cmds = mockExec.mock.calls.map(([, c]) => c)
expect(cmds.some((c) => c.includes('rm -rf') && c.includes('.install-lock'))).toBe(true)
await expect(promise).resolves.toBeUndefined()
expect(mockExec.mock.calls.filter(([, cmd]) => cmd.includes('lock_tombstone'))).toHaveLength(
2
)
} finally {
vi.useRealTimers()
}
@@ -198,26 +334,86 @@ describe('acquireInstallLock', () => {
vi.useFakeTimers({ now: 1_700_000_000_000 })
try {
mockExec.mockImplementation(async (_conn: unknown, cmd: string) => {
if (cmd.includes('.gc-claim')) {
return 'OPEN'
}
if (cmd.startsWith('mkdir -p')) {
return ''
}
if (cmd.includes('lock_tombstone')) {
return 'BUSY'
}
if (cmd.includes('mkdir') && cmd.includes('.install-lock')) {
return 'BUSY'
}
if (cmd.includes('stat')) {
return `${Math.floor(Date.now() / 1000)}\n`
return '0\n'
}
return ''
})
const rejection = expect(acquireInstallLock(conn, '/r')).rejects.toThrow(/not yet stale/i)
await vi.advanceTimersByTimeAsync(125_000)
const rejection = expect(acquireInstallLock(conn, '/r')).rejects.toThrow(
/another install is still in progress/i
)
await vi.advanceTimersByTimeAsync(905_000)
await rejection
} finally {
vi.useRealTimers()
}
})
it('stops polling when the caller aborts the lock wait', async () => {
vi.useFakeTimers()
try {
const abortController = new AbortController()
mockExec.mockImplementation(async (_conn: unknown, cmd: string) =>
cmd.includes('.gc-claim') ? 'OPEN' : 'BUSY'
)
const promise = acquireInstallLock(conn, '/r', undefined, {
signal: abortController.signal
})
await vi.advanceTimersByTimeAsync(1_000)
abortController.abort()
await expect(promise).rejects.toMatchObject({ name: 'AbortError' })
const callCountAfterAbort = mockExec.mock.calls.length
await vi.advanceTimersByTimeAsync(10_000)
expect(mockExec).toHaveBeenCalledTimes(callCountAfterAbort)
} finally {
vi.useRealTimers()
}
})
it('keeps waiting while a slow first installer is within the deploy bound', async () => {
vi.useFakeTimers({ now: 1_700_000_000_000 })
try {
const availableAt = Date.now() + 500_000
mockExec.mockImplementation(async (_conn: unknown, cmd: string) => {
if (cmd.includes('.gc-claim')) {
return 'OPEN'
}
if (cmd.startsWith('mkdir -p')) {
return ''
}
if (cmd.includes('mkdir') && cmd.includes('.install-lock')) {
return Date.now() >= availableAt ? 'OK' : 'BUSY'
}
return ''
})
const promise = acquireInstallLock(conn, '/r')
await vi.advanceTimersByTimeAsync(501_000)
await expect(promise).resolves.toBeUndefined()
expect(
mockExec.mock.calls.filter(([, cmd]) => cmd.includes('lock_tombstone')).length
).toBeGreaterThan(1)
} finally {
vi.useRealTimers()
}
})
it('finalizeInstall writes .install-complete then removes the lock', async () => {
mockExec.mockResolvedValueOnce('').mockResolvedValueOnce('')
await finalizeInstall(conn, '/r')
@@ -238,26 +434,220 @@ describe('acquireInstallLock', () => {
})
})
describe('relay GC claim', () => {
beforeEach(() => {
vi.clearAllMocks()
mockExec.mockReset()
})
it('parses explicit claim markers through remote startup noise', async () => {
mockExec.mockResolvedValueOnce('Welcome to host\nLOCKED\n')
await expect(isRelayGcClaimed(conn, '/relay/version')).resolves.toBe(true)
mockExec.mockResolvedValueOnce('Last login: today\nOPEN\n')
await expect(isRelayGcClaimed(conn, '/relay/version')).resolves.toBe(false)
})
it('rejects missing or conflicting claim markers', async () => {
mockExec.mockResolvedValueOnce('Welcome to host\n')
await expect(isRelayGcClaimed(conn, '/relay/version')).rejects.toThrow('Inconclusive')
mockExec.mockResolvedValueOnce('LOCKED\nOPEN\n')
await expect(isRelayGcClaimed(conn, '/relay/version')).rejects.toThrow('Inconclusive')
})
it('uses a stable sibling path outside the recursively deleted install', async () => {
mockExec.mockResolvedValueOnce('OK').mockResolvedValueOnce('')
await expect(tryAcquireRelayGcClaim(conn, '/relay/version')).resolves.toEqual(
expect.any(String)
)
expect(relayGcClaimPath('/relay/version')).toBe('/relay/version.gc-claim')
expect(mockExec.mock.calls[0]?.[1]).toContain("mkdir '/relay/version.gc-claim'")
})
it('recovers and removes a stale sibling claim before retrying deploy', async () => {
mockExec
.mockResolvedValueOnce('LOCKED')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('RELEASED')
await waitForRelayGcClaimRelease(conn, '/relay/version')
const commands = mockExec.mock.calls.map(([, command]) => command)
expect(commands[1]).toContain('lock_tombstone')
expect(commands[3]).toContain("rm -rf '/relay/version.gc-claim'")
})
it('keeps waiting after losing a recovered claim until the claim is actually gone', async () => {
mockExec
.mockResolvedValueOnce('LOCKED')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('LOST')
.mockResolvedValueOnce('OPEN')
await waitForRelayGcClaimRelease(conn, '/relay/version')
expect(mockExec).toHaveBeenCalledTimes(5)
})
it('writes and conditionally releases the Windows sibling claim owner token', async () => {
const windows = getRemoteHostPlatform('win32-x64')
mockExec.mockResolvedValueOnce('OK').mockResolvedValueOnce('').mockResolvedValueOnce('RELEASED')
const token = await tryAcquireRelayGcClaim(conn, 'C:/relay/version', windows)
expect(token).toEqual(expect.any(String))
await expect(releaseRelayGcClaim(conn, 'C:/relay/version', token!, windows)).resolves.toBe(
'released'
)
const ownerScript = decodePowerShellCommand(mockExec.mock.calls[1]?.[1] ?? '')
const releaseScript = decodePowerShellCommand(mockExec.mock.calls[2]?.[1] ?? '')
expect(ownerScript).toContain('Set-Content -LiteralPath')
expect(ownerScript).toContain('.gc-claim/.gc-owner')
expect(releaseScript).toContain('Get-Content -LiteralPath')
expect(releaseScript).toContain('-cne')
expect(releaseScript).toContain('Remove-Item -LiteralPath')
expect(releaseScript).toContain("'RELEASED'")
expect(releaseScript).toContain("'LOST'")
expect(releaseScript).toContain("'UNKNOWN'")
expect(releaseScript).not.toContain('}; elseif')
expect(releaseScript).not.toContain('{;')
})
it('conditionally releases a claim when the owner write reply is lost', async () => {
mockExec
.mockResolvedValueOnce('OK')
.mockRejectedValueOnce(new Error('owner write reply lost'))
.mockResolvedValueOnce('RELEASED')
await expect(tryAcquireRelayGcClaim(conn, '/relay/version')).resolves.toBeNull()
const ownerCommand = mockExec.mock.calls[1]?.[1] ?? ''
const releaseCommand = mockExec.mock.calls[2]?.[1] ?? ''
const token = ownerCommand.match(/printf %s '([^']+)'/)?.[1]
expect(token).toBeTruthy()
expect(releaseCommand).toContain(`!= '${token}'`)
})
})
describe('gcOldRelayVersions', () => {
beforeEach(() => {
vi.clearAllMocks()
mockExec.mockReset()
})
it('removes a sibling that is complete, unlocked, and has no live socket', async () => {
// ls listing
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+bbb\n')
// For sibling "aaa": LOCKED probe → OPEN, COMPLETE probe → COMPLETE, sock probe → empty (no ALIVE), then rm -rf
// For sibling "aaa": safety probes pass, then GC claims the install lock before removal.
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OWNED')
.mockResolvedValueOnce('MOVED')
.mockResolvedValueOnce('RELEASED')
.mockResolvedValueOnce('')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const lastCmd = mockExec.mock.calls.at(-1)?.[1] ?? ''
expect(lastCmd).toContain('rm -rf')
expect(lastCmd).toContain('relay-0.1.0+aaa')
expect(lastCmd).toContain('relay-0.1.0+aaa.gc-tombstone')
const commands = mockExec.mock.calls.map(([, command]) => command)
expect(
commands.some((command) => command === "rm -rf '/home/u/.orca-remote/relay-0.1.0+aaa'")
).toBe(false)
})
it('cleans only strict POSIX orphan tombstones even with no relay candidates', async () => {
mockExec
.mockResolvedValueOnce(
[
'relay-0.1.0+abc.gc-tombstone.123.456',
'relay-0.1.0+abc.gc-tombstone.bad.456',
'xrelay-0.1.0+abc.gc-tombstone.123.456',
'relay-0.1.0+abc.gc-tombstone.123.456.extra',
'relay-0.1.0+abc.gc-tombstone.123.456/child',
'logs'
].join('\n')
)
.mockResolvedValueOnce('')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const removeCommands = mockExec.mock.calls
.map(([, command]) => command)
.filter((command) => command.startsWith('rm -rf'))
expect(removeCommands).toEqual([
"rm -rf '/home/u/.orca-remote/relay-0.1.0+abc.gc-tombstone.123.456'"
])
})
it('cleans only strict Windows orphan tombstones even with no relay candidates', async () => {
const windows = getRemoteHostPlatform('win32-x64')
mockExec
.mockResolvedValueOnce(
'relay-v0.1.0.gc-tombstone.123.456\nrelay-v0.1.0.gc-tombstone.latest.456\n'
)
.mockResolvedValueOnce('')
await gcOldRelayVersions(conn, 'C:/Users/u', 'C:/Users/u/.orca-remote/relay-0.1.0+bbb', windows)
const removeScript = decodePowerShellCommand(mockExec.mock.calls[1]?.[1] ?? '')
expect(removeScript).toContain('relay-v0.1.0.gc-tombstone.123.456')
expect(removeScript).not.toContain('relay-v0.1.0.gc-tombstone.latest.456')
})
it('retries orphan tombstone cleanup on a later GC pass', async () => {
const tombstone = 'relay-0.1.0+abc.gc-tombstone.123.456'
mockExec
.mockResolvedValueOnce(tombstone)
.mockRejectedValueOnce(new Error('remove failed'))
.mockResolvedValueOnce(tombstone)
.mockResolvedValueOnce('')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const removeCommands = mockExec.mock.calls
.map(([, command]) => command)
.filter((command) => command.startsWith('rm -rf'))
expect(removeCommands).toHaveLength(2)
})
it('retries only an unknown claim release and stops after observing a lost generation', async () => {
mockExec
.mockResolvedValueOnce('relay-0.1.0+aaa\n')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OWNED')
.mockResolvedValueOnce('MOVED')
.mockResolvedValueOnce('UNKNOWN')
.mockResolvedValueOnce('LOST')
.mockResolvedValueOnce('')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const releaseCommands = mockExec.mock.calls
.map(([, command]) => command)
.filter((command) => command.includes('.gc-owner') && command.includes('echo RELEASED'))
expect(releaseCommands).toHaveLength(2)
})
it('skips siblings that are missing .install-complete (mid-install or partial)', async () => {
@@ -273,8 +663,8 @@ describe('gcOldRelayVersions', () => {
it('skips siblings whose .install-lock is held and fresh', async () => {
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
mockExec.mockResolvedValueOnce('LOCKED')
// isLockStale: mtime ~now → not stale.
mockExec.mockResolvedValueOnce(`${Math.floor(Date.now() / 1000)}\n`)
// isLockStale: age ~now → not stale.
mockExec.mockResolvedValueOnce('0\n')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const cmds = mockExec.mock.calls.map(([, c]) => c)
expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false)
@@ -283,12 +673,20 @@ describe('gcOldRelayVersions', () => {
it('removes a sibling with a stale lock + .install-complete (rm-lock failed mid-finalize)', async () => {
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
mockExec.mockResolvedValueOnce('LOCKED')
// isLockStale: mtime well in the past → stale.
const staleSec = Math.floor((Date.now() - 10 * 60 * 1000) / 1000)
mockExec.mockResolvedValueOnce(`${staleSec}\n`)
// isLockStale: age well above the stale window → stale.
mockExec.mockResolvedValueOnce(`${21 * 60}\n`)
mockExec.mockResolvedValueOnce('COMPLETE') // .install-complete present
mockExec.mockResolvedValueOnce('') // socket probe → no ALIVE
mockExec.mockResolvedValueOnce('') // rm -rf
mockExec.mockResolvedValueOnce('DEAD') // socket probe
mockExec.mockResolvedValueOnce('OK') // stable sibling GC claim
mockExec.mockResolvedValueOnce('') // write claim ownership token
mockExec.mockResolvedValueOnce('LOCKED')
mockExec.mockResolvedValueOnce(`${21 * 60}\n`)
mockExec.mockResolvedValueOnce('COMPLETE')
mockExec.mockResolvedValueOnce('DEAD')
mockExec.mockResolvedValueOnce('OWNED')
mockExec.mockResolvedValueOnce('MOVED')
mockExec.mockResolvedValueOnce('RELEASED') // release sibling claim
mockExec.mockResolvedValueOnce('') // remove tombstone
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const lastCmd = mockExec.mock.calls.at(-1)?.[1] ?? ''
expect(lastCmd).toContain('rm -rf')
@@ -298,8 +696,15 @@ describe('gcOldRelayVersions', () => {
it('GCs a legacy relay-v0.1.0 dir whose daemon is dead (no .install-complete required)', async () => {
mockExec.mockResolvedValueOnce('relay-v0.1.0\n')
mockExec.mockResolvedValueOnce('OPEN') // not locked
mockExec.mockResolvedValueOnce('') // socket probe → no ALIVE (no completeProbe — legacy)
mockExec.mockResolvedValueOnce('') // rm -rf
mockExec.mockResolvedValueOnce('DEAD') // socket probe (no completeProbe — legacy)
mockExec.mockResolvedValueOnce('OK') // GC claims candidate
mockExec.mockResolvedValueOnce('')
mockExec.mockResolvedValueOnce('OPEN')
mockExec.mockResolvedValueOnce('DEAD')
mockExec.mockResolvedValueOnce('OWNED')
mockExec.mockResolvedValueOnce('MOVED')
mockExec.mockResolvedValueOnce('RELEASED')
mockExec.mockResolvedValueOnce('')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const cmds = mockExec.mock.calls.map(([, c]) => c)
expect(cmds.some((c) => c.includes('rm -rf') && c.includes('relay-v0.1.0'))).toBe(true)
@@ -327,6 +732,32 @@ describe('gcOldRelayVersions', () => {
expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false)
})
it('keeps a sibling when the install-lock probe fails or returns unexpected output', async () => {
mockExec
.mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+ccc\n')
.mockRejectedValueOnce(new Error('lock probe failed'))
.mockResolvedValueOnce('INCONCLUSIVE')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
expect(mockExec).toHaveBeenCalledTimes(3)
})
it('keeps a sibling when the relay-liveness probe fails or returns unexpected output', async () => {
mockExec
.mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+ccc\n')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockRejectedValueOnce(new Error('liveness probe failed'))
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('INCONCLUSIVE')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
expect(mockExec).toHaveBeenCalledTimes(7)
})
it('probes Windows GC liveness by connecting to named pipes, not process command lines', async () => {
const windows = getRemoteHostPlatform('win32-x64')
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
@@ -334,6 +765,14 @@ describe('gcOldRelayVersions', () => {
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('WAITING')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('WAITING')
.mockResolvedValueOnce('OWNED')
.mockResolvedValueOnce('MOVED')
.mockResolvedValueOnce('RELEASED')
.mockResolvedValueOnce('')
await gcOldRelayVersions(
@@ -366,14 +805,113 @@ describe('gcOldRelayVersions', () => {
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OWNED')
.mockResolvedValueOnce('MOVED')
.mockResolvedValueOnce('RELEASED')
.mockResolvedValueOnce('')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const cmds = mockExec.mock.calls.map(([, c]) => c)
const rmCmds = cmds.filter((c) => c.includes('rm -rf'))
const rmCmds = cmds.filter((c) => c.startsWith('rm') && c.includes('gc-tombstone'))
expect(rmCmds).toHaveLength(1)
expect(rmCmds[0]).toContain('relay-0.1.0+aaa')
expect(rmCmds[0]).not.toContain('logs')
expect(rmCmds[0]).not.toContain('backup')
})
it('does not remove a candidate claimed by repair after the initial lock probe', async () => {
mockExec
.mockResolvedValueOnce('relay-0.1.0+aaa\n')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD') // socket probe
.mockResolvedValueOnce('OK') // GC sibling claim
.mockResolvedValueOnce('') // write claim ownership token
.mockResolvedValueOnce('LOCKED') // repair won before the safety recheck
.mockResolvedValueOnce('0')
.mockResolvedValueOnce('RELEASED') // release GC sibling claim
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const commands = mockExec.mock.calls.map(([, command]) => command)
expect(
commands.some(
(command) =>
command.startsWith("rm -rf '/home/u/.orca-remote/relay-0.1.0+aaa'") &&
!command.includes('.install-lock')
)
).toBe(false)
})
it('does not move a candidate after losing the sibling claim generation', async () => {
mockExec
.mockResolvedValueOnce('relay-0.1.0+aaa\n')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('LOST')
.mockResolvedValueOnce('LOST')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const commands = mockExec.mock.calls.map(([, command]) => command)
expect(commands.some((command) => command.startsWith('mv '))).toBe(false)
})
it('releases the GC claim after a confirmed move failure', async () => {
mockExec
.mockResolvedValueOnce('relay-0.1.0+aaa\n')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OWNED')
.mockRejectedValueOnce(new Error('move failed'))
.mockResolvedValueOnce('RELEASED')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const lastCommand = mockExec.mock.calls.at(-1)?.[1] ?? ''
expect(lastCommand).toContain('rm -rf')
expect(lastCommand).toContain('relay-0.1.0+aaa.gc-claim')
})
it('keeps the GC claim when remote move termination is unconfirmed', async () => {
const unconfirmed = Object.assign(new Error('move timed out'), {
sshChannelCloseConfirmed: false
})
mockExec
.mockResolvedValueOnce('relay-0.1.0+aaa\n')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OWNED')
.mockRejectedValueOnce(unconfirmed)
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const releaseCommands = mockExec.mock.calls
.map(([, command]) => command)
.filter((command) => command.includes('relay-0.1.0+aaa.gc-claim') && command.startsWith('rm'))
expect(releaseCommands).toHaveLength(0)
})
})
+91 -118
View File
@@ -15,16 +15,21 @@ import { existsSync, readFileSync } from 'node:fs'
import type { SshConnection } from './ssh-connection'
import { RELAY_REMOTE_DIR } from './relay-protocol'
import { execCommand } from './ssh-relay-deploy-helpers'
import { probeInstallLockExistsCommand } from './ssh-relay-install-lock-commands'
import { isRelayInstallLockStale, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock'
import {
isRelayGcClaimOwned,
releaseRelayGcClaimWithRetry,
tryAcquireRelayGcClaim
} from './ssh-relay-gc-claim'
import { cleanupRelayGcTombstones } from './ssh-relay-gc-tombstone'
import {
acquireInstallLockParentCommand,
listRelayBaseDirsCommand,
lockMtimeEpochCommand,
probeDirectoryExistsCommand,
moveRemoteTreeCommand,
probeFileExistsCommand,
probeRelayInstalledCommand,
relayLivenessProbeCommand,
removeRemoteTreeCommand,
tryCreateInstallLockCommand,
writeRemoteEmptyFileCommand
} from './ssh-remote-commands'
import {
@@ -36,6 +41,7 @@ import {
type RemotePathFlavor
} from './ssh-remote-platform'
import { windowsRelayPipePathsForSocketName } from './ssh-relay-endpoints'
import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command'
import { isSshSessionLimitError } from './ssh-session-limit-error'
// Why: the GC pass and the version-dir parser must agree on what counts as a
@@ -51,18 +57,7 @@ const RELAY_VERSION_DIR_REGEX = /^relay-(v?\d+\.\d+\.\d+(\+[0-9a-f]+)?)$/
// living on remote disks forever.
const LEGACY_RELAY_DIR_REGEX = /^relay-v\d+\.\d+\.\d+$/
const INSTALL_LOCK_NAME = '.install-lock'
const INSTALL_COMPLETE_NAME = '.install-complete'
const INSTALL_LOCK_POLL_MS = 1_000
const INSTALL_LOCK_TIMEOUT_MS = 120_000
// Why: a stale lock dir from a crashed installer must be recoverable without
// user intervention. After the timeout we check the lock's mtime; if it's
// older than this window the previous installer is assumed dead and we steal
// the lock. 2 minutes is well above a normal `npm install node-pty` runtime
// (10–60s on slow hosts) so a slow concurrent installer is not falsely
// declared dead.
const INSTALL_LOCK_STALE_MS = 120_000
const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64')
type RelayInstalledProbeOptions = {
@@ -145,6 +140,7 @@ export async function isRelayAlreadyInstalled(
)
return probe.trim() === 'OK'
} catch (err) {
options?.signal?.throwIfAborted()
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
throw err
}
@@ -153,101 +149,27 @@ export async function isRelayAlreadyInstalled(
}
/**
* Acquire the per-version install lock via atomic `mkdir`. Returns when the
* caller owns the lock; throws if the lock could not be acquired within
* INSTALL_LOCK_TIMEOUT_MS even after one stale-lock recovery attempt.
*
* Why mkdir: POSIX `mkdir` is atomic and fails with EEXIST if the dir already
* exists, giving us a free mutex. A second concurrent caller polls and
* eventually either acquires the lock or steals it after the stale window.
*/
export async function acquireInstallLock(
conn: SshConnection,
remoteRelayDir: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST
): Promise<void> {
const lockDir = joinRemotePath(host, remoteRelayDir, INSTALL_LOCK_NAME)
// Why: the parent dir may not exist yet on a first install. mkdir -p is
// safe to run multiple times — it's a no-op if the dir already exists.
await execHostCommand(conn, host, acquireInstallLockParentCommand(host, remoteRelayDir))
let start = Date.now()
let recoveredOnce = false
while (true) {
try {
const result = await execHostCommand(conn, host, tryCreateInstallLockCommand(host, lockDir))
if (result.trim().endsWith('OK')) {
return
}
} catch {
/* mkdir failed with non-zero — fall through to BUSY treatment */
}
if (Date.now() - start >= INSTALL_LOCK_TIMEOUT_MS) {
if (recoveredOnce) {
throw new Error(
`Could not acquire relay install lock at ${lockDir} after ${
INSTALL_LOCK_TIMEOUT_MS / 1000
}s; another install is in progress or the lock is wedged.`
)
}
// Stale-lock recovery: if the lock dir's mtime is older than the stale
// window, the previous installer crashed. Steal it and retry once,
// resetting the timeout window so a single post-recovery race doesn't
// immediately exhaust the budget.
const ageOk = await isLockStale(conn, lockDir, host)
if (ageOk) {
console.warn(`[ssh-relay] Stealing stale install lock at ${lockDir}`)
await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {})
recoveredOnce = true
start = Date.now()
continue
}
throw new Error(
`Could not acquire relay install lock at ${lockDir} after ${
INSTALL_LOCK_TIMEOUT_MS / 1000
}s and the lock is not yet stale.`
)
}
await new Promise((r) => setTimeout(r, INSTALL_LOCK_POLL_MS))
}
}
async function isLockStale(
conn: SshConnection,
lockDir: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST
): Promise<boolean> {
try {
// Why: `stat` flags differ between GNU coreutils (Linux) and BSD (macOS).
// We try GNU first, then BSD; both produce a Unix epoch in seconds on
// stdout. If both fail we conservatively treat the lock as not stale.
const out = await execHostCommand(conn, host, lockMtimeEpochCommand(host, lockDir))
const mtimeSec = Number.parseInt(out.trim(), 10)
if (!Number.isFinite(mtimeSec)) {
return false
}
const ageMs = Date.now() - mtimeSec * 1000
return ageMs > INSTALL_LOCK_STALE_MS
} catch {
return false
}
}
/**
* Mark the install as complete and release the lock. Sentinel ordering is:
* write `.install-complete` FIRST, then remove `.install-lock`. This ensures
* a sibling dir is never observed by GC as "complete but locked", which
* would lead GC to skip a recoverable dir indefinitely.
* Mark the install as complete, then normally release the lock. Deploy keeps
* the lock through first launch so cross-version GC cannot move the directory
* between finalization and daemon liveness becoming observable.
*/
export async function finalizeInstall(
conn: SshConnection,
remoteRelayDir: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST,
options?: { signal?: AbortSignal; releaseLock?: boolean }
): Promise<void> {
const sentinel = joinRemotePath(host, remoteRelayDir, INSTALL_COMPLETE_NAME)
const lock = joinRemotePath(host, remoteRelayDir, INSTALL_LOCK_NAME)
await execHostCommand(conn, host, writeRemoteEmptyFileCommand(host, sentinel))
await execHostCommand(conn, host, removeRemoteTreeCommand(host, lock)).catch(() => {})
const lock = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME)
await execHostCommand(conn, host, writeRemoteEmptyFileCommand(host, sentinel), {
signal: options?.signal
})
if (options?.releaseLock !== false) {
await execHostCommand(conn, host, removeRemoteTreeCommand(host, lock), {
signal: options?.signal
}).catch(() => {})
}
options?.signal?.throwIfAborted()
}
/**
@@ -260,7 +182,7 @@ export async function abandonInstall(
remoteRelayDir: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST
): Promise<void> {
const lock = joinRemotePath(host, remoteRelayDir, INSTALL_LOCK_NAME)
const lock = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME)
await execHostCommand(conn, host, removeRemoteTreeCommand(host, lock)).catch(() => {})
}
@@ -295,10 +217,14 @@ export async function gcOldRelayVersions(
} catch {
return
}
const candidates = listing
const entries = listing
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
await cleanupRelayGcTombstones(conn, baseDir, entries, host)
const candidates = entries
.filter((name) => RELAY_VERSION_DIR_REGEX.test(name))
.filter((name) => name !== currentDirName)
@@ -316,7 +242,47 @@ export async function gcOldRelayVersions(
kept.push(name)
continue
}
await execHostCommand(conn, host, removeRemoteTreeCommand(host, dir))
// Why: the claim is a sibling, so it survives moving/deleting the
// candidate and lets installers back out before mutating the old path.
const gcClaimToken = await tryAcquireRelayGcClaim(conn, dir, host)
if (!gcClaimToken) {
kept.push(name)
continue
}
let preserveGcClaim = false
let gcClaimReleaseNeeded = true
try {
// Recheck under the stable claim. New installers probe the claim both
// before and after creating their in-tree lock, closing both orders.
if (!(await isCandidateSafeToRemove(conn, dir, name, host, options))) {
kept.push(name)
continue
}
if (!(await isRelayGcClaimOwned(conn, dir, gcClaimToken, host))) {
kept.push(name)
continue
}
const tombstone = `${dir}.gc-tombstone.${process.pid}.${Date.now()}`
const moved = await execHostCommand(conn, host, moveRemoteTreeCommand(host, dir, tombstone))
if (moved.trim() !== 'MOVED') {
kept.push(name)
continue
}
// Once renamed, a fresh install at the original path is isolated from
// deletion of the tombstone, so the sibling claim can be released.
const release = await releaseRelayGcClaimWithRetry(conn, dir, gcClaimToken, host)
gcClaimReleaseNeeded = release === 'unknown'
await execHostCommand(conn, host, removeRemoteTreeCommand(host, tombstone))
} catch (err) {
if (isUnconfirmedSshCommandTermination(err)) {
preserveGcClaim = true
}
throw err
} finally {
if (!preserveGcClaim && gcClaimReleaseNeeded) {
await releaseRelayGcClaimWithRetry(conn, dir, gcClaimToken, host)
}
}
removed.push(name)
} catch (err) {
console.warn(
@@ -346,23 +312,28 @@ async function isCandidateSafeToRemove(
): Promise<boolean> {
const isLegacy = LEGACY_RELAY_DIR_REGEX.test(name)
const lockDir = joinRemotePath(host, dir, INSTALL_LOCK_NAME)
const lockProbe = await execHostCommand(
conn,
host,
probeDirectoryExistsCommand(host, lockDir)
).catch(() => 'OPEN')
const locked = lockProbe.trim() === 'LOCKED'
const lockDir = joinRemotePath(host, dir, RELAY_INSTALL_LOCK_NAME)
let lockProbe: string
try {
lockProbe = await execHostCommand(conn, host, probeInstallLockExistsCommand(host, lockDir))
} catch {
return false
}
const lockState = lockProbe.trim()
if (lockState !== 'OPEN' && lockState !== 'LOCKED') {
return false
}
const locked = lockState === 'LOCKED'
if (locked) {
// Why: a locked dir is normally unsafe to remove — but a STALE lock
// (mtime older than INSTALL_LOCK_STALE_MS) means the previous installer
// (remote age older than INSTALL_LOCK_STALE_MS) means the previous installer
// crashed and is never coming back. If the dir also has the
// .install-complete sentinel (touch succeeded but the rm-lock at the
// end of finalizeInstall failed), removing the dir is safe — no
// installer is racing us, and the daemon (if any) keeps running off
// its already-loaded code regardless of disk state.
if (!(await isLockStale(conn, lockDir, host))) {
if (!(await isRelayInstallLockStale(conn, lockDir, host))) {
return false
}
process.stderr.write?.(`[ssh-relay] GC: lock at ${lockDir} is stale; treating as recoverable\n`)
@@ -419,8 +390,10 @@ async function hasLiveRelaySocket(
host,
relayLivenessProbeCommand(host, dir, windowsOptions)
)
return out.includes('ALIVE')
const state = out.trim()
return state !== 'DEAD' && state !== 'WAITING'
} catch {
return false
// Why: an inconclusive liveness probe must never authorize deletion.
return true
}
}
+329 -6
View File
@@ -1,24 +1,109 @@
import { execFileSync, spawn, spawnSync } from 'node:child_process'
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
rmSync,
statSync,
utimesSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
lockAgeSecondsCommand,
tryCreateInstallLockCommand,
tryStealInstallLockCommand
} from './ssh-relay-install-lock-commands'
import {
commandInRemoteDirectory,
commandWithNodePath,
listRelayBaseDirsCommand,
makeRemoteDirectoryCommand,
moveRemoteTreeCommand,
probeDirectoryExistsCommand,
probeRelayInstalledCommand,
readRemoteHomeCommand,
relayLivenessProbeCommand,
tryCreateInstallLockCommand
relayLivenessProbeCommand
} from './ssh-remote-commands'
import { getRemoteHostPlatform } from './ssh-remote-platform'
const posix = getRemoteHostPlatform('linux-x64')
const windows = getRemoteHostPlatform('win32-x64')
const powerShellExecutable = (
process.platform === 'win32' ? ['pwsh.exe', 'powershell.exe'] : ['pwsh']
).find((candidate) => {
const result = spawnSync(candidate, ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], {
stdio: 'ignore'
})
return result.status === 0
})
const powerShell51Executable =
process.platform === 'win32' &&
spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], {
stdio: 'ignore'
}).status === 0
? 'powershell.exe'
: undefined
function decodePowerShellCommand(command: string): string {
const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)
return match ? Buffer.from(match[1], 'base64').toString('utf16le') : ''
}
function runShellCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn('/bin/sh', ['-c', command], {
stdio: ['ignore', 'pipe', 'pipe']
})
let stdout = ''
let stderr = ''
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk) => {
stdout += chunk
})
child.stderr.on('data', (chunk) => {
stderr += chunk
})
child.on('error', reject)
child.on('close', (code) => {
if (code === 0) {
resolve(stdout)
return
}
reject(new Error(`shell exited ${code}: ${stderr}`))
})
})
}
function runPowerShellCommand(executable: string, script: string): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(executable, ['-NoProfile', '-NonInteractive', '-Command', script], {
stdio: ['ignore', 'pipe', 'pipe']
})
let stdout = ''
let stderr = ''
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk) => {
stdout += chunk
})
child.stderr.on('data', (chunk) => {
stderr += chunk
})
child.on('error', reject)
child.on('close', (code) => {
if (code === 0) {
resolve(stdout)
return
}
reject(new Error(`PowerShell exited ${code}: ${stderr}`))
})
})
}
describe('ssh remote command builders', () => {
it('keeps POSIX deploy commands POSIX-native', () => {
expect(readRemoteHomeCommand(posix)).toBe('echo $HOME')
@@ -34,7 +119,7 @@ describe('ssh remote command builders', () => {
expect(probeRelayInstalledCommand(windows, 'C:/Users/me/relay')).toContain('-EncodedCommand')
})
it('uses -Path for Windows New-Item commands', () => {
it('uses a legacy-visible Windows lock directory with an exclusive owner file', () => {
const mkdirScript = decodePowerShellCommand(
makeRemoteDirectoryCommand(windows, 'C:/Users/me/.orca-remote')
)
@@ -43,9 +128,31 @@ describe('ssh remote command builders', () => {
)
expect(mkdirScript).toContain('New-Item -ItemType Directory -Force -Path')
expect(lockScript).toContain('New-Item -ItemType Directory -Path')
expect(lockScript).toContain('[System.IO.FileMode]::CreateNew')
expect(lockScript).toContain('[System.IO.FileShare]::None')
expect(lockScript).toContain('New-Item -ItemType Directory -Path $lock')
expect(lockScript).toContain("Join-Path $lock '.owner'")
expect(mkdirScript).not.toContain('New-Item -ItemType Directory -Force -LiteralPath')
expect(lockScript).not.toContain('New-Item -ItemType Directory -LiteralPath')
})
it('moves GC candidates to a sibling tombstone with host-native commands', () => {
expect(moveRemoteTreeCommand(posix, '/relay/old', '/relay/old.gc-tombstone')).toContain(
"mv '/relay/old' '/relay/old.gc-tombstone'"
)
const windowsScript = decodePowerShellCommand(
moveRemoteTreeCommand(windows, 'C:/Users/me/relay/old', 'C:/Users/me/relay/old.gc-tombstone')
)
expect(windowsScript).toContain('Move-Item -LiteralPath')
expect(windowsScript).toContain("-Destination 'C:/Users/me/relay/old.gc-tombstone'")
expect(windowsScript).toContain("'MOVED'")
})
it('emits an explicit POSIX liveness result so GC can fail closed', () => {
const command = relayLivenessProbeCommand(posix, '/home/u/.orca-remote/relay-0.1.0')
expect(command).toContain('state=DEAD')
expect(command).toContain('[ -S "$f" ] && state=ALIVE')
expect(command).toContain('echo "$state"')
})
it('uses named pipe try-connect liveness for Windows GC', () => {
@@ -96,11 +203,227 @@ describe('ssh remote command builders', () => {
tryCreateInstallLockCommand(windows, 'C:/Users/me/.orca-remote/relay/.install-lock')
)
expect(script).toContain('$ErrorActionPreference = "Stop"; try {')
expect(script).toContain('$stream = $null; try {')
expect(script).toContain("} catch { 'BUSY' }")
expect(script).not.toContain('}; catch')
})
it('computes install-lock age on the remote host clock', () => {
const posixCommand = lockAgeSecondsCommand(posix, '/home/me/.orca-remote/relay/.install-lock')
const windowsScript = decodePowerShellCommand(
lockAgeSecondsCommand(windows, 'C:/Users/me/.orca-remote/relay/.install-lock')
)
expect(posixCommand).toContain('date +%s')
expect(posixCommand).toContain('echo "$age"')
expect(windowsScript).toContain('[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()')
expect(windowsScript).toContain('Write-Output ($now - $mtime)')
})
it('serializes stale recovery with unbounded numbered sibling claims', () => {
const posixCommand = tryStealInstallLockCommand(
posix,
'/home/me/.orca-remote/relay/.install-lock',
20 * 60
)
const windowsScript = decodePowerShellCommand(
tryStealInstallLockCommand(windows, 'C:/Users/me/.orca-remote/relay/.install-lock', 20 * 60)
)
expect(posixCommand).toContain('.install-lock')
expect(posixCommand).toContain('.install-lock.steal')
expect(posixCommand).toContain('steal_generation')
expect(posixCommand).toContain('mtime=${lock_key%%:*}')
expect(posixCommand).toContain('-gt 1200')
expect(posixCommand).toContain('current_mtime')
expect(posixCommand).toContain('steal_generation + 1')
expect(posixCommand).not.toContain('.next.')
expect(posixCommand).toContain('trap')
expect(windowsScript).toContain('$lock.steal')
expect(windowsScript).toContain('$stealGeneration++')
expect(windowsScript).toContain('-gt 1200')
expect(windowsScript).toContain('$currentIdentity -eq $lockIdentity')
expect(windowsScript).toContain('[System.IO.FileMode]::CreateNew')
expect(windowsScript).not.toContain('.next.')
expect(windowsScript).toContain('finally')
})
it.runIf(powerShellExecutable)(
'emits a parseable Windows stale-lock recovery command',
() => {
const script = decodePowerShellCommand(
tryStealInstallLockCommand(
windows,
'C:/Users/orca-missing/.orca-remote/relay/.install-lock',
20 * 60
)
)
const result = spawnSync(
powerShellExecutable!,
['-NoProfile', '-NonInteractive', '-Command', script],
{ encoding: 'utf8' }
)
expect(result.status, result.stderr).toBe(0)
expect(result.stdout.trim()).toBe('BUSY')
},
15_000
)
it.runIf(powerShell51Executable)(
'lets only one Windows PowerShell 5.1 caller acquire an install lock',
async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-windows-race-'))
try {
const lockPath = join(root, '.install-lock')
const script = decodePowerShellCommand(tryCreateInstallLockCommand(windows, lockPath))
const outputs = await Promise.all(
Array.from({ length: 16 }, () => runPowerShellCommand(powerShell51Executable!, script))
)
expect(outputs.filter((output) => output.trim().endsWith('OK'))).toHaveLength(1)
expect(statSync(lockPath).isDirectory()).toBe(true)
expect(statSync(join(lockPath, '.owner')).isFile()).toBe(true)
} finally {
rmSync(root, { recursive: true, force: true })
}
},
30_000
)
it.runIf(powerShell51Executable)(
'recovers a stale Windows lock past abandoned steal generations',
async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-windows-stale-'))
try {
const lockPath = join(root, '.install-lock')
mkdirSync(lockPath)
const staleDate = new Date(Date.now() - 60 * 60_000)
utimesSync(lockPath, staleDate, staleDate)
for (let i = 0; i < 12; i++) {
const orphan = `${lockPath}.steal.${i}`
mkdirSync(orphan)
utimesSync(orphan, staleDate, staleDate)
}
const script = decodePowerShellCommand(
tryStealInstallLockCommand(windows, lockPath, 20 * 60)
)
const output = await runPowerShellCommand(powerShell51Executable!, script)
expect(output.trim()).toBe('OK')
expect(statSync(lockPath).isDirectory()).toBe(true)
expect(statSync(join(lockPath, '.owner')).isFile()).toBe(true)
expect(readdirSync(root).filter((name) => name.includes('.steal.'))).toHaveLength(0)
expect(readdirSync(root).filter((name) => name.includes('.tombstone.'))).toHaveLength(0)
} finally {
rmSync(root, { recursive: true, force: true })
}
},
15_000
)
it.runIf(powerShell51Executable)(
'lets only one Windows PowerShell 5.1 caller replace a stale lock',
async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-windows-steal-race-'))
try {
const lockPath = join(root, '.install-lock')
mkdirSync(lockPath)
const staleDate = new Date(Date.now() - 60 * 60_000)
utimesSync(lockPath, staleDate, staleDate)
const script = decodePowerShellCommand(
tryStealInstallLockCommand(windows, lockPath, 20 * 60)
)
const outputs = await Promise.all(
Array.from({ length: 16 }, () => runPowerShellCommand(powerShell51Executable!, script))
)
expect(outputs.filter((output) => output.trim().endsWith('OK'))).toHaveLength(1)
expect(statSync(lockPath).isDirectory()).toBe(true)
expect(statSync(join(lockPath, '.owner')).isFile()).toBe(true)
expect(readdirSync(root).filter((name) => name.includes('.tombstone.'))).toHaveLength(0)
} finally {
rmSync(root, { recursive: true, force: true })
}
},
30_000
)
it.runIf(powerShellExecutable)(
'keeps a new Windows lock visible to the previous directory-only GC probe',
async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-windows-compat-'))
try {
const lockPath = join(root, '.install-lock')
const acquire = decodePowerShellCommand(tryCreateInstallLockCommand(windows, lockPath))
const legacyProbe = decodePowerShellCommand(probeDirectoryExistsCommand(windows, lockPath))
await expect(runPowerShellCommand(powerShellExecutable!, acquire)).resolves.toMatch(/OK/)
await expect(runPowerShellCommand(powerShellExecutable!, legacyProbe)).resolves.toMatch(
/LOCKED/
)
} finally {
rmSync(root, { recursive: true, force: true })
}
},
15_000
)
it.runIf(process.platform !== 'win32')(
'recovers and cleans more than eight orphaned numbered steal claims',
() => {
const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-'))
try {
const lockDir = join(root, '.install-lock')
mkdirSync(lockDir)
const staleDate = new Date(Date.now() - 60 * 60_000)
utimesSync(lockDir, staleDate, staleDate)
const lockMtimeSeconds = Math.floor(statSync(lockDir).mtimeMs / 1000)
for (let i = 0; i < 12; i++) {
const orphan = `${lockDir}.steal.${i}`
mkdirSync(orphan)
utimesSync(orphan, staleDate, staleDate)
}
const command = tryStealInstallLockCommand(posix, lockDir, 20 * 60)
const output = execFileSync('/bin/sh', ['-c', command], { encoding: 'utf8' })
expect(output.trim()).toBe('OK')
expect(existsSync(lockDir)).toBe(true)
expect(readdirSync(root).filter((name) => name.includes('.steal.'))).toHaveLength(0)
expect(Math.floor(statSync(lockDir).mtimeMs / 1000)).toBeGreaterThan(lockMtimeSeconds)
} finally {
rmSync(root, { recursive: true, force: true })
}
}
)
it.runIf(process.platform !== 'win32')(
'lets only one POSIX caller move and recreate a stale install lock',
async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-race-'))
try {
const lockDir = join(root, '.install-lock')
mkdirSync(lockDir)
const staleDate = new Date(Date.now() - 60 * 60_000)
utimesSync(lockDir, staleDate, staleDate)
const command = tryStealInstallLockCommand(posix, lockDir, 20 * 60)
const outputs = await Promise.all(
Array.from({ length: 64 }, () => runShellCommand(command))
)
const okCount = outputs.filter((output) => output.trim().endsWith('OK')).length
expect(okCount).toBe(1)
expect(existsSync(lockDir)).toBe(true)
expect(readdirSync(root).some((name) => name.includes('.tombstone'))).toBe(false)
} finally {
rmSync(root, { recursive: true, force: true })
}
}
)
it('makes Windows remote directory changes fail before running scoped commands', () => {
const scopedCommand = decodePowerShellCommand(
commandInRemoteDirectory(windows, 'C:/Users/me/.orca-remote/relay-0.1.0', "'READY'")
+21 -33
View File
@@ -45,6 +45,24 @@ export function removeRemoteTreeCommand(host: RemoteHostPlatform, remotePath: st
)
}
export function moveRemoteTreeCommand(
host: RemoteHostPlatform,
sourcePath: string,
destinationPath: string
): string {
if (!isWindowsRemoteHost(host)) {
return `mv ${shellEscape(sourcePath)} ${shellEscape(destinationPath)} 2>&1 && echo MOVED || echo BUSY`
}
return powerShellCommand(
[
'try {',
`Move-Item -LiteralPath ${powerShellLiteral(sourcePath)} -Destination ${powerShellLiteral(destinationPath)} -ErrorAction Stop`,
"'MOVED'",
`} catch { 'BUSY' }`
].join('; ')
)
}
export function writeRemoteEmptyFileCommand(host: RemoteHostPlatform, remotePath: string): string {
if (!isWindowsRemoteHost(host)) {
return `touch ${shellEscape(remotePath)}`
@@ -81,36 +99,6 @@ export function probeRelayInstalledCommand(
)
}
export function acquireInstallLockParentCommand(
host: RemoteHostPlatform,
remoteRelayDir: string
): string {
return makeRemoteDirectoryCommand(host, remoteRelayDir)
}
export function tryCreateInstallLockCommand(host: RemoteHostPlatform, lockDir: string): string {
if (!isWindowsRemoteHost(host)) {
return `mkdir ${shellEscape(lockDir)} 2>&1 && echo OK || echo BUSY`
}
// New-Item has no -LiteralPath parameter; using it breaks stock Windows PowerShell.
return powerShellCommand(
`$ErrorActionPreference = "Stop"; try { $null = New-Item -ItemType Directory -Path ${powerShellLiteral(lockDir)}; 'OK' } catch { 'BUSY' }`
)
}
export function lockMtimeEpochCommand(host: RemoteHostPlatform, lockDir: string): string {
if (!isWindowsRemoteHost(host)) {
return `stat -c %Y ${shellEscape(lockDir)} 2>/dev/null || stat -f %m ${shellEscape(lockDir)} 2>/dev/null || echo`
}
return powerShellCommand(
[
`$item = Get-Item -LiteralPath ${powerShellLiteral(lockDir)} -ErrorAction Stop`,
'$dto = [DateTimeOffset]$item.LastWriteTimeUtc',
'Write-Output $dto.ToUnixTimeSeconds()'
].join('; ')
)
}
export function listRelayBaseDirsCommand(host: RemoteHostPlatform, baseDir: string): string {
if (!isWindowsRemoteHost(host)) {
return `ls -1 ${shellEscape(baseDir)} 2>/dev/null || true`
@@ -155,9 +143,9 @@ export function relayLivenessProbeCommand(
): string {
if (!isWindowsRemoteHost(host)) {
return (
`for f in ${shellEscape(dir)}/relay-*.sock ${shellEscape(dir)}/relay.sock; do ` +
`[ -S "$f" ] && echo ALIVE && break; ` +
'done; true'
`state=DEAD; for f in ${shellEscape(dir)}/relay-*.sock ${shellEscape(dir)}/relay.sock; do ` +
`[ -S "$f" ] && state=ALIVE && break; ` +
'done; echo "$state"'
)
}
if (!windowsOptions) {
@@ -41,6 +41,22 @@ describe('resolveRemoteNodePath', () => {
await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node')
})
it('skips an incomplete system Node and selects a complete NVM toolchain', async () => {
execCommandMock
.mockResolvedValueOnce('/usr/bin/node\n/home/u/.nvm/versions/node/v22.22.0/bin/node\n')
.mockRejectedValueOnce(new Error('/usr/bin/npm: not found'))
.mockResolvedValueOnce('__ORCA_NODE_VERSION__\nv22.22.0\n__ORCA_NPM_VERSION__\n11.13.0\n')
await expect(resolveRemoteNodePath(conn)).resolves.toBe(
'/home/u/.nvm/versions/node/v22.22.0/bin/node'
)
expect(execCommandMock.mock.calls[1]![1]).toContain("'/usr/bin/npm' --version")
expect(execCommandMock.mock.calls[2]![1]).toContain(
"'/home/u/.nvm/versions/node/v22.22.0/bin/npm' --version"
)
})
it('probes mise install directories', async () => {
execCommandMock
.mockResolvedValueOnce('/home/u/.local/share/mise/installs/node/20/bin/node\n')
+28 -28
View File
@@ -1,21 +1,21 @@
import type { SshConnection } from './ssh-connection'
import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils'
import { createSshOperationAbortError } from './ssh-connection-utils'
import type { RemoteHostPlatform } from './ssh-remote-platform'
import { isWindowsRemoteHost, normalizeWindowsRemotePath } from './ssh-remote-platform'
import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell'
import { powerShellCommand } from './ssh-remote-powershell'
import {
buildPosixNodeInstallGuidance,
type RemoteNodeResolutionOptions
} from './ssh-remote-node-install-guidance'
import { execCommand } from './ssh-relay-deploy-helpers'
import {
buildPosixNodeToolchainProbe,
buildWindowsNodeToolchainProbe,
nodeToolchainVersionsMeetRequirements
} from './ssh-remote-node-toolchain-probe'
import { isSshSessionLimitError } from './ssh-session-limit-error'
import { buildSshLoginShellCommand } from './ssh-login-shell-command'
// Why: the relay requires Node.js 18+. Version managers like nvm keep every
// installed version on disk, so a naive "highest version" glob can hand back
// Node 8/10/12 and crash the relay on launch. Gate every candidate on this.
const MIN_NODE_MAJOR = 18
// Why: the login-shell fallback catches custom PATH setups in ~/.profile that
// the path probes don't cover. Interactive configs (conda prompts, etc.) can
// hang a login shell, so keep this short.
@@ -53,7 +53,7 @@ export async function resolveRemoteNodePath(
// Probe the on-disk install directories of every common Node version manager
// plus system package-manager locations. Every probe runs unconditionally so
// a missing directory prints nothing rather than short-circuiting later
// probes. Returns the first candidate that meets the minimum version.
// probes. Returns the first candidate with a complete Node/npm toolchain.
async function tryResolveViaKnownPaths(
conn: SshConnection,
options?: RemoteNodeResolutionOptions
@@ -113,7 +113,7 @@ true
continue
}
seen.add(candidate)
if (await nodeMeetsVersionRequirement(conn, candidate, options)) {
if (await nodeToolchainMeetsRequirements(conn, candidate, options)) {
console.log(`[ssh-relay] Found node via path probe: ${candidate}`)
return candidate
}
@@ -122,6 +122,7 @@ true
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
throw err
}
throwIfAborted(options)
// Fall through to login shell.
}
return null
@@ -159,7 +160,7 @@ async function tryResolveViaLoginShell(
return null
}
if (await nodeMeetsVersionRequirement(conn, candidate, options)) {
if (await nodeToolchainMeetsRequirements(conn, candidate, options)) {
console.log(`[ssh-relay] Found node via login shell (${shell}): ${candidate}`)
return candidate
}
@@ -167,15 +168,18 @@ async function tryResolveViaLoginShell(
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
throw err
}
throwIfAborted(options)
// Fall through.
}
return null
}
// Returns true if `nodePath` runs and reports Node >= MIN_NODE_MAJOR.
// Returns true if `nodePath` runs, reports Node >= 18, and has a runnable npm
// beside it. Deployment prepends this same directory before invoking npm, so
// accepting a looser pairing would recreate #8450.
// Caches nothing — this runs at most a few times per resolution (one per
// candidate), and the exec round-trip dominates.
async function nodeMeetsVersionRequirement(
async function nodeToolchainMeetsRequirements(
conn: SshConnection,
nodePath: string,
options?: RemoteNodeResolutionOptions
@@ -183,14 +187,17 @@ async function nodeMeetsVersionRequirement(
try {
const versionOutput = await execCommand(
conn,
`${shellEscape(nodePath)} --version`,
commandOptions({ wrapCommand: false }, options)
buildPosixNodeToolchainProbe(nodePath),
// Why: the paired probe uses POSIX PATH assignment syntax, which fish
// and csh cannot parse when sshd delegates directly to the login shell.
commandOptions({ wrapCommand: true }, options)
)
return nodeVersionMeetsRequirement(versionOutput)
return nodeToolchainVersionsMeetRequirements(versionOutput)
} catch (err) {
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
throw err
}
throwIfAborted(options)
// Binary missing or fails to run — not usable.
return false
}
@@ -231,7 +238,7 @@ async function resolveRemoteWindowsNodePath(
continue
}
const normalized = normalizeWindowsRemotePath(nodePath)
if (await windowsNodeMeetsVersionRequirement(conn, normalized, options)) {
if (await windowsNodeToolchainMeetsRequirements(conn, normalized, options)) {
console.log(`[ssh-relay] Found Windows node at: ${normalized}`)
return normalized
}
@@ -240,13 +247,14 @@ async function resolveRemoteWindowsNodePath(
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
throw err
}
throwIfAborted(options)
// Fall through to the shared error below.
}
throwWindowsNodeNotFound(options)
}
async function windowsNodeMeetsVersionRequirement(
async function windowsNodeToolchainMeetsRequirements(
conn: SshConnection,
nodePath: string,
options?: RemoteNodeResolutionOptions
@@ -254,27 +262,19 @@ async function windowsNodeMeetsVersionRequirement(
try {
const versionOutput = await execCommand(
conn,
powerShellCommand(`& ${powerShellLiteral(nodePath)} --version`),
powerShellCommand(buildWindowsNodeToolchainProbe(nodePath)),
commandOptions({ wrapCommand: false }, options)
)
return nodeVersionMeetsRequirement(versionOutput)
return nodeToolchainVersionsMeetRequirements(versionOutput)
} catch (err) {
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
throw err
}
throwIfAborted(options)
return false
}
}
function nodeVersionMeetsRequirement(versionOutput: string): boolean {
const match = versionOutput.trim().match(/^v?(\d+)/)
if (!match) {
return false
}
const major = Number.parseInt(match[1]!, 10)
return major >= MIN_NODE_MAJOR
}
async function throwNodeNotFound(
conn: SshConnection,
options?: RemoteNodeResolutionOptions
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import {
buildPosixNodeToolchainProbe,
buildWindowsNodeToolchainProbe,
nodeToolchainVersionsMeetRequirements
} from './ssh-remote-node-toolchain-probe'
describe('remote Node/npm toolchain probe', () => {
it('probes npm beside the selected POSIX Node with that directory on PATH', () => {
expect(buildPosixNodeToolchainProbe('/home/u/My Node/bin/node')).toBe(
"printf '%s\\n' '__ORCA_NODE_VERSION__' && '/home/u/My Node/bin/node' --version && " +
"printf '%s\\n' '__ORCA_NPM_VERSION__' && PATH='/home/u/My Node/bin':$PATH " +
"'/home/u/My Node/bin/npm' --version"
)
})
it('probes npm.cmd beside the selected Windows Node', () => {
const probe = buildWindowsNodeToolchainProbe('C:/Program Files/nodejs/node.exe')
expect(probe).toContain("Test-Path -LiteralPath 'C:/Program Files/nodejs/npm.cmd'")
expect(probe).toContain("$env:PATH = 'C:/Program Files/nodejs' + ';' + $env:PATH")
expect(probe).toContain("& 'C:/Program Files/nodejs/npm.cmd' --version")
})
it('requires marked, parseable Node and npm versions', () => {
expect(
nodeToolchainVersionsMeetRequirements(
'banner\n__ORCA_NODE_VERSION__\nv22.22.0\n__ORCA_NPM_VERSION__\n11.13.0\n'
)
).toBe(true)
expect(
nodeToolchainVersionsMeetRequirements(
'__ORCA_NODE_VERSION__\nv22.22.0\n__ORCA_NPM_VERSION__\nshim did nothing\n'
)
).toBe(false)
expect(
nodeToolchainVersionsMeetRequirements(
'__ORCA_NODE_VERSION__\nv16.20.2\n__ORCA_NPM_VERSION__\n10.8.2\n'
)
).toBe(false)
})
it('accepts legacy Node-only output from existing proxy integrations', () => {
expect(nodeToolchainVersionsMeetRequirements('v18.0.0\n')).toBe(true)
expect(nodeToolchainVersionsMeetRequirements('v16.20.2\n')).toBe(false)
})
})
@@ -0,0 +1,64 @@
import { posix as posixPath } from 'node:path'
import { shellEscape } from './ssh-connection-utils'
import { powerShellLiteral } from './ssh-remote-powershell'
const MIN_NODE_MAJOR = 18
const NODE_VERSION_MARKER = '__ORCA_NODE_VERSION__'
const NPM_VERSION_MARKER = '__ORCA_NPM_VERSION__'
export function buildPosixNodeToolchainProbe(nodePath: string): string {
const nodeBinDir = posixPath.dirname(nodePath)
const npmPath = posixPath.join(nodeBinDir, 'npm')
return [
`printf '%s\\n' '${NODE_VERSION_MARKER}'`,
`${shellEscape(nodePath)} --version`,
`printf '%s\\n' '${NPM_VERSION_MARKER}'`,
`PATH=${shellEscape(nodeBinDir)}:$PATH ${shellEscape(npmPath)} --version`
].join(' && ')
}
export function buildWindowsNodeToolchainProbe(nodePath: string): string {
const nodeBinDir = posixPath.dirname(nodePath)
const npmPath = posixPath.join(nodeBinDir, 'npm.cmd')
return [
`if (!(Test-Path -LiteralPath ${powerShellLiteral(npmPath)} -PathType Leaf)) { exit 1 }`,
`$env:PATH = ${powerShellLiteral(nodeBinDir)} + ';' + $env:PATH`,
`Write-Output ${powerShellLiteral(NODE_VERSION_MARKER)}`,
`& ${powerShellLiteral(nodePath)} --version`,
'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }',
`Write-Output ${powerShellLiteral(NPM_VERSION_MARKER)}`,
`& ${powerShellLiteral(npmPath)} --version`,
'exit $LASTEXITCODE'
].join('; ')
}
export function nodeToolchainVersionsMeetRequirements(versionOutput: string): boolean {
const nodeMajor = markedVersionMajor(versionOutput, NODE_VERSION_MARKER)
const npmMajor = markedVersionMajor(versionOutput, NPM_VERSION_MARKER)
if (versionOutput.includes(NODE_VERSION_MARKER)) {
return nodeMajor !== null && nodeMajor >= MIN_NODE_MAJOR && npmMajor !== null
}
// Why: existing proxy integrations and tests may return only the Node
// version even though production probes now emit both version markers.
const legacyMatch = versionOutput.trim().match(/^v?(\d+)/)
return legacyMatch ? Number.parseInt(legacyMatch[1]!, 10) >= MIN_NODE_MAJOR : false
}
function markedVersionMajor(output: string, marker: string): number | null {
const lines = output.split(/\r?\n/)
const markerIndex = lines.indexOf(marker)
if (markerIndex < 0) {
return null
}
for (const line of lines.slice(markerIndex + 1)) {
if (line.startsWith('__ORCA_')) {
return null
}
const match = line.trim().match(/^v?(\d+)(?:\.\d+){1,2}(?:[-+].*)?$/)
if (match) {
return Number.parseInt(match[1]!, 10)
}
}
return null
}
+23 -11
View File
@@ -11,30 +11,38 @@ import { powerShellCommand } from './ssh-remote-powershell'
const PLATFORM_PROBE_MARKER = '__ORCA_REMOTE_PLATFORM__'
export async function detectRemoteHostPlatform(
conn: SshConnection
conn: SshConnection,
options?: { signal?: AbortSignal }
): Promise<RemoteHostPlatform | null> {
const unamePlatform = await detectUnamePlatform(conn)
const unamePlatform = await detectUnamePlatform(conn, options?.signal)
if (unamePlatform) {
return getRemoteHostPlatform(unamePlatform)
}
const windowsPlatform = await detectWindowsPlatform(conn)
const windowsPlatform = await detectWindowsPlatform(conn, options?.signal)
return windowsPlatform ? getRemoteHostPlatform(windowsPlatform) : null
}
async function detectUnamePlatform(conn: SshConnection): Promise<RelayPlatform | null> {
async function detectUnamePlatform(
conn: SshConnection,
signal?: AbortSignal
): Promise<RelayPlatform | null> {
try {
const output = await execCommand(
conn,
// Why: Remote startup output may omit its trailing newline and must not absorb the marker.
`printf '\\n%s ' '${PLATFORM_PROBE_MARKER}'; uname -sm`
)
// Why: Remote startup output may omit its trailing newline and must not absorb the marker.
const command = `printf '\\n%s ' '${PLATFORM_PROBE_MARKER}'; uname -sm`
const output = signal
? await execCommand(conn, command, { signal })
: await execCommand(conn, command)
return parseRemotePlatformOutput(output)
} catch {
signal?.throwIfAborted()
return null
}
}
async function detectWindowsPlatform(conn: SshConnection): Promise<RelayPlatform | null> {
async function detectWindowsPlatform(
conn: SshConnection,
signal?: AbortSignal
): Promise<RelayPlatform | null> {
try {
const script = [
'$arch = $env:PROCESSOR_ARCHITECTURE',
@@ -43,9 +51,13 @@ async function detectWindowsPlatform(conn: SshConnection): Promise<RelayPlatform
// Why: Remote startup output may omit its trailing newline and must not absorb the marker.
`Write-Output ("\`n${PLATFORM_PROBE_MARKER} Windows " + $arch)`
].join('; ')
const output = await execCommand(conn, powerShellCommand(script), { wrapCommand: false })
const output = await execCommand(conn, powerShellCommand(script), {
wrapCommand: false,
...(signal ? { signal } : {})
})
return parseRemotePlatformOutput(output)
} catch {
signal?.throwIfAborted()
return null
}
}
+28 -16
View File
@@ -83,6 +83,16 @@ function expectNoOrcaControlMasterArgs(args: string[]): void {
expect(args).not.toContain('ControlPersist=300')
}
function expectOrcaControlMasterArgs(args: string[]): void {
if (process.platform === 'win32') {
expectNoOrcaControlMasterArgs(args)
return
}
expect(args).toContain('ControlMaster=auto')
expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true)
expect(args).toContain('ControlPersist=300')
}
type EventedProcess = EventEmitter & {
stdin: EventEmitter & {
write: ReturnType<typeof vi.fn>
@@ -360,9 +370,7 @@ describe('spawnSystemSsh', () => {
})
})
expect(args).toContain('ControlMaster=auto')
expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true)
expect(args).toContain('ControlPersist=300')
expectOrcaControlMasterArgs(args)
expect(args).not.toContain('-S')
})
@@ -374,9 +382,7 @@ describe('spawnSystemSsh', () => {
})
})
expect(args).toContain('ControlMaster=auto')
expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true)
expect(args).toContain('ControlPersist=300')
expectOrcaControlMasterArgs(args)
expect(args).not.toContain('-S')
})
@@ -387,9 +393,7 @@ describe('spawnSystemSsh', () => {
})
})
expect(args).toContain('ControlMaster=auto')
expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true)
expect(args).toContain('ControlPersist=300')
expectOrcaControlMasterArgs(args)
expect(args).not.toContain('-S')
})
@@ -414,9 +418,7 @@ describe('spawnSystemSsh', () => {
resolvedConfig: createResolvedConfig()
})
expect(args).toContain('ControlMaster=auto')
expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true)
expect(args).toContain('ControlPersist=300')
expectOrcaControlMasterArgs(args)
expect(args).not.toContain('-S')
})
@@ -432,10 +434,11 @@ describe('spawnSystemSsh', () => {
it('adds keepalive options to Orca-owned ControlMaster connections', () => {
const args = buildSshArgs(createTarget(), { resolvedConfig: createResolvedConfig() })
expect(args).toContain('ControlMaster=auto')
expect(args).toContain('ControlPersist=300')
expect(args).toContain('ServerAliveInterval=15')
expect(args).toContain('ServerAliveCountMax=3')
expectOrcaControlMasterArgs(args)
if (process.platform !== 'win32') {
expect(args).toContain('ServerAliveInterval=15')
expect(args).toContain('ServerAliveCountMax=3')
}
})
it('spawns a remote command through the system ssh target', () => {
@@ -507,6 +510,15 @@ describe('spawnSystemSsh', () => {
expect(mockProc.stdin.end).toHaveBeenCalledWith('contents')
})
it('marks a system command channel when local teardown is requested', () => {
const channel = spawnSystemSshCommand(createTarget(), 'npm install')
channel.close()
expect(channel._closeRequested).toBe(true)
expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM')
})
it('removes wrapped process listeners after command close', () => {
const proc = createEventedProcess()
spawnMock.mockReturnValue(proc)
+3
View File
@@ -17,6 +17,7 @@ export type SystemSshProcess = {
export type SystemSshCommandChannel = ClientChannel & {
_process?: ChildProcess
_closeRequested?: boolean
}
type SystemSshCommandOptions = SshExecOptions & SystemSshBuildArgsOptions
@@ -103,12 +104,14 @@ function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel {
stdin: NodeJS.WritableStream
stderr: NodeJS.ReadableStream
_process?: ChildProcess
_closeRequested?: boolean
close: () => void
}
mutableChannel.stdin = proc.stdin!
mutableChannel.stderr = proc.stderr!
mutableChannel._process = proc
mutableChannel.close = () => {
mutableChannel._closeRequested = true
try {
proc.kill('SIGTERM')
} catch {
@@ -100,7 +100,13 @@ export async function awaitWithSystemSshAbort<T>(
// children to emit close after we've already signaled them.
abortChildren()
suppressLateOperationError = true
abortReject?.(createAbortError())
abortReject?.(
Object.assign(createAbortError(), {
// The child was signaled, but this fast abort path intentionally does
// not wait for process exit; callers holding remote locks must retain them.
sshChannelCloseConfirmed: false
})
)
}
signal.addEventListener('abort', abort, { once: true })
if (signal.aborted) {
+4
View File
@@ -304,6 +304,7 @@ import type {
MigrationUnsupportedPtyEntry
} from '../shared/agent-status-types'
import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent'
import type { AgentQuestionAnsweredInferenceRequest } from '../shared/agent-question-answered-intent'
import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts'
import type {
RuntimeBrowserDriverState,
@@ -3203,6 +3204,9 @@ export type PreloadApi = {
/** Return the current main-process hook cache after renderer hydration. */
getSnapshot: () => Promise<AgentStatusIpcPayload[]>
inferInterrupt: (request: AgentInterruptInferenceRequest) => Promise<boolean>
/** Guarded clear for an answered AskUserQuestion wait — the CLI emits no
* hook at answer time, so the renderer reports the submit keystroke. */
inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest) => Promise<boolean>
/** Listen for PTYs that still use a legacy numeric pane key but have
* registry-backed UUID pane proof. */
onMigrationUnsupported: (callback: (entry: MigrationUnsupportedPtyEntry) => void) => () => void
+3
View File
@@ -145,6 +145,7 @@ import type {
MigrationUnsupportedPtyEntry
} from '../shared/agent-status-types'
import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent'
import type { AgentQuestionAnsweredInferenceRequest } from '../shared/agent-question-answered-intent'
import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts'
import type {
SpeechErrorEvent,
@@ -4404,6 +4405,8 @@ const api = {
ipcRenderer.invoke('agentStatus:getSnapshot'),
inferInterrupt: (request: AgentInterruptInferenceRequest): Promise<boolean> =>
ipcRenderer.invoke('agentStatus:inferInterrupt', request),
inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest): Promise<boolean> =>
ipcRenderer.invoke('agentStatus:inferQuestionAnswered', request),
onMigrationUnsupported: (
callback: (entry: MigrationUnsupportedPtyEntry) => void
): (() => void) => {
+23
View File
@@ -181,6 +181,29 @@ describe('PtyHandler', () => {
expect(handler.activePtyCount).toBe(1)
})
it('normalizes a missing native binding as degraded node-pty availability', async () => {
mockPtySpawn.mockImplementationOnce(() => {
throw new Error(
'Failed to load native module: conpty.node, checked: build/Release, prebuilds/win32-x64'
)
})
await expect(dispatcher.callRequest('pty.spawn', {})).rejects.toThrow(
'node-pty is not available on this remote host'
)
expect(handler.activePtyCount).toBe(0)
})
it('preserves unrelated node-pty spawn failures', async () => {
mockPtySpawn.mockImplementationOnce(() => {
throw new Error('File not found: missing-shell.exe')
})
await expect(dispatcher.callRequest('pty.spawn', {})).rejects.toThrow(
'File not found: missing-shell.exe'
)
})
it('atomically caps concurrent PTY spawn admission', async () => {
const results = await Promise.allSettled(
Array.from({ length: MAX_RELAY_PTY_SESSIONS + 1 }, () =>
+83 -28
View File
@@ -1,6 +1,8 @@
/* oxlint-disable max-lines */
import type { IPty } from 'node-pty'
import type * as NodePty from 'node-pty'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { resolveWindowsGitBashShellPath } from '../main/git-bash'
import { WINDOWS_GIT_BASH_SHELL } from '../shared/windows-terminal-shell'
import type { RelayDispatcher, RequestContext } from './dispatcher'
@@ -38,21 +40,11 @@ import {
import { isTuiAgent } from '../shared/tui-agent-config'
import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups'
// Why: node-pty is a native addon that may not be installed on the remote.
// Dynamic import keeps the require() lazy so loadPty() returns null gracefully
// when the native module is unavailable. The static type import lets vitest
// intercept it in tests.
let ptyModule: typeof NodePty | null = null
async function loadPty(): Promise<typeof NodePty | null> {
if (ptyModule) {
return ptyModule
}
try {
ptyModule = await import('node-pty')
return ptyModule
} catch {
return null
}
function isMissingNodePtyNativeBinding(error: unknown): boolean {
return (
error instanceof Error &&
/Failed to load native module: (?:conpty|pty)\.node(?:,|$)/.test(error.message)
)
}
type ManagedPty = {
@@ -296,6 +288,9 @@ export class PtyHandler {
private pendingCreationDrainResolvers = new Set<() => void>()
private worktreeRemovalCoordinator: RelayPtyWorktreeRemovalCoordinator | null = null
private disposePromise: Promise<void> | null = null
private ptyModule: typeof NodePty | null = null
private ptyModuleLoadPromise: Promise<typeof NodePty | null> | null = null
private reloadPtyModuleFromDisk = false
// Why: external observers need to drop per-pane state when a PTY exits.
// Today the relay composes multiple consumers (hook-server cache eviction
// and plugin-overlay dir cleanup) into a single callback at the call site
@@ -316,6 +311,55 @@ export class PtyHandler {
this.registerHandlers()
}
private async loadPty(): Promise<typeof NodePty | null> {
if (this.ptyModule) {
return this.ptyModule
}
if (this.ptyModuleLoadPromise) {
return this.ptyModuleLoadPromise
}
this.ptyModuleLoadPromise = this.loadPtyUncached()
try {
return await this.ptyModuleLoadPromise
} finally {
this.ptyModuleLoadPromise = null
}
}
private async loadPtyUncached(): Promise<typeof NodePty | null> {
if (!this.reloadPtyModuleFromDisk) {
try {
this.ptyModule = await import('node-pty')
return this.ptyModule
} catch {
this.reloadPtyModuleFromDisk = true
}
}
// Why: the relay is launched from its install dir today, but module
// resolution must remain tied to the deployed bundle rather than cwd.
const moduleEntry = join(__dirname, 'node_modules', 'node-pty', 'lib', 'index.js')
if (!existsSync(moduleEntry)) {
return null
}
try {
this.ptyModule = require(moduleEntry) as typeof NodePty
return this.ptyModule
} catch {
return null
}
}
private invalidatePtyModuleAfterBindingFailure(): void {
this.ptyModule = null
this.reloadPtyModuleFromDisk = true
const moduleRoot = join(__dirname, 'node_modules', 'node-pty')
for (const cachedPath of Object.keys(require.cache)) {
if (isPathInsideOrEqual(moduleRoot, cachedPath)) {
delete require.cache[cachedPath]
}
}
}
setGraceTimeMs(graceTimeMs: number): void {
this.graceTimeMs = Math.max(0, Math.floor(graceTimeMs))
}
@@ -741,7 +785,7 @@ export class PtyHandler {
params: Record<string, unknown>,
context?: RequestContext
): Promise<{ id: string }> {
const pty = await loadPty()
const pty = await this.loadPty()
if (!pty) {
throw new Error('node-pty is not available on this remote host')
}
@@ -814,17 +858,28 @@ export class PtyHandler {
// includes Homebrew, nvm, and user-installed CLIs (claude, codex, gh).
// When overlays are injected, the launch wrapper keeps those paths after
// user startup files re-export their defaults.
const term = pty.spawn(shell, shellLaunch.args, {
// Why: node-pty overwrites env.TERM with `name`; keep caller-selected
// terminal identities instead of losing them at the final spawn boundary.
name: spawnEnv.TERM ?? 'xterm-256color',
cols,
rows,
cwd,
// Why: relay shells inherit process.env; never let an ambient Orca marker
// enable shell-ready behavior unless this spawn explicitly requested it.
env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env }
})
let term: IPty
try {
term = pty.spawn(shell, shellLaunch.args, {
// Why: node-pty overwrites env.TERM with `name`; keep caller-selected
// terminal identities instead of losing them at the final spawn boundary.
name: spawnEnv.TERM ?? 'xterm-256color',
cols,
rows,
cwd,
// Why: relay shells inherit process.env; never let an ambient Orca marker
// enable shell-ready behavior unless this spawn explicitly requested it.
env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env }
})
} catch (error) {
// Why: Windows node-pty loads conpty.node only on first spawn, after the
// wrapper import succeeded. Keep that late failure on the degraded path.
if (isMissingNodePtyNativeBinding(error)) {
this.invalidatePtyModuleAfterBindingFailure()
throw new Error('node-pty is not available on this remote host')
}
throw error
}
// Why: capture the renderer-supplied paneKey on the managed entry so the
// exit listener can evict per-pane caches without the relay needing a
@@ -1215,7 +1270,7 @@ export class PtyHandler {
}
private async reviveEntry(entry: SerializedPtyEntry): Promise<void> {
const ptyMod = await loadPty()
const ptyMod = await this.loadPty()
if (!ptyMod) {
return
}
+1 -1
View File
@@ -25,7 +25,7 @@ export type RelayProcess = {
export function spawnRelay(
entryPath: string,
args: string[] = [],
options: Pick<SpawnOptions, 'env'> = {}
options: Pick<SpawnOptions, 'cwd' | 'env'> = {}
): RelayProcess {
const proc = spawn('node', [entryPath, ...args], {
stdio: ['pipe', 'pipe', 'pipe'],
+80 -3
View File
@@ -1,5 +1,13 @@
import { afterAll, beforeAll, describe, expect, it, afterEach } from 'vitest'
import { existsSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import { rm } from 'node:fs/promises'
import * as path from 'node:path'
import { tmpdir } from 'node:os'
@@ -46,9 +54,14 @@ afterAll(async () => {
}
})
function spawn(args: string[] = [], env?: NodeJS.ProcessEnv): RelayProcess {
function spawnRelayEntry(
entryPath: string,
args: string[] = [],
env?: NodeJS.ProcessEnv
): RelayProcess {
let relayArgs = args
if (!args.includes('--sock-path')) {
// Why: Windows relays require a named pipe; filesystem socket paths fail with EACCES.
const socketDir = mkdtempSync(path.join(tmpdir(), 'relay-sock-'))
spawnedSocketDirs.push(socketDir)
relayArgs = [
@@ -59,7 +72,11 @@ function spawn(args: string[] = [], env?: NodeJS.ProcessEnv): RelayProcess {
path.join(socketDir, 'agent-hooks')
]
}
return spawnRelay(relayEntry, relayArgs, env ? { env } : undefined)
return spawnRelay(entryPath, relayArgs, env ? { env } : undefined)
}
function spawn(args: string[] = [], env?: NodeJS.ProcessEnv): RelayProcess {
return spawnRelayEntry(relayEntry, args, env)
}
function waitForChildExit(
@@ -75,6 +92,22 @@ function waitForChildExit(
})
}
function writeMockNodePty(root: string, source: string, withPackageEntry = false): void {
const nodePtyDir = path.join(root, 'node_modules', 'node-pty')
const libDir = path.join(nodePtyDir, 'lib')
mkdirSync(libDir, { recursive: true })
if (withPackageEntry) {
writeFileSync(path.join(nodePtyDir, 'package.json'), '{"main":"lib/index.js"}\n')
}
writeFileSync(path.join(libDir, 'index.js'), source)
}
const WORKING_NODE_PTY_MODULE = `module.exports = { spawn() { return {
pid: process.pid,
process: 'mock-shell',
onData() {}, onExit() {}, write() {}, resize() {}, kill() {}, clear() {}
} } }\n`
describe('Subprocess: Relay entry point', () => {
let relay: RelayProcess | null = null
let tmpDir: string
@@ -103,6 +136,50 @@ describe('Subprocess: Relay entry point', () => {
expect(readFileSync(relayEntry, 'utf8')).not.toContain('.toReversed(')
})
it('loads node-pty after an in-place dependency repair without restarting', async () => {
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-native-repair-'))
const repairedRelayEntry = path.join(tmpDir, 'relay.js')
copyFileSync(relayEntry, repairedRelayEntry)
relay = spawnRelayEntry(repairedRelayEntry)
await relay.sentinelReceived
const failedId = relay.send('pty.spawn', { cols: 80, rows: 24 })
const failed = await relay.waitForResponse(failedId)
expect(failed.error?.message).toContain('node-pty is not available')
writeMockNodePty(tmpDir, WORKING_NODE_PTY_MODULE)
const repairedId = relay.send('pty.spawn', { cols: 80, rows: 24 })
const repaired = await relay.waitForResponse(repairedId)
expect(repaired.error).toBeUndefined()
expect(repaired.result).toMatchObject({ id: 'pty-1' })
}, 10_000)
it('reloads node-pty after a late native binding failure without restarting', async () => {
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-native-late-repair-'))
const repairedRelayEntry = path.join(tmpDir, 'relay.js')
copyFileSync(relayEntry, repairedRelayEntry)
writeMockNodePty(
tmpDir,
`module.exports = { spawn() { throw new Error('Failed to load native module: conpty.node, checked: prebuilds/win32-x64') } }\n`,
true
)
relay = spawnRelayEntry(repairedRelayEntry)
await relay.sentinelReceived
const failedId = relay.send('pty.spawn', { cols: 80, rows: 24 })
const failed = await relay.waitForResponse(failedId)
expect(failed.error?.message).toContain('node-pty is not available')
writeMockNodePty(tmpDir, WORKING_NODE_PTY_MODULE, true)
const repairedId = relay.send('pty.spawn', { cols: 80, rows: 24 })
const repaired = await relay.waitForResponse(repairedId)
expect(repaired.error).toBeUndefined()
expect(repaired.result).toMatchObject({ id: 'pty-2' })
}, 10_000)
it('responds to fs.stat over stdin/stdout', async () => {
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-'))
writeFileSync(path.join(tmpDir, 'test.txt'), 'hello')
@@ -68,6 +68,10 @@ import {
isMarkdownPreviewFindShortcut,
setActiveMarkdownPreviewSearchMatch
} from './markdown-preview-search'
import {
getMarkdownAnnotationBlockKeyForSelection,
isMarkdownPreviewAddReviewNoteShortcut
} from './markdown-preview-annotation-shortcut'
import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit'
import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
@@ -916,6 +920,20 @@ export default function MarkdownPreview({
return
}
if (
isMarkdownPreviewAddReviewNoteShortcut(event, getShortcutPlatform(), keybindings) &&
targetInsidePreview &&
markdownAnnotationsEnabled
) {
const blockKey = getMarkdownAnnotationBlockKeyForSelection(root, window.getSelection())
if (blockKey) {
event.preventDefault()
event.stopPropagation()
setActiveAnnotationBlockKey(blockKey)
}
return
}
if (!isSearchOpen) {
return
}
@@ -930,7 +948,7 @@ export default function MarkdownPreview({
window.addEventListener('keydown', handleKeyDown, { capture: true })
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
}, [closeSearch, isSearchOpen, keybindings, openSearch])
}, [closeSearch, isSearchOpen, keybindings, markdownAnnotationsEnabled, openSearch])
const handleCopyMarkdownReviewNotes = useCallback(async (): Promise<void> => {
if (markdownReviewNotes.length === 0) {
@@ -1248,6 +1266,7 @@ export default function MarkdownPreview({
className={`markdown-annotation-block ${hasReviewNotes ? 'has-review-notes' : ''}`.trim()}
data-source-line={range.startLine}
data-source-end-line={range.endLine}
data-annotation-block-key={blockKey}
onClick={(event) => handleAnnotatedMarkdownBlockClick(range, event)}
>
{rendered}
@@ -1668,6 +1687,11 @@ export default function MarkdownPreview({
}
const blockKey = `li:${range.startLine}-${range.endLine}`
const hasReviewNotes = getMarkdownCommentsForRange(range).length > 0
const controls = renderAnnotationControls(
range,
blockKey,
getMarkdownPreviewAnnotationQuote(children)
)
return (
<li {...props}>
<div
@@ -1676,14 +1700,13 @@ export default function MarkdownPreview({
}`.trim()}
data-source-line={range.startLine}
data-source-end-line={range.endLine}
// Why: only advertise the block to the add-review-note shortcut
// when the composer can actually render (mirrors wrapAnnotatedBlock).
data-annotation-block-key={controls ? blockKey : undefined}
onClick={(event) => handleAnnotatedMarkdownBlockClick(range, event)}
>
<span className="markdown-annotation-list-content">{children}</span>
{renderAnnotationControls(
range,
blockKey,
getMarkdownPreviewAnnotationQuote(children)
)}
{controls}
</div>
</li>
)
@@ -48,7 +48,11 @@ import {
getDiffCommentPopoverTop
} from '../diff-comments/diff-comment-popover-position'
import { isLinuxUserAgent } from '../terminal-pane/pane-helpers'
import { installEditorSaveShortcut, installMonacoEditorFindShortcut } from './editor-shortcuts'
import {
installEditorAddReviewNoteShortcut,
installEditorSaveShortcut,
installMonacoEditorFindShortcut
} from './editor-shortcuts'
import { Plus } from 'lucide-react'
import {
getMonacoMarkdownSelectionAnnotationTarget,
@@ -198,6 +202,10 @@ export default function MonacoEditor({
const [commentPopover, setCommentPopover] = useState<MarkdownCommentPopoverState | null>(null)
const [selectionAnnotationTarget, setSelectionAnnotationTarget] =
useState<MonacoMarkdownSelectionAnnotationTarget | null>(null)
// Why: the Monaco mount closure installs its keydown listeners once, so the
// add-review-note shortcut reads the live selection target through a ref.
const selectionAnnotationTargetRef = useRef<MonacoMarkdownSelectionAnnotationTarget | null>(null)
selectionAnnotationTargetRef.current = selectionAnnotationTarget
const isDark =
settings?.theme === 'dark' ||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
@@ -413,6 +421,18 @@ export default function MonacoEditor({
propsRef.current.onSave(value)
})
const cleanupFindShortcut = installMonacoEditorFindShortcut(editorInstance)
// Opens the same composer as the selection "+" button; the target ref
// mirrors the last-rendered selection target and is null unless markdown
// annotations are enabled and text is selected.
const cleanupAddReviewNoteShortcut = installEditorAddReviewNoteShortcut(editorDomNode, () => {
const target = selectionAnnotationTargetRef.current
if (!target) {
return false
}
setCommentPopover(target)
setSelectionAnnotationTarget(null)
return true
})
const searchInFilesAction = editorInstance.addAction({
id: 'orca.searchInFiles',
label: translate('auto.components.editor.MonacoEditor.fd68ae03b3', 'Search in Files'),
@@ -512,6 +532,7 @@ export default function MonacoEditor({
gutterMouseDownSub.dispose()
cleanupSaveShortcut()
cleanupFindShortcut()
cleanupAddReviewNoteShortcut()
editorDomNode.removeEventListener('paste', onLargeTextPaste, { capture: true })
searchInFilesAction.dispose()
autoHeightSub?.dispose()
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useEditorState, type Editor } from '@tiptap/react'
import type { DiffComment, MarkdownDocument } from '../../../../shared/types'
import { useAppStore } from '@/store'
@@ -10,9 +10,7 @@ import { useLinkBubble } from './useLinkBubble'
import { useEditorScrollRestore } from './useEditorScrollRestore'
import { useModifierHeldClass } from './useModifierHeldClass'
import { registerPendingEditorFlush } from './editor-pending-flush'
import type { MarkdownTocItem } from './markdown-table-of-contents'
import { findRichMarkdownTocHeadingTarget } from './rich-markdown-toc-heading-target'
import { selectMarkdownTableOfContents } from './markdown-toc-visibility-gate'
import { useRichMarkdownTableOfContents } from './use-rich-markdown-table-of-contents'
import { RichMarkdownEditorSurface } from './RichMarkdownEditorSurface'
import { useRichMarkdownEditorInstance } from './useRichMarkdownEditorInstance'
import { useRichMarkdownMenuController } from './useRichMarkdownMenuController'
@@ -56,10 +54,6 @@ type RichMarkdownEditorProps = {
headerSlot?: React.ReactNode
}
function flattenMarkdownTocItems(items: MarkdownTocItem[]): MarkdownTocItem[] {
return items.flatMap((item) => [item, ...flattenMarkdownTocItems(item.children)])
}
export default function RichMarkdownEditor({
fileId,
content,
@@ -112,6 +106,7 @@ export default function RichMarkdownEditor({
const onOpenDocLinkRef = useRef(onOpenDocLink)
const handleLocalImagePickRef = useRef<() => void>(() => {})
const openSearchRef = useRef<() => void>(() => {})
const openAnnotationPopoverRef = useRef<(requireLiveSelection?: boolean) => boolean>(() => false)
// Why: ProseMirror keeps the initial handleKeyDown closure, so `editor` stays
// stuck at the first-render null value unless we read the live instance here.
const editorRef = useRef<Editor | null>(null)
@@ -145,17 +140,10 @@ export default function RichMarkdownEditor({
worktreeId,
worktreeRoot
})
// Why: building the table of contents runs a full-document remark parse on
// every content change. The result is only used while the panel is open
// (closed by default), so gate the parse on visibility; including
// showTableOfContents in deps rebuilds the outline the moment it opens.
const tableOfContentsItems = useMemo(
() => selectMarkdownTableOfContents(showTableOfContents, content),
[content, showTableOfContents]
)
const flatTableOfContentsItems = useMemo(
() => flattenMarkdownTocItems(tableOfContentsItems),
[tableOfContentsItems]
const { tableOfContentsItems, navigateToTableOfContentsItem } = useRichMarkdownTableOfContents(
showTableOfContents,
content,
scrollContainerRef
)
// Why: assigning callback refs during render keeps them current before any
@@ -166,6 +154,7 @@ export default function RichMarkdownEditor({
onSaveRef.current = onSave
onOpenDocLinkRef.current = onOpenDocLink
isEditingLinkRef.current = isEditingLink
openAnnotationPopoverRef.current = review.openAnnotationPopover
const reconcileRoundTripRef = useRichMarkdownReconcileRoundTrip({
htmlSuperscriptLinkContext,
filePath,
@@ -258,6 +247,7 @@ export default function RichMarkdownEditor({
markdownSourceLineOffsetRef: review.markdownSourceLineOffsetRef,
flushPendingSerialization,
openSearchRef,
openAnnotationPopoverRef,
syncAnnotationTarget: review.syncAnnotationTarget,
clearAnnotationTarget: review.clearAnnotationTarget,
scrollRichMarkdownReviewNoteCardIntoView: review.scrollRichMarkdownReviewNoteCardIntoView,
@@ -360,18 +350,6 @@ export default function RichMarkdownEditor({
})
openSearchRef.current = openSearch
const navigateToTableOfContentsItem = useCallback(
(id: string): void => {
const container = scrollContainerRef.current
if (!container) {
return
}
const heading = findRichMarkdownTocHeadingTarget(container, flatTableOfContentsItems, id)
heading?.scrollIntoView({ block: 'center' })
},
[flatTableOfContentsItems]
)
return (
<RichMarkdownEditorSurface
editor={editor}
@@ -18,6 +18,7 @@ vi.mock('@/store', () => ({
}))
import {
installEditorAddReviewNoteShortcut,
installEditorFindShortcut,
installMonacoDiffChangeNavigationShortcut,
installMonacoEditorFindShortcut
@@ -201,6 +202,74 @@ describe('installEditorFindShortcut', () => {
})
})
describe('installEditorAddReviewNoteShortcut', () => {
it('invokes add-review-note on its default binding and honors overrides', () => {
const container = document.createElement('div')
const input = document.createElement('textarea')
const onAddReviewNote = vi.fn(() => true)
container.appendChild(input)
document.body.appendChild(container)
const dispose = installEditorAddReviewNoteShortcut(container, onAddReviewNote)
const defaultEvent = dispatchKeyDown(input, {
key: 'n',
code: 'KeyN',
metaKey: true,
altKey: true
})
const repeatEvent = dispatchKeyDown(input, {
key: 'n',
code: 'KeyN',
metaKey: true,
altKey: true,
repeat: true
})
const unrelatedEvent = dispatchKeyDown(input, { key: 'n', code: 'KeyN', metaKey: true })
expect(defaultEvent.defaultPrevented).toBe(true)
expect(repeatEvent.defaultPrevented).toBe(false)
expect(unrelatedEvent.defaultPrevented).toBe(false)
expect(onAddReviewNote).toHaveBeenCalledTimes(1)
shortcutState.keybindings = { 'editor.addReviewNote': ['Mod+Shift+A'] }
const overriddenEvent = dispatchKeyDown(input, {
key: 'a',
code: 'KeyA',
metaKey: true,
shiftKey: true
})
expect(overriddenEvent.defaultPrevented).toBe(true)
expect(onAddReviewNote).toHaveBeenCalledTimes(2)
dispose()
dispatchKeyDown(input, { key: 'a', code: 'KeyA', metaKey: true, shiftKey: true })
expect(onAddReviewNote).toHaveBeenCalledTimes(2)
})
it('leaves the chord unconsumed when the handler reports it did not act', () => {
const container = document.createElement('div')
const input = document.createElement('textarea')
const onDownstreamKeyDown = vi.fn()
const onAddReviewNote = vi.fn(() => false)
container.appendChild(input)
document.body.appendChild(container)
input.addEventListener('keydown', onDownstreamKeyDown)
const dispose = installEditorAddReviewNoteShortcut(container, onAddReviewNote)
const event = dispatchKeyDown(input, {
key: 'n',
code: 'KeyN',
metaKey: true,
altKey: true
})
expect(onAddReviewNote).toHaveBeenCalledTimes(1)
expect(event.defaultPrevented).toBe(false)
expect(onDownstreamKeyDown).toHaveBeenCalledTimes(1)
dispose()
})
})
describe('installMonacoDiffChangeNavigationShortcut', () => {
function createDiffNavigationFixture(): {
container: HTMLDivElement
@@ -79,6 +79,27 @@ export function installMonacoDiffChangeNavigationShortcut(
return () => target.removeEventListener('keydown', handleKeyDown, true)
}
export function installEditorAddReviewNoteShortcut(
target: HTMLElement,
onAddReviewNote: () => boolean
): () => void {
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.repeat || !editorShortcutMatches('editor.addReviewNote', event)) {
return
}
// Why: only consume the chord when a composer actually opens; on files
// where review notes can never apply the key must stay available to
// whatever else the user bound it to.
if (onAddReviewNote()) {
event.preventDefault()
event.stopPropagation()
}
}
target.addEventListener('keydown', handleKeyDown, true)
return () => target.removeEventListener('keydown', handleKeyDown, true)
}
type MonacoFindShortcutEditor = {
getAction: (id: string) => { run: () => void | Promise<void> } | null
getContainerDomNode: () => HTMLElement
@@ -0,0 +1,95 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from 'vitest'
import {
getMarkdownAnnotationBlockKeyForSelection,
isMarkdownPreviewAddReviewNoteShortcut
} from './markdown-preview-annotation-shortcut'
function createPreviewFixture(): {
root: HTMLDivElement
block: HTMLDivElement
paragraph: HTMLParagraphElement
} {
const root = document.createElement('div')
const block = document.createElement('div')
block.className = 'markdown-annotation-block'
block.setAttribute('data-annotation-block-key', 'p:3-5')
const paragraph = document.createElement('p')
paragraph.textContent = 'Some rendered markdown text'
block.appendChild(paragraph)
root.appendChild(block)
document.body.appendChild(root)
return { root, block, paragraph }
}
function selectTextIn(node: Node): Selection {
const selection = window.getSelection()
if (!selection) {
throw new Error('Selection API unavailable in test environment')
}
const range = document.createRange()
range.selectNodeContents(node)
selection.removeAllRanges()
selection.addRange(range)
return selection
}
afterEach(() => {
window.getSelection()?.removeAllRanges()
document.body.replaceChildren()
})
describe('getMarkdownAnnotationBlockKeyForSelection', () => {
it('returns the block key for a selection inside an annotation block', () => {
const { root, paragraph } = createPreviewFixture()
const selection = selectTextIn(paragraph)
expect(getMarkdownAnnotationBlockKeyForSelection(root, selection)).toBe('p:3-5')
})
it('returns null for a collapsed selection', () => {
const { root, paragraph } = createPreviewFixture()
const selection = selectTextIn(paragraph)
selection.collapseToStart()
expect(getMarkdownAnnotationBlockKeyForSelection(root, selection)).toBeNull()
})
it('returns null when the selection is outside the preview root', () => {
const { root } = createPreviewFixture()
const outside = document.createElement('p')
outside.textContent = 'other text'
document.body.appendChild(outside)
const selection = selectTextIn(outside)
expect(getMarkdownAnnotationBlockKeyForSelection(root, selection)).toBeNull()
})
it('returns null without a selection', () => {
const { root } = createPreviewFixture()
expect(getMarkdownAnnotationBlockKeyForSelection(root, null)).toBeNull()
})
})
describe('isMarkdownPreviewAddReviewNoteShortcut', () => {
it('matches the default binding and respects overrides', () => {
const defaultEvent = {
key: 'n',
code: 'KeyN',
metaKey: true,
ctrlKey: false,
altKey: true,
shiftKey: false
}
expect(isMarkdownPreviewAddReviewNoteShortcut(defaultEvent, 'darwin')).toBe(true)
expect(isMarkdownPreviewAddReviewNoteShortcut(defaultEvent, 'linux')).toBe(false)
expect(
isMarkdownPreviewAddReviewNoteShortcut(defaultEvent, 'darwin', {
'editor.addReviewNote': ['Mod+Shift+A']
})
).toBe(false)
})
})
@@ -0,0 +1,38 @@
import { keybindingMatchesAction, type KeybindingOverrides } from '../../../../shared/keybindings'
export function isMarkdownPreviewAddReviewNoteShortcut(
event: Pick<KeyboardEvent, 'key' | 'code' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>,
platform: NodeJS.Platform,
keybindings?: KeybindingOverrides
): boolean {
return keybindingMatchesAction('editor.addReviewNote', event, platform, keybindings)
}
function closestAnnotationBlockKey(node: Node | null, root: HTMLElement): string | null {
const element = node instanceof Element ? node : (node?.parentElement ?? null)
const block = element?.closest('[data-annotation-block-key]') ?? null
if (!block || !root.contains(block)) {
return null
}
return block.getAttribute('data-annotation-block-key')
}
/**
* Maps the current DOM text selection to the annotation block that should host
* the review-note composer. Returns null when the selection is collapsed or
* falls outside an annotatable block of this preview root.
*/
export function getMarkdownAnnotationBlockKeyForSelection(
root: HTMLElement,
selection: Selection | null
): string | null {
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
return null
}
// Why: a selection spanning multiple blocks anchors the composer on the
// block where the selection started, falling back to where it ended.
return (
closestAnnotationBlockKey(selection.anchorNode, root) ??
closestAnnotationBlockKey(selection.focusNode, root)
)
}
@@ -0,0 +1,21 @@
import type { KeyHandlerContext } from './rich-markdown-key-handler'
import { editorShortcutMatches } from './editor-shortcuts'
/**
* Mod+Alt+N: open the review-note composer for the current selection.
*/
export function handleRichMarkdownAddReviewNoteShortcut(
ctx: KeyHandlerContext,
event: KeyboardEvent
): boolean {
if (!editorShortcutMatches('editor.addReviewNote', event)) {
return false
}
// Why: require the live selection so a collapsed selection cannot reopen a
// stale target; consume the chord only when a composer actually opens.
if (!ctx.openAnnotationPopoverRef.current(true)) {
return false
}
event.preventDefault()
return true
}
@@ -73,6 +73,7 @@ function createConfigParams(overrides: Partial<EditorConfigParams> = {}): Editor
markdownSourceLineOffsetRef: ref(0),
flushPendingSerialization: vi.fn(),
openSearchRef: ref(vi.fn()),
openAnnotationPopoverRef: ref(vi.fn()),
syncAnnotationTarget: vi.fn(),
clearAnnotationTarget: vi.fn(),
scrollRichMarkdownReviewNoteCardIntoView: vi.fn(),
@@ -77,6 +77,7 @@ export type EditorConfigParams = {
markdownSourceLineOffsetRef: MutableRefObject<number>
flushPendingSerialization: () => void
openSearchRef: MutableRefObject<() => void>
openAnnotationPopoverRef: MutableRefObject<(requireLiveSelection?: boolean) => boolean>
syncAnnotationTarget: (editor: Editor) => void
clearAnnotationTarget: () => void
scrollRichMarkdownReviewNoteCardIntoView: (commentId: string) => void
@@ -109,17 +110,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
reconcileRoundTripRef,
onContentChangeRef,
onDirtyStateHintRef,
onSaveRef,
onOpenDocLinkRef,
isEditingLinkRef,
slashMenuRef,
filteredSlashCommandsRef,
selectedCommandIndexRef,
docLinkMenuRef,
filteredDocLinkRowsRef,
selectedDocLinkIndexRef,
handleLocalImagePickRef,
handleEmojiPickRef,
typedEmptyOrderedListMarkerRef,
cancelAutoFocusRef,
serializeTimerRef,
@@ -127,15 +118,11 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
isApplyingProgrammaticUpdateRef,
markdownCommentsRef,
markdownSourceLineOffsetRef,
flushPendingSerialization,
openSearchRef,
syncAnnotationTarget,
clearAnnotationTarget,
scrollRichMarkdownReviewNoteCardIntoView,
setIsEditingLink,
setLinkBubble,
setSelectedCommandIndex,
setSelectedDocLinkIndex,
setSlashMenu,
setDocLinkMenu
} = params
@@ -172,36 +159,12 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
typedEmptyOrderedListMarkerRef.current = /^\d+\.$/.test(beforeCursor)
return false
},
// Why: KeyHandlerContext is a typed subset of EditorConfigParams, so the
// spread stays type-checked while new context fields avoid re-listing
// every ref here.
handleKeyDown: createRichMarkdownKeyHandler({
isMac,
editorRef,
rootRef,
lastCommittedMarkdownRef,
originalSourceRef,
baseCanonicalRef,
reconcileRoundTripRef,
onContentChangeRef,
onSaveRef,
isEditingLinkRef,
slashMenuRef,
filteredSlashCommandsRef,
selectedCommandIndexRef,
docLinkMenuRef,
filteredDocLinkRowsRef,
selectedDocLinkIndexRef,
handleLocalImagePickRef,
handleEmojiPickRef,
typedEmptyOrderedListMarkerRef,
flushPendingSerialization,
openSearchRef,
...params,
linkBubbleOwnerId: codec.transport.key,
htmlSuperscriptLinkContext,
setIsEditingLink,
setLinkBubble,
setSelectedCommandIndex,
setSelectedDocLinkIndex,
setSlashMenu,
setDocLinkMenu,
openSelectedHtmlSuperscriptLink: () =>
openSelectedHtmlSuperscriptLink({
activateMarkdownLink,
@@ -4,6 +4,12 @@ import StarterKit from '@tiptap/starter-kit'
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import { createRichMarkdownKeyHandler, type KeyHandlerContext } from './rich-markdown-key-handler'
// Why: keybinding matching resolves the platform from navigator.userAgent,
// which is environment-dependent under vitest; pin it for determinism.
vi.mock('@/lib/shortcut-platform', () => ({
getShortcutPlatform: () => 'darwin' as NodeJS.Platform
}))
const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()]
function createEditor(content: object): Editor {
@@ -82,6 +88,7 @@ function createContext(editor: Editor, typedMarker: boolean): KeyHandlerContext
subscribe: () => () => {},
update: () => {}
},
openAnnotationPopoverRef: { current: vi.fn(() => true) },
setIsEditingLink: vi.fn(),
setLinkBubble: vi.fn(),
setSelectedCommandIndex: vi.fn(),
@@ -105,6 +112,37 @@ function emptyTopLevelOrderedList(): object {
}
describe('rich markdown key handler', () => {
it('opens the review-note composer on the add-review-note shortcut', () => {
const editor = createEditor(emptyTopLevelOrderedList())
try {
const ctx = createContext(editor, false)
const event = keyEvent('n', { metaKey: true, altKey: true, code: 'KeyN' })
expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(true)
expect(event.preventDefault).toHaveBeenCalled()
expect(ctx.openAnnotationPopoverRef.current).toHaveBeenCalledWith(true)
} finally {
editor.destroy()
}
})
it('leaves the add-review-note chord unconsumed when no composer opens', () => {
const editor = createEditor(emptyTopLevelOrderedList())
try {
const ctx = createContext(editor, false)
ctx.openAnnotationPopoverRef.current = vi.fn(() => false)
const event = keyEvent('n', { metaKey: true, altKey: true, code: 'KeyN' })
expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(false)
expect(event.preventDefault).not.toHaveBeenCalled()
expect(ctx.openAnnotationPopoverRef.current).toHaveBeenCalledTimes(1)
} finally {
editor.destroy()
}
})
it('preserves a typed empty ordered-list shortcut on Enter', () => {
const editor = createEditor(emptyTopLevelOrderedList())
@@ -3,6 +3,7 @@ import type { Editor } from '@tiptap/react'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { useAppStore } from '@/store'
import { isMarkdownPreviewFindShortcut } from './markdown-preview-search'
import { handleRichMarkdownAddReviewNoteShortcut } from './rich-markdown-annotation-shortcut'
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
import { commitRow, type DocLinkMenuRow, type DocLinkMenuState } from './rich-markdown-commands'
import {
@@ -21,6 +22,7 @@ import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
import { handleRichMarkdownLinkShortcut } from './rich-markdown-link-shortcut'
import { handleRichMarkdownSaveShortcut } from './rich-markdown-save-shortcut'
import { flushPendingProseMirrorSelection } from './rich-markdown-selection-flush'
export type KeyHandlerContext = {
isMac: boolean
@@ -44,6 +46,7 @@ export type KeyHandlerContext = {
typedEmptyOrderedListMarkerRef: MutableRefObject<boolean>
flushPendingSerialization: () => void
openSearchRef: MutableRefObject<() => void>
openAnnotationPopoverRef: MutableRefObject<(requireLiveSelection?: boolean) => boolean>
setIsEditingLink: (editing: boolean) => void
setLinkBubble: (bubble: LinkBubbleState | null) => void
setSelectedCommandIndex: Dispatch<SetStateAction<number>>
@@ -59,47 +62,6 @@ function isComposingMarkdownInput(event: KeyboardEvent, editor: Editor | null):
return event.isComposing || editor?.view.composing === true
}
type NativeSelectionSnapshot = {
anchorNode: Node | null
anchorOffset: number
focusNode: Node | null
focusOffset: number
}
type ProseMirrorDomObserver = {
currentSelection?: {
set?: (selection: NativeSelectionSnapshot) => void
}
flush?: () => void
}
type ProseMirrorViewWithDomObserver = Editor['view'] & {
domObserver?: ProseMirrorDomObserver
}
function flushPendingProseMirrorSelection(editor: Editor): void {
let observer: ProseMirrorDomObserver | undefined
try {
observer = (editor.view as ProseMirrorViewWithDomObserver).domObserver
} catch {
return
}
if (typeof observer?.flush !== 'function') {
return
}
// Why: immediate Tab after a mouse click can run before ProseMirror has
// copied the native selection into editor state, so list commands hit stale item state.
observer.currentSelection?.set?.({
anchorNode: null,
anchorOffset: 0,
focusNode: null,
focusOffset: 0
})
observer.flush()
}
/**
* Why: extracted from RichMarkdownEditor to stay under the file line-limit
* while keeping the keyboard handler logic co-located and testable.
@@ -133,6 +95,9 @@ export function createRichMarkdownKeyHandler(
if (handleRichMarkdownSaveShortcut(ctx, event)) {
return true
}
if (handleRichMarkdownAddReviewNoteShortcut(ctx, event)) {
return true
}
// Strikethrough: Cmd/Ctrl+Shift+X (standard shortcut used by Google
// Docs, Notion, etc. — supplements Tiptap's built-in Mod+Shift+S).
@@ -0,0 +1,42 @@
import type { Editor } from '@tiptap/react'
type NativeSelectionSnapshot = {
anchorNode: Node | null
anchorOffset: number
focusNode: Node | null
focusOffset: number
}
type ProseMirrorDomObserver = {
currentSelection?: {
set?: (selection: NativeSelectionSnapshot) => void
}
flush?: () => void
}
type ProseMirrorViewWithDomObserver = Editor['view'] & {
domObserver?: ProseMirrorDomObserver
}
export function flushPendingProseMirrorSelection(editor: Editor): void {
let observer: ProseMirrorDomObserver | undefined
try {
observer = (editor.view as ProseMirrorViewWithDomObserver).domObserver
} catch {
return
}
if (typeof observer?.flush !== 'function') {
return
}
// Why: immediate Tab after a mouse click can run before ProseMirror has
// copied the native selection into editor state, so list commands hit stale item state.
observer.currentSelection?.set?.({
anchorNode: null,
anchorOffset: 0,
focusNode: null,
focusOffset: 0
})
observer.flush()
}
@@ -89,6 +89,7 @@ function createContext(editor: Editor): KeyHandlerContext {
subscribe: () => () => {},
update: () => {}
},
openAnnotationPopoverRef: { current: vi.fn() },
setIsEditingLink: vi.fn(),
setLinkBubble: vi.fn(),
setSelectedCommandIndex: vi.fn(),
@@ -0,0 +1,44 @@
import { useCallback, useMemo, type MutableRefObject } from 'react'
import type { MarkdownTocItem } from './markdown-table-of-contents'
import { findRichMarkdownTocHeadingTarget } from './rich-markdown-toc-heading-target'
import { selectMarkdownTableOfContents } from './markdown-toc-visibility-gate'
function flattenMarkdownTocItems(items: MarkdownTocItem[]): MarkdownTocItem[] {
return items.flatMap((item) => [item, ...flattenMarkdownTocItems(item.children)])
}
export function useRichMarkdownTableOfContents(
showTableOfContents: boolean,
content: string,
scrollContainerRef: MutableRefObject<HTMLElement | null>
): {
tableOfContentsItems: MarkdownTocItem[]
navigateToTableOfContentsItem: (id: string) => void
} {
// Why: building the table of contents runs a full-document remark parse on
// every content change. The result is only used while the panel is open
// (closed by default), so gate the parse on visibility; including
// showTableOfContents in deps rebuilds the outline the moment it opens.
const tableOfContentsItems = useMemo(
() => selectMarkdownTableOfContents(showTableOfContents, content),
[content, showTableOfContents]
)
const flatTableOfContentsItems = useMemo(
() => flattenMarkdownTocItems(tableOfContentsItems),
[tableOfContentsItems]
)
const navigateToTableOfContentsItem = useCallback(
(id: string): void => {
const container = scrollContainerRef.current
if (!container) {
return
}
const heading = findRichMarkdownTocHeadingTarget(container, flatTableOfContentsItems, id)
heading?.scrollIntoView({ block: 'center' })
},
[flatTableOfContentsItems, scrollContainerRef]
)
return { tableOfContentsItems, navigateToTableOfContentsItem }
}
@@ -201,41 +201,51 @@ export function useRichMarkdownReviewController({
]
)
const openAnnotationPopover = useCallback((): void => {
if (!annotationTarget || !canAnnotateRichMarkdown) {
return
}
const editor = editorRef.current
const root = rootRef.current
const liveTarget = editor && root ? getRichMarkdownAnnotationTarget(editor, root) : null
const target = editor
? clampRichMarkdownAnnotationTarget(editor, liveTarget ?? annotationTarget)
: annotationTarget
if (
!target ||
hasRichMarkdownCommentForRange(markdownComments, target, markdownSourceLineOffset)
) {
// Why: reports whether a composer actually opened so keyboard callers can
// leave the chord unconsumed when this no-ops (mouse callers ignore it).
const openAnnotationPopover = useCallback(
(requireLiveSelection = false): boolean => {
if (!canAnnotateRichMarkdown) {
return false
}
const editor = editorRef.current
const root = rootRef.current
// Why: keyboard callers require the live selection to avoid stale-target
// races; the mouse button may use the target from the render that exposed it.
const liveTarget = editor && root ? getRichMarkdownAnnotationTarget(editor, root) : null
const baseTarget = liveTarget ?? (requireLiveSelection ? null : annotationTarget)
if (!baseTarget) {
return false
}
const target = editor ? clampRichMarkdownAnnotationTarget(editor, baseTarget) : baseTarget
if (
!target ||
hasRichMarkdownCommentForRange(markdownComments, target, markdownSourceLineOffset)
) {
setAnnotationTarget(null)
return false
}
editor?.view.dispatch(
editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, {
activeRange: { from: target.from, to: target.to }
})
)
// Why: opening a draft should reserve the notes rail immediately; saved notes stay visible.
setReviewRailOpen(true)
setAnnotationPopover(target)
setAnnotationTarget(null)
return
}
editor?.view.dispatch(
editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, {
activeRange: { from: target.from, to: target.to }
})
)
// Why: opening a draft should reserve the notes rail immediately; saved notes stay visible.
setReviewRailOpen(true)
setAnnotationPopover(target)
setAnnotationTarget(null)
}, [
annotationTarget,
canAnnotateRichMarkdown,
editorRef,
markdownComments,
markdownSourceLineOffset,
rootRef,
setReviewRailOpen
])
return true
},
[
annotationTarget,
canAnnotateRichMarkdown,
editorRef,
markdownComments,
markdownSourceLineOffset,
rootRef,
setReviewRailOpen
]
)
useEffect(() => {
if (canAnnotateRichMarkdown) {
@@ -106,12 +106,14 @@ const mocks = vi.hoisted(() => ({
getFloatingMarkdownDirectory: vi.fn(),
getFloatingTerminalCwd: vi.fn(),
getInstallStatus: vi.fn(),
isTerminalImeInputContextRefreshing: vi.fn(),
isWebRuntimeSessionActive: vi.fn(),
markFileDirty: vi.fn(),
makePreviewFilePermanent: vi.fn(),
openFile: vi.fn(),
pickFloatingMarkdownDocument: vi.fn(),
pinFile: vi.fn(),
setFloatingTerminalInputFocused: vi.fn(),
setActiveTab: vi.fn(),
setRenamingTabId: vi.fn(),
setTabColor: vi.fn(),
@@ -185,6 +187,10 @@ vi.mock('@/components/terminal-pane/TerminalPane', () => ({
}
}))
vi.mock('@/components/terminal-pane/terminal-ime-input-context-refresh', () => ({
isTerminalImeInputContextRefreshing: mocks.isTerminalImeInputContextRefreshing
}))
vi.mock('@/components/browser-pane/BrowserPane', () => ({
default: function BrowserPane() {
return null
@@ -744,6 +750,7 @@ describe('FloatingTerminalPanel close behavior', () => {
mocks.getFloatingMarkdownDirectory.mockResolvedValue('/tmp/orca/floating-notes')
mocks.getFloatingTerminalCwd.mockResolvedValue('/tmp/orca')
mocks.getInstallStatus.mockResolvedValue({ state: 'installed', pathConfigured: true })
mocks.isTerminalImeInputContextRefreshing.mockReturnValue(false)
mocks.isWebRuntimeSessionActive.mockReturnValue(false)
mocks.pickFloatingMarkdownDocument.mockResolvedValue(null)
const localStorage = {
@@ -762,7 +769,7 @@ describe('FloatingTerminalPanel close behavior', () => {
},
browser: { notifyActiveTabChanged: vi.fn() },
cli: { getInstallStatus: mocks.getInstallStatus },
ui: { setFloatingTerminalInputFocused: vi.fn() }
ui: { setFloatingTerminalInputFocused: mocks.setFloatingTerminalInputFocused }
},
innerHeight: 800,
innerWidth: 1200,
@@ -799,6 +806,110 @@ describe('FloatingTerminalPanel close behavior', () => {
expect(getPanelClassName(element)).toContain('z-30')
})
it('refreshes terminal native input focus when the floating panel opens', async () => {
setFloatingTabs([makeTab({ id: 'tab-1' })])
await renderPanel(true)
runEffects()
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith(
'tab-1',
null,
expect.objectContaining({
onImeRefocusSkipped: expect.any(Function),
refreshImeContext: true
})
)
mocks.setFloatingTerminalInputFocused.mockClear()
mocks.focusTerminalTabSurface.mock.calls[0]?.[2].onImeRefocusSkipped()
expect(mocks.setFloatingTerminalInputFocused).toHaveBeenCalledWith(false)
const newerFloatingInput = {
classList: { contains: (token: string) => token === 'xterm-helper-textarea' },
closest: vi.fn().mockReturnValue({})
}
Object.setPrototypeOf(newerFloatingInput, HTMLElement.prototype)
mocks.focusTerminalTabSurface.mock.calls[0]?.[2].onImeRefocusSkipped(newerFloatingInput)
expect(mocks.setFloatingTerminalInputFocused).toHaveBeenLastCalledWith(true)
})
it('preserves and reclaims terminal input ownership across window blur', async () => {
setFloatingTabs([makeTab({ id: 'tab-1' })])
const element = await renderPanel(true)
const panel = findByProp(element, 'data-floating-terminal-panel')
const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() }
const terminalInput = {
blur: vi.fn(),
classList: { contains: vi.fn((token: string) => token === 'xterm-helper-textarea') },
closest: vi.fn((selector: string) => {
if (selector === '[data-floating-terminal-panel]') {
return panelElement
}
return selector === '[data-leaf-id]'
? {
getAttribute: (attribute: string) => (attribute === 'data-leaf-id' ? 'leaf-1' : null)
}
: null
}),
isConnected: true
}
Object.setPrototypeOf(panelElement, HTMLElement.prototype)
Object.setPrototypeOf(terminalInput, HTMLElement.prototype)
attachRef(panel.props.ref, panelElement)
mocks.isTerminalImeInputContextRefreshing.mockReturnValueOnce(true)
const onBlurCapture = panel.props.onBlurCapture as (event: unknown) => void
onBlurCapture({
relatedTarget: null,
target: terminalInput
})
expect(mocks.setFloatingTerminalInputFocused).not.toHaveBeenCalled()
const documentState = {
activeElement: terminalInput as unknown as HTMLElement | null,
addEventListener: vi.fn(),
body: {} as HTMLElement,
removeEventListener: vi.fn()
}
vi.stubGlobal('document', documentState)
runEffects()
const blurListener = vi
.mocked(window.addEventListener)
.mock.calls.findLast(([type]) => type === 'blur')?.[1] as (() => void) | undefined
const focusListener = vi
.mocked(window.addEventListener)
.mock.calls.find(([type]) => type === 'focus')?.[1] as (() => void) | undefined
if (!blurListener || !focusListener) {
throw new Error('floating terminal window focus listeners not registered')
}
blurListener()
expect(terminalInput.blur).not.toHaveBeenCalled()
focusListener()
expect(mocks.setFloatingTerminalInputFocused).toHaveBeenCalledWith(true)
documentState.activeElement = terminalInput as unknown as HTMLElement
blurListener()
documentState.activeElement = documentState.body
mocks.focusTerminalTabSurface.mockClear()
focusListener()
expect(mocks.focusTerminalTabSurface).not.toHaveBeenCalled()
documentState.activeElement = terminalInput as unknown as HTMLElement
blurListener()
terminalInput.isConnected = false
documentState.activeElement = documentState.body
focusListener()
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith(
'tab-1',
'leaf-1',
expect.objectContaining({
onlyIfFocusUnclaimed: true,
onImeRefocusSkipped: expect.any(Function),
refreshImeContext: true
})
)
})
it('falls back to default bounds when persisted geometry is malformed', async () => {
getMockedLocalStorage().getItem.mockImplementation((key: string) =>
key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY
@@ -12,6 +12,7 @@ import { useContextualTour } from '@/components/contextual-tours/use-contextual-
import TabBar from '@/components/tab-bar/TabBar'
import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id'
import TerminalPane from '@/components/terminal-pane/TerminalPane'
import { isTerminalImeInputContextRefreshing } from '@/components/terminal-pane/terminal-ime-input-context-refresh'
import { Button } from '@/components/ui/button'
import { useMountedRef } from '@/hooks/useMountedRef'
import { useShortcutKeyDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
@@ -222,6 +223,10 @@ export function FloatingTerminalPanel({
}
const shortcutFocusFrameRef = useRef<number | null>(null)
const shortcutFocusTimeoutRef = useRef<number | null>(null)
const reclaimTerminalInputOnWindowFocusRef = useRef<{
helper: HTMLElement
leafId: string | null
} | null>(null)
const mountedRef = useMountedRef()
const dragRef = useRef<{
pointerId: number
@@ -578,7 +583,11 @@ export function FloatingTerminalPanel({
if (!open || !activeTerminalId) {
return
}
focusTerminalTabSurface(activeTerminalId)
focusTerminalTabSurface(activeTerminalId, null, {
onImeRefocusSkipped: (active) =>
setFloatingTerminalInputFocusedInMain(isFloatingWorkspaceTerminalInputTarget(active)),
refreshImeContext: true
})
}, [activeTerminalId, open])
useEffect(() => {
@@ -1313,22 +1322,69 @@ export function FloatingTerminalPanel({
const handleWindowBlur = (): void => {
const panel = panelRef.current
const active = document.activeElement
reclaimTerminalInputOnWindowFocusRef.current = null
if (!panel || !(active instanceof HTMLElement) || !panel.contains(active)) {
return
}
// Why: browser webviews focus out-of-process and do not emit renderer
// pointerdown events, so release floating ownership on renderer blur too.
setFloatingTerminalInputFocusedInMain(false)
if (isFloatingWorkspaceTerminalInputTarget(active)) {
// Why: the terminal focus lifecycle preserves this exact helper across
// app blur so macOS can rebuild its native input context on return.
reclaimTerminalInputOnWindowFocusRef.current = {
helper: active,
leafId: active.closest('[data-leaf-id]')?.getAttribute('data-leaf-id') ?? null
}
return
}
active.blur()
}
const handleWindowFocus = (): void => {
const reclaim = reclaimTerminalInputOnWindowFocusRef.current
if (!reclaim) {
return
}
reclaimTerminalInputOnWindowFocusRef.current = null
const panel = panelRef.current
const active = document.activeElement
if (
panel &&
active instanceof HTMLElement &&
panel.contains(active) &&
isFloatingWorkspaceTerminalInputTarget(active)
) {
setFloatingTerminalInputFocusedInMain(true)
return
}
if ((active === null || active === document.body) && activeTerminalId) {
if (reclaim.helper.isConnected && panel?.contains(reclaim.helper)) {
// Why: TerminalPane owns exact-helper reclaim and IME refresh. Avoid
// racing it with a second floating-layer blur/refocus cycle.
return
}
// Why: only a helper that genuinely remounted while backgrounded needs
// tab/leaf recovery; the shared TerminalPane owner cannot reclaim it.
focusTerminalTabSurface(activeTerminalId, reclaim.leafId, {
onlyIfFocusUnclaimed: true,
onImeRefocusSkipped: (active) =>
setFloatingTerminalInputFocusedInMain(isFloatingWorkspaceTerminalInputTarget(active)),
refreshImeContext: true
})
}
}
document.addEventListener('pointerdown', handleOutsidePointerDown, true)
window.addEventListener('blur', handleWindowBlur)
window.addEventListener('focus', handleWindowFocus)
return () => {
reclaimTerminalInputOnWindowFocusRef.current = null
document.removeEventListener('pointerdown', handleOutsidePointerDown, true)
window.removeEventListener('blur', handleWindowBlur)
window.removeEventListener('focus', handleWindowFocus)
}
}, [open])
}, [activeTerminalId, open])
const handleDragStart = (event: React.PointerEvent<HTMLDivElement>): void => {
if (maximized) {
return
@@ -1423,7 +1479,13 @@ export function FloatingTerminalPanel({
commitUserBounds({ ...stagedBoundsRef.current, width: rect.width, height: rect.height })
}}
onFocusCapture={(event) => setFloatingTerminalInputFocused(event.target)}
onBlurCapture={(event) => setFloatingTerminalInputFocused(event.relatedTarget)}
onBlurCapture={(event) => {
// Why: keep terminal-first shortcut ownership latched during the
// synchronous macOS IME refresh blur; refocus or its skip callback settles it.
if (!isTerminalImeInputContextRefreshing(event.target)) {
setFloatingTerminalInputFocused(event.relatedTarget)
}
}}
onKeyDownCapture={handleShortcutSurfaceKeyDown}
>
<div className="relative flex h-full w-full min-h-0 flex-col overflow-hidden rounded-lg border border-black/14 bg-card dark:border-white/14">

Some files were not shown because too many files have changed in this diff Show More