mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 16:02:42 +00:00
Merge branch 'main' of https://github.com/stablyai/orca into native-chat-still-shows-loading-when-ai-done
This commit is contained in:
@@ -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 |
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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$/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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 () => {
|
||||
|
||||
+634
-184
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)`
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'")
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user