From 7574ee8403b30dbe3142c114e6633228986ac0a6 Mon Sep 17 00:00:00 2001 From: iverJisty Date: Fri, 4 Sep 2026 13:19:40 +0800 Subject: [PATCH 01/58] fix(ports): route the status-bar popover scan to the workspace's host (#17048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ports): route the status-bar popover scan to the workspace's host - PortsStatusSegment resolved its runtime target from the global active runtime, so opening the popover on a paired-remote workspace scanned the client OS and reported zero workspace ports - Resolve the target from the active worktree's owner host, matching PortsPanel, PortRow, and WorktreeCardPorts - Add publishWorkspacePortScanForHost: store the host's scan under its own key, then republish the aggregate through setWorkspacePortScanProjection so a single-host refresh no longer drops every other host's ports - Publish through the projection setter instead of setWorkspacePortScan, which wrote the synthetic all-hosts key back into workspacePortScansByKey and made the next merge fold the aggregate into itself (duplicate rows) - Reuse the helper for the manual panel refresh and the post-stop refresh, and share the aggregate key constant with WorkspacePortScanner * test(ports): cover popover host routing and aggregate preservation - PortsStatusSegment.host-routing: popover scans the active workspace's owner host, keeps other hosts in the projection, publishes a failed scan under its own host, and stays local when the workspace has no owner - workspace-port-scan-publish: single-host key vs all-hosts projection, and repeated publishes never accumulate duplicate rows * fix(ports): surface a host whose port scan failed instead of dropping it - The merged projection only carries unavailableReason when every host failed, so one unreachable server read as "this workspace has no ports" - Add getUnavailableWorkspacePortHosts: hosts that failed while another host still answered, with the local host distinguished by a null environment id - Show one notice per failed host in the popover, named by its runtime environment or the local host label, above the surviving hosts' ports - Reuse the existing scan-unavailable string so no catalog entry is added * test(ports): prove the popover's own failed scan reaches the host notice - Make the mocked store setters write back, so a publish and the notice that reads it can no longer name different scan keys with every assertion green - Cover open popover -> remote scan rejects -> notice names the host, the seam the store-write and render-only tests each stopped short of - Drop an assertion comment that claimed to prove port preservation when it only exercised the render path * fix(ports): review nits — single-write publish, colon-safe host keys, failure port retention, docstrings * fix(ports): keep the popover count and body in agreement, name every failed host - A failed scan retains the host's last-good ports, and the badge/header count them; the notice now sits above the list instead of replacing it, so the popover no longer claims N ports over an empty body. - getUnavailableWorkspacePortHosts reports all-hosts-failed too, so total loss of contact names each host instead of printing raw scan keys under platform 'unknown'. - Scan keys parse to a discriminated host ref, so an unrecognised key is 'unknown' rather than silently blamed on the local machine. - Extract useWorktreeRuntimeTarget for the four ports surfaces that hand-rolled the same owner-settings spread. * fix(ports): label the local host from the failed scan's platform, not the renderer's userAgent A paired web client's browser is not the Orca host, so deriving 'Local Mac' from navigator.userAgent mislabels a Linux host. Carry each failed scan's own platform through the unavailable-host list instead. * fix(ports): keep the Ports panel list under its failure notice too The retained-ports change gave a failed scan both ports and an unavailableReason, and the right-sidebar panel hid every section behind the notice — stripping the stop and open actions for ports the status bar still counts. Gate the sections on whether anything is left to list, matching the popover, behind a testable predicate. * fix(ports): let a retained-port failure keep its debounce grace period The popover publishes the host's last-good ports alongside the failure reason the moment its own scan fails. reconcileTransientPortScanFailures treated any published result carrying unavailableReason as a spent grace period, so the very next background poll replaced those ports with an empty unavailable scan — the retention never survived one poll interval. Keep the grace while the published result still has ports; the tolerance still clears them on schedule. * fix(ports): prune stale hosts in the poll's single map write A manual publish (the ports popover) can resolve after the host-set change already pruned its key, re-adding it; the poll's per-key writes only ever added, so a removed host kept its ports in the count and held a permanent unavailable notice until the next host-set change. Publish the poll's already-pruned map in one replaceWorkspacePortScans instead, which also collapses N per-host notifications into one and drops any synthetic all-hosts key that leaked in. * fix(ports): fail closed for direct SSH workspaces --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- .../ports/WorkspacePortScanner.test.tsx | 31 ++ .../components/ports/WorkspacePortScanner.tsx | 37 +- .../right-sidebar/PortsPanel.test.tsx | 53 +-- .../local-workspace-port-sections.test.ts | 30 ++ .../local-workspace-port-sections.ts | 20 + .../local-workspace-ports-panel.tsx | 63 ++- .../WorktreeCard.compact-hover.test.tsx | 4 +- ....compact-ports-hover-independence.test.tsx | 4 +- .../sidebar/WorktreeCardPorts.test.tsx | 2 +- .../components/sidebar/WorktreeCardPorts.tsx | 24 +- .../PortsStatusSegment.host-routing.test.tsx | 407 ++++++++++++++++++ .../status-bar/PortsStatusSegment.tsx | 141 ++++-- .../ports-status-popover-rows.test.tsx | 5 +- .../status-bar/ports-status-popover-rows.tsx | 26 +- .../src/lib/workspace-port-actions.ts | 85 ++-- .../workspace-port-host-availability.test.ts | 148 +++++++ .../lib/workspace-port-host-availability.ts | 65 +++ .../lib/workspace-port-scan-debounce.test.ts | 32 ++ .../src/lib/workspace-port-scan-debounce.ts | 10 +- .../lib/workspace-port-scan-publish.test.ts | 153 +++++++ .../src/runtime/runtime-client-target.ts | 15 + .../runtime/use-worktree-runtime-target.ts | 16 + 22 files changed, 1185 insertions(+), 186 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/local-workspace-port-sections.test.ts create mode 100644 src/renderer/src/components/status-bar/PortsStatusSegment.host-routing.test.tsx create mode 100644 src/renderer/src/lib/workspace-port-host-availability.test.ts create mode 100644 src/renderer/src/lib/workspace-port-host-availability.ts create mode 100644 src/renderer/src/lib/workspace-port-scan-publish.test.ts create mode 100644 src/renderer/src/runtime/use-worktree-runtime-target.ts diff --git a/src/renderer/src/components/ports/WorkspacePortScanner.test.tsx b/src/renderer/src/components/ports/WorkspacePortScanner.test.tsx index be1c69cbac7..0ae53fee931 100644 --- a/src/renderer/src/components/ports/WorkspacePortScanner.test.tsx +++ b/src/renderer/src/components/ports/WorkspacePortScanner.test.tsx @@ -493,6 +493,37 @@ describe('WorkspacePortScanner', () => { expect(useAppStore.getState().workspacePortScansByKey['environment:env-3:all']).toBeUndefined() }) + // Why: a manual publish (the ports popover) can resolve after the host-set + // change already pruned its key, re-adding it. Per-key writes never delete, so + // that removed host would otherwise hold its ports and a permanent + // unavailable notice until the next host-set change. + it('drops a stale host re-added after pruning on the next poll', async () => { + await act(async () => { + root?.render() + await flushPromises() + }) + + const staleKey = 'environment:env-removed:all' + act(() => { + const state = useAppStore.getState() + state.replaceWorkspacePortScans( + { + ...state.workspacePortScansByKey, + [staleKey]: { ...emptyScan, unavailableReason: 'gone' } + }, + state.workspacePortScan + ) + }) + expect(useAppStore.getState().workspacePortScansByKey[staleKey]).toBeDefined() + + await act(async () => { + vi.advanceTimersByTime(30_000) + await flushPromises() + }) + + expect(useAppStore.getState().workspacePortScansByKey[staleKey]).toBeUndefined() + }) + it('clears ports immediately when the final worktree is removed', async () => { runtimeEnvironmentCall.mockImplementation(({ method }) => { if (method === 'workspacePorts.scan') { diff --git a/src/renderer/src/components/ports/WorkspacePortScanner.tsx b/src/renderer/src/components/ports/WorkspacePortScanner.tsx index f2805eb88a7..2b12bd86cd8 100644 --- a/src/renderer/src/components/ports/WorkspacePortScanner.tsx +++ b/src/renderer/src/components/ports/WorkspacePortScanner.tsx @@ -4,10 +4,11 @@ import { getHasAnyWorktreesFromState } from '@/store/selectors' import { getActiveRuntimeTarget, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { mergeWorkspacePortScans, - runtimeTargetForExecutionHostId, + WORKSPACE_PORT_ALL_HOSTS_SCAN_KEY, scanWorkspacePortsForTarget, workspacePortScanKeyForTarget } from '@/lib/workspace-port-actions' +import { runtimeTargetForExecutionHostId } from '@/runtime/runtime-client-target' import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval' import { reconcileTransientPortScanFailures, @@ -41,7 +42,6 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) const setWorkspacePortScanProjection = useAppStore((s) => s.setWorkspacePortScanProjection) const replaceWorkspacePortScans = useAppStore((s) => s.replaceWorkspacePortScans) - const setWorkspacePortScanForKey = useAppStore((s) => s.setWorkspacePortScanForKey) const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing) const inFlightRef = useRef | null>(null) const generationRef = useRef(0) @@ -124,40 +124,40 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): const activeTargetKeys = new Set( allTargets.map((target) => workspacePortScanKeyForTarget(target)) ) + const publishedScans = useAppStore.getState().workspacePortScansByKey const reconciled = reconcileTransientPortScanFailures( results, - useAppStore.getState().workspacePortScansByKey, + publishedScans, portScanDebounceRef.current, WORKSPACE_PORT_SCAN_FAILURE_THRESHOLD, activeTargetKeys ) const scansByKey = Object.fromEntries( - Object.entries(useAppStore.getState().workspacePortScansByKey).filter(([key]) => - activeTargetKeys.has(key) - ) + Object.entries(publishedScans).filter(([key]) => activeTargetKeys.has(key)) ) - let sourceChanged = false + // Why: a manual publish that lands after a host is pruned re-adds its key, + // and per-key writes never delete. Dropping the inactive keys here is what + // stops a removed host from holding a permanent unavailable notice. + let sourceChanged = + Object.keys(scansByKey).length !== Object.keys(publishedScans).length for (const { key, result } of reconciled) { sourceChanged ||= scansByKey[key] !== result scansByKey[key] = result - setWorkspacePortScanForKey(key, result) } const activeScan = scansByKey[scanKey] const merged = mergeWorkspacePortScans(scansByKey) const projectionKey = allTargets.length > 1 - ? 'all-hosts:all' + ? WORKSPACE_PORT_ALL_HOSTS_SCAN_KEY : activeScan ? scanKey : workspacePortScanKeyForTarget(allTargets[0]) if (sourceChanged || useAppStore.getState().workspacePortScan?.key !== projectionKey) { - setWorkspacePortScanProjection( - merged - ? { - key: projectionKey, - result: merged - } - : null + // Why: one store update for the whole poll — a large host set must not + // fan out a notification to every subscriber per host. + replaceWorkspacePortScans( + sourceChanged ? scansByKey : publishedScans, + merged ? { key: projectionKey, result: merged } : null ) } } @@ -177,8 +177,7 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): hasWorktrees, scanKey, setWorkspacePortScan, - setWorkspacePortScanProjection, - setWorkspacePortScanForKey, + replaceWorkspacePortScans, setWorkspacePortScanRefreshing ] ) @@ -215,7 +214,7 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): : Object.fromEntries(retainedEntries) const retainedProjection = mergeWorkspacePortScans(retainedScans) const retainedProjectionKey = - targetKeys.size > 1 ? 'all-hosts:all' : Object.keys(retainedScans)[0] + targetKeys.size > 1 ? WORKSPACE_PORT_ALL_HOSTS_SCAN_KEY : Object.keys(retainedScans)[0] // Why: unchanged hosts stay visible while the replacement RPC runs; removed // hosts and the old synthetic aggregate are excluded immediately. const nextProjection = diff --git a/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx b/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx index ea1d85f15b8..1121a05ae64 100644 --- a/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx +++ b/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx @@ -451,25 +451,26 @@ describe('PortsPanel runtime routing', () => { }) it('returns post-stop refresh failures without throwing', async () => { - const setWorkspacePortScan = vi.fn() + const replaceWorkspacePortScans = vi.fn() const setWorkspacePortScanRefreshing = vi.fn() localScan.mockRejectedValueOnce(new Error('scan failed')) await expect( refreshWorkspacePortScanAfterStop({ runtimeTarget: { kind: 'local' }, - setWorkspacePortScan: setWorkspacePortScan as never, + replaceWorkspacePortScans: replaceWorkspacePortScans as never, + getWorkspacePortScansByKey: () => ({}), setWorkspacePortScanRefreshing: setWorkspacePortScanRefreshing as never }) ).resolves.toEqual({ ok: false, reason: 'scan failed' }) - expect(setWorkspacePortScan).not.toHaveBeenCalled() + expect(replaceWorkspacePortScans).not.toHaveBeenCalled() expect(setWorkspacePortScanRefreshing).toHaveBeenNthCalledWith(1, true) expect(setWorkspacePortScanRefreshing).toHaveBeenNthCalledWith(2, false) }) it('ignores settled remote post-stop refresh failures after updating state', async () => { - const setWorkspacePortScan = vi.fn() + const replaceWorkspacePortScans = vi.fn() const setWorkspacePortScanRefreshing = vi.fn() const firstScan = { ...emptyScan, scannedAt: 2 } let scanCalls = 0 @@ -500,7 +501,8 @@ describe('PortsPanel runtime routing', () => { await expect( refreshWorkspacePortScanAfterStop({ runtimeTarget: { kind: 'environment', environmentId: 'env-1' }, - setWorkspacePortScan: setWorkspacePortScan as never, + replaceWorkspacePortScans: replaceWorkspacePortScans as never, + getWorkspacePortScansByKey: () => ({}), setWorkspacePortScanRefreshing: setWorkspacePortScanRefreshing as never }) ).resolves.toEqual({ ok: true }) @@ -510,18 +512,20 @@ describe('PortsPanel runtime routing', () => { 'workspacePorts.scan', 'workspacePorts.scan' ]) - expect(setWorkspacePortScan).toHaveBeenCalledTimes(1) - expect(setWorkspacePortScan).toHaveBeenCalledWith({ - key: 'environment:env-1:all', - result: firstScan - }) + expect(replaceWorkspacePortScans).toHaveBeenCalledTimes(1) + expect(replaceWorkspacePortScans).toHaveBeenCalledWith( + { 'environment:env-1:all': firstScan }, + { + key: 'environment:env-1:all', + result: firstScan + } + ) expect(setWorkspacePortScanRefreshing).toHaveBeenNthCalledWith(1, true) expect(setWorkspacePortScanRefreshing).toHaveBeenNthCalledWith(2, false) }) it('preserves an all-host projection after refreshing one host post-stop', async () => { - const setWorkspacePortScan = vi.fn() - const setWorkspacePortScanForKey = vi.fn() + const replaceWorkspacePortScans = vi.fn() const setWorkspacePortScanRefreshing = vi.fn() const localPort: WorkspacePort = { ...workspacePort, id: 'local-port', port: 5173 } const refreshedRemotePort: WorkspacePort = { @@ -571,23 +575,24 @@ describe('PortsPanel runtime routing', () => { await expect( refreshWorkspacePortScanAfterStop({ runtimeTarget: { kind: 'environment', environmentId: 'env-1' }, - setWorkspacePortScan: setWorkspacePortScan as never, - setWorkspacePortScanForKey: setWorkspacePortScanForKey as never, + replaceWorkspacePortScans: replaceWorkspacePortScans as never, getWorkspacePortScansByKey: () => ({ 'local:all': localHostScan }), setWorkspacePortScanRefreshing: setWorkspacePortScanRefreshing as never }) ).resolves.toEqual({ ok: true }) - expect(setWorkspacePortScanForKey).toHaveBeenCalledWith('environment:env-1:all', remoteHostScan) - expect(setWorkspacePortScan).toHaveBeenLastCalledWith({ - key: 'all-hosts:all', - result: expect.objectContaining({ - ports: expect.arrayContaining([ - expect.objectContaining({ port: 5173 }), - expect.objectContaining({ port: 3000 }) - ]) - }) - }) + expect(replaceWorkspacePortScans).toHaveBeenLastCalledWith( + { 'local:all': localHostScan, 'environment:env-1:all': remoteHostScan }, + { + key: 'all-hosts:all', + result: expect.objectContaining({ + ports: expect.arrayContaining([ + expect.objectContaining({ port: 5173 }), + expect.objectContaining({ port: 3000 }) + ]) + }) + } + ) expect(scanCalls).toBe(2) }) diff --git a/src/renderer/src/components/right-sidebar/local-workspace-port-sections.test.ts b/src/renderer/src/components/right-sidebar/local-workspace-port-sections.test.ts new file mode 100644 index 00000000000..68733927ee7 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/local-workspace-port-sections.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { shouldShowLocalWorkspacePortSections } from './local-workspace-port-sections' + +const empty = { activePorts: [], otherWorkspacePorts: [], externalPorts: [] } + +describe('shouldShowLocalWorkspacePortSections', () => { + it('shows the sections whenever the scan succeeded', () => { + expect(shouldShowLocalWorkspacePortSections(null, empty)).toBe(true) + expect(shouldShowLocalWorkspacePortSections({}, empty)).toBe(true) + }) + + // Why: a failed scan keeps the host's last-good ports, and the status bar + // still counts and lists them — hiding the sections here would strip the + // stop and open actions for ports the user can still see elsewhere. + it.each([ + ['activePorts', { ...empty, activePorts: [{}] }], + ['otherWorkspacePorts', { ...empty, otherWorkspacePorts: [{}] }], + ['externalPorts', { ...empty, externalPorts: [{}] }] + ])('keeps the sections when a failed scan retained %s', (_section, sections) => { + expect(shouldShowLocalWorkspacePortSections({ unavailableReason: 'dropped' }, sections)).toBe( + true + ) + }) + + it('lets the notice stand alone when a failed scan has nothing left to list', () => { + expect(shouldShowLocalWorkspacePortSections({ unavailableReason: 'dropped' }, empty)).toBe( + false + ) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/local-workspace-port-sections.ts b/src/renderer/src/components/right-sidebar/local-workspace-port-sections.ts index d968bb85144..2a0eadf380a 100644 --- a/src/renderer/src/components/right-sidebar/local-workspace-port-sections.ts +++ b/src/renderer/src/components/right-sidebar/local-workspace-port-sections.ts @@ -35,6 +35,26 @@ export function getLocalWorkspacePortSections( } } +/** + * Whether the panel still renders its port sections under a failure notice. + * Why: a failed scan retains the host's last-good ports, so hiding every + * section would drop the stop and open actions for ports the status bar still + * counts and lists. + */ +export function shouldShowLocalWorkspacePortSections( + scan: { unavailableReason?: string } | null | undefined, + sections: { activePorts: unknown[]; otherWorkspacePorts: unknown[]; externalPorts: unknown[] } +): boolean { + if (!scan?.unavailableReason) { + return true + } + return ( + sections.activePorts.length > 0 || + sections.otherWorkspacePorts.length > 0 || + sections.externalPorts.length > 0 + ) +} + function workspacePortAsExternal(port: WorkspacePort & { kind: 'workspace' }): WorkspacePort { return { id: port.id, diff --git a/src/renderer/src/components/right-sidebar/local-workspace-ports-panel.tsx b/src/renderer/src/components/right-sidebar/local-workspace-ports-panel.tsx index 0955e89031b..1737e7da487 100644 --- a/src/renderer/src/components/right-sidebar/local-workspace-ports-panel.tsx +++ b/src/renderer/src/components/right-sidebar/local-workspace-ports-panel.tsx @@ -4,11 +4,11 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { useActiveWorktree, useRepoById } from '@/store/selectors' import { cn } from '@/lib/utils' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { useWorktreeRuntimeTarget } from '@/runtime/use-worktree-runtime-target' import { killWorkspacePortForTarget, openWorkspacePortInBrowser, + publishWorkspacePortScanForHost, refreshWorkspacePortScanAfterStop, resolvePortOpenInOrcaBrowser, scanWorkspacePortsForTarget, @@ -19,10 +19,14 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import type { WorkspacePort } from '../../../../shared/workspace-ports' import { translate } from '@/i18n/i18n' -import { getLocalWorkspacePortSections } from './local-workspace-port-sections' +import { + getLocalWorkspacePortSections, + shouldShowLocalWorkspacePortSections +} from './local-workspace-port-sections' import { LocalPortSection } from './local-port-section' import { LocalPortDetailsDialog } from './local-port-details-dialog' +/** Right-sidebar Ports panel scoped to the active workspace's owner host. */ export function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): React.JSX.Element { const activeWorktree = useActiveWorktree() const activeRepo = useRepoById(activeWorktree?.repoId ?? null) @@ -31,8 +35,7 @@ export function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) const scansByKey = useAppStore((s) => s.workspacePortScansByKey) const refreshing = useAppStore((s) => s.workspacePortScanRefreshing) - const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) - const setWorkspacePortScanForKey = useAppStore((s) => s.setWorkspacePortScanForKey) + const replaceWorkspacePortScans = useAppStore((s) => s.replaceWorkspacePortScans) const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing) const [detailsPort, setDetailsPort] = useState(null) const [collapsedSections, setCollapsedSections] = useState>({ @@ -40,26 +43,24 @@ export function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): external: true }) - const runtimeTarget = useMemo(() => { - const activeRuntimeEnvironmentId = getRuntimeEnvironmentIdForWorktree( - useAppStore.getState(), - activeWorktree?.id - ) - // Why: the Ports panel acts on the active workspace; use that workspace's - // host owner even if the sidebar is focused elsewhere. - return getActiveRuntimeTarget({ ...settings, activeRuntimeEnvironmentId }) - }, [activeWorktree?.id, settings]) - const scanKey = `${workspacePortRuntimeTargetKey(runtimeTarget)}:all` + // Why: the Ports panel acts on the active workspace; use that workspace's + // host owner even if the sidebar is focused elsewhere. + const runtimeTarget = useWorktreeRuntimeTarget(activeWorktree?.id) + const scanKey = runtimeTarget ? `${workspacePortRuntimeTargetKey(runtimeTarget)}:all` : null const refresh = useCallback(() => { - if (!activeRepo) { + if (!activeRepo || !runtimeTarget || !scanKey) { return Promise.resolve() } setWorkspacePortScanRefreshing(true) const promise = scanWorkspacePortsForTarget(runtimeTarget) .then((nextScan) => { - setWorkspacePortScanForKey(scanKey, nextScan) - setWorkspacePortScan({ key: scanKey, result: nextScan }) + publishWorkspacePortScanForHost({ + scanKey, + scan: nextScan, + replaceWorkspacePortScans, + getWorkspacePortScansByKey: () => useAppStore.getState().workspacePortScansByKey + }) }) .catch((error) => { const message = error instanceof Error ? error.message : String(error) @@ -86,14 +87,13 @@ export function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): activeRepo, runtimeTarget, scanKey, - setWorkspacePortScan, - setWorkspacePortScanForKey, + replaceWorkspacePortScans, setWorkspacePortScanRefreshing ]) // Why: WorkspacePortScanner already owns the 30s all-worktree poll. The // panel scopes that shared result instead of starting a second scan loop. - const displayScan = isVisible ? (scansByKey[scanKey] ?? null) : null + const displayScan = isVisible && scanKey ? (scansByKey[scanKey] ?? null) : null const toggleSection = useCallback((sectionId: string) => { setCollapsedSections((current) => ({ ...current, [sectionId]: !current[sectionId] })) @@ -122,8 +122,7 @@ export function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): ) const refreshResult = await refreshWorkspacePortScanAfterStop({ runtimeTarget, - setWorkspacePortScan, - setWorkspacePortScanForKey, + replaceWorkspacePortScans, getWorkspacePortScansByKey: () => useAppStore.getState().workspacePortScansByKey, setWorkspacePortScanRefreshing }) @@ -139,13 +138,7 @@ export function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): ) } }, - [ - activeRepo, - runtimeTarget, - setWorkspacePortScan, - setWorkspacePortScanForKey, - setWorkspacePortScanRefreshing - ] + [activeRepo, runtimeTarget, replaceWorkspacePortScans, setWorkspacePortScanRefreshing] ) const handleOpenPortInBrowser = useCallback( @@ -181,6 +174,12 @@ export function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): [activeRepo?.id, activeWorktree?.id, displayScan] ) + const showPortSections = shouldShowLocalWorkspacePortSections(displayScan, { + activePorts, + otherWorkspacePorts, + externalPorts + }) + if (!activeRepo) { return (
@@ -209,7 +208,7 @@ export function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): size="icon-xs" className="text-muted-foreground hover:text-foreground" onClick={() => void refresh()} - disabled={refreshing} + disabled={refreshing || !runtimeTarget} aria-label={translate( 'auto.components.right.sidebar.PortsPanel.7822e3edc6', 'Refresh Ports' @@ -237,7 +236,7 @@ export function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }):
)} - {!displayScan?.unavailableReason && ( + {showPortSections && ( <> ({ usePromptCacheCountdownStartedAt: vi.fn() @@ -52,7 +52,7 @@ vi.mock('@/store', () => ({ recordFeatureInteraction, remoteBranchConflictByWorktreeId: {}, setRemoteBrowserPageHandle: vi.fn(), - setWorkspacePortScan, + replaceWorkspacePortScans, setWorkspacePortScanRefreshing, settings, sshConnectionStates: new Map(), diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx index e8376a64f08..2c1f10945d3 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx @@ -13,7 +13,7 @@ import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports const fetchHostedReviewForBranch = vi.fn() const fetchIssue = vi.fn() const fetchLinearIssue = vi.fn() -const setWorkspacePortScan = vi.fn() +const replaceWorkspacePortScans = vi.fn() const setWorkspacePortScanRefreshing = vi.fn() const cacheTimerMocks = vi.hoisted(() => ({ usePromptCacheCountdownStartedAt: vi.fn() @@ -43,7 +43,7 @@ vi.mock('@/store', () => ({ recordFeatureInteraction: vi.fn(), remoteBranchConflictByWorktreeId: {}, setRemoteBrowserPageHandle: vi.fn(), - setWorkspacePortScan, + replaceWorkspacePortScans, setWorkspacePortScanRefreshing, settings, sshConnectionStates: new Map(), diff --git a/src/renderer/src/components/sidebar/WorktreeCardPorts.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardPorts.test.tsx index 0a8bb4d248e..db23d18dbc2 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardPorts.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardPorts.test.tsx @@ -8,7 +8,7 @@ vi.mock('@/store', () => ({ selector({ createBrowserTab: vi.fn(), setRemoteBrowserPageHandle: vi.fn(), - setWorkspacePortScan: vi.fn(), + replaceWorkspacePortScans: vi.fn(), setWorkspacePortScanRefreshing: vi.fn(), settings: null }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardPorts.tsx b/src/renderer/src/components/sidebar/WorktreeCardPorts.tsx index 7f7c411dfa2..cc4f567e50a 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardPorts.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardPorts.tsx @@ -1,11 +1,10 @@ -import React, { useCallback, useMemo } from 'react' +import React, { useCallback } from 'react' import { Plug, Copy, ExternalLink, Trash2 } from 'lucide-react' import { toast } from 'sonner' import { useAppStore } from '@/store' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { useWorktreeRuntimeTarget } from '@/runtime/use-worktree-runtime-target' import { canStopWorkspacePort, getPortOpenBrowserTooltipLabel, @@ -97,21 +96,18 @@ function PortAction({ ) } +/** One port row on a sidebar worktree card, with open/copy/stop actions on its owner host. */ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element { const settings = useAppStore((s) => s.settings) const localhostLabelRoute = useLocalhostLabelRouteForPort(port) - const runtimeEnvironmentId = useAppStore((s) => - getRuntimeEnvironmentIdForWorktree(s, port.kind === 'workspace' ? port.owner.worktreeId : null) - ) + const createBrowserTab = useAppStore((s) => s.createBrowserTab) const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) - const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) - const setWorkspacePortScanForKey = useAppStore((s) => s.setWorkspacePortScanForKey) + const replaceWorkspacePortScans = useAppStore((s) => s.replaceWorkspacePortScans) const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) - const runtimeTarget = useMemo( - () => getActiveRuntimeTarget({ ...settings, activeRuntimeEnvironmentId: runtimeEnvironmentId }), - [runtimeEnvironmentId, settings] + const runtimeTarget = useWorktreeRuntimeTarget( + port.kind === 'workspace' ? port.owner.worktreeId : null ) const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process') const address = addressForPort(port) @@ -203,8 +199,7 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element { ) const refreshResult = await refreshWorkspacePortScanAfterStop({ runtimeTarget, - setWorkspacePortScan, - setWorkspacePortScanForKey, + replaceWorkspacePortScans, getWorkspacePortScansByKey: () => useAppStore.getState().workspacePortScansByKey, setWorkspacePortScanRefreshing }) @@ -226,8 +221,7 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element { port, recordFeatureInteraction, runtimeTarget, - setWorkspacePortScan, - setWorkspacePortScanForKey, + replaceWorkspacePortScans, setWorkspacePortScanRefreshing ] ) diff --git a/src/renderer/src/components/status-bar/PortsStatusSegment.host-routing.test.tsx b/src/renderer/src/components/status-bar/PortsStatusSegment.host-routing.test.tsx new file mode 100644 index 00000000000..9b22826d6bd --- /dev/null +++ b/src/renderer/src/components/status-bar/PortsStatusSegment.host-routing.test.tsx @@ -0,0 +1,407 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkspacePort, WorkspacePortScanResult } from '../../../../shared/workspace-ports' + +const { popoverHandle, runWorkspacePortScanForTargetMock, storeState } = vi.hoisted(() => { + const storeState = { + settings: { activeRuntimeEnvironmentId: null as string | null }, + activeWorktreeId: 'runtime-repo::/srv/app', + workspacePortScan: null as { key: string; result: WorkspacePortScanResult } | null, + workspacePortScansByKey: {} as Record, + workspacePortScanRefreshing: false, + runtimeEnvironments: [] as { id: string; name: string }[], + recordFeatureInteraction: vi.fn(), + replaceWorkspacePortScans: + vi.fn< + ( + scansByKey: Record, + projection: { key: string; result: WorkspacePortScanResult } | null + ) => void + >() + } + // Why: the real store writes back. A bare spy lets a publish and the notice + // that reads it drift onto different scan keys with every assertion green. + storeState.replaceWorkspacePortScans.mockImplementation((scansByKey, projection) => { + storeState.workspacePortScansByKey = scansByKey + storeState.workspacePortScan = projection + }) + return { + popoverHandle: { onOpenChange: null as ((open: boolean) => void) | null }, + runWorkspacePortScanForTargetMock: vi.fn(), + storeState + } +}) + +vi.mock('@/store', () => { + const useAppStore = Object.assign( + (selector: (state: typeof storeState) => unknown) => selector(storeState), + { getState: () => storeState } + ) + return { useAppStore } +}) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: (_state: unknown, worktreeId: string | null | undefined) => { + if (worktreeId === 'runtime-repo::/srv/app') { + return 'runtime:env-1' + } + if (worktreeId === 'ssh-repo::/srv/app') { + return 'ssh:server-1' + } + return 'local' + } +})) + +vi.mock('@/runtime/runtime-rpc-client', async () => { + const actual = await import('@/runtime/runtime-client-target') + return { + getActiveRuntimeTarget: actual.getActiveRuntimeTarget, + callRuntimeRpc: vi.fn(), + assertRuntimeEnvironmentCapability: vi.fn(), + RuntimeRpcCallError: class RuntimeRpcCallError extends Error { + code?: string + } + } +}) + +vi.mock('@/lib/workspace-port-scan-client', () => ({ + runWorkspacePortScanForTarget: runWorkspacePortScanForTargetMock +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ + children, + onOpenChange + }: { + children: React.ReactNode + onOpenChange: (open: boolean) => void + }) => { + popoverHandle.onOpenChange = onOpenChange + return <>{children} + }, + PopoverContent: ({ children }: { children: React.ReactNode }) => <>{children}, + PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('@/components/SelectedTextCopyMenu', () => ({ + SelectedTextCopyMenu: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('./ports-status-popover-rows', () => ({ + PortRow: () =>
, + WorkspaceGroupRows: () =>
+})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, options?: Record) => + options + ? fallback.replace(/{{(\w+)}}/g, (_match, name: string) => String(options[name] ?? '')) + : fallback +})) + +import { PortsStatusSegment } from './PortsStatusSegment' + +function workspacePort(overrides: Partial & { port: number; id: string }) { + return { + bindHost: '0.0.0.0', + connectHost: '127.0.0.1', + port: overrides.port, + id: overrides.id, + pid: 4321, + processName: 'node', + protocol: 'http' as const, + kind: 'workspace' as const, + owner: { + worktreeId: 'runtime-repo::/srv/app', + repoId: 'runtime-repo', + displayName: 'runtime app', + path: '/srv/app', + confidence: 'cwd' as const + } + } +} + +const localHostScan: WorkspacePortScanResult = { + platform: 'linux', + scannedAt: 10, + ports: [workspacePort({ id: 'local-5173', port: 5173 })] +} + +const remoteHostScan: WorkspacePortScanResult = { + platform: 'linux', + scannedAt: 20, + ports: [workspacePort({ id: 'remote-3000', port: 3000 })] +} + +describe('PortsStatusSegment popover host routing', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + popoverHandle.onOpenChange = null + storeState.settings = { activeRuntimeEnvironmentId: null } + storeState.activeWorktreeId = 'runtime-repo::/srv/app' + storeState.workspacePortScan = null + storeState.workspacePortScansByKey = { 'local:all': localHostScan } + storeState.runtimeEnvironments = [{ id: 'env-1', name: 'linux-box' }] + storeState.recordFeatureInteraction.mockClear() + storeState.replaceWorkspacePortScans.mockClear() + runWorkspacePortScanForTargetMock.mockReset() + runWorkspacePortScanForTargetMock.mockResolvedValue(remoteHostScan) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root.render() + }) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + async function openPopover(): Promise { + await act(async () => { + popoverHandle.onOpenChange?.(true) + await Promise.resolve() + await Promise.resolve() + }) + } + + it("scans the active workspace's host, not the globally focused runtime", async () => { + await openPopover() + + expect(runWorkspacePortScanForTargetMock).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + undefined + ) + expect(storeState.replaceWorkspacePortScans).toHaveBeenCalledTimes(1) + expect(storeState.workspacePortScansByKey['environment:env-1:all']).toBe(remoteHostScan) + }) + + it('keeps other hosts in the projection instead of overwriting it with one host', async () => { + await openPopover() + + const [, projection] = storeState.replaceWorkspacePortScans.mock.calls.at(-1) as [ + Record, + { key: string; result: WorkspacePortScanResult } + ] + expect(projection).toEqual({ + key: 'all-hosts:all', + result: expect.objectContaining({ + ports: expect.arrayContaining([ + expect.objectContaining({ port: 5173 }), + expect.objectContaining({ port: 3000 }) + ]) + }) + }) + }) + + it('publishes a failed scan under its own host without dropping other hosts', async () => { + runWorkspacePortScanForTargetMock.mockRejectedValueOnce(new Error('remote scan failed')) + + await openPopover() + + const [, projection] = storeState.replaceWorkspacePortScans.mock.calls.at(-1) as [ + Record, + { key: string; result: WorkspacePortScanResult } + ] + expect(projection.key).toBe('all-hosts:all') + expect(projection.result.ports).toEqual([expect.objectContaining({ port: 5173 })]) + expect(storeState.workspacePortScansByKey['environment:env-1:all']).toEqual( + expect.objectContaining({ unavailableReason: 'remote scan failed' }) + ) + }) + + it('keeps the failed host last-good ports while naming the failure', async () => { + storeState.workspacePortScansByKey = { + 'local:all': localHostScan, + 'environment:env-1:all': remoteHostScan + } + runWorkspacePortScanForTargetMock.mockRejectedValueOnce(new Error('remote scan failed')) + + await openPopover() + + // Why: one dropped scan must not clear the host's ports the way the + // background poll's debounce does not — the notice names the failure + // while the projection keeps serving the last-good rows. + const failed = storeState.workspacePortScansByKey['environment:env-1:all'] + expect(failed.unavailableReason).toBe('remote scan failed') + expect(failed.platform).toBe('linux') + expect(failed.ports).toEqual([expect.objectContaining({ port: 3000 })]) + const [, projection] = storeState.replaceWorkspacePortScans.mock.calls.at(-1) as [ + Record, + { key: string; result: WorkspacePortScanResult } + ] + expect(projection.key).toBe('all-hosts:all') + expect(projection.result.ports.map((port) => port.port).sort()).toEqual([3000, 5173]) + }) + + // Why: separate tests already cover "the failure is stored" and "a stored + // failure renders". Only this one proves both halves name the same scan key. + it('surfaces the host it just failed to scan on the next render', async () => { + runWorkspacePortScanForTargetMock.mockRejectedValueOnce(new Error('remote scan failed')) + + await openPopover() + act(() => { + root.render() + }) + + expect(container.textContent).toContain( + 'Port scan unavailable on linux-box: remote scan failed' + ) + }) + + it('names the host whose scan failed while another host still reports ports', () => { + act(() => { + root.unmount() + }) + storeState.workspacePortScansByKey = { + 'local:all': localHostScan, + 'environment:env-1:all': { + platform: 'linux', + scannedAt: 30, + ports: [], + unavailableReason: 'Remote connection dropped' + } + } + storeState.workspacePortScan = { key: 'all-hosts:all', result: localHostScan } + root = createRoot(container) + act(() => { + root.render() + }) + + expect(container.textContent).toContain( + 'Port scan unavailable on linux-box: Remote connection dropped' + ) + // The notice sits above the list rather than replacing it: a reachable + // host's count still renders. + expect(container.textContent).toContain('1 workspace') + }) + + // Why: a failed scan keeps the host's last-good ports, and the badge and + // header count them. Replacing the list with the notice left the popover + // claiming N ports over an empty body. + it('keeps the list under the notice when a failed scan retained its ports', () => { + act(() => { + root.unmount() + }) + storeState.activeWorktreeId = 'local-repo::/home/dev/app' + const retained: WorkspacePortScanResult = { + ...localHostScan, + unavailableReason: 'lsof is unavailable' + } + storeState.workspacePortScansByKey = { 'local:all': retained } + storeState.workspacePortScan = { key: 'local:all', result: retained } + root = createRoot(container) + act(() => { + root.render() + }) + + expect(container.textContent).toContain('1 workspace · 0 external') + expect(container.querySelectorAll('[data-testid="workspace-group-rows"]')).toHaveLength(1) + expect(container.textContent).toContain('Port scan unavailable on Local Linux') + }) + + // Why: total loss of contact is where naming the host matters most, and the + // merged projection can only offer platform 'unknown' and raw scan keys. + it('names every host when all of them failed with nothing left to list', () => { + act(() => { + root.unmount() + }) + const merged: WorkspacePortScanResult = { + platform: 'unknown', + scannedAt: 30, + ports: [], + unavailableReason: 'local:all: lsof is unavailable; environment:env-1:all: dropped' + } + storeState.workspacePortScansByKey = { + 'local:all': { + platform: 'darwin', + scannedAt: 30, + ports: [], + unavailableReason: 'lsof is unavailable' + }, + 'environment:env-1:all': { + platform: 'linux', + scannedAt: 30, + ports: [], + unavailableReason: 'dropped' + } + } + storeState.workspacePortScan = { key: 'all-hosts:all', result: merged } + root = createRoot(container) + act(() => { + root.render() + }) + + // Local label comes from the scan's own platform, not the renderer's + // userAgent — a paired web client is not the Orca host. + expect(container.textContent).toContain( + 'Port scan unavailable on Local Mac: lsof is unavailable' + ) + expect(container.textContent).toContain('Port scan unavailable on linux-box: dropped') + expect(container.textContent).not.toContain('unavailable on unknown') + expect(container.textContent).not.toContain('environment:env-1:all:') + // The notice takes over the body only when there is nothing left to list. + expect(container.querySelectorAll('[data-testid="workspace-group-rows"]')).toHaveLength(0) + expect(container.textContent).not.toContain('No workspace ports detected') + }) + + it('stays on the local host when the active workspace has no runtime owner', async () => { + act(() => { + root.unmount() + }) + storeState.activeWorktreeId = 'local-repo::/home/dev/app' + root = createRoot(container) + act(() => { + root.render() + }) + + await openPopover() + + expect(runWorkspacePortScanForTargetMock).toHaveBeenCalledWith({ kind: 'local' }, undefined) + const [nextScans, projection] = storeState.replaceWorkspacePortScans.mock.calls.at(-1) as [ + Record, + { key: string; result: WorkspacePortScanResult } + ] + expect(nextScans['local:all']).toBe(remoteHostScan) + expect(projection).toEqual({ + key: 'local:all', + result: remoteHostScan + }) + }) + + it('does not substitute the local host for a direct-SSH workspace', async () => { + act(() => { + root.unmount() + }) + storeState.activeWorktreeId = 'ssh-repo::/srv/app' + root = createRoot(container) + act(() => { + root.render() + }) + + await openPopover() + + expect(runWorkspacePortScanForTargetMock).not.toHaveBeenCalled() + expect(storeState.replaceWorkspacePortScans).not.toHaveBeenCalled() + expect(storeState.recordFeatureInteraction).toHaveBeenCalledWith('ports') + }) +}) diff --git a/src/renderer/src/components/status-bar/PortsStatusSegment.tsx b/src/renderer/src/components/status-bar/PortsStatusSegment.tsx index 0626137c010..b56f5432cc8 100644 --- a/src/renderer/src/components/status-bar/PortsStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/PortsStatusSegment.tsx @@ -3,39 +3,83 @@ import { Plug, ChevronDown, ChevronRight, LoaderCircle } from 'lucide-react' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useAppStore } from '@/store' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { + publishWorkspacePortScanForHost, scanWorkspacePortsForTarget, workspacePortScanKeyForTarget } from '@/lib/workspace-port-actions' +import { useWorktreeRuntimeTarget } from '@/runtime/use-worktree-runtime-target' +import { + getUnavailableWorkspacePortHosts, + type WorkspacePortHostRef +} from '@/lib/workspace-port-host-availability' +import { getLocalExecutionHostLabel } from '../../../../shared/execution-host' import { getExternalWorkspacePorts, getWorkspacePortGroups } from '@/lib/workspace-port-groups' import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu' import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy' import { PortRow, WorkspaceGroupRows } from './ports-status-popover-rows' import { translate } from '@/i18n/i18n' +import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports' type PortsStatusSegmentProps = { compact?: boolean iconOnly: boolean } +/** Status-bar plug icon with the workspace port count and a per-host ports popover. */ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React.JSX.Element { - const settings = useAppStore((s) => s.settings) const scan = useAppStore((s) => s.workspacePortScan?.result ?? null) const refreshing = useAppStore((s) => s.workspacePortScanRefreshing) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) - const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) - const setWorkspacePortScanForKey = useAppStore((s) => s.setWorkspacePortScanForKey) + const replaceWorkspacePortScans = useAppStore((s) => s.replaceWorkspacePortScans) + const scansByKey = useAppStore((s) => s.workspacePortScansByKey) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const [open, setOpen] = useState(false) const [externalOpen, setExternalOpen] = useState(false) - const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) - const scanKey = workspacePortScanKeyForTarget(runtimeTarget) + const runtimeTarget = useWorktreeRuntimeTarget(activeWorktreeId) + const scanKey = runtimeTarget ? workspacePortScanKeyForTarget(runtimeTarget) : null const workspaceGroups = useMemo(() => getWorkspacePortGroups(scan), [scan]) const externalPorts = useMemo(() => getExternalWorkspacePorts(scan), [scan]) + const unavailableHosts = useMemo(() => getUnavailableWorkspacePortHosts(scansByKey), [scansByKey]) + const hostLabel = useCallback( + (host: WorkspacePortHostRef, hostScanKey: string, platform: NodeJS.Platform | null) => { + if (host.kind === 'local') { + // Why: a paired web client's own userAgent is not the Orca host's + // platform, so name the machine the scan actually ran on. + return getLocalExecutionHostLabel(platform) + } + if (host.kind === 'unknown') { + return hostScanKey + } + return ( + runtimeEnvironments.find((environment) => environment.id === host.environmentId)?.name ?? + host.environmentId + ) + }, + [runtimeEnvironments] + ) const workspacePortCount = workspaceGroups.reduce((count, group) => count + group.ports.length, 0) const totalCount = workspacePortCount + externalPorts.length + const unavailableNotices = useMemo(() => { + if (unavailableHosts.length > 0) { + return unavailableHosts.map((entry) => ({ + id: entry.scanKey, + host: hostLabel(entry.host, entry.scanKey, entry.platform), + reason: entry.reason + })) + } + // Why: a projection published without per-host scans has no host to name. + return scan?.unavailableReason + ? [{ id: 'projection', host: scan.platform, reason: scan.unavailableReason }] + : [] + }, [hostLabel, scan?.platform, scan?.unavailableReason, unavailableHosts]) + // Why: a failed scan keeps the host's last-good ports, and those ports are + // counted in the badge and header — replacing the list with the notice would + // leave the popover claiming N ports over an empty body. Only take over the + // body when there is genuinely nothing left to list. + const noticeReplacesList = Boolean(scan?.unavailableReason) && totalCount === 0 const handleOpenChange = useCallback( (nextOpen: boolean) => { setOpen(nextOpen) @@ -43,33 +87,36 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React return } recordFeatureInteraction('ports') + if (!runtimeTarget || !scanKey) { + return + } // Why: the 30s background poll is intentionally quiet; opening the // popover should still collapse that stale window without flashing icons. - void scanWorkspacePortsForTarget(runtimeTarget) - .then((result) => { - setWorkspacePortScanForKey(scanKey, result) - setWorkspacePortScan({ key: scanKey, result }) + const publish = (result: WorkspacePortScanResult): void => { + publishWorkspacePortScanForHost({ + scanKey, + scan: result, + replaceWorkspacePortScans, + getWorkspacePortScansByKey: () => useAppStore.getState().workspacePortScansByKey }) + } + void scanWorkspacePortsForTarget(runtimeTarget) + .then(publish) .catch((error) => { const message = error instanceof Error ? error.message : String(error) - setWorkspacePortScan({ - key: scanKey, - result: { - platform: 'unknown', - scannedAt: Date.now(), - ports: [], - unavailableReason: message || 'Workspace port scan failed.' - } + // Why: one dropped scan must not clear the host's last-good ports the + // way the background poll's debounce does not; the failure is still + // recorded so the host is named by the unavailable notice below. + const previous = useAppStore.getState().workspacePortScansByKey[scanKey] + publish({ + platform: previous?.platform ?? 'unknown', + scannedAt: Date.now(), + ports: previous?.ports ?? [], + unavailableReason: message || 'Workspace port scan failed.' }) }) }, - [ - recordFeatureInteraction, - runtimeTarget, - scanKey, - setWorkspacePortScan, - setWorkspacePortScanForKey - ] + [recordFeatureInteraction, runtimeTarget, scanKey, replaceWorkspacePortScans] ) return ( @@ -153,14 +200,18 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React
- {scan?.unavailableReason ? ( -
- {translate( - 'auto.components.status.bar.PortsStatusSegment.95495019ed', - 'Port scan unavailable on {{value0}}: {{value1}}', - { value0: scan.platform, value1: scan.unavailableReason } - )} -
+ {unavailableNotices.length > 0 && !noticeReplacesList && ( + + )} + + {noticeReplacesList ? ( + ) : (
{workspaceGroups.length > 0 ? ( @@ -237,3 +288,27 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React ) } + +type PortScanUnavailableNotice = { id: string; host: string; reason: string } + +function PortScanUnavailableNotices({ + notices, + className +}: { + notices: PortScanUnavailableNotice[] + className: string +}): React.JSX.Element { + return ( +
+ {notices.map((notice) => ( +
+ {translate( + 'auto.components.status.bar.PortsStatusSegment.95495019ed', + 'Port scan unavailable on {{value0}}: {{value1}}', + { value0: notice.host, value1: notice.reason } + )} +
+ ))} +
+ ) +} diff --git a/src/renderer/src/components/status-bar/ports-status-popover-rows.test.tsx b/src/renderer/src/components/status-bar/ports-status-popover-rows.test.tsx index 47c86d89c57..ba926909272 100644 --- a/src/renderer/src/components/status-bar/ports-status-popover-rows.test.tsx +++ b/src/renderer/src/components/status-bar/ports-status-popover-rows.test.tsx @@ -17,8 +17,7 @@ const { settings: { openLinksInApp: true }, createBrowserTab: vi.fn(), setRemoteBrowserPageHandle: vi.fn(), - setWorkspacePortScan: vi.fn(), - setWorkspacePortScanForKey: vi.fn(), + replaceWorkspacePortScans: vi.fn(), setWorkspacePortScanRefreshing: vi.fn(), recordFeatureInteraction: vi.fn(), workspacePortScansByKey: {} @@ -46,7 +45,7 @@ vi.mock('@/lib/worktree-activation', () => ({ })) vi.mock('@/lib/worktree-runtime-owner', () => ({ - getRuntimeEnvironmentIdForWorktree: () => null + getExecutionHostIdForWorktree: () => 'local' })) vi.mock('@/runtime/runtime-rpc-client', () => ({ diff --git a/src/renderer/src/components/status-bar/ports-status-popover-rows.tsx b/src/renderer/src/components/status-bar/ports-status-popover-rows.tsx index c6d7eb41625..4c07c44ed21 100644 --- a/src/renderer/src/components/status-bar/ports-status-popover-rows.tsx +++ b/src/renderer/src/components/status-bar/ports-status-popover-rows.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react' +import React, { useCallback } from 'react' import { Copy, ExternalLink, FolderOpen, Trash2 } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' @@ -15,9 +15,8 @@ import { } from '@/lib/workspace-port-actions' import type { WorkspacePortGroup } from '@/lib/workspace-port-groups' import { useLocalhostLabelRouteForPort } from '@/lib/workspace-port-localhost-label-selector' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { useWorktreeRuntimeTarget } from '@/runtime/use-worktree-runtime-target' import { useAppStore } from '@/store' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import type { WorkspacePort } from '../../../../shared/workspace-ports' import { translate } from '@/i18n/i18n' @@ -67,6 +66,7 @@ function PortAction({ ) } +/** One port row in the status-bar popover, with open/copy/stop actions on its owner host. */ export function PortRow({ port, activeWorktreeId, @@ -78,21 +78,13 @@ export function PortRow({ }): React.JSX.Element { const settings = useAppStore((s) => s.settings) const localhostLabelRoute = useLocalhostLabelRouteForPort(port) - const runtimeEnvironmentId = useAppStore((s) => - getRuntimeEnvironmentIdForWorktree( - s, - port.kind === 'workspace' ? port.owner.worktreeId : activeWorktreeId - ) - ) const createBrowserTab = useAppStore((s) => s.createBrowserTab) const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) - const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) - const setWorkspacePortScanForKey = useAppStore((s) => s.setWorkspacePortScanForKey) + const replaceWorkspacePortScans = useAppStore((s) => s.replaceWorkspacePortScans) const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) - const runtimeTarget = useMemo( - () => getActiveRuntimeTarget({ ...settings, activeRuntimeEnvironmentId: runtimeEnvironmentId }), - [runtimeEnvironmentId, settings] + const runtimeTarget = useWorktreeRuntimeTarget( + port.kind === 'workspace' ? port.owner.worktreeId : activeWorktreeId ) const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process') const canStop = canStopWorkspacePort(port) @@ -187,8 +179,7 @@ export function PortRow({ ) const refreshResult = await refreshWorkspacePortScanAfterStop({ runtimeTarget, - setWorkspacePortScan, - setWorkspacePortScanForKey, + replaceWorkspacePortScans, getWorkspacePortScansByKey: () => useAppStore.getState().workspacePortScansByKey, setWorkspacePortScanRefreshing }) @@ -210,8 +201,7 @@ export function PortRow({ port, recordFeatureInteraction, runtimeTarget, - setWorkspacePortScan, - setWorkspacePortScanForKey, + replaceWorkspacePortScans, setWorkspacePortScanRefreshing ] ) diff --git a/src/renderer/src/lib/workspace-port-actions.ts b/src/renderer/src/lib/workspace-port-actions.ts index cca201234a4..cf745d3e25f 100644 --- a/src/renderer/src/lib/workspace-port-actions.ts +++ b/src/renderer/src/lib/workspace-port-actions.ts @@ -7,7 +7,6 @@ import { type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' -import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' import type { WorkspacePort, WorkspacePortKillResult, @@ -22,6 +21,11 @@ import { RUNTIME_BROWSER_UNAVAILABLE_MESSAGE } from './client-creation-action-po export { addressForPort } from './workspace-port-urls' const WORKSPACE_PORT_STOP_SETTLE_MS = 500 +const WORKSPACE_PORT_TARGET_UNAVAILABLE_REASON = + 'Workspace ports are unavailable for this execution host.' + +/** Projection key for the merged multi-host view; never a per-host scan key. */ +export const WORKSPACE_PORT_ALL_HOSTS_SCAN_KEY = 'all-hosts:all' export function canStopWorkspacePort( port: WorkspacePort @@ -33,13 +37,17 @@ type BrowserTabCreator = ReturnType['createBrowserT type RemoteBrowserPageHandleSetter = ReturnType< typeof useAppStore.getState >['setRemoteBrowserPageHandle'] -type WorkspacePortScanSetter = ReturnType['setWorkspacePortScan'] -type WorkspacePortScanByKeySetter = ReturnType< - typeof useAppStore.getState ->['setWorkspacePortScanForKey'] type WorkspacePortScanRefreshingSetter = ReturnType< typeof useAppStore.getState >['setWorkspacePortScanRefreshing'] +type ReplaceWorkspacePortScansSetter = ReturnType< + typeof useAppStore.getState +>['replaceWorkspacePortScans'] + +export type WorkspacePortScanPublisher = { + replaceWorkspacePortScans: ReplaceWorkspacePortScansSetter + getWorkspacePortScansByKey: () => Record +} function delay(ms: number): Promise { return new Promise((resolve) => window.setTimeout(resolve, ms)) @@ -94,12 +102,15 @@ export function goToWorkspacePortOwner(port: WorkspacePort): boolean { export async function openWorkspacePortInBrowser(args: { port: WorkspacePort activeWorktreeId?: string | null - runtimeTarget: RuntimeClientTarget + runtimeTarget: RuntimeClientTarget | null createBrowserTab: BrowserTabCreator setRemoteBrowserPageHandle: RemoteBrowserPageHandleSetter openInOrcaBrowser?: boolean localhostLabelRoute?: LocalhostWorktreeLabelRoute | null }): Promise<{ ok: true } | { ok: false; reason: string }> { + if (!args.runtimeTarget) { + return { ok: false, reason: WORKSPACE_PORT_TARGET_UNAVAILABLE_REASON } + } const rawUrl = browserUrlForPort(args.port) let url = rawUrl if (args.runtimeTarget.kind === 'local' && args.localhostLabelRoute) { @@ -166,22 +177,38 @@ export async function openWorkspacePortInBrowser(args: { } } -export async function refreshWorkspacePortScanAfterStop(args: { - runtimeTarget: RuntimeClientTarget - setWorkspacePortScan: WorkspacePortScanSetter - setWorkspacePortScanForKey?: WorkspacePortScanByKeySetter - setWorkspacePortScanRefreshing: WorkspacePortScanRefreshingSetter - getWorkspacePortScansByKey?: () => Record -}): Promise<{ ok: true } | { ok: false; reason: string }> { +/** + * Stores one host's scan and republishes the aggregate the status bar reads. + * Why: a single-host publish used to overwrite that aggregate, so every other + * host's ports vanished from the count until the next background poll. One + * replaceWorkspacePortScans update (not setWorkspacePortScan) keeps the synthetic + * all-hosts key out of workspacePortScansByKey, where re-merging it would + * duplicate rows — and notifies subscribers once instead of twice for one scan. + */ +export function publishWorkspacePortScanForHost( + args: WorkspacePortScanPublisher & { scanKey: string; scan: WorkspacePortScanResult } +): void { + const scansByKey = { ...args.getWorkspacePortScansByKey(), [args.scanKey]: args.scan } + const merged = mergeWorkspacePortScans(scansByKey) + args.replaceWorkspacePortScans(scansByKey, { + key: Object.keys(scansByKey).length > 1 ? WORKSPACE_PORT_ALL_HOSTS_SCAN_KEY : args.scanKey, + result: merged ?? args.scan + }) +} + +/** Re-scans one host after a port stop (immediately, then settled) and republishes the aggregate. */ +export async function refreshWorkspacePortScanAfterStop( + args: WorkspacePortScanPublisher & { + runtimeTarget: RuntimeClientTarget | null + setWorkspacePortScanRefreshing: WorkspacePortScanRefreshingSetter + } +): Promise<{ ok: true } | { ok: false; reason: string }> { + if (!args.runtimeTarget) { + return { ok: false, reason: WORKSPACE_PORT_TARGET_UNAVAILABLE_REASON } + } const scanKey = workspacePortScanKeyForTarget(args.runtimeTarget) const publishScan = (scan: WorkspacePortScanResult): void => { - args.setWorkspacePortScanForKey?.(scanKey, scan) - const currentScans = args.getWorkspacePortScansByKey?.() ?? {} - const merged = mergeWorkspacePortScans({ ...currentScans, [scanKey]: scan }) - args.setWorkspacePortScan({ - key: merged && Object.keys(currentScans).length > 0 ? 'all-hosts:all' : scanKey, - result: merged ?? scan - }) + publishWorkspacePortScanForHost({ ...args, scanKey, scan }) } args.setWorkspacePortScanRefreshing(true) try { @@ -216,19 +243,6 @@ export function workspacePortRuntimeTargetKey(target: RuntimeClientTarget): stri return target.kind === 'local' ? 'local' : `environment:${target.environmentId}` } -export function runtimeTargetForExecutionHostId( - hostId: ExecutionHostId -): RuntimeClientTarget | null { - const parsed = parseExecutionHostId(hostId) - if (parsed?.kind === 'local') { - return { kind: 'local' } - } - if (parsed?.kind === 'runtime') { - return { kind: 'environment', environmentId: parsed.environmentId } - } - return null -} - export function workspacePortScanKeyForTarget(target: RuntimeClientTarget): string { return `${workspacePortRuntimeTargetKey(target)}:all` } @@ -295,9 +309,12 @@ export async function scanWorkspacePortsForTarget( } export async function killWorkspacePortForTarget( - target: RuntimeClientTarget, + target: RuntimeClientTarget | null, args: { repoId: string; pid: number; port: number } ): Promise { + if (!target) { + return { ok: false, reason: WORKSPACE_PORT_TARGET_UNAVAILABLE_REASON } + } if (target.kind === 'local') { return window.api.workspacePorts.kill(args) } diff --git a/src/renderer/src/lib/workspace-port-host-availability.test.ts b/src/renderer/src/lib/workspace-port-host-availability.test.ts new file mode 100644 index 00000000000..7652c65a21a --- /dev/null +++ b/src/renderer/src/lib/workspace-port-host-availability.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest' +import type { WorkspacePortScanResult } from '../../../shared/workspace-ports' +import { + getUnavailableWorkspacePortHosts, + workspacePortHostForScanKey +} from './workspace-port-host-availability' + +function scan(overrides: Partial = {}): WorkspacePortScanResult { + return { platform: 'linux', scannedAt: 1, ports: [], ...overrides } +} + +describe('getUnavailableWorkspacePortHosts', () => { + it('reports the failed host while another host still answers', () => { + expect( + getUnavailableWorkspacePortHosts({ + 'local:all': scan(), + 'environment:env-1:all': scan({ unavailableReason: 'Remote connection dropped' }) + }) + ).toEqual([ + { + scanKey: 'environment:env-1:all', + host: { kind: 'environment', environmentId: 'env-1' }, + platform: 'linux', + reason: 'Remote connection dropped' + } + ]) + }) + + it('reports the local host as a local host ref, not an absent environment id', () => { + expect( + getUnavailableWorkspacePortHosts({ + 'local:all': scan({ unavailableReason: 'lsof is unavailable' }), + 'environment:env-1:all': scan() + }) + ).toEqual([ + { + scanKey: 'local:all', + host: { kind: 'local' }, + platform: 'linux', + reason: 'lsof is unavailable' + } + ]) + }) + + it('keeps colons inside an environment id when parsing the scan key', () => { + // Why: keys are `${targetKey}:all`, so the id runs to the last `:all` — + // splitting on the first colon would truncate ids that contain colons. + expect( + getUnavailableWorkspacePortHosts({ + 'local:all': scan(), + 'environment:weird:id:all': scan({ unavailableReason: 'Remote connection dropped' }) + }) + ).toEqual([ + { + scanKey: 'environment:weird:id:all', + host: { kind: 'environment', environmentId: 'weird:id' }, + platform: 'linux', + reason: 'Remote connection dropped' + } + ]) + }) + + // Why: total loss of contact is where naming the host matters most — the merged + // projection joins raw internal scan keys, so it cannot name them itself. + it('names every host when all of them failed', () => { + expect( + getUnavailableWorkspacePortHosts({ + 'local:all': scan({ unavailableReason: 'lsof is unavailable' }), + 'environment:env-1:all': scan({ unavailableReason: 'Remote connection dropped' }) + }) + ).toEqual([ + { + scanKey: 'local:all', + host: { kind: 'local' }, + platform: 'linux', + reason: 'lsof is unavailable' + }, + { + scanKey: 'environment:env-1:all', + host: { kind: 'environment', environmentId: 'env-1' }, + platform: 'linux', + reason: 'Remote connection dropped' + } + ]) + }) + + it('names a single failed host', () => { + expect( + getUnavailableWorkspacePortHosts({ + 'local:all': scan({ unavailableReason: 'lsof is unavailable' }) + }) + ).toEqual([ + { + scanKey: 'local:all', + host: { kind: 'local' }, + platform: 'linux', + reason: 'lsof is unavailable' + } + ]) + }) + + // Why: the synthetic all-hosts projection key must never be labelled as the + // local machine — that would blame the wrong host for a remote failure. + it('marks an unrecognised scan key as an unknown host', () => { + expect( + getUnavailableWorkspacePortHosts({ + 'all-hosts:all': scan({ unavailableReason: 'Remote connection dropped' }) + }) + ).toEqual([ + { + scanKey: 'all-hosts:all', + host: { kind: 'unknown' }, + platform: 'linux', + reason: 'Remote connection dropped' + } + ]) + }) + + // Why: a paired web client's userAgent is not the Orca host's platform, so the + // caller labels the local host from the scan's own platform. + it("carries the failed scan's platform, and null when it is unknown", () => { + expect( + getUnavailableWorkspacePortHosts({ + 'local:all': scan({ platform: 'win32', unavailableReason: 'netstat failed' }), + 'environment:env-1:all': scan({ platform: 'unknown', unavailableReason: 'dropped' }) + }).map((entry) => entry.platform) + ).toEqual(['win32', null]) + }) + + it('stays silent when nothing failed', () => { + expect(getUnavailableWorkspacePortHosts({ 'local:all': scan() })).toEqual([]) + expect(getUnavailableWorkspacePortHosts({})).toEqual([]) + }) +}) + +describe('workspacePortHostForScanKey', () => { + it.each([ + ['local:all', { kind: 'local' }], + ['environment:env-1:all', { kind: 'environment', environmentId: 'env-1' }], + ['environment:weird:id:all', { kind: 'environment', environmentId: 'weird:id' }], + ['all-hosts:all', { kind: 'unknown' }], + ['environment::all', { kind: 'unknown' }], + ['environment:env-1', { kind: 'unknown' }], + ['local', { kind: 'unknown' }] + ])('maps %s', (scanKey, expected) => { + expect(workspacePortHostForScanKey(scanKey)).toEqual(expected) + }) +}) diff --git a/src/renderer/src/lib/workspace-port-host-availability.ts b/src/renderer/src/lib/workspace-port-host-availability.ts new file mode 100644 index 00000000000..4c643a5a118 --- /dev/null +++ b/src/renderer/src/lib/workspace-port-host-availability.ts @@ -0,0 +1,65 @@ +import type { WorkspacePortScanResult } from '../../../shared/workspace-ports' + +/** + * Host a per-host scan key points at. `unknown` is kept distinct from `local` so + * an unrecognised key (a synthetic projection key that leaked into the per-host + * map, say) is never mislabelled as a local failure. + */ +export type WorkspacePortHostRef = + | { kind: 'local' } + | { kind: 'environment'; environmentId: string } + | { kind: 'unknown' } + +export type UnavailableWorkspacePortHost = { + scanKey: string + host: WorkspacePortHostRef + /** Platform the failed scan last ran on; drives the local host's label. */ + platform: NodeJS.Platform | null + reason: string +} + +// Why: mirrors workspacePortScanKeyForTarget (`${targetKey}:all`, where the +// target key is `local` or `environment:`) without importing the heavier +// workspace-port-actions module into this pure helper. Splitting on the last +// `:all` keeps environment ids that themselves contain colons intact. +const ENVIRONMENT_SCAN_KEY_PREFIX = 'environment:' +const SCAN_KEY_SUFFIX = ':all' +const LOCAL_SCAN_KEY = `local${SCAN_KEY_SUFFIX}` + +/** Host a per-host scan key names; `unknown` for any other key shape. */ +export function workspacePortHostForScanKey(scanKey: string): WorkspacePortHostRef { + if (scanKey === LOCAL_SCAN_KEY) { + return { kind: 'local' } + } + if (!scanKey.endsWith(SCAN_KEY_SUFFIX) || !scanKey.startsWith(ENVIRONMENT_SCAN_KEY_PREFIX)) { + return { kind: 'unknown' } + } + const environmentId = scanKey.slice( + ENVIRONMENT_SCAN_KEY_PREFIX.length, + scanKey.length - SCAN_KEY_SUFFIX.length + ) + return environmentId ? { kind: 'environment', environmentId } : { kind: 'unknown' } +} + +/** + * Every host whose latest scan failed, named by host rather than by scan key. + * Why: on a remote host "none listening" and "could not look" are different + * answers, and the merged projection collapses both the partial case (no reason + * at all) and the total case (reasons joined with raw internal keys). + */ +export function getUnavailableWorkspacePortHosts( + scansByKey: Record +): UnavailableWorkspacePortHost[] { + return Object.entries(scansByKey).flatMap(([scanKey, scan]) => + scan?.unavailableReason + ? [ + { + scanKey, + host: workspacePortHostForScanKey(scanKey), + platform: scan.platform === 'unknown' ? null : scan.platform, + reason: scan.unavailableReason + } + ] + : [] + ) +} diff --git a/src/renderer/src/lib/workspace-port-scan-debounce.test.ts b/src/renderer/src/lib/workspace-port-scan-debounce.test.ts index bacac7ecacf..747de6672aa 100644 --- a/src/renderer/src/lib/workspace-port-scan-debounce.test.ts +++ b/src/renderer/src/lib/workspace-port-scan-debounce.test.ts @@ -25,6 +25,11 @@ function unavailable(): WorkspacePortScanResult { return { platform: 'unknown', scannedAt: 1, ports: [], unavailableReason: 'scan failed' } } +/** What the ports popover publishes when its own scan fails: reason + last-good ports. */ +function unavailableWithRetainedPorts(portIds: string[]): WorkspacePortScanResult { + return { ...good(portIds), unavailableReason: 'scan failed' } +} + const FAILURE_THRESHOLD = 2 function createHarness(): { @@ -125,6 +130,33 @@ describe('reconcileTransientPortScanFailures', () => { expect(state.has('flaky:all')).toBe(false) }) + // Why: the popover publishes reason + last-good ports the moment its own scan + // fails. Counting that as a spent grace period would drop those ports on the + // very next poll, so the retention would never survive one poll interval. + it('still grants the grace period after a failure that retained its ports', () => { + const { apply, publish } = createHarness() + apply([{ key: 'h:all', result: good(['tcp:3000']) }]) + const popoverResult = unavailableWithRetainedPorts(['tcp:3000']) + publish('h:all', popoverResult) + + const next = apply([{ key: 'h:all', result: unavailable() }]) + + expect(next[0].result).toBe(popoverResult) + expect(next[0].result.ports).toHaveLength(1) + }) + + it('still drops retained ports once failures reach the tolerance', () => { + const { apply, publish } = createHarness() + apply([{ key: 'h:all', result: good(['tcp:3000']) }]) + publish('h:all', unavailableWithRetainedPorts(['tcp:3000'])) + apply([{ key: 'h:all', result: unavailable() }]) + + const next = apply([{ key: 'h:all', result: unavailable() }]) + + expect(next[0].result.ports).toHaveLength(0) + expect(next[0].result.unavailableReason).toBe('scan failed') + }) + it('uses a newer manual result instead of resurrecting stale ports', () => { const { apply, publish } = createHarness() apply([{ key: 'h:all', result: good(['tcp:3000']) }]) diff --git a/src/renderer/src/lib/workspace-port-scan-debounce.ts b/src/renderer/src/lib/workspace-port-scan-debounce.ts index a6281f0ed9f..2677cbb0e52 100644 --- a/src/renderer/src/lib/workspace-port-scan-debounce.ts +++ b/src/renderer/src/lib/workspace-port-scan-debounce.ts @@ -34,10 +34,14 @@ export function reconcileTransientPortScanFailures( return { key, result } } const failures = previousFailures + 1 + // Why: a surface that hit the same failure first (the ports popover) republishes + // the host's last-good ports alongside the reason. Treating that as a spent grace + // period would drop those ports on the very next poll, undoing the retention. + const publishedIsRetainable = + Boolean(publishedResult) && + (!publishedResult.unavailableReason || publishedResult.ports.length > 0) const nextResult = - failures < failureThreshold && publishedResult && !publishedResult.unavailableReason - ? publishedResult - : result + failures < failureThreshold && publishedIsRetainable ? publishedResult : result state.set(key, { consecutiveFailures: failures, publishedResult: nextResult }) return { key, result: nextResult } }) diff --git a/src/renderer/src/lib/workspace-port-scan-publish.test.ts b/src/renderer/src/lib/workspace-port-scan-publish.test.ts new file mode 100644 index 00000000000..2f1386855a8 --- /dev/null +++ b/src/renderer/src/lib/workspace-port-scan-publish.test.ts @@ -0,0 +1,153 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkspacePortScanResult } from '../../../shared/workspace-ports' + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: vi.fn(), + callRuntimeRpc: vi.fn(), + assertRuntimeEnvironmentCapability: vi.fn(), + RuntimeRpcCallError: class RuntimeRpcCallError extends Error { + code?: string + } +})) + +vi.mock('./workspace-port-scan-client', () => ({ + runWorkspacePortScanForTarget: vi.fn() +})) + +const { publishWorkspacePortScanForHost, WORKSPACE_PORT_ALL_HOSTS_SCAN_KEY } = + await import('./workspace-port-actions') +type WorkspacePortScanPublisher = Parameters[0] + +function scanWithPort(port: number, scannedAt: number): WorkspacePortScanResult { + return { + platform: 'linux', + scannedAt, + ports: [ + { + id: `tcp:${port}`, + bindHost: '0.0.0.0', + connectHost: '127.0.0.1', + port, + pid: 100 + port, + processName: 'node', + protocol: 'http', + kind: 'external' + } + ] + } +} + +/** Mirrors the store's replaceWorkspacePortScans semantics: one atomic update. */ +function makeStoreHarness(initial: Record = {}): { + scansByKey: Record + projections: { key: string; result: WorkspacePortScanResult }[] + publisher: Omit +} { + let scansByKey: Record = { ...initial } + const projections: { key: string; result: WorkspacePortScanResult }[] = [] + return { + get scansByKey() { + return scansByKey + }, + projections, + publisher: { + replaceWorkspacePortScans: ( + nextScansByKey: Record, + projection: { key: string; result: WorkspacePortScanResult } | null + ) => { + scansByKey = nextScansByKey + if (projection) { + projections.push(projection) + } + }, + getWorkspacePortScansByKey: () => scansByKey + } + } +} + +describe('publishWorkspacePortScanForHost', () => { + let localScan: WorkspacePortScanResult + let remoteScan: WorkspacePortScanResult + + beforeEach(() => { + localScan = scanWithPort(5173, 10) + remoteScan = scanWithPort(3000, 20) + }) + + it('publishes the single tracked host under its own key', () => { + const harness = makeStoreHarness() + + publishWorkspacePortScanForHost({ + ...harness.publisher, + scanKey: 'local:all', + scan: localScan + }) + + expect(harness.projections).toEqual([{ key: 'local:all', result: localScan }]) + expect(Object.keys(harness.scansByKey)).toEqual(['local:all']) + }) + + it('keeps the other host in the projection when one host refreshes', () => { + const harness = makeStoreHarness({ 'local:all': localScan }) + + publishWorkspacePortScanForHost({ + ...harness.publisher, + scanKey: 'environment:env-1:all', + scan: remoteScan + }) + + const projection = harness.projections.at(-1) + expect(projection?.key).toBe(WORKSPACE_PORT_ALL_HOSTS_SCAN_KEY) + expect(projection?.result.ports.map((port) => port.port).sort()).toEqual([3000, 5173]) + expect(Object.keys(harness.scansByKey).sort()).toEqual(['environment:env-1:all', 'local:all']) + }) + + it('publishes map and projection in a single store update', () => { + const harness = makeStoreHarness({ 'local:all': localScan }) + const replaceSpy = vi.spyOn(harness.publisher, 'replaceWorkspacePortScans') + + publishWorkspacePortScanForHost({ + ...harness.publisher, + scanKey: 'environment:env-1:all', + scan: remoteScan + }) + + // Why: two sequential setter calls notify subscribers twice for one scan; + // one atomic replace keeps map and projection from ever disagreeing. + expect(replaceSpy).toHaveBeenCalledTimes(1) + const [nextScans, projection] = replaceSpy.mock.calls[0] + expect(Object.keys(nextScans).sort()).toEqual(['environment:env-1:all', 'local:all']) + expect(projection?.key).toBe(WORKSPACE_PORT_ALL_HOSTS_SCAN_KEY) + }) + + it('does not accumulate duplicate rows across repeated publishes', () => { + const harness = makeStoreHarness({ 'local:all': localScan }) + + publishWorkspacePortScanForHost({ + ...harness.publisher, + scanKey: 'environment:env-1:all', + scan: remoteScan + }) + publishWorkspacePortScanForHost({ + ...harness.publisher, + scanKey: 'environment:env-1:all', + scan: { ...remoteScan, scannedAt: 30 } + }) + + // Why: the aggregate must never land in the per-host map, or the next merge + // folds the merged result back into itself and rows multiply. + expect(harness.scansByKey[WORKSPACE_PORT_ALL_HOSTS_SCAN_KEY]).toBeUndefined() + expect( + harness.projections + .at(-1) + ?.result.ports.map((port) => port.port) + .sort() + ).toEqual([3000, 5173]) + }) +}) diff --git a/src/renderer/src/runtime/runtime-client-target.ts b/src/renderer/src/runtime/runtime-client-target.ts index 1a0b8e4b8a4..fbf9af17374 100644 --- a/src/renderer/src/runtime/runtime-client-target.ts +++ b/src/renderer/src/runtime/runtime-client-target.ts @@ -1,4 +1,5 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' +import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' export type RuntimeClientTarget = { kind: 'local' } | { kind: 'environment'; environmentId: string } @@ -9,6 +10,20 @@ export function getActiveRuntimeTarget( return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } } +/** RPC target for a dispatchable host; direct SSH cannot use this client path. */ +export function runtimeTargetForExecutionHostId( + hostId: ExecutionHostId +): RuntimeClientTarget | null { + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind === 'local') { + return { kind: 'local' } + } + if (parsed?.kind === 'runtime') { + return { kind: 'environment', environmentId: parsed.environmentId } + } + return null +} + export function settingsForRuntimeOwner( settings: Pick | null | undefined, runtimeEnvironmentId: string | null | undefined diff --git a/src/renderer/src/runtime/use-worktree-runtime-target.ts b/src/renderer/src/runtime/use-worktree-runtime-target.ts new file mode 100644 index 00000000000..25bd9099e90 --- /dev/null +++ b/src/renderer/src/runtime/use-worktree-runtime-target.ts @@ -0,0 +1,16 @@ +import { useAppStore } from '@/store' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { runtimeTargetForExecutionHostId, type RuntimeClientTarget } from './runtime-client-target' + +/** + * Runtime target that owns `worktreeId`, which is not always the globally + * focused runtime — acting on the focused one scans the wrong host and reports + * that workspace as having no ports. Direct-SSH owners return null. + */ +export function useWorktreeRuntimeTarget( + worktreeId: string | null | undefined +): RuntimeClientTarget | null { + return useAppStore((state) => + runtimeTargetForExecutionHostId(getExecutionHostIdForWorktree(state, worktreeId)) + ) +} From 3941edd4b6d474bf1c170cfbb7a0c80798d97bdc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:20:35 -0700 Subject: [PATCH 02/58] perf(ipc): build the filesystem allowed-root list once per authorization (#18423) * perf(ipc): build the filesystem allowed-root list once per authorization * perf(ipc): keep the allowed-root snapshot lazy so granted external paths build nothing Hoisting getAllowedRoots to the top of resolveAuthorizedPath made every read of a path covered by an external grant build the full root list, where main built none (the grant answered before isPathAllowed reached the roots). Build on first use instead: still one build per authorization, zero when a grant already answers. * test(ipc): skip the allowed-root symlink escapes on Windows Unprivileged Windows cannot create symlinks (EPERM), so both cases failed in setup instead of exercising the escape check. --- src/main/ipc/filesystem-allowed-roots.test.ts | 372 ++++++++++++++++++ src/main/ipc/filesystem-allowed-roots.ts | 59 ++- src/main/ipc/filesystem-auth.ts | 65 ++- src/main/project-runtime-git-options.ts | 7 +- src/shared/project-groups.ts | 23 +- 5 files changed, 492 insertions(+), 34 deletions(-) create mode 100644 src/main/ipc/filesystem-allowed-roots.test.ts diff --git a/src/main/ipc/filesystem-allowed-roots.test.ts b/src/main/ipc/filesystem-allowed-roots.test.ts new file mode 100644 index 00000000000..f94c99c5fdb --- /dev/null +++ b/src/main/ipc/filesystem-allowed-roots.test.ts @@ -0,0 +1,372 @@ +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type * as RepoWorktrees from '../repo-worktrees' +import { listRepoWorktreeGraph } from '../repo-worktrees' +import type * as ProjectGroupsModule from '../../shared/project-groups' +import { buildProjectGroupChildIndex, getProjectGroupSubtreeIds } from '../../shared/project-groups' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { getWorktreeMirrorDistro } from '../project-runtime-git-options' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../shared/project-group-types' +import type { Project } from '../../shared/project-types' +import type { Repo } from '../../shared/repo-types' +import { getAllowedRoots } from './filesystem-allowed-roots' +import { authorizeExternalPath, resolveAuthorizedPath } from './filesystem-auth' +import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' +import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic' + +vi.mock('../repo-worktrees', async () => { + const actual = await vi.importActual('../repo-worktrees') + return { ...actual, listRepoWorktreeGraph: vi.fn(async () => []) } +}) + +vi.mock('../../shared/project-groups', async () => { + const actual = await vi.importActual('../../shared/project-groups') + return { + ...actual, + buildProjectGroupChildIndex: vi.fn(actual.buildProjectGroupChildIndex), + getProjectGroupSubtreeIds: vi.fn(actual.getProjectGroupSubtreeIds) + } +}) + +type StoreFixture = { + repos: Repo[] + projects: Project[] + projectGroups: ProjectGroup[] + folderWorkspaces: FolderWorkspace[] + workspaceDir?: string +} + +type StoreCallCounts = { + getRepos: number + getProjects: number + getProjectGroups: number + getFolderWorkspaces: number +} + +function makeCountingStore(fixture: StoreFixture): { store: Store; counts: StoreCallCounts } { + const counts: StoreCallCounts = { + getRepos: 0, + getProjects: 0, + getProjectGroups: 0, + getFolderWorkspaces: 0 + } + const store = { + getRepos: () => { + counts.getRepos += 1 + // Match the real store, which rehydrates fresh repo objects on every read. + return fixture.repos.map((repo) => ({ ...repo })) + }, + getProjects: () => { + counts.getProjects += 1 + return fixture.projects.map((project) => ({ ...project })) + }, + getProjectGroups: () => { + counts.getProjectGroups += 1 + return fixture.projectGroups.map((group) => ({ ...group })) + }, + getFolderWorkspaces: () => { + counts.getFolderWorkspaces += 1 + return fixture.folderWorkspaces.map((workspace) => ({ ...workspace })) + }, + getSettings: () => ({ nestWorkspaces: false, workspaceDir: fixture.workspaceDir ?? '' }) + } as unknown as Store + return { store, counts } +} + +/** + * The pre-change `getAllowedRoots` algorithm, kept verbatim so the equivalence test compares the + * new root list against the old one rather than against a hand-written expectation. + */ +function referenceAllowedRoots(store: Store): string[] { + const scopeStore = store as unknown as { + getRepos: () => Repo[] + getProjectGroups?: () => ProjectGroup[] + getFolderWorkspaces?: () => FolderWorkspace[] + getSettings: () => { workspaceDir?: string; nestWorkspaces?: boolean } + } + const localRepos = scopeStore.getRepos().filter((repo) => !repo.connectionId) + const settings = scopeStore.getSettings() + + const scopeRepos = scopeStore.getRepos() + const projectGroups = scopeStore.getProjectGroups?.() ?? [] + const isRemoteOnly = ( + folderPath: string, + projectGroupId: string, + connectionId: string | null | undefined + ): boolean => { + if (connectionId) { + return true + } + const groupIds = getProjectGroupSubtreeIds(projectGroups, projectGroupId) + const candidates = scopeRepos.filter( + (repo) => + (typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) || + isPathInsideOrEqual(folderPath, repo.path) + ) + return candidates.length > 0 && candidates.every((repo) => Boolean(repo.connectionId)) + } + const folderScopeRoots: string[] = [] + for (const group of projectGroups) { + if (group.parentPath && !isRemoteOnly(group.parentPath, group.id, group.connectionId)) { + folderScopeRoots.push(resolve(group.parentPath)) + } + } + for (const workspace of scopeStore.getFolderWorkspaces?.() ?? []) { + const connectionId = + workspace.connectionId ?? + projectGroups.find((group) => group.id === workspace.projectGroupId)?.connectionId ?? + null + if (!isRemoteOnly(workspace.folderPath, workspace.projectGroupId, connectionId)) { + folderScopeRoots.push(resolve(workspace.folderPath)) + } + } + + const roots = [...localRepos.map((repo) => resolve(repo.path)), ...folderScopeRoots] + if (settings.workspaceDir) { + if (localRepos.length === 0) { + roots.push(resolve(settings.workspaceDir)) + } else { + for (const repo of localRepos) { + roots.push( + resolve( + computeWorkspaceRoot( + repo.path, + getWorktreePathSettings(repo, settings as never, getWorktreeMirrorDistro(store, repo)) + ) + ) + ) + } + } + } + return roots +} + +function makeRepo(overrides: Partial & Pick): Repo { + return { + displayName: overrides.id, + badgeColor: '#000000', + addedAt: 1, + kind: 'git', + ...overrides + } +} + +function makeGroup(overrides: Partial & Pick): ProjectGroup { + return { + name: overrides.id, + parentPath: null, + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeWorkspace( + overrides: Partial & Pick +): FolderWorkspace { + return { + projectGroupId: 'group-root', + name: overrides.id, + comment: '', + linkedTask: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +/** Repos, nested groups, folder workspaces (one not a git worktree), and an SSH repo. */ +function makeMixedFixture(): StoreFixture { + const repos = [ + makeRepo({ id: 'repo-local', path: '/repos/app', projectGroupId: 'group-root' }), + makeRepo({ id: 'repo-nested', path: '/repos/nested', projectGroupId: 'group-child' }), + makeRepo({ id: 'repo-folder', path: '/folders/plain', kind: 'folder' }), + makeRepo({ + id: 'repo-ssh', + path: '/remote/app', + connectionId: 'ssh-1', + projectGroupId: 'group-remote' + }) + ] + const projectGroups = [ + makeGroup({ id: 'group-root', parentPath: '/folders/root' }), + makeGroup({ id: 'group-child', parentGroupId: 'group-root', parentPath: '/folders/child' }), + makeGroup({ id: 'group-grandchild', parentGroupId: 'group-child' }), + makeGroup({ id: 'group-remote', parentPath: '/remote/scope' }), + makeGroup({ id: 'group-connection', parentPath: '/remote/via-group', connectionId: 'ssh-1' }) + ] + const folderWorkspaces = [ + makeWorkspace({ id: 'ws-git', folderPath: '/folders/root/feature' }), + // Not a git worktree: a plain folder workspace under a folder-kind repo. + makeWorkspace({ + id: 'ws-plain', + folderPath: '/folders/plain/scratch', + projectGroupId: 'group-child' + }), + makeWorkspace({ id: 'ws-remote', folderPath: '/remote/ws', projectGroupId: 'group-remote' }), + makeWorkspace({ + id: 'ws-connection', + folderPath: '/remote/direct', + projectGroupId: 'group-connection' + }), + makeWorkspace({ + id: 'ws-unlinked', + folderPath: '/folders/unlinked', + projectGroupId: 'group-orphan' + }) + ] + const projects: Project[] = [ + { + id: 'project-1', + displayName: 'App', + badgeColor: '#000000', + sourceRepoIds: ['repo-local', 'repo-nested'], + createdAt: 1, + updatedAt: 1 + }, + { + id: 'project-2', + displayName: 'Folder', + badgeColor: '#000000', + sourceRepoIds: ['repo-folder'], + createdAt: 1, + updatedAt: 1 + } + ] + return { repos, projects, projectGroups, folderWorkspaces, workspaceDir: '/workspaces' } +} + +beforeEach(() => { + invalidateAuthorizedRootsCache() + vi.mocked(buildProjectGroupChildIndex).mockClear() + vi.mocked(getProjectGroupSubtreeIds).mockClear() +}) + +describe('getAllowedRoots', () => { + it('produces the same roots as the pre-change implementation', () => { + const { store } = makeCountingStore(makeMixedFixture()) + + expect(getAllowedRoots(store)).toEqual(referenceAllowedRoots(store)) + }) + + it('reads the store once and indexes project groups once per build', () => { + const fixture = makeMixedFixture() + const { store, counts } = makeCountingStore(fixture) + + getAllowedRoots(store) + + expect.soft(counts.getRepos).toBe(1) + expect.soft(counts.getProjectGroups).toBe(1) + expect.soft(counts.getFolderWorkspaces).toBe(1) + // Batched runtime resolution scans the project list once, not once per local repo. + expect.soft(counts.getProjects).toBe(1) + // The per-scope subtree walk no longer rebuilds the parent->children index. + expect.soft(vi.mocked(buildProjectGroupChildIndex)).toHaveBeenCalledTimes(1) + expect.soft(vi.mocked(getProjectGroupSubtreeIds)).not.toHaveBeenCalled() + }) +}) + +describe('resolveAuthorizedPath allowed-root reuse', () => { + let repoRoot: string + let outsideRoot: string + let store: Store + let counts: StoreCallCounts + + beforeEach(async () => { + repoRoot = await mkdtemp(join(await realpath(tmpdir()), 'orca-allowed-roots-')) + outsideRoot = await mkdtemp(join(await realpath(tmpdir()), 'orca-outside-')) + const fixture = makeMixedFixture() + fixture.repos = [makeRepo({ id: 'repo-local', path: repoRoot }), ...fixture.repos] + fixture.projects[0]!.sourceRepoIds = ['repo-local'] + ;({ store, counts } = makeCountingStore(fixture)) + }) + + afterEach(async () => { + await rm(repoRoot, { recursive: true, force: true }) + await rm(outsideRoot, { recursive: true, force: true }) + }) + + it('builds the allowed-root list once per call across repeated reads', async () => { + const dirPath = join(repoRoot, 'src') + await mkdir(dirPath) + await writeFile(join(dirPath, 'index.ts'), 'export {}\n') + const callCount = 5 + + for (let index = 0; index < callCount; index += 1) { + await resolveAuthorizedPath(dirPath, store) + await resolveAuthorizedPath(join(dirPath, 'index.ts'), store) + } + + const buildCount = callCount * 2 + // One build per authorization, not one per raw-path check plus one per realpath check. + expect.soft(counts.getFolderWorkspaces).toBe(buildCount) + expect.soft(counts.getRepos).toBe(buildCount) + expect.soft(counts.getProjects).toBe(buildCount) + expect.soft(vi.mocked(buildProjectGroupChildIndex)).toHaveBeenCalledTimes(buildCount) + expect.soft(vi.mocked(getProjectGroupSubtreeIds)).not.toHaveBeenCalled() + }) + + // Why (both symlink cases): creating a symlink on Windows needs elevation or + // Developer Mode, so these would fail EPERM in setup rather than exercise the + // escape check. Every non-symlink case still runs there. + it.skipIf(process.platform === 'win32')( + 'still refuses a symlink that escapes every allowed root', + async () => { + const secret = join(outsideRoot, 'secret.txt') + await writeFile(secret, 'secret\n') + const escape = join(repoRoot, 'escape.txt') + await symlink(secret, escape) + + await expect(resolveAuthorizedPath(escape, store)).rejects.toThrow('Access denied') + expect(vi.mocked(listRepoWorktreeGraph)).toHaveBeenCalled() + } + ) + + it('builds no allowed-root list at all for a granted external path', async () => { + const external = join(outsideRoot, 'external.md') + await writeFile(external, 'notes\n') + authorizeExternalPath(external) + counts.getRepos = 0 + counts.getProjects = 0 + counts.getFolderWorkspaces = 0 + + for (let index = 0; index < 5; index += 1) { + await expect(resolveAuthorizedPath(external, store)).resolves.toBe(external) + } + + // The grant answers on its own; hoisting the snapshot must not turn zero builds into one per read. + expect.soft(counts.getRepos).toBe(0) + expect.soft(counts.getProjects).toBe(0) + expect.soft(counts.getFolderWorkspaces).toBe(0) + expect.soft(vi.mocked(buildProjectGroupChildIndex)).not.toHaveBeenCalled() + }) + + it.skipIf(process.platform === 'win32')( + 'still refuses a directory symlink that escapes every allowed root', + async () => { + const outsideDir = join(outsideRoot, 'nested') + await mkdir(outsideDir) + await writeFile(join(outsideDir, 'file.txt'), 'secret\n') + const escape = join(repoRoot, 'escape-dir') + await symlink(outsideDir, escape) + + await expect(resolveAuthorizedPath(join(escape, 'file.txt'), store)).rejects.toThrow( + 'Access denied' + ) + } + ) +}) diff --git a/src/main/ipc/filesystem-allowed-roots.ts b/src/main/ipc/filesystem-allowed-roots.ts index 3cb7fe4fa55..cef249430c6 100644 --- a/src/main/ipc/filesystem-allowed-roots.ts +++ b/src/main/ipc/filesystem-allowed-roots.ts @@ -1,9 +1,16 @@ import { resolve } from 'node:path' import type { Store } from '../persistence' import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic' -import { getWorktreeMirrorDistro } from '../project-runtime-git-options' +import { + getWorktreeMirrorDistroForRuntime, + resolveLocalProjectRuntimesForRepos +} from '../project-runtime-git-options' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' -import { getProjectGroupSubtreeIds } from '../../shared/project-groups' +import { + buildProjectGroupChildIndex, + collectProjectGroupSubtreeIds, + type ProjectGroupChildIndex +} from '../../shared/project-groups' import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { ProjectGroup } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' @@ -11,18 +18,22 @@ import type { Repo } from '../../shared/repo-types' type FolderScopeStore = Pick & Partial> +// Why: SSH repo paths are remote-host paths; treating them as local roots could authorize unrelated local folders or probe SSH-only paths. +function filterLocalRepos(repos: readonly Repo[]): Repo[] { + return repos.filter((repo) => !repo.connectionId) +} + export function getLocalRepos(store: Store) { - // Why: SSH repo paths are remote-host paths; treating them as local roots could authorize unrelated local folders or probe SSH-only paths. - return store.getRepos().filter((repo) => !repo.connectionId) + return filterLocalRepos(store.getRepos()) } function getFolderScopeCandidateRepos( folderPath: string, projectGroupId: string, - projectGroups: readonly ProjectGroup[], + childGroupIndex: ProjectGroupChildIndex, repos: readonly Repo[] ): Repo[] { - const groupIds = getProjectGroupSubtreeIds(projectGroups, projectGroupId) + const groupIds = collectProjectGroupSubtreeIds(childGroupIndex, projectGroupId) return repos.filter( (repo) => (typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) || @@ -34,13 +45,18 @@ function isRemoteOnlyFolderScope( folderPath: string, projectGroupId: string, connectionId: string | null | undefined, - projectGroups: readonly ProjectGroup[], + childGroupIndex: ProjectGroupChildIndex, repos: readonly Repo[] ): boolean { if (connectionId) { return true } - const candidates = getFolderScopeCandidateRepos(folderPath, projectGroupId, projectGroups, repos) + const candidates = getFolderScopeCandidateRepos( + folderPath, + projectGroupId, + childGroupIndex, + repos + ) return candidates.length > 0 && candidates.every((repo) => Boolean(repo.connectionId)) } @@ -55,16 +71,22 @@ function getFolderWorkspaceConnectionId( ) } -function getLocalFolderScopeRoots(store: Store): string[] { +function getLocalFolderScopeRoots(store: Store, repos: readonly Repo[]): string[] { const scopeStore = store as FolderScopeStore - const repos = scopeStore.getRepos() // Why: many filesystem tests use narrow Store doubles; folder scopes are additive. const projectGroups = scopeStore.getProjectGroups?.() ?? [] + const childGroupIndex = buildProjectGroupChildIndex(projectGroups) const roots: string[] = [] for (const group of projectGroups) { if ( group.parentPath && - !isRemoteOnlyFolderScope(group.parentPath, group.id, group.connectionId, projectGroups, repos) + !isRemoteOnlyFolderScope( + group.parentPath, + group.id, + group.connectionId, + childGroupIndex, + repos + ) ) { roots.push(resolve(group.parentPath)) } @@ -75,7 +97,7 @@ function getLocalFolderScopeRoots(store: Store): string[] { workspace.folderPath, workspace.projectGroupId, getFolderWorkspaceConnectionId(workspace, projectGroups), - projectGroups, + childGroupIndex, repos ) ) { @@ -86,16 +108,19 @@ function getLocalFolderScopeRoots(store: Store): string[] { } export function getAllowedRoots(store: Store): string[] { - const localRepos = getLocalRepos(store) + // Why one read: `getRepos` rehydrates every repo, and this runs twice per filesystem IPC. + const repos = store.getRepos() + const localRepos = filterLocalRepos(repos) const settings = store.getSettings() const roots = [ ...localRepos.map((repo) => resolve(repo.path)), - ...getLocalFolderScopeRoots(store) + ...getLocalFolderScopeRoots(store, repos) ] if (settings.workspaceDir) { if (localRepos.length === 0) { roots.push(resolve(settings.workspaceDir)) } else { + const projectRuntimeByRepoId = resolveLocalProjectRuntimesForRepos(store, localRepos) for (const repo of localRepos) { roots.push( resolve( @@ -104,7 +129,11 @@ export function getAllowedRoots(store: Store): string[] { // Why enriched here too: placement has to agree with the create // flow, or renderer file access is denied for a worktree Orca // just put on the WSL side. - getWorktreePathSettings(repo, settings, getWorktreeMirrorDistro(store, repo)) + getWorktreePathSettings( + repo, + settings, + getWorktreeMirrorDistroForRuntime(projectRuntimeByRepoId.get(repo.id)) + ) ) ) ) diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 122617845ed..894e39945c1 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -43,7 +43,24 @@ export function authorizeExternalPath(targetPath: string): void { } catch {} } -export function isPathAllowed(targetPath: string, store: Store): boolean { +/** + * One allowed-root list shared by every check in a single authorization. + * + * Lazy so a path already covered by an external grant still builds nothing at all, the way it did + * before the list was hoisted out of the individual checks. + */ +type AllowedRootsSnapshot = { get: () => readonly string[] } + +function createAllowedRootsSnapshot(store: Store): AllowedRootsSnapshot { + let roots: readonly string[] | undefined + return { get: () => (roots ??= getAllowedRoots(store)) } +} + +export function isPathAllowed( + targetPath: string, + store: Store, + allowedRoots?: AllowedRootsSnapshot +): boolean { const resolvedTarget = resolve(targetPath) if (authorizedExternalPaths.has(resolvedTarget)) { return true @@ -53,7 +70,9 @@ export function isPathAllowed(targetPath: string, store: Store): boolean { return true } } - return getAllowedRoots(store).some((root) => isDescendantOrEqual(resolvedTarget, root)) + return (allowedRoots?.get() ?? getAllowedRoots(store)).some((root) => + isDescendantOrEqual(resolvedTarget, root) + ) } export type ResolveAuthorizedPathOptions = { @@ -69,7 +88,10 @@ export async function resolveAuthorizedPath( options: ResolveAuthorizedPathOptions = {} ): Promise { const resolvedTarget = resolve(targetPath) - if (!(await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store))) { + // Why: the roots depend only on store state, not on the candidate path, so one snapshot serves + // every authorization below; each candidate is still checked against it in full. + const allowedRoots = createAllowedRootsSnapshot(store) + if (!(await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store, { allowedRoots }))) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } @@ -80,14 +102,15 @@ export async function resolveAuthorizedPath( realParent = await realpath(dirname(resolvedTarget)) } catch (error) { if (isENOENT(error)) { - return resolveAuthorizedMissingPath(resolvedTarget, store) + return resolveAuthorizedMissingPath(resolvedTarget, store, allowedRoots) } throw error } const candidateTarget = resolve(realParent, basename(resolvedTarget)) if ( !(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, { - canonicalSourcePath: resolvedTarget + canonicalSourcePath: resolvedTarget, + allowedRoots })) ) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) @@ -100,7 +123,8 @@ export async function resolveAuthorizedPath( const realTarget = resolve(await realpath(resolvedTarget)) if ( !(await isPathAllowedIncludingRegisteredWorktrees(realTarget, store, { - canonicalSourcePath: resolvedTarget + canonicalSourcePath: resolvedTarget, + allowedRoots })) ) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) @@ -110,11 +134,15 @@ export async function resolveAuthorizedPath( if (!isENOENT(error)) { throw error } - return resolveAuthorizedMissingPath(resolvedTarget, store) + return resolveAuthorizedMissingPath(resolvedTarget, store, allowedRoots) } } -async function resolveAuthorizedMissingPath(resolvedTarget: string, store: Store): Promise { +async function resolveAuthorizedMissingPath( + resolvedTarget: string, + store: Store, + allowedRoots: AllowedRootsSnapshot +): Promise { let existingAncestor = resolvedTarget const missingSegments: string[] = [] @@ -124,7 +152,8 @@ async function resolveAuthorizedMissingPath(resolvedTarget: string, store: Store const candidateTarget = resolve(realAncestor, ...missingSegments) if ( !(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, { - canonicalSourcePath: resolvedTarget + canonicalSourcePath: resolvedTarget, + allowedRoots })) ) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) @@ -148,9 +177,9 @@ async function resolveAuthorizedMissingPath(resolvedTarget: string, store: Store async function isPathAllowedIncludingRegisteredWorktrees( targetPath: string, store: Store, - options: { canonicalSourcePath?: string } = {} + options: { canonicalSourcePath?: string; allowedRoots?: AllowedRootsSnapshot } = {} ): Promise { - if (isPathAllowed(targetPath, store)) { + if (isPathAllowed(targetPath, store, options.allowedRoots)) { return true } @@ -158,7 +187,14 @@ async function isPathAllowedIncludingRegisteredWorktrees( return true } - if (await isPathAllowedByCanonicalAllowedRoot(targetPath, options.canonicalSourcePath, store)) { + if ( + await isPathAllowedByCanonicalAllowedRoot( + targetPath, + options.canonicalSourcePath, + store, + options.allowedRoots + ) + ) { return true } @@ -178,12 +214,13 @@ async function isPathAllowedIncludingRegisteredWorktrees( async function isPathAllowedByCanonicalAllowedRoot( targetPath: string, sourcePath: string | undefined, - store: Store + store: Store, + allowedRoots?: AllowedRootsSnapshot ): Promise { if (!sourcePath) { return false } - for (const root of getAllowedRoots(store)) { + for (const root of allowedRoots?.get() ?? getAllowedRoots(store)) { const resolvedRoot = resolve(root) if (!isDescendantOrEqual(sourcePath, resolvedRoot)) { continue diff --git a/src/main/project-runtime-git-options.ts b/src/main/project-runtime-git-options.ts index 808d31d5fcf..20aa0e9659a 100644 --- a/src/main/project-runtime-git-options.ts +++ b/src/main/project-runtime-git-options.ts @@ -102,7 +102,12 @@ export function getWorktreeMirrorDistro( store: ProjectRuntimeResolutionStore, repo: Repo ): string | undefined { - const projectRuntime = resolveLocalProjectRuntimeForRepo(store, repo) + return getWorktreeMirrorDistroForRuntime(resolveLocalProjectRuntimeForRepo(store, repo)) +} + +export function getWorktreeMirrorDistroForRuntime( + projectRuntime: ProjectExecutionRuntimeResolution | undefined +): string | undefined { if (!projectRuntime || projectRuntime.status !== 'resolved') { return undefined } diff --git a/src/shared/project-groups.ts b/src/shared/project-groups.ts index 67c2897ead6..c4fe8badb47 100644 --- a/src/shared/project-groups.ts +++ b/src/shared/project-groups.ts @@ -109,10 +109,12 @@ export function clearMissingProjectGroupMemberships(repos: Repo[], groups: Proje ) } -export function getProjectGroupSubtreeIds( - groups: readonly Pick[], - rootGroupId: string -): Set { +export type ProjectGroupChildIndex = ReadonlyMap + +/** Build once and reuse when collecting subtrees for more than one root. */ +export function buildProjectGroupChildIndex( + groups: readonly Pick[] +): ProjectGroupChildIndex { const childGroupsByParentId = new Map() for (const group of groups) { if (!group.parentGroupId) { @@ -122,7 +124,20 @@ export function getProjectGroupSubtreeIds( children.push(group.id) childGroupsByParentId.set(group.parentGroupId, children) } + return childGroupsByParentId +} +export function getProjectGroupSubtreeIds( + groups: readonly Pick[], + rootGroupId: string +): Set { + return collectProjectGroupSubtreeIds(buildProjectGroupChildIndex(groups), rootGroupId) +} + +export function collectProjectGroupSubtreeIds( + childGroupsByParentId: ProjectGroupChildIndex, + rootGroupId: string +): Set { const subtreeIds = new Set() const pending = [rootGroupId] while (pending.length > 0) { From 79d5fb469a31f0fce51fe360bb894ac2f9d7b121 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:23:54 -0400 Subject: [PATCH 03/58] fix(cloud): recalibrate the relay monitor's postgres-retry freeze to a measured bar (#18580) The global relay_cells FOR UPDATE lock made successful retries a steady-state rate: fleet-wide p50 430 / p90 924 / p99 1320 / max 1504 per five minutes over the last 24 h, 55% of windows over the 300 bar, only 22% of 15-minute gates clean. Three read-only dry-runs on 2026-09-04 froze on it, blocking the same-cap roll that carries #18521 and the beginProof crash guard to the 23 cells. 2000 clears every measured healthy gate; the exhausted-retry, director concurrency, and pool bars keep the incident discriminator role. --- .../relay-ops/src/incident-monitor.test.ts | 17 +++++++++------ cloud/apps/relay-ops/src/incident-monitor.ts | 21 +++++++++++++------ cloud/docs/relay-incident-monitor.md | 16 +++++++++++++- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/cloud/apps/relay-ops/src/incident-monitor.test.ts b/cloud/apps/relay-ops/src/incident-monitor.test.ts index 61a73b64dbe..4e1da9fab26 100644 --- a/cloud/apps/relay-ops/src/incident-monitor.test.ts +++ b/cloud/apps/relay-ops/src/incident-monitor.test.ts @@ -111,14 +111,19 @@ describe('incident monitor evaluator', () => { }) }) - it('freezes when postgres retries exceed the recalibrated ceiling', () => { - const sample = healthySample() - sample.sources['relay-logs']!.signals['relay.postgres_retries'] = - signal(INCIDENT_MONITOR_THRESHOLDS.relayPostgresRetries + 1) - expect(evaluateIncidentSample(sample, startedAt)).toMatchObject({ + // Why: the global relay_cells lock made retries a steady-state rate (24 h p99 + // 1320/5min on 2026-09-04); the bar fences only unbounded growth beyond that. + it('tolerates the measured healthy retry rate and freezes above the bar', () => { + const healthy = healthySample() + healthy.sources['relay-logs']!.signals['relay.postgres_retries'] = signal(1504) + expect(evaluateIncidentSample(healthy, startedAt).status).toBe('green') + + const incident = healthySample() + incident.sources['relay-logs']!.signals['relay.postgres_retries'] = signal(2001) + expect(evaluateIncidentSample(incident, startedAt)).toMatchObject({ status: 'freeze', failures: [ - expect.objectContaining({ signal: 'relay.postgres_retries', threshold: 300 }) + expect.objectContaining({ signal: 'relay.postgres_retries', threshold: 2000 }) ] }) }) diff --git a/cloud/apps/relay-ops/src/incident-monitor.ts b/cloud/apps/relay-ops/src/incident-monitor.ts index 868bb86fb93..a121568d918 100644 --- a/cloud/apps/relay-ops/src/incident-monitor.ts +++ b/cloud/apps/relay-ops/src/incident-monitor.ts @@ -32,11 +32,20 @@ export const INCIDENT_MONITOR_THRESHOLDS = { relayPoolWaiting: 800, relayPoolWaitMs: 2_500, // Why: successful lock retries are the contention machinery working, not harm. - // Healthy 2026-08-26 baseline bursts to 234/5min (26% of windows crossed the old - // bar of 20, set unmeasured at the monitor's 2026-07-28 birth); the 2026-08-23 - // incident ran ~2,200-3,000/5min. 300 clears healthy bursts with ~10x incident - // margin; relayPostgresRetryExhausted below bounds the terminally failed share. - relayPostgresRetries: 300, + // Recalibrated 2026-09-04 from 300, which was set 2026-08-26 when healthy bursts + // reached 234/5min. The global relay_cells FOR UPDATE lock has since become the + // fleet's steady state: measured fleet-wide (director + cells, summed per five + // minutes) 2026-09-03T05Z..2026-09-04T05Z p50 430 / p90 924 / p99 1320 / max + // 1504, with 55% of windows over 300 and only 22% of 15-minute gates clean, so + // the bar blocked the very cell roll that carries the 500 ms lock wait (#18521) + // and the beginProof crash guard to the cells. The 2026-08-23 lock incident on + // this same metric peaked at 1510 in one window and 646 in the next, so it is + // not separable from today's contention by retries alone; it is caught by + // relayPostgresRetryExhausted (467 at the peak vs a 300 bar), director + // concurrency, and the pool bars. 2000 passes every healthy 15-minute window + // measured in the last 24 h and still fences unbounded growth. Re-tighten once + // the fleet is on the 500 ms lock wait and the baseline is re-measured. + relayPostgresRetries: 2000, // Why: 300 per five minutes, recalibrated 2026-09-04 from a bar of zero that no // production window has cleared since #18521 shipped to the director. That // change cut the request-path cell-inventory wait from the 1 s pool lock_timeout @@ -48,7 +57,7 @@ export const INCIDENT_MONITOR_THRESHOLDS = { // quiet hours p50 2 / max 36; pre-#18521 daytime p50 10 / p90 25 / max 87; // post-#18521 p50 42 / p90 147 / max 220. The 2026-08-23 lock incident peaked // at 467. 300 clears every measured healthy window and still sits below the - // incident shape; relayPostgresRetries above stays the ~10x discriminator. + // incident shape; retries above fence only unbounded growth. // User-facing /v1/assign 503 share did not move with #18521 (13.9% old image // vs 12.3% new, same evening), so exhaustion is not a proxy for user harm. relayPostgresRetryExhausted: 300, diff --git a/cloud/docs/relay-incident-monitor.md b/cloud/docs/relay-incident-monitor.md index 337d3f1b20f..5c563f6a2e2 100644 --- a/cloud/docs/relay-incident-monitor.md +++ b/cloud/docs/relay-incident-monitor.md @@ -99,7 +99,7 @@ durably marked consumed before mutation and cannot authorize another run. | Cloud SQL deadlocks | over 0 | | Relay pool waiters | over 800 | | Relay pool wait | over 2,500 ms | -| PostgreSQL retries in five minutes | over 300 | +| PostgreSQL retries in five minutes | over 2,000 | | Exhausted PostgreSQL retries in five minutes | over 300 | | Director instances | outside 5–6 | | Director CPU or memory | over 80% | @@ -139,6 +139,20 @@ heartbeats, and matching live admission. logs: healthy-day bursts reach 234/5min with zero exhausted retries and 26% of five-minute windows over 20, while the 2026-08-23 lock-contention incident ran roughly 2,200–3,000/5min. +- Recalibrated the PostgreSQL-retry freeze from 300 to 2,000 per five minutes + (2026-09-04). Basis: the global `relay_cells FOR UPDATE` lock made + successful retries a steady-state rate. Measured fleet-wide (director + + cells, summed per five minutes from the `orca_relay_postgres_retries` + log metric) over 2026-09-03T05Z..2026-09-04T05Z: p50 430 / p90 924 / + p99 1,320 / max 1,504; 55% of windows over 300; only 22% of 15-minute gates + clean at 300 versus 100% at 2,000. Three read-only dry-runs on 2026-09-04 + froze on this bar (runs 33836470590, 33838698725) or on a genuine six-cell + crash storm (33837160275), blocking the same-cap roll that carries #18521 + and the `beginProof` crash guard to the 23 cells. The 2026-08-23 incident + on this metric peaked at 1,510 then 646, so retries alone no longer + separate it from today's baseline; the exhausted-retry bar (incident peak + 467 vs bar 300), director concurrency, and the pool bars carry that role. + Re-tighten after the fleet is on the 500 ms lock wait. - Recalibrated the exhausted-PostgreSQL-retry freeze from 0 to 300 per five minutes (2026-09-04). Basis: #18521 cut the request-path cell-inventory lock wait from the 1 s pool `lock_timeout` to 500 ms, so contended waiters From b378101901d8062765ec81faa88addf6ad037d94 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:40:55 -0400 Subject: [PATCH 04/58] docs(cloud): reconcile the 2026-08-23 retry figure with the gate metric (#18581) --- cloud/docs/relay-incident-monitor.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cloud/docs/relay-incident-monitor.md b/cloud/docs/relay-incident-monitor.md index 5c563f6a2e2..870c95dd413 100644 --- a/cloud/docs/relay-incident-monitor.md +++ b/cloud/docs/relay-incident-monitor.md @@ -138,7 +138,9 @@ heartbeats, and matching live admission. `jsonPayload.event="orca_relay_postgres_transaction_retry"` in production logs: healthy-day bursts reach 234/5min with zero exhausted retries and 26% of five-minute windows over 20, while the 2026-08-23 lock-contention - incident ran roughly 2,200–3,000/5min. + incident ran roughly 2,200–3,000/5min by raw log-line count (the gate's + own `orca_relay_postgres_retries` metric read 1,510 for that window; see the + 2026-09-04 entry). - Recalibrated the PostgreSQL-retry freeze from 300 to 2,000 per five minutes (2026-09-04). Basis: the global `relay_cells FOR UPDATE` lock made successful retries a steady-state rate. Measured fleet-wide (director + From 561a94038c3ab550be22bbf680f15e06b6afd7dc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:06:09 -0700 Subject: [PATCH 05/58] fix(ssh): stop the daemon's own services from blocking the superseded-relay reap (#18586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isReapableRelayHusk` required `childCount === 0`, where `childCount` came from `pgrep -P | grep -c .`. But the relay forks service children of its own, and `relay-ai-vault-service.js` never exits once spawned. Any relay that had served a single AI Vault request therefore reported a non-zero child count forever, so the sweep answered `retained-live-work` for a superseded, disconnected relay holding no user work at all — and its version directory stayed pinned against GC by its own live socket. The probe now censuses each direct child instead of counting them, and the reap gate reads the count of children it could *not* positively identify as relay infrastructure. The asymmetry is the safety argument (docs/reference/ssh-execution-boundary.md): subtracting a child we can name is positive knowledge, assuming about one we cannot is not. An unrecognised argv, an argv `ps` would not print, and a host without `pgrep` all keep the relay unreapable. `reapEmptyRelayHuskCommand` re-runs the same census on the host immediately before signalling. Fixes #13614 --- src/main/ssh/relay-daemon-service-children.ts | 62 +++++++++ ...dpoint-incumbent-shell.integration.test.ts | 118 ++++++++++++++++-- .../ssh/ssh-relay-endpoint-incumbent.test.ts | 64 +++++++--- src/main/ssh/ssh-relay-endpoint-incumbent.ts | 47 +++++-- .../ssh/ssh-relay-endpoint-takeover.test.ts | 29 +++-- src/main/ssh/ssh-relay-endpoint-takeover.ts | 15 ++- .../ssh-relay-superseded-endpoints.test.ts | 10 +- src/shared/relay-artifacts.ts | 18 ++- 8 files changed, 307 insertions(+), 56 deletions(-) create mode 100644 src/main/ssh/relay-daemon-service-children.ts diff --git a/src/main/ssh/relay-daemon-service-children.ts b/src/main/ssh/relay-daemon-service-children.ts new file mode 100644 index 00000000000..35e755ea518 --- /dev/null +++ b/src/main/ssh/relay-daemon-service-children.ts @@ -0,0 +1,62 @@ +/** + * Telling a relay daemon's own service processes apart from the work it holds. + * + * The reap gate used to ask `pgrep -P | grep -c .` and demand zero. But the daemon + * forks service children of its own — `relay-ai-vault-service.js` is spawned lazily and then + * never exits — so that count is permanently non-zero on any relay that has touched the AI + * Vault, whether or not it holds a single PTY. A superseded, disconnected relay holding + * nothing therefore reported `retained-live-work` forever, its version directory stayed + * pinned against GC by its own live socket, and the population grew without bound (#13614). + * + * The asymmetry below is the whole safety argument, and it follows + * docs/reference/ssh-execution-boundary.md: *subtracting a child we can positively identify + * as relay infrastructure is sound; assuming anything about a child we cannot identify is + * not.* An argv that does not match, an argv `ps` would not print, and a host without + * `pgrep` all count against the relay and keep it unreapable. Losing sight of a child is + * never evidence that it holds nothing. + */ +import { RELAY_DAEMON_SERVICE_ENTRY_FILENAMES } from '../../shared/relay-artifacts' +import { shellEscape } from './ssh-connection-utils' + +/** Shell variable set to the daemon's direct-child count, or `unknown`. */ +export const RELAY_CHILD_COUNT_VAR = 'kids' + +/** Shell variable set to the count of children not identified as relay services, or `unknown`. */ +export const RELAY_UNRECOGNIZED_CHILD_COUNT_VAR = 'unrecognized_kids' + +/** + * `case` patterns matching a service child's argv. Suffix-anchored on purpose: both entries + * are forked with no script arguments, so the argv ends at the filename, and the leading `/` + * requires the absolute path the daemon forks rather than a bare mention of the name. A + * future arg would stop matching and the relay would go back to being retained — the safe + * direction to fail in. + */ +function serviceChildArgvPatterns(): string { + return RELAY_DAEMON_SERVICE_ENTRY_FILENAMES.map( + (filename) => `*${shellEscape(`/${filename}`)}` + ).join('|') +} + +/** + * POSIX shell that censuses the direct children of `$pid`, setting `kids` and + * `unrecognized_kids`. Both stay `unknown` when the host cannot enumerate children at all. + */ +export function relayDaemonChildCensusShell(): string[] { + return [ + `${RELAY_CHILD_COUNT_VAR}=unknown`, + `${RELAY_UNRECOGNIZED_CHILD_COUNT_VAR}=unknown`, + 'if command -v pgrep >/dev/null 2>&1; then', + ` ${RELAY_CHILD_COUNT_VAR}=0`, + ` ${RELAY_UNRECOGNIZED_CHILD_COUNT_VAR}=0`, + ' for kid in $(pgrep -P "$pid" 2>/dev/null); do', + ` ${RELAY_CHILD_COUNT_VAR}=$((${RELAY_CHILD_COUNT_VAR}+1))`, + ' kid_args=$(ps -o args= -p "$kid" 2>/dev/null | tr -d "\\n")', + ' case "$kid_args" in', + ` ${serviceChildArgvPatterns()}) ;;`, + // An unreadable or unrecognised argv lands here, which is what keeps the relay retained. + ` *) ${RELAY_UNRECOGNIZED_CHILD_COUNT_VAR}=$((${RELAY_UNRECOGNIZED_CHILD_COUNT_VAR}+1)) ;;`, + ' esac', + ' done', + 'fi' + ] +} diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts b/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts index 7ece0b6532e..a8975d0520b 100644 --- a/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts +++ b/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts @@ -4,7 +4,7 @@ * generated scripts through /bin/sh against real unix sockets and real processes. */ import { execFile, spawn, type ChildProcess } from 'node:child_process' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' @@ -15,21 +15,32 @@ import { type RelayEndpointIncumbent } from './ssh-relay-endpoint-incumbent' import { reapEmptyRelayHuskCommand } from './ssh-relay-endpoint-takeover' +import { RELAY_DAEMON_SERVICE_ENTRY_FILENAMES } from '../../shared/relay-artifacts' const posixOnly = process.platform === 'win32' ? describe.skip : describe const FAKE_RELAY_SOURCE = ` const net = require('net') +const path = require('path') const sock = process.argv[process.argv.indexOf('--sock-path') + 1] +function spawnChild(args) { + require('child_process').spawn(process.execPath, args, { stdio: 'ignore' }) +} if (process.argv.includes('--with-child')) { - require('child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { - stdio: 'ignore' - }) + spawnChild(['-e', 'setTimeout(() => {}, 60000)']) +} +// Why forked the same way production does: the exclusion is argv-shaped, so a hand-written +// stand-in would test the test rather than the shell that runs on someone's host. +for (const name of process.argv.filter((arg) => arg.startsWith('--service-child='))) { + spawnChild([path.join(__dirname, name.slice('--service-child='.length))]) } net.createServer(() => {}).listen(sock, () => process.stdout.write('READY\\n')) process.on('SIGTERM', () => process.exit(0)) ` +// Self-limiting: these are orphaned when the relay under test is reaped. +const IDLE_SERVICE_SOURCE = 'setTimeout(() => {}, 60000)\n' + function sh(script: string): Promise { return new Promise((resolve, reject) => { execFile('/bin/sh', ['-c', script], { timeout: 20_000 }, (error, stdout) => { @@ -43,14 +54,21 @@ function sh(script: string): Promise { } let workDir: string +let pgreplessBinDir: string let hasLsof = false const running: ChildProcess[] = [] -function startFakeRelay(sockPath: string, withChild = false): Promise { +function startFakeRelay( + sockPath: string, + options: { withChild?: boolean; serviceChildren?: readonly string[] } = {} +): Promise { const args = [join(workDir, 'relay.js'), '--sock-path', sockPath] - if (withChild) { + if (options.withChild) { args.push('--with-child') } + for (const name of options.serviceChildren ?? []) { + args.push(`--service-child=${name}`) + } const child = spawn(process.execPath, args, { stdio: ['ignore', 'pipe', 'ignore'] }) running.push(child) return new Promise((resolve, reject) => { @@ -68,9 +86,31 @@ async function probe(sockPath: string): Promise { return parseRelayEndpointIncumbentProbe(sockPath, output) } +/** The relay forks its children after it starts listening, so the probe can race them. */ +async function waitForChildCount( + sockPath: string, + expected: number +): Promise { + let incumbent = await probe(sockPath) + for (let attempt = 0; attempt < 50 && incumbent.holders[0]?.childCount !== expected; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 100)) + incumbent = await probe(sockPath) + } + return incumbent +} + beforeAll(async () => { workDir = mkdtempSync(join(tmpdir(), 'orca-relay-incumbent-')) writeFileSync(join(workDir, 'relay.js'), FAKE_RELAY_SOURCE) + for (const filename of RELAY_DAEMON_SERVICE_ENTRY_FILENAMES) { + writeFileSync(join(workDir, filename), IDLE_SERVICE_SOURCE) + } + writeFileSync(join(workDir, 'looks-like-relay-watcher.js'), IDLE_SERVICE_SOURCE) + pgreplessBinDir = join(workDir, 'pgrepless-bin') + mkdirSync(pgreplessBinDir) + for (const tool of ['ps', 'tr']) { + symlinkSync((await sh(`command -v ${tool}`)).trim(), join(pgreplessBinDir, tool)) + } hasLsof = await sh('command -v lsof >/dev/null 2>&1 && echo yes || echo no').then( (out) => out.trim() === 'yes' ) @@ -105,13 +145,51 @@ posixOnly('relay endpoint probe against a real socket', () => { return } expect(incumbent.holders.map((holder) => holder.pid)).toEqual([relay.pid]) - expect(incumbent.holders[0]).toMatchObject({ matchesRelayArgv: true, childCount: 0 }) + expect(incumbent.holders[0]).toMatchObject({ + matchesRelayArgv: true, + childCount: 0, + unrecognizedChildCount: 0 + }) expect(isReapableRelayHusk(incumbent)).toBe(true) }) + it("counts the daemon's own service children but does not hold them against it", async () => { + const sockPath = join(workDir, 'services.sock') + await startFakeRelay(sockPath, { serviceChildren: RELAY_DAEMON_SERVICE_ENTRY_FILENAMES }) + const incumbent = await waitForChildCount(sockPath, RELAY_DAEMON_SERVICE_ENTRY_FILENAMES.length) + + expect(incumbent.holders[0].childCount).toBe(RELAY_DAEMON_SERVICE_ENTRY_FILENAMES.length) + expect(incumbent.holders[0].unrecognizedChildCount).toBe(0) + expect(isReapableRelayHusk(incumbent)).toBe(true) + }) + + it('still retains a relay holding work alongside its service children', async () => { + const sockPath = join(workDir, 'services-and-work.sock') + await startFakeRelay(sockPath, { + withChild: true, + serviceChildren: RELAY_DAEMON_SERVICE_ENTRY_FILENAMES + }) + const incumbent = await waitForChildCount( + sockPath, + RELAY_DAEMON_SERVICE_ENTRY_FILENAMES.length + 1 + ) + + expect(incumbent.holders[0].unrecognizedChildCount).toBe(1) + expect(isReapableRelayHusk(incumbent)).toBe(false) + }) + + it('does not excuse a child that merely mentions a service entry name', async () => { + const sockPath = join(workDir, 'lookalike.sock') + await startFakeRelay(sockPath, { serviceChildren: ['looks-like-relay-watcher.js'] }) + const incumbent = await waitForChildCount(sockPath, 1) + + expect(incumbent.holders[0].unrecognizedChildCount).toBe(1) + expect(isReapableRelayHusk(incumbent)).toBe(false) + }) + it('refuses to call a relay with a live child an empty husk', async () => { const sockPath = join(workDir, 'busy.sock') - await startFakeRelay(sockPath, true) + await startFakeRelay(sockPath, { withChild: true }) const incumbent = await probe(sockPath) expect(incumbent.verdict).toBe('live') @@ -150,12 +228,34 @@ posixOnly('empty relay husk reap against a real process', () => { it('refuses to signal a relay that acquired a child after it was probed', async () => { const sockPath = join(workDir, 'raced.sock') - const relay = await startFakeRelay(sockPath, true) + const relay = await startFakeRelay(sockPath, { withChild: true }) const output = await sh(reapEmptyRelayHuskCommand(relay.pid!, sockPath)) expect(output.trim()).toBe('BUSY') expect(relay.killed).toBe(false) }) + it('terminates a relay whose only children are its own service processes (#13614)', async () => { + const sockPath = join(workDir, 'service-husk.sock') + const relay = await startFakeRelay(sockPath, { + serviceChildren: RELAY_DAEMON_SERVICE_ENTRY_FILENAMES + }) + await waitForChildCount(sockPath, RELAY_DAEMON_SERVICE_ENTRY_FILENAMES.length) + const output = await sh(reapEmptyRelayHuskCommand(relay.pid!, sockPath)) + expect(output.trim()).toBe('GONE') + }) + + it('refuses to signal when the host cannot enumerate children at all', async () => { + const sockPath = join(workDir, 'no-pgrep.sock') + const relay = await startFakeRelay(sockPath) + // A PATH carrying every tool the script needs except `pgrep`: the census answers + // `unknown`, which must reach BUSY rather than the zero a missing tool would imply. + const output = await sh( + `PATH=${pgreplessBinDir}\n${reapEmptyRelayHuskCommand(relay.pid!, sockPath)}` + ) + expect(output.trim()).toBe('BUSY') + expect(relay.killed).toBe(false) + }) + it('refuses to signal a pid whose argv is not this relay at this socket', async () => { const sockPath = join(workDir, 'mismatch.sock') await startFakeRelay(sockPath) diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts b/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts index a65cb33fc57..de4cc28d170 100644 --- a/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts +++ b/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts @@ -32,17 +32,24 @@ describe('parseRelayEndpointIncumbentProbe', () => { it('reports live when the socket accepted a connection', () => { const incumbent = parseRelayEndpointIncumbentProbe( SOCK, - probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=4242 yes 13']) + probeOutput([ + 'PRESENT=yes', + 'LISTEN=accepted', + 'HOLDERS_SOURCE=lsof', + 'HOLDER=4242 yes 13 11' + ]) ) expect(incumbent.verdict).toBe('live') expect(incumbent.evidence).toBe('accepted-connection') - expect(incumbent.holders).toEqual([{ pid: 4242, matchesRelayArgv: true, childCount: 13 }]) + expect(incumbent.holders).toEqual([ + { pid: 4242, matchesRelayArgv: true, childCount: 13, unrecognizedChildCount: 11 } + ]) }) it('reports live when a process still holds an inode that refuses connections', () => { const incumbent = parseRelayEndpointIncumbentProbe( SOCK, - probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof', 'HOLDER=91 yes 2']) + probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof', 'HOLDER=91 yes 2 2']) ) expect(incumbent.verdict).toBe('live') expect(incumbent.evidence).toBe('holder-process') @@ -85,7 +92,12 @@ describe('parseRelayEndpointIncumbentProbe', () => { it('drops holder lines that do not carry a usable pid', () => { const incumbent = parseRelayEndpointIncumbentProbe( SOCK, - probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof', 'HOLDER=- no unknown']) + probeOutput([ + 'PRESENT=yes', + 'LISTEN=refused', + 'HOLDERS_SOURCE=lsof', + 'HOLDER=- no unknown unknown' + ]) ) expect(incumbent.holders).toEqual([]) expect(incumbent.verdict).toBe('exited') @@ -94,9 +106,24 @@ describe('parseRelayEndpointIncumbentProbe', () => { it('keeps an unreadable child count as null rather than zero', () => { const [holder] = parseRelayEndpointIncumbentProbe( SOCK, - probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=7 yes unknown']) + probeOutput([ + 'PRESENT=yes', + 'LISTEN=accepted', + 'HOLDERS_SOURCE=lsof', + 'HOLDER=7 yes unknown unknown' + ]) ).holders expect(holder.childCount).toBeNull() + expect(holder.unrecognizedChildCount).toBeNull() + }) + + it('keeps a holder line with no unrecognized-child field unreapable', () => { + const incumbent = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=7 yes 0']) + ) + expect(incumbent.holders[0].unrecognizedChildCount).toBeNull() + expect(isReapableRelayHusk(incumbent)).toBe(false) }) }) @@ -172,27 +199,36 @@ describe('mayLaunchOverRelayEndpoint', () => { describe('isReapableRelayHusk', () => { const husk = parseRelayEndpointIncumbentProbe( SOCK, - probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=500 yes 0']) + probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=500 yes 0 0']) ) - it('accepts a single proven relay holder with zero children', () => { + it('accepts a single proven relay holder with no unaccounted-for children', () => { expect(isReapableRelayHusk(husk)).toBe(true) }) - it('refuses a relay that still holds children', () => { + it('accepts a relay whose only children are its own service processes (#13614)', () => { + const withServices = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=500 yes 2 0']) + ) + expect(withServices.holders[0].childCount).toBe(2) + expect(isReapableRelayHusk(withServices)).toBe(true) + }) + + it('refuses a relay that still holds children it could not account for', () => { expect( isReapableRelayHusk({ ...husk, - holders: [{ pid: 500, matchesRelayArgv: true, childCount: 1 }] + holders: [{ pid: 500, matchesRelayArgv: true, childCount: 3, unrecognizedChildCount: 1 }] }) ).toBe(false) }) - it('refuses a holder whose child count could not be read', () => { + it('refuses a holder whose unrecognized-child count could not be read', () => { expect( isReapableRelayHusk({ ...husk, - holders: [{ pid: 500, matchesRelayArgv: true, childCount: null }] + holders: [{ pid: 500, matchesRelayArgv: true, childCount: 0, unrecognizedChildCount: null }] }) ).toBe(false) }) @@ -201,7 +237,7 @@ describe('isReapableRelayHusk', () => { expect( isReapableRelayHusk({ ...husk, - holders: [{ pid: 500, matchesRelayArgv: false, childCount: 0 }] + holders: [{ pid: 500, matchesRelayArgv: false, childCount: 0, unrecognizedChildCount: 0 }] }) ).toBe(false) }) @@ -211,8 +247,8 @@ describe('isReapableRelayHusk', () => { isReapableRelayHusk({ ...husk, holders: [ - { pid: 500, matchesRelayArgv: true, childCount: 0 }, - { pid: 501, matchesRelayArgv: true, childCount: 0 } + { pid: 500, matchesRelayArgv: true, childCount: 0, unrecognizedChildCount: 0 }, + { pid: 501, matchesRelayArgv: true, childCount: 0, unrecognizedChildCount: 0 } ] }) ).toBe(false) diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent.ts b/src/main/ssh/ssh-relay-endpoint-incumbent.ts index 2688267f4c7..628a9558793 100644 --- a/src/main/ssh/ssh-relay-endpoint-incumbent.ts +++ b/src/main/ssh/ssh-relay-endpoint-incumbent.ts @@ -20,6 +20,11 @@ */ import type { SshConnection } from './ssh-connection' import { shellEscape } from './ssh-connection-utils' +import { + RELAY_CHILD_COUNT_VAR, + RELAY_UNRECOGNIZED_CHILD_COUNT_VAR, + relayDaemonChildCensusShell +} from './relay-daemon-service-children' import { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' @@ -38,6 +43,12 @@ export type RelayEndpointHolder = { matchesRelayArgv: boolean /** Direct children, or null when `pgrep` could not answer. Never guessed. */ childCount: number | null + /** + * Direct children *not* positively identified as the daemon's own service processes, or + * null when the host could not enumerate them. This — not `childCount` — is what says + * whether the relay holds anything; see relay-daemon-service-children.ts. + */ + unrecognizedChildCount: number | null } export type RelayEndpointIncumbent = { @@ -95,11 +106,9 @@ export function relayEndpointIncumbentProbeCommand(nodePath: string, sockPath: s ' args=$(ps -o args= -p "$pid" 2>/dev/null | tr "\\n" " ")', ' match=no', ' case "$args" in *relay.js*"$sock"*) match=yes ;; esac', - ' kids=unknown', - ' if command -v pgrep >/dev/null 2>&1; then', - ' kids=$(pgrep -P "$pid" 2>/dev/null | grep -c .)', - ' fi', - ' printf \'HOLDER=%s %s %s\\n\' "$pid" "$match" "$kids"', + ...relayDaemonChildCensusShell().map((line) => ` ${line}`), + ' printf \'HOLDER=%s %s %s %s\\n\' "$pid" "$match" ' + + `"$${RELAY_CHILD_COUNT_VAR}" "$${RELAY_UNRECOGNIZED_CHILD_COUNT_VAR}"`, ' done', 'else', " printf 'HOLDERS_SOURCE=unavailable\\n'", @@ -159,19 +168,25 @@ export function parseRelayEndpointIncumbentProbe( } function parseHolder(value: string): RelayEndpointHolder | null { - const [rawPid, rawMatch, rawKids] = value.split(/\s+/) + const [rawPid, rawMatch, rawKids, rawUnrecognized] = value.split(/\s+/) const pid = Number.parseInt(rawPid ?? '', 10) if (!Number.isInteger(pid) || pid <= 0) { return null } - const childCount = Number.parseInt(rawKids ?? '', 10) return { pid, matchesRelayArgv: rawMatch === 'yes', - childCount: Number.isInteger(childCount) && childCount >= 0 ? childCount : null + childCount: parseChildCount(rawKids), + unrecognizedChildCount: parseChildCount(rawUnrecognized) } } +/** `unknown`, a missing field, and anything unparseable are all "could not tell" — never 0. */ +function parseChildCount(raw: string | undefined): number | null { + const count = Number.parseInt(raw ?? '', 10) + return Number.isInteger(count) && count >= 0 ? count : null +} + function unverifiableEndpoint(sockPath: string): RelayEndpointIncumbent { return { sockPath, @@ -239,8 +254,12 @@ export function mayLaunchOverRelayEndpoint(incumbent: RelayEndpointIncumbent): b /** * A live relay that provably holds nothing: identity confirmed against its argv, exactly one - * holder, and zero children. Reaping it destroys no user work. Anything less is retained — - * killing the wrong pid on someone's remote host is the worst outcome available here. + * holder, and no child the host could not account for as one of the daemon's own service + * processes. Reaping it destroys no user work. Anything less is retained — killing the wrong + * pid on someone's remote host is the worst outcome available here. + * + * Why not `childCount === 0`: the daemon's AI Vault sidecar never exits once spawned, so that + * gate was unreachable for any relay that had ever served a vault request (#13614). */ export function isReapableRelayHusk(incumbent: RelayEndpointIncumbent): boolean { if (incumbent.verdict !== 'live' || !incumbent.holdersEnumerable) { @@ -250,12 +269,16 @@ export function isReapableRelayHusk(incumbent: RelayEndpointIncumbent): boolean return false } const [holder] = incumbent.holders - return holder.matchesRelayArgv && holder.childCount === 0 + return holder.matchesRelayArgv && holder.unrecognizedChildCount === 0 } export function describeRelayEndpointIncumbent(incumbent: RelayEndpointIncumbent): string { const holders = incumbent.holders - .map((holder) => `${holder.pid}(children=${holder.childCount ?? 'unknown'})`) + .map( + (holder) => + `${holder.pid}(children=${holder.childCount ?? 'unknown'},` + + `unrecognized=${holder.unrecognizedChildCount ?? 'unknown'})` + ) .join(',') return ( `${incumbent.sockPath} verdict=${incumbent.verdict} evidence=${incumbent.evidence} ` + diff --git a/src/main/ssh/ssh-relay-endpoint-takeover.test.ts b/src/main/ssh/ssh-relay-endpoint-takeover.test.ts index 687d633b92b..d23f065f478 100644 --- a/src/main/ssh/ssh-relay-endpoint-takeover.test.ts +++ b/src/main/ssh/ssh-relay-endpoint-takeover.test.ts @@ -14,6 +14,7 @@ import { resolveRelayEndpointBeforeRelaunch } from './ssh-relay-endpoint-takeover' import { RelayVersionMismatchError } from './ssh-relay-version-mismatch-error' +import { RELAY_DAEMON_SERVICE_ENTRY_FILENAMES } from '../../shared/relay-artifacts' import type { SshConnection } from './ssh-connection' import { getRemoteHostPlatform } from './ssh-remote-platform' @@ -42,7 +43,7 @@ beforeEach(() => { describe('incumbent alive and refusing', () => { it('refuses to rebind a live relay holding PTYs, and signals nothing', async () => { execCommand.mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13']) + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13 11']) ) await expect(resolve()).rejects.toSatisfy(isRelayEndpointHeldError) // The whole point of #8585: the incumbent's socket must survive so it is not orphaned. @@ -52,9 +53,9 @@ describe('incumbent alive and refusing', () => { it('names the incumbent pid and the Reset Relay escape hatch in the error', async () => { execCommand.mockResolvedValue( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13']) + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13 11']) ) - await expect(resolve()).rejects.toThrow(/3669803\(children=13\)/) + await expect(resolve()).rejects.toThrow(/3669803\(children=13,unrecognized=11\)/) await expect(resolve()).rejects.toThrow(/Reset Relay/) }) @@ -70,7 +71,7 @@ describe('incumbent alive and refusing', () => { it('reaps a live relay only when it provably holds nothing, and confirms it is gone', async () => { execCommand .mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0']) ) .mockResolvedValueOnce('GONE\n') await expect(resolve()).resolves.toMatchObject({ verdict: 'live' }) @@ -80,7 +81,7 @@ describe('incumbent alive and refusing', () => { it('does not launch over an empty relay whose death could not be confirmed', async () => { execCommand .mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0']) ) .mockResolvedValueOnce('LIVE\n') await expect(resolve()).rejects.toSatisfy(isRelayEndpointHeldError) @@ -89,7 +90,7 @@ describe('incumbent alive and refusing', () => { it('does not launch over a relay the host refused to signal on its own re-check', async () => { execCommand .mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0']) ) .mockResolvedValueOnce('BUSY\n') await expect(resolve()).rejects.toSatisfy(isRelayEndpointHeldError) @@ -138,9 +139,19 @@ describe('reapEmptyRelayHuskCommand', () => { }) it('aborts without signalling when the host cannot count children', () => { - expect(reapEmptyRelayHuskCommand(4242, SOCK)).toContain( - "command -v pgrep >/dev/null 2>&1 || { printf 'BUSY\\n'; exit 0; }" - ) + const command = reapEmptyRelayHuskCommand(4242, SOCK) + // The census leaves both counters at `unknown` without pgrep, and the gate demands "0". + expect(command).toContain('unrecognized_kids=unknown') + expect(command).toContain('command -v pgrep >/dev/null 2>&1') + expect(command).toContain('[ "$unrecognized_kids" = "0" ] ||') + }) + + it('subtracts only the daemon service children it can name from the reap gate', () => { + const command = reapEmptyRelayHuskCommand(4242, SOCK) + for (const filename of RELAY_DAEMON_SERVICE_ENTRY_FILENAMES) { + expect(command).toContain(`*'/${filename}'`) + } + expect(command).toContain('unrecognized_kids=$((unrecognized_kids+1))') }) }) diff --git a/src/main/ssh/ssh-relay-endpoint-takeover.ts b/src/main/ssh/ssh-relay-endpoint-takeover.ts index f104aab5256..8f6130620cb 100644 --- a/src/main/ssh/ssh-relay-endpoint-takeover.ts +++ b/src/main/ssh/ssh-relay-endpoint-takeover.ts @@ -2,13 +2,17 @@ * Deciding whether a relay socket path is ours to take, and acting on the answer. * * The only destructive action available here is a SIGTERM to a relay that has been proven — - * by argv, by socket-holder enumeration, and by a zero child count re-checked on the host - * immediately before the signal — to hold nothing at all. Everything else is left running. + * by argv, by socket-holder enumeration, and by a child census re-run on the host immediately + * before the signal — to hold nothing at all. Everything else is left running. * Per docs/reference/ssh-execution-boundary.md, a relay we merely failed to reach is * `unverifiable`, and `unverifiable` never authorizes a kill or a rebind. */ import type { SshConnection } from './ssh-connection' import { shellEscape } from './ssh-connection-utils' +import { + RELAY_UNRECOGNIZED_CHILD_COUNT_VAR, + relayDaemonChildCensusShell +} from './relay-daemon-service-children' import { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' import { describeRelayEndpointIncumbent, @@ -39,9 +43,10 @@ export function reapEmptyRelayHuskCommand(pid: number, sockPath: string): string `sock=${shellEscape(sockPath)}`, 'args=$(ps -o args= -p "$pid" 2>/dev/null | tr "\\n" " ")', 'case "$args" in *relay.js*"$sock"*) ;; *) printf \'MISMATCH\\n\'; exit 0 ;; esac', - "command -v pgrep >/dev/null 2>&1 || { printf 'BUSY\\n'; exit 0; }", - 'kids=$(pgrep -P "$pid" 2>/dev/null | grep -c .)', - '[ "$kids" = "0" ] || { printf \'BUSY\\n\'; exit 0; }', + // Why the same census as the probe: `unknown` (no pgrep) and any child this host could + // not account for as a relay service both land on BUSY, so nothing is signalled. + ...relayDaemonChildCensusShell(), + `[ "$${RELAY_UNRECOGNIZED_CHILD_COUNT_VAR}" = "0" ] || { printf 'BUSY\\n'; exit 0; }`, // SIGTERM only: the relay's own handler disposes and unlinks. SIGKILL would leave the // socket inode behind and skip that shutdown path for no gain on an empty daemon. 'kill -TERM "$pid" 2>/dev/null || true', diff --git a/src/main/ssh/ssh-relay-superseded-endpoints.test.ts b/src/main/ssh/ssh-relay-superseded-endpoints.test.ts index 874d9aae3fe..9168f4688bd 100644 --- a/src/main/ssh/ssh-relay-superseded-endpoints.test.ts +++ b/src/main/ssh/ssh-relay-superseded-endpoints.test.ts @@ -67,7 +67,7 @@ describe('classifySupersededRelay', () => { 'PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', - 'HOLDER=3669803 yes 13' + 'HOLDER=3669803 yes 13 11' ]) ) ).toBe('retained-live-work') @@ -76,7 +76,7 @@ describe('classifySupersededRelay', () => { it('nominates only a proven empty relay for reaping', () => { expect( classifySupersededRelay( - incumbent(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + incumbent(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0']) ) ).toBe('reap-candidate') }) @@ -101,7 +101,7 @@ describe('sweepSupersededRelayEndpoints', () => { execCommand .mockResolvedValueOnce(`${OLD_SOCK}\n`) .mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13']) + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13 11']) ) const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) expect(findings).toHaveLength(1) @@ -114,7 +114,7 @@ describe('sweepSupersededRelayEndpoints', () => { execCommand .mockResolvedValueOnce(`${OLD_SOCK}\n`) .mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0']) ) .mockResolvedValueOnce('GONE\n') const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) @@ -126,7 +126,7 @@ describe('sweepSupersededRelayEndpoints', () => { execCommand .mockResolvedValueOnce(`${OLD_SOCK}\n`) .mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0']) ) .mockResolvedValueOnce('LIVE\n') const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) diff --git a/src/shared/relay-artifacts.ts b/src/shared/relay-artifacts.ts index 273f6e059b8..2f9e563f839 100644 --- a/src/shared/relay-artifacts.ts +++ b/src/shared/relay-artifacts.ts @@ -37,6 +37,12 @@ export type RelayArtifact = { * optional one would loop forever redeploying a relay that is already correct. */ optional?: boolean + /** + * Forked by the relay daemon as a long-lived child of its own. These are relay + * infrastructure, never user work, and the reap gate subtracts them from a daemon's + * child census; see src/main/ssh/relay-daemon-service-children.ts. + */ + daemonServiceChild?: boolean } /** The bare Windows process-table addon; see docs/reference/windows-process-enumeration.md. */ @@ -44,8 +50,8 @@ export const RELAY_WINDOWS_PROCESS_TREE_FILENAME = 'windows-process-tree.node' export const RELAY_ARTIFACTS: readonly RelayArtifact[] = [ { filename: 'relay.js' }, - { filename: 'relay-watcher.js' }, - { filename: 'relay-ai-vault-service.js' }, + { filename: 'relay-watcher.js', daemonServiceChild: true }, + { filename: 'relay-ai-vault-service.js', daemonServiceChild: true }, { filename: 'managed-hook-runtime.js' }, // Forked by the AI Vault title reader; without it a relay answers every WSL // title request with no title and no error. @@ -62,6 +68,14 @@ export const RELAY_ARTIFACTS: readonly RelayArtifact[] = [ { filename: RELAY_WINDOWS_PROCESS_TREE_FILENAME, windowsOnly: true, optional: true } ] +/** + * The daemon's own service children, by entry filename. Anything else under a relay pid is + * either user work or unidentified, and both keep the relay unreapable. + */ +export const RELAY_DAEMON_SERVICE_ENTRY_FILENAMES: readonly string[] = RELAY_ARTIFACTS.filter( + (artifact) => artifact.daemonServiceChild +).map((artifact) => artifact.filename) + /** Written after the artifacts, so it is never an input to its own hash. */ export const RELAY_VERSION_FILENAME = '.version' From b85510f3a9a3751b7320f18f6c753720c265ed86 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:51:21 -0700 Subject: [PATCH 06/58] fix(terminal): warn about remote work when closing the window or quitting (#18593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native window-close warning was built from a local-only pty set: any worktree with a connectionId was dropped whole, and any remote runtime pty was filtered out. A build, test run, or agent on an SSH or Orca Remote host was therefore structurally invisible to it, on every platform. The quit path skipped the check entirely (#524), so remote work got no prompt at all. Route both paths through the same probe the tab-close guard uses, so the two cannot drift, and keep the verdict vocabulary of the SSH execution boundary: only a host that answers "no children" suppresses the warning. An unreachable host is `unverifiable`, never `exited`, so it warns rather than quitting silently — with its own copy, because "could not reach the host" is a different claim than "processes are running". Quit still ignores local ptys, preserving #524: quitting is an unambiguous instruction to end this machine's processes, but not to end execution on someone else's, which a bounded relay grace period will SIGKILL once the countdown expires. The probe budget is 1.5s (vs the tab guard's 4s) because quit is time sensitive; expiry raises the prompt, so an unreachable host costs a click rather than the 15s RPC timeout or a silently orphaned build. --- config/scripts/locale-ko-key-overrides.json | 2 +- .../components/TerminalWorkspaceDialogs.tsx | 14 +- .../terminal/pty-running-work-probe.ts | 88 +++++++ .../running-terminal-close-guard.test.ts | 12 +- .../terminal/running-terminal-close-guard.ts | 38 +-- ...terminal-tab-close-running-confirm.test.ts | 8 +- .../window-close-running-work.test.ts | 231 ++++++++++++++++++ .../terminal/window-close-running-work.ts | 70 ++++++ .../use-terminal-editor-close-foundation.ts | 53 ++-- ...tor-close-foundation.window-close.test.tsx | 103 ++++++++ src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- src/shared/remote-execution-host-pty-id.ts | 14 ++ 17 files changed, 575 insertions(+), 71 deletions(-) create mode 100644 src/renderer/src/components/terminal/pty-running-work-probe.ts create mode 100644 src/renderer/src/components/terminal/window-close-running-work.test.ts create mode 100644 src/renderer/src/components/terminal/window-close-running-work.ts create mode 100644 src/renderer/src/components/use-terminal-editor-close-foundation.window-close.test.tsx create mode 100644 src/shared/remote-execution-host-pty-id.ts diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index f368ecc3cbc..bf5f62d1fa5 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -492,7 +492,7 @@ "ko": "agent CLI를 찾지 못했습니다. 하나를 설치하거나 설정에서 기본 agent를 선택하세요." }, "auto.components.Terminal.7958465754": { - "ko": "실행 중인 프로세스가 있는 로컬 terminals이 있습니다. 그래도 창을 닫으시겠습니까?" + "ko": "실행 중인 프로세스가 있는 terminals이 있습니다. 그래도 창을 닫으시겠습니까?" }, "auto.components.Terminal.cdc9ac4b2d": { "ko": "편집기" diff --git a/src/renderer/src/components/TerminalWorkspaceDialogs.tsx b/src/renderer/src/components/TerminalWorkspaceDialogs.tsx index 52ddbf1ba5d..bb3ffbfd621 100644 --- a/src/renderer/src/components/TerminalWorkspaceDialogs.tsx +++ b/src/renderer/src/components/TerminalWorkspaceDialogs.tsx @@ -24,6 +24,7 @@ export function TerminalWorkspaceDialogs({ saveDialogFile, saveDialogFileId, setWindowCloseDialogOpen, + windowCloseDialogKind, windowCloseDialogOpen } = controller return ( @@ -82,10 +83,15 @@ export function TerminalWorkspaceDialogs({ {translate('auto.components.Terminal.2fa9c69ff3', 'Close Window?')} - {translate( - 'auto.components.Terminal.7958465754', - 'There are local terminals with running processes. Close the window anyway?' - )} + {windowCloseDialogKind === 'unverifiable' + ? translate( + 'auto.components.Terminal.b7c1f0a934', + 'A remote host could not be reached, so Orca cannot tell whether work is still running there. Close the window anyway?' + ) + : translate( + 'auto.components.Terminal.7958465754', + 'There are terminals with running processes. Close the window anyway?' + )} diff --git a/src/renderer/src/components/terminal/pty-running-work-probe.ts b/src/renderer/src/components/terminal/pty-running-work-probe.ts new file mode 100644 index 00000000000..609b71f12ca --- /dev/null +++ b/src/renderer/src/components/terminal/pty-running-work-probe.ts @@ -0,0 +1,88 @@ +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' +import { isRemoteExecutionHostPtyId } from '../../../../shared/remote-execution-host-pty-id' +import { isClientOnlyUnverifiableInspection } from '../../../../shared/terminal-process-inspection' + +/** + * One probe answer in the fixed `live` / `unverifiable` / `exited` vocabulary of + * `docs/reference/ssh-execution-boundary.md`. `exited` is only ever produced by a host that + * answered; every failure to reach the owner — a rejection, a closed transport, or a deadline + * that expired first — stays `unverifiable`, because loss of contact is not evidence of death. + */ +export type PtyRunningWorkVerdict = 'live' | 'unverifiable' | 'exited' + +export type PtyRunningWorkProbe = { + ptyId: string + verdict: PtyRunningWorkVerdict + /** Why the owner could not be observed. Only set for `unverifiable`. */ + reason?: string + /** The deadline expired before this pty's probe answered at all. */ + timedOut: boolean + /** The pty is owned by a remote execution host (relay runtime or app SSH). */ + remote: boolean +} + +type ProbeSettings = Pick | null | undefined + +/** + * Probes every pty for running work and resolves at whichever comes first: every answer, or the + * deadline. Never rejects, and never reports a pty it did not hear back about as idle. + * + * Callers own the policy. This owns only the measurement, so the tab-close guard and the + * window-close guard cannot drift apart on what an unanswered remote host means. + */ +export async function probePtyRunningWork( + settings: ProbeSettings, + ptyIds: readonly string[], + options: { timeoutMs: number } +): Promise { + if (ptyIds.length === 0) { + return [] + } + const probes: PtyRunningWorkProbe[] = ptyIds.map((ptyId) => ({ + ptyId, + verdict: 'unverifiable', + reason: 'probe_deadline', + timedOut: true, + remote: isRemoteExecutionHostPtyId(ptyId) + })) + + const settle = Promise.all( + ptyIds.map(async (ptyId, index) => { + const probe = probes[index] + if (!probe) { + return + } + try { + const inspection = await inspectRuntimeTerminalProcess(settings, ptyId) + probe.timedOut = false + if (isClientOnlyUnverifiableInspection(inspection)) { + probe.verdict = 'unverifiable' + probe.reason = inspection.reason + return + } + probe.verdict = inspection.hasChildProcesses ? 'live' : 'exited' + delete probe.reason + } catch { + // Why: `inspectRuntimeTerminalProcess` already maps every failure it can classify onto a + // reason; an unclassified throw is still a failure to observe, so it stays unverifiable. + probe.timedOut = false + probe.verdict = 'unverifiable' + probe.reason = 'probe_failed' + } + }) + ) + + let deadline: ReturnType | undefined + try { + await Promise.race([ + settle, + new Promise((resolve) => { + deadline = setTimeout(resolve, options.timeoutMs) + }) + ]) + } finally { + clearTimeout(deadline) + } + return probes +} diff --git a/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts b/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts index d63c69df7c9..165119f8b31 100644 --- a/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts +++ b/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts @@ -46,10 +46,12 @@ function visibleRequest() { return useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm } +// Drains pending microtasks. The probe resolves through several await points (per-pty inspect, +// the batch join, the deadline race), so this flushes generously rather than counting ticks. async function settleProbe(): Promise { - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() + for (let tick = 0; tick < 12; tick += 1) { + await Promise.resolve() + } } describe('shouldConfirmRunningTerminalClose', () => { @@ -329,6 +331,7 @@ describe('guardRunningTerminalClose', () => { vi.advanceTimersByTime(RUNNING_CLOSE_PROBE_TIMEOUT_MS) vi.useRealTimers() + await settleProbe() expect(onClose).not.toHaveBeenCalled() expect(visibleRequest()).toMatchObject({ terminalTabId: 'tab-1', tabLabel: 'npm run dev' }) @@ -351,6 +354,7 @@ describe('guardRunningTerminalClose', () => { guard() vi.advanceTimersByTime(RUNNING_CLOSE_PROBE_TIMEOUT_MS) vi.useRealTimers() + await settleProbe() expect(visibleRequest()?.copyKind).toBe('agent') }) @@ -368,6 +372,7 @@ describe('guardRunningTerminalClose', () => { guard(onClose) vi.advanceTimersByTime(RUNNING_CLOSE_PROBE_TIMEOUT_MS) vi.useRealTimers() + await settleProbe() requestSpy.mockRestore() expect(onClose).toHaveBeenCalledTimes(1) @@ -385,6 +390,7 @@ describe('guardRunningTerminalClose', () => { vi.advanceTimersByTime(RUNNING_CLOSE_PROBE_TIMEOUT_MS) vi.useRealTimers() await settleProbe() + await settleProbe() expect(onClose).not.toHaveBeenCalled() useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() diff --git a/src/renderer/src/components/terminal/running-terminal-close-guard.ts b/src/renderer/src/components/terminal/running-terminal-close-guard.ts index 881cd262353..6bb9ff5a582 100644 --- a/src/renderer/src/components/terminal/running-terminal-close-guard.ts +++ b/src/renderer/src/components/terminal/running-terminal-close-guard.ts @@ -1,10 +1,9 @@ import { useAppStore } from '@/store' -import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' import { useRunningTerminalCloseConfirmStore } from '@/store/running-terminal-close-confirm' import type { TerminalTabCloseReason } from '@/store/slices/terminal-tab-retirement' import type { AppState } from '@/store/types' import { resolveBusyPtyCloseCopyKind } from './terminal-close-copy-kind' -import { isClientOnlyUnverifiableInspection } from '../../../../shared/terminal-process-inspection' +import { probePtyRunningWork } from './pty-running-work-probe' export type RunningTerminalCloseGuardOptions = { force?: boolean @@ -44,7 +43,7 @@ export function shouldConfirmRunningTerminalClose( * the store's own teardown collector unions both for exactly that reason — reading only * the map would let a close slip through the window with no prompt. A stale id costs * nothing: its probe fails and the guard falls open. */ -function collectTabPtyIds( +export function collectTabPtyIds( state: Pick, terminalTabId: string ): string[] { @@ -112,44 +111,33 @@ export function guardRunningTerminalClose(params: { decided = true } - const probeTimeout = setTimeout(() => { - try { + void probePtyRunningWork(settings, ptyIds, { timeoutMs: RUNNING_CLOSE_PROBE_TIMEOUT_MS }) + .then((probes) => { + if (decided) { + return + } // Why: a probe that has not answered yet is unknown, not idle. Ask, treating every pty // as a candidate, so a degraded relay costs a click instead of a killed remote command. - confirmClose(ptyIds) - } catch { - closeNow() - } - }, RUNNING_CLOSE_PROBE_TIMEOUT_MS) - - void Promise.allSettled(ptyIds.map((ptyId) => inspectRuntimeTerminalProcess(settings, ptyId))) - .then((results) => { - clearTimeout(probeTimeout) - if (decided) { + if (probes.some((probe) => probe.timedOut)) { + confirmClose(ptyIds) return } // Why: fail open on an *answered* probe, matching the Cmd+W pane path — a rejection // (wedged relay, legacy provider) or a stale remote handle is not evidence of a live // child, and a close button that silently does nothing is worse than closing a busy tab. - const busyPtyIds = ptyIds.filter((_, index) => { - const result = results[index] - return ( - result?.status === 'fulfilled' && - !isClientOnlyUnverifiableInspection(result.value) && - result.value.hasChildProcesses - ) - }) + const busyPtyIds = probes + .filter((probe) => probe.verdict === 'live') + .map((probe) => probe.ptyId) if (busyPtyIds.length === 0) { closeNow() return } confirmClose(busyPtyIds) }) - // Why: allSettled never rejects, so this only fires when the decision above throws (a + // Why: the probe never rejects, so this only fires when the decision above throws (a // copy-kind lookup, a store subscriber). Without it the tab would silently never close // and the user would get no feedback at all; the pane path it replaced had this catch. .catch(() => { - clearTimeout(probeTimeout) closeNow() }) } diff --git a/src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts b/src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts index 066c5795ef9..9dfe20ef501 100644 --- a/src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts @@ -87,10 +87,12 @@ function visibleRequest() { return useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm } +// Drains pending microtasks. The probe resolves through several await points (per-pty inspect, +// the batch join, the deadline race), so this flushes generously rather than counting ticks. async function settleProbe(): Promise { - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() + for (let tick = 0; tick < 12; tick += 1) { + await Promise.resolve() + } } describe('closeTerminalTab running-process confirmation', () => { diff --git a/src/renderer/src/components/terminal/window-close-running-work.test.ts b/src/renderer/src/components/terminal/window-close-running-work.test.ts new file mode 100644 index 00000000000..77dc4ad745b --- /dev/null +++ b/src/renderer/src/components/terminal/window-close-running-work.test.ts @@ -0,0 +1,231 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { getStateMock, inspectRuntimeTerminalProcessMock } = vi.hoisted(() => ({ + getStateMock: vi.fn(), + inspectRuntimeTerminalProcessMock: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: getStateMock } +})) + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + inspectRuntimeTerminalProcess: inspectRuntimeTerminalProcessMock +})) + +import { + assessWindowCloseRunningWork, + WINDOW_CLOSE_PROBE_TIMEOUT_MS +} from './window-close-running-work' + +const LOCAL_PTY = 'pty-local' +const SSH_PTY = 'ssh:openclaw@@pty-7' +const RUNTIME_PTY = 'remote:env-1@@handle-1' +/** A runtime pty minted without an owner id. Still someone else's machine. */ +const OWNERLESS_RUNTIME_PTY = 'remote:handle-2' + +const BUSY = { + foregroundProcess: 'pnpm build', + hasChildProcesses: true, + foregroundProcessEvidence: {} +} +const IDLE = { foregroundProcess: 'bash', hasChildProcesses: false, foregroundProcessEvidence: {} } +const UNVERIFIABLE = { + foregroundProcess: null, + hasChildProcesses: false, + verdict: 'unverifiable', + reason: 'transport_loss' +} + +/** One worktree, one tab, owning `ptyIds`. */ +function setState(ptyIds: string[]): void { + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { 'worktree-1': [{ id: 'tab-1' }] }, + ptyIdsByTabId: { 'tab-1': ptyIds }, + terminalLayoutsByTabId: {} + }) +} + +/** Answers each pty id from `byPtyId`; anything unlisted never settles. */ +function answerWith(byPtyId: Record): void { + inspectRuntimeTerminalProcessMock.mockImplementation((_settings: unknown, ptyId: string) => + ptyId in byPtyId ? Promise.resolve(byPtyId[ptyId]) : new Promise(() => {}) + ) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('assessWindowCloseRunningWork', () => { + it('warns about a live process on an SSH host (F15: remote work was filtered out entirely)', async () => { + setState([SSH_PTY]) + answerWith({ [SSH_PTY]: BUSY }) + + await expect(assessWindowCloseRunningWork({ isQuitting: false })).resolves.toEqual({ + kind: 'running' + }) + }) + + it('warns on quit about a live process on an SSH host', async () => { + setState([SSH_PTY]) + answerWith({ [SSH_PTY]: BUSY }) + + await expect(assessWindowCloseRunningWork({ isQuitting: true })).resolves.toEqual({ + kind: 'running' + }) + }) + + it('warns on quit about a live process on a paired runtime host', async () => { + setState([RUNTIME_PTY]) + answerWith({ [RUNTIME_PTY]: BUSY }) + + await expect(assessWindowCloseRunningWork({ isQuitting: true })).resolves.toEqual({ + kind: 'running' + }) + }) + + it('counts an owner-less remote pty as remote work', async () => { + setState([OWNERLESS_RUNTIME_PTY]) + answerWith({ [OWNERLESS_RUNTIME_PTY]: BUSY }) + + await expect(assessWindowCloseRunningWork({ isQuitting: true })).resolves.toEqual({ + kind: 'running' + }) + }) + + // The crux of docs/reference/ssh-execution-boundary.md: an unreachable host is `unverifiable`, + // and quitting on `unverifiable` as though it were `exited` is what orphans live remote work. + it('warns rather than quitting silently when a remote host answers unverifiable', async () => { + setState([SSH_PTY]) + answerWith({ [SSH_PTY]: UNVERIFIABLE }) + + await expect(assessWindowCloseRunningWork({ isQuitting: true })).resolves.toEqual({ + kind: 'unverifiable' + }) + }) + + it('warns rather than quitting silently when a remote probe throws', async () => { + setState([SSH_PTY]) + inspectRuntimeTerminalProcessMock.mockRejectedValue(new Error('relay wedged')) + + await expect(assessWindowCloseRunningWork({ isQuitting: true })).resolves.toEqual({ + kind: 'unverifiable' + }) + }) + + it('stops waiting at the budget and warns, so an unreachable host cannot hang the quit', async () => { + setState([SSH_PTY]) + answerWith({}) + vi.useFakeTimers() + + const pending = assessWindowCloseRunningWork({ isQuitting: true }) + await vi.advanceTimersByTimeAsync(WINDOW_CLOSE_PROBE_TIMEOUT_MS) + + await expect(pending).resolves.toEqual({ kind: 'unverifiable' }) + }) + + it('does not resolve before the budget expires', async () => { + setState([SSH_PTY]) + answerWith({}) + vi.useFakeTimers() + const settled = vi.fn() + + void assessWindowCloseRunningWork({ isQuitting: true }).then(settled) + await vi.advanceTimersByTimeAsync(WINDOW_CLOSE_PROBE_TIMEOUT_MS - 1) + + expect(settled).not.toHaveBeenCalled() + }) + + it('does not warn when the owning remote host reports an idle shell', async () => { + setState([SSH_PTY]) + answerWith({ [SSH_PTY]: IDLE }) + + await expect(assessWindowCloseRunningWork({ isQuitting: true })).resolves.toEqual({ + kind: 'none' + }) + }) + + it('reports a live process even when a sibling remote pane is only unverifiable', async () => { + setState([SSH_PTY, RUNTIME_PTY]) + answerWith({ [SSH_PTY]: UNVERIFIABLE, [RUNTIME_PTY]: BUSY }) + + await expect(assessWindowCloseRunningWork({ isQuitting: true })).resolves.toEqual({ + kind: 'running' + }) + }) + + it('still warns about a live local process when closing the window', async () => { + setState([LOCAL_PTY]) + answerWith({ [LOCAL_PTY]: BUSY }) + + await expect(assessWindowCloseRunningWork({ isQuitting: false })).resolves.toEqual({ + kind: 'running' + }) + }) + + // A local probe has no transport to lose, so its failure means the pty is gone — unlike a + // remote host going quiet, it is not a reason to hold up the close. + it('does not warn when only a local probe is unverifiable', async () => { + setState([LOCAL_PTY]) + answerWith({ [LOCAL_PTY]: UNVERIFIABLE }) + + await expect(assessWindowCloseRunningWork({ isQuitting: false })).resolves.toEqual({ + kind: 'none' + }) + }) + + // #524 decided quitting is an unambiguous instruction to end this machine's processes. It is + // not an instruction to end execution on someone else's, which is why remote still warns above. + it('leaves local-only quit unprompted, and never probes for it', async () => { + setState([LOCAL_PTY]) + answerWith({ [LOCAL_PTY]: BUSY }) + + await expect(assessWindowCloseRunningWork({ isQuitting: true })).resolves.toEqual({ + kind: 'none' + }) + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + }) + + it('probes a pane the layout has bound before the liveness map caught up', async () => { + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { 'worktree-1': [{ id: 'tab-1' }] }, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: { 'tab-1': { ptyIdsByLeafId: { leaf: SSH_PTY } } } + }) + answerWith({ [SSH_PTY]: BUSY }) + + await expect(assessWindowCloseRunningWork({ isQuitting: true })).resolves.toEqual({ + kind: 'running' + }) + }) + + it('probes each pty once when the map and the layout name the same one', async () => { + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { 'worktree-1': [{ id: 'tab-1' }] }, + ptyIdsByTabId: { 'tab-1': [SSH_PTY] }, + terminalLayoutsByTabId: { 'tab-1': { ptyIdsByLeafId: { leaf: SSH_PTY } } } + }) + answerWith({ [SSH_PTY]: IDLE }) + + await assessWindowCloseRunningWork({ isQuitting: true }) + + expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledTimes(1) + }) + + it('closes without probing when no workspace owns a pty', async () => { + setState([]) + + await expect(assessWindowCloseRunningWork({ isQuitting: false })).resolves.toEqual({ + kind: 'none' + }) + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal/window-close-running-work.ts b/src/renderer/src/components/terminal/window-close-running-work.ts new file mode 100644 index 00000000000..427ee72dae6 --- /dev/null +++ b/src/renderer/src/components/terminal/window-close-running-work.ts @@ -0,0 +1,70 @@ +import { useAppStore } from '@/store' +import { isRemoteExecutionHostPtyId } from '../../../../shared/remote-execution-host-pty-id' +import { collectTabPtyIds } from './running-terminal-close-guard' +import { probePtyRunningWork } from './pty-running-work-probe' + +/** + * Upper bound on how long closing the window or quitting may wait on the probes. + * + * Shorter than the tab-close guard's 4s because quit is time-sensitive in a way one tab close is + * not: the user has already asked to leave, and a quit that stalls on an unreachable host is its + * own bug. A healthy local inspect answers in single-digit milliseconds and a healthy remote one + * is a single RPC round-trip on an already-open mux channel, so this leaves roughly 3x headroom + * over a slow-but-live transcontinental host while capping the worst case — a host that is simply + * gone — at ~1.5s instead of the 15s RPC timeout the probe would otherwise inherit. + * + * Expiry raises the prompt rather than quitting silently: an unanswered probe is `unverifiable`, + * and `unverifiable` is never evidence that remote work has stopped. + */ +export const WINDOW_CLOSE_PROBE_TIMEOUT_MS = 1_500 + +/** Which warning the close should raise, if any. */ +export type WindowCloseRunningWork = + /** Every pty that mattered answered, and none had children. */ + | { kind: 'none' } + /** An owning host reported a live child process. */ + | { kind: 'running' } + /** A remote execution host could not be observed, so its work may still be live. */ + | { kind: 'unverifiable' } + +/** + * Decides whether a window close or quit should stop and ask. + * + * Two deliberate asymmetries: + * + * - **Quit only considers remote ptys.** Quitting is an unambiguous instruction to end this + * machine's processes (#524), but it is not an instruction to end execution on someone else's: + * the client detaches while the relay keeps running, and a target with a bounded grace period + * then SIGKILLs that work once the countdown expires. + * - **Only a remote `unverifiable` warns.** A local probe has no transport to lose, so its failure + * means the pty is gone. A remote one that cannot be reached is the case + * `docs/reference/ssh-execution-boundary.md` exists to protect: loss of contact is not evidence + * of `exited`, so it must fail toward asking rather than toward a silent quit. + */ +export async function assessWindowCloseRunningWork(params: { + isQuitting: boolean +}): Promise { + const state = useAppStore.getState() + const ptyIds = new Set( + Object.values(state.tabsByWorktree) + .flatMap((worktreeTabs) => worktreeTabs ?? []) + .flatMap((tab) => collectTabPtyIds(state, tab.id)) + ) + const candidatePtyIds = params.isQuitting + ? [...ptyIds].filter(isRemoteExecutionHostPtyId) + : [...ptyIds] + if (candidatePtyIds.length === 0) { + return { kind: 'none' } + } + + const probes = await probePtyRunningWork(state.settings, candidatePtyIds, { + timeoutMs: WINDOW_CLOSE_PROBE_TIMEOUT_MS + }) + if (probes.some((probe) => probe.verdict === 'live')) { + return { kind: 'running' } + } + if (probes.some((probe) => probe.remote && probe.verdict === 'unverifiable')) { + return { kind: 'unverifiable' } + } + return { kind: 'none' } +} diff --git a/src/renderer/src/components/use-terminal-editor-close-foundation.ts b/src/renderer/src/components/use-terminal-editor-close-foundation.ts index 74849e3f5a2..2aceced683d 100644 --- a/src/renderer/src/components/use-terminal-editor-close-foundation.ts +++ b/src/renderer/src/components/use-terminal-editor-close-foundation.ts @@ -1,8 +1,9 @@ import { useCallback, useRef, useState } from 'react' -import { useAppStore } from '../store' -import { getConnectionId } from '../lib/connection-context' -import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' import { CLOSE_DIALOG_DEBOUNCE_MS } from './terminal-workspace-model' +import { + assessWindowCloseRunningWork, + type WindowCloseRunningWork +} from './terminal/window-close-running-work' import type { TerminalWorkspaceProjectionController } from './use-terminal-workspace-projection' import { runWithWindowCloseCheckpointScope } from './window-close-request-coordinator' import { showShutdownCheckpointFailureToast } from '@/lib/shutdown-checkpoint-failure-toast' @@ -27,6 +28,11 @@ export function useTerminalEditorCloseFoundation( closeDialogDebounceTimersRef.current.add(timer) }, []) const [windowCloseDialogOpen, setWindowCloseDialogOpen] = useState(false) + // Why: "running" and "could not reach the host" are different claims, and telling the user + // processes are running when the truth is that a host went quiet is the fabricated certainty + // docs/reference/ssh-execution-boundary.md forbids. + const [windowCloseDialogKind, setWindowCloseDialogKind] = + useState>('running') const windowCloseAfterDirtyRef = useRef<{ isQuitting: boolean } | null>(null) const confirmNativeWindowClose = useCallback(() => { @@ -46,33 +52,21 @@ export function useTerminalEditorCloseFoundation( const proceedToNativeWindowClose = useCallback( (isQuitting: boolean) => { - if (!isQuitting) { - const state = useAppStore.getState() - const localPtyIds = Object.entries(state.tabsByWorktree).flatMap( - ([worktreeId, worktreeTabs]) => { - const connectionId = getConnectionId(worktreeId) - if (connectionId !== null) { - return [] - } - return worktreeTabs - .flatMap((tab) => state.ptyIdsByTabId[tab.id] ?? []) - .filter((ptyId) => !isRemoteRuntimePtyId(ptyId)) + void assessWindowCloseRunningWork({ isQuitting }) + .then((runningWork) => { + if (runningWork.kind === 'none') { + confirmNativeWindowClose() + return } - ) - if (localPtyIds.length > 0) { - void Promise.all(localPtyIds.map((id) => window.api.pty.hasChildProcesses(id))).then( - (results) => { - if (results.some(Boolean)) { - setWindowCloseDialogOpen(true) - } else { - confirmNativeWindowClose() - } - } - ) - return - } - } - confirmNativeWindowClose() + setWindowCloseDialogKind(runningWork.kind) + setWindowCloseDialogOpen(true) + }) + // Why: the assessment must never be able to trap the window. A thrown store read is + // not evidence either way, and a close that silently does nothing is unrecoverable + // without SIGKILL, so fall through to the close the user actually asked for. + .catch(() => { + confirmNativeWindowClose() + }) }, [confirmNativeWindowClose] ) @@ -88,6 +82,7 @@ export function useTerminalEditorCloseFoundation( releaseCloseDialogGuardAfterDebounce, windowCloseDialogOpen, setWindowCloseDialogOpen, + windowCloseDialogKind, windowCloseAfterDirtyRef, confirmNativeWindowClose, proceedToNativeWindowClose diff --git a/src/renderer/src/components/use-terminal-editor-close-foundation.window-close.test.tsx b/src/renderer/src/components/use-terminal-editor-close-foundation.window-close.test.tsx new file mode 100644 index 00000000000..d7f3f69d925 --- /dev/null +++ b/src/renderer/src/components/use-terminal-editor-close-foundation.window-close.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment happy-dom + +/** + * Wiring for the window-close/quit running-work warning. The policy in + * `terminal/window-close-running-work.ts` is inert unless `proceedToNativeWindowClose` actually + * consults it, so pin that it does — and that a warning stops the native close rather than + * confirming it. + */ +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { assessWindowCloseRunningWorkMock, confirmWindowCloseMock } = vi.hoisted(() => ({ + assessWindowCloseRunningWorkMock: vi.fn(), + confirmWindowCloseMock: vi.fn() +})) + +vi.mock('./terminal/window-close-running-work', () => ({ + assessWindowCloseRunningWork: assessWindowCloseRunningWorkMock +})) +vi.mock('./window-close-request-coordinator', () => ({ + runWithWindowCloseCheckpointScope: (fn: () => unknown) => fn() +})) +vi.mock('@/lib/shutdown-checkpoint-failure-toast', () => ({ + showShutdownCheckpointFailureToast: vi.fn() +})) + +const { useTerminalEditorCloseFoundation } = await import('./use-terminal-editor-close-foundation') + +const controller = { openFiles: [] } as unknown as Parameters< + typeof useTerminalEditorCloseFoundation +>[0] + +function mountFoundation() { + return renderHook(() => useTerminalEditorCloseFoundation(controller)) +} + +beforeEach(() => { + vi.clearAllMocks() + Object.assign(globalThis, { + window: Object.assign(globalThis.window, { + api: { ui: { confirmWindowClose: confirmWindowCloseMock } } + }) + }) +}) + +afterEach(() => { + cleanup() +}) + +describe('proceedToNativeWindowClose', () => { + it('asks the running-work policy about the quit rather than assuming it is safe', async () => { + assessWindowCloseRunningWorkMock.mockResolvedValue({ kind: 'none' }) + const { result } = mountFoundation() + + await act(async () => { + result.current.proceedToNativeWindowClose(true) + }) + + expect(assessWindowCloseRunningWorkMock).toHaveBeenCalledWith({ isQuitting: true }) + expect(confirmWindowCloseMock).toHaveBeenCalledTimes(1) + expect(result.current.windowCloseDialogOpen).toBe(false) + }) + + it('raises the dialog and does not close when a host reports live work', async () => { + assessWindowCloseRunningWorkMock.mockResolvedValue({ kind: 'running' }) + const { result } = mountFoundation() + + await act(async () => { + result.current.proceedToNativeWindowClose(true) + }) + + expect(result.current.windowCloseDialogOpen).toBe(true) + expect(result.current.windowCloseDialogKind).toBe('running') + expect(confirmWindowCloseMock).not.toHaveBeenCalled() + }) + + it('raises the unverifiable copy when a remote host could not be reached', async () => { + assessWindowCloseRunningWorkMock.mockResolvedValue({ kind: 'unverifiable' }) + const { result } = mountFoundation() + + await act(async () => { + result.current.proceedToNativeWindowClose(true) + }) + + expect(result.current.windowCloseDialogOpen).toBe(true) + expect(result.current.windowCloseDialogKind).toBe('unverifiable') + expect(confirmWindowCloseMock).not.toHaveBeenCalled() + }) + + // Why: a thrown assessment is not evidence either way, and a close that silently does nothing + // leaves SIGKILL as the user's only exit. + it('falls through to the close when the assessment throws', async () => { + assessWindowCloseRunningWorkMock.mockRejectedValue(new Error('store blew up')) + const { result } = mountFoundation() + + await act(async () => { + result.current.proceedToNativeWindowClose(false) + }) + + expect(confirmWindowCloseMock).toHaveBeenCalledTimes(1) + expect(result.current.windowCloseDialogOpen).toBe(false) + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 87fcb50a2a1..f2da58bffb3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2251,7 +2251,8 @@ "Terminal": { "73768427cf": "Close", "f82e9f02df": "Cancel", - "7958465754": "There are local terminals with running processes. Close the window anyway?", + "7958465754": "There are terminals with running processes. Close the window anyway?", + "b7c1f0a934": "A remote host could not be reached, so Orca cannot tell whether work is still running there. Close the window anyway?", "2fa9c69ff3": "Close Window?", "cd51e28d8b": "Save", "0037b21794": "Don't Save", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9999634daee..98b07917880 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1924,7 +1924,7 @@ "Terminal": { "73768427cf": "Cerrar", "f82e9f02df": "Cancelar", - "7958465754": "Hay terminales locales con procesos en ejecución. ¿Cerrar la ventana de todos modos?", + "7958465754": "Hay terminales con procesos en ejecución. ¿Cerrar la ventana de todos modos?", "2fa9c69ff3": "¿Cerrar ventana?", "cd51e28d8b": "Guardar", "0037b21794": "No guardar", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 8eff250a820..5bf316d36f7 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -2087,7 +2087,7 @@ "Terminal": { "73768427cf": "Fermer", "f82e9f02df": "Annuler", - "7958465754": "Des terminaux locaux exécutent des processus. Fermer quand même la fenêtre ?", + "7958465754": "Des terminaux exécutent des processus. Fermer quand même la fenêtre ?", "2fa9c69ff3": "Fermer la fenêtre ?", "cd51e28d8b": "Enregistrer", "0037b21794": "Ne pas enregistrer", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b3c30da9446..4dc81b9201d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1924,7 +1924,7 @@ "Terminal": { "73768427cf": "閉じる", "f82e9f02df": "キャンセル", - "7958465754": "プロセスが実行中のローカルターミナルがあります。このままウィンドウを閉じますか?", + "7958465754": "プロセスが実行中のターミナルがあります。このままウィンドウを閉じますか?", "2fa9c69ff3": "ウィンドウを閉じますか?", "cd51e28d8b": "保存", "0037b21794": "保存しないでください", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ac75cca2209..d9d822b8fc3 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1929,7 +1929,7 @@ "Terminal": { "73768427cf": "닫기", "f82e9f02df": "취소", - "7958465754": "실행 중인 프로세스가 있는 로컬 terminals이 있습니다. 그래도 창을 닫으시겠습니까?", + "7958465754": "실행 중인 프로세스가 있는 terminals이 있습니다. 그래도 창을 닫으시겠습니까?", "2fa9c69ff3": "창을 닫으시겠습니까?", "cd51e28d8b": "저장", "0037b21794": "저장하지 않음", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7a3b47c8f2a..46dc592dd2c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1927,7 +1927,7 @@ "Terminal": { "73768427cf": "关闭", "f82e9f02df": "取消", - "7958465754": "有正在运行的进程的本地终端。还是关窗吧?", + "7958465754": "有正在运行的进程的终端。还是关窗吧?", "2fa9c69ff3": "关闭窗口?", "cd51e28d8b": "保存", "0037b21794": "不保存", diff --git a/src/shared/remote-execution-host-pty-id.ts b/src/shared/remote-execution-host-pty-id.ts new file mode 100644 index 00000000000..c76ada39e03 --- /dev/null +++ b/src/shared/remote-execution-host-pty-id.ts @@ -0,0 +1,14 @@ +import { parseRemoteRuntimePtyId } from './remote-runtime-pty-id' +import { parseAppSshPtyId } from './ssh-pty-id' + +/** + * Whether the process behind this pty runs on an execution host other than this machine — + * a paired runtime environment or an app SSH target. + * + * Deliberately broader than the inspection module's private remote check, which only counts a + * `remote:` id that carries an owner environment id. An owner-less `remote:` still runs + * somewhere else, and treating it as local is how remote work becomes invisible to a guard. + */ +export function isRemoteExecutionHostPtyId(ptyId: string): boolean { + return parseRemoteRuntimePtyId(ptyId) !== null || parseAppSshPtyId(ptyId) !== null +} From 11e459e9330b0712988b9c4afb063c71ee0b1507 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:53:24 -0700 Subject: [PATCH 07/58] fix(crash-reporting): bound replay-guard wedge bursts in the ring without losing their spans (#18441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `terminal_replay_guard_wedged_release` was not in COALESCED_RENDERER_BREADCRUMB_NAMES, and its per-pane hashes give every entry a unique ring identity. One mount/reveal/wake transition expires every in-flight replay write at once, so a burst arrives as N distinct entries against a 30-slot FIFO ring. Measured, from the 09-02 corpus (121 `renderer.breadcrumb` spans across 9 of 55 diagnostic bundles): - bundle 26461769: 26 events in 0.96s - murlock1000: 62 events over 85s - 8907a508 mixes two call sites in one window (2 crumbs carry `tabIdHash`, 2 do not) Not measured: no captured report's ring actually lost slots to this crumb. All 57 reports have zero wedge crumbs in `Recent activity:`, and in all 9 bundles the burst predates the report's ring window — for 26461769 the burst ran 13:36:03.784Z-13:36:04.742Z while the ring-owning main process started at 13:43:44.380Z, 7m40s later. So this bounds a demonstrated hazard, not an observed loss. An earlier draft of this commit asserted "26 of 30 slots / 87% of the pre-crash trail" as a measurement; that was a model, and it is removed. The burst evidence lives entirely in the durable span stream, and suppressed repeats normally emit no span (see the 1000-emissions/1-span case in crash-reporting-renderer-breadcrumbs.test.ts), so coalescing alone would have cut that 121-event corpus to 13 with the multiplicity recorded nowhere. Instead: - the ring coalesces: one slot per call site, plus `suppressedSinceLast` - every wedge event still emits its own `renderer.breadcrumb` span, via PER_EVENT_TRACED_COALESCED_BREADCRUMB_NAMES. Span volume is unchanged at 121, and the span deliberately carries no count so a span-stream total cannot double-count what the ring already claims - the coalesce key is `ptyId`/`tabIdHash` *presence*, not name alone: those fields are absent on the restore call site (restoreScrollbackBuffers) and present on reattach, so name-only keying would collapse 8907a508's two call sites into whichever crumb landed last. Bounded at 4 slots per storm, matching the webgl `kind` and duplicate-tab `resolvedToActiveWorktree` precedents in the same file. Replaying the corpus timestamps: 121 events -> 14 ring writes. This is a diagnostics fix, not a crash fix. It does not stop panes wedging, and it does not explain the "can't type" reports in this round. --- .../crash-reporting-renderer-breadcrumbs.ts | 24 +++- ...reporting-replay-guard-wedge-burst.test.ts | 128 ++++++++++++++++++ 2 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 src/main/ipc/crash-reporting-replay-guard-wedge-burst.test.ts diff --git a/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts b/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts index 97e0a8f9d65..8d126f557b5 100644 --- a/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts +++ b/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts @@ -49,6 +49,7 @@ function recordRendererBreadcrumbTrace( const DUPLICATE_TAB_OWNER_BREADCRUMB = 'terminal_tab_id_owned_by_multiple_worktrees' const PARK_VERDICT_CHURN_BREADCRUMB = 'terminal_park_verdict_churn' const REACT_COMMIT_CASCADE_BREADCRUMB = 'react_commit_cascade' +const REPLAY_GUARD_WEDGED_BREADCRUMB = 'terminal_replay_guard_wedged_release' const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([ 'renderer_error', 'renderer_unhandled_rejection', @@ -56,6 +57,7 @@ const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([ DUPLICATE_TAB_OWNER_BREADCRUMB, PARK_VERDICT_CHURN_BREADCRUMB, REACT_COMMIT_CASCADE_BREADCRUMB, + REPLAY_GUARD_WEDGED_BREADCRUMB, TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB ]) const RENDERER_BREADCRUMB_COALESCE_MS = 30_000 @@ -69,6 +71,11 @@ const RENDERER_BREADCRUMB_COALESCE_MS = 30_000 // 30-entry ring to two such bursts. `suppressedSinceLast` keeps the pane count // — the only signal these carry — in one slot. const NAME_ONLY_COALESCED_BREADCRUMB_NAMES = new Set(['terminal_safe_fit_retry_exhausted']) +// Why: the 30-slot ring is the scarce sink; the durable span stream is not. For +// bounded-rate pane telemetry whose multiplicity is the whole signal, spans are the +// only place a burst survives the restart that clears the ring, so coalesce the ring +// but keep every event's span. +const PER_EVENT_TRACED_COALESCED_BREADCRUMB_NAMES = new Set([REPLAY_GUARD_WEDGED_BREADCRUMB]) function rendererBreadcrumbCoalesceKey( name: string, @@ -77,6 +84,13 @@ function rendererBreadcrumbCoalesceKey( if (NAME_ONLY_COALESCED_BREADCRUMB_NAMES.has(name)) { return name } + // Why presence and not value: `ptyId`/`tabIdHash` are absent on the restore call + // site (layout-serialization restoreScrollbackBuffers) and present on reattach, so + // their presence is the call-site identity a mixed burst would otherwise lose. Four + // slots per storm at most, regardless of pane count. + if (name === REPLAY_GUARD_WEDGED_BREADCRUMB) { + return `${name}:${data?.ptyId ? 'pty' : ''}:${data?.tabIdHash ? 'tab' : ''}` + } // Why trigger and not name alone: `burst` means damping engaged a commit // short of React #185, `window` means slow benign churn. Collapsing them // would drop the near-crash signal into a slow-churn slot. Still bounded — @@ -191,9 +205,13 @@ export function recordRendererBreadcrumbFromRenderer( minIntervalMs: RENDERER_BREADCRUMB_COALESCE_MS, ...(origin ? { origin } : {}) }) - // Why: tracing every suppressed duplicate would preserve the same - // serialization and disk churn that breadcrumb coalescing removes. - if (coalesceResult) { + if (PER_EVENT_TRACED_COALESCED_BREADCRUMB_NAMES.has(args.name)) { + // Why the raw data: every event already gets its own span, so folding the ring's + // running count in here would double-count in any span-stream total. + recordRendererBreadcrumbTrace(args.name, data) + } else if (coalesceResult) { + // Why gated: tracing every suppressed duplicate would preserve the same + // serialization and disk churn that breadcrumb coalescing removes. recordRendererBreadcrumbTrace( args.name, coalesceResult.suppressedSinceLast > 0 diff --git a/src/main/ipc/crash-reporting-replay-guard-wedge-burst.test.ts b/src/main/ipc/crash-reporting-replay-guard-wedge-burst.test.ts new file mode 100644 index 00000000000..e3823f0b313 --- /dev/null +++ b/src/main/ipc/crash-reporting-replay-guard-wedge-burst.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + clearCrashBreadcrumbsForTest, + getCrashBreadcrumbSnapshot, + recordCrashBreadcrumb +} from '../crash-reporting/crash-breadcrumb-store' +import { recordRendererBreadcrumbFromRenderer } from './crash-reporting-renderer-breadcrumbs' + +type SpanOptions = { attributes: Record } +const startSpanMock = vi.fn((_name: string, _options: SpanOptions) => ({ end: () => {} })) +vi.mock('../observability/tracer', () => ({ + startSpan: (name: string, options: SpanOptions) => startSpanMock(name, options) +})) + +const WEDGE_BREADCRUMB = 'terminal_replay_guard_wedged_release' + +/** Reattach-path shape: identity-bearing (`tabIdHash`, optionally `ptyId`). */ +function emitReattachWedge(pane: number, withPtyId = false): void { + recordRendererBreadcrumbFromRenderer({ + name: WEDGE_BREADCRUMB, + data: { + paneId: pane, + leafIdHash: `leaf${String(pane).padStart(5, '0')}`, + tabIdHash: `tab${String(pane).padStart(6, '0')}`, + worktreeIdHash: 'caa15fa9', + ...(withPtyId ? { ptyId: `…@@pty-${pane}` } : {}) + } + }) +} + +/** Restore-path shape (restoreScrollbackBuffers): no tabIdHash, no ptyId. */ +function emitRestoreWedge(pane: number): void { + recordRendererBreadcrumbFromRenderer({ + name: WEDGE_BREADCRUMB, + data: { paneId: pane, leafIdHash: `leaf${String(pane).padStart(5, '0')}` } + }) +} + +function wedgeCrumbs(): ReturnType { + return getCrashBreadcrumbSnapshot().filter((entry) => entry.name === WEDGE_BREADCRUMB) +} + +function wedgeSpanCount(): number { + return startSpanMock.mock.calls.filter( + (call) => call[1].attributes['breadcrumb.name'] === WEDGE_BREADCRUMB + ).length +} + +beforeEach(() => { + startSpanMock.mockClear() +}) + +afterEach(() => { + clearCrashBreadcrumbsForTest() +}) + +// One mount/reveal/wake transition expires every in-flight replay write at once, so +// the burst reaches the 30-slot ring as N distinct entries. Field span streams measure +// bursts of 26 in 0.96s and 62 over 85s. No captured report in the 09-02 corpus shows +// a ring that actually drained — all nine bursts predate their report's ring window — +// so this bounds a demonstrated hazard, not an observed loss, and must not cost the +// durable span evidence that did carry those bursts. +describe('replay-guard wedge burst against the fixed-size breadcrumb ring', () => { + it('costs one ring slot per call site and preserves the pre-crash trail', () => { + for (let index = 0; index < 10; index += 1) { + recordCrashBreadcrumb(`pre_crash_evidence_${index}`, { index }) + } + + for (let pane = 0; pane < 26; pane += 1) { + emitReattachWedge(pane) + } + + const snapshot = getCrashBreadcrumbSnapshot() + expect(snapshot.filter((entry) => entry.name.startsWith('pre_crash_evidence_'))).toHaveLength( + 10 + ) + expect(wedgeCrumbs()).toHaveLength(1) + }) + + it('carries the burst multiplicity into the ring as suppressedSinceLast', () => { + for (let pane = 0; pane < 26; pane += 1) { + emitReattachWedge(pane) + } + + // 26 emissions: one owns the slot, 25 fold into it. + expect(wedgeCrumbs()[0]?.data?.suppressedSinceLast).toBe(25) + }) + + // The 121-event field corpus lives entirely in the renderer.breadcrumb span stream, + // and the ring is cleared by the restart that usually precedes the crash report, so + // ring coalescing must not suppress the per-event spans. + it('still emits one durable span per wedge event', () => { + for (let pane = 0; pane < 26; pane += 1) { + emitReattachWedge(pane) + } + + expect(wedgeSpanCount()).toBe(26) + // Why no count on the span: one span per event already carries the multiplicity. + expect( + startSpanMock.mock.calls.some((call) => + JSON.stringify(call[1]).includes('suppressedSinceLast') + ) + ).toBe(false) + }) + + // Bundle 8907a508 mixes restore-path (identity-less) and reattach-path crumbs in one + // window; name-only keying would report only the last one's shape. + it('keeps restore-path and reattach-path call sites in separate slots', () => { + emitRestoreWedge(1) + emitRestoreWedge(2) + emitReattachWedge(3) + emitReattachWedge(4, true) + + const crumbs = wedgeCrumbs() + expect(crumbs).toHaveLength(3) + expect(crumbs.map((crumb) => Boolean(crumb.data?.tabIdHash))).toEqual([false, true, true]) + expect(crumbs.map((crumb) => Boolean(crumb.data?.ptyId))).toEqual([false, false, true]) + }) + + it('bounds a many-pane burst to one slot within a call site', () => { + for (let pane = 0; pane < 40; pane += 1) { + emitReattachWedge(pane, pane % 2 === 0) + } + + expect(wedgeCrumbs()).toHaveLength(2) + }) +}) From 9acfba401a91f6be5419950fe920447f3bf047f6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:53:31 -0700 Subject: [PATCH 08/58] fix(crash-reporting): stop claiming kills that never landed, and leave proof when the own-Chromium pid set is unreadable (#18578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(crash-reporting): stop the codex POSIX teardown claiming a group that was already gone terminatePosixTree's default group signal swallowed every process.kill error and then recorded a self_tree_kill unconditionally, so an ESRCH — proof the group was already gone and this teardown killed nothing — still put a suspect in the five-second render-process-gone attribution window. Every sibling group-kill in the tree already records only on a proven signal: terminateDedicatedPosixGroup in this same file, forceKillPosixPtyProcessGroups, and the claude account-login teardown. This makes the outlier match them. * fix(crash-reporting): leave proof when the own-Chromium pid set cannot be read `readOrcaChromiumProcessPids` returns an empty set when `getAppMetrics()` throws, which is the right decision — refusing every kill would orphan every PTY, git, codex and notebook tree main tears down, and on main a refusal from `killSourceControlAgentProcess` releases the managed-home lock with the agent still alive. But the empty set was byte-identical to "no Chromium on this host", so the fail-open was invisible in a field bundle. Keeps the decision, adds a coalesced durable `own_chromium_pids_unreadable` crumb so the two cases are distinguishable. Coalesced because the gate reads this set on every tree kill. * style(crash-reporting): tighten the group-signal comments to the WHY --- .../codex-app-server-process-teardown.test.ts | 62 ++++++++++++++++++- .../codex-app-server-process-teardown.ts | 29 +++++---- src/main/orca-chromium-process-pids.ts | 30 ++++++++- src/main/own-chromium-tree-kill-guard.test.ts | 34 ++++++++++ 4 files changed, 141 insertions(+), 14 deletions(-) diff --git a/src/main/codex/codex-app-server-process-teardown.test.ts b/src/main/codex/codex-app-server-process-teardown.test.ts index 1ddb672a531..cec8f91d081 100644 --- a/src/main/codex/codex-app-server-process-teardown.test.ts +++ b/src/main/codex/codex-app-server-process-teardown.test.ts @@ -1,7 +1,14 @@ import type { ChildProcess } from 'node:child_process' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + findSelfInitiatedTreeKills, + resetSelfInitiatedTreeKillLogForTest +} from '../crash-reporting/self-initiated-tree-kill-log' import { terminateCodexAppServerProcessTree } from './codex-app-server-process-teardown' +/** Above pid_max on every supported POSIX host, so the group signal is a real ESRCH. */ +const UNREACHABLE_PGID = 2_147_483_647 + function child() { return { pid: 1234, @@ -10,6 +17,10 @@ function child() { } describe('terminateCodexAppServerProcessTree', () => { + beforeEach(() => { + resetSelfInitiatedTreeKillLogForTest() + }) + it('waits for the Windows tree kill before releasing the wrapper', async () => { const target = child() const release = Promise.withResolvers() @@ -120,6 +131,55 @@ describe('terminateCodexAppServerProcessTree', () => { expect(target.kill).not.toHaveBeenCalled() }) + /** + * `selfInitiatedTreeKillCount` decides whether a `render-process-gone` was + * ours. A group that had already exited was killed by nobody, so crediting it + * puts a suspect in the five-second window that Orca never issued. Exercised + * through the real `process.kill(-pgid)` because the swallow being tested + * lives in the production default, not in an injectable seam. + */ + it('does not claim a snapshot group that was already gone', async () => { + const target = { pid: UNREACHABLE_PGID, kill: vi.fn(() => true) as ChildProcess['kill'] } + + await expect( + terminateCodexAppServerProcessTree(target, undefined, { + platform: 'darwin', + captureDescendants: async () => ({ + rootPgid: UNREACHABLE_PGID, + descendants: [], + capturedAtMs: 1 + }), + terminateDescendants: async () => true + }) + ).resolves.toBe(true) + + expect(target.kill).toHaveBeenLastCalledWith('SIGKILL') + expect(findSelfInitiatedTreeKills(Date.now())).toEqual([]) + }) + + it('claims a snapshot group the signal actually reached', async () => { + const target = child() + const signalProcessGroup = vi.fn() + + await expect( + terminateCodexAppServerProcessTree(target, undefined, { + platform: 'darwin', + captureDescendants: async () => ({ rootPgid: 1234, descendants: [], capturedAtMs: 1 }), + terminateDescendants: async () => true, + signalProcessGroup + }) + ).resolves.toBe(true) + + expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL') + expect(findSelfInitiatedTreeKills(Date.now())).toEqual([ + expect.objectContaining({ + pid: 1234, + site: 'codex-app-server-teardown', + scope: 'posix-process-group' + }) + ]) + }) + it('tears down 40 dedicated groups without process-table scans or cross-group fanout', async () => { const killMocks = Array.from({ length: 40 }, () => vi.fn(() => true)) const targets = killMocks.map((kill, index) => ({ diff --git a/src/main/codex/codex-app-server-process-teardown.ts b/src/main/codex/codex-app-server-process-teardown.ts index a35ad4d3164..5a9c6e3574b 100644 --- a/src/main/codex/codex-app-server-process-teardown.ts +++ b/src/main/codex/codex-app-server-process-teardown.ts @@ -128,19 +128,24 @@ async function terminatePosixTree( if (descendantsExited && snapshot.rootPgid === rootPid) { const signalGroup = deps.signalProcessGroup ?? - ((pgid: number, signal: NodeJS.Signals) => { - try { - process.kill(-pgid, signal) - } catch { - // Group already exited. - } + ((pgid: number, signal: NodeJS.Signals) => process.kill(-pgid, signal)) + let groupSignalled = false + try { + signalGroup(snapshot.rootPgid, 'SIGKILL') + groupSignalled = true + } catch { + // Already-gone is still the desired outcome, but nothing here killed it, + // and a crumb for a kill we never landed is a false render-process-gone suspect. + } + if (groupSignalled) { + // Outside the try, as in terminateDedicatedPosixGroup: that catch is the + // already-gone contract, not a breadcrumb handler. + recordSelfInitiatedTreeKill({ + pid: snapshot.rootPgid, + site: 'codex-app-server-teardown', + scope: 'posix-process-group' }) - signalGroup(snapshot.rootPgid, 'SIGKILL') - recordSelfInitiatedTreeKill({ - pid: snapshot.rootPgid, - site: 'codex-app-server-teardown', - scope: 'posix-process-group' - }) + } } if (!descendantsExited) { child.kill('SIGCONT') diff --git a/src/main/orca-chromium-process-pids.ts b/src/main/orca-chromium-process-pids.ts index f22babc6921..b2613e42b79 100644 --- a/src/main/orca-chromium-process-pids.ts +++ b/src/main/orca-chromium-process-pids.ts @@ -1,4 +1,5 @@ import { getAppEnvironment, hasAppEnvironment } from '../shared/app-environment' +import { recordCoalescedDurableCrashBreadcrumb } from './crash-reporting/durable-crash-breadcrumb' /** * PIDs of Orca's own Chromium processes — browser, renderers, GPU, utilities. @@ -11,6 +12,14 @@ import { getAppEnvironment, hasAppEnvironment } from '../shared/app-environment' * Empty on a Node host and empty on failure: that is "no refusal proven", never * "safe to kill" — callers must keep every other guard they already have. * + * Why failure stays open rather than refusing everything: a refusal is not free. + * `terminateWindowsProcessTree` resolves without killing, and + * `killSourceControlAgentProcess` returns that straight to a caller that then + * releases the managed-home lock, so failing closed would trade one unreadable + * metrics table for every PTY, git, codex and notebook tree in main leaking at + * once. The `own_chromium_pids_unreadable` crumb is the price of that choice: + * without it a throw is byte-identical to "no Chromium on this host". + * * Host coverage: only Electron main installs a Chromium-backed AppEnvironment * (main-process-preflight). The standalone daemon installs none and `orcad` * installs a Node one whose `getAppMetrics()` is `[]`, so this set is empty in @@ -30,7 +39,26 @@ export function readOrcaChromiumProcessPids(): ReadonlySet { .map((metric) => metric.pid) .filter((pid) => Number.isInteger(pid) && pid > 0) return new Set(pids) - } catch { + } catch (error) { + recordUnreadableOwnChromiumMetrics(error) return new Set() } } + +// Why coalesced: the gate reads this set on every tree kill, so a persistently +// broken metrics table would otherwise flood the 30-slot ring it shares. +const UNREADABLE_METRICS_COALESCE_MS = 60_000 + +function recordUnreadableOwnChromiumMetrics(error: unknown): void { + try { + recordCoalescedDurableCrashBreadcrumb({ + name: 'own_chromium_pids_unreadable', + data: { cause: error instanceof Error ? error.message : String(error) }, + coalesceKey: 'own-chromium-pids-unreadable', + minIntervalMs: UNREADABLE_METRICS_COALESCE_MS + }) + } catch { + // Diagnostics must never turn an admitted kill into a thrown one: callers + // read this set outside their own try. + } +} diff --git a/src/main/own-chromium-tree-kill-guard.test.ts b/src/main/own-chromium-tree-kill-guard.test.ts index 7e98661aca6..bd3b1674e18 100644 --- a/src/main/own-chromium-tree-kill-guard.test.ts +++ b/src/main/own-chromium-tree-kill-guard.test.ts @@ -143,6 +143,40 @@ describe('refusing to tree-kill our own Chromium processes', () => { ) }) + /** + * Fail-open is the deliberate choice — see `orca-chromium-process-pids.ts` for + * why refusing everything is worse — so the crumb is the only thing that keeps + * an unreadable metrics table distinguishable from a host that has no Chromium. + */ + it('leaves proof, and still admits the kill, when the Chromium metrics cannot be read', () => { + appMetricsMock.mockImplementation(() => { + throw new Error('getAppMetrics unavailable') + }) + + expect([...readOrcaChromiumProcessPids()]).toEqual([]) + // Coalesced: the gate reads this set on every kill, so a broken table must + // not evict the ring it shares with the refusal crumb. + expect([...readOrcaChromiumProcessPids()]).toEqual([]) + expect( + admitSelfInitiatedTreeKill({ + pid: RENDERER_PID, + site: 'pty-descendant-sweep', + scope: 'win-taskkill-tree' + }) + ).toBe(true) + + expect( + getCrashBreadcrumbSnapshot().filter( + (breadcrumb) => breadcrumb.name === 'own_chromium_pids_unreadable' + ) + ).toEqual([ + expect.objectContaining({ + name: 'own_chromium_pids_unreadable', + data: expect.objectContaining({ cause: 'getAppMetrics unavailable' }) + }) + ]) + }) + it('refuses an own-Chromium pid at the gate the account teardowns share', () => { expect( admitSelfInitiatedTreeKill({ From 7a714d1bd2750789cd26ad213f9ae2f129807eef Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:53:34 -0700 Subject: [PATCH 09/58] fix(terminals): add equality bailouts to the tab pane-expansion actions (#18332) * fix(terminals): bail out of no-op pane-expansion store writes * test(terminals): lock the root-state identity of the bailout A `return {}` bailout keeps the map reference but still allocates a new root state, so zustand walks every listener. Assert root identity too. --- .../store/terminals/terminal-layout-state.ts | 17 +- ...inal-pane-expansion-write-bailout.test.tsx | 152 ++++++++++++++++++ 2 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/store/terminals/terminal-pane-expansion-write-bailout.test.tsx diff --git a/src/renderer/src/store/terminals/terminal-layout-state.ts b/src/renderer/src/store/terminals/terminal-layout-state.ts index d95439cf5a8..9b5cf8323c2 100644 --- a/src/renderer/src/store/terminals/terminal-layout-state.ts +++ b/src/renderer/src/store/terminals/terminal-layout-state.ts @@ -43,15 +43,20 @@ export function createTerminalLayoutActions( } }) }, + // Why: pane mount/unmount re-asserts the same booleans; bailing like setTabLayout keeps map subscribers asleep. setTabPaneExpanded: (tabId, expanded) => { - set((s) => ({ - expandedPaneByTabId: { ...s.expandedPaneByTabId, [tabId]: expanded } - })) + set((s) => + s.expandedPaneByTabId[tabId] === expanded + ? s + : { expandedPaneByTabId: { ...s.expandedPaneByTabId, [tabId]: expanded } } + ) }, setTabCanExpandPane: (tabId, canExpand) => { - set((s) => ({ - canExpandPaneByTabId: { ...s.canExpandPaneByTabId, [tabId]: canExpand } - })) + set((s) => + s.canExpandPaneByTabId[tabId] === canExpand + ? s + : { canExpandPaneByTabId: { ...s.canExpandPaneByTabId, [tabId]: canExpand } } + ) }, setTabLayout: (tabId, layout) => { let ownershipTransfers: ReturnType = [] diff --git a/src/renderer/src/store/terminals/terminal-pane-expansion-write-bailout.test.tsx b/src/renderer/src/store/terminals/terminal-pane-expansion-write-bailout.test.tsx new file mode 100644 index 00000000000..d0d32f7742f --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-pane-expansion-write-bailout.test.tsx @@ -0,0 +1,152 @@ +// @vitest-environment happy-dom + +import { Profiler } from 'react' +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { createTestStore } from '../slices/store-test-helpers' + +afterEach(cleanup) + +type TestStore = ReturnType + +const TAB_ID = 'tab-1' +const NO_OP_WRITES = 25 + +function recordPublishedMapKeys(store: TestStore): string[] { + const published: string[] = [] + store.subscribe((next, previous) => { + if (next.expandedPaneByTabId !== previous.expandedPaneByTabId) { + published.push('expandedPaneByTabId') + } + if (next.canExpandPaneByTabId !== previous.canExpandPaneByTabId) { + published.push('canExpandPaneByTabId') + } + }) + return published +} + +// Mirrors use-terminal-workspace-store-bindings.ts:17, which subscribes to the raw map. +function ExpandedPaneSubscriber({ store }: { store: TestStore }): React.JSX.Element { + const expandedPaneByTabId = store((s) => s.expandedPaneByTabId) + return {String(expandedPaneByTabId[TAB_ID] === true)} +} + +function CanExpandPaneSubscriber({ store }: { store: TestStore }): React.JSX.Element { + const canExpandPaneByTabId = store((s) => s.canExpandPaneByTabId) + return {String(canExpandPaneByTabId[TAB_ID] === true)} +} + +function renderCommitCounter(subscriber: React.JSX.Element): () => number { + let commits = 0 + render( + { + commits += 1 + }} + > + {subscriber} + + ) + const mountCommits = commits + return () => commits - mountCommits +} + +describe('setTabPaneExpanded', () => { + it('publishes the first write for an unseen tab and a real toggle', () => { + const store = createTestStore() + const published = recordPublishedMapKeys(store) + + store.getState().setTabPaneExpanded(TAB_ID, false) + expect(published).toEqual(['expandedPaneByTabId']) + expect(store.getState().expandedPaneByTabId[TAB_ID]).toBe(false) + + store.getState().setTabPaneExpanded(TAB_ID, true) + expect(published).toEqual(['expandedPaneByTabId', 'expandedPaneByTabId']) + expect(store.getState().expandedPaneByTabId[TAB_ID]).toBe(true) + }) + + it('bails out when the value is unchanged', () => { + const store = createTestStore() + store.getState().setTabPaneExpanded(TAB_ID, false) + const before = store.getState().expandedPaneByTabId + // Root identity too: returning `{}` keeps the map but allocates a new root, so zustand still walks every listener. + const rootBefore = store.getState() + const published = recordPublishedMapKeys(store) + + for (let i = 0; i < NO_OP_WRITES; i += 1) { + store.getState().setTabPaneExpanded(TAB_ID, false) + } + + expect(published).toEqual([]) + expect(store.getState().expandedPaneByTabId).toBe(before) + expect(store.getState()).toBe(rootBefore) + }) + + it('costs no React commit in a map subscriber when the value is unchanged', () => { + const store = createTestStore() + store.getState().setTabPaneExpanded(TAB_ID, false) + const commitsSinceMount = renderCommitCounter() + + for (let i = 0; i < NO_OP_WRITES; i += 1) { + act(() => { + store.getState().setTabPaneExpanded(TAB_ID, false) + }) + } + expect(commitsSinceMount()).toBe(0) + + act(() => { + store.getState().setTabPaneExpanded(TAB_ID, true) + }) + expect(commitsSinceMount()).toBe(1) + }) +}) + +describe('setTabCanExpandPane', () => { + it('publishes the first write for an unseen tab and a real toggle', () => { + const store = createTestStore() + const published = recordPublishedMapKeys(store) + + store.getState().setTabCanExpandPane(TAB_ID, false) + expect(published).toEqual(['canExpandPaneByTabId']) + expect(store.getState().canExpandPaneByTabId[TAB_ID]).toBe(false) + + store.getState().setTabCanExpandPane(TAB_ID, true) + expect(published).toEqual(['canExpandPaneByTabId', 'canExpandPaneByTabId']) + expect(store.getState().canExpandPaneByTabId[TAB_ID]).toBe(true) + }) + + it('bails out when the value is unchanged', () => { + const store = createTestStore() + store.getState().setTabCanExpandPane(TAB_ID, false) + const before = store.getState().canExpandPaneByTabId + const rootBefore = store.getState() + const published = recordPublishedMapKeys(store) + + for (let i = 0; i < NO_OP_WRITES; i += 1) { + store.getState().setTabCanExpandPane(TAB_ID, false) + } + + expect(published).toEqual([]) + expect(store.getState().canExpandPaneByTabId).toBe(before) + expect(store.getState()).toBe(rootBefore) + }) + + it('costs no React commit in a map subscriber when the value is unchanged', () => { + const store = createTestStore() + store.getState().setTabCanExpandPane(TAB_ID, false) + const commitsSinceMount = renderCommitCounter() + + for (let i = 0; i < NO_OP_WRITES; i += 1) { + act(() => { + store.getState().setTabCanExpandPane(TAB_ID, false) + }) + } + expect(commitsSinceMount()).toBe(0) + + act(() => { + store.getState().setTabCanExpandPane(TAB_ID, true) + }) + expect(commitsSinceMount()).toBe(1) + }) +}) From cc9e9ed65fc3d4f69d79224eb66c437fb819528d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:53:37 -0700 Subject: [PATCH 10/58] fix(crash-reporting): sample system memory before the process is gone (#18356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(crash-reporting): sample system memory before the renderer dies * fix(crash-reporting): make the pre-gone host sample decisive, not just present Round-1 review said the shipped field set could not decide G4-oom. Fixed. Decisive field (blocking #1). The investigation's own win-lowspec repro falsified "low available commit kills": at a 127 MB commit floor Windows grew the pagefile to 2029 MB and nothing died, and it named the missing datum — pagefile-growth headroom / system-drive free space. `getSystemMemoryInfo()` gives neither. Added `swap-volume-free-space.ts`: one `fs.statfs` on the volume backing the pagefile (SystemRoot on Windows, the root fs elsewhere, resolved via `path.parse().root`), published as `systemMemoryPreGoneSwapVolumeFreeMB`. Together with the already-emitted commit limit that separates "commit was low" from "commit was refused". Pagefile *max* size needs a registry read; skipped deliberately — per-operation interpreter spawning is exactly what docs/reference/windows-edr-posture.md says not to add for telemetry. Darwin honesty (blocking #2). Every reading now carries `systemMemoryPressureSignal`: `available-commit` on Windows (swapFree is ullAvailPageFile), `mem-available` on Linux when MemAvailable is present, `none` otherwise — which is always on darwin. A future analyst cannot now table `freeMB: 272` from a healthy Mac as evidence of exhaustion, because the same record says the platform gave no pressure verdict. Partial rebuttal on the suggested reuse: `host-memory.ts:86` was considered and rejected as a periodic source. It spawns `/usr/bin/memory_pressure` per call, and the sampler this PR needs runs every 10 s for the app's lifetime; a subprocess at that cadence is worse than the gap it closes. The reviewer conceded this tradeoff is arguable — what was not acceptable was shipping the darwin gap silently, so it is now in the data, not only in a comment. Staleness (blocking #3). Confirmed the measurement: four of five G4 reports carried a ~37 s-old sample (4872/36796/37332/38017/39715 ms). Host memory no longer rides the 60 s process-metrics sweep; `pre-gone-host-memory.ts` samples it on its own 10 s timer with its own `systemMemoryPreGoneSampleAgeMs`. One GlobalMemoryStatusEx-class call plus one statfs is cheap enough at that rate. A refusal shorter than the interval stays invisible and the module comment says so — no polling cadence fixes that. Non-blocking, all taken: renamed `gone-time-system-memory.ts` -> `system-memory-details.ts` with the now-false "reads AFTER the crash" framing scoped to the gone-time caller; pre-gone host keys moved out of the `processMetrics` namespace to `systemMemoryPreGone*`, so the string-surgery `preGoneDetailKey` helper is gone and a `systemMemory` prefix scan sees both reads; the bare catch no longer spans both halves of the sample, and a test pins that a throwing host read leaves the process-metric sample intact; the inert second test is replaced by three that go red without this change (verified: swap-volume, pressure-signal and cadence assertions all fail when the production hunks are reverted). Rebuttal, non-blocking #5 (duplicated electron mock across two test files): declined. `vi.mock` is hoisted per file, so the mock cannot be shared without a setup module, and this directory already has 26 focused test files that each re-declare it. Splitting by concern is the local convention. `startPreGoneProcessMetricsSampling` is renamed `startPreGoneCrashSampling` since it now starts two samplers. * fix(crash-reporting): test the arming, gate the swap volume, unblock the host read Round-2 review blocked on four items. All four addressed. WHAT THIS BRANCH ACTUALLY DOES, AT HEAD (blocking #4). The commit-1 message ("13 lines, 1 production file, no new module, new optional numeric fields only", `processMetricsPreGoneSystemMemory*` keys, a `preGoneDetailKey` helper, a `pre-gone-system-memory.test.ts`) describes a superseded revision; every one of those claims is false now, so it must not be used as the PR description. The change against origin/main is: 3 new production modules (`pre-gone-host-memory.ts`, `system-memory-details.ts`, `swap-volume-free-space.ts`), 1 deleted (`gone-time-system-memory.ts`), plus edits to `process-gone-diagnostics.ts` and `main-process-ready-runtime.ts` and 2 test files. It adds a second main-process interval timer that runs for the life of the app: every 10 s one synchronous GlobalMemoryStatusEx-class read, and on win32/darwin one `fs.statfs` on the swap-backing volume. Details are `systemMemoryPreGone*`, and two of them are STRINGS, not numbers: `systemMemoryPreGonePressureSignal` (enum) and `systemMemoryPreGoneSwapVolume` (a drive label, separator-trimmed so it is not a path). Both are assigned after `sanitizeCrashReportDetails`; neither carries user content. Arming is now tested (blocking #1). The reviewer deleted `startPreGoneSystemMemorySampling(...)` from `startPreGoneCrashSampling` and all 264 tests stayed green — confirmed and fixed. `pre-gone-host-memory.test.ts` now calls `startPreGoneCrashSampling()` with production defaults and asserts both `setInterval` calls, their literal periods `[60_000, 10_000]`, that both timers are unref'd, and that advancing 10 s takes a fresh host sample that reaches `buildProcessGoneCrashDetails` with `SampleAgeMs: 0`. Verified red on revert: deleting the arming line -> 1 failure; changing the interval constant to 30_000 -> 1 failure (the old assertion compared the constant to itself and caught neither). The tautological `10_000 < 60_000 / 2` test is gone, superseded by this one. Swap volume is win32/darwin only (blocking #2). On Linux swap is a fixed partition, a fixed-size swapfile, or zram; none grow into root-fs free space, so `SwapVolumeFreeMB: 380000` beside `SwapFreeMB: 0` would have invited exactly the wrong verdict on the two Linux cluster members. `swapVolumeAnchor` returns undefined off win32/darwin, so no field and no statfs at all. The comment claiming "elsewhere swap is on the root fs" was wrong and is gone. The Windows anchor is still the DEFAULT pagefile volume, so the measured volume now ships with the number (`systemMemoryPreGoneSwapVolume: 'C:'`) instead of being implied. The honesty label covers it: win32 reads `available-commit` only when the volume datum is present, and `available-commit-unqualified` otherwise — which also fixes non-blocking #5, where the synchronous gone-time read claimed a verdict its own fields could not support. Host read no longer waits on statfs (blocking #3). `samplePreGoneSystemMemory` now commits the synchronous memory reading first and merges volume free space in afterwards, so the cadence is 10 s regardless of disk-metadata latency and a hung volume can no longer stop host sampling — precisely the paging-storm case this exists for. The in-flight latch now guards only the statfs. Verified red on revert to the serialized shape (2 failures). A stale-but-slow-moving volume value merging into a newer memory sample is deliberate and commented. Non-blocking #3 (reset does not invalidate an in-flight sample): fixed with a generation counter bumped by `resetPreGoneSystemMemorySamplingForTest`, so a late statfs cannot repopulate a reset sample. Separately, the volume read now only runs after a host sample committed, which removes the real `statfs('/')` side effect from `process-gone-diagnostics.test.ts` entirely. REBUTTAL, darwin `memory_pressure` reuse (non-blocking #2): declined, with evidence. `readDarwinAvailableMemory` at src/main/memory/host-memory.ts:87 is reached only via `collectHostMemory` <- `runSnapshot` <- `collectMemorySnapshot`, whose only callers are the `memory:getSnapshot` IPC handler and orca-runtime-pty-foreground-process-reads.ts:170 — both on demand. There is no periodic snapshot, so there is no cached reading to reuse for free; adopting it means spawning `/usr/bin/memory_pressure` on a main-process timer for the life of the app, and its module-global `darwinAvailabilitySupported` latch is shared with the memory UI. The gap is not hidden: darwin ships `PressureSignal: 'none'` in the data, and the module comment now cites the existing reader and why it is not used here rather than claiming Orca lacks one. Verified: `vitest src/main/crash-reporting src/main/startup src/main/memory` = 769 passed / 6 skipped (crash-reporting re-run 5x, no flake); `tsc --noEmit -p config/tsconfig.node.json` 0; `oxlint` 0; `oxfmt --check` 0. * fix(crash-reporting): stop a stale statfs qualifying the commit verdict Round-3 adversarial review, 2 blocking. Both fixed with mutation-verified tests. 1. `mergeSwapVolumeFreeSpace` merged the volume reading into whatever sample was current at RESOLUTION time, and `pressureSignal` then upgraded win32 from `available-commit-unqualified` to the decisive `available-commit` on the strength of it. The `swapVolumeReadInFlight` latch makes every intervening tick skip the merge, so the lag is as old as the last STARTED statfs, not the last tick — and no age field exposed it, because `systemMemoryPreGoneSampleAgeMs` describes only the synchronous memory read. Reviewer's executed scenario: a statfs issued at t=0 on a healthy host (40 GB free) resolving at t=20 s of commit pressure emitted `SwapFreeMB: 200` beside `SwapVolumeFreeMB: 40000`, labelled `available-commit`, with `SampleAgeMs: 0`. That reads as "the pagefile had room, so this was not a commit refusal" — the opposite conclusion, wearing the branch's highest-confidence label, on exactly the win32 G4-oom reports this exists to decide. The datum still ships (it is the only pagefile-expandability signal there is), but now: - the sample carries `swapVolumeSampledAtMs` — the tick that ISSUED the statfs, never the one it resolved on — surfaced as `systemMemoryPreGoneSwapVolumeAgeMs`; - only a statfs that answers on its own tick may qualify the verdict. `withSwapVolumeFreeSpace` takes `coTimed`; false keeps `available-commit-unqualified`. The next tick issues a fresh statfs, so the verdict recovers on its own. 2. The branch's sole production entry point — `startPreGoneCrashSampling()` at main-process-ready-runtime.ts:128 — was untested. Deleting it left 691 tests across crash-reporting/ and startup/ green, while a comment in the new test file claimed that gap was why the test was written. This is pure instrumentation, so that one line is the whole of its value in the shipped app. Added a source-level wiring test (the pattern this repo already uses for arm-once ready-phase lines) that pins the import, exactly one call, the call at statement indent, and that `main-process-ready.ts` awaits the function it lives in. The misleading comment is gone. Mutation-verified — each goes red alone: coTimed -> always true 1 failed (verdict) drop swapVolumeSampledAtMs age 1 failed (verdict test) delete startPreGoneCrashSampling() 1 failed (wiring) wrap it in `if (!is.dev) { ... }` 1 failed (wiring) Verified: tsc -p config/tsconfig.node.json exit 0; oxlint src/main/crash-reporting src/main/startup exit 0; 268 tests in crash-reporting/ pass. Across crash-reporting/ + startup/ + memory/: 769 passed, 2 failed — both environment-dependent and failing identically on the unmodified tree (Xvfb rebind, and a whole-repo glob census that times out). * fix(crash-reporting): stop free disk standing in for pagefile growability The win32 reading was promoted to the decisive `available-commit` whenever a co-timed volume number merely existed, which the data cannot support: a fixed or disabled pagefile grows into no amount of empty disk, its maximum is unreadable here, and the measured volume is only the DEFAULT pagefile drive. A host with 180 MB of available commit, a commit limit at RAM and 812 GB free read as "the pagefile had room" — the opposite conclusion, under the branch's most confident label. The volume datum is now named for what it is (`available-commit-volume-cotimed`, context beside the commit number), and the one decisive win32 case — a commit limit at or below RAM, i.e. no pagefile behind it — gets its own label. Also: carry the last volume reading onto the sample that replaces it, aged and non-qualifying, so a statfs slower than one tick no longer makes the field vanish from the reports it exists for; don't commit a reading whose every memory field failed, which shipped an age and a disk-free number with no host memory beside them; and move the startup wiring test beside the file it pins, scoped to the ready-phase entry's own body so the call cannot satisfy it from a sibling export nothing calls. * fix(crash-reporting): co-time the statfs by tick, not sample identity A tick whose host read fails leaves the pre-gone sample object in place, so the identity check still read a 25 s-late statfs as co-timed. --- .../gone-time-system-memory.ts | 77 ---- .../pre-gone-host-memory.test.ts | 379 ++++++++++++++++++ .../crash-reporting/pre-gone-host-memory.ts | 164 ++++++++ .../process-gone-diagnostics.test.ts | 22 +- .../process-gone-diagnostics.ts | 23 +- .../crash-reporting/swap-volume-free-space.ts | 67 ++++ .../crash-reporting/system-memory-details.ts | 161 ++++++++ .../startup/main-process-ready-runtime.ts | 9 +- .../pre-gone-crash-sampling-wiring.test.ts | 49 +++ 9 files changed, 854 insertions(+), 97 deletions(-) delete mode 100644 src/main/crash-reporting/gone-time-system-memory.ts create mode 100644 src/main/crash-reporting/pre-gone-host-memory.test.ts create mode 100644 src/main/crash-reporting/pre-gone-host-memory.ts create mode 100644 src/main/crash-reporting/swap-volume-free-space.ts create mode 100644 src/main/crash-reporting/system-memory-details.ts create mode 100644 src/main/startup/pre-gone-crash-sampling-wiring.test.ts diff --git a/src/main/crash-reporting/gone-time-system-memory.ts b/src/main/crash-reporting/gone-time-system-memory.ts deleted file mode 100644 index 7cca89d2d4b..00000000000 --- a/src/main/crash-reporting/gone-time-system-memory.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { CrashReportDetailValue } from '../../shared/crash-reporting' - -// ─── System memory at gone time ───────────────────────────────────── -// Why: the system outlives the crashed process, so this IS sampleable at -// process-gone — it separates "renderer grew huge" from "machine out of -// memory/commit", which the per-process buckets alone cannot. -// Timing honesty: this reads AFTER the crashed process's memory returned to -// the OS, so free/swapFree can look healthier than they were at kill time. -// Platform honesty: swap* exist on Windows/Linux only. On Linux `free` is -// /proc/meminfo MemFree and is NOT the pressure signal — it excludes page cache -// and other reclaimable memory; `available` (MemAvailable, Linux-only) is. On -// macOS `free` is near-meaningless (file cache and compression keep it low on -// healthy machines); fileBacked/purgeable are the only reclaimability proxy this -// API gives there, and none of these fields answers "was the machine under -// pressure" on macOS — that needs a signal Electron does not expose. - -type CrashReportDetails = Record - -export function memoryKBFieldMB(value: unknown): number | undefined { - const kb = typeof value === 'number' && Number.isFinite(value) ? value : undefined - return kb === undefined ? undefined : Math.round(Math.max(0, kb) / 1024) -} - -type SystemMemoryInfoLike = { - total?: unknown - free?: unknown - available?: unknown - swapTotal?: unknown - swapFree?: unknown - fileBacked?: unknown - purgeable?: unknown -} - -type SystemMemoryInfoReader = () => SystemMemoryInfoLike | null - -function readElectronSystemMemoryInfo(): SystemMemoryInfoLike | null { - const read = (process as NodeJS.Process & { getSystemMemoryInfo?: () => SystemMemoryInfoLike }) - .getSystemMemoryInfo - if (typeof read !== 'function') { - return null - } - try { - return read.call(process) - } catch { - return null - } -} - -let systemMemoryInfoReader: SystemMemoryInfoReader = readElectronSystemMemoryInfo - -export function setSystemMemoryInfoReaderForTest(reader: SystemMemoryInfoReader | null): void { - systemMemoryInfoReader = reader ?? readElectronSystemMemoryInfo -} - -export function getSystemMemoryAtGoneDetails(): CrashReportDetails { - const info = systemMemoryInfoReader() - if (!info) { - return {} - } - const details: CrashReportDetails = {} - const fields: readonly [keyof SystemMemoryInfoLike, string][] = [ - ['total', 'systemMemoryTotalMB'], - ['free', 'systemMemoryFreeMB'], - ['available', 'systemMemoryAvailableMB'], - ['swapTotal', 'systemMemorySwapTotalMB'], - ['swapFree', 'systemMemorySwapFreeMB'], - ['fileBacked', 'systemMemoryFileBackedMB'], - ['purgeable', 'systemMemoryPurgeableMB'] - ] - for (const [field, key] of fields) { - const mb = memoryKBFieldMB(info[field]) - if (mb !== undefined) { - details[key] = mb - } - } - return details -} diff --git a/src/main/crash-reporting/pre-gone-host-memory.test.ts b/src/main/crash-reporting/pre-gone-host-memory.test.ts new file mode 100644 index 00000000000..0df13d4fee5 --- /dev/null +++ b/src/main/crash-reporting/pre-gone-host-memory.test.ts @@ -0,0 +1,379 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + getSystemMemoryDetails, + setSystemMemoryInfoReaderForTest, + withSwapVolumeFreeSpace +} from './system-memory-details' +import { + readSwapVolumeFreeSpace, + setSwapVolumeFreeSpaceReaderForTest, + type SwapVolumeFreeSpace +} from './swap-volume-free-space' +import { samplePreGoneSystemMemory } from './pre-gone-host-memory' +import { + buildProcessGoneCrashDetails, + resetPreGoneCrashSamplingForTest, + samplePreGoneProcessMetrics, + startPreGoneCrashSampling +} from './process-gone-diagnostics' + +type MetricFixture = { + pid: number + creationTime: number + type: string + memory: { workingSetSize: number; peakWorkingSetSize?: number; privateBytes?: number } +} + +const { appMetricsMock } = vi.hoisted(() => ({ + appMetricsMock: vi.fn<() => MetricFixture[]>(() => []) +})) + +vi.mock('electron', () => ({ app: { getAppMetrics: appMetricsMock } })) + +const BROWSER_AND_RENDERER: MetricFixture[] = [ + { pid: 10, creationTime: 1, type: 'Browser', memory: { workingSetSize: 1024 * 250 } }, + { + pid: 11, + creationTime: 2, + type: 'Tab', + memory: { workingSetSize: 1024 * 400, peakWorkingSetSize: 1024 * 420, privateBytes: 1024 * 260 } + } +] + +const BROWSER_ONLY: MetricFixture[] = [BROWSER_AND_RENDERER[0]] + +const UNDER_COMMIT_PRESSURE = { + total: 16_000 * 1024, + free: 400 * 1024, + swapTotal: 48_000 * 1024, + swapFree: 200 * 1024 +} + +const AFTER_THE_CORPSE_RELEASED = { + total: 16_000 * 1024, + free: 3_000 * 1024, + swapTotal: 48_000 * 1024, + swapFree: 2_900 * 1024 +} + +// Commit limit ~= RAM: a disabled or fixed pagefile, which no amount of empty +// disk can grow into. `swapTotal > total` is all this API can say about that. +const FIXED_PAGEFILE_UNDER_PRESSURE = { + total: 16_000 * 1024, + free: 300 * 1024, + swapTotal: 16_100 * 1024, + swapFree: 180 * 1024 +} + +const NO_PAGEFILE_UNDER_PRESSURE = { + ...FIXED_PAGEFILE_UNDER_PRESSURE, + swapTotal: 15_900 * 1024 +} + +const BEFORE_THE_STORM = { + total: 16_000 * 1024, + free: 9_000 * 1024, + swapTotal: 48_000 * 1024, + swapFree: 30_000 * 1024 +} + +describe('pre-gone host memory', () => { + beforeEach(() => { + resetPreGoneCrashSamplingForTest() + setSystemMemoryInfoReaderForTest(null) + setSwapVolumeFreeSpaceReaderForTest(null) + appMetricsMock.mockClear() + appMetricsMock.mockReturnValue(BROWSER_AND_RENDERER) + }) + + it('carries a pre-gone host reading, not only the post-mortem one', async () => { + setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE) + setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 120, volume: 'C:' })) + await samplePreGoneSystemMemory(Date.now() - 5_000) + + // The renderer dies; its ~400 MB returns to the OS, so the gone-time read + // now shows a much healthier machine than the one that refused the alloc. + setSystemMemoryInfoReaderForTest(() => AFTER_THE_CORPSE_RELEASED) + appMetricsMock.mockReturnValue(BROWSER_ONLY) + + const details = buildProcessGoneCrashDetails({ processType: 'renderer' }, 'renderer') + + expect(details.systemMemorySwapFreeMB).toBe(2_900) + expect(details.systemMemoryPreGoneSwapFreeMB).toBe(200) + expect(details.systemMemoryPreGoneFreeMB).toBe(400) + expect(details.systemMemoryPreGoneTotalMB).toBe(16_000) + // Why: host memory keeps its own key family, so a `systemMemory` prefix scan sees both reads. + expect( + Object.keys(details).filter((key) => key.startsWith('processMetricsPreGoneSystem')) + ).toEqual([]) + }) + + // Why this decides the cluster: 200 MB available commit is only a REFUSAL when + // the pagefile cannot grow, which is what the volume's free space says. + it('reports swap-volume free space so low commit can be told from refused commit', async () => { + setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE) + setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 120, volume: 'C:' })) + await samplePreGoneSystemMemory(Date.now() - 5_000) + + const details = buildProcessGoneCrashDetails({ processType: 'renderer' }, 'renderer') + + expect(details.systemMemoryPreGoneSwapVolumeFreeMB).toBe(120) + // Which volume was measured: Windows only names the DEFAULT pagefile drive. + expect(details.systemMemoryPreGoneSwapVolume).toBe('C:') + }) + + it('omits swap-volume free space on Linux, where swap cannot grow into free disk', async () => { + // Linux swap is a fixed partition, a fixed-size swapfile, or zram; reporting + // root-fs free space next to SwapFreeMB 0 would read as headroom that is not there. + setSwapVolumeFreeSpaceReaderForTest(null) + + await expect(readSwapVolumeFreeSpace('linux')).resolves.toBeUndefined() + }) + + it('labels the reading with the pressure verdict the platform can actually give', () => { + // Windows available commit is only a REFUSAL when the pagefile cannot grow, + // which nothing here proves, so no label may read as that verdict. + setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE) + const windowsCommit = getSystemMemoryDetails('win32') + expect(windowsCommit.systemMemoryPressureSignal).toBe('available-commit-unqualified') + expect( + withSwapVolumeFreeSpace(windowsCommit, { freeMB: 120, volume: 'C:' }, 'win32') + .systemMemoryPressureSignal + ).toBe('available-commit-volume-cotimed') + // A volume number from a different moment describes a different machine. + expect( + withSwapVolumeFreeSpace(windowsCommit, { freeMB: 120, volume: 'C:' }, 'win32', false) + .systemMemoryPressureSignal + ).toBe('available-commit-unqualified') + + setSystemMemoryInfoReaderForTest(() => ({ total: 16_000 * 1024, free: 400 * 1024 })) + expect(getSystemMemoryDetails('linux').systemMemoryPressureSignal).toBe('none') + + setSystemMemoryInfoReaderForTest(() => ({ total: 16_000 * 1024, available: 900 * 1024 })) + expect(getSystemMemoryDetails('linux').systemMemoryPressureSignal).toBe('mem-available') + + // darwin free/fileBacked/purgeable answer reclaimability, never pressure. + setSystemMemoryInfoReaderForTest(() => ({ + total: 16_000 * 1024, + free: 272 * 1024, + fileBacked: 2_694 * 1024, + purgeable: 0 + })) + expect(getSystemMemoryDetails('darwin').systemMemoryPressureSignal).toBe('none') + }) + + // Why this and not the volume number: the branch's own repro needed a pagefile + // that CANNOT grow to kill anything, and neither the pagefile maximum nor its + // drive is readable here — `swapVolumeAnchor` measures SystemRoot's volume, + // which a relocated pagefile does not live on. + it('never reads free disk as proof the pagefile could have grown', () => { + setSystemMemoryInfoReaderForTest(() => FIXED_PAGEFILE_UNDER_PRESSURE) + const fixedPagefile = withSwapVolumeFreeSpace( + getSystemMemoryDetails('win32'), + { freeMB: 812_000, volume: 'C:' }, + 'win32' + ) + // 180 MB of commit beside 812 GB of free disk: co-timed, and still not a + // verdict — reading it as "the pagefile had room" is the opposite conclusion. + expect(fixedPagefile.systemMemoryPressureSignal).toBe('available-commit-volume-cotimed') + + // The one decisive win32 case: commit limit at or below RAM means there is + // no pagefile behind it, so the floor cannot heal however empty the disk is. + setSystemMemoryInfoReaderForTest(() => NO_PAGEFILE_UNDER_PRESSURE) + expect( + withSwapVolumeFreeSpace( + getSystemMemoryDetails('win32'), + { freeMB: 812_000, volume: 'C:' }, + 'win32' + ).systemMemoryPressureSignal + ).toBe('available-commit-hard-capped') + }) + + // Why the verdict and not just the field: a statfs issued on a healthy host at + // t=0 that resolves 20 s into a commit storm prints "200 MB commit, 40 GB of + // pagefile headroom" — which reads as NOT a commit refusal, the opposite + // conclusion, under the branch's most confident label. + it('will not let a statfs that outlived its tick qualify the win32 commit verdict', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.useFakeTimers() + let resolveVolume: (value: SwapVolumeFreeSpace) => void = () => {} + try { + setSystemMemoryInfoReaderForTest(() => BEFORE_THE_STORM) + setSwapVolumeFreeSpaceReaderForTest( + () => + new Promise((resolve) => { + resolveVolume = resolve + }) + ) + void samplePreGoneSystemMemory(0) + + // The storm arrives; the in-flight latch makes every tick skip the merge, + // so the pending statfs is as old as the tick that STARTED it. + setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE) + await samplePreGoneSystemMemory(10_000) + await samplePreGoneSystemMemory(20_000) + + resolveVolume({ freeMB: 40_000, volume: 'C:' }) + await vi.advanceTimersByTimeAsync(0) + + vi.setSystemTime(20_000) + const stale = buildProcessGoneCrashDetails({}, 'renderer') + expect(stale.systemMemoryPreGoneSwapFreeMB).toBe(200) + // The pre-storm volume number still ships — but carrying its own age, and + // without promoting the verdict the analyst reads. + expect(stale.systemMemoryPreGoneSwapVolumeFreeMB).toBe(40_000) + expect(stale.systemMemoryPreGoneSampleAgeMs).toBe(0) + expect(stale.systemMemoryPreGoneSwapVolumeAgeMs).toBe(20_000) + expect(stale.systemMemoryPreGonePressureSignal).toBe('available-commit-unqualified') + + // The next tick's statfs answers on its own tick, so it qualifies again. + setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 900, volume: 'C:' })) + await samplePreGoneSystemMemory(30_000) + vi.setSystemTime(30_000) + const fresh = buildProcessGoneCrashDetails({}, 'renderer') + expect(fresh.systemMemoryPreGoneSwapVolumeFreeMB).toBe(900) + expect(fresh.systemMemoryPreGoneSwapVolumeAgeMs).toBe(0) + expect(fresh.systemMemoryPreGonePressureSignal).toBe('available-commit-volume-cotimed') + } finally { + vi.useRealTimers() + Object.defineProperty(process, 'platform', platform) + } + }) + + // Round 5: sample identity alone could not see these ticks. A host read that + // returns nothing leaves the sample object in place, so `sample === issuedFor` + // still held 25 s and two ticks later and the statfs re-qualified the verdict. + it('will not let ticks with a failed host read pass a stale statfs off as co-timed', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.useFakeTimers() + let resolveVolume: (value: SwapVolumeFreeSpace) => void = () => {} + try { + setSystemMemoryInfoReaderForTest(() => BEFORE_THE_STORM) + setSwapVolumeFreeSpaceReaderForTest( + () => + new Promise((resolve) => { + resolveVolume = resolve + }) + ) + void samplePreGoneSystemMemory(0) + + // GlobalMemoryStatusEx starts failing: the sample is neither replaced nor erased. + setSystemMemoryInfoReaderForTest(() => null) + await samplePreGoneSystemMemory(10_000) + await samplePreGoneSystemMemory(20_000) + + resolveVolume({ freeMB: 40_000, volume: 'C:' }) + await vi.advanceTimersByTimeAsync(0) + + vi.setSystemTime(25_000) + const details = buildProcessGoneCrashDetails({}, 'renderer') + // 25 s of lag: the label must not say co-timed beside that age. + expect(details.systemMemoryPreGoneSwapVolumeAgeMs).toBe(25_000) + expect(details.systemMemoryPreGonePressureSignal).toBe('available-commit-unqualified') + } finally { + vi.useRealTimers() + Object.defineProperty(process, 'platform', platform) + } + }) + + it("arms the host sampler on its own unref'd 10 s timer, not the metric sweep's", async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + const readHostMemory = vi.fn(() => UNDER_COMMIT_PRESSURE) + setSystemMemoryInfoReaderForTest(readHostMemory) + setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 120, volume: 'C:' })) + const setIntervalSpy = vi.spyOn(globalThis, 'setInterval') + try { + startPreGoneCrashSampling() + + // Literal millisecond values: asserting the constants against themselves + // would let a cadence regression through, and 37 s of staleness is the bug. + expect(setIntervalSpy.mock.calls.map(([, ms]) => ms)).toEqual([60_000, 10_000]) + for (const { value } of setIntervalSpy.mock.results) { + expect((value as NodeJS.Timeout).hasRef()).toBe(false) + } + expect(readHostMemory).toHaveBeenCalledTimes(1) + + readHostMemory.mockReturnValue(AFTER_THE_CORPSE_RELEASED) + await vi.advanceTimersByTimeAsync(10_000) + // One host tick, no extra metric sweep: the two samplers run independently. + expect(readHostMemory).toHaveBeenCalledTimes(2) + expect(appMetricsMock).toHaveBeenCalledTimes(1) + + const details = buildProcessGoneCrashDetails({}, 'renderer') + expect(details.systemMemoryPreGoneSampleAgeMs).toBe(0) + expect(details.systemMemoryPreGoneSwapFreeMB).toBe(2_900) + } finally { + setIntervalSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('commits the host reading without waiting on the swap-volume statfs', async () => { + // Why: statfs is slowest during the paging storm this sampler targets, and + // a hung volume must not stall or silently skip host sampling. + setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE) + setSwapVolumeFreeSpaceReaderForTest(() => new Promise(() => {})) + + void samplePreGoneSystemMemory(Date.now() - 5_000) + expect(buildProcessGoneCrashDetails({}, 'renderer').systemMemoryPreGoneSwapFreeMB).toBe(200) + + // A second tick still refreshes the reading while that statfs hangs. + setSystemMemoryInfoReaderForTest(() => AFTER_THE_CORPSE_RELEASED) + void samplePreGoneSystemMemory(Date.now()) + expect(buildProcessGoneCrashDetails({}, 'renderer').systemMemoryPreGoneSwapFreeMB).toBe(2_900) + }) + + it('publishes no pre-gone host keys when every memory field failed to read', async () => { + // Why not "no keys at all": the reading always carries its signal label, so a + // committed empty one would ship an age and a volume number with no memory + // numbers beside them — a disk-free figure standing in for a host reading. + setSystemMemoryInfoReaderForTest(() => ({ total: Number.NaN, free: undefined })) + await samplePreGoneSystemMemory(Date.now()) + + const details = buildProcessGoneCrashDetails({}, 'renderer') + + expect(Object.keys(details).filter((key) => key.startsWith('systemMemoryPreGone'))).toEqual([]) + }) + + it('carries the last volume reading forward, aged, instead of dropping it', async () => { + vi.useFakeTimers() + try { + setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE) + setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 42, volume: 'C:' })) + await samplePreGoneSystemMemory(0) + + // The next tick's statfs hangs — during the paging storm this targets, that + // is the normal case — so the tick has no volume reading of its own, and + // the sample that replaces the last one would otherwise drop the field. + setSwapVolumeFreeSpaceReaderForTest(() => new Promise(() => {})) + void samplePreGoneSystemMemory(10_000) + vi.setSystemTime(10_000) + + const details = buildProcessGoneCrashDetails({}, 'renderer') + expect(details.systemMemoryPreGoneSwapVolumeFreeMB).toBe(42) + expect(details.systemMemoryPreGoneSwapVolume).toBe('C:') + expect(details.systemMemoryPreGoneSampleAgeMs).toBe(0) + // Carried, not re-read: it ships at its real age, never as a fresh number. + expect(details.systemMemoryPreGoneSwapVolumeAgeMs).toBe(10_000) + } finally { + vi.useRealTimers() + } + }) + + it('keeps a failed host read from erasing the process-metric sample', async () => { + samplePreGoneProcessMetrics(Date.now() - 5_000) + setSystemMemoryInfoReaderForTest(() => { + throw new Error('getSystemMemoryInfo unavailable') + }) + await samplePreGoneSystemMemory(Date.now() - 5_000) + setSystemMemoryInfoReaderForTest(null) + + const details = buildProcessGoneCrashDetails({ processType: 'renderer' }, 'renderer') + + expect(details.processMetricsPreGoneRendererWorkingSetMB).toBe(400) + expect(Object.keys(details).filter((key) => key.startsWith('systemMemoryPreGone'))).toEqual([]) + }) +}) diff --git a/src/main/crash-reporting/pre-gone-host-memory.ts b/src/main/crash-reporting/pre-gone-host-memory.ts new file mode 100644 index 00000000000..0db56796750 --- /dev/null +++ b/src/main/crash-reporting/pre-gone-host-memory.ts @@ -0,0 +1,164 @@ +import type { CrashReportDetailValue } from '../../shared/crash-reporting' +import { readSwapVolumeFreeSpace } from './swap-volume-free-space' +import { + getSystemMemoryDetails, + SYSTEM_MEMORY_KEY_PREFIX, + withSwapVolumeFreeSpace +} from './system-memory-details' + +// ─── Pre-gone host memory sampling ────────────────────────────────── +// Why sample at all: the gone-time host read lands after the corpse released +// its pages, so it reports a healthier machine than the one that refused the +// allocation. +// Why 10 s and not the 60 s process-metrics cadence: at 60 s, four of five +// field OOMs carried a ~37 s old host reading — far too stale to see a +// transient commit refusal. A refusal shorter than the interval stays +// invisible; no cadence fixes that. + +export const PRE_GONE_SYSTEM_MEMORY_SAMPLE_INTERVAL_MS = 10_000 + +type CrashReportDetails = Record + +type PreGoneSystemMemorySample = { + details: CrashReportDetails + sampledAtMs: number + /** Tick that ISSUED the statfs now merged in — never the tick it resolved on. */ + swapVolumeSampledAtMs?: number +} + +let preGoneSample: PreGoneSystemMemorySample | null = null +let preGoneTimer: ReturnType | null = null +let swapVolumeReadInFlight = false +let samplingGeneration = 0 +let sampleTick = 0 + +const PRESSURE_SIGNAL_KEY = `${SYSTEM_MEMORY_KEY_PREFIX}PressureSignal` + +/** + * Carries the last volume reading onto the sample that replaces its own. + * + * Why: a statfs slower than one tick would otherwise make the field vanish from + * the reports it exists for — the next tick replaces the sample wholesale, and + * the in-flight latch keeps intervening ticks from merging anything. It ships + * with its own (now larger) age and, not being co-timed, never names the label. + */ +function withCarriedSwapVolume(sample: PreGoneSystemMemorySample): PreGoneSystemMemorySample { + const previous = preGoneSample + if (!previous || previous.swapVolumeSampledAtMs === undefined) { + return sample + } + const freeMB = previous.details[`${SYSTEM_MEMORY_KEY_PREFIX}SwapVolumeFreeMB`] + const volume = previous.details[`${SYSTEM_MEMORY_KEY_PREFIX}SwapVolume`] + if (typeof freeMB !== 'number' || typeof volume !== 'string') { + return sample + } + return { + ...sample, + details: withSwapVolumeFreeSpace(sample.details, { freeMB, volume }, process.platform, false), + swapVolumeSampledAtMs: previous.swapVolumeSampledAtMs + } +} + +function commitHostMemorySample(nowMs: number): boolean { + try { + const details = getSystemMemoryDetails() + // Why not `length === 0`: the signal label is appended unconditionally, so a + // reading that resolved no memory field at all still arrives with one key. + if (!Object.keys(details).some((key) => key !== PRESSURE_SIGNAL_KEY)) { + return false + } + preGoneSample = withCarriedSwapVolume({ details, sampledAtMs: nowMs }) + return true + } catch { + // Why: a failed read must not erase the previous good sample. + return false + } +} + +async function mergeSwapVolumeFreeSpace(issuedOnTick: number): Promise { + if (swapVolumeReadInFlight) { + return + } + swapVolumeReadInFlight = true + const generation = samplingGeneration + const issuedFor = preGoneSample + try { + const volume = await readSwapVolumeFreeSpace() + if (volume && preGoneSample && generation === samplingGeneration) { + // Why only its own tick qualifies: a statfs that outlived its tick carries a + // pre-storm volume number, and the latch makes that lag unbounded. It still + // ships beside its age, but it may not decide the verdict. + // Why the tick counter and not sample identity: a tick whose host read fails + // leaves the sample object in place, so identity alone reads as co-timed. + const coTimed = issuedOnTick === sampleTick + preGoneSample = { + ...preGoneSample, + details: withSwapVolumeFreeSpace(preGoneSample.details, volume, process.platform, coTimed), + swapVolumeSampledAtMs: issuedFor?.sampledAtMs + } + } + } catch { + // Why: the memory reading is already committed and stands on its own. + } finally { + swapVolumeReadInFlight = false + } +} + +export async function samplePreGoneSystemMemory(nowMs: number = Date.now()): Promise { + // Why commit before awaiting: the volume read is a statfs, and under the very + // paging storm this targets it is slowest — it must never delay, or (via an + // in-flight latch) skip, the cheap synchronous host reading. + const tick = ++sampleTick + if (!commitHostMemorySample(nowMs)) { + return + } + await mergeSwapVolumeFreeSpace(tick) +} + +export function startPreGoneSystemMemorySampling( + intervalMs: number = PRE_GONE_SYSTEM_MEMORY_SAMPLE_INTERVAL_MS +): void { + if (preGoneTimer) { + return + } + void samplePreGoneSystemMemory() + preGoneTimer = setInterval(() => void samplePreGoneSystemMemory(), intervalMs) + preGoneTimer.unref?.() +} + +export function resetPreGoneSystemMemorySamplingForTest(): void { + if (preGoneTimer) { + clearInterval(preGoneTimer) + } + preGoneTimer = null + preGoneSample = null + swapVolumeReadInFlight = false + // Why bump: an already-awaited volume read must not repopulate a reset sample. + samplingGeneration += 1 +} + +/** Keyed as `systemMemoryPreGone*` so a scan over the `systemMemory` family sees both reads. */ +export function preGoneSystemMemoryDetails(nowMs: number): CrashReportDetails { + if (!preGoneSample) { + return {} + } + const details: CrashReportDetails = { + [`${SYSTEM_MEMORY_KEY_PREFIX}PreGoneSampleAgeMs`]: Math.max( + 0, + nowMs - preGoneSample.sampledAtMs + ) + } + // Why its own age: the volume read resolves out of band, so it can be older + // than the memory reading printed beside it, and that gap must be readable. + if (preGoneSample.swapVolumeSampledAtMs !== undefined) { + details[`${SYSTEM_MEMORY_KEY_PREFIX}PreGoneSwapVolumeAgeMs`] = Math.max( + 0, + nowMs - preGoneSample.swapVolumeSampledAtMs + ) + } + for (const [key, value] of Object.entries(preGoneSample.details)) { + details[`${SYSTEM_MEMORY_KEY_PREFIX}PreGone${key.slice(SYSTEM_MEMORY_KEY_PREFIX.length)}`] = + value + } + return details +} diff --git a/src/main/crash-reporting/process-gone-diagnostics.test.ts b/src/main/crash-reporting/process-gone-diagnostics.test.ts index e31645865d8..6a6ec410733 100644 --- a/src/main/crash-reporting/process-gone-diagnostics.test.ts +++ b/src/main/crash-reporting/process-gone-diagnostics.test.ts @@ -2,11 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { buildProcessGoneCrashDetails, collectProcessGoneMetricDetails, - resetPreGoneProcessMetricsSamplingForTest, + resetPreGoneCrashSamplingForTest, samplePreGoneProcessMetrics, - startPreGoneProcessMetricsSampling + startPreGoneCrashSampling } from './process-gone-diagnostics' -import { setSystemMemoryInfoReaderForTest } from './gone-time-system-memory' +import { setSystemMemoryInfoReaderForTest } from './system-memory-details' type MetricFixture = { pid?: number @@ -27,7 +27,7 @@ vi.mock('electron', () => ({ describe('process gone diagnostics', () => { beforeEach(() => { - resetPreGoneProcessMetricsSamplingForTest() + resetPreGoneCrashSamplingForTest() setSystemMemoryInfoReaderForTest(null) }) @@ -141,8 +141,8 @@ describe('process gone diagnostics', () => { appMetricsMock.mockReturnValue([ { pid: 30, type: 'Tab', memory: { workingSetSize: 1024 * 100 } } ]) - startPreGoneProcessMetricsSampling(1_000) - startPreGoneProcessMetricsSampling(1_000) + startPreGoneCrashSampling(1_000) + startPreGoneCrashSampling(1_000) // A crash inside the first interval already has a sample to draw from. expect(buildProcessGoneCrashDetails({}, 'renderer')).toMatchObject({ @@ -582,12 +582,12 @@ describe('process gone diagnostics', () => { it("arms an unref'd interval so sampling never holds the event loop open", () => { const setIntervalSpy = vi.spyOn(globalThis, 'setInterval') try { - startPreGoneProcessMetricsSampling(60_000) + startPreGoneCrashSampling(60_000) const timer = setIntervalSpy.mock.results[0]?.value as NodeJS.Timeout expect(timer.hasRef()).toBe(false) } finally { setIntervalSpy.mockRestore() - resetPreGoneProcessMetricsSamplingForTest() + resetPreGoneCrashSamplingForTest() } }) @@ -641,7 +641,7 @@ describe('process gone diagnostics', () => { expect(details.systemMemoryTotalMB).toBe(16_384) }) - it('samples system memory at gone time but never into the pre-gone snapshot', () => { + it('samples system memory at gone time but never into the processMetrics family', () => { appMetricsMock.mockReturnValue([{ pid: 1, type: 'Browser', memory: { workingSetSize: 0 } }]) samplePreGoneProcessMetrics() setSystemMemoryInfoReaderForTest(() => ({ @@ -658,7 +658,9 @@ describe('process gone diagnostics', () => { systemMemorySwapTotalMB: 8_192, systemMemorySwapFreeMB: 40 }) - expect(details.processMetricsPreGoneSystemMemoryTotalMB).toBeUndefined() + expect( + Object.keys(details).filter((key) => key.startsWith('processMetricsPreGoneSystem')) + ).toEqual([]) }) it('leaves records unflagged when the crashed bucket is still populated', () => { diff --git a/src/main/crash-reporting/process-gone-diagnostics.ts b/src/main/crash-reporting/process-gone-diagnostics.ts index d0bb380a2b6..bf0d735a2c7 100644 --- a/src/main/crash-reporting/process-gone-diagnostics.ts +++ b/src/main/crash-reporting/process-gone-diagnostics.ts @@ -3,7 +3,13 @@ import { sanitizeCrashReportDetails, type CrashReportDetailValue } from '../../shared/crash-reporting' -import { getSystemMemoryAtGoneDetails, memoryKBFieldMB } from './gone-time-system-memory' +import { getSystemMemoryDetails, memoryKBFieldMB } from './system-memory-details' +import { + PRE_GONE_SYSTEM_MEMORY_SAMPLE_INTERVAL_MS, + preGoneSystemMemoryDetails, + resetPreGoneSystemMemorySamplingForTest, + startPreGoneSystemMemorySampling +} from './pre-gone-host-memory' type ProcessMetricLike = { pid?: unknown @@ -204,8 +210,9 @@ export function samplePreGoneProcessMetrics(nowMs: number = Date.now()): void { } } -export function startPreGoneProcessMetricsSampling( - intervalMs: number = PROCESS_METRICS_PRE_GONE_SAMPLE_INTERVAL_MS +export function startPreGoneCrashSampling( + intervalMs: number = PROCESS_METRICS_PRE_GONE_SAMPLE_INTERVAL_MS, + systemMemoryIntervalMs: number = PRE_GONE_SYSTEM_MEMORY_SAMPLE_INTERVAL_MS ): void { if (preGoneSampleTimer) { return @@ -213,14 +220,16 @@ export function startPreGoneProcessMetricsSampling( samplePreGoneProcessMetrics() preGoneSampleTimer = setInterval(() => samplePreGoneProcessMetrics(), intervalMs) preGoneSampleTimer.unref?.() + startPreGoneSystemMemorySampling(systemMemoryIntervalMs) } -export function resetPreGoneProcessMetricsSamplingForTest(): void { +export function resetPreGoneCrashSamplingForTest(): void { if (preGoneSampleTimer) { clearInterval(preGoneSampleTimer) } preGoneSampleTimer = null preGoneSample = null + resetPreGoneSystemMemorySamplingForTest() } const PROCESS_METRICS_KEY_PREFIX = 'processMetrics' @@ -271,7 +280,7 @@ export function buildProcessGoneCrashDetails( const crashDetails: CrashReportDetails = { ...sanitizedDetails, ...liveMetricDetails, - ...getSystemMemoryAtGoneDetails() + ...getSystemMemoryDetails() } // Why: with the crasher gone, Largest names a survivor — flag that so the // live buckets are read as "everyone else", not as the crashed process. @@ -290,8 +299,10 @@ export function buildProcessGoneCrashDetails( if (liveMetricDetails[crashedBucketCountKey] === 0 || sampledSameBucketProcessVanished) { crashDetails.processMetricsCrashedProcessAbsent = true } + const nowMs = Date.now() if (preGoneSample) { - Object.assign(crashDetails, preGoneSampleDetails(preGoneSample, Date.now())) + Object.assign(crashDetails, preGoneSampleDetails(preGoneSample, nowMs)) } + Object.assign(crashDetails, preGoneSystemMemoryDetails(nowMs)) return crashDetails } diff --git a/src/main/crash-reporting/swap-volume-free-space.ts b/src/main/crash-reporting/swap-volume-free-space.ts new file mode 100644 index 00000000000..3ad40b7629b --- /dev/null +++ b/src/main/crash-reporting/swap-volume-free-space.ts @@ -0,0 +1,67 @@ +import { statfs } from 'node:fs/promises' +import path from 'node:path' + +// Why: a system-managed Windows pagefile — and a macOS swapfile — only grows +// into free space on its own volume, so low available commit is a REFUSED +// allocation only when that volume is full too. Linux is excluded on purpose: +// its swap is a fixed partition, a fixed-size swapfile, or zram, none of which +// grow into root-fs free space, so the number would read as headroom that +// cannot exist. The measured volume ships alongside because Windows only names +// the DEFAULT pagefile drive; a relocated pagefile lives elsewhere. + +const BYTES_PER_MB = 1024 * 1024 + +export type SwapVolumeFreeSpace = { + freeMB: number + /** Which volume was measured, separator-trimmed so redaction sees no path. */ + volume: string +} + +type SwapVolumeFreeSpaceReader = ( + platform: NodeJS.Platform +) => Promise + +function swapVolumeAnchor(platform: NodeJS.Platform): string | undefined { + if (platform === 'win32') { + const anchor = process.env.SystemRoot || process.env.SystemDrive + return anchor ? path.parse(anchor).root || anchor : undefined + } + return platform === 'darwin' ? path.sep : undefined +} + +function volumeLabel(root: string): string { + const trimmed = root.replace(/[\\/]+$/, '') + return trimmed.length > 0 ? trimmed : root +} + +async function statfsSwapVolumeFreeSpace( + platform: NodeJS.Platform +): Promise { + const root = swapVolumeAnchor(platform) + if (!root) { + return undefined + } + try { + const stats = await statfs(root) + const bytes = Number(stats.bsize) * Number(stats.bavail) + return Number.isFinite(bytes) + ? { freeMB: Math.round(Math.max(0, bytes) / BYTES_PER_MB), volume: volumeLabel(root) } + : undefined + } catch { + return undefined + } +} + +let swapVolumeFreeSpaceReader: SwapVolumeFreeSpaceReader = statfsSwapVolumeFreeSpace + +export function setSwapVolumeFreeSpaceReaderForTest( + reader: SwapVolumeFreeSpaceReader | null +): void { + swapVolumeFreeSpaceReader = reader ?? statfsSwapVolumeFreeSpace +} + +export function readSwapVolumeFreeSpace( + platform: NodeJS.Platform = process.platform +): Promise { + return swapVolumeFreeSpaceReader(platform) +} diff --git a/src/main/crash-reporting/system-memory-details.ts b/src/main/crash-reporting/system-memory-details.ts new file mode 100644 index 00000000000..1f2cf556faa --- /dev/null +++ b/src/main/crash-reporting/system-memory-details.ts @@ -0,0 +1,161 @@ +import type { CrashReportDetailValue } from '../../shared/crash-reporting' +import type { SwapVolumeFreeSpace } from './swap-volume-free-space' + +// ─── Host system memory for crash reports ─────────────────────────── +// Why: the system outlives the crashed process, so this IS sampleable at +// process-gone — it separates "renderer grew huge" from "machine out of +// memory/commit", which the per-process buckets alone cannot. The gone-time +// caller reads AFTER the corpse returned its pages, so free/swapFree read +// healthier than at kill time; the pre-gone sampler carries a live reading past +// that. +// Every reading is labelled `systemMemoryPressureSignal` so no report can be +// read as a pressure verdict the platform never gave: +// win32 — swapFree is MEMORYSTATUSEX.ullAvailPageFile, i.e. available +// COMMIT, which pagefile growth can heal (a 127 MB commit floor healed to +// 2029 MB mid-hold on the win-lowspec repro, killing nothing). Free space on +// the swap volume does NOT establish that it could: a fixed-size or disabled +// pagefile grows into no amount of empty disk, its maximum is unreadable +// here (needs a registry read), and the measured volume is only the DEFAULT +// pagefile drive. So a co-timed volume reading is context beside the commit +// number — `available-commit-volume-cotimed` — never a verdict. The one +// decisive win32 case is a commit limit at or below RAM: no pagefile exists +// to grow, so the floor cannot heal (`available-commit-hard-capped`). +// linux — MemAvailable is the real signal; MemFree is not (it excludes page +// cache and other reclaimable memory). +// darwin — none. `free` stays low on healthy machines and +// fileBacked/purgeable are only a reclaimability proxy. The real signal +// needs `memory_pressure -Q`; Orca's reader for it +// (src/main/memory/host-memory.ts) is on-demand, and spawning a subprocess +// on a 10 s app-lifetime timer costs more than the gap it closes. + +type CrashReportDetails = Record + +export const SYSTEM_MEMORY_KEY_PREFIX = 'systemMemory' + +export function memoryKBFieldMB(value: unknown): number | undefined { + const kb = typeof value === 'number' && Number.isFinite(value) ? value : undefined + return kb === undefined ? undefined : Math.round(Math.max(0, kb) / 1024) +} + +type SystemMemoryInfoLike = { + total?: unknown + free?: unknown + available?: unknown + swapTotal?: unknown + swapFree?: unknown + fileBacked?: unknown + purgeable?: unknown +} + +type SystemMemoryInfoReader = () => SystemMemoryInfoLike | null + +/** How far this reading may be read as a "was the host under pressure" verdict. */ +export type SystemMemoryPressureSignal = + | 'available-commit-hard-capped' + | 'available-commit-volume-cotimed' + | 'available-commit-unqualified' + | 'mem-available' + | 'none' + +function readElectronSystemMemoryInfo(): SystemMemoryInfoLike | null { + const read = (process as NodeJS.Process & { getSystemMemoryInfo?: () => SystemMemoryInfoLike }) + .getSystemMemoryInfo + if (typeof read !== 'function') { + return null + } + try { + return read.call(process) + } catch { + return null + } +} + +let systemMemoryInfoReader: SystemMemoryInfoReader = readElectronSystemMemoryInfo + +export function setSystemMemoryInfoReaderForTest(reader: SystemMemoryInfoReader | null): void { + systemMemoryInfoReader = reader ?? readElectronSystemMemoryInfo +} + +function numericDetail(details: CrashReportDetails, suffix: string): number | undefined { + const value = details[`${SYSTEM_MEMORY_KEY_PREFIX}${suffix}`] + return typeof value === 'number' ? value : undefined +} + +/** Windows commit limit = RAM + pagefile, so a limit at or below RAM has no pagefile behind it. */ +function pagefileBacksCommit(details: CrashReportDetails): boolean | undefined { + const total = numericDetail(details, 'TotalMB') + const swapTotal = numericDetail(details, 'SwapTotalMB') + return total === undefined || swapTotal === undefined ? undefined : swapTotal > total +} + +function pressureSignal( + platform: NodeJS.Platform, + details: CrashReportDetails, + volumeCoTimed = true +): SystemMemoryPressureSignal { + if (platform === 'win32' && `${SYSTEM_MEMORY_KEY_PREFIX}SwapFreeMB` in details) { + if (pagefileBacksCommit(details) === false) { + return 'available-commit-hard-capped' + } + return volumeCoTimed && `${SYSTEM_MEMORY_KEY_PREFIX}SwapVolumeFreeMB` in details + ? 'available-commit-volume-cotimed' + : 'available-commit-unqualified' + } + if (platform === 'linux' && `${SYSTEM_MEMORY_KEY_PREFIX}AvailableMB` in details) { + return 'mem-available' + } + return 'none' +} + +export function getSystemMemoryDetails( + platform: NodeJS.Platform = process.platform +): CrashReportDetails { + const info = systemMemoryInfoReader() + if (!info) { + return {} + } + const details: CrashReportDetails = {} + const fields: readonly [keyof SystemMemoryInfoLike, string][] = [ + ['total', 'TotalMB'], + ['free', 'FreeMB'], + ['available', 'AvailableMB'], + ['swapTotal', 'SwapTotalMB'], + ['swapFree', 'SwapFreeMB'], + ['fileBacked', 'FileBackedMB'], + ['purgeable', 'PurgeableMB'] + ] + for (const [field, suffix] of fields) { + const mb = memoryKBFieldMB(info[field]) + if (mb !== undefined) { + details[`${SYSTEM_MEMORY_KEY_PREFIX}${suffix}`] = mb + } + } + details[`${SYSTEM_MEMORY_KEY_PREFIX}PressureSignal`] = pressureSignal(platform, details) + return details +} + +/** + * Merges the statfs-derived volume datum, which needs an await and so is only + * reachable from the periodic sampler, and relabels the reading it sits beside. + * + * `coTimed` false means the statfs outlived the tick that issued it, so this + * volume number and the commit number beside it describe different moments — + * during a pagefile-growth storm that is exactly when they diverge, and a + * pre-storm 40 GB printed next to 200 MB of commit reads as "the pagefile had + * room", the opposite conclusion. The datum still ships (with its own age), but + * only a co-timed one is named in the label. + */ +export function withSwapVolumeFreeSpace( + details: CrashReportDetails, + volume: SwapVolumeFreeSpace, + platform: NodeJS.Platform = process.platform, + coTimed = true +): CrashReportDetails { + const merged: CrashReportDetails = { + ...details, + [`${SYSTEM_MEMORY_KEY_PREFIX}SwapVolumeFreeMB`]: volume.freeMB, + [`${SYSTEM_MEMORY_KEY_PREFIX}SwapVolume`]: volume.volume + } + merged[`${SYSTEM_MEMORY_KEY_PREFIX}PressureSignal`] = pressureSignal(platform, merged, coTimed) + return merged +} diff --git a/src/main/startup/main-process-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts index 75784a4c196..26b920652df 100644 --- a/src/main/startup/main-process-ready-runtime.ts +++ b/src/main/startup/main-process-ready-runtime.ts @@ -9,7 +9,7 @@ import { RpcDispatcher } from '../runtime/rpc/dispatcher' import { browserManager } from '../browser/browser-manager' import { configureBrowserClientPageAutomationRuntime } from '../browser/browser-client-page-automation-runtime' import { BrowserClientPageCommandError } from '../browser/browser-client-page-command-failure' -import { startPreGoneProcessMetricsSampling } from '../crash-reporting/process-gone-diagnostics' +import { startPreGoneCrashSampling } from '../crash-reporting/process-gone-diagnostics' import { recordProcessGoneCrash } from './main-window-lifecycle-flags' import { handleGpuChildCrash } from './gpu-lifecycle' import { isGpuFallbackCrashCandidate } from '../crash-reporting/gpu-crash-fallback-decision' @@ -130,9 +130,10 @@ export async function initializeReadyRuntimeServices(): Promise { console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error) ) } - // Why: process-gone metrics only see survivors; retain a recent whole-app - // snapshot for comparison in crash reports. - startPreGoneProcessMetricsSampling() + // Why: process-gone metrics only see survivors, and the gone-time host memory + // read lands after the corpse released its pages; both need a live pre-gone + // sample to compare against in crash reports. + startPreGoneCrashSampling() app.on('child-process-gone', (_event, details) => { recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, { name: details.name, diff --git a/src/main/startup/pre-gone-crash-sampling-wiring.test.ts b/src/main/startup/pre-gone-crash-sampling-wiring.test.ts new file mode 100644 index 00000000000..2a8e008c0b3 --- /dev/null +++ b/src/main/startup/pre-gone-crash-sampling-wiring.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Guards the one line that arms pre-gone crash sampling. + * + * That branch is pure instrumentation, so this line is the whole of its value in + * the shipped app: deleting it left all 691 tests across `src/main/crash-reporting/` + * and `src/main/startup/` green while every crash report silently lost its only + * host reading taken before the dying process returned its pages. + * + * Source-level because that is the property: the sampler is armed once inside the + * ready-phase composition, which has no runtime seam to assert against. + */ +describe('pre-gone crash sampling startup wiring', () => { + // Why normalize: the indent anchors below are `\n`-prefixed, and nothing pins + // src/**/*.ts to LF, so a CRLF Windows checkout would fail them spuriously. + const readSource = (name: string): string => + readFileSync(join(process.cwd(), 'src/main/startup', name), 'utf8').replace(/\r\n/g, '\n') + + const readyRuntimeSource = readSource('main-process-ready-runtime.ts') + const readySource = readSource('main-process-ready.ts') + + const READY_ENTRY = 'export async function initializeReadyRuntimeServices(' + // Why the entry's body and not the file: the call satisfies a whole-file grep + // just as well from a sibling export nothing calls, which arms nothing. + const readyRuntimeEntryBody = readyRuntimeSource + .slice(readyRuntimeSource.indexOf(READY_ENTRY) + READY_ENTRY.length) + .split('\nexport ')[0] + + it('arms the sampler unconditionally inside the function app readiness runs', () => { + expect(readyRuntimeSource).toContain( + "import { startPreGoneCrashSampling } from '../crash-reporting/process-gone-diagnostics'" + ) + expect(readyRuntimeSource).toContain(READY_ENTRY) + expect(readyRuntimeEntryBody.split('startPreGoneCrashSampling()').length - 1).toBe(1) + // Why pin the indent: the call also matches as the body of an added + // `if (...)` guard, which keeps every other assertion here true while the + // sampler silently stops arming on most startups. + expect(readyRuntimeEntryBody).toContain('\n startPreGoneCrashSampling()') + + // ...and that this really is the function app readiness runs. + expect(readySource).toContain( + "import { initializeReadyRuntimeServices } from './main-process-ready-runtime'" + ) + expect(readySource).toContain('\n await initializeReadyRuntimeServices()') + }) +}) From 2ee507d744b8f8abc61f91bd0c77563ee3bb5c79 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:22:09 -0700 Subject: [PATCH 11/58] fix(ssh): move Windows file writes off PowerShell 5.1 stdin onto sftp (#18596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ssh): move Windows file writes off PowerShell 5.1 stdin onto sftp #16432 was fixed by chunking writes to 32KB, on the belief that a `DefaultShell=cmd.exe` host caps one stdin at roughly 50KB. Re-measured on Windows 11 26200.9168 / OpenSSH_for_Windows_10.0p2, that premise is wrong in both directions, and the chunking does not fix the hang. The real constraint: a read on Windows PowerShell 5.1's redirected-stdin handle over a non-pty ssh exec can die permanently when it finds the stream momentarily empty, taking both the remaining data and the EOF with it. It is probabilistic per such read — not a size threshold, and not certain on the first one. Measured by swapping the copy loop for a counting reader: a 1.5s gap before any byte -> 0 bytes received, 6 of 6 1 byte, 1.5s gap, then 32767 -> exactly 1 byte 32768, 1.5s gap, then 32768 -> exactly 32768 a continuous 2MB -> 167936 / 270336 / 372736 Those three 2MB figures are one payload run three times under the same conditions, which is what rules out a threshold. Independently reproduced by a second harness where one 1.9MB counted read completed through 39 reads and another died after 11. A payload that fits one burst usually presents only one read that can find the stream empty, which is why 32KB mostly works — and it still failed 15 times in 120 under load, and 1 in 40 on a quiet host. Neither rate survives the 62 execs a 1.9MB file needs: even 2.5% compounds to about four uploads in five failing. No chunk size helps, because the defect is per blocking read, not per byte. Three controls on the same host, same DefaultShell, rule out both a size limit and cmd.exe: `findstr` took 2,016,000 bytes through one exec's stdin, sftp moved 1.9MB 5/5, and PowerShell 7 took 2MB in one exec. Windows writes now go over the sftp subsystem, whose batch script is read by the *local* client, so no remote process reads a pipe at all. PowerShell 7 is the fallback where sftp is unavailable, and Windows PowerShell 5.1 is last, still bounded, and now reports the host limitation and its remedy instead of a bare timeout. Measured on the same host, through this code: 1.9MB x20 all succeeded, hash-verified, median 315ms, against 0/6 before. 32KB x120 zero hangs, against 15/120. Also: - Stage under a unique name per attempt. An abandoned write leaves a remote process that may still hold the staging file, and losing contact is not evidence it died (docs/reference/ssh-execution-boundary.md), so a retry must not reuse a name its predecessor may own. Sweep is best-effort and never treated as proof of anything. - Create upload directories over sftp too; the JSON mkdir batch rode the same defective read. - Cover makeWindowsWriteFileCommand and the publish command against the 8000-char budget, which F11 flagged as untested. * fix(ssh): replace the staged Windows write atomically, and translate ssh -l Three review findings, all on the failure path that the success-path measurements say nothing about. CodeRabbit, Critical: the publish deleted the destination before moving the staged file onto it, so a failed move destroyed the user's existing file and left a window where a reader saw no file at all. That is worse than the truncated partial the staging discipline exists to prevent. Now File.Replace (Win32 ReplaceFile, atomic), falling back to a plain Move only when the destination is absent — and that race is safe, because a destination appearing in between makes Move throw with the staged file preserved. The exclusive branch already had it right: Move throwing on an existing destination is the exclusive contract. Append stays non-atomic and now says why. buildSshArgs can emit '-l ' for a config alias no Host block claims, and the translator threw on it. isSftpUnavailableError read that throw as 'this host cannot do sftp', so those hosts fell back to the defective PowerShell 5.1 path and had the refusal cached against them for 30 minutes, silently. '-l' now maps to '-o User=', with a test for the exact argument shape buildSshArgs produces in that case. CodeRabbit, minor: two assertions passed on an absent observation — an unmatched regex yields '' and every() is true of an empty list. Both now assert the positive form first, and the same audit was applied to the three other some()/every() assertions in the file. The temp-file test now asserts mode 0600 rather than only that the file is cleaned up. * fix(ssh): keep a path sftp cannot spell from becoming a verdict about the host Audit of isSftpUnavailableError, prompted by the '-l' gap having the same shape: a per-operation condition being written into a per-host cache that holds for 30 minutes. It had a second instance, and this one was mine. UnsupportedSftpPathError was classified as 'this host cannot do sftp', but it is thrown for a UNC or relative destination and for any path sftp's batch lexer cannot quote -- including a *local* filename containing a newline, which POSIX clients allow. One such file would have routed every later Windows write to that host down the defective PowerShell 5.1 path for the rest of the cache window. The host verdict is now only the errors that really are host-scoped: a refused subsystem, a client that will not start, and an untranslatable argument list. A path refusal falls back for that one write and leaves the cache alone, in both the file-write and directory-creation paths. Revert-tested. Removing the operation-scoped catch fails all three new tests, whether or not the predicate is also widened. Widening the predicate alone does not fail them, correctly: with the catch in place the predicate no longer gates that path, so keeping it narrow is defence-in-depth rather than the live mechanism. Flag audit at the same time: -F, -o, -T, -S, -p, -i, -J, -l and -- are now the complete set buildSshArgs can emit, and all are handled. * fix(ssh): make the atomic publish actually run, and unroll the mkdir batch Two runtime defects that only a real host could surface. Both were invisible to unit tests that assert the shape of the generated command string, because both are PowerShell rejecting an argument at execution time. File.Replace was passed a bare $null for destinationBackupFileName. PowerShell coerces $null to an empty string when binding a .NET string parameter, and Replace rejects that with 'The path is not of a legal form' -- so every create-mode publish failed. The Critical fix was inert as shipped. Now [NullString]::Value, which is the construct that exists for this. Measured on awin, same staging-file lock, opposite outcomes: old publish rc=1 destination MISSING <- prior contents destroyed new publish rc=1 destination PRESENT, sha 7f06b7e0... unchanged control, destination present, no lock rc=0 replaced exactly control, destination absent, no lock rc=0 Move fallback created it End-to-end through the real uploader afterwards: 1.9MB x15 all hashes exact, median 303ms; overwrite of an existing destination exact both times. Separately, the PowerShell mkdir fallback could not create a tree of more than one directory. '@($json | ConvertFrom-Json)' wraps the parsed array in another array, so the loop variable binds to the whole thing and [string] of it is the paths joined by spaces. It only ever worked for a one-element batch, where stringifying a single-element array happens to yield the element -- which is why no existing test caught it. Pre-existing on main; fixed here because this PR puts that command on the fallback tier and claims the ladder works. Both tiers now verified live against a three-directory tree. --- src/main/ssh/ssh-remote-powershell.ts | 20 +- ...-remote-windows-command-line-limit.test.ts | 44 ++ src/main/ssh/ssh-system-fallback.test.ts | 85 ++- .../ssh/system-ssh-file-binary-transfer.ts | 236 ++----- src/main/ssh/system-ssh-file-transfer.ts | 80 ++- src/main/ssh/system-ssh-sftp-args.test.ts | 141 ++++ src/main/ssh/system-ssh-sftp-args.ts | 95 +++ src/main/ssh/system-ssh-sftp-path.test.ts | 59 ++ src/main/ssh/system-ssh-sftp-path.ts | 46 ++ src/main/ssh/system-ssh-sftp-transfer.ts | 191 +++++ src/main/ssh/system-ssh-windows-file-write.ts | 138 ++++ .../ssh/system-ssh-windows-upload.test.ts | 659 ++++++++++++++---- ...tem-ssh-windows-write-capabilities.test.ts | 79 +++ .../system-ssh-windows-write-capabilities.ts | 52 ++ .../ssh/system-ssh-windows-write-strategy.ts | 329 +++++++++ 15 files changed, 1900 insertions(+), 354 deletions(-) create mode 100644 src/main/ssh/system-ssh-sftp-args.test.ts create mode 100644 src/main/ssh/system-ssh-sftp-args.ts create mode 100644 src/main/ssh/system-ssh-sftp-path.test.ts create mode 100644 src/main/ssh/system-ssh-sftp-path.ts create mode 100644 src/main/ssh/system-ssh-sftp-transfer.ts create mode 100644 src/main/ssh/system-ssh-windows-file-write.ts create mode 100644 src/main/ssh/system-ssh-windows-write-capabilities.test.ts create mode 100644 src/main/ssh/system-ssh-windows-write-capabilities.ts create mode 100644 src/main/ssh/system-ssh-windows-write-strategy.ts diff --git a/src/main/ssh/ssh-remote-powershell.ts b/src/main/ssh/ssh-remote-powershell.ts index 8c94fd3c483..420223ced29 100644 --- a/src/main/ssh/ssh-remote-powershell.ts +++ b/src/main/ssh/ssh-remote-powershell.ts @@ -11,14 +11,24 @@ export { // to leave room for the `/c` wrapper sshd adds before cmd.exe counts the line. const WINDOWS_REMOTE_COMMAND_LINE_BUDGET_CHARS = 8_000 -export function powerShellCommand(script: string): string { - const inline = encodedPowerShellCommand(script) +/** + * `pwsh.exe` is PowerShell 7. It is not present on a stock Windows install, so it is only ever + * chosen after a probe — but where it exists it reads a redirected stdin correctly, which Windows + * PowerShell 5.1 does not (see `system-ssh-file-binary-transfer.ts`). + */ +export type WindowsPowerShellExecutable = 'powershell.exe' | 'pwsh.exe' + +export function powerShellCommand( + script: string, + executable: WindowsPowerShellExecutable = 'powershell.exe' +): string { + const inline = encodedPowerShellCommand(script, executable) if (inline.length <= WINDOWS_REMOTE_COMMAND_LINE_BUDGET_CHARS) { return inline } // Why: these scripts are repetitive enough that gzip beats the UTF-16LE tax by // ~4x, which is the difference between a line cmd.exe runs and one it refuses. - const compressed = encodedPowerShellCommand(selfExtractingPowerShellScript(script)) + const compressed = encodedPowerShellCommand(selfExtractingPowerShellScript(script), executable) if (compressed.length > WINDOWS_REMOTE_COMMAND_LINE_BUDGET_CHARS) { throw new Error( `Remote Windows command needs ${compressed.length} characters; Orca budgets ${WINDOWS_REMOTE_COMMAND_LINE_BUDGET_CHARS} for a line sshd hands to cmd.exe, which itself refuses more than ${CMD_EXE_COMMAND_LINE_MAX_CHARS}.` @@ -27,8 +37,8 @@ export function powerShellCommand(script: string): string { return compressed } -function encodedPowerShellCommand(script: string): string { - return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShellCommand(script)}` +function encodedPowerShellCommand(script: string, executable: WindowsPowerShellExecutable): string { + return `${executable} -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShellCommand(script)}` } /** Orca-prefixed names so the payload can never shadow the bootstrap's own state. */ diff --git a/src/main/ssh/ssh-remote-windows-command-line-limit.test.ts b/src/main/ssh/ssh-remote-windows-command-line-limit.test.ts index c104078238e..fa34a8fbda7 100644 --- a/src/main/ssh/ssh-remote-windows-command-line-limit.test.ts +++ b/src/main/ssh/ssh-remote-windows-command-line-limit.test.ts @@ -4,6 +4,10 @@ import { CMD_EXE_COMMAND_LINE_MAX_CHARS } from '../providers/windows-shell-args' import { getRemoteHostPlatform } from './ssh-remote-platform' import { tryStealInstallLockCommand } from './ssh-relay-install-lock-commands' import { decodeRemotePowerShellScript, powerShellCommand } from './ssh-remote-powershell' +import { + makeWindowsPublishStagedFileCommand, + makeWindowsWriteFileCommand +} from './system-ssh-windows-file-write' import { cleanupOwnedRelayUploadStageCommand, promoteOwnedRelayUploadStageCommand, @@ -38,6 +42,17 @@ describe('Windows remote command line limit', () => { [ 'steal stale install lock', tryStealInstallLockCommand(windows, 'C:\\Users\\orca\\.orca-remote\\relay', 1_200) + ], + // F11 flagged these two as uncovered. They carry one path literal each, so they are the file + // commands whose length a caller can actually move. + ['write file', makeWindowsWriteFileCommand('C:\\Users\\orca\\.orca-remote\\relay.js')], + [ + 'publish staged file', + makeWindowsPublishStagedFileCommand( + 'C:\\Users\\orca\\.orca-remote\\relay.js.orca-partial-0123456789ab', + 'C:\\Users\\orca\\.orca-remote\\relay.js', + 'create' + ) ] ])('keeps the %s command inside what sshd\u2019s cmd.exe accepts', (_name, command) => { expect(command.length).toBeLessThanOrEqual(CMD_EXE_COMMAND_LINE_MAX_CHARS) @@ -76,3 +91,32 @@ describe('Windows remote command line limit', () => { ) }) }) + +/** + * F11 asked whether a pathological path could reach the budget, and what happens if it does. + * Measured: the inline encoding crosses 8000 at roughly 2500 high-entropy path characters — an + * order of magnitude past what Windows itself accepts — and the failure is a throw before any ssh + * is spawned, never a hang. + */ +describe('Windows file command budget headroom', () => { + it('absorbs a path far longer than Windows will accept', () => { + const deep = `C:\\Users\\orca\\${'segment\\'.repeat(30)}relay.js` + + expect(deep.length).toBeGreaterThan(260) + expect(makeWindowsWriteFileCommand(deep).length).toBeLessThanOrEqual( + CMD_EXE_COMMAND_LINE_MAX_CHARS + ) + }) + + it('throws rather than spawning a line cmd.exe would refuse', () => { + // Random segments so gzip cannot rescue it, which is the only way to reach the ceiling at all. + const incompressible = Array.from( + { length: 400 }, + (_unused, index) => `${index}-${Math.random().toString(36).slice(2)}` + ).join('\\') + + expect(() => makeWindowsWriteFileCommand(`C:\\${incompressible}\\f.bin`)).toThrow( + /Orca budgets 8000/ + ) + }) +}) diff --git a/src/main/ssh/ssh-system-fallback.test.ts b/src/main/ssh/ssh-system-fallback.test.ts index c366e899bf6..b477ad682ef 100644 --- a/src/main/ssh/ssh-system-fallback.test.ts +++ b/src/main/ssh/ssh-system-fallback.test.ts @@ -709,9 +709,13 @@ describe('spawnSystemSsh', () => { expect(args[standaloneControlIdx + 1]).toBe('none') }) - it('writes files to Windows system SSH targets with PowerShell stdin bytes', async () => { - const proc = createEventedProcess() - spawnMock.mockImplementation(() => closeOnceSpawned(proc)) + it('sends Windows file writes over sftp, not through a remote PowerShell stdin', async () => { + const spawned: EventedProcess[] = [] + spawnMock.mockImplementation(() => { + const proc = createEventedProcess() + spawned.push(proc) + return closeOnceSpawned(proc) + }) const hostPlatform = getRemoteHostPlatform('win32-x64') const promise = writeFileViaSystemSsh( @@ -722,16 +726,24 @@ describe('spawnSystemSsh', () => { ) await expect(promise).resolves.toBeUndefined() - const args = spawnMock.mock.calls[0][1] as string[] - const remoteCommand = args.at(-1) ?? '' - expect(remoteCommand).toContain('powershell.exe') - expect(remoteCommand).not.toContain('/bin/sh') - expect(proc.stdin.end).toHaveBeenCalledWith(Buffer.from('0.1.0', 'utf-8')) + // #16432, re-measured: Windows PowerShell 5.1 can lose a redirected stdin for good when a read + // finds it momentarily empty, so the bytes must not travel that way at all. + const batch = String(spawned[0]!.stdin.end.mock.calls[0]?.[0] ?? '') + expect(batch).toContain('put ') + expect(batch).toContain('/C:/Users/me/.orca-remote/relay/.version.orca-partial-') + const sftpArgs = spawnMock.mock.calls[0][1] as string[] + expect(sftpArgs).toContain('-b') + // The rename that publishes it reads the staged file, never a pipe. + const publish = (spawnMock.mock.calls[1][1] as string[]).at(-1) ?? '' + expect(publish).toContain('powershell.exe') + expect(decodePowerShellCommand(publish)).toContain( + '[System.IO.File]::Replace($staging, $path, [NullString]::Value)' + ) + expect(publish).not.toContain('/bin/sh') }) - it('writes binary buffers to Windows system SSH targets with CreateNew mode', async () => { - const proc = createEventedProcess() - spawnMock.mockImplementation(() => closeOnceSpawned(proc)) + it('enforces an exclusive Windows buffer write at the rename, where it is atomic', async () => { + spawnMock.mockImplementation(() => closeOnceSpawned(createEventedProcess())) const hostPlatform = getRemoteHostPlatform('win32-x64') const promise = writeBufferViaSystemSsh( @@ -742,12 +754,11 @@ describe('spawnSystemSsh', () => { ) await expect(promise).resolves.toBeUndefined() - const args = spawnMock.mock.calls[0][1] as string[] - const remoteCommand = args.at(-1) ?? '' - expect(remoteCommand).toContain('powershell.exe') - expect(decodePowerShellCommand(remoteCommand)).toContain('CreateNew') - expect(remoteCommand).not.toContain('/bin/sh') - expect(proc.stdin.end).toHaveBeenCalledWith(Buffer.from('png')) + const publish = decodePowerShellCommand((spawnMock.mock.calls[1][1] as string[]).at(-1) ?? '') + // `File::Move` raising on an existing destination is what carries the exclusive contract now; + // a `CreateNew` on the staged file would only refuse a leftover of our own. + expect(publish).toContain('[System.IO.File]::Move($staging, $path)') + expect(publish).not.toContain('[System.IO.File]::Delete($path)') }) it('downloads files from Windows system SSH targets with PowerShell stdout bytes', async () => { @@ -779,8 +790,7 @@ describe('spawnSystemSsh', () => { }) it('forces standalone SSH for Windows file writes when requested', async () => { - const proc = createEventedProcess() - spawnMock.mockImplementation(() => closeOnceSpawned(proc)) + spawnMock.mockImplementation(() => closeOnceSpawned(createEventedProcess())) const hostPlatform = getRemoteHostPlatform('win32-x64') const promise = writeFileViaSystemSsh( @@ -791,10 +801,14 @@ describe('spawnSystemSsh', () => { ) await expect(promise).resolves.toBeUndefined() - const args = spawnMock.mock.calls[0][1] as string[] - const standaloneControlIdx = args.indexOf('-S') + const sftpArgs = spawnMock.mock.calls[0][1] as string[] + // sftp's own `-S` names a program to run, so the same request has to be spelled as an option. + expect(sftpArgs).not.toContain('-S') + expect(sftpArgs).toContain('ControlPath=none') + const publishArgs = spawnMock.mock.calls[1][1] as string[] + const standaloneControlIdx = publishArgs.indexOf('-S') expect(standaloneControlIdx).toBeGreaterThan(-1) - expect(args[standaloneControlIdx + 1]).toBe('none') + expect(publishArgs[standaloneControlIdx + 1]).toBe('none') }) it('uploads a Windows directory as a mkdir batch plus per-file writes, never one blob', async () => { @@ -819,19 +833,20 @@ describe('spawnSystemSsh', () => { rmSync(localDir, { recursive: true, force: true }) } + // #16432: directories first, then the file — but both over sftp now, so the only PowerShell + // left is the rename that publishes the staged file, which reads a file rather than a pipe. + const mkdirBatch = String(spawned[0]!.stdin.end.mock.calls[0]?.[0] ?? '') + expect(mkdirBatch).toBe('-mkdir "/C:/Users/me/.orca-remote/relay"\n') + const putBatch = String(spawned[1]!.stdin.end.mock.calls[0]?.[0] ?? '') + expect(putBatch).toContain('put ') + expect(putBatch).toContain('/C:/Users/me/.orca-remote/relay/relay.js.orca-partial-') const commands = spawnMock.mock.calls.map((call) => (call[1] as string[]).at(-1) ?? '') - // #16432: directories first (metadata only), then the file bytes on their own stdin. One batch - // meant base64-ing the whole bundle into a single PowerShell string, which the remote never read. - expect(commands).toHaveLength(2) - expect(commands.every((command) => command.includes('powershell.exe'))).toBe(true) expect(commands.every((command) => !command.includes('/bin/sh'))).toBe(true) expect(commands.join('\n')).not.toContain('tar -xzf') - expect(JSON.parse(spawned[0].stdin.end.mock.calls[0]?.[0] as string)).toEqual([ - 'C:/Users/me/.orca-remote/relay' - ]) - expect(Buffer.from(spawned[1].stdin.end.mock.calls[0]?.[0] as Buffer).toString('utf-8')).toBe( - 'console.log("relay")' - ) + // Nothing base64s the bundle into one PowerShell string any more, and nothing reads one. + expect( + commands.some((command) => decodePowerShellCommand(command).includes('OpenStandardInput')) + ).toBe(false) }) it('forces standalone SSH for Windows upload packages when requested', async () => { @@ -855,9 +870,9 @@ describe('spawnSystemSsh', () => { } const args = spawnMock.mock.calls[0][1] as string[] - const standaloneControlIdx = args.indexOf('-S') - expect(standaloneControlIdx).toBeGreaterThan(-1) - expect(args[standaloneControlIdx + 1]).toBe('none') + // The first spawn is the sftp client, whose own `-S` names a program to run. + expect(args).not.toContain('-S') + expect(args).toContain('ControlPath=none') }) it('throws when no system ssh is found', () => { diff --git a/src/main/ssh/system-ssh-file-binary-transfer.ts b/src/main/ssh/system-ssh-file-binary-transfer.ts index b0c5b662ed1..149d10dfbd3 100644 --- a/src/main/ssh/system-ssh-file-binary-transfer.ts +++ b/src/main/ssh/system-ssh-file-binary-transfer.ts @@ -1,5 +1,7 @@ import { constants, createWriteStream } from 'node:fs' -import { lstat, open } from 'node:fs/promises' +import { lstat, mkdtemp, open, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type { Writable } from 'node:stream' import { pipeline } from 'node:stream/promises' import type { SshTarget } from '../../shared/ssh-types' @@ -16,6 +18,16 @@ import { throwIfAborted, waitForChannelClose } from './system-ssh-operation-lifecycle' +import { + writeWindowsRemoteFile, + type WindowsWriteSource +} from './system-ssh-windows-write-strategy' + +export { + WINDOWS_STDIN_WRITE_CHUNK_BYTES, + WINDOWS_STDIN_WRITE_TIMEOUT_MS +} from './system-ssh-windows-write-strategy' +export { WINDOWS_STAGED_WRITE_SUFFIX } from './system-ssh-windows-file-write' type SystemSshOperationOptions = SystemSshBuildArgsOptions & { signal?: AbortSignal @@ -74,13 +86,16 @@ export async function writeBufferViaSystemSsh( ): Promise { throwIfAborted(options?.signal) if (options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform)) { - await writeWindowsBytesViaSystemSsh( + await writeWindowsRemoteFile( target, remotePath, - contents.length, - (offset, maxBytes) => - Promise.resolve(contents.subarray(offset, Math.min(offset + maxBytes, contents.length))), - options + { + totalBytes: contents.length, + readChunk: (offset, maxBytes) => + Promise.resolve(contents.subarray(offset, Math.min(offset + maxBytes, contents.length))), + withLocalFile: (send) => withTemporaryLocalFile(contents, send) + }, + options ?? {} ) return } @@ -127,20 +142,19 @@ export async function uploadFileViaSystemSsh( throwIfAborted(options?.signal) if (options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform)) { - // #16432: a Windows host cannot take a whole file through one stdin, however the local side - // paces it — see WINDOWS_STDIN_WRITE_CHUNK_BYTES. This is the path that carries the large - // files, so it is the one that has to be chunked and bounded. - await writeWindowsBytesViaSystemSsh( - target, - remotePath, - openedStat.size, - async (offset, maxBytes) => { + // This is the path that carries the large files, so it is the one the transport choice is + // made for; see the #16432 note below. + const source: WindowsWriteSource = { + totalBytes: openedStat.size, + readChunk: async (offset, maxBytes) => { const buffer = Buffer.allocUnsafe(Math.min(maxBytes, openedStat.size - offset)) const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset) return buffer.subarray(0, bytesRead) }, - options - ) + // The verified local file is already exactly the payload, so sftp sends it as is. + withLocalFile: (send) => send(localPath) + } + await writeWindowsRemoteFile(target, remotePath, source, options ?? {}) return } @@ -173,158 +187,56 @@ export async function uploadFileViaSystemSsh( } /** - * #16432: Windows PowerShell 5.1 stops draining a redirected stdin over a non-pty ssh exec - * somewhere between 50KB and 1MB, depending on the host's `DefaultShell`, and it hangs rather than - * failing. The reporter measured that on both constructs he tried — `[Console]::In.ReadToEnd()` and - * `new IO.StreamReader([Console]::OpenStandardInput())`, the latter reading incrementally, which is - * why the limit cannot be attributed to materializing the payload. `Stream.CopyTo` reads the same - * `[Console]::OpenStandardInput()` object with the same incremental `Read` loop, so nothing in it - * escapes that limit either: no single write may exceed what one stdin is known to carry. + * #16432, re-measured: the constraint is not a size limit, and it is not cmd.exe's. * - * 32KB is an order of magnitude under the low end of the measured range, and under 50KB, which the - * reporter measured succeeding against a stream reader on the worse of the two `DefaultShell` - * settings. - */ -export const WINDOWS_STDIN_WRITE_CHUNK_BYTES = 32 * 1024 - -/** No Windows stdin write should ever outlive this; a wedged PowerShell never closes on its own. */ -export const WINDOWS_STDIN_WRITE_TIMEOUT_MS = 60_000 - -/** Suffix for the path a multi-exec Windows write lands on before it is published by rename. */ -export const WINDOWS_STAGED_WRITE_SUFFIX = '.orca-partial' - -/** - * Splits one logical Windows write into stdin-sized execs. + * A read on Windows PowerShell 5.1's redirected-stdin handle over a non-pty ssh exec can die + * permanently when it finds the stream momentarily empty: no further bytes arrive, and no EOF ever + * does. It is probabilistic per such read — not a size threshold, and not certain on the first one. + * Measured on Windows 11 26200.9168 / OpenSSH_for_Windows_10.0p2 with `DefaultShell = cmd.exe`, by + * replacing the copy loop with a counting reader: * - * A write that needs more than one exec cannot land on the destination directly: a chunk failing - * mid-file would leave a truncated artifact under the real name with nothing marking it incomplete, - * and the retry would then meet its own leftovers — under `exclusive` the retry's `CreateNew` fails - * on them. Multi-exec creates therefore land on a staging path and are published by a rename, which - * is also where `exclusive` is enforced: once, at the destination, instead of smeared across the - * first chunk. A caller-requested append cannot be staged without reading the remote file back, so - * it keeps writing straight through, as its own protocol already implies. + * - a 1.5s gap before any byte, which forces the first read to find nothing -> 0 bytes, 6 of 6 + * - one byte, a 1.5s gap, then 32767 more -> exactly 1 byte, then nothing + * - 32768, a 1.5s gap, then 32768 more -> exactly 32768, then nothing + * - a continuous 2MB -> 167936 / 270336 / 372736, then nothing + * + * Those three 2MB death points are one payload run three times under the same conditions, which is + * what rules out a threshold: a stream that died at a fixed point would not vary by 2x. Independently reproduced by + * a second harness, where one 1.9MB counted read survived 39 reads to completion and another died + * after 11 — same construct, same payload. + * + * A payload small enough to arrive in one burst usually presents only one read that can find the + * stream empty (the one waiting for EOF), which is why 32KB mostly works: it still failed 15 times + * in 120 with the host under load, and 1 in 40 on a quiet one. Neither rate is survivable across + * the 62 execs a 1.9MB file needs — even 2.5% compounds to roughly four uploads in five failing — + * and no chunk size helps, because the client does not control whether its bytes arrive together. + * + * The same host, same `DefaultShell`, same connection pattern contradicts every size-limit reading: + * `findstr` took 2,016,000 bytes through one exec's stdin, and PowerShell 7 took 2MB. So cmd.exe is + * not the ceiling and neither is ~50KB. Writes now go over sftp, which moves the whole payload + * without any remote process reading a pipe; see `system-ssh-windows-write-strategy.ts` for the + * fallback order. + * + * Successes are never partial. Across every run in both harnesses a failed write hung; not one + * produced a short file, so this defect cannot silently truncate an upload. */ -async function writeWindowsBytesViaSystemSsh( - target: SshTarget, - remotePath: string, - totalBytes: number, - readChunk: (offset: number, maxBytes: number) => Promise, - options: SystemSshWriteBufferOptions -): Promise { - throwIfAborted(options.signal) - const staged = !options.append && totalBytes > WINDOWS_STDIN_WRITE_CHUNK_BYTES - const writePath = staged ? `${remotePath}${WINDOWS_STAGED_WRITE_SUFFIX}` : remotePath - let offset = 0 - // An empty write still has to run: it is what creates (or truncates) the file. - do { - const chunk = await readChunk(offset, WINDOWS_STDIN_WRITE_CHUNK_BYTES) - if (chunk.length === 0 && offset < totalBytes) { - throw new Error(`Source ran short during upload of ${remotePath}`) - } - await writeWindowsChunkViaSystemSsh( - target, - writePath, - chunk, - { - ...options, - append: staged ? offset > 0 : options.append === true || offset > 0, - exclusive: staged ? false : options.exclusive === true && offset === 0 - }, - offset - ) - offset += chunk.length - } while (offset < totalBytes) - if (staged) { - await publishWindowsStagedWrite(target, writePath, remotePath, options) + +/** A staged write is materialized locally first when the source is a buffer rather than a file. */ +async function withTemporaryLocalFile( + contents: Buffer, + send: (localPath: string) => Promise +): Promise { + const directory = await mkdtemp(join(tmpdir(), 'orca-win-upload-')) + const localPath = join(directory, 'payload.bin') + try { + // 0600: the payload can be repository content, and tmpdir is shared on every platform. + await writeFile(localPath, contents, { mode: 0o600 }) + return await send(localPath) + } finally { + await rm(directory, { recursive: true, force: true }).catch(() => {}) } } -async function writeWindowsChunkViaSystemSsh( - target: SshTarget, - remotePath: string, - chunk: Buffer, - options: SystemSshWriteBufferOptions, - offset: number -): Promise { - throwIfAborted(options.signal) - const channel = spawnSystemSshCommand(target, makeWindowsWriteFileCommand(remotePath, options), { - wrapCommand: false, - ...getSystemSshBuildArgsFromOperationOptions(options) - }) - const closePromise = awaitWithSystemSshAbort( - options.signal, - () => channel.close(), - waitForChannelClose( - channel, - `write ${remotePath} at offset ${offset}`, - WINDOWS_STDIN_WRITE_TIMEOUT_MS - ) - ) - if (!options.signal?.aborted) { - channel.stdin.end(chunk) - } - await closePromise -} - -async function publishWindowsStagedWrite( - target: SshTarget, - stagingPath: string, - remotePath: string, - options: SystemSshWriteBufferOptions -): Promise { - throwIfAborted(options.signal) - const channel = spawnSystemSshCommand( - target, - makeWindowsPublishStagedFileCommand(stagingPath, remotePath, options.exclusive === true), - { wrapCommand: false, ...getSystemSshBuildArgsFromOperationOptions(options) } - ) - const closePromise = awaitWithSystemSshAbort( - options.signal, - () => channel.close(), - waitForChannelClose(channel, `publish ${remotePath}`, WINDOWS_STDIN_WRITE_TIMEOUT_MS) - ) - if (!options.signal?.aborted) { - channel.stdin.end() - } - await closePromise -} - -function makeWindowsWriteFileCommand( - remotePath: string, - options?: { append?: boolean; exclusive?: boolean } -): string { - const fileMode = options?.append ? 'Append' : options?.exclusive ? 'CreateNew' : 'Create' - return powerShellCommand( - [ - '$ErrorActionPreference = "Stop"', - `$path = ${powerShellLiteral(remotePath)}`, - '$parent = [System.IO.Path]::GetDirectoryName($path)', - 'if ($parent) { $null = [System.IO.Directory]::CreateDirectory($parent) }', - '$inputStream = [Console]::OpenStandardInput()', - `$outputStream = [System.IO.File]::Open($path, [System.IO.FileMode]::${fileMode}, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)`, - 'try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose() }' - ].join('; ') - ) -} - -// `File::Move` throws when the destination exists, which is exactly the exclusive contract; the -// non-exclusive caller asked to replace, so it deletes first (a no-op on an absent path). -function makeWindowsPublishStagedFileCommand( - stagingPath: string, - remotePath: string, - exclusive: boolean -): string { - return powerShellCommand( - [ - '$ErrorActionPreference = "Stop"', - `$staging = ${powerShellLiteral(stagingPath)}`, - `$path = ${powerShellLiteral(remotePath)}`, - ...(exclusive ? [] : ['[System.IO.File]::Delete($path)']), - '[System.IO.File]::Move($staging, $path)' - ].join('; ') - ) -} - function makePosixWriteFileCommand( remotePath: string, options?: { append?: boolean; exclusive?: boolean } diff --git a/src/main/ssh/system-ssh-file-transfer.ts b/src/main/ssh/system-ssh-file-transfer.ts index f728c0eab02..d5757904356 100644 --- a/src/main/ssh/system-ssh-file-transfer.ts +++ b/src/main/ssh/system-ssh-file-transfer.ts @@ -27,6 +27,12 @@ import { WINDOWS_STDIN_WRITE_TIMEOUT_MS, writeBufferViaSystemSsh } from './system-ssh-file-binary-transfer' +import { + isSftpPathUnsupportedError, + isSftpUnavailableError, + makeDirectoriesViaSftp +} from './system-ssh-sftp-transfer' +import { getWindowsRemoteWriteCapabilities } from './system-ssh-windows-write-capabilities' type SystemSshOperationOptions = SystemSshBuildArgsOptions & { signal?: AbortSignal @@ -161,9 +167,14 @@ async function collectWindowsUploadPlan( return plan } -// Why the JSON envelope survives here: a path list is metadata, so this payload stays in the -// hundreds of bytes even for a deep tree. Batched anyway, so a pathological tree cannot walk back -// into the same stdin size that wedges PowerShell. +/** + * Creates the upload's directories, preferring sftp's own `mkdir`. + * + * The PowerShell fallback keeps the JSON envelope, batched under one stdin's worth: a path list is + * metadata, so it stays in the hundreds of bytes even for a deep tree. It is still a redirected + * stdin read though, so on Windows PowerShell 5.1 it carries the same defect as any other — which + * is why sftp is tried first even for a payload this small. + */ async function createWindowsUploadDirectories( target: SshTarget, directories: readonly string[], @@ -175,23 +186,27 @@ async function createWindowsUploadDirectories( if (batch.length === 0) { return } + const pending = batch const payload = JSON.stringify(batch) batch = [] batchBytes = 0 throwIfAborted(options.signal) - const channel = spawnSystemSshCommand(target, makeWindowsCreateDirectoriesCommand(), { - wrapCommand: false, - ...getSystemSshBuildArgsFromOperationOptions(options) - }) - const closePromise = awaitWithSystemSshAbort( - options.signal, - () => channel.close(), - waitForChannelClose(channel, 'windows relay upload mkdir', WINDOWS_STDIN_WRITE_TIMEOUT_MS) + await getWindowsRemoteWriteCapabilities(target).runWithFallback( + 'sftp-subsystem', + async () => { + try { + await makeDirectoriesViaSftp(target, pending, options) + } catch (error) { + // A directory sftp cannot address is this batch's problem, not the host's verdict. + if (!isSftpPathUnsupportedError(error)) { + throw error + } + await createWindowsUploadDirectoriesViaPowerShell(target, payload, options) + } + }, + () => createWindowsUploadDirectoriesViaPowerShell(target, payload, options), + isSftpUnavailableError ) - if (!options.signal?.aborted) { - channel.stdin.end(payload) - } - await closePromise } for (const directory of directories) { const entryBytes = Buffer.byteLength(directory) + 4 @@ -204,17 +219,44 @@ async function createWindowsUploadDirectories( await flush() } +async function createWindowsUploadDirectoriesViaPowerShell( + target: SshTarget, + payload: string, + options: SystemSshOperationOptions +): Promise { + const channel = spawnSystemSshCommand(target, makeWindowsCreateDirectoriesCommand(), { + wrapCommand: false, + ...getSystemSshBuildArgsFromOperationOptions(options) + }) + const closePromise = awaitWithSystemSshAbort( + options.signal, + () => channel.close(), + waitForChannelClose(channel, 'windows relay upload mkdir', WINDOWS_STDIN_WRITE_TIMEOUT_MS) + ) + if (!options.signal?.aborted) { + channel.stdin.end(payload) + } + await closePromise +} + function makeWindowsCreateDirectoriesCommand(): string { return powerShellCommand( [ '$ErrorActionPreference = "Stop"', - // The reporter measured this reader surviving 50KB where `[Console]::In` wedged at the same - // size (#16432); the batch above stays under that. + // Reached only where the host has no sftp subsystem. Windows PowerShell 5.1 can lose a + // redirected stdin for good when a read finds it empty (#16432); a batch this small usually + // arrives in one piece, and "usually" is exactly why sftp is preferred. '$reader = New-Object System.IO.StreamReader([Console]::OpenStandardInput())', 'try { $json = $reader.ReadToEnd() } finally { $reader.Dispose() }', 'if ([string]::IsNullOrWhiteSpace($json)) { return }', - 'foreach ($path in @($json | ConvertFrom-Json)) {', - ' $null = [System.IO.Directory]::CreateDirectory([string]$path)', + // `[string[]]`, not `@(...)`: ConvertFrom-Json emits the parsed array as a single pipeline + // object, so `@(...)` wraps it in *another* array and the loop variable binds to the whole + // thing. `[string]` of that is the paths joined by spaces, which CreateDirectory rejects with + // "The given path's format is not supported". It only ever worked for a one-element batch, + // where stringifying a single-element array happens to yield the element. Measured on + // WindowsPowerShell 5.1.26100 against a three-directory tree. + 'foreach ($path in [string[]]($json | ConvertFrom-Json)) {', + ' $null = [System.IO.Directory]::CreateDirectory($path)', '}' ].join('; ') ) diff --git a/src/main/ssh/system-ssh-sftp-args.test.ts b/src/main/ssh/system-ssh-sftp-args.test.ts new file mode 100644 index 00000000000..d971390f8dd --- /dev/null +++ b/src/main/ssh/system-ssh-sftp-args.test.ts @@ -0,0 +1,141 @@ +/** + * `buildSshArgs` is shared with the sftp client, and three of its flags mean something else there. + * Every case below is a silent wrong-target rather than an error if the translation is skipped, + * which is why the fallback is "refuse and use another transport", never "pass it through". + */ +import { describe, expect, it } from 'vitest' +import { + SftpArgTranslationError, + translateSshArgsToSftpArgs, + withSftpKeepalive +} from './system-ssh-sftp-args' + +describe('translateSshArgsToSftpArgs', () => { + it('sends the port as an option, since sftp -p preserves mtimes instead', () => { + const args = translateSshArgsToSftpArgs(['-p', '2222', '--', 'dev@win.example']) + + expect(args).toEqual(['-o', 'Port=2222', '--', 'dev@win.example']) + }) + + it('sends the login name as an option, since sftp has no -l', () => { + // `buildSshArgs` emits `-l` for a config alias no Host block claims. Throwing here would send + // exactly those hosts to the transport this PR exists to stop using, silently. + const args = translateSshArgsToSftpArgs(['-l', 'neil', '--', 'awin']) + + expect(args).toEqual(['-o', 'User=neil', '--', 'awin']) + }) + + it('translates the whole unclaimed-alias shape buildSshArgs emits', () => { + const args = translateSshArgsToSftpArgs([ + '-o', + 'BatchMode=no', + '-T', + '-S', + 'none', + '-o', + 'Hostname=192.168.0.186', + '-p', + '2222', + '-l', + 'neil', + '--', + 'awin' + ]) + + expect(args).toEqual([ + '-o', + 'BatchMode=no', + '-o', + 'ControlPath=none', + '-o', + 'Hostname=192.168.0.186', + '-o', + 'Port=2222', + '-o', + 'User=neil', + '--', + 'awin' + ]) + }) + + it('spells ControlPath=none out, since sftp -S names a program to run', () => { + // `sftp -S none` would try to exec a binary called `none`. + const args = translateSshArgsToSftpArgs(['-S', 'none', '--', 'dev@win.example']) + + expect(args).toEqual(['-o', 'ControlPath=none', '--', 'dev@win.example']) + }) + + it('refuses any other -S, which would hand sftp an ssh binary Orca did not choose', () => { + expect(() => translateSshArgsToSftpArgs(['-S', '/tmp/ctl.sock'])).toThrow( + SftpArgTranslationError + ) + }) + + it('drops -T, which sftp does not have', () => { + expect(translateSshArgsToSftpArgs(['-T', '--', 'host'])).toEqual(['--', 'host']) + }) + + it('passes through the flags both clients spell the same way', () => { + const args = translateSshArgsToSftpArgs([ + '-F', + '/tmp/config', + '-o', + 'BatchMode=yes', + '-i', + '/tmp/key', + '-J', + 'jump.example', + '--', + 'dev@win.example' + ]) + + expect(args).toEqual([ + '-F', + '/tmp/config', + '-o', + 'BatchMode=yes', + '-i', + '/tmp/key', + '-J', + 'jump.example', + '--', + 'dev@win.example' + ]) + }) + + it('takes everything after -- as the destination without reinterpreting it', () => { + // A host literally named `-p` is not a flag once `--` has been seen. + expect(translateSshArgsToSftpArgs(['--', '-p'])).toEqual(['--', '-p']) + }) + + it('refuses an unknown flag rather than guessing what sftp would do with it', () => { + // The point of the throw: a flag added to buildSshArgs later must degrade to another + // transport, not reach sftp carrying a different meaning. + expect(() => translateSshArgsToSftpArgs(['-A', '--', 'host'])).toThrow(SftpArgTranslationError) + }) + + it('refuses a value flag with no value', () => { + expect(() => translateSshArgsToSftpArgs(['-o'])).toThrow(SftpArgTranslationError) + }) +}) + +describe('withSftpKeepalive', () => { + it('asks OpenSSH to notice a dead peer, since the transfer itself has no wall-clock bound', () => { + expect(withSftpKeepalive(['--', 'host'])).toEqual([ + '-o', + 'ServerAliveInterval=15', + '-o', + 'ServerAliveCountMax=3', + '--', + 'host' + ]) + }) + + it('leaves a caller-stated keepalive policy alone', () => { + const args = withSftpKeepalive(['-o', 'ServerAliveInterval=60', '--', 'host']) + + expect(args.filter((arg) => arg.startsWith('ServerAliveInterval'))).toEqual([ + 'ServerAliveInterval=60' + ]) + }) +}) diff --git a/src/main/ssh/system-ssh-sftp-args.ts b/src/main/ssh/system-ssh-sftp-args.ts new file mode 100644 index 00000000000..17fa37e78bb --- /dev/null +++ b/src/main/ssh/system-ssh-sftp-args.ts @@ -0,0 +1,95 @@ +/** + * Rewrites `buildSshArgs` output for the sftp(1) client. + * + * Three flags ssh and sftp share spell different things: sftp's `-p` is "preserve mtime", its `-S` + * names the ssh binary to run, and it has no `-T` at all. Passing ssh's list through unchanged + * would silently connect to the wrong port and try to exec a program called `none`. + * + * Anything this table does not recognize throws. A flag added to `buildSshArgs` later must degrade + * to the non-sftp transfer path, never reach sftp carrying a different meaning. + */ + +/** `buildSshArgs` emitted a flag with no sftp equivalent; the caller should use another transport. */ +export class SftpArgTranslationError extends Error { + constructor(flag: string) { + super(`No sftp equivalent for system ssh argument ${JSON.stringify(flag)}`) + this.name = 'SftpArgTranslationError' + } +} + +/** Flags whose spelling and meaning are identical in both clients. */ +const PASSTHROUGH_VALUE_FLAGS = new Set(['-F', '-o', '-i', '-J']) + +export function translateSshArgsToSftpArgs(sshArgs: readonly string[]): string[] { + const sftpArgs: string[] = [] + let index = 0 + while (index < sshArgs.length) { + const flag = sshArgs[index]! + if (flag === '--') { + // Everything after `--` is the destination, which both clients spell the same way. + sftpArgs.push(...sshArgs.slice(index)) + return sftpArgs + } + const value = sshArgs[index + 1] + if (PASSTHROUGH_VALUE_FLAGS.has(flag)) { + if (value === undefined) { + throw new SftpArgTranslationError(flag) + } + sftpArgs.push(flag, value) + index += 2 + continue + } + if (flag === '-T') { + // sftp never allocates a tty, so ssh's "no tty" request has nothing to translate to. + index += 1 + continue + } + if (flag === '-p') { + if (value === undefined) { + throw new SftpArgTranslationError(flag) + } + sftpArgs.push('-o', `Port=${value}`) + index += 2 + continue + } + if (flag === '-l') { + // sftp has no `-l`; the login name is an option there. `buildSshArgs` emits this for an + // unclaimed config alias, so throwing would route those hosts down the defective path and + // then cache the refusal against them for half an hour. + if (value === undefined) { + throw new SftpArgTranslationError(flag) + } + sftpArgs.push('-o', `User=${value}`) + index += 2 + continue + } + if (flag === '-S') { + // ssh's `-S none` is ControlPath=none; sftp's `-S` would run a binary called `none`. + if (value !== 'none') { + throw new SftpArgTranslationError(flag) + } + sftpArgs.push('-o', 'ControlPath=none') + index += 2 + continue + } + throw new SftpArgTranslationError(flag) + } + return sftpArgs +} + +/** + * A transfer that stalls mid-stream has no per-write bound to catch it, so ask OpenSSH to notice a + * dead peer itself. Only added when the caller has not already stated a keepalive policy. + */ +export function withSftpKeepalive(sftpArgs: readonly string[]): string[] { + const hasOption = (name: string): boolean => + sftpArgs.some((arg, position) => sftpArgs[position - 1] === '-o' && arg.startsWith(`${name}=`)) + const keepalive: string[] = [] + if (!hasOption('ServerAliveInterval')) { + keepalive.push('-o', 'ServerAliveInterval=15') + } + if (!hasOption('ServerAliveCountMax')) { + keepalive.push('-o', 'ServerAliveCountMax=3') + } + return [...keepalive, ...sftpArgs] +} diff --git a/src/main/ssh/system-ssh-sftp-path.test.ts b/src/main/ssh/system-ssh-sftp-path.test.ts new file mode 100644 index 00000000000..e196c2e3e8f --- /dev/null +++ b/src/main/ssh/system-ssh-sftp-path.test.ts @@ -0,0 +1,59 @@ +/** + * Both functions here guard against the same measured failure: sftp's batch lexer treats `\` as an + * escape, so a Windows path handed over raw is silently mis-targeted *and the client still exits + * 0*. On Windows 11 / OpenSSH 10.0p2, `put src C:\Users\neil\qt\a.bin` created a file literally + * named `C` in the start directory and reported success. + */ +import { describe, expect, it } from 'vitest' +import { + quoteSftpBatchArgument, + toSftpRemotePath, + UnsupportedSftpPathError +} from './system-ssh-sftp-path' + +describe('toSftpRemotePath', () => { + it('roots a drive path under /, which is the namespace the Windows sftp-server exposes', () => { + // `pwd` in that session reports `/C:/Users/dev`. + expect(toSftpRemotePath('C:/Users/dev/f.bin')).toBe('/C:/Users/dev/f.bin') + }) + + it('accepts a path already in that namespace unchanged', () => { + expect(toSftpRemotePath('/C:/Users/dev/f.bin')).toBe('/C:/Users/dev/f.bin') + }) + + it('converts the separators Orca stores paths with', () => { + expect(toSftpRemotePath('C:\\Users\\dev\\f.bin')).toBe('/C:/Users/dev/f.bin') + }) + + it('declines a UNC path rather than guessing where it lands', () => { + // A guess here writes real bytes to the wrong place; declining falls back to another transport. + expect(() => toSftpRemotePath('//server/share/f.bin')).toThrow(UnsupportedSftpPathError) + }) + + it('declines a relative path, which would resolve against the session start directory', () => { + expect(() => toSftpRemotePath('Users/dev/f.bin')).toThrow(UnsupportedSftpPathError) + }) +}) + +describe('quoteSftpBatchArgument', () => { + it('escapes the backslashes in a Windows client local path', () => { + // Unescaped, sftp reads this as C:srcf.bin and fails to find the source. + expect(quoteSftpBatchArgument('C:\\src\\f.bin')).toBe('"C:\\\\src\\\\f.bin"') + }) + + it('keeps a path with spaces as one argument', () => { + expect(quoteSftpBatchArgument('/tmp/two words.bin')).toBe('"/tmp/two words.bin"') + }) + + it('escapes an embedded quote, which would otherwise end the argument early', () => { + expect(quoteSftpBatchArgument('/tmp/dq".bin')).toBe('"/tmp/dq\\".bin"') + }) + + it('refuses a line break, which would split one batch command into two', () => { + expect(() => quoteSftpBatchArgument('/tmp/a\nrm -rf b')).toThrow(UnsupportedSftpPathError) + }) + + it('refuses a NUL, which truncates the argument', () => { + expect(() => quoteSftpBatchArgument('/tmp/a\0b')).toThrow(UnsupportedSftpPathError) + }) +}) diff --git a/src/main/ssh/system-ssh-sftp-path.ts b/src/main/ssh/system-ssh-sftp-path.ts new file mode 100644 index 00000000000..2b5bfe53f02 --- /dev/null +++ b/src/main/ssh/system-ssh-sftp-path.ts @@ -0,0 +1,46 @@ +import { normalizeWindowsRemotePath } from './ssh-remote-platform' + +/** + * A path this transfer cannot express to sftp. Callers treat it as "use another transport", never + * as a transfer failure. + */ +export class UnsupportedSftpPathError extends Error { + constructor(path: string) { + super(`Path cannot be addressed over sftp: ${JSON.stringify(path)}`) + this.name = 'UnsupportedSftpPathError' + } +} + +/** + * Converts a Windows remote path to the namespace OpenSSH's Windows sftp-server exposes, which + * roots every drive under `/`: `C:/Users/dev/f` is `/C:/Users/dev/f`, and `pwd` there reports + * `/C:/Users/dev`. + */ +export function toSftpRemotePath(remotePath: string): string { + const normalized = normalizeWindowsRemotePath(remotePath) + if (/^\/[a-zA-Z]:\//.test(normalized)) { + return normalized + } + if (/^[a-zA-Z]:\//.test(normalized)) { + return `/${normalized}` + } + // UNC (`//server/share`) and relative paths have no settled mapping in this namespace, and a + // guess here writes real bytes to the wrong place. Decline instead. + throw new UnsupportedSftpPathError(remotePath) +} + +/** + * Quotes one argument of an sftp batch line. + * + * Escaping is load-bearing, not cosmetic: sftp's batch lexer treats `\` as an escape even inside + * double quotes, so an unescaped Windows local path `C:\src\f.bin` is read as `C:srcf.bin`, and an + * unescaped destination `C:\Users\dev\f.bin` writes a file literally named `C` in the start + * directory — while sftp still exits 0. Both measured on Windows 11 / OpenSSH 10.0p2. + */ +export function quoteSftpBatchArgument(value: string): string { + if (/[\n\r\0]/.test(value)) { + // A line break would split one batch command into two; NUL truncates the argument. + throw new UnsupportedSftpPathError(value) + } + return `"${value.replace(/([\\"])/g, '\\$1')}"` +} diff --git a/src/main/ssh/system-ssh-sftp-transfer.ts b/src/main/ssh/system-ssh-sftp-transfer.ts new file mode 100644 index 00000000000..c50375fa8eb --- /dev/null +++ b/src/main/ssh/system-ssh-sftp-transfer.ts @@ -0,0 +1,191 @@ +import { accessSync, constants, existsSync, statSync } from 'node:fs' +import { posix, win32 } from 'node:path' +import type { SshTarget } from '../../shared/ssh-types' +import { buildSshArgs, type SystemSshBuildArgsOptions } from './system-ssh-args' +import { findSystemSsh } from './system-ssh-binary' +import { + SftpArgTranslationError, + translateSshArgsToSftpArgs, + withSftpKeepalive +} from './system-ssh-sftp-args' +import { + quoteSftpBatchArgument, + toSftpRemotePath, + UnsupportedSftpPathError +} from './system-ssh-sftp-path' +import { throwIfAborted } from './system-ssh-operation-lifecycle' +import { runProcess } from '../../shared/child-process/run-process' + +/** The host answered, but not with an sftp subsystem. The caller must fall back, not fail. */ +export class SftpSubsystemUnavailableError extends Error { + constructor(detail: string) { + super(`Remote host has no usable sftp subsystem: ${detail}`) + this.name = 'SftpSubsystemUnavailableError' + } +} + +/** + * True for the errors that mean "this host cannot serve sftp at all". + * + * Host-scoped, and therefore the only errors safe to remember: a capability cache keyed by host + * turns anything it accepts into a verdict about every later write to that host. Deliberately + * narrow — a permission denial or a missing directory is a real failure that must surface, not a + * reason to retry the whole upload down a slower path. + */ +export function isSftpUnavailableError(error: unknown): boolean { + return error instanceof SftpSubsystemUnavailableError || error instanceof SftpArgTranslationError +} + +/** + * True when *this path* cannot be spelled for sftp, which says nothing about the host. + * + * Kept apart from the host verdict on purpose. A UNC destination, or a local file whose name + * contains a newline, is a property of one operation; caching it would degrade every subsequent + * write to that host for the cache's whole retry window on the strength of one odd filename. + */ +export function isSftpPathUnsupportedError(error: unknown): boolean { + return error instanceof UnsupportedSftpPathError +} + +/** Neither kind of refusal moves a byte, so a staged file cannot exist to sweep. */ +export function isSftpRefusalBeforeStaging(error: unknown): boolean { + return isSftpUnavailableError(error) || isSftpPathUnsupportedError(error) +} + +function systemSftpCandidates(sshPath: string | null, platform: NodeJS.Platform): string[] { + const pathApi = platform === 'win32' ? win32 : posix + const executable = platform === 'win32' ? 'sftp.exe' : 'sftp' + const candidates: string[] = [] + // Why the ssh binary's own directory first: a host with two OpenSSH installs must pair the sftp + // client with the ssh that `buildSshArgs` was built for, not whichever one PATH happens to reach. + if (sshPath) { + candidates.push(pathApi.join(pathApi.dirname(sshPath), executable)) + } + if (platform === 'win32') { + const systemRoot = process.env.SystemRoot || process.env.WINDIR + if (systemRoot) { + candidates.push(win32.join(systemRoot, 'System32', 'OpenSSH', executable)) + } + } else { + candidates.push('/usr/bin/sftp', '/usr/local/bin/sftp', '/opt/homebrew/bin/sftp') + } + return candidates +} + +/** Locate the sftp client paired with the system ssh binary. Returns null when there is none. */ +export function findSystemSftp(): string | null { + if (process.env.ORCA_SYSTEM_SFTP_PATH) { + return process.env.ORCA_SYSTEM_SFTP_PATH + } + const sshPath = findSystemSsh() + for (const candidate of systemSftpCandidates(sshPath, process.platform)) { + try { + if (!statSync(candidate).isFile()) { + continue + } + if (process.platform !== 'win32') { + accessSync(candidate, constants.X_OK) + } + return candidate + } catch { + continue + } + } + return findSftpOnPath() +} + +function findSftpOnPath(): string | null { + const pathValue = process.env.PATH + if (!pathValue) { + return null + } + const pathApi = process.platform === 'win32' ? win32 : posix + const executable = process.platform === 'win32' ? 'sftp.exe' : 'sftp' + for (const entry of pathValue.split(pathApi.delimiter)) { + const directory = entry.trim().replace(/^"|"$/g, '') + if (!directory) { + continue + } + const candidate = pathApi.join(directory, executable) + if (existsSync(candidate)) { + return candidate + } + } + return null +} + +/** + * OpenSSH prints this when the server refuses the subsystem — a host with `Subsystem sftp` + * commented out, or an internal-sftp block that does not apply to this user. + */ +const SUBSYSTEM_REFUSED_PATTERN = /subsystem request failed|no such file or directory.*sftp-server/i + +export type SftpBatchOptions = SystemSshBuildArgsOptions & { signal?: AbortSignal } + +/** + * Runs one sftp batch script. + * + * The script goes to the *local* sftp client's stdin, which is the point: no remote process ever + * reads a redirected stdin, so none of this rides the Windows PowerShell stdin defect. + */ +export async function runSftpBatch( + target: SshTarget, + commands: readonly string[], + options?: SftpBatchOptions +): Promise { + throwIfAborted(options?.signal) + const sftpPath = findSystemSftp() + if (!sftpPath) { + throw new SftpSubsystemUnavailableError('no sftp client binary found alongside ssh') + } + const args = withSftpKeepalive(translateSshArgsToSftpArgs(buildSshArgs(target, options))) + let result + try { + result = await runProcess({ + program: sftpPath, + args: ['-b', '-', ...args], + // `-b -` takes the script on stdin, and that stdin is the *local* client's — no remote + // process reads a pipe anywhere in this transfer, which is the whole point of preferring it. + input: `${commands.join('\n')}\n`, + // Why no timeout: a large upload is legitimately slow, and a wall-clock cap would fail a + // healthy transfer on a slow link. A dead peer is caught by the ServerAlive options instead. + timeoutMs: null, + signal: options?.signal + }) + } catch (error) { + // A client that will not start is "this host cannot do sftp" from the caller's side, not a + // transfer failure: the payload never left. Falling back is the only useful answer. + throw new SftpSubsystemUnavailableError( + `sftp client at ${sftpPath} could not be started: ${error instanceof Error ? error.message : String(error)}` + ) + } + if (result.code === 0) { + return + } + throwIfAborted(options?.signal) + const detail = result.stderr.trim() + if (SUBSYSTEM_REFUSED_PATTERN.test(detail)) { + throw new SftpSubsystemUnavailableError(detail) + } + throw new Error(`sftp batch failed (exit ${result.code}): ${detail}`) +} + +/** + * Creates remote directories, parents first. + * + * `-mkdir` keeps sftp going when a directory is already there; batch mode otherwise aborts the + * whole script on the first non-zero status, which for an idempotent tree walk is not a failure. + */ +export function makeDirectoriesViaSftp( + target: SshTarget, + remoteDirectories: readonly string[], + options?: SftpBatchOptions +): Promise { + const commands = remoteDirectories.map( + (directory) => `-mkdir ${quoteSftpBatchArgument(toSftpRemotePath(directory))}` + ) + if (commands.length === 0) { + return Promise.resolve() + } + return runSftpBatch(target, commands, options) +} diff --git a/src/main/ssh/system-ssh-windows-file-write.ts b/src/main/ssh/system-ssh-windows-file-write.ts new file mode 100644 index 00000000000..8b98d4aec50 --- /dev/null +++ b/src/main/ssh/system-ssh-windows-file-write.ts @@ -0,0 +1,138 @@ +import { randomBytes } from 'node:crypto' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { normalizeWindowsRemotePath } from './ssh-remote-platform' + +/** + * Suffix marking the path a Windows write lands on before it is published by rename. + * + * The random tail is the fix for a measured harm, not decoration. A write that loses contact with + * the host leaves a remote process that may still hold the staging file open exclusively, and + * `docs/reference/ssh-execution-boundary.md` is explicit that losing contact is not evidence that + * process died — so the retry must not reuse the name it may still own. A fresh name per attempt + * means a retry never meets its predecessor's lock; the abandoned file is cleaned up best-effort + * and never treated as proof of anything. + */ +export const WINDOWS_STAGED_WRITE_SUFFIX = '.orca-partial' + +export function makeWindowsStagingPath(remotePath: string): string { + return `${remotePath}${WINDOWS_STAGED_WRITE_SUFFIX}-${randomBytes(6).toString('hex')}` +} + +export type WindowsPublishMode = 'create' | 'exclusive' | 'append' + +/** + * Publishes a staged upload onto its real name. + * + * Every branch reads the staged *file*, never a redirected stdin, which is what makes this safe on + * a host whose Windows PowerShell 5.1 cannot drain a piped stdin. + * + * The replacing branch must never delete the destination first. Deleting and then moving loses the + * user's existing file outright if the move fails, and exposes a window where a reader sees no file + * at all — a worse outcome than the truncated-partial this staging discipline exists to prevent. + * `File.Replace` is the atomic swap (Win32 `ReplaceFile`), and it requires the destination to + * exist, so an absent one falls back to a plain `Move`. That fallback is raced deliberately: if the + * destination appears in between, `Move` throws, the staged file survives, and the destination is + * left exactly as whoever created it left it. + * + * `File::Move` throwing on an existing destination is also precisely the exclusive contract, which + * is why that branch needs nothing else. + */ +export function makeWindowsPublishStagedFileCommand( + stagingPath: string, + remotePath: string, + mode: WindowsPublishMode +): string { + const preamble = [ + '$ErrorActionPreference = "Stop"', + `$staging = ${powerShellLiteral(stagingPath)}`, + `$path = ${powerShellLiteral(remotePath)}`, + '$parent = [System.IO.Path]::GetDirectoryName($path)', + 'if ($parent) { $null = [System.IO.Directory]::CreateDirectory($parent) }' + ] + if (mode === 'append') { + return powerShellCommand( + [ + ...preamble, + // Not atomic, and cannot cheaply be: appending is defined as extending the destination, so + // a failure part-way leaves it longer than it was rather than destroyed. The caller's + // chunked-append protocol already restarts from its own offset. + '$in = [System.IO.File]::OpenRead($staging)', + '$out = [System.IO.File]::Open($path, [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)', + 'try { $in.CopyTo($out) } finally { $out.Dispose(); $in.Dispose() }', + '[System.IO.File]::Delete($staging)' + ].join('; ') + ) + } + if (mode === 'exclusive') { + return powerShellCommand([...preamble, '[System.IO.File]::Move($staging, $path)'].join('; ')) + } + return powerShellCommand( + [ + ...preamble, + // `[NullString]::Value`, not `$null`: PowerShell coerces a bare `$null` to an empty string + // when binding a .NET `string` parameter, and `Replace` rejects that with "The path is not + // of a legal form" — so every publish would fail. Measured on WindowsPowerShell 5.1.26100. + 'try { [System.IO.File]::Replace($staging, $path, [NullString]::Value) } catch [System.IO.FileNotFoundException] { [System.IO.File]::Move($staging, $path) }' + ].join('; ') + ) +} + +/** Best-effort removal of a staged file whose write was abandoned. Never asserts the writer died. */ +export function makeWindowsDiscardStagedFileCommand(stagingPath: string): string { + return powerShellCommand( + [ + // Deliberately not `Stop`: the previous writer may still hold this file, and that is a + // possibility to tolerate, not an error to report. The unique staging name means a leftover + // blocks nothing; sweeping it is housekeeping. + '$ErrorActionPreference = "SilentlyContinue"', + `$staging = ${powerShellLiteral(stagingPath)}`, + '[System.IO.File]::Delete($staging)' + ].join('; ') + ) +} + +/** + * The ancestor directories of a Windows remote path, drive root first. + * + * sftp's `mkdir` creates one level, so a batch has to name each level itself. The drive root is + * excluded: `-mkdir "/C:/"` is not a directory anyone creates. + */ +export function windowsRemoteAncestorDirectories(remotePath: string): string[] { + const normalized = normalizeWindowsRemotePath(remotePath) + const segments = normalized.split('/') + segments.pop() + const ancestors: string[] = [] + // Start past the drive (`C:`) or the UNC host, which are never created. + for (let depth = 2; depth <= segments.length; depth += 1) { + const directory = segments.slice(0, depth).join('/') + if (directory) { + ancestors.push(directory) + } + } + return ancestors +} + +/** + * `[Console]::OpenStandardInput()` into a `FileStream`, used only by the two stdin fallbacks. + * + * On Windows PowerShell 5.1 this is the defective read; see the strategy comment in + * `system-ssh-file-binary-transfer.ts`. It is correct under PowerShell 7. + */ +export function makeWindowsWriteFileCommand( + remotePath: string, + options?: { append?: boolean; exclusive?: boolean; executable?: 'powershell.exe' | 'pwsh.exe' } +): string { + const fileMode = options?.append ? 'Append' : options?.exclusive ? 'CreateNew' : 'Create' + return powerShellCommand( + [ + '$ErrorActionPreference = "Stop"', + `$path = ${powerShellLiteral(remotePath)}`, + '$parent = [System.IO.Path]::GetDirectoryName($path)', + 'if ($parent) { $null = [System.IO.Directory]::CreateDirectory($parent) }', + '$inputStream = [Console]::OpenStandardInput()', + `$outputStream = [System.IO.File]::Open($path, [System.IO.FileMode]::${fileMode}, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)`, + 'try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose() }' + ].join('; '), + options?.executable ?? 'powershell.exe' + ) +} diff --git a/src/main/ssh/system-ssh-windows-upload.test.ts b/src/main/ssh/system-ssh-windows-upload.test.ts index 207c3e2df8e..c3a5ff80276 100644 --- a/src/main/ssh/system-ssh-windows-upload.test.ts +++ b/src/main/ssh/system-ssh-windows-upload.test.ts @@ -1,29 +1,40 @@ /** - * #16432: the Windows relay upload pushed the whole bundle into one PowerShell stdin, which - * Windows PowerShell 5.1 cannot drain over a non-pty ssh exec — the remote blocks forever, and - * `waitForChannelClose()` had no timeout, so the UI sat at "Connecting…" with no error. Covered - * here: no write exceeds one stdin's worth on any Windows path (bundle upload *and* single-file - * upload, which is the one that carries large files), a partial write never lands under the real - * name, and a remote that never closes fails instead of hanging. + * #16432. The original fix chunked the payload because the constraint was believed to be a ~50KB + * cmd.exe stdin ceiling. Re-measured on Windows 11 26200.9168 / OpenSSH_for_Windows_10.0p2, it is + * not a size limit and not cmd.exe's: a read on Windows PowerShell 5.1's redirected-stdin handle + * over a non-pty ssh exec can die permanently when it finds the stream momentarily empty, taking + * both the remaining data and the EOF with it. It is probabilistic per such read — identical 2MB + * payloads died at 167936, 270336 and 372736 — so a 32KB chunk still failed 15 times in 120 under + * load, while `findstr` took 2,016,000 bytes through one exec on the same host. + * + * So the covering property is no longer "every write is small". It is "the bytes do not cross a + * remote process's stdin at all": sftp first, PowerShell 7 next, and Windows PowerShell 5.1 last, + * bounded and loud. The staging-and-rename discipline is kept on every path, with a unique staging + * name per attempt so a retry never meets a predecessor's lock. */ import { EventEmitter } from 'node:events' import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' -import { rm } from 'node:fs/promises' +import { readFile, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PassThrough, Writable } from 'node:stream' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as SystemSshOperationLifecycle from './system-ssh-operation-lifecycle' -const { spawnSystemSshCommandMock, waitForChannelCloseSpy } = vi.hoisted(() => ({ +const { spawnSystemSshCommandMock, waitForChannelCloseSpy, runProcessMock } = vi.hoisted(() => ({ spawnSystemSshCommandMock: vi.fn(), - waitForChannelCloseSpy: vi.fn() + waitForChannelCloseSpy: vi.fn(), + runProcessMock: vi.fn() })) vi.mock('./system-ssh-command', () => ({ spawnSystemSshCommand: spawnSystemSshCommandMock })) +vi.mock('../../shared/child-process/run-process', () => ({ + runProcess: runProcessMock +})) + // Delegates to the real implementation; the spy only records whether each wait was given a bound. vi.mock('./system-ssh-operation-lifecycle', async (importActual) => { const actual = (await importActual()) as typeof SystemSshOperationLifecycle @@ -41,6 +52,11 @@ import { } from './system-ssh-file-binary-transfer' import { waitForChannelClose } from './system-ssh-operation-lifecycle' import { getRemoteHostPlatform } from './ssh-remote-platform' +import { + clearWindowsRemoteWriteCapabilitiesForTests, + getWindowsRemoteWriteCapabilities +} from './system-ssh-windows-write-capabilities' +import { explainWindowsPowerShellStdinFailure } from './system-ssh-windows-write-strategy' import type { SshTarget } from '../../shared/ssh-types' type FakeChannel = EventEmitter & { @@ -50,7 +66,12 @@ type FakeChannel = EventEmitter & { written: Buffer } -const target = { id: 'win-1', host: 'win.example', username: 'dev' } as unknown as SshTarget +const target = { + id: 'win-1', + host: 'win.example', + username: 'dev', + port: 22 +} as unknown as SshTarget const hostPlatform = getRemoteHostPlatform('win32-x64') const remoteRoot = 'C:/Users/dev/.orca-remote' @@ -78,96 +99,238 @@ function createFakeChannel(onEnd: (channel: FakeChannel) => void): FakeChannel { return channel } -type RecordedCommand = { script: string; stdin: Buffer } +type RecordedCommand = { script: string; executable: string; stdin: Buffer } +type RecordedSftpBatch = { args: string[]; script: string } -describe('Windows upload stdin framing', () => { - let localDir: string - const commands: RecordedCommand[] = [] - /** Index of the spawn that should report a non-zero exit, to model a chunk failing mid-file. */ - let failAtSpawn = -1 +const sftpBatches: RecordedSftpBatch[] = [] +const commands: RecordedCommand[] = [] +/** Index of the exec that should report a non-zero exit, to model a chunk failing mid-file. */ +let failAtSpawn = -1 +let localDir: string - const fileWrites = (): RecordedCommand[] => - commands.filter((command) => command.script.includes('FileMode]::')) - const writtenPath = (command: RecordedCommand): string => - /\$path = '((?:[^']|'')*)'/.exec(command.script)?.[1].replace(/''/g, "'") ?? '' - const fileMode = (command: RecordedCommand): string | undefined => - /FileMode\]::(\w+)/.exec(command.script)?.[1] +const fileWrites = (): RecordedCommand[] => + commands.filter((command) => command.script.includes('OpenStandardInput')) +const writtenPath = (command: RecordedCommand): string => + /\$path = '((?:[^']|'')*)'/.exec(command.script)?.[1]?.replace(/''/g, "'") ?? '' +const fileMode = (command: RecordedCommand): string | undefined => + /FileMode\]::(\w+)/.exec(command.script)?.[1] +const putLines = (): string[] => + sftpBatches.flatMap((batch) => batch.script.split('\n').filter((line) => line.startsWith('put '))) +const putDestination = (line: string): string => /put "(?:[^"]*)" "([^"]*)"/.exec(line)?.[1] ?? '' +const putSource = (line: string): string => /put "([^"]*)"/.exec(line)?.[1] ?? '' - beforeEach(() => { - commands.length = 0 - failAtSpawn = -1 - waitForChannelCloseSpy.mockClear() - localDir = mkdtempSync(join(tmpdir(), 'orca-win-upload-')) - spawnSystemSshCommandMock.mockReset() - spawnSystemSshCommandMock.mockImplementation((_target: SshTarget, command: string) => { - const spawnIndex = spawnSystemSshCommandMock.mock.calls.length - 1 - return createFakeChannel((channel) => { - commands.push({ script: decodePowerShellCommand(command), stdin: channel.written }) - setImmediate(() => - spawnIndex === failAtSpawn - ? channel.emit('close', 1, null) - : channel.emit('close', 0, null) - ) +/** Makes every sftp batch succeed, recording what it was asked to do. */ +function acceptSftp(): void { + runProcessMock.mockImplementation( + async (spec: { args: string[]; input: string; program: string }) => { + const script = spec.input + sftpBatches.push({ args: spec.args, script }) + // Model the real client: `put` copies the local file, so read it while it still exists. + for (const line of script.split('\n').filter((entry) => entry.startsWith('put '))) { + await readFile(putSource(line)) + } + return { code: 0, signal: null, stdout: '', stderr: '', timedOut: false } + } + ) +} + +/** Models a host whose sshd has no `Subsystem sftp` line. */ +function refuseSftp(): void { + runProcessMock.mockImplementation(async (spec: { args: string[]; input: string }) => { + sftpBatches.push({ args: spec.args, script: spec.input }) + return { + code: 255, + signal: null, + stdout: '', + stderr: 'subsystem request failed on channel 0\nConnection closed', + timedOut: false + } + }) +} + +/** Models a host with no PowerShell 7, which cmd.exe reports as an unrecognized command. */ +function refusePwsh(): void { + spawnSystemSshCommandMock.mockImplementation((_target: SshTarget, command: string) => { + const spawnIndex = spawnSystemSshCommandMock.mock.calls.length - 1 + const executable = command.split(' ')[0] ?? '' + return createFakeChannel((channel) => { + commands.push({ + script: decodePowerShellCommand(command), + executable, + stdin: channel.written + }) + setImmediate(() => { + if (executable === 'pwsh.exe') { + channel.stderr.write( + "'pwsh.exe' is not recognized as an internal or external command,\noperable program or batch file." + ) + channel.emit('close', 9009, null) + return + } + channel.emit('close', spawnIndex === failAtSpawn ? 1 : 0, null) }) }) }) +} - afterEach(async () => { - await rm(localDir, { recursive: true, force: true }) +beforeEach(() => { + commands.length = 0 + sftpBatches.length = 0 + failAtSpawn = -1 + clearWindowsRemoteWriteCapabilitiesForTests() + waitForChannelCloseSpy.mockClear() + localDir = mkdtempSync(join(tmpdir(), 'orca-win-upload-')) + process.env.ORCA_SYSTEM_SFTP_PATH = '/usr/bin/sftp' + runProcessMock.mockReset() + acceptSftp() + spawnSystemSshCommandMock.mockReset() + spawnSystemSshCommandMock.mockImplementation((_target: SshTarget, command: string) => { + const spawnIndex = spawnSystemSshCommandMock.mock.calls.length - 1 + return createFakeChannel((channel) => { + commands.push({ + script: decodePowerShellCommand(command), + executable: command.split(' ')[0] ?? '', + stdin: channel.written + }) + setImmediate(() => + spawnIndex === failAtSpawn ? channel.emit('close', 1, null) : channel.emit('close', 0, null) + ) + }) }) +}) - it('never pushes a whole artifact bundle into one PowerShell stdin', async () => { - mkdirSync(join(localDir, 'node'), { recursive: true }) - // Comfortably past the ~50KB point at which the reporter measured PowerShell 5.1 wedging. - writeFileSync(join(localDir, 'node', 'relay.js'), Buffer.alloc(600 * 1024, 0x61)) - writeFileSync(join(localDir, 'index.js'), Buffer.alloc(300 * 1024, 0x62)) +afterEach(async () => { + delete process.env.ORCA_SYSTEM_SFTP_PATH + await rm(localDir, { recursive: true, force: true }) +}) - await uploadDirectoryViaSystemSsh(target, localDir, remoteRoot, { hostPlatform }) - - const largest = Math.max(...commands.map((command) => command.stdin.length)) - expect(largest).toBeLessThanOrEqual(WINDOWS_STDIN_WRITE_CHUNK_BYTES) - // The base64 + JSON envelope is gone entirely: nothing reads the bundle as one string. - expect(commands.some((command) => command.script.includes('FromBase64String'))).toBe(false) - // `[Console]::In` wedged at 50KB where the stream reader did not, so the mkdir batch — the one - // payload still read as a string — must use the reader the reporter measured surviving. - expect(commands.some((command) => command.script.includes('[Console]::In.ReadToEnd()'))).toBe( - false - ) - expect( - commands.filter((command) => command.script.includes('StreamReader([Console]::')) - ).toHaveLength(1) - }) - - it('bounds the single-file upload too, which is the path large files take', async () => { - const contents = Buffer.alloc(WINDOWS_STDIN_WRITE_CHUNK_BYTES * 3 + 11, 0x64) +describe('Windows upload over sftp', () => { + it('moves the payload without any remote process reading a stdin', async () => { + const contents = Buffer.alloc(WINDOWS_STDIN_WRITE_CHUNK_BYTES * 60 + 11, 0x64) const localPath = join(localDir, 'big.node') writeFileSync(localPath, contents) await uploadFileViaSystemSsh(target, localPath, `${remoteRoot}/big.node`, { hostPlatform }) - const writes = fileWrites() - expect(writes).toHaveLength(4) - expect(Math.max(...writes.map((write) => write.stdin.length))).toBe( - WINDOWS_STDIN_WRITE_CHUNK_BYTES - ) - expect(Buffer.concat(writes.map((write) => write.stdin)).equals(contents)).toBe(true) - // A wedged PowerShell never closes on its own, so no wait on this path may be unbounded. - expect( - waitForChannelCloseSpy.mock.calls.every((call) => call[2] === WINDOWS_STDIN_WRITE_TIMEOUT_MS) - ).toBe(true) + // The defect is a remote stdin read; the fix is that there is not one. + expect(fileWrites()).toHaveLength(0) + expect(putLines()).toHaveLength(1) + // One transfer, not 61 execs: the whole point of the change. + expect(sftpBatches).toHaveLength(1) }) - it('writes every byte of every artifact across the chunked writes', async () => { - const contents = Buffer.alloc(WINDOWS_STDIN_WRITE_CHUNK_BYTES * 2 + 17, 0x63) - writeFileSync(join(localDir, 'relay.js'), contents) + it('creates the parent chain and sends the payload in one round trip', async () => { + writeFileSync(join(localDir, 'relay.js'), 'x') - await uploadDirectoryViaSystemSsh(target, localDir, remoteRoot, { hostPlatform }) + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/a/b/relay.js`, { + hostPlatform + }) - const writes = fileWrites() - expect(writes).toHaveLength(3) - expect(Buffer.concat(writes.map((write) => write.stdin)).equals(contents)).toBe(true) - // Only the first write creates the staging file; the rest must extend it or it is truncated. - expect(writes.map(fileMode)).toEqual(['Create', 'Append', 'Append']) + expect(sftpBatches).toHaveLength(1) + expect(sftpBatches[0]!.script.split('\n').filter(Boolean)).toEqual([ + '-mkdir "/C:/Users"', + '-mkdir "/C:/Users/dev"', + '-mkdir "/C:/Users/dev/.orca-remote"', + '-mkdir "/C:/Users/dev/.orca-remote/a"', + '-mkdir "/C:/Users/dev/.orca-remote/a/b"', + expect.stringContaining('put ') as unknown as string + ]) + }) + + it('addresses the destination in the drive-rooted namespace sftp exposes', async () => { + writeFileSync(join(localDir, 'relay.js'), 'x') + + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform + }) + + // A backslash destination silently writes a file named `C` and still exits 0, so the leading + // slash and forward separators are correctness, not style. + expect(putDestination(putLines()[0]!)).toMatch( + /^\/C:\/Users\/dev\/\.orca-remote\/relay\.js\.orca-partial-[0-9a-f]{12}$/ + ) + }) + + it('never lands a partial under the real name, and publishes by rename', async () => { + writeFileSync(join(localDir, 'relay.js'), 'x') + const remotePath = `${remoteRoot}/relay.js` + + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), remotePath, { hostPlatform }) + + const destination = putDestination(putLines()[0]!) + // Assert the positive first: an unmatched regex yields '', which would satisfy the `not.toBe` + // below without this test ever having seen a destination. + expect(destination).toContain(WINDOWS_STAGED_WRITE_SUFFIX) + expect(destination).not.toBe(`/C:${remotePath.slice(2)}`) + const publish = commands.at(-1)! + expect(publish.script).toContain( + '[System.IO.File]::Replace($staging, $path, [NullString]::Value)' + ) + // The publish reads the staged file, never a pipe, so it is safe on PowerShell 5.1. + expect(publish.script).not.toContain('OpenStandardInput') + }) + + it('never deletes the destination it is replacing', async () => { + writeFileSync(join(localDir, 'relay.js'), 'x') + + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform + }) + + const publish = commands.at(-1)! + // Delete-then-move destroys the user's existing file outright if the move then fails, and + // exposes a window where a reader sees no file at all — worse than the truncated partial the + // staging discipline exists to prevent. `File.Replace` is the atomic swap. + expect(publish.script).not.toContain('[System.IO.File]::Delete($path)') + expect(publish.script).toContain( + '[System.IO.File]::Replace($staging, $path, [NullString]::Value)' + ) + // An absent destination cannot be Replaced, so that case falls back to a plain Move. + expect(publish.script).toContain( + 'catch [System.IO.FileNotFoundException] { [System.IO.File]::Move($staging, $path) }' + ) + }) + + it('gives every attempt its own staging name, so a retry cannot meet a predecessor lock', async () => { + writeFileSync(join(localDir, 'relay.js'), 'x') + + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform + }) + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform + }) + + const [first, second] = putLines().map(putDestination) + expect(first).toContain(WINDOWS_STAGED_WRITE_SUFFIX) + // Losing contact is not evidence the previous writer died, so the name must not be reused. + expect(second).not.toBe(first) + }) + + it('enforces exclusive at the rename, where it is atomic', async () => { + writeFileSync(join(localDir, 'import.bin'), 'x') + + await uploadFileViaSystemSsh(target, join(localDir, 'import.bin'), `${remoteRoot}/import.bin`, { + hostPlatform, + exclusive: true + }) + + const publish = commands.at(-1)! + expect(publish.script).toContain('[System.IO.File]::Move($staging, $path)') + expect(publish.script).not.toContain('[System.IO.File]::Delete($path)') + }) + + it('appends by concatenating the staged file, not by piping bytes to the remote', async () => { + await writeBufferViaSystemSsh(target, `${remoteRoot}/log.bin`, Buffer.from('tail'), { + hostPlatform, + append: true + }) + + expect(fileWrites()).toHaveLength(0) + const publish = commands.at(-1)! + expect(publish.script).toContain('FileMode]::Append') + expect(publish.script).toContain('$in.CopyTo($out)') + expect(publish.script).toContain('[System.IO.File]::Delete($staging)') }) it('still creates an empty artifact on the host', async () => { @@ -175,80 +338,310 @@ describe('Windows upload stdin framing', () => { await uploadDirectoryViaSystemSsh(target, localDir, remoteRoot, { hostPlatform }) - expect(fileWrites().map(writtenPath)).toEqual([`${remoteRoot}/empty.txt`]) - expect(fileWrites()[0].stdin).toHaveLength(0) - expect(fileMode(fileWrites()[0])).toBe('Create') + expect(putLines()).toHaveLength(1) + expect(commands.at(-1)!.script).toContain('[System.IO.File]::Move($staging, $path)') }) - it('lands a multi-chunk write on a staging path and publishes it by rename', async () => { - const remotePath = `${remoteRoot}/relay.js` - writeFileSync(join(localDir, 'relay.js'), Buffer.alloc(WINDOWS_STDIN_WRITE_CHUNK_BYTES + 1)) + it('writes a buffer through a 0600 temp file that does not outlive the transfer', async () => { + const seen: { path: string; contents: Buffer; mode: number }[] = [] + runProcessMock.mockImplementation(async (spec: { args: string[]; input: string }) => { + sftpBatches.push({ args: spec.args, script: spec.input }) + for (const line of spec.input.split('\n').filter((entry) => entry.startsWith('put '))) { + const path = putSource(line) + seen.push({ + path, + contents: await readFile(path), + mode: (await stat(path)).mode & 0o777 + }) + } + return { code: 0, signal: null, stdout: '', stderr: '', timedOut: false } + }) + + await writeBufferViaSystemSsh(target, `${remoteRoot}/version`, Buffer.from('1.2.3'), { + hostPlatform + }) + + expect(seen).toHaveLength(1) + expect(seen[0]!.contents.toString()).toBe('1.2.3') + // The payload can be repository content and tmpdir is world-readable on every platform, so the + // window between write and upload must not be group- or world-readable. + expect(seen[0]!.mode).toBe(0o600) + await expect(readFile(seen[0]!.path)).rejects.toThrow() + }) + + it('creates upload directories over sftp rather than a PowerShell stdin batch', async () => { + mkdirSync(join(localDir, 'node'), { recursive: true }) + writeFileSync(join(localDir, 'node', 'relay.js'), 'x') await uploadDirectoryViaSystemSsh(target, localDir, remoteRoot, { hostPlatform }) - // Nothing touches the real name until every byte is on the host. - expect(fileWrites().map(writtenPath)).toEqual([ - `${remotePath}${WINDOWS_STAGED_WRITE_SUFFIX}`, - `${remotePath}${WINDOWS_STAGED_WRITE_SUFFIX}` - ]) - const publish = commands.at(-1)! - expect(publish.script).toContain('[System.IO.File]::Move($staging, $path)') - expect(publish.script).toContain('[System.IO.File]::Delete($path)') + // Anchor on a non-empty observation: `some` is false of an empty list, so this would pass even + // if no command had been recorded at all. + expect(commands.length).toBeGreaterThan(0) + expect(commands.some((command) => command.script.includes('StreamReader([Console]::'))).toBe( + false + ) + expect(sftpBatches[0]!.script).toContain('-mkdir "/C:/Users/dev/.orca-remote"') + }) + + it('sweeps the staged bytes when the publish is the thing that fails', async () => { + writeFileSync(join(localDir, 'import.bin'), 'x') + // An exclusive conflict is the ordinary way to get here: the payload is on the host, and the + // rename that would have given it a name refuses. + spawnSystemSshCommandMock.mockImplementation((_target: SshTarget, command: string) => { + const script = decodePowerShellCommand(command) + return createFakeChannel((channel) => { + commands.push({ script, executable: command.split(' ')[0] ?? '', stdin: channel.written }) + const failed = script.includes('::Move($staging, $path)') + setImmediate(() => channel.emit('close', failed ? 1 : 0, null)) + }) + }) + + await expect( + uploadFileViaSystemSsh(target, join(localDir, 'import.bin'), `${remoteRoot}/import.bin`, { + hostPlatform, + exclusive: true + }) + ).rejects.toThrow() + + const sweep = commands.at(-1)! + expect(sweep.script).toContain('[System.IO.File]::Delete($staging)') + // Tolerated, not asserted: the previous writer may still hold the file, and losing contact is + // not evidence it died. + expect(sweep.script).toContain('$ErrorActionPreference = "SilentlyContinue"') + }) + + it('reports a cancelled transfer as an abort, not as a failed one', async () => { + writeFileSync(join(localDir, 'relay.js'), 'x') + const controller = new AbortController() + // runProcess reports the kill as a non-zero exit rather than throwing, so without checking the + // signal first a user pressing cancel is indistinguishable from the transfer genuinely failing. + runProcessMock.mockImplementation(async (spec: { args: string[]; input: string }) => { + sftpBatches.push({ args: spec.args, script: spec.input }) + controller.abort() + return { code: 255, signal: 'SIGTERM', stdout: '', stderr: '', timedOut: false } + }) + + let error: Error | undefined + try { + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform, + signal: controller.signal + }) + } catch (thrown) { + error = thrown as Error + } + + expect(error?.name).toBe('AbortError') + expect(error?.message).not.toContain('sftp batch failed') + // A cancel is also not evidence about the host, so it must not send later writes to the slow + // path, and must not fall through to the defective reader now. + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('sftp-subsystem')).toBe(true) + expect(fileWrites()).toHaveLength(0) + }) + + it('does not let one unaddressable path become a verdict about the host', async () => { + writeFileSync(join(localDir, 'relay.js'), 'x') + + // A UNC destination has no settled mapping in sftp's drive-rooted namespace, so this write + // falls back — but the host still serves sftp perfectly well for every other path. + await uploadFileViaSystemSsh( + target, + join(localDir, 'relay.js'), + '//fileserver/share/relay.js', + { hostPlatform } + ) + + expect(fileWrites().length).toBeGreaterThan(0) + expect(sftpBatches).toHaveLength(0) + // The 30-minute capability cache is keyed by host; caching this would send every later write + // to the same machine down the defective path on the strength of one odd destination. + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('sftp-subsystem')).toBe(true) + }) + + it('keeps using sftp for the next file after one path it could not spell', async () => { + writeFileSync(join(localDir, 'relay.js'), 'x') + + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), '//fileserver/share/a.js', { + hostPlatform + }) + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/b.js`, { + hostPlatform + }) + + expect(putLines()).toHaveLength(1) + expect(putDestination(putLines()[0]!)).toContain('/C:/Users/dev/.orca-remote/b.js') + }) + + it('does not let a local filename sftp cannot quote become a verdict either', async () => { + // POSIX clients allow a newline in a filename, and sftp's batch lexer would read it as the end + // of one command and the start of another. + const awkward = join(localDir, 'two\nlines.js') + writeFileSync(awkward, 'x') + + await uploadFileViaSystemSsh(target, awkward, `${remoteRoot}/relay.js`, { hostPlatform }) + + expect(fileWrites().length).toBeGreaterThan(0) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('sftp-subsystem')).toBe(true) + }) + + it('translates the ssh argument list rather than passing it to a client that reads it differently', async () => { + writeFileSync(join(localDir, 'relay.js'), 'x') + + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform, + disableControlMaster: true + }) + + const args = sftpBatches[0]!.args + // sftp's `-T` does not exist, its `-p` preserves mtime, and its `-S` names a program to run. + expect(args).not.toContain('-T') + expect(args).not.toContain('-p') + expect(args).not.toContain('-S') + expect(args).toContain('ControlPath=none') + expect(args).toContain('ServerAliveInterval=15') + }) +}) + +describe('Windows upload on a host with no sftp subsystem', () => { + beforeEach(() => { + refuseSftp() + }) + + it('creates a multi-directory tree, which the one-element case never exercised', async () => { + mkdirSync(join(localDir, 'node', 'deep'), { recursive: true }) + writeFileSync(join(localDir, 'index.js'), 'a') + writeFileSync(join(localDir, 'node', 'deep', 'x.js'), 'b') + + await uploadDirectoryViaSystemSsh(target, localDir, remoteRoot, { hostPlatform }) + + const mkdir = commands.find((command) => command.script.includes('ConvertFrom-Json'))! + // `@($json | ConvertFrom-Json)` wraps the parsed array in another array, so the loop variable + // binds to the whole thing and `[string]` of it is the paths joined by spaces — which + // CreateDirectory rejects. It only ever worked for a single directory, where stringifying a + // one-element array happens to yield the element, so no batch of one can catch this. + expect(mkdir.script).toContain('[string[]]($json | ConvertFrom-Json)') + expect(mkdir.script).not.toContain('@($json | ConvertFrom-Json)') + const batch = JSON.parse(mkdir.stdin.toString('utf-8')) as string[] + expect(batch.length).toBeGreaterThan(1) + }) + + it('falls back rather than failing the transfer', async () => { + const contents = Buffer.alloc(WINDOWS_STDIN_WRITE_CHUNK_BYTES + 5, 0x61) + writeFileSync(join(localDir, 'relay.js'), contents) + + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform + }) + + expect(Buffer.concat(fileWrites().map((write) => write.stdin)).equals(contents)).toBe(true) + }) + + it('remembers the refusal, so a multi-file upload probes once', async () => { + writeFileSync(join(localDir, 'a.js'), 'a') + writeFileSync(join(localDir, 'b.js'), 'b') + writeFileSync(join(localDir, 'c.js'), 'c') + + await uploadDirectoryViaSystemSsh(target, localDir, remoteRoot, { hostPlatform }) + + // One refusal is enough; re-probing per file is a wasted round trip on every file. + expect(sftpBatches).toHaveLength(1) + }) + + it('does not spend a sweep round trip when sftp declined before moving any bytes', async () => { + writeFileSync(join(localDir, 'relay.js'), 'x') + + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform + }) + + // A refused subsystem staged nothing, so there is nothing to delete — and on a host without + // sftp that sweep would otherwise be paid on every single write. + expect(commands.length).toBeGreaterThan(0) + expect(commands.some((command) => command.script.includes('Delete($staging)'))).toBe(false) + }) + + it('prefers PowerShell 7, which reads a redirected stdin correctly', async () => { + writeFileSync(join(localDir, 'relay.js'), Buffer.alloc(WINDOWS_STDIN_WRITE_CHUNK_BYTES * 3)) + + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform + }) + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe']) + // PowerShell 7 took 2MB through one exec when measured, so chunking it buys nothing. + expect(fileWrites()[0]!.stdin).toHaveLength(WINDOWS_STDIN_WRITE_CHUNK_BYTES * 3) + }) + + it('bounds every write when only Windows PowerShell 5.1 is available', async () => { + refusePwsh() + const contents = Buffer.alloc(WINDOWS_STDIN_WRITE_CHUNK_BYTES * 3 + 11, 0x64) + writeFileSync(join(localDir, 'big.node'), contents) + + await uploadFileViaSystemSsh(target, join(localDir, 'big.node'), `${remoteRoot}/big.node`, { + hostPlatform + }) + + const writes = fileWrites().filter((write) => write.executable === 'powershell.exe') + expect(writes).toHaveLength(4) + expect(Math.max(...writes.map((write) => write.stdin.length))).toBe( + WINDOWS_STDIN_WRITE_CHUNK_BYTES + ) + expect(Buffer.concat(writes.map((write) => write.stdin)).equals(contents)).toBe(true) + expect(writes.map(fileMode)).toEqual(['Create', 'Append', 'Append', 'Append']) + // A wedged PowerShell never closes on its own, so no wait on this path may be unbounded. + // Count first: `every` is true of zero calls, so a wait that moved to a different helper would + // pass this silently. + expect(waitForChannelCloseSpy.mock.calls.length).toBeGreaterThan(0) + expect( + waitForChannelCloseSpy.mock.calls.every((call) => call[2] === WINDOWS_STDIN_WRITE_TIMEOUT_MS) + ).toBe(true) + }) + + it('remembers that PowerShell 7 is absent instead of re-probing per chunk', async () => { + refusePwsh() + writeFileSync(join(localDir, 'big.node'), Buffer.alloc(WINDOWS_STDIN_WRITE_CHUNK_BYTES * 3)) + + await uploadFileViaSystemSsh(target, join(localDir, 'big.node'), `${remoteRoot}/big.node`, { + hostPlatform + }) + + expect(fileWrites().filter((write) => write.executable === 'pwsh.exe')).toHaveLength(1) }) it('leaves no truncated file under the real name when a chunk fails mid-file', async () => { writeFileSync(join(localDir, 'relay.js'), Buffer.alloc(WINDOWS_STDIN_WRITE_CHUNK_BYTES * 3)) - // Spawns: 0 = mkdir batch, 1..3 = chunk writes. Fail the second chunk. - failAtSpawn = 2 + // Spawn 0 is the pwsh write; fail it and every retry beneath it. + failAtSpawn = 0 await expect( - uploadDirectoryViaSystemSsh(target, localDir, remoteRoot, { hostPlatform }) + uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform + }) ).rejects.toThrow() + expect(fileWrites().length).toBeGreaterThan(0) expect(fileWrites().map(writtenPath)).not.toContain(`${remoteRoot}/relay.js`) expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) }) +}) - it('enforces exclusive once at the rename, so a retry is not blocked by its own leftovers', async () => { - const localPath = join(localDir, 'import.bin') - writeFileSync(localPath, Buffer.alloc(WINDOWS_STDIN_WRITE_CHUNK_BYTES + 1)) +describe('last-resort Windows PowerShell failure reporting', () => { + it('names the host limitation and its remedy, not just the timeout', () => { + const timeout = new Error('write C:/x at offset 0 timed out after 60000ms with no response') - await uploadFileViaSystemSsh(target, localPath, `${remoteRoot}/import.bin`, { - hostPlatform, - exclusive: true - }) + const explained = explainWindowsPowerShellStdinFailure(timeout) as Error - // CreateNew on chunk one would fail against a leftover staging file from a failed attempt; - // `File::Move` raising on an existing destination is what carries the exclusive contract. - expect(fileWrites().map(fileMode)).toEqual(['Create', 'Append']) - const publish = commands.at(-1)! - expect(publish.script).toContain('[System.IO.File]::Move($staging, $path)') - expect(publish.script).not.toContain('[System.IO.File]::Delete($path)') + // "timed out" alone sends the user to retry a network they cannot fix; the fix is host-side. + expect(explained.message).toContain('Windows PowerShell 5.1') + expect(explained.message).toContain('Subsystem sftp sftp-server.exe') + expect(explained.cause).toBe(timeout) }) - it('keeps a single-chunk write on the destination, with the caller mode intact', async () => { - await writeBufferViaSystemSsh(target, `${remoteRoot}/version`, Buffer.from('1.2.3'), { - hostPlatform, - exclusive: true - }) + it('leaves a real failure alone, so a permission error is not reported as a host limitation', () => { + const denied = new Error('write C:/x at offset 0 failed (exit 1): Access to the path is denied') - expect(fileWrites()).toHaveLength(1) - expect(writtenPath(fileWrites()[0])).toBe(`${remoteRoot}/version`) - expect(fileMode(fileWrites()[0])).toBe('CreateNew') - expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) - }) - - it('appends onto the destination rather than staging, since append cannot be staged', async () => { - const remotePath = `${remoteRoot}/log.bin` - await writeBufferViaSystemSsh( - target, - remotePath, - Buffer.alloc(WINDOWS_STDIN_WRITE_CHUNK_BYTES + 1), - { hostPlatform, append: true } - ) - - expect(fileWrites().map(writtenPath)).toEqual([remotePath, remotePath]) - expect(fileWrites().map(fileMode)).toEqual(['Append', 'Append']) + expect(explainWindowsPowerShellStdinFailure(denied)).toBe(denied) }) }) diff --git a/src/main/ssh/system-ssh-windows-write-capabilities.test.ts b/src/main/ssh/system-ssh-windows-write-capabilities.test.ts new file mode 100644 index 00000000000..ad723d592b0 --- /dev/null +++ b/src/main/ssh/system-ssh-windows-write-capabilities.test.ts @@ -0,0 +1,79 @@ +/** + * Whether a Windows host has an sftp subsystem is a fact about that host, so the cache is keyed by + * the endpoint that executes rather than by Orca's target id — otherwise a hardened host is + * re-probed once per file, and two targets pointing at one machine learn the same fact twice. + */ +import { afterEach, describe, expect, it } from 'vitest' +import type { SshTarget } from '../../shared/ssh-types' +import { + clearWindowsRemoteWriteCapabilitiesForTests, + getWindowsRemoteWriteCapabilities, + getWindowsRemoteWriteExecutionHostKey +} from './system-ssh-windows-write-capabilities' + +const asTarget = (fields: Partial): SshTarget => fields as SshTarget + +afterEach(() => { + clearWindowsRemoteWriteCapabilitiesForTests() +}) + +describe('getWindowsRemoteWriteExecutionHostKey', () => { + it('gives two targets on one endpoint the same key', () => { + const first = asTarget({ id: 'a', host: 'win.example', username: 'dev', port: 22 }) + const second = asTarget({ id: 'b', host: 'win.example', username: 'dev', port: 22 }) + + // A target re-created under a new id has not changed what the host supports. + expect(getWindowsRemoteWriteExecutionHostKey(first)).toBe( + getWindowsRemoteWriteExecutionHostKey(second) + ) + }) + + it('separates hosts, ports and users', () => { + const base = { id: 'a', host: 'win.example', username: 'dev', port: 22 } + const keys = [ + asTarget(base), + asTarget({ ...base, host: 'other.example' }), + asTarget({ ...base, port: 2222 }), + asTarget({ ...base, username: 'ops' }) + ].map(getWindowsRemoteWriteExecutionHostKey) + + expect(new Set(keys).size).toBe(4) + }) + + it('keys a config alias by the alias, since ssh_config decides where it lands', () => { + const alias = asTarget({ id: 'a', host: 'stale.example', configHost: 'winbox' }) + + expect(getWindowsRemoteWriteExecutionHostKey(alias)).toBe('config:winbox') + }) +}) + +describe('getWindowsRemoteWriteCapabilities', () => { + it('shares one cache across targets that reach the same host', () => { + const first = asTarget({ id: 'a', host: 'win.example', username: 'dev', port: 22 }) + const second = asTarget({ id: 'b', host: 'win.example', username: 'dev', port: 22 }) + + getWindowsRemoteWriteCapabilities(first).rememberUnsupported('sftp-subsystem') + + expect(getWindowsRemoteWriteCapabilities(second).shouldTry('sftp-subsystem')).toBe(false) + }) + + it('does not let one host answer for another', () => { + const hardened = asTarget({ id: 'a', host: 'hardened.example', username: 'dev', port: 22 }) + const ordinary = asTarget({ id: 'b', host: 'ordinary.example', username: 'dev', port: 22 }) + + getWindowsRemoteWriteCapabilities(hardened).rememberUnsupported('sftp-subsystem') + + expect(getWindowsRemoteWriteCapabilities(ordinary).shouldTry('sftp-subsystem')).toBe(true) + }) + + it('keeps the two capabilities independent', () => { + const target = asTarget({ id: 'a', host: 'win.example', username: 'dev', port: 22 }) + const capabilities = getWindowsRemoteWriteCapabilities(target) + + capabilities.rememberUnsupported('pwsh') + + // No PowerShell 7 says nothing about whether the host will serve sftp. + expect(capabilities.shouldTry('sftp-subsystem')).toBe(true) + expect(capabilities.shouldTry('pwsh')).toBe(false) + }) +}) diff --git a/src/main/ssh/system-ssh-windows-write-capabilities.ts b/src/main/ssh/system-ssh-windows-write-capabilities.ts new file mode 100644 index 00000000000..dcd03f19807 --- /dev/null +++ b/src/main/ssh/system-ssh-windows-write-capabilities.ts @@ -0,0 +1,52 @@ +import type { SshTarget } from '../../shared/ssh-types' +import { CapabilityProbeCache } from '../../shared/capability-probe-cache' + +/** + * Whether a Windows host can take a file write over the sftp subsystem, and whether it has a + * PowerShell 7 to fall back to. Both are host facts, so they are cached per execution host rather + * than per transfer — a hardened host with `Subsystem sftp` removed must not be re-probed on every + * file of a multi-file upload. + */ +export type WindowsRemoteWriteCapability = 'sftp-subsystem' | 'pwsh' + +// Why re-probe at all: an admin can enable the subsystem, or install PowerShell 7, without the +// user restarting Orca. Long enough that a hardened host costs one failed probe per half hour. +export const WINDOWS_WRITE_CAPABILITY_RETRY_INTERVAL_MS = 30 * 60_000 + +const capabilitiesByExecutionHost = new Map< + string, + CapabilityProbeCache +>() + +/** + * Keyed by the endpoint that executes, not by target id: two Orca targets pointing at one host + * describe the same sshd, and a target re-created under a new id has not changed what that host + * supports. A config alias is its own key because ssh_config, not Orca, resolves where it lands. + */ +export function getWindowsRemoteWriteExecutionHostKey(target: SshTarget): string { + if (target.configHost) { + return `config:${target.configHost}` + } + const port = target.port ?? 22 + return target.username + ? `host:${target.username}@${target.host}:${port}` + : `host:${target.host}:${port}` +} + +export function getWindowsRemoteWriteCapabilities( + target: SshTarget +): CapabilityProbeCache { + const key = getWindowsRemoteWriteExecutionHostKey(target) + let cache = capabilitiesByExecutionHost.get(key) + if (!cache) { + cache = new CapabilityProbeCache( + WINDOWS_WRITE_CAPABILITY_RETRY_INTERVAL_MS + ) + capabilitiesByExecutionHost.set(key, cache) + } + return cache +} + +export function clearWindowsRemoteWriteCapabilitiesForTests(): void { + capabilitiesByExecutionHost.clear() +} diff --git a/src/main/ssh/system-ssh-windows-write-strategy.ts b/src/main/ssh/system-ssh-windows-write-strategy.ts new file mode 100644 index 00000000000..f2cdca12516 --- /dev/null +++ b/src/main/ssh/system-ssh-windows-write-strategy.ts @@ -0,0 +1,329 @@ +import type { SshTarget } from '../../shared/ssh-types' +import { getSystemSshBuildArgsFromOperationOptions } from './system-ssh-args' +import { spawnSystemSshCommand } from './system-ssh-command' +import { + awaitWithSystemSshAbort, + throwIfAborted, + waitForChannelClose +} from './system-ssh-operation-lifecycle' +import { + isSftpPathUnsupportedError, + isSftpRefusalBeforeStaging, + isSftpUnavailableError, + runSftpBatch +} from './system-ssh-sftp-transfer' +import { quoteSftpBatchArgument, toSftpRemotePath } from './system-ssh-sftp-path' +import { getWindowsRemoteWriteCapabilities } from './system-ssh-windows-write-capabilities' +import { + makeWindowsDiscardStagedFileCommand, + makeWindowsPublishStagedFileCommand, + makeWindowsStagingPath, + makeWindowsWriteFileCommand, + windowsRemoteAncestorDirectories, + type WindowsPublishMode +} from './system-ssh-windows-file-write' + +/** No Windows stdin write should ever outlive this; a wedged PowerShell never closes on its own. */ +export const WINDOWS_STDIN_WRITE_TIMEOUT_MS = 60_000 + +/** + * Bound on one stdin write for the last-resort Windows PowerShell 5.1 path. + * + * Measured on Windows 11 26200 / OpenSSH 10.0p2: a 32KB write still hangs 15 times in 120 under + * load, and no smaller value removes the risk. The defect is per blocking read, not per byte, so + * shrinking the chunk trades one risky read for more execs that each carry their own. This is a + * damage bound on a path known to be unreliable, not a safe size. + */ +export const WINDOWS_STDIN_WRITE_CHUNK_BYTES = 32 * 1024 + +export type WindowsWriteOptions = Parameters< + typeof getSystemSshBuildArgsFromOperationOptions +>[0] & { + signal?: AbortSignal + append?: boolean + exclusive?: boolean +} + +/** Bytes to write, plus a way to present them to sftp, which can only send a local file. */ +export type WindowsWriteSource = { + totalBytes: number + readChunk: (offset: number, maxBytes: number) => Promise + withLocalFile: (send: (localPath: string) => Promise) => Promise +} + +function publishMode(options: WindowsWriteOptions): WindowsPublishMode { + return options.append ? 'append' : options.exclusive === true ? 'exclusive' : 'create' +} + +/** + * Writes one file to a Windows host, preferring transports that do not push bytes through a remote + * PowerShell's stdin. + * + * Order, and why: sftp carries the whole payload in one transfer and never has a remote process + * read a pipe. Measured on Windows 11 / OpenSSH 10.0p2: 1.9MB in a median 315ms over sftp against + * 0 of 6 completions on the chunked path, whose best case was ~62 execs at ~350ms each. PowerShell + * 7 reads a redirected stdin correctly but is not installed by default. Windows PowerShell 5.1 is + * always present and is the defective reader, so it is last and it is bounded. + * + * Every transport stages under a unique name and publishes by rename, so no partial write is ever + * visible under the real name and no retry inherits a predecessor's lock. + */ +export async function writeWindowsRemoteFile( + target: SshTarget, + remotePath: string, + source: WindowsWriteSource, + options: WindowsWriteOptions +): Promise { + throwIfAborted(options.signal) + const capabilities = getWindowsRemoteWriteCapabilities(target) + await capabilities.runWithFallback( + 'sftp-subsystem', + () => writeViaSftp(target, remotePath, source, options), + () => writeViaRemoteStdin(target, remotePath, source, options), + isSftpUnavailableError + ) +} + +/** + * Stages under a name nothing else can own, publishes it, and sweeps the staging file if either + * step fails. + * + * Shared by both transports so the cleanup contract cannot drift between them: a failed publish — + * an exclusive conflict is the ordinary case — leaves bytes on the host that no longer have a + * purpose, and the sweep is what stops them accumulating. + */ +async function stageThenPublish( + target: SshTarget, + remotePath: string, + options: WindowsWriteOptions, + stage: (stagingPath: string) => Promise, + nothingStaged: (error: unknown) => boolean = () => false +): Promise { + const stagingPath = makeWindowsStagingPath(remotePath) + try { + await stage(stagingPath) + await publishStagedWrite(target, stagingPath, remotePath, options) + } catch (error) { + // A transport that declined before it moved any bytes has nothing to sweep, and sweeping + // anyway would spend a round trip on every write to a host that has no sftp subsystem. + if (!nothingStaged(error)) { + await discardStagedWrite(target, stagingPath, options) + } + throw error + } +} + +/** + * A path sftp cannot address falls back for this write alone, without touching the host verdict. + * + * The distinction matters because the capability cache is keyed by host and holds for half an hour: + * routing one UNC destination, or one local filename containing a newline, into + * `rememberUnsupported` would send every later write to that host down the defective path too. + */ +async function writeViaSftp( + target: SshTarget, + remotePath: string, + source: WindowsWriteSource, + options: WindowsWriteOptions +): Promise { + try { + await attemptSftpWrite(target, remotePath, source, options) + } catch (error) { + if (!isSftpPathUnsupportedError(error)) { + throw error + } + await writeViaRemoteStdin(target, remotePath, source, options) + } +} + +function attemptSftpWrite( + target: SshTarget, + remotePath: string, + source: WindowsWriteSource, + options: WindowsWriteOptions +): Promise { + const mkdirs = windowsRemoteAncestorDirectories(remotePath).map( + (directory) => `-mkdir ${quoteSftpBatchArgument(toSftpRemotePath(directory))}` + ) + return stageThenPublish( + target, + remotePath, + options, + (stagingPath) => + source.withLocalFile((localPath) => + // One round trip: the parent chain and the payload travel in the same batch. + runSftpBatch( + target, + [ + ...mkdirs, + `put ${quoteSftpBatchArgument(localPath)} ${quoteSftpBatchArgument(toSftpRemotePath(stagingPath))}` + ], + options + ) + ), + isSftpRefusalBeforeStaging + ) +} + +function writeViaRemoteStdin( + target: SshTarget, + remotePath: string, + source: WindowsWriteSource, + options: WindowsWriteOptions +): Promise { + const capabilities = getWindowsRemoteWriteCapabilities(target) + return stageThenPublish(target, remotePath, options, (stagingPath) => + capabilities.runWithFallback( + 'pwsh', + () => writeStdinChunks(target, stagingPath, source, options, 'pwsh.exe'), + () => writeStdinChunks(target, stagingPath, source, options, 'powershell.exe'), + isPwshUnavailableError + ) + ) +} + +/** + * PowerShell 7 takes the whole payload in one exec — measured at 2MB — so only the 5.1 path pays + * for chunking, and only because a bounded write is the most that path can be trusted with. + */ +async function writeStdinChunks( + target: SshTarget, + stagingPath: string, + source: WindowsWriteSource, + options: WindowsWriteOptions, + executable: 'powershell.exe' | 'pwsh.exe' +): Promise { + const chunkBytes = + executable === 'pwsh.exe' ? Math.max(source.totalBytes, 1) : WINDOWS_STDIN_WRITE_CHUNK_BYTES + let offset = 0 + // An empty write still has to run: it is what creates the staged file. + do { + const chunk = await source.readChunk(offset, chunkBytes) + if (chunk.length === 0 && offset < source.totalBytes) { + throw new Error(`Source ran short during upload of ${stagingPath}`) + } + await writeOneStdinChunk( + target, + stagingPath, + chunk, + { ...options, append: offset > 0, exclusive: false }, + offset, + executable + ) + offset += chunk.length + } while (offset < source.totalBytes) +} + +async function writeOneStdinChunk( + target: SshTarget, + stagingPath: string, + chunk: Buffer, + options: WindowsWriteOptions, + offset: number, + executable: 'powershell.exe' | 'pwsh.exe' +): Promise { + throwIfAborted(options.signal) + const channel = spawnSystemSshCommand( + target, + makeWindowsWriteFileCommand(stagingPath, { + append: options.append, + exclusive: options.exclusive, + executable + }), + { wrapCommand: false, ...getSystemSshBuildArgsFromOperationOptions(options) } + ) + const closePromise = awaitWithSystemSshAbort( + options.signal, + () => channel.close(), + waitForChannelClose( + channel, + `write ${stagingPath} at offset ${offset}`, + WINDOWS_STDIN_WRITE_TIMEOUT_MS + ) + ).catch((error: unknown) => { + throw executable === 'powershell.exe' ? explainWindowsPowerShellStdinFailure(error) : error + }) + if (!options.signal?.aborted) { + channel.stdin.end(chunk) + } + await closePromise +} + +/** + * Names the cause on the one path that can hang, so the failure is not just "timed out". + * + * A user seeing this needs to know it is a host limitation with a host-side remedy, not a network + * fault they should retry into. + */ +export function explainWindowsPowerShellStdinFailure(error: unknown): unknown { + const message = error instanceof Error ? error.message : String(error) + if (!/timed out/i.test(message)) { + return error + } + return new Error( + `${message}\nWindows PowerShell 5.1 can lose a redirected stdin permanently when a read finds it momentarily empty, so this write cannot be made reliable from the client. Enable the sftp subsystem on the host (sshd_config: "Subsystem sftp sftp-server.exe"), or install PowerShell 7, and Orca will use it automatically.`, + { cause: error instanceof Error ? error : undefined } + ) +} + +function isPwshUnavailableError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + // cmd.exe's "not recognized" and sshd's exit 9009 both mean "no pwsh here". A timeout does not: + // that is the stdin defect, and PowerShell 7 does not have it, so it must not be cached as absent. + return /is not recognized as an internal or external command|9009|CommandNotFoundException/i.test( + message + ) +} + +async function publishStagedWrite( + target: SshTarget, + stagingPath: string, + remotePath: string, + options: WindowsWriteOptions +): Promise { + await runWindowsCommandWithoutStdin( + target, + makeWindowsPublishStagedFileCommand(stagingPath, remotePath, publishMode(options)), + `publish ${remotePath}`, + options + ) +} + +async function discardStagedWrite( + target: SshTarget, + stagingPath: string, + options: WindowsWriteOptions +): Promise { + try { + await runWindowsCommandWithoutStdin( + target, + makeWindowsDiscardStagedFileCommand(stagingPath), + `discard ${stagingPath}`, + { ...options, signal: undefined } + ) + } catch { + // Housekeeping only. The staging name is unique, so a leftover blocks nothing, and a failure + // here says nothing about whether the abandoned writer is still alive. + } +} + +function runWindowsCommandWithoutStdin( + target: SshTarget, + command: string, + label: string, + options: WindowsWriteOptions +): Promise { + const channel = spawnSystemSshCommand(target, command, { + wrapCommand: false, + ...getSystemSshBuildArgsFromOperationOptions(options) + }) + const closePromise = awaitWithSystemSshAbort( + options.signal, + () => channel.close(), + waitForChannelClose(channel, label, WINDOWS_STDIN_WRITE_TIMEOUT_MS) + ) + if (!options.signal?.aborted) { + channel.stdin.end() + } + return closePromise +} From 7b108abf710d21069691554da8bb4e7d3e2c805a Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 4 Sep 2026 04:34:22 -0400 Subject: [PATCH 12/58] fix(relay): stop taking the fleet-wide cell inventory lock on per-connection paths (#18606) * fix(relay): stop taking the fleet-wide cell inventory lock on per-connection paths activateControl, acquireActivity, changeActivity and removeSupersededSameCellControls each adjust exactly one cell's reservation, yet took SELECT * FROM relay_cells FOR UPDATE, so every desktop rebind and phone reconnect in the fleet queued behind every other one and behind placement. They now use the single-row atomic update (or lock only their own cell row), leaving the inventory lock to placement and sweeps. Fleet-wide 55P03 retries ran p50 430 / p99 1320 per five minutes on 2026-09-03, every cell pinned sqlLatencyMsMax at the lock timeout, and the old cell image crashed on the resulting pool timeouts ~every 15 minutes. A real-Postgres test holds another cell's row and asserts a rebind proceeds; re-adding the inventory lock fails it. * fix(relay): lock the touched cell rows in order on cross-cell activity moves Review found that acquireActivity's existing-lease branch could lock the old lease's cell row (via removeActivityLease) before the new cell's row, which cycles with placement's ascending inventory lock; reproduced on real Postgres as paired 55P03 retries. lockCellRows now takes the one or two rows a per-connection path touches in cell_id order with the 500 ms request bound, and the census fails on any inline relay_cells FOR UPDATE outside the named lock helpers. A three-cell Postgres test moves an activity from the highest cell to a lower one while the target row is held and asserts the mover holds nothing else; five revert-mutants (inventory lock on each path, dropped ordering, dropped ORDER BY) fail it. * test(relay): make the inline relay_cells lock census scan whole statements Review showed two evasions: a FOR UPDATE inside query() and a queryLocked whose FROM relay_cells sat past a fixed line window. The guard now matches every query()/queryLocked() template statement in full; both evasions fail it. Also clears relay_cell_connection_snapshots in the connection- headroom Postgres suite so an aborted run does not poison the next. --- ...nment-connection-headroom-postgres.test.ts | 6 + ...ment-control-supersession-postgres.test.ts | 4 + cloud/apps/relay/src/assignment-store.ts | 32 ++- .../src/cell-inventory-lock-census.test.ts | 73 ++++- ...rol-rebind-inventory-lock-postgres.test.ts | 260 ++++++++++++++++++ 5 files changed, 361 insertions(+), 14 deletions(-) create mode 100644 cloud/apps/relay/src/control-rebind-inventory-lock-postgres.test.ts diff --git a/cloud/apps/relay/src/assignment-connection-headroom-postgres.test.ts b/cloud/apps/relay/src/assignment-connection-headroom-postgres.test.ts index 6ac9521c3d6..80a74a47eeb 100644 --- a/cloud/apps/relay/src/assignment-connection-headroom-postgres.test.ts +++ b/cloud/apps/relay/src/assignment-connection-headroom-postgres.test.ts @@ -44,6 +44,12 @@ describePostgres('PostgreSQL assignment connection headroom', () => { `DELETE FROM relay_assignments WHERE user_id LIKE 'connection-headroom-postgres-%'` ) + // A snapshot left by an aborted run rejects the replayed watermark + // with stale_connection_snapshot. + await database.query( + `DELETE FROM relay_cell_connection_snapshots WHERE cell_id = ?`, + [cell.id] + ) await database.query( `DELETE FROM relay_cell_connection_runtime WHERE cell_id = ?`, [cell.id] diff --git a/cloud/apps/relay/src/assignment-control-supersession-postgres.test.ts b/cloud/apps/relay/src/assignment-control-supersession-postgres.test.ts index 10193b78cc6..cf8819686b5 100644 --- a/cloud/apps/relay/src/assignment-control-supersession-postgres.test.ts +++ b/cloud/apps/relay/src/assignment-control-supersession-postgres.test.ts @@ -38,6 +38,10 @@ describePostgres('PostgreSQL control supersession', () => { [identity.userId] ) await database.query(`DELETE FROM relay_assignments WHERE user_id = ?`, [identity.userId]) + // A snapshot left by an aborted run rejects the replayed watermark with stale_connection_snapshot. + await database.query(`DELETE FROM relay_cell_connection_snapshots WHERE cell_id = ?`, [ + cell.id + ]) await database.query(`DELETE FROM relay_cell_connection_runtime WHERE cell_id = ?`, [cell.id]) await database.query(`DELETE FROM relay_cell_connection_limits WHERE cell_id = ?`, [cell.id]) await database.query(`DELETE FROM relay_cell_runtime WHERE cell_id = ?`, [cell.id]) diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index d0517d46746..9d240e304a7 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -3202,8 +3202,7 @@ export class RelayAssignmentStore { ) const requestDelta = ACTIVITY_REQUEST_UNITS[kind] * (after - before) if (requestDelta !== 0) { - await this.lockCellInventory(transaction, 'request') - await this.adjustCellReservation(transaction, text(row, 'cell_id'), requestDelta) + await this.adjustCellReservationAtomically(transaction, text(row, 'cell_id'), requestDelta) } }) }) @@ -3263,9 +3262,12 @@ export class RelayAssignmentStore { } const units = ACTIVITY_REQUEST_UNITS[input.kind] if (existing) { - await this.lockCellInventory(transaction, 'request') + // Why: a client-chosen activity id can move between cells, so lock the + // one or two rows this path touches in cell_id order, the same order + // placement takes the inventory in, and no cycle can form. + await this.lockCellRows(transaction, [text(existing, 'cell_id'), input.cellId]) await this.removeActivityLease(transaction, identity, existing, now) - await this.adjustCellReservation(transaction, input.cellId, units) + await this.adjustCellReservationAtomically(transaction, input.cellId, units) } await this.adjustActivityCount(transaction, identity, input.kind, 1, expiresAt, now) await transaction.query( @@ -3580,8 +3582,7 @@ export class RelayAssignmentStore { ) await this.touchAssignment(transaction, identity, expiresAt, now) } else { - await this.lockCellInventory(transaction, 'request') - await this.adjustCellReservation(transaction, input.cellId, 1) + await this.adjustCellReservationAtomically(transaction, input.cellId, 1) await this.adjustActivityCount(transaction, identity, 'control', 1, expiresAt, now) await transaction.query( `INSERT INTO relay_assignment_activity_leases @@ -6954,6 +6955,19 @@ export class RelayAssignmentStore { return rows } + // Per-connection paths touch one or two cells. Locking exactly those rows, + // in the same ascending order the inventory lock uses (ORDER BY fixes the + // row-lock order), keeps them off the fleet-wide lock without a cycle. + private async lockCellRows(database: RelayDatabase, cellIds: string[]): Promise { + const distinct = [...new Set(cellIds)] + return await database.queryLocked( + `SELECT * FROM relay_cells WHERE cell_id IN (${distinct.map(() => '?').join(', ')}) + ORDER BY cell_id ASC`, + distinct, + { lockTimeoutMs: CELL_INVENTORY_LOCK_TIMEOUT_MS } + ) + } + private async lockGeneralCellInventory( database: RelayDatabase, mode: CellInventoryLockMode @@ -7590,7 +7604,10 @@ export class RelayAssignmentStore { ) { throw new Error('activity_lease_shape_mismatch') } - const cells = await this.lockCellInventory(database, 'request') + // Why: this recomputes one cell's reservation from its leases, so only that + // row needs to be held; the 23-row inventory lock here serialised every + // desktop control rebind in the fleet behind every other one. + const cellRow = (await this.lockCellRows(database, [cellId]))[0] await database.query( `DELETE FROM relay_assignment_activity_leases WHERE user_id = ? AND relay_host_id = ? AND activity_kind = 'control' @@ -7611,7 +7628,6 @@ export class RelayAssignmentStore { [cellId] ) )[0]! - const cellRow = cells.find((cell) => text(cell, 'cell_id') === cellId) const cellUnits = integer(cellUnitsRow, 'request_units') if (!cellRow) throw new Error('assigned_cell_missing') if (cellUnits > integer(cellRow, 'capacity_requests')) { diff --git a/cloud/apps/relay/src/cell-inventory-lock-census.test.ts b/cloud/apps/relay/src/cell-inventory-lock-census.test.ts index 8ca7cee55f5..a26e15f8e1d 100644 --- a/cloud/apps/relay/src/cell-inventory-lock-census.test.ts +++ b/cloud/apps/relay/src/cell-inventory-lock-census.test.ts @@ -25,10 +25,12 @@ const CENSUS: CensusEntry[] = [ { method: 'assignOnce', mode: 'nowait', reach: 'both' }, { method: 'assignOnce', mode: 'nowait', reach: 'both' }, { method: 'refreshDrainMigrationLeasesOnce', mode: 'request', reach: 'request' }, - // Reachable from neither: changeActivity has no production callers, only tests. - { method: 'changeActivity', mode: 'request', reach: 'orphan' }, - { method: 'acquireActivity', mode: 'request', reach: 'request' }, - { method: 'activateControl', mode: 'request', reach: 'request' }, + // changeActivity, acquireActivity, activateControl and + // removeSupersededSameCellControls no longer take the inventory: they lock + // only the one or two cell rows they touch, in cell_id order (lockCellRows), + // so they cannot cycle with placement's ordered inventory lock, and the + // 23-row lock there had serialised every reconnect in the fleet behind every + // other one. { method: 'startEvacuation', mode: 'request', reach: 'request' }, { method: 'completeEvacuationFromDeadSourceOnce', mode: 'request', reach: 'request' }, { method: 'completeEvacuationFromDeadSourceOnce', mode: 'nowait', reach: 'request' }, @@ -48,8 +50,31 @@ const CENSUS: CensusEntry[] = [ { method: 'releaseExpiredActivityLeases', mode: 'nowait', reach: 'sweep' }, { method: 'releaseExpiredActivity', mode: 'nowait', reach: 'sweep' }, { method: 'reconcileReservationAccounting', mode: 'pool-default', reach: 'both' }, - { method: 'leastLoadedCell', mode: 'pool-default', reach: 'both' }, - { method: 'removeSupersededSameCellControls', mode: 'request', reach: 'request' } + { method: 'leastLoadedCell', mode: 'pool-default', reach: 'both' } +] + +// Every inline `FROM relay_cells ... FOR UPDATE` outside the named lock helpers, +// in source order: whole-table locks in reconciliation and sticky placement, +// and single-row locks for a cell the method is already scoped to (heartbeat, +// fence, drain generation, configuration, or a reservation adjust that runs +// under a lock its caller already holds). A new inline lock fails the census +// below until it is listed here; per-connection paths that touch more than one +// cell go through lockCellRows so the order is fixed. +const NAMED_LOCK_HELPERS = ['lockCellInventory', 'lockGeneralCellInventory', 'lockCellRows'] + +const INLINE_CELL_LOCK_SITES = [ + 'reconcileCellsWithOptions', + 'assignStickyOnce', + 'recordCellHeartbeat', + 'attestCellFence', + 'adoptLegacyCellFence', + 'commitLegacyCellFenceAdoption', + 'prepareCellFenceAttempt', + 'attestCellFenceAttempt', + 'attestCellFenceAttempt', + 'configureCell', + 'assertDrainCellGeneration', + 'adjustCellReservation' ] // The background sweeps, and nothing else. A method reachable from one of these @@ -151,6 +176,42 @@ describe('cell inventory lock call-site census', () => { ) }) + // Why: the census only sees lockCellInventory calls, so a hand-written + // `relay_cells ... FOR UPDATE` would escape classification entirely. + it('routes every relay_cells row lock through a named lock helper', () => { + const lines = storeSource() + const rawSites: string[] = [] + // Whole statements, not a fixed window: a wide column list or a raw + // FOR UPDATE inside query() must not slip past. + const source = lines.join('\n') + const bounds: { name: string; start: number }[] = [] + lines.forEach((line, index) => { + const declaration = DECLARATION.exec(line) + if (declaration) bounds.push({ name: declaration[1]!, start: index }) + }) + const methodAt = (offset: number): string => { + const lineIndex = source.slice(0, offset).split('\n').length - 1 + let name = '' + for (const bound of bounds) if (bound.start <= lineIndex) name = bound.name + return name + } + const tick = String.fromCharCode(96) + const statementCall = new RegExp( + '\\.(queryLocked|query)\\(\\s*' + tick + '([^' + tick + ']*)' + tick, + 'g' + ) + for (const call of source.matchAll(statementCall)) { + const statement = call[2]! + if (!/\bFROM\s+relay_cells\b/.test(statement)) continue + const locks = call[1] === 'queryLocked' || /\bFOR\s+UPDATE\b/.test(statement) + if (!locks) continue + const method = methodAt(call.index) + if (NAMED_LOCK_HELPERS.includes(method)) continue + rawSites.push(method) + } + expect(rawSites).toEqual(INLINE_CELL_LOCK_SITES) + }) + it('leaves no call site taking the inventory without naming a mode', () => { const source = readFileSync(new URL('./assignment-store.ts', import.meta.url), 'utf8') const unclassified = source diff --git a/cloud/apps/relay/src/control-rebind-inventory-lock-postgres.test.ts b/cloud/apps/relay/src/control-rebind-inventory-lock-postgres.test.ts new file mode 100644 index 00000000000..e990ac1ed1a --- /dev/null +++ b/cloud/apps/relay/src/control-rebind-inventory-lock-postgres.test.ts @@ -0,0 +1,260 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import { openRelayDatabase, type RelayDatabase } from './database.js' + +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const describePostgres = databaseUrl ? describe : describe.skip + +// Three cells: the inventory lock covers more than the rows a move touches, and +// a high-to-low move exposes any lock taken out of cell_id order. +const cells = [ + { + id: 'rebind-inventory-postgres-a', + url: 'https://rebind-inventory-postgres-a.example.com', + capacityRequests: 1_000, + connectionHardCap: 600 as const, + connectionUnobservedBound: 50 + }, + { + id: 'rebind-inventory-postgres-b', + url: 'https://rebind-inventory-postgres-b.example.com', + capacityRequests: 1_000, + connectionHardCap: 600 as const, + connectionUnobservedBound: 50 + }, + { + id: 'rebind-inventory-postgres-c', + url: 'https://rebind-inventory-postgres-c.example.com', + capacityRequests: 1_000, + connectionHardCap: 600 as const, + connectionUnobservedBound: 50 + } +] +const identity = { userId: 'rebind-inventory-postgres-user', relayHostId: 'rebindinvhost001' } + +function heartbeat(cell: (typeof cells)[number]) { + return { + cellId: cell.id, + cellUrl: cell.url, + cellIncarnation: '11111111-1111-4111-8111-111111111111', + startedAt: 50, + ready: true, + observedRequests: 0, + totalConnections: 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: 0, + connectionInclusionWatermark: 1, + connectionHardCap: 600 as const, + connectionUnobservedBound: 50 + } +} + +// Why: every desktop control rebind used to take the fleet-wide relay_cells +// FOR UPDATE lock, so a rebind on one cell queued behind whatever held any +// other cell's row, until COMMIT (55P03 at the request bound). A rebind only +// touches its own cell row, so it must proceed while another cell's row is +// held elsewhere. +describePostgres('PostgreSQL control rebind under a held cell row', () => { + const databases: RelayDatabase[] = [] + + beforeAll(async () => { + databases.push( + await openRelayDatabase({ databaseUrl, dataDir: '' }), + await openRelayDatabase({ databaseUrl, dataDir: '' }) + ) + }) + + async function removeTestRows(database: RelayDatabase): Promise { + await database.query( + `DELETE FROM relay_control_connection_reservations WHERE user_id = ?`, + [identity.userId] + ) + for (const table of [ + 'relay_assignment_activity_leases', + 'relay_post_drain_migration_pins', + 'relay_assignment_migration_incarnations', + 'relay_assignment_migrations', + 'relay_assignments' + ]) { + await database.query(`DELETE FROM ${table} WHERE user_id = ?`, [identity.userId]) + } + for (const cell of cells) { + for (const table of [ + 'relay_cell_connection_snapshots', + 'relay_cell_connection_runtime', + 'relay_cell_connection_limits', + 'relay_cell_runtime', + 'relay_cells' + ]) { + await database.query(`DELETE FROM ${table} WHERE cell_id = ?`, [cell.id]) + } + } + } + + afterAll(async () => { + if (databases[0]) await removeTestRows(databases[0]) + for (const connection of databases) await connection.close() + }) + + it("rebinds and supersedes a control while another cell's row is held", async () => { + // A prior aborted run leaves connection snapshots that reject a replayed watermark. + await removeTestRows(databases[0]!) + const store = new RelayAssignmentStore(databases[0]!, () => 100) + await store.reconcileCells(cells) + for (const cell of cells) await store.recordCellHeartbeat(heartbeat(cell)) + // Pin the host to cell A so placement is deterministic. + await store.setCellEnabled(cells[1]!.id, false) + await store.setCellEnabled(cells[2]!.id, false) + const assignment = await store.assign(identity) + expect(assignment.cellId).toBe(cells[0]!.id) + await store.setCellEnabled(cells[1]!.id, true) + await store.setCellEnabled(cells[2]!.id, true) + await store.activateControl(identity, { + cellId: cells[0]!.id, + assignmentEpoch: assignment.assignmentEpoch, + generation: 1, + connectionInclusionWatermark: 10 + }) + + // Hold only cell B's row on a second connection, the way a rebind on B + // does, for longer than the request-path lock bound. + let releaseInventory!: () => void + const inventoryReleased = new Promise((resolve) => { + releaseInventory = resolve + }) + let inventoryHeld!: () => void + const inventoryHeldPromise = new Promise((resolve) => { + inventoryHeld = resolve + }) + const holder = databases[1]!.transaction(async (transaction) => { + await transaction.queryLocked(`SELECT * FROM relay_cells WHERE cell_id = ?`, [cells[1]!.id]) + inventoryHeld() + await inventoryReleased + }) + await inventoryHeldPromise + + // A generation-2 rebind on cell A supersedes generation 1. It must not + // wait on cell B's row. + const startedAt = Date.now() + const blockedStatement = async (): Promise => { + const rows = await databases[1]!.query( + `SELECT left(query, 160) AS q FROM pg_stat_activity + WHERE datname = current_database() AND wait_event_type = 'Lock'` + ) + return rows.map((row) => String(row.q)).join(' | ') + } + const timeout = new Promise((_, reject) => + setTimeout( + () => + void blockedStatement().then((statement) => + reject(new Error(`rebind on cell A blocked behind cell B's row: ${statement}`)) + ), + 2_000 + ) + ) + const rebound = await Promise.race([ + store.activateControl(identity, { + cellId: cells[0]!.id, + assignmentEpoch: assignment.assignmentEpoch, + generation: 2, + connectionInclusionWatermark: 11 + }), + timeout + ]) + const elapsedMs = Date.now() - startedAt + releaseInventory() + await holder + + expect(rebound).toBe(`control:${cells[0]!.id}:2`) + expect(elapsedMs).toBeLessThan(2_000) + const controls = await databases[0]!.query( + `SELECT activity_id FROM relay_assignment_activity_leases + WHERE user_id = ? AND activity_kind = 'control' ORDER BY activity_id`, + [identity.userId] + ) + expect(controls).toEqual([{ activity_id: `control:${cells[0]!.id}:2` }]) + const reserved = await databases[0]!.query( + `SELECT reserved_requests FROM relay_cells WHERE cell_id = ?`, + [cells[0]!.id] + ) + expect(Number(reserved[0]!.reserved_requests)).toBe(1) + }, 15_000) + + // Why: a phone's activity id is client-chosen and can follow the host across + // a migration, so acquireActivity may touch two cell rows. Moving from the + // higher cell to the lower one is where an unordered lock cycles with + // placement's ascending inventory lock (reproduced live before this fix). + it('moves an activity from a higher cell to a lower one in cell_id order', async () => { + await removeTestRows(databases[0]!) + const [cellA, cellB, cellC] = cells as [typeof cells[0], typeof cells[0], typeof cells[0]] + const store = new RelayAssignmentStore(databases[0]!, () => 100) + await store.reconcileCells(cells) + for (const cell of cells) await store.recordCellHeartbeat(heartbeat(cell)) + await store.setCellEnabled(cellA.id, false) + await store.setCellEnabled(cellB.id, false) + const assignment = await store.assign(identity) + expect(assignment.cellId).toBe(cellC.id) + await store.setCellEnabled(cellA.id, true) + await store.setCellEnabled(cellB.id, true) + const activityId = 'splice:rebind-inventory-postgres' + await store.acquireActivity(identity, { activityId, kind: 'splice', cellId: cellC.id }) + // The migration makes B authoritative; the lease still sits on C. + const migration = await store.startEvacuation(identity, cellB.id) + expect(migration.targetCellId).toBe(cellB.id) + + // Hold B elsewhere. An ordered move locks B first and queues here holding + // nothing else. Locking C first (the old lease's row, as an unordered move + // does) or the whole inventory (which takes A) shows up as a held row. + let releaseRow!: () => void + const rowReleased = new Promise((resolve) => { + releaseRow = resolve + }) + let rowHeld!: () => void + const rowHeldPromise = new Promise((resolve) => { + rowHeld = resolve + }) + const heldWhileMoverWaits: string[] = [] + const holder = databases[1]!.transaction(async (transaction) => { + await transaction.queryLocked(`SELECT * FROM relay_cells WHERE cell_id = ?`, [cellB.id]) + rowHeld() + await rowReleased + for (const cell of [cellA, cellC]) { + try { + await transaction.queryLocked(`SELECT * FROM relay_cells WHERE cell_id = ?`, [cell.id], { + failIfUnavailable: true + }) + } catch { + heldWhileMoverWaits.push(cell.id) + } + } + }) + await rowHeldPromise + const move = store.acquireActivity(identity, { activityId, kind: 'splice', cellId: cellB.id }) + let moved = false + void move.then(() => { + moved = true + }) + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(moved).toBe(false) + releaseRow() + await holder + await move + expect(heldWhileMoverWaits).toEqual([]) + + const reservations = await databases[0]!.query( + `SELECT cell_id, reserved_requests FROM relay_cells + WHERE cell_id IN (?, ?, ?) ORDER BY cell_id ASC`, + [cellA.id, cellB.id, cellC.id] + ) + const reserved = reservations.map((row) => [String(row.cell_id), Number(row.reserved_requests)]) + expect(reserved).toEqual([ + [cellA.id, 0], + // Migration grant plus the moved splice, as in the SQLite origin-scoped + // reservation case: the lock change did not alter accounting. + [cellB.id, 6], + // The sticky grant stays on the source until the migration completes. + [cellC.id, 1] + ]) + }, 15_000) +}) From fb69f00b65bb3096ae58010a780fcbf771b7ea17 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:34:47 -0700 Subject: [PATCH 13/58] fix(hosts): resolve a folder workspace's SSH host from the repo's host, not its raw connectionId (#18598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(hosts): resolve a folder workspace's SSH host from the repo's host, not its raw connectionId `resolveFolderWorkspaceHost` inferred a workspace's host by reading `repo.connectionId` directly. SSH ownership has two spellings on a repo row, and a row carrying only `executionHostId: 'ssh:'` has no `connectionId` to read — so it counted as a local repo and the workspace resolved `{ kind: 'local' }`. That is an execute-here answer for a workspace whose files are on an SSH host, the #11163 class, and it fires on a well-formed row. Resolve the host first, then read the target off it. Every other row keeps its existing contribution, including a `runtime:` row's nested SSH target: that target is not this client's to dial, but narrowing it here would be a second behaviour change riding on this one. The runtime branch above still answers `local`, and now says so — `FolderWorkspaceHost` has no runtime variant, and widening the type is its own change, not an oversight to be silently corrected. Three smaller items that stand on their own: - `resolveWorktreeExecutionHost` gains a `malformed` reason distinct from `unknown`. `unknown` (nothing carries the id) is a verdict the launch path may legitimately dispose of as a plain local folder; `malformed` (the row named a host that cannot be parsed) must fail closed. One word for two situations is the shape that lost the distinction in #18006. The strict read is private to that module: `getRepoExecutionHostId` stays the answer everywhere else, since its fall-through to `local` is harmless for the grouping, label and index callers that are nearly all of its ~340 call sites. - `readAllWorktreeMetaForRepo` / `readWorktreeMetaForRepo` replace four open-coded copies of the same host-qualified read (the F7/F8 lockstep shape). - `getExecutionHostLabel` answers 'Unknown host' rather than 'All hosts' for an id that names no host. Showing one unroutable row as though it were on every host is wrong on its own terms. Plain English like every other label in that module, none of which resolve through the renderer's i18n catalog. * fix(hosts): resolve the host in candidate selection too, not just in resolution The first pass fixed how a repo row is classified once it reaches `resolveFolderWorkspaceHost`. The candidate filter decides which rows reach it at all, and it read `repo.connectionId` raw as well — so an SSH-only row outside the project-group subtree was dropped before the new logic could see it, and the execute-here bug survived for the population the fix was for, via a different path. Found in review by CodeRabbit. Three repo-row reads had the same root cause, not one: - the scope-connection filter, comparing a path repo's raw field against the workspace/group connection; - the group-connection set, built from group repos' raw fields; - that set's membership test against path repos' raw fields. The last two are one comparison with the mismatch on either side, so resolving only the path side would have reintroduced it from the other direction. All three, plus the resolution loop, now go through one `getRepoScopeConnectionId` helper. Non-SSH hosts still fall back to the raw field, so a `runtime:` row keeps contributing its nested target exactly as before. The new tests use a repo matched only by path, outside the subtree — the population every existing test missed, which is why four passing revert-tests did not catch this. One of them is labelled as pinning the resolver rather than the filter: under the old raw read both rows came back connectionless and matched each other by accident, so it survives a filter revert and must not be counted as coverage for it. --- .../listing/detected-provider-listing.ts | 7 +- .../register-worktree-catalog-handlers.ts | 11 +- .../listing/ssh-worktree-fallback.ts | 4 +- .../listing/worktree-discovery-metadata.ts | 4 +- .../host-qualified-worktree-meta.ts | 23 ++- src/main/runtime/worktree-launch-host-repo.ts | 10 +- src/shared/execution-host.test.ts | 13 ++ src/shared/execution-host.ts | 9 +- .../folder-workspace-execution-host.test.ts | 188 ++++++++++++++++++ src/shared/folder-workspace-execution-host.ts | 38 +++- ...worktree-execution-host-resolution.test.ts | 34 ++++ .../worktree-execution-host-resolution.ts | 38 +++- 12 files changed, 348 insertions(+), 31 deletions(-) diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing.ts b/src/main/ipc/worktrees/listing/detected-provider-listing.ts index 25c08d262fb..388c825530f 100644 --- a/src/main/ipc/worktrees/listing/detected-provider-listing.ts +++ b/src/main/ipc/worktrees/listing/detected-provider-listing.ts @@ -26,8 +26,7 @@ import { type DetectedWorktreeSideEffectToken } from './detected-worktree-scan-cache' import { loggedWorktreeListFailures, warnOnce } from './worktree-listing-diagnostics' -import { readAllWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta' -import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { readAllWorktreeMetaForRepo } from '../../../persistence/host-qualified-worktree-meta' export async function listDetectedWorktreesForCapturedRepo( store: Store, @@ -40,9 +39,7 @@ export async function listDetectedWorktreesForCapturedRepo( providerAbort?.signal.aborted ? ({ providerAbortStatus: providerAbort.status() } as const) : undefined - const allMeta = isFolderRepo(repo) - ? undefined - : readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo)) + const allMeta = isFolderRepo(repo) ? undefined : readAllWorktreeMetaForRepo(store, repo) // Why: only the disconnected fallbacks read this, so keep parseWorktreeId over the whole host snapshot // off the connected path entirely. let cachedSshWorktreeMetaIndex: SshWorktreeMetaIndex | undefined diff --git a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts index d684461a381..4f3055c63a2 100644 --- a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts @@ -23,7 +23,10 @@ import { warnOnce } from './worktree-listing-diagnostics' import type { WorktreeIpcContext } from '../worktree-ipc-context' -import { readAllWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta' +import { + readAllWorktreeMetaForHost, + readAllWorktreeMetaForRepo +} from '../../../persistence/host-qualified-worktree-meta' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' const WORKTREE_LIST_ALL_CONCURRENCY = 8 @@ -174,9 +177,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo if (!repo) { return [] } - const allMeta = repo.connectionId - ? readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo)) - : undefined + const allMeta = repo.connectionId ? readAllWorktreeMetaForRepo(store, repo) : undefined const sshWorktreeMetaIndex = repo.connectionId ? createSshWorktreeMetaIndex(Object.entries(allMeta ?? {})) : new Map() @@ -226,7 +227,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo }) } loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) - const metadata = allMeta ?? readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo)) + const metadata = allMeta ?? readAllWorktreeMetaForRepo(store, repo) return buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata) .filter((worktree) => worktree.visible) .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata)) diff --git a/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts index 5c734d8bcd9..ecf8ab3abc1 100644 --- a/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts +++ b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts @@ -9,7 +9,7 @@ import type { GitWorktreeInfo, DetectedWorktree, Worktree } from '../../../../sh import type { Store } from '../../../persistence/loading-store/store' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { - readWorktreeMetaForHost, + readWorktreeMetaForRepo, writeWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta' import { getRepoOwnedWorktreeMeta } from '../../../worktree-metadata-ownership' @@ -159,7 +159,7 @@ export function buildDetectedGitWorktrees( const legacyMeta = allMeta === undefined ? store.getWorktreeMeta?.(worktreeId) : undefined const metaById = allMeta ?? (legacyMeta ? { [worktreeId]: legacyMeta } : {}) const meta = - readWorktreeMetaForHost(store, worktreeId, getRepoExecutionHostId(repo)) ?? + readWorktreeMetaForRepo(store, worktreeId, repo) ?? getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount) const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) const detected = toDetectedWorktree({ diff --git a/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts b/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts index b17677ddfcc..3edb8efc1d7 100644 --- a/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts +++ b/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts @@ -4,7 +4,7 @@ import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-lookup' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { - readWorktreeMetaForHost, + readWorktreeMetaForRepo, writeWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta' import { getRepoOwnedWorktreeMeta } from '../../../worktree-metadata-ownership' @@ -44,7 +44,7 @@ export function resolveWorktreeMetaWithDiscoveryBackfill( // Why: the locator-keyed row is only a stand-in for a missing snapshot, so don't read it when we have one. const legacyMeta = allMeta === undefined ? store.getWorktreeMeta?.(worktreeId) : undefined const existing = - readWorktreeMetaForHost(store, worktreeId, executionHostId) ?? + readWorktreeMetaForRepo(store, worktreeId, repo) ?? getRepoOwnedWorktreeMeta( repo, worktreeId, diff --git a/src/main/persistence/host-qualified-worktree-meta.ts b/src/main/persistence/host-qualified-worktree-meta.ts index a9d1c0e8fb1..6267311f471 100644 --- a/src/main/persistence/host-qualified-worktree-meta.ts +++ b/src/main/persistence/host-qualified-worktree-meta.ts @@ -1,4 +1,5 @@ -import type { ExecutionHostId } from '../../shared/execution-host' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import type { Repo } from '../../shared/repo-types' import type { WorktreeMeta } from '../../shared/worktree/meta-types' /** @@ -52,6 +53,26 @@ export function readWorktreeMetaForHost( return store.getWorktreeMetaForHost?.(worktreeId, executionHostId) } +/** + * The same two reads keyed off a repo row, so the resolve-then-read pair lives in one place. Four + * call sites had open-coded it identically, which is the shape that lets one copy drift from the + * rest (F7/F8). + */ +export function readAllWorktreeMetaForRepo( + store: Pick, + repo: Pick +): Record { + return readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo)) +} + +export function readWorktreeMetaForRepo( + store: Pick, + worktreeId: string, + repo: Pick +): WorktreeMeta | undefined { + return readWorktreeMetaForHost(store, worktreeId, getRepoExecutionHostId(repo)) +} + export function writeWorktreeMetaForHost( store: Pick, worktreeId: string, diff --git a/src/main/runtime/worktree-launch-host-repo.ts b/src/main/runtime/worktree-launch-host-repo.ts index 4decb7acb56..7db9f4dae18 100644 --- a/src/main/runtime/worktree-launch-host-repo.ts +++ b/src/main/runtime/worktree-launch-host-repo.ts @@ -17,7 +17,10 @@ export type WorktreeHostRouting = | { kind: 'resolved'; hostId: ExecutionHostId; repo: T | null } /** No row carries this repo id and the worktree names no host — nothing ever named a host. */ | { kind: 'unowned' } - /** Rival rows disagree about the host; guessing one is the cross-host leak. */ + /** + * No single trustworthy host: rival rows disagree, or the resolved row named one that cannot be + * parsed. Guessing is the cross-host leak in both cases. + */ | { kind: 'ambiguous' } /** @@ -33,7 +36,10 @@ export function resolveWorktreeHostRouting { const resolution = resolveWorktreeExecutionHost(createRepoRowExecutionHostLookup(repos), worktree) if (resolution.kind === 'unresolved') { - return resolution.reason === 'ambiguous' ? { kind: 'ambiguous' } : { kind: 'unowned' } + // Only `unknown` — nothing anywhere carries the id — becomes `unowned`, which callers dispose of + // as a plain local folder. `malformed` is a row that declared a host and named an unparseable + // one, so it joins `ambiguous`: guessing is the cross-host leak either way. + return resolution.reason === 'unknown' ? { kind: 'unowned' } : { kind: 'ambiguous' } } return { kind: 'resolved', hostId: resolution.hostId, repo: resolution.owner } } diff --git a/src/shared/execution-host.test.ts b/src/shared/execution-host.test.ts index 9905fc5fe2a..fba1d1fee8d 100644 --- a/src/shared/execution-host.test.ts +++ b/src/shared/execution-host.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { ALL_EXECUTION_HOSTS_SCOPE, LOCAL_EXECUTION_HOST_ID, + getExecutionHostLabel, getLocalExecutionHostLabel, getRepoExecutionHostId, getRepoSshConnectionId, @@ -169,4 +170,16 @@ describe('execution host id delimiter invariant', () => { targetId: 'a|b' }) }) + + // "All hosts" is the everything-scope. Answering with it for an id that names no host shows one + // unroutable row as though it were on every host, which is the opposite of what it is. + it('labels an id that names no host as one unknown host, not as every host', () => { + for (const id of ['ssh:', 'ssh:a|b', 'ssh:%zz', 'runtime:', 'quantum:box'] as const) { + expect(getExecutionHostLabel(id as never)).toBe('Unknown host') + } + expect(getExecutionHostLabel(null)).toBe('Unknown host') + expect(getExecutionHostLabel(ALL_EXECUTION_HOSTS_SCOPE)).toBe('All hosts') + expect(getExecutionHostLabel('ssh:box')).toBe('box') + expect(getExecutionHostLabel('runtime:env-1')).toBe('env-1') + }) }) diff --git a/src/shared/execution-host.ts b/src/shared/execution-host.ts index a77d02b3882..bbe55aea1a0 100644 --- a/src/shared/execution-host.ts +++ b/src/shared/execution-host.ts @@ -226,13 +226,18 @@ export function getSettingsFocusedExecutionHostId( : LOCAL_EXECUTION_HOST_ID } -export function getExecutionHostLabel(id: ExecutionHostScope): string { +export function getExecutionHostLabel(id: ExecutionHostScope | null | undefined): string { if (id === ALL_EXECUTION_HOSTS_SCOPE) { return 'All hosts' } const parsed = parseExecutionHostId(id) if (!parsed) { - return 'All hosts' + // Not "All hosts": an id that names no host is one *unknown* host, and answering with the + // everything-scope label shows an unroutable row as though it were on every host. + // Plain English like every other label in this module (`Local Mac`, `This computer`, + // `All hosts`) — none of them resolve through the renderer's i18n catalog, so a lone + // translated string here would read inconsistently. + return 'Unknown host' } switch (parsed.kind) { case 'local': diff --git a/src/shared/folder-workspace-execution-host.test.ts b/src/shared/folder-workspace-execution-host.test.ts index 2ccced49907..e1af0a7920e 100644 --- a/src/shared/folder-workspace-execution-host.test.ts +++ b/src/shared/folder-workspace-execution-host.test.ts @@ -174,6 +174,194 @@ describe('folder workspace execution host', () => { expect(resolveFolderWorkspaceHost(state({ repos: [] }), 'fw-1')).toEqual({ kind: 'local' }) }) + // SSH ownership has two spellings on a repo row. A row carrying only `executionHostId: 'ssh:*'` + // has no `connectionId`, and reading the raw field counted it as a local repo — so a workspace + // whose files live on an SSH host resolved `local`, which is an execute-here answer for a remote + // path. These fire on well-formed rows; nothing malformed is involved. + it('resolves a repo that names its SSH host only through executionHostId', () => { + const resolved = resolveFolderWorkspaceHost( + state({ + repos: [ + repo({ + id: 'repo-1', + path: '/work/app/a', + projectGroupId: 'group-1', + executionHostId: 'ssh:box' + }) + ] + }), + 'fw-1' + ) + + expect(resolved).toEqual({ kind: 'ssh', targetId: 'box' }) + }) + + it('mixes such a repo with a local one as ambiguous rather than local', () => { + const resolved = resolveFolderWorkspaceHost( + state({ + repos: [ + repo({ id: 'repo-1', path: '/work/app/a', projectGroupId: 'group-1' }), + repo({ + id: 'repo-2', + path: '/work/app/b', + projectGroupId: 'group-1', + executionHostId: 'ssh:box' + }) + ] + }), + 'fw-1' + ) + + expect(resolved).toEqual({ kind: 'ambiguous' }) + }) + + it('matches a scope connection against such a repo instead of calling it ambiguous', () => { + const resolved = resolveFolderWorkspaceHost( + state({ + folderWorkspaces: [workspace({ connectionId: 'box' })], + repos: [ + repo({ + id: 'repo-1', + path: '/work/app/a', + projectGroupId: 'group-1', + executionHostId: 'ssh:box' + }) + ] + }), + 'fw-1' + ) + + expect(resolved).toEqual({ kind: 'ssh', targetId: 'box' }) + }) + + it('reads the target off the host, so a percent-encoded id decodes', () => { + const resolved = resolveFolderWorkspaceHost( + state({ + repos: [ + repo({ + id: 'repo-1', + path: '/work/app/a', + projectGroupId: 'group-1', + executionHostId: `ssh:${encodeURIComponent('box 1')}` + }) + ] + }), + 'fw-1' + ) + + expect(resolved).toEqual({ kind: 'ssh', targetId: 'box 1' }) + }) + + // Deliberately unchanged: a `runtime:` row's nested SSH target is not this client's to dial, but + // narrowing that here would be a second behaviour change riding on the SSH fix. + it('leaves a runtime row contributing its nested connection exactly as before', () => { + const resolved = resolveFolderWorkspaceHost( + state({ + repos: [ + repo({ + id: 'repo-1', + path: '/work/app/a', + projectGroupId: 'group-1', + executionHostId: 'runtime:env-1', + connectionId: 'nested-box' + }) + ] + }), + 'fw-1' + ) + + expect(resolved).toEqual({ kind: 'ssh', targetId: 'nested-box' }) + }) + + it('still answers local for a runtime pin, which the type cannot express otherwise', () => { + const resolved = resolveFolderWorkspaceHost( + state({ + folderWorkspaces: [workspace({ executionHostId: 'runtime:env-1' })] + }), + 'fw-1' + ) + + expect(resolved).toEqual({ kind: 'local' }) + }) + + // The candidate FILTER decides which rows reach the resolver, and it read `repo.connectionId` raw + // too — so an SSH-only repo outside the project-group subtree was dropped before any of the above + // could classify it. Every test before this one uses a repo inside the subtree, which is never + // filtered, so none of them could have caught it (found in review by CodeRabbit). + describe('a repo matched only by path, outside the project-group subtree', () => { + const sshOnlyPathRepo = repo({ + id: 'repo-path', + path: '/work/app/nested', + executionHostId: 'ssh:box' + }) + + it('survives the scope-connection filter instead of being dropped as connectionless', () => { + const scoped = state({ + folderWorkspaces: [workspace({ connectionId: 'box' })], + repos: [sshOnlyPathRepo] + }) + + expect(findFolderWorkspaceCandidateRepos(scoped, 'fw-1')).toEqual([sshOnlyPathRepo]) + expect(resolveFolderWorkspaceHost(scoped, 'fw-1')).toEqual({ kind: 'ssh', targetId: 'box' }) + }) + + // Pins the resolver, not the filter: under the old raw read BOTH rows came back connectionless, + // so they matched each other by accident and this case survived the filter either way. The + // legacy-vs-unified pairing below is the one that discriminates. + it('survives the group-connection filter when the group is on that same SSH host', () => { + const scoped = state({ + repos: [ + repo({ + id: 'repo-group', + path: '/work/app/group', + projectGroupId: 'group-1', + executionHostId: 'ssh:box' + }), + sshOnlyPathRepo + ] + }) + + expect(findFolderWorkspaceCandidateRepos(scoped, 'fw-1')).toHaveLength(2) + expect(resolveFolderWorkspaceHost(scoped, 'fw-1')).toEqual({ kind: 'ssh', targetId: 'box' }) + }) + + // Both sides of the group comparison are resolved, so the legacy spelling on one side and the + // unified spelling on the other still match. + it('matches a legacy-spelled group repo against a unified-spelled path repo', () => { + const scoped = state({ + repos: [ + repo({ + id: 'repo-group', + path: '/work/app/group', + projectGroupId: 'group-1', + connectionId: 'box' + }), + sshOnlyPathRepo + ] + }) + + expect(findFolderWorkspaceCandidateRepos(scoped, 'fw-1')).toHaveLength(2) + expect(resolveFolderWorkspaceHost(scoped, 'fw-1')).toEqual({ kind: 'ssh', targetId: 'box' }) + }) + + // A `runtime:` row's nested target is still read from the raw field, so it matches a scope + // connection exactly as it does today. Pinned so the carve-out stays a decision. + it('leaves a runtime row matching the scope connection through its nested target', () => { + const runtimePathRepo = repo({ + id: 'repo-path', + path: '/work/app/nested', + executionHostId: 'runtime:env-1', + connectionId: 'box' + }) + const scoped = state({ + folderWorkspaces: [workspace({ connectionId: 'box' })], + repos: [runtimePathRepo] + }) + + expect(findFolderWorkspaceCandidateRepos(scoped, 'fw-1')).toEqual([runtimePathRepo]) + }) + }) + it('reads each repository membership once while collecting candidates', () => { let membershipReads = 0 const repos = Array.from({ length: 32 }, (_, index) => { diff --git a/src/shared/folder-workspace-execution-host.ts b/src/shared/folder-workspace-execution-host.ts index dc4dc80c51f..0aee75de543 100644 --- a/src/shared/folder-workspace-execution-host.ts +++ b/src/shared/folder-workspace-execution-host.ts @@ -17,7 +17,7 @@ import type { ProjectGroup } from './project-group-types' import type { Repo } from './repo-types' import { isPathInsideOrEqual } from './cross-platform-path' import { getProjectGroupSubtreeIds } from './project-groups' -import { parseExecutionHostId } from './execution-host' +import { getRepoExecutionHostId, parseExecutionHostId } from './execution-host' export type FolderWorkspaceHostState = { folderWorkspaces: readonly FolderWorkspace[] @@ -36,6 +36,24 @@ export function normalizeConnectionId(value: string | null | undefined): string return value?.trim() || null } +/** + * The SSH target whose filesystem holds this repo's files, or `null` for anything else. + * + * SSH ownership has two spellings on a repo row — the legacy `connectionId` field and the unified + * `executionHostId` — so reading the raw field sees only one of them and a row carrying only + * `executionHostId: 'ssh:'` reads as if it had no connection at all. Every comparison in + * this file goes through here: the candidate filters decide which rows reach the resolver, so + * reading raw in either place drops the row before the resolver can classify it. + * + * A non-SSH host falls back to the raw field so a `runtime:` row keeps contributing its nested + * target exactly as it does today. That target is not this client's to dial, but changing it is a + * separate defect with its own reasoning — see the note in `resolveFolderWorkspaceHost`. + */ +function getRepoScopeConnectionId(repo: Repo): string | null { + const host = parseExecutionHostId(getRepoExecutionHostId(repo)) + return host?.kind === 'ssh' ? host.targetId : normalizeConnectionId(repo.connectionId) +} + function getFolderScopeCandidateRepos(args: { folderPath: string projectGroupId: string @@ -59,18 +77,18 @@ function getFolderScopeCandidateRepos(args: { if (args.connectionId) { return [ ...groupRepos, - ...pathRepos.filter((repo) => normalizeConnectionId(repo.connectionId) === args.connectionId) + ...pathRepos.filter((repo) => getRepoScopeConnectionId(repo) === args.connectionId) ] } if (groupRepos.length === 0) { return pathRepos } - const groupConnectionIds = new Set( - groupRepos.map((repo) => normalizeConnectionId(repo.connectionId)) - ) + // Both sides resolved: comparing a resolved path repo against a raw group read would reintroduce + // the same mismatch from the other direction. + const groupConnectionIds = new Set(groupRepos.map(getRepoScopeConnectionId)) return [ ...groupRepos, - ...pathRepos.filter((repo) => groupConnectionIds.has(normalizeConnectionId(repo.connectionId))) + ...pathRepos.filter((repo) => groupConnectionIds.has(getRepoScopeConnectionId(repo))) ] } @@ -102,6 +120,12 @@ export function resolveFolderWorkspaceHost( } const explicitHost = parseExecutionHostId(workspace.executionHostId) if (explicitHost) { + // A `runtime:` workspace deliberately answers `local`, and `FolderWorkspaceHost` has no runtime + // variant to answer with instead. That omission is known: a runtime environment's own server + // normalizes its work to `local`, and the nested SSH target on such a row is addressable only as + // the pair (environmentId, targetId) — handing it to this client's SSH table would dial a + // same-named box in the wrong namespace. Widening the type is its own change, not an oversight + // here. return explicitHost.kind === 'ssh' ? { kind: 'ssh', targetId: explicitHost.targetId } : { kind: 'local' } @@ -114,7 +138,7 @@ export function resolveFolderWorkspaceHost( let hasLocalRepo = false const connectionIds = new Set() for (const repo of candidateRepos) { - const connectionId = normalizeConnectionId(repo.connectionId) + const connectionId = getRepoScopeConnectionId(repo) if (connectionId) { connectionIds.add(connectionId) } else { diff --git a/src/shared/worktree-execution-host-resolution.test.ts b/src/shared/worktree-execution-host-resolution.test.ts index b82cea476eb..b31281fe925 100644 --- a/src/shared/worktree-execution-host-resolution.test.ts +++ b/src/shared/worktree-execution-host-resolution.test.ts @@ -156,6 +156,40 @@ describe('resolveWorktreeExecutionHost', () => { it('reports an unknown owner distinctly from a conflicting one', () => { expect(resolve([], { repoId: 'r' })).toEqual({ kind: 'unresolved', reason: 'unknown' }) }) + + // `unknown` is a verdict the launch path disposes of as a plain local folder, so a row that + // declared a host and named an unparseable one must not share the word — it has to fail closed. + it('reports a row naming an unparseable host distinctly from an unknown one', () => { + for (const executionHostId of ['ssh:', 'ssh:a|b', 'ssh:%zz', 'runtime:', 'quantum:box']) { + expect(resolve([{ id: 'r', executionHostId }], { repoId: 'r' })).toEqual({ + kind: 'unresolved', + reason: 'malformed' + }) + } + }) + + it('does not recover a host from the connectionId such a row overrode', () => { + expect( + resolve([{ id: 'r', executionHostId: 'ssh:a|b', connectionId: 'openclaw' }], { + repoId: 'r' + }) + ).toEqual({ kind: 'unresolved', reason: 'malformed' }) + }) + + it('still resolves every row that names a parseable host', () => { + expect(resolve([{ id: 'r', executionHostId: 'ssh:box' }], { repoId: 'r' })).toMatchObject({ + kind: 'resolved', + hostId: 'ssh:box' + }) + expect(resolve([{ id: 'r', connectionId: 'box' }], { repoId: 'r' })).toMatchObject({ + kind: 'resolved', + hostId: 'ssh:box' + }) + expect(resolve([{ id: 'r' }], { repoId: 'r' })).toMatchObject({ + kind: 'resolved', + hostId: 'local' + }) + }) }) it('ignores an unparseable host id rather than treating it as a host', () => { diff --git a/src/shared/worktree-execution-host-resolution.ts b/src/shared/worktree-execution-host-resolution.ts index 00b66b7f11c..0172106aee0 100644 --- a/src/shared/worktree-execution-host-resolution.ts +++ b/src/shared/worktree-execution-host-resolution.ts @@ -54,7 +54,29 @@ export type WorktreeExecutionHostResolution = /** Display metadata only. The decisions are `hostId` / `connectionId`. */ owner: T | null } - | { kind: 'unresolved'; reason: 'ambiguous' | 'unknown' } + /** + * Three reasons, not two, and deliberately not collapsed. `unknown` (nothing carries the id) is a + * verdict the launch path may legitimately dispose of as a plain local folder; `malformed` (the + * row named a host that cannot be parsed) must fail closed. A vocabulary that cannot express the + * difference guarantees it is lost at the first caller that switches on it — the same shape as + * #18006, where one word had to stand for two liveness situations. + */ + | { kind: 'unresolved'; reason: 'ambiguous' | 'unknown' | 'malformed' } + +/** + * The owner row's host, or `null` when the row names one that cannot be parsed. + * + * Module-private and deliberately not a second exported reading of a repo row: only this resolution + * needs the distinction, because only this resolution is routing. `getRepoExecutionHostId` stays the + * answer everywhere else — its fall-through to `local` is harmless for the grouping, label and index + * callers that make up nearly all of its ~340 call sites, and is wrong only when the value decides + * where work runs. + */ +function resolveOwnerRowHostId(row: ExecutionHostOwnerRow): ExecutionHostId | null { + return row.executionHostId?.trim() + ? normalizeExecutionHostId(row.executionHostId) + : getRepoExecutionHostId(row) +} export function resolveWorktreeExecutionHost( lookup: ExecutionHostOwnerLookup, @@ -80,9 +102,13 @@ export function resolveWorktreeExecutionHost( if (match.kind !== 'resolved') { return { kind: 'unresolved', reason: match.kind === 'ambiguous' ? 'ambiguous' : 'unknown' } } + const hostId = resolveOwnerRowHostId(match.owner) + if (!hostId) { + return { kind: 'unresolved', reason: 'malformed' } + } return { kind: 'resolved', - hostId: getRepoExecutionHostId(match.owner), + hostId, connectionId: getRepoSshConnectionId(match.owner), owner: match.owner } @@ -115,12 +141,14 @@ export function createRepoRowExecutionHostLookup getRepoExecutionHostId(repo) !== ownerHostId) + const ownerHostId = resolveOwnerRowHostId(owner) + return rows.some((repo) => resolveOwnerRowHostId(repo) !== ownerHostId) ? { kind: 'ambiguous' } : { kind: 'resolved', owner } }, + // A row naming an unparseable host matches no host, which is what stops a worktree on a real + // host from adopting it. byHost: (repoId, hostId) => - rowsFor(repoId).find((repo) => getRepoExecutionHostId(repo) === hostId) ?? null + rowsFor(repoId).find((repo) => resolveOwnerRowHostId(repo) === hostId) ?? null } } From 01d7228b7e971749b1f3da06f8e9a6be18e76b94 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:44:52 -0700 Subject: [PATCH 14/58] docs: add WeChat group 9 QR code Adds group 9 QR fallback QR codes and capacity guidance to the README variants. --- README.md | 4 ++-- docs/assets/wechat-qr-group9.jpg | Bin 0 -> 395815 bytes docs/readme/README.fr.md | 4 ++-- docs/readme/README.ko.md | 4 ++-- docs/readme/README.zh-CN.md | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) create mode 100644 docs/assets/wechat-qr-group9.jpg diff --git a/README.md b/README.md index 7a3cbe2360c..2ae59035da8 100644 --- a/README.md +++ b/README.md @@ -238,9 +238,9 @@ Pair with your desktop app to monitor and steer your agents from your phone. - **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**. - **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements. -- **WeChat:** Scan to join the Orca community WeChat group 8. +- **WeChat:** Scan to join the Orca community WeChat group 8. Group 8 may be full; if so, scan the Group 9 QR code instead. - WeChat group 8 QR code for the Orca community + WeChat group 8 QR code for the Orca community  WeChat group 9 QR code for the Orca community - **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues). - **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out. diff --git a/docs/assets/wechat-qr-group9.jpg b/docs/assets/wechat-qr-group9.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2bf46a28c3d464682461ffe47a0bde9a797fd1e8 GIT binary patch literal 395815 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%0A})3YYTWdQ<)MjpTIe+>DtyHDxbw7R>fp<(p2Sw zYQ1}PrhRk%0jX%0Gm6iQ#PbsdC0{-?p~~6(N}d&kyg4M0b&)2mX+JhE9w%nKxzq2J z)BfkmVfI|Yc600DsE+mWkd~n~Sh3hp@0{LQ!GD!Katun3Sn0pZI6;u6;lv_W1s!6| z4fr3G?*ItpPQ;zmb+ICO0v+2*Pb}tr)c}>F<-{gM9%|a-Xq+Jn^j5N`=@w$ciTQX~ zo$5rq+G30{lZcfE)TofNMdXrJox95AtKJXl{iF7=)M4-cGLlk7kyDHDV;VK_TpPjl zk)n#5nR81qvjGd`7aQmKcXew6t!!oiB1_{g%zk+?2HH)zj&U73@QIn(7(T!Cfe1As zZoRCrVie=4Mfr>rRQVmgS@u@;zMI-*N#VtUl`|@qli>!@xgr=>US?r@smLVZKC$H>_4|8z-FOGvv4@#$G>Y1 zSRZI`pd<){_kXO zJr|j+X|DUa!P}zdTWe621PXgMjhws~?;@vmU_zB&t#}$*$si{a?wSPVbOP~eBUccH zsxto*0!-)?vvgMfUWC>Jbm*ftKK?vt^LHUAGUpBrT?pJ_^S#UOPl0j03f+f-?7}|*p+T$jzT4!V z;;-zXFJ6d|RWG;VS97hz3ZWsvatr)X5fEBrmbsd^sA5xzjeVonX1xuqm=_;#z1lx9 zWADA*qEmZh2QS_Wl*Ptu!t=<<)?jbpAXRP3k*Ymw7;ipTJF+F>q;#fcjB-AbWyIY= zSm3t4jYG>m=I09|v*1G`v�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

+eJYM%OGbb%m6-p4VPnsq6KXXx#RFfH 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

Ky`!`w&HWv8|oX%eL8Ne^#M$IeKRU@Yp+VCw> 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

HymMEbr4sJE{6) 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�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 literal 0 HcmV?d00001 diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index 97c78d4e713..e601abc2344 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -243,9 +243,9 @@ Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votr - **Discord :** Rejoignez la communauté sur **[Discord](https://discord.gg/fzjDKHxv8Q)**. - **Twitter / X :** Suivez **[@orca_build](https://x.com/orca_build)** pour les news et annonces. -- **WeChat :** Scannez pour rejoindre le groupe WeChat 8 de la communauté Orca. +- **WeChat :** Scannez pour rejoindre le groupe WeChat 8 de la communauté Orca. Le groupe 8 est peut-être complet ; dans ce cas, scannez plutôt le QR code du groupe 9. - QR code WeChat groupe 8 de la communauté Orca + QR code WeChat groupe 8 de la communauté Orca  QR code WeChat groupe 9 de la communauté Orca - **Feedback & idées :** On ship vite. Il manque quelque chose ? [Demandez une feature](https://github.com/stablyai/orca/issues). - **Confidentialité :** Voir la [doc confidentialité & télémétrie](https://www.onorca.dev/docs/telemetry) pour ce qu'Orca collecte en anonyme et comment désactiver la télémétrie. diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 4a75722ff8c..837ecf2133f 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -238,9 +238,9 @@ yay -S stably-orca-bin - **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** 커뮤니티에 참여하세요. - **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요. -- **WeChat:** QR 코드를 스캔해 Orca 커뮤니티 WeChat 그룹 8에 참여하세요. +- **WeChat:** QR 코드를 스캔해 Orca 커뮤니티 WeChat 그룹 8에 참여하세요. 그룹 8이 가득 찼을 수 있으니, 그런 경우 그룹 9 QR 코드를 스캔하세요. - Orca 커뮤니티 WeChat 그룹 8 QR 코드 + Orca 커뮤니티 WeChat 그룹 8 QR 코드  Orca 커뮤니티 WeChat 그룹 9 QR 코드 - **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요. - **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요. diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index d7bae3fba9e..10f47e20fe6 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -235,9 +235,9 @@ yay -S stably-orca-bin - **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。 - **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。 -- **微信:** 扫码加入 Orca 社区微信第 8 群。 +- **微信:** 扫码加入 Orca 社区微信第 8 群。第 8 群可能已满,如遇这种情况请扫描第 9 群二维码。 - Orca 社区微信第 8 群二维码 + Orca 社区微信第 8 群二维码  Orca 社区微信第 9 群二维码 - **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。 - **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。 From 766b5b153c9f009d1445ad0d0aed4ac3f179973d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:56:42 -0700 Subject: [PATCH 15/58] fix(relay): release the ConPTY conin handle after teardown, not before it (#18601) A Windows SSH relay leaked one Windows File handle per terminal, for the life of the relay process, across reconnects. node-pty's `kill()` flips `readable` on the conin and conout sockets and destroys neither; `_cleanUpProcess` destroys `_outSocket`, so only conin is stranded, and it wraps a real named-pipe handle from `fs.openSync(term.conin, 'w')`. The obvious fix -- and the one config/patches/node-pty@1.1.0.patch ships for the desktop -- releases it at the top of the branch, before `_getConsoleProcessList()` forks and before the native kill. Measured against a real Windows SSH host, that is three times worse than leaving the leak alone: teardown aborts partway, the forked console-list agent is never reaped, and both pipe handles stay alive. Releasing it at the end of the branch instead is flat. 20 spawn/kill cycles, handles bucketed by NT object type, identical numbers standalone and through a real relay: published node-pty File +1/terminal, Process flat desktop patch placement File +2/terminal, Process +1/terminal released last (this) File flat, Process flat `windowsTerminal.js` takes the desktop's error-listener hunks verbatim. The conin listener is not what fixes the leak -- adding it alone changed nothing -- but it is what keeps a pipe error retiring one terminal instead of the host. The desktop patch has the early placement and therefore the regression, measured against its exact installed tree. Correcting it there needs its own verification on a Windows desktop build, so the trees diverge on this one hunk deliberately and a test pins that so a future patch sync cannot copy the bug back. --- ...e-pty-1.1.0-windows-pty-teardown-patch.cjs | 158 ++++++++++++++ config/scripts/build-relay.mjs | 11 + ...de-pty-windows-pty-teardown-patch.test.mjs | 195 ++++++++++++++++++ src/main/ssh/ssh-relay-deploy.ts | 25 ++- src/shared/relay-artifacts.ts | 4 + 5 files changed, 386 insertions(+), 7 deletions(-) create mode 100644 config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs create mode 100644 config/scripts/node-pty-windows-pty-teardown-patch.test.mjs diff --git a/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs b/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs new file mode 100644 index 00000000000..dd26784ee46 --- /dev/null +++ b/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs @@ -0,0 +1,158 @@ +const { createHash } = require('node:crypto') +const { readFileSync, renameSync, rmSync, writeFileSync } = require('node:fs') +const { join, resolve } = require('node:path') + +/** + * Release the ConPTY teardown handles a relay's npm-installed node-pty never releases. + * + * Two files, and the ORDER of one of the edits is the whole fix. + * + * `windowsPtyAgent.js` -- `kill()` flips `readable` on both sockets and destroys neither. + * `_cleanUpProcess` destroys `_outSocket`, so the conout handle comes back; nothing ever destroys + * `_inSocket`, and it wraps a real Windows named-pipe handle from `fs.openSync(term.conin, 'w')`. + * Every terminal leaks one File handle for the life of the host process. + * + * The obvious fix -- and the one the desktop patch ships -- releases it at the TOP of the branch, + * before `_getConsoleProcessList()` forks and before the native kill. That is measurably worse than + * leaving the leak alone: teardown aborts partway, the forked console-list agent is never reaped, + * and both pipe handles stay alive instead of one. This asset releases it at the END of the branch + * instead, after the fork and the kill have already happened. + * + * Measured on a Windows SSH host, 20 spawn/kill cycles, handles bucketed by NT object type + * (identical numbers standalone and through a real relay): + * + * published node-pty File +1/terminal, Process flat + * desktop patch placement File +2/terminal, Process +1/terminal <-- 3x WORSE + * released last (here) File flat, Process flat + * + * `windowsTerminal.js` carries the desktop's error-listener hunks verbatim. The conin listener is + * what keeps a pipe error retiring one terminal instead of the host -- its own comment names the + * failure mode: "Without a listener, Node promotes errors such as write EAGAIN to uncaughtException". + * It is not what fixes the leak (adding it changed nothing on its own), but it is the guard that + * makes destroying conin safe at all. + * + * Why this ships as a relay asset rather than only in config/patches/node-pty@1.1.0.patch: pnpm + * patches do not cross the SSH boundary -- a relay host runs the tree `npm install` put there. + * + * DELIBERATE DIVERGENCE FROM THE DESKTOP: the desktop patch has the early placement and therefore + * the +2 File / +1 Process regression, measured against its exact installed tree. Correcting it + * there is a separate change with its own verification, so the two trees differ on this one hunk on + * purpose, and the test pins that so a future "sync the patches" does not copy the bug back. + * + * NOT ADDRESSED, AND A SEPARATE DEFECT THAT IS STILL OPEN: a terminal that exits on its own is + * still torn down through `kill()` -- both hosts call `destroy()` on natural exit and + * `WindowsTerminal.destroy()` is `kill()` -- but the shell is already gone by then, and the + * ordering this patch relies on does not hold. Measured over 20 self-exit cycles with that + * `destroy()` issued: published +3 File/+1 Process per terminal, desktop-patched +2/+1, this tree + * +2/+1. So this patch does not close it and the desktop patch does not either. It is reachable + * for every Windows user, local and relay, on every terminal closed by typing `exit`. + */ + +const EXPECTED_NODE_PTY_VERSION = '1.1.0' + +/** Each entry is one published file, its patched form, and the edits between them. */ +const PATCH_TARGETS = [ + { + relativePath: ['lib', 'windowsPtyAgent.js'], + originalSha256: '8636d16b38266112204061a22b135734177c242837982fd3a4055be726efa64a', + patchedSha256: '1e23ef480569e73706e3ab4f5482c7e553c76f51414ae8e7b0bdcc2fd75f7280', + replacements: [ + [ + ' this._ptyNative.kill(this._pty, this._useConptyDll);\n this._conoutSocketWorker.dispose();\n', + ' this._ptyNative.kill(this._pty, this._useConptyDll);\n this._conoutSocketWorker.dispose();\n // Orca: released AFTER the console-list fork and the native kill, not before them.\n // Destroying conin first aborts teardown partway -- measured on a Windows SSH relay\n // as +2 File and +1 Process handles per terminal, against +1 File unpatched.\n this._inSocket.destroy();\n' + ] + ] + }, + { + relativePath: ['lib', 'windowsTerminal.js'], + originalSha256: 'c3a65716f53fed0135a8a633373d5f9c2ab092544d651f27ef0a67096dd3bcd9', + patchedSha256: '8247ecd69be8b18257050fb026b290024612c5ffc6d492ff1d46f81e613be2cf', + replacements: [ + [ + ' _this._agent = new windowsPtyAgent_1.WindowsPtyAgent(file, args, parsedEnv, cwd, _this._cols, _this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor);\n _this._socket = _this._agent.outSocket;\n // Not available until `ready` event emitted.\n _this._pid = _this._agent.innerPid;', + " _this._agent = new windowsPtyAgent_1.WindowsPtyAgent(file, args, parsedEnv, cwd, _this._cols, _this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor);\n _this._socket = _this._agent.outSocket;\n // Attach before readiness so a broken ConPTY output pipe cannot be unhandled.\n _this._socket.on('error', function (err) {\n var code = err && err.code;\n // PTY output can report EPIPE before `_close()` wins the race.\n _this._close();\n if (code === 'EPIPE' || code === 'ERR_STREAM_PUSH_AFTER_EOF' || code === 'ERR_STREAM_DESTROYED') {\n return;\n }\n // EIO, happens when someone closes our child process: the only process\n // in the terminal.\n // node < 0.6.14: errno 5\n // node >= 0.6.14: read EIO\n if (typeof code === 'string') {\n if (~code.indexOf('errno 5') || ~code.indexOf('EIO'))\n return;\n }\n // Throw anything else.\n if (_this.listeners('error').length < 2) {\n throw err;\n }\n });\n // Not available until `ready` event emitted.\n _this._pid = _this._agent.innerPid;" + ], + [ + " }\n });\n // Shutdown if `error` event is emitted.\n _this._socket.on('error', function (err) {\n // Close terminal session.\n _this._close();\n // EIO, happens when someone closes our child process: the only process\n // in the terminal.\n // node < 0.6.14: errno 5\n // node >= 0.6.14: read EIO\n if (err.code) {\n if (~err.code.indexOf('errno 5') || ~err.code.indexOf('EIO'))\n return;\n }\n // Throw anything else.\n if (_this.listeners('error').length < 2) {\n throw err;\n }\n });\n // Cleanup after the socket is closed.\n _this._socket.on('close', function () {", + " }\n });\n // Cleanup after the socket is closed.\n _this._socket.on('close', function () {" + ], + [ + ' _this._readable = true;\n _this._writable = true;\n _this._forwardEvents();\n return _this;', + " _this._readable = true;\n _this._writable = true;\n // A ConPTY input-pipe error must retire only this terminal. Without a listener, Node promotes\n // errors such as write EAGAIN to uncaughtException and kills every PTY in the daemon.\n _this._agent.inSocket.on('error', function () {\n if (!_this._writable) {\n return;\n }\n _this._close();\n try {\n _this._agent.kill();\n }\n catch (_a) {\n // The failing pipe may have raced process exit; the terminal is already unwritable.\n }\n });\n _this._forwardEvents();\n return _this;" + ], + [ + 'exports.WindowsTerminal = WindowsTerminal;\n//# sourceMappingURL=windowsTerminal.js.map', + 'exports.WindowsTerminal = WindowsTerminal;\n//# sourceMappingURL=windowsTerminal.js.map\n' + ] + ] + } +] + +function inspectTarget(relayDir, target) { + const nodePtyDir = resolve(relayDir, 'node_modules', 'node-pty') + const packageJson = JSON.parse(readFileSync(join(nodePtyDir, 'package.json'), 'utf8')) + if (packageJson.version !== EXPECTED_NODE_PTY_VERSION) { + throw new Error( + `Refusing to patch node-pty ${packageJson.version}; expected ${EXPECTED_NODE_PTY_VERSION}` + ) + } + const filePath = join(nodePtyDir, ...target.relativePath) + return { filePath, source: readFileSync(filePath, 'utf8') } +} + +function assertPatchedNodePtyWindowsTeardown(relayDir = process.cwd()) { + for (const target of PATCH_TARGETS) { + const inspected = inspectTarget(relayDir, target) + if (sourceSha256(inspected.source) !== target.patchedSha256) { + throw new Error( + `node-pty ConPTY teardown release is not installed in ${target.relativePath.join('/')}` + ) + } + } +} + +function patchNodePtyWindowsTeardown(relayDir = process.cwd()) { + for (const target of PATCH_TARGETS) { + const inspected = inspectTarget(relayDir, target) + const sourceHash = sourceSha256(inspected.source) + if (sourceHash === target.patchedSha256) { + continue + } + if (sourceHash !== target.originalSha256) { + throw new Error( + `Refusing to patch unexpected node-pty source in ${target.relativePath.join('/')}` + ) + } + let patchedSource = inspected.source + for (const [from, to] of target.replacements) { + // Why the count check: an anchor that matched twice would patch the wrong site silently, and + // the hash below would then reject a tree this script had already rewritten. + if (patchedSource.split(from).length - 1 !== 1) { + throw new Error(`Refusing to patch ${target.relativePath.join('/')}; anchor is not unique`) + } + patchedSource = patchedSource.replace(from, to) + } + const temporaryPath = `${inspected.filePath}.orca-patch-${process.pid}` + // Why: a terminated remote install must leave either known source version recoverable on reconnect. + try { + writeFileSync(temporaryPath, patchedSource) + renameSync(temporaryPath, inspected.filePath) + } finally { + rmSync(temporaryPath, { force: true }) + } + } + assertPatchedNodePtyWindowsTeardown(relayDir) +} + +function sourceSha256(source) { + return createHash('sha256').update(source).digest('hex') +} + +if (require.main === module) { + patchNodePtyWindowsTeardown() +} + +module.exports = { + assertPatchedNodePtyWindowsTeardown, + patchNodePtyWindowsTeardown +} diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 289c7a957bd..4d408712f97 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -57,6 +57,13 @@ const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join( 'relay-assets', NODE_PTY_CONSOLE_LIST_PATCH_FILENAME ) +const NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME = 'node-pty-1.1.0-windows-pty-teardown-patch.cjs' +const NODE_PTY_WINDOWS_TEARDOWN_PATCH_SOURCE = join( + ROOT, + 'config', + 'relay-assets', + NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME +) const NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME = 'node-pty-1.1.0-master-cloexec-patch.cjs' const NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE = join( ROOT, @@ -132,6 +139,10 @@ for (const platform of RELAY_BUILD_PLATFORMS) { NODE_PTY_CONSOLE_LIST_PATCH_SOURCE, join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME) ) + copyFileSync( + NODE_PTY_WINDOWS_TEARDOWN_PATCH_SOURCE, + join(outDir, NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME) + ) } copyFileSync( NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE, diff --git a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs new file mode 100644 index 00000000000..380cdd44c01 --- /dev/null +++ b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs @@ -0,0 +1,195 @@ +// The relay's copy of the ConPTY teardown release, and the guard that keeps it in lockstep with the +// desktop's own node-pty patch. pnpm patches do not cross the SSH boundary, so a relay runs the tree +// `npm install` put there; the desktop had this fix and the relay did not, and every terminal on a +// Windows SSH host leaked one File handle for the life of the relay process. +// +// The ORDER of the conin release is the fix. Releasing it at the top of the branch -- what the +// desktop patch does -- was measured at 3x WORSE than shipping nothing (File +2/terminal and a new +// Process +1/terminal); releasing it after the console-list fork and the native kill is flat. +import { createRequire } from 'node:module' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + assertPatchedNodePtyWindowsTeardown, + patchNodePtyWindowsTeardown +} = require('../relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs') +const projectDir = resolve(import.meta.dirname, '..', '..') +const cleanupDirs = [] + +const PATCHED_FILES = ['windowsPtyAgent.js', 'windowsTerminal.js'] + +/** The hunks config/patches/node-pty@1.1.0.patch adds to the installed desktop tree. */ +const DESKTOP_HUNKS = { + 'windowsPtyAgent.js': [ + [ + [ + ' this._inSocket.readable = false;', + ' // The non-DLL path previously only flipped `readable`, leaving the', + ' // conin PipeWrap alive until the host exited (#947).', + ' this._inSocket.destroy();', + ' this._outSocket.readable = false;', + '' + ].join('\n'), + [ + ' this._inSocket.readable = false;', + ' this._outSocket.readable = false;', + '' + ].join('\n') + ] + ], + 'windowsTerminal.js': [ + [ + ' // Attach before readiness so a broken ConPTY output pipe cannot be unhandled.', + null + ], + [' // A ConPTY input-pipe error must retire only this terminal.', null] + ] +} + +function desktopPath(file) { + return join(projectDir, 'node_modules', 'node-pty', 'lib', file) +} + +afterEach(() => { + for (const dir of cleanupDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('Windows SSH relay node-pty ConPTY teardown patch', () => { + // Why reconstruct rather than vendor upstream: the installed tree IS the published file plus the + // desktop's hunks, so un-applying them yields upstream exactly -- and pinning that against this + // asset's own hashes is what fails loudly if either side of the pair moves. + it('takes the desktop error listeners verbatim', () => { + const fixture = writeNodePtyFixture('1.1.0') + patchNodePtyWindowsTeardown(fixture.root) + + expect(readFileSync(join(fixture.libDir, 'windowsTerminal.js'), 'utf8')).toBe( + readFileSync(desktopPath('windowsTerminal.js'), 'utf8') + ) + }) + + // The one hunk that must NOT match the desktop, and the reason is measured, not stylistic: + // releasing conin before `_getConsoleProcessList()` forks aborts teardown partway. + it('releases conin after the console-list fork, not before it like the desktop patch', () => { + const fixture = writeNodePtyFixture('1.1.0') + patchNodePtyWindowsTeardown(fixture.root) + const patched = readFileSync(join(fixture.libDir, 'windowsPtyAgent.js'), 'utf8') + + const branch = patched.slice( + patched.indexOf('if (!this._useConptyDll) {'), + patched.indexOf('else {', patched.indexOf('if (!this._useConptyDll) {')) + ) + expect(branch).toContain('this._inSocket.destroy();') + expect(branch.indexOf('this._inSocket.destroy();')).toBeGreaterThan( + branch.indexOf('this._conoutSocketWorker.dispose();') + ) + expect(branch.indexOf('this._inSocket.destroy();')).toBeGreaterThan( + branch.indexOf('this._getConsoleProcessList()') + ) + // Pinned so a future "sync the relay asset to config/patches" cannot copy the regression back. + expect(patched).not.toBe(readFileSync(desktopPath('windowsPtyAgent.js'), 'utf8')) + }) + + it('installs and verifies idempotently', () => { + const fixture = writeNodePtyFixture('1.1.0') + + patchNodePtyWindowsTeardown(fixture.root) + const once = PATCHED_FILES.map((file) => readFileSync(join(fixture.libDir, file), 'utf8')) + for (const file of PATCHED_FILES) { + expect(existsSync(`${join(fixture.libDir, file)}.orca-patch-${process.pid}`)).toBe(false) + } + expect(() => assertPatchedNodePtyWindowsTeardown(fixture.root)).not.toThrow() + + patchNodePtyWindowsTeardown(fixture.root) + expect(PATCHED_FILES.map((file) => readFileSync(join(fixture.libDir, file), 'utf8'))).toEqual( + once + ) + }) + + it('refuses a different package version or unexpected source', () => { + const wrongVersion = writeNodePtyFixture('1.2.0-beta.11') + expect(() => patchNodePtyWindowsTeardown(wrongVersion.root)).toThrow('expected 1.1.0') + + for (const file of PATCHED_FILES) { + const drifted = writeNodePtyFixture('1.1.0') + const path = join(drifted.libDir, file) + writeFileSync(path, `${readFileSync(path, 'utf8')}\n// drift`) + expect(() => patchNodePtyWindowsTeardown(drifted.root)).toThrow('unexpected node-pty') + } + }) + + it('refuses a half-applied tree, so one file cannot pass for both', () => { + for (const file of PATCHED_FILES) { + const partial = writeNodePtyFixture('1.1.0') + const fixture = writeNodePtyFixture('1.1.0') + patchNodePtyWindowsTeardown(fixture.root) + writeFileSync(join(partial.libDir, file), readFileSync(join(fixture.libDir, file), 'utf8')) + expect(() => assertPatchedNodePtyWindowsTeardown(partial.root)).toThrow('is not installed') + } + }) +}) + +/** A published node-pty tree, rebuilt by un-applying the desktop hunks from the installed one. */ +function writeNodePtyFixture(version) { + const root = mkdtempSync(join(projectDir, '.node-pty-teardown-patch-test-')) + cleanupDirs.push(root) + const libDir = join(root, 'node_modules', 'node-pty', 'lib') + mkdirSync(libDir, { recursive: true }) + writeFileSync(join(root, 'node_modules', 'node-pty', 'package.json'), JSON.stringify({ version })) + for (const file of PATCHED_FILES) { + const desktop = readFileSync(desktopPath(file), 'utf8') + for (const [marker] of DESKTOP_HUNKS[file]) { + expect(desktop).toContain(marker) + } + writeFileSync(join(libDir, file), unapplyDesktopHunks(file, desktop)) + } + return { root, libDir } +} + +/** + * Reverse of the published-to-desktop transform. + * + * `windowsTerminal.js` is taken verbatim from the desktop, so the asset's own replacement table is + * the transform and reversing it is exact. `windowsPtyAgent.js` deliberately diverges, so its + * published form is rebuilt from the desktop hunk instead -- which is also what makes this file the + * place that notices if the desktop hunk itself ever moves. + */ +function unapplyDesktopHunks(file, desktop) { + if (file === 'windowsPtyAgent.js') { + let published = desktop + for (const [patched, original] of DESKTOP_HUNKS[file]) { + expect(published.split(patched).length - 1).toBe(1) + published = published.replace(patched, original) + } + return published + } + const asset = readFileSync( + join(projectDir, 'config', 'relay-assets', 'node-pty-1.1.0-windows-pty-teardown-patch.cjs'), + 'utf8' + ) + const { PATCH_TARGETS } = loadPatchTargets(asset) + const target = PATCH_TARGETS.find((entry) => entry.relativePath.at(-1) === file) + expect(target).toBeDefined() + let published = desktop + for (const [from, to] of target.replacements.toReversed()) { + expect(published.split(to).length - 1).toBe(1) + published = published.replace(to, from) + } + return published +} + +function loadPatchTargets(assetSource) { + const module = { exports: {} } + const factory = new Function( + 'module', + 'exports', + 'require', + `${assetSource}\nmodule.exports.PATCH_TARGETS = PATCH_TARGETS` + ) + factory(module, module.exports, require) + return module.exports +} diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 257eb984caa..e7450478d92 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -743,6 +743,7 @@ function uploadStageNamespaceIfSupported( const NODE_PTY_VERSION = '1.1.0' const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs' +const NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME = 'node-pty-1.1.0-windows-pty-teardown-patch.cjs' const NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME = 'node-pty-1.1.0-master-cloexec-patch.cjs' const NODE_PTY_CLOEXEC_STATUS_PREFIX = 'ORCA-NPTY-CLOEXEC:' /** @@ -791,7 +792,8 @@ function nativeDepsProbeJs(successToken: string): string { // Why: node-pty's Windows wrapper defers conpty.node until first spawn, so require("node-pty") alone can't prove the binding is healthy. const loadNodePty = 'require("node-pty"); require("node-pty/lib/utils").loadNativeModule(process.platform==="win32"&&Number(require("os").release().split(".")[2])>=18309?"conpty":"pty");' + - `if(process.platform==="win32"){require("./${NODE_PTY_CONSOLE_LIST_PATCH_FILENAME}").assertPatchedNodePtyConsoleListAgent(process.cwd())}` + `if(process.platform==="win32"){require("./${NODE_PTY_CONSOLE_LIST_PATCH_FILENAME}").assertPatchedNodePtyConsoleListAgent(process.cwd());` + + `require("./${NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME}").assertPatchedNodePtyWindowsTeardown(process.cwd())}` return `(()=>{const missing=[];try{${loadNodePty}}catch{missing.push("node-pty")}try{require("@parcel/watcher")}catch{missing.push("@parcel/watcher")}if(missing.length){console.log("${NATIVE_DEPS_MISSING_PREFIX}"+missing.join(","));process.exitCode=1}else{console.log(${JSON.stringify(successToken)})}})()` } @@ -1327,10 +1329,16 @@ async function applyNodePtyMasterCloexecPatch( nodePath: string, signal?: AbortSignal ): Promise { - // Both Unix relay platforms leak, by different bugs: Linux inherits the master through forkpty()'s - // no-O_CLOEXEC path, macOS orphans one throwaway /dev/ptmx fd per spawn in pty_posix_spawn. Only - // Windows, which has no fds, is short-circuited -- and answering 'fixed' from a gate that ran - // nothing is exactly how a leaking darwin tree got published to the shared cache. + // Both Unix relay platforms leak the pty master, by different bugs: Linux inherits it through + // forkpty()'s no-O_CLOEXEC path, macOS orphans one throwaway /dev/ptmx fd per spawn in + // pty_posix_spawn. Windows is short-circuited because it has no fds for a master to leak into -- + // and answering 'fixed' from a gate that ran nothing is exactly how a leaking darwin tree got + // published to the shared cache. + // + // What 'fixed' means here is exactly "this tree does not leak the pty MASTER", which is the only + // thing the shared native-deps cache keys on. It is NOT a statement that a Windows relay leaks + // nothing: it leaked one Windows File handle per terminal until the ConPTY teardown patch above, + // by a mechanism that has nothing to do with fds. Read this gate as scoped to its own question. if (isWindowsRemoteHost(hostPlatform) || isWindowsRelayPlatform(platform)) { return 'fixed' } @@ -1530,8 +1538,11 @@ async function rebuildNativeDeps( } function windowsNodePtyPatchCommand(nodePath: string): string { - // Why: pnpm patches do not cross the SSH boundary; apply the version-checked fallback to the remote npm package. - return `& ${powerShellLiteral(nodePath)} ${powerShellLiteral(NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` + // Why: pnpm patches do not cross the SSH boundary; apply the version-checked fallbacks to the remote npm package. + return [ + `& ${powerShellLiteral(nodePath)} ${powerShellLiteral(NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }`, + `& ${powerShellLiteral(nodePath)} ${powerShellLiteral(NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME)}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` + ].join('; ') } async function makeNodePtySpawnHelperExecutable( diff --git a/src/shared/relay-artifacts.ts b/src/shared/relay-artifacts.ts index 2f9e563f839..73ce8c7af1b 100644 --- a/src/shared/relay-artifacts.ts +++ b/src/shared/relay-artifacts.ts @@ -57,6 +57,10 @@ export const RELAY_ARTIFACTS: readonly RelayArtifact[] = [ // title request with no title and no error. { filename: 'wsl-transcript-fs-process-entry.js' }, { filename: 'node-pty-1.1.0-console-list-agent-patch.cjs', windowsOnly: true }, + // The ConPTY teardown release the desktop's own node-pty patch already carries; pnpm patches do + // not cross the SSH boundary, so a relay ran the unpatched npm tree and leaked one Windows File + // handle per terminal for the life of the relay process. + { filename: 'node-pty-1.1.0-windows-pty-teardown-patch.cjs', windowsOnly: true }, // Only Linux relays run it, but it ships everywhere: the manifest's only // platform axis is Windows, and a second one would buy nothing but a fork in // the hash. Its presence is what moves a host to a fresh relay directory, and From 637dc30a3211ec0667c55118a4d17edbee5cff80 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:11:09 -0700 Subject: [PATCH 16/58] fix(relay): observe Windows PTY child processes instead of answering false (#18591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(relay): observe Windows PTY child processes instead of answering false `processHasChildren` returned a hardcoded `false` on Windows, and a hardcoded negative is indistinguishable from a measurement. Every close guard reads it as "nothing is running in this pane", so an SSH-to-Windows tab running a build closed with no prompt. Measured on a real Windows SSH host: a live `PING.EXE` under the pane's `cmd.exe` still reported `hasChildProcesses: false`, while the identical harness on Linux reported `sleep` / `true`. Windows has no `ps`, but it does have a process table, and the pane walk over it already existed for the foreground reader. The answer now comes from `queryWindowsPaneProcessInventory`; a table it could not read reports `unverifiable` rather than a fabricated negative. `hasChildProcesses` is a boolean, which cannot hold the third answer, and it is read both as "busy, do not close" and as "the agent took the PTY, safe to type into" — so no single mapping of `unverifiable` is safe for both. The verdict moves to a new optional `childProcessEvidence` member that the close paths read; the boolean keeps its exact meaning for every client that cannot. Cost: `pty.inspectProcess` is the polled path and a relay host has no `@vscode/windows-process-tree`, so its table read falls back to the 1.36s CIM scan. Polling that would reinstate the fork storm the shared table exists to prevent, so only a caller whose answer decides something asks for the scan. * fix(runtime): forward scanChildProcesses through the environment inspection RPC `guardRunningTerminalClose` asks the host to pay for a real child-process read, but the environment path dropped the option before it reached the wire: the renderer sent only `expectedIncarnationId`, and the RPC schema — the shared `TerminalHandle` — silently stripped anything else. A host routing that pane through an SSH relay then declined to scan and answered `unverifiable`, which `inspectionReportsRunningWork` reads as running work. The result was a close confirmation on an idle pane, which is the nag this PR exists to avoid. Forwarded through all four layers: renderer payload, RPC schema, method handler, and the runtime/controller signatures. The schema is a dedicated extension rather than a field on `TerminalHandle`, so `clearBuffer`/`agentStatus`/`isRunningAgent` keep refusing an option they have no use for. The silent strip is not itself the defect — it is what makes a new optional member safe to send to an old host, per docs/reference/remote-wire-compatibility.md. The defect was the schema and its caller drifting inside one version, so the tests pin the registered method rather than the schema alone: pointing it back at `TerminalHandle` compiles, parses, and drops the option. Found by review on #18591. * fix(terminal): teach the shared running-work probe the third child-process answer Rebasing onto main landed `probePtyRunningWork`, which is a better home for this than the close guard: it already speaks `live` / `unverifiable` / `exited`, and it exists so the tab-close and window-close guards cannot drift. The child-process verdict belongs there, not in a parallel predicate beside it. So the mapping moves into the probe and `inspectionReportsRunningWork` is deleted rather than kept alongside. The probe now asks for the scan, and a host that could not observe the pane reports `unverifiable` instead of collapsing onto `exited` -- which is what `hasChildProcesses: false` meant on every Windows relay. The pane-close path is routed through the same probe for the same reason; it was the third caller asking this question through a direct inspect of its own. --- src/main/ipc/pty/ipc/inspect.ts | 17 ++- src/main/providers/pty-process-inspection.ts | 16 ++- .../ssh-pty-provider-rpc-operations.ts | 6 +- src/main/providers/ssh-pty-provider.ts | 2 +- ...tore-structured-agent-session-tabs-once.ts | 2 +- .../terminal-inspect-process-params.test.ts | 92 ++++++++++++ .../terminal/terminal-query-methods.ts | 23 +-- .../rpc/methods/terminal/unary-schemas.ts | 11 ++ .../runtime-pty-controller-contract.ts | 2 +- src/preload/api/pty-api.ts | 2 +- .../pty-bridge-stream-and-serialization.ts | 2 +- src/relay/pty-child-process-inspection.ts | 81 +++++++++++ src/relay/pty-handler-spawn-admission.test.ts | 3 +- src/relay/pty-handler-test-harness.ts | 4 +- ...ler-windows-child-process-evidence.test.ts | 132 ++++++++++++++++++ src/relay/pty-handler.ts | 30 +++- src/relay/pty-shell-utils.test.ts | 78 ++++++++++- src/relay/pty-shell-utils.ts | 42 +----- .../use-terminal-pane-close-actions.ts | 10 +- ...-running-work-probe-child-evidence.test.ts | 94 +++++++++++++ .../terminal/pty-running-work-probe.ts | 21 ++- .../running-terminal-close-guard.test.ts | 4 +- ...al-close-confirm-keyboard-vs-mouse.test.ts | 7 +- .../runtime-terminal-inspection.test.ts | 52 +++++++ .../runtime/runtime-terminal-inspection.ts | 10 +- src/shared/terminal-process-inspection.ts | 13 ++ 26 files changed, 665 insertions(+), 91 deletions(-) create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-inspect-process-params.test.ts create mode 100644 src/relay/pty-child-process-inspection.ts create mode 100644 src/relay/pty-handler-windows-child-process-evidence.test.ts create mode 100644 src/renderer/src/components/terminal/pty-running-work-probe-child-evidence.test.ts diff --git a/src/main/ipc/pty/ipc/inspect.ts b/src/main/ipc/pty/ipc/inspect.ts index c13c4242fea..041abaa7854 100644 --- a/src/main/ipc/pty/ipc/inspect.ts +++ b/src/main/ipc/pty/ipc/inspect.ts @@ -170,7 +170,10 @@ export function installPtyInspectIpcHandlers(deps: { ipcMain.handle( 'pty:inspectProcess', - async (_event, args: { id: string; expectedIncarnationId?: string }) => { + async ( + _event, + args: { id: string; expectedIncarnationId?: string; scanChildProcesses?: boolean } + ) => { // Why: same routing hazard as pty:hasPty — an unroutable id must read as client-only unverifiable, not as a local-provider answer or a raised IPC error. if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { return clientOnlyUnverifiableInspection('terminal_gone') @@ -182,10 +185,14 @@ export function installPtyInspectIpcHandlers(deps: { if (!hasPtyProviderForInspection(args.id)) { return clientOnlyUnverifiableInspection('terminal_gone') } - return args.expectedIncarnationId - ? inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id, { - expectedIncarnationId: args.expectedIncarnationId - }) + const options = { + ...(args.expectedIncarnationId + ? { expectedIncarnationId: args.expectedIncarnationId } + : {}), + ...(args.scanChildProcesses === true ? { scanChildProcesses: true } : {}) + } + return Object.keys(options).length > 0 + ? inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id, options) : inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id) } ) diff --git a/src/main/providers/pty-process-inspection.ts b/src/main/providers/pty-process-inspection.ts index 6869899de8b..59d910b2238 100644 --- a/src/main/providers/pty-process-inspection.ts +++ b/src/main/providers/pty-process-inspection.ts @@ -11,14 +11,24 @@ export type PtyProcessInspection = TerminalProcessInspection type CompletionSensitivePtyProvider = IPtyProvider & { inspectProcess?: ( id: string, - options?: { expectedIncarnationId?: PtyIncarnationId } + options?: PtyProcessInspectionOptions ) => Promise } +/** + * `scanChildProcesses` marks a read whose answer decides something once, rather than a poll that + * self-corrects on its next tick. Only hosts where the child answer costs a process-table read + * act on it; everywhere else the answer was already captured. + */ +export type PtyProcessInspectionOptions = { + expectedIncarnationId?: PtyIncarnationId + scanChildProcesses?: boolean +} + export async function inspectPtyProviderProcess( provider: IPtyProvider, ptyId: string, - options?: { expectedIncarnationId?: PtyIncarnationId } + options?: PtyProcessInspectionOptions ): Promise { if (provider.hasPty?.(ptyId) === false) { throw new Error('terminal_gone') @@ -37,7 +47,7 @@ export async function inspectPtyProviderProcess( export async function inspectPtyProviderProcessForRenderer( provider: IPtyProvider, ptyId: string, - options?: { expectedIncarnationId?: PtyIncarnationId } + options?: PtyProcessInspectionOptions ): Promise { try { return await inspectPtyProviderProcess(provider, ptyId, options) diff --git a/src/main/providers/ssh-pty-provider-rpc-operations.ts b/src/main/providers/ssh-pty-provider-rpc-operations.ts index 71ddbefce97..2e273239cd4 100644 --- a/src/main/providers/ssh-pty-provider-rpc-operations.ts +++ b/src/main/providers/ssh-pty-provider-rpc-operations.ts @@ -56,13 +56,15 @@ export function createSshPtyProviderRpcOperations({ mux, toRelayPtyId }: SshPtyP // Guarded by ssh-pty-inspect-observation-identity.test.ts; #17525 removes the poll. inspectProcess: async ( id: string, - options?: { expectedIncarnationId?: string } + options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean } ): Promise => { return (await mux.request('pty.inspectProcess', { id: toRelayPtyId(id), ...(options?.expectedIncarnationId ? { expectedIncarnationId: options.expectedIncarnationId } - : {}) + : {}), + // Additive request member: an older relay ignores it and answers as it always did. + ...(options?.scanChildProcesses === true ? { scanChildProcesses: true } : {}) })) as PtyProcessInspection }, serialize: async (ids: string[]): Promise => { diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 70c01b5a1c7..3d0c46d7a03 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -63,7 +63,7 @@ export class SshPtyProvider implements IPtyProvider { this.rpcOperations.getForegroundProcess(id) inspectProcess = ( id: string, - options?: { expectedIncarnationId?: string } + options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean } ): Promise => this.rpcOperations.inspectProcess(id, options) serialize = (ids: string[]): Promise => this.rpcOperations.serialize(ids) revive = (state: string): Promise => this.rpcOperations.revive(state) diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 0ed3700ba99..4466594b0dc 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -153,7 +153,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu async inspectTerminalProcess( terminalSelector: string, - options?: { expectedIncarnationId?: string } + options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean } ): Promise { const leaf = this.resolveLiveLeafForHandle(terminalSelector) if (!leaf?.ptyId || !this.ptyController) { diff --git a/src/main/runtime/rpc/methods/terminal/terminal-inspect-process-params.test.ts b/src/main/runtime/rpc/methods/terminal/terminal-inspect-process-params.test.ts new file mode 100644 index 00000000000..48649bc372c --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-inspect-process-params.test.ts @@ -0,0 +1,92 @@ +// The host half of the same contract: an RPC schema silently strips keys it does not declare, which +// is exactly what forward compatibility needs and exactly how a caller's option can vanish inside +// one version. `scanChildProcesses` has to be declared here, and only here -- the sibling handle +// methods have no use for it and must keep refusing it. +import { describe, expect, it, vi } from 'vitest' +import type { ZodType } from 'zod' +import { TERMINAL_QUERY_METHODS } from './terminal-query-methods' +import { TerminalHandle, TerminalInspectProcess } from './unary-schemas' + +/** The method as registered, so a schema swap on the definition cannot pass unseen. */ +function inspectProcessMethod() { + const method = TERMINAL_QUERY_METHODS.find((entry) => entry.name === 'terminal.inspectProcess') + if (!method) { + throw new Error('terminal.inspectProcess is not registered') + } + return method +} + +async function callRegisteredHandler( + params: Record +): Promise<{ terminal: string; options: unknown }> { + const method = inspectProcessMethod() + const parsed = (method.params as ZodType).parse(params) + const inspectTerminalProcess = vi.fn(async () => ({ + foregroundProcess: null, + hasChildProcesses: false + })) + await method.handler(parsed, { runtime: { inspectTerminalProcess } } as never, undefined as never) + const [terminal, options] = inspectTerminalProcess.mock.calls[0] as unknown as [string, unknown] + return { terminal, options } +} + +describe('terminal.inspectProcess registration', () => { + // The half the schema test alone cannot see: pointing the method back at the shared handle schema + // compiles, parses, and silently drops the option. This exercises the registered definition. + it('carries scanChildProcesses from the wire into the runtime call', async () => { + await expect( + callRegisteredHandler({ terminal: 'term_1', scanChildProcesses: true }) + ).resolves.toEqual({ terminal: 'term_1', options: { scanChildProcesses: true } }) + }) + + it('carries it alongside the incarnation fence', async () => { + await expect( + callRegisteredHandler({ + terminal: 'term_1', + expectedIncarnationId: 'inc-1', + scanChildProcesses: true + }) + ).resolves.toEqual({ + terminal: 'term_1', + options: { expectedIncarnationId: 'inc-1', scanChildProcesses: true } + }) + }) + + it('keeps the legacy one-argument shape for a bare poll', async () => { + await expect(callRegisteredHandler({ terminal: 'term_1' })).resolves.toEqual({ + terminal: 'term_1', + options: undefined + }) + }) +}) + +describe('terminal.inspectProcess params', () => { + it('preserves scanChildProcesses', () => { + expect(TerminalInspectProcess.parse({ terminal: 'term_1', scanChildProcesses: true })).toEqual({ + terminal: 'term_1', + scanChildProcesses: true + }) + }) + + it('preserves it alongside the incarnation fence', () => { + expect( + TerminalInspectProcess.parse({ + terminal: 'term_1', + expectedIncarnationId: 'inc-1', + scanChildProcesses: true + }) + ).toEqual({ terminal: 'term_1', expectedIncarnationId: 'inc-1', scanChildProcesses: true }) + }) + + it('leaves it absent for a polling caller', () => { + expect(TerminalInspectProcess.parse({ terminal: 'term_1' })).toEqual({ terminal: 'term_1' }) + }) + + // The shape that produced the bug, pinned so nobody "simplifies" the method back onto the shared + // handle schema: TerminalHandle drops the option on the floor without complaining. + it('shows why the shared handle schema could not carry it', () => { + expect(TerminalHandle.parse({ terminal: 'term_1', scanChildProcesses: true })).toEqual({ + terminal: 'term_1' + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts index a8aec9ff573..bf7b4a5bd87 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts @@ -1,6 +1,7 @@ import { defineMethod, type RpcAnyMethod } from '../../core' import { TerminalHandle, + TerminalInspectProcess, TerminalListParams, TerminalRead, TerminalRecoverPane, @@ -65,15 +66,21 @@ export const TERMINAL_QUERY_METHODS: RpcAnyMethod[] = [ }), defineMethod({ name: 'terminal.inspectProcess', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - process: await runtime.inspectTerminalProcess( - params.terminal, - params.expectedIncarnationId + params: TerminalInspectProcess, + handler: async (params, { runtime }) => { + const options = { + ...(params.expectedIncarnationId ? { expectedIncarnationId: params.expectedIncarnationId } - : undefined - ) - }) + : {}), + ...(params.scanChildProcesses === true ? { scanChildProcesses: true } : {}) + } + return { + process: await runtime.inspectTerminalProcess( + params.terminal, + Object.keys(options).length > 0 ? options : undefined + ) + } + } }), defineMethod({ name: 'terminal.isRunningAgent', diff --git a/src/main/runtime/rpc/methods/terminal/unary-schemas.ts b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts index 323b34a7544..afe0a3bb486 100644 --- a/src/main/runtime/rpc/methods/terminal/unary-schemas.ts +++ b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts @@ -13,6 +13,17 @@ export const TerminalFocus = TerminalHandle.extend({ navigation: z.enum(['caller', 'host']).optional() }) +/** + * `terminal.inspectProcess` carries one member the sibling handle methods must not: whether the + * caller's answer decides something once, which is what licenses the host to pay for a process-table + * read. Extended rather than added to `TerminalHandle` so `clearBuffer`/`agentStatus`/`isRunningAgent` + * keep refusing an option they have no use for. + */ +export const TerminalInspectProcess = TerminalHandle.extend({ + // Additive request member understood by newer hosts; legacy hosts safely ignore it. + scanChildProcesses: z.boolean().optional() +}) + export const TerminalListParams = z.object({ worktree: OptionalString, limit: OptionalFiniteNumber, diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 194d0bb665c..665c6fdb609 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -109,7 +109,7 @@ export type RuntimePtyController = { getForegroundProcess(ptyId: string): Promise inspectProcess?( ptyId: string, - options?: { expectedIncarnationId?: PtyIncarnationId } + options?: { expectedIncarnationId?: PtyIncarnationId; scanChildProcesses?: boolean } ): Promise confirmForegroundProcess?(ptyId: string): Promise confirmShellForeground?(ptyId: string): Promise diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index bf012306675..a1850398357 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -112,7 +112,7 @@ export type PtyApi = { getForegroundProcess: (id: string) => Promise inspectProcess: ( id: string, - options?: { expectedIncarnationId?: string } + options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean } ) => Promise confirmForegroundProcess: (id: string) => Promise getCwd: (id: string) => Promise diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index 7e2d7bbe4ff..414a5514bfa 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -7,7 +7,7 @@ import type { TerminalProcessInspection } from '../../shared/terminal-process-in export const ptyStreamAndSerializationApi = { inspectProcess: ( id: string, - options?: { expectedIncarnationId?: string } + options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean } ): Promise => ipcRenderer.invoke('pty:inspectProcess', { id, ...options }), confirmForegroundProcess: (id: string): Promise => diff --git a/src/relay/pty-child-process-inspection.ts b/src/relay/pty-child-process-inspection.ts new file mode 100644 index 00000000000..6d246817053 --- /dev/null +++ b/src/relay/pty-child-process-inspection.ts @@ -0,0 +1,81 @@ +/** + * Whether anything is running under a pane's shell. + * + * Split out of `pty-shell-utils` because it is a distinct question from "what is in front" and + * carries its own platform reasoning, its own cost budget, and the verdict vocabulary from + * docs/reference/ssh-execution-boundary.md. + */ +import { queryWindowsPaneProcessInventory } from '../main/providers/windows-foreground-process-rows' +import { getProcessTableIndex } from '../shared/process-table-index' +import { + getFreshProcessTableSnapshot, + getProcessTableSnapshot +} from '../shared/process-table-snapshot-reader' +import type { PtyChildProcessVerdict } from '../shared/terminal-process-inspection' +import { isProcessAlive } from './pty-shell-utils' + +/** + * Check whether a process has child processes. + * + * Why the shared snapshot and not `pgrep -P`: this answers one field of + * `pty.inspectProcess`, which every tracked pane polls on a 750ms/2000ms + * cadence, and the fork was neither cached nor coalesced. procps-ng opens six + * procfs files per process to resolve a ppid — including a `/proc//ctty` + * that never exists on Linux — so one call cost O(host process count) syscalls, + * ~4k opens per pgrep on a 690-process host, at up to 8 forks/sec (#13537). + * `getForegroundProcessName` in the same RPC already captured the TTL-cached + * `ps` table, whose index carries the parent/child map, so the answer is free. + * + * `fresh` opts out of that TTL. A poll can read a 500ms-old table because its + * next tick corrects it, but a close or cleanup decision acts on the answer + * once and destructively — a child that started inside the TTL would be killed + * with no confirmation. `pgrep` scanned per call, so anything that decides + * has to keep scanning per call. + */ +export async function inspectPtyChildProcesses( + pid: number, + options?: { fresh?: boolean } +): Promise { + if (process.platform === 'win32') { + // Windows has no `ps`, but it does have a process table, and the pane walk over it already + // exists for the foreground reader. Answering `false` from nothing was the older shape: a + // hardcoded negative is indistinguishable from a measurement, and every close guard reads it + // as "nothing is running here". + // + // Deliberately the TTL-cached table even when `fresh` is asked for: on a relay without the + // native binding this falls back to the CIM scan, whose own 1.36s runtime is longer than the + // 500ms TTL a fresh read would be refreshing, so a "fresh" answer is not meaningfully fresher + // while N sequential ones are an N x 1.36s stall. + const inventory = await queryWindowsPaneProcessInventory(pid) + if (inventory) { + return inventory.candidates.length > 0 ? 'children' : 'no-children' + } + // A null inventory is an unreadable table OR a snapshot that never showed the root, and + // neither of those looked at the pane. The one answer available without the table is a root + // the kernel says is gone: nothing runs under a shell that does not exist. + return isProcessAlive(pid) ? 'unverifiable' : 'no-children' + } + try { + const rows = options?.fresh + ? await getFreshProcessTableSnapshot() + : await getProcessTableSnapshot() + return (getProcessTableIndex(rows).childrenByPpid.get(pid)?.length ?? 0) > 0 + ? 'children' + : 'no-children' + } catch { + return 'unverifiable' + } +} + +/** + * The boolean the wire has always carried. `unverifiable` keeps spelling itself `false` here on + * purpose: this value reaches clients too old to know the third answer, and it is read both as + * "busy, do not close" and as "the agent has taken over, safe to type into", so no single mapping + * of `unverifiable` is safe for both. Callers that can act on the distinction read the verdict. + */ +export async function processHasChildren( + pid: number, + options?: { fresh?: boolean } +): Promise { + return (await inspectPtyChildProcesses(pid, options)) === 'children' +} diff --git a/src/relay/pty-handler-spawn-admission.test.ts b/src/relay/pty-handler-spawn-admission.test.ts index c25a623f406..045ee2e412d 100644 --- a/src/relay/pty-handler-spawn-admission.test.ts +++ b/src/relay/pty-handler-spawn-admission.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import * as ptyChildProcessInspection from './pty-child-process-inspection' import * as ptyShellUtils from './pty-shell-utils' import * as processTableSnapshotReader from '../shared/process-table-snapshot-reader' @@ -92,7 +93,7 @@ describe('PtyHandler', () => { }) it('rescans the process table for a close decision but not for a poll', async () => { - const hasChildren = vi.mocked(ptyShellUtils.processHasChildren) + const hasChildren = vi.mocked(ptyChildProcessInspection.processHasChildren) const snapshot = vi .spyOn(processTableSnapshotReader, 'getStrictProcessTableSnapshotWithAge') .mockResolvedValue({ diff --git a/src/relay/pty-handler-test-harness.ts b/src/relay/pty-handler-test-harness.ts index e3d99213ea5..fe6e17c5d9c 100644 --- a/src/relay/pty-handler-test-harness.ts +++ b/src/relay/pty-handler-test-harness.ts @@ -1,6 +1,6 @@ import { vi } from 'vitest' import type { Mock } from 'vitest' -import * as ptyShellUtils from './pty-shell-utils' +import * as ptyChildProcessInspection from './pty-child-process-inspection' import { PtyHandler } from './pty-handler' import type { RelayDispatcher } from './dispatcher' @@ -115,7 +115,7 @@ export function beginPtyHandlerTest(mocks: PtyHandlerTestMocks): { notifyOutput: vi.fn(), dispose: vi.fn() }) - vi.spyOn(ptyShellUtils, 'processHasChildren').mockResolvedValue(false) + vi.spyOn(ptyChildProcessInspection, 'processHasChildren').mockResolvedValue(false) mockPtySpawn.mockReturnValue({ ...mockPtyInstance }) diff --git a/src/relay/pty-handler-windows-child-process-evidence.test.ts b/src/relay/pty-handler-windows-child-process-evidence.test.ts new file mode 100644 index 00000000000..6d723824a04 --- /dev/null +++ b/src/relay/pty-handler-windows-child-process-evidence.test.ts @@ -0,0 +1,132 @@ +// Regression guard for the Windows SSH child-process answer. The relay used to return a hardcoded +// `false` here, which every close guard reads as "nothing is running in this pane" -- so a Windows +// SSH pane running a build closed with no prompt. The answer now comes from the process table, and +// the one thing it may never do again is fabricate a negative. +// +// The second contract is cost. `pty.inspectProcess` is the polled path (750ms/2000ms per tracked +// pane) and a relay host has no `@vscode/windows-process-tree`, so its table read falls back to a +// 1.36s CIM scan. Polling that would reinstate the fork storm the shared table exists to prevent, +// so only a caller whose answer decides something asks for the scan. +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' + +const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ + mockPtySpawn: vi.fn(), + mockCreateShellPromptReadinessProbe: vi.fn(), + mockPtyInstance: { + pid: process.pid, + process: 'xterm-256color', + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + } +})) + +vi.mock('node-pty', () => ({ spawn: mockPtySpawn })) + +vi.mock('../main/pty/posix-pty-process-groups', () => ({ + forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) +})) + +vi.mock('../main/shell-prompt-readiness-probe', () => ({ + createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe +})) + +import * as ptyChildProcessInspection from './pty-child-process-inspection' +import type { PtyHandler } from './pty-handler' +import { + beginPtyHandlerTest, + createPtyRequestHelpers, + endPtyHandlerTest +} from './pty-handler-test-harness' +import type { MockDispatcher } from './pty-handler-test-harness' + +type Inspection = { + foregroundProcess: string | null + hasChildProcesses: boolean + childProcessEvidence?: string +} + +describe('PtyHandler Windows child-process evidence', () => { + let dispatcher: MockDispatcher + let handler: PtyHandler + let originalPlatform: PropertyDescriptor | undefined + let inspectChildren: ReturnType + + const { spawnPty } = createPtyRequestHelpers(() => dispatcher) + + /** Spawn under the harness's POSIX platform, then answer as the Windows relay would. */ + async function spawnThenBecomeWindows(): Promise { + const { id } = await spawnPty() + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + return id + } + + async function inspect(params: Record): Promise { + return (await dispatcher.callRequest('pty.inspectProcess', params)) as Inspection + } + + beforeEach(() => { + ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ + mockPtySpawn, + mockPtyInstance, + mockCreateShellPromptReadinessProbe + })) + inspectChildren = vi + .spyOn(ptyChildProcessInspection, 'inspectPtyChildProcesses') + .mockResolvedValue('no-children') + }) + + afterEach(async () => { + await endPtyHandlerTest(handler, originalPlatform) + }) + + it('publishes what the host observed when the caller pays for the scan', async () => { + const id = await spawnThenBecomeWindows() + inspectChildren.mockResolvedValue('children') + + const result = await inspect({ id, scanChildProcesses: true }) + + expect(inspectChildren).toHaveBeenCalledWith(mockPtyInstance.pid) + expect(result.childProcessEvidence).toBe('children') + expect(result.hasChildProcesses).toBe(true) + }) + + it('reports an observed-empty pane as no-children, not merely false', async () => { + const id = await spawnThenBecomeWindows() + inspectChildren.mockResolvedValue('no-children') + + const result = await inspect({ id, scanChildProcesses: true }) + + // Asserted alongside the value so the case fails if the answer stops coming from a real read. + expect(inspectChildren).toHaveBeenCalledWith(mockPtyInstance.pid) + expect(result.childProcessEvidence).toBe('no-children') + expect(result.hasChildProcesses).toBe(false) + }) + + it('keeps the compatibility boolean false when the host could not observe the pane', async () => { + const id = await spawnThenBecomeWindows() + inspectChildren.mockResolvedValue('unverifiable') + + const result = await inspect({ id, scanChildProcesses: true }) + + expect(result.childProcessEvidence).toBe('unverifiable') + // Clients too old to read the verdict also read `true` as "an agent took the PTY, safe to + // type into it", so `unverifiable` must not be promoted to `true` on the shared boolean. + expect(result.hasChildProcesses).toBe(false) + }) + + it('never reads the process table for a poll, and says so instead of guessing', async () => { + const id = await spawnThenBecomeWindows() + + const result = await inspect({ id }) + + expect(inspectChildren).not.toHaveBeenCalled() + expect(result.childProcessEvidence).toBe('unverifiable') + expect(result.hasChildProcesses).toBe(false) + }) +}) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 42f8bdc0a77..190bcabb3f9 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -10,11 +10,11 @@ import type { RelayDispatcher, RequestContext } from './dispatcher' import { resolveDefaultShell, resolveProcessCwd, - processHasChildren, getForegroundProcessName, isProcessAlive, listShellProfiles } from './pty-shell-utils' +import { inspectPtyChildProcesses, processHasChildren } from './pty-child-process-inspection' import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch' import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces' import { addWslEnvKeys } from '../shared/wsl-env' @@ -55,6 +55,7 @@ import { import { isTuiAgent } from '../shared/tui-agent-config' import type { TuiAgent } from '../shared/tui-agent' import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups' +import type { PtyChildProcessVerdict } from '../shared/terminal-process-inspection' import { terminatePtyJob } from '../main/windows/windows-pty-job' import { stripInheritedBuildModeEnv } from '../main/pty/build-mode-env' import { stripLegacyTerminalShimEnv } from '../main/pty/legacy-terminal-shim-dir' @@ -2610,6 +2611,7 @@ export class PtyHandler { private async inspectProcess(params: Record): Promise<{ foregroundProcess: string | null hasChildProcesses: boolean + childProcessEvidence?: PtyChildProcessVerdict foregroundProcessEvidence?: RemoteForegroundEvidence }> { pruneRetiredPtyIncarnations(this.retiredIncarnations) @@ -2716,14 +2718,28 @@ export class PtyHandler { evidence?.verdict === 'live' ? (evidence.processName ?? managed.pty.process) || null : managed.pty.process || null + // Derive child liveness from the same capture; do not fork a second process-table probe for + // each field/pane in an event burst. + // + // Why Windows is gated on the caller asking: this is the one field whose Windows answer costs + // a process-table read, and `inspectProcess` is the polled path (750ms/2000ms per tracked + // pane). A relay host has no `@vscode/windows-process-tree`, so the read falls back to the + // 1.36s CIM scan, and polling that would reinstate exactly the fork storm the shared table + // exists to prevent (#15209, #15036). Close and cleanup decisions ask for the scan by name; + // a poll gets the honest `unverifiable` instead of a fabricated negative. + const childProcessEvidence: PtyChildProcessVerdict = rows + ? rows.some((row) => row.ppid === managed.pty.pid) + ? 'children' + : 'no-children' + : process.platform === 'win32' && params.scanChildProcesses !== true + ? 'unverifiable' + : await inspectPtyChildProcesses(managed.pty.pid) return { foregroundProcess, - // Derive child liveness from the same capture; do not fork a second - // process-table probe for each field/pane in an event burst. Windows - // has no evidence capture, so preserve the compatibility child probe. - hasChildProcesses: rows - ? rows.some((row) => row.ppid === managed.pty.pid) - : await processHasChildren(managed.pty.pid), + // `unverifiable` keeps spelling itself `false` on the compatibility field, which is what + // every client too old to read the verdict receives. + hasChildProcesses: childProcessEvidence === 'children', + childProcessEvidence, ...(evidence ? { foregroundProcessEvidence: evidence } : {}) } } diff --git a/src/relay/pty-shell-utils.test.ts b/src/relay/pty-shell-utils.test.ts index 95d6a8e050f..94953aae1bc 100644 --- a/src/relay/pty-shell-utils.test.ts +++ b/src/relay/pty-shell-utils.test.ts @@ -14,10 +14,10 @@ vi.mock('child_process', () => ({ import { resetWindowsProcessRowsSnapshotForTests } from '../main/providers/windows-foreground-process-rows' import { __setWindowsProcessTreeLoaderForTests } from '../main/windows/windows-process-table' import { resetProcessTableSnapshotForTests } from '../shared/process-table-snapshot-reader' +import { inspectPtyChildProcesses, processHasChildren } from './pty-child-process-inspection' import { getForegroundProcessName, isProcessAlive, - processHasChildren, resolveDefaultCwd, resolveWindowsDefaultShell } from './pty-shell-utils' @@ -42,6 +42,14 @@ function mockExecFile( * Feed the native Windows snapshot. A real snapshot always contains the * querying process, and the reader rejects a table without it. */ +/** A native reader that answers, but with no snapshot -- an unreadable table, not an empty one. */ +function mockUnreadableWindowsProcessTable(): void { + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + getAllProcesses: (cb: (value: undefined) => void) => cb(undefined) + })) +} + function mockWindowsProcessTable( rows: { pid: number; ppid: number; name: string; commandLine?: string }[] ): void { @@ -594,19 +602,79 @@ describe('processHasChildren', () => { }) }) - it('reports no children when the process table is unreadable', async () => { + it('reports an unreadable POSIX table as unverifiable, and still spells it false on the wire', async () => { await withProcessPlatform('linux', async () => { mockExecFile(() => new Error('ps table unavailable')) + await expect(inspectPtyChildProcesses(100)).resolves.toBe('unverifiable') await expect(processHasChildren(100)).resolves.toBe(false) }) }) +}) - it('spawns nothing on Windows, where the answer was always false', async () => { +describe('inspectPtyChildProcesses on Windows', () => { + // Why this describe exists: the relay used to `return false` here unconditionally, and a + // hardcoded negative is indistinguishable from a measurement. Every close guard reads it as + // "nothing is running here", so a Windows SSH pane running a build closed with no prompt. + it('walks the process table rather than answering from nothing', async () => { await withProcessPlatform('win32', async () => { - await expect(processHasChildren(100)).resolves.toBe(false) + mockWindowsProcessTable([ + { pid: 100, ppid: 99, name: 'cmd.exe', commandLine: 'cmd.exe' }, + { pid: 101, ppid: 100, name: 'PING.EXE', commandLine: 'ping -n 40 127.0.0.1' } + ]) - expect(execFileMock).not.toHaveBeenCalled() + await expect(inspectPtyChildProcesses(100)).resolves.toBe('children') + await expect(processHasChildren(100)).resolves.toBe(true) + }) + }) + + it('finds a grandchild the shell backgrounded, not just direct children', async () => { + await withProcessPlatform('win32', async () => { + mockWindowsProcessTable([ + { pid: 100, ppid: 99, name: 'cmd.exe', commandLine: 'cmd.exe' }, + { pid: 101, ppid: 100, name: 'node.exe', commandLine: 'node build.js' }, + { pid: 102, ppid: 101, name: 'tsc.exe', commandLine: 'tsc --watch' } + ]) + + await expect(inspectPtyChildProcesses(102)).resolves.toBe('no-children') + await expect(inspectPtyChildProcesses(101)).resolves.toBe('children') + }) + }) + + it('separates an observed-empty shell from a table it could not read', async () => { + await withProcessPlatform('win32', async () => { + mockWindowsProcessTable([{ pid: 100, ppid: 99, name: 'cmd.exe', commandLine: 'cmd.exe' }]) + await expect(inspectPtyChildProcesses(100)).resolves.toBe('no-children') + + resetWindowsProcessRowsSnapshotForTests() + mockUnreadableWindowsProcessTable() + const alive = vi.spyOn(process, 'kill').mockReturnValue(true as never) + try { + await expect(inspectPtyChildProcesses(100)).resolves.toBe('unverifiable') + // The compatibility boolean keeps spelling unverifiable `false`: it reaches clients that + // cannot read the verdict, and they read `true` as "an agent took the PTY, safe to type". + await expect(processHasChildren(100)).resolves.toBe(false) + } finally { + alive.mockRestore() + } + }) + }) + + it('does not read a missing shell as unverifiable when the kernel says it is gone', async () => { + await withProcessPlatform('win32', async () => { + // The root is absent from the snapshot, which on its own cannot distinguish a filtered + // table from an exited shell. Only ESRCH settles it. + mockWindowsProcessTable([{ pid: 900, ppid: 1, name: 'explorer.exe' }]) + const gone = vi.spyOn(process, 'kill').mockImplementation(() => { + const error = new Error('no such process') as NodeJS.ErrnoException + error.code = 'ESRCH' + throw error + }) + try { + await expect(inspectPtyChildProcesses(100)).resolves.toBe('no-children') + } finally { + gone.mockRestore() + } }) }) }) diff --git a/src/relay/pty-shell-utils.ts b/src/relay/pty-shell-utils.ts index d84faad6ae9..9c8585933a1 100644 --- a/src/relay/pty-shell-utils.ts +++ b/src/relay/pty-shell-utils.ts @@ -11,10 +11,7 @@ import { import { getFirstCommandToken } from '../shared/command-token-scanner' import { getProcessTableIndex, type ProcessTableIndex } from '../shared/process-table-index' import { PS_MAX_BUFFER_BYTES, type ProcessTableRow } from '../shared/process-table-snapshot' -import { - getFreshProcessTableSnapshot, - getProcessTableSnapshot -} from '../shared/process-table-snapshot-reader' +import { getProcessTableSnapshot } from '../shared/process-table-snapshot-reader' import { selectForegroundProcessCandidate } from '../shared/foreground-process-selection' import { resolveOuterWrapperForegroundProcess, @@ -169,43 +166,6 @@ export async function resolveProcessCwd(pid: number, fallbackCwd: string): Promi return fallbackCwd } -/** - * Check whether a process has child processes. - * - * Why the shared snapshot and not `pgrep -P`: this answers one field of - * `pty.inspectProcess`, which every tracked pane polls on a 750ms/2000ms - * cadence, and the fork was neither cached nor coalesced. procps-ng opens six - * procfs files per process to resolve a ppid — including a `/proc//ctty` - * that never exists on Linux — so one call cost O(host process count) syscalls, - * ~4k opens per pgrep on a 690-process host, at up to 8 forks/sec (#13537). - * `getForegroundProcessName` in the same RPC already captured the TTL-cached - * `ps` table, whose index carries the parent/child map, so the answer is free. - * - * `fresh` opts out of that TTL. A poll can read a 500ms-old table because its - * next tick corrects it, but a close or cleanup decision acts on the answer - * once and destructively — a child that started inside the TTL would be killed - * with no confirmation. `pgrep` scanned per call, so anything that decides - * has to keep scanning per call. - */ -export async function processHasChildren( - pid: number, - options?: { fresh?: boolean } -): Promise { - // Windows has no `ps`; the previous `pgrep` fork always failed here too, so - // this keeps the same answer without spawning anything to reach it. - if (process.platform === 'win32') { - return false - } - try { - const rows = options?.fresh - ? await getFreshProcessTableSnapshot() - : await getProcessTableSnapshot() - return (getProcessTableIndex(rows).childrenByPpid.get(pid)?.length ?? 0) > 0 - } catch { - return false - } -} - // Why: signal 0 probes existence without delivering a signal. Only ESRCH ("no // such process") proves the pid is gone; EPERM means it exists but is // unsignalable, so treat every non-ESRCH outcome as alive. Kept conservative so diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts index 886c19fdf0d..330b42166cd 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts @@ -5,7 +5,7 @@ import { makePaneKey } from '../../../../shared/stable-pane-id' import { closeWebRuntimeTerminal } from '@/runtime/web-runtime-session' import { resolveLeafCloseCopyKind } from '../terminal/terminal-close-copy-kind' import { RUNNING_CLOSE_PROBE_TIMEOUT_MS } from '../terminal/running-terminal-close-guard' -import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' +import { probePtyRunningWork } from '../terminal/pty-running-work-probe' import { detachTerminalPaneToTab, isTerminalTabStripDropTarget, @@ -99,12 +99,14 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr copyKind: getCloseDialogCopyKind(paneId) }) const probeTimeout = setTimeout(() => decide(confirmClose), RUNNING_CLOSE_PROBE_TIMEOUT_MS) - void inspectRuntimeTerminalProcess(settings, ptyId) - .then((process) => { + // Why the shared probe rather than a direct inspect: this is the same question the tab-close + // guard asks, and the two must not drift on what an unanswered host means. + void probePtyRunningWork(settings, [ptyId], { timeoutMs: RUNNING_CLOSE_PROBE_TIMEOUT_MS }) + .then((probes) => { clearTimeout(probeTimeout) decide(() => { if ( - !process.hasChildProcesses || + probes[0]?.verdict !== 'live' || settings?.skipCloseTerminalWithRunningProcessConfirm ) { executeClosePane(paneId) diff --git a/src/renderer/src/components/terminal/pty-running-work-probe-child-evidence.test.ts b/src/renderer/src/components/terminal/pty-running-work-probe-child-evidence.test.ts new file mode 100644 index 00000000000..4d002ee0191 --- /dev/null +++ b/src/renderer/src/components/terminal/pty-running-work-probe-child-evidence.test.ts @@ -0,0 +1,94 @@ +// The probe is the one place an inspection becomes a verdict, so it is the one place that has to +// know `hasChildProcesses` cannot hold the third answer. A host that could not read its own process +// table spells that the same way as one that read it and found nothing; Windows relays spelled it +// `false` unconditionally, which read here as `exited` -- an idle verdict for a pane nobody looked at. +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { inspectRuntimeTerminalProcessMock } = vi.hoisted(() => ({ + inspectRuntimeTerminalProcessMock: vi.fn() +})) + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + inspectRuntimeTerminalProcess: inspectRuntimeTerminalProcessMock +})) + +import { probePtyRunningWork } from './pty-running-work-probe' + +const SETTINGS = { activeRuntimeEnvironmentId: null } + +describe('probePtyRunningWork child-process evidence', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('asks the host to pay for a scan, because these probes back destructive decisions', async () => { + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: 'cmd.exe', + hasChildProcesses: false, + childProcessEvidence: 'no-children' + }) + + await probePtyRunningWork(SETTINGS, ['pty-a'], { timeoutMs: 1000 }) + + expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledWith(SETTINGS, 'pty-a', { + scanChildProcesses: true + }) + }) + + it('reports a host that could not observe the pane as unverifiable, not exited', async () => { + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: 'cmd.exe', + hasChildProcesses: false, + childProcessEvidence: 'unverifiable' + }) + + const [probe] = await probePtyRunningWork(SETTINGS, ['pty-a'], { timeoutMs: 1000 }) + + expect(probe).toMatchObject({ + verdict: 'unverifiable', + reason: 'host_child_processes_unobserved', + timedOut: false + }) + }) + + it('reports an observed-empty pane as exited', async () => { + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: 'cmd.exe', + hasChildProcesses: false, + childProcessEvidence: 'no-children' + }) + + const [probe] = await probePtyRunningWork(SETTINGS, ['pty-a'], { timeoutMs: 1000 }) + + expect(probe?.verdict).toBe('exited') + }) + + it('reports an observed child as live', async () => { + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: 'PING.EXE', + hasChildProcesses: true, + childProcessEvidence: 'children' + }) + + const [probe] = await probePtyRunningWork(SETTINGS, ['pty-a'], { timeoutMs: 1000 }) + + expect(probe?.verdict).toBe('live') + }) + + it('keeps the boolean meaning for a host that never published the verdict', async () => { + inspectRuntimeTerminalProcessMock.mockResolvedValueOnce({ + foregroundProcess: 'node', + hasChildProcesses: true + }) + inspectRuntimeTerminalProcessMock.mockResolvedValueOnce({ + foregroundProcess: 'bash', + hasChildProcesses: false + }) + + const [live] = await probePtyRunningWork(SETTINGS, ['pty-a'], { timeoutMs: 1000 }) + const [idle] = await probePtyRunningWork(SETTINGS, ['pty-b'], { timeoutMs: 1000 }) + + expect(live?.verdict).toBe('live') + expect(idle?.verdict).toBe('exited') + }) +}) diff --git a/src/renderer/src/components/terminal/pty-running-work-probe.ts b/src/renderer/src/components/terminal/pty-running-work-probe.ts index 609b71f12ca..1ee456ba389 100644 --- a/src/renderer/src/components/terminal/pty-running-work-probe.ts +++ b/src/renderer/src/components/terminal/pty-running-work-probe.ts @@ -54,14 +54,31 @@ export async function probePtyRunningWork( return } try { - const inspection = await inspectRuntimeTerminalProcess(settings, ptyId) + // Why the flag: this probe backs decisions that act once and destructively, so it is worth + // a host process-table read on platforms where the child question costs one. The polled + // inspections deliberately do not ask for it. + const inspection = await inspectRuntimeTerminalProcess(settings, ptyId, { + scanChildProcesses: true + }) probe.timedOut = false if (isClientOnlyUnverifiableInspection(inspection)) { probe.verdict = 'unverifiable' probe.reason = inspection.reason return } - probe.verdict = inspection.hasChildProcesses ? 'live' : 'exited' + // `hasChildProcesses` cannot hold the third answer: a host that could not read its own + // process table spells that the same way as one that read it and found nothing. Windows + // relays spelled it `false` unconditionally, which read here as `exited`. + if (inspection.childProcessEvidence === 'unverifiable') { + probe.verdict = 'unverifiable' + probe.reason = 'host_child_processes_unobserved' + return + } + probe.verdict = + (inspection.childProcessEvidence ?? + (inspection.hasChildProcesses ? 'children' : 'no-children')) === 'children' + ? 'live' + : 'exited' delete probe.reason } catch { // Why: `inspectRuntimeTerminalProcess` already maps every failure it can classify onto a diff --git a/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts b/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts index 165119f8b31..6b5b8343086 100644 --- a/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts +++ b/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts @@ -111,7 +111,9 @@ describe('guardRunningTerminalClose', () => { guard(onClose) await settleProbe() - expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledWith(expect.anything(), 'pty-a') + expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledWith(expect.anything(), 'pty-a', { + scanChildProcesses: true + }) expect(onClose).not.toHaveBeenCalled() expect(visibleRequest()).toMatchObject({ terminalTabId: 'tab-1' }) }) diff --git a/src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts b/src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts index 29b2e6cfd08..b2cdcb5b2ad 100644 --- a/src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts +++ b/src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts @@ -5,7 +5,7 @@ * * Mouse close path: SortableTab (X onClick / onAuxClick button===1) -> onClose * -> Terminal.tsx handleCloseTab -> closeTerminalTab() -> running-process guard. - * Keyboard path: Cmd+W -> TerminalPane.handleRequestClosePane -> inspectRuntimeTerminalProcess + * Keyboard path: Cmd+W -> TerminalPane.handleRequestClosePane -> probePtyRunningWork * (split panes) or closeTerminalTab's guard (last pane) -> CloseTerminalDialog. */ import { readFileSync } from 'node:fs' @@ -99,8 +99,11 @@ describe('#10142 close confirmation policy is the same for keyboard and mouse', 'utf8' ) const handler = source.slice(source.indexOf('const handleRequestClosePane')) + // The shared probe, not a direct inspect: the pane path asks the same question as the tab + // guard, and routing both through one measurement is what stops them drifting on what an + // unanswered host means. expect(handler.slice(0, handler.indexOf('useImperativeHandle'))).toContain( - 'inspectRuntimeTerminalProcess' + 'probePtyRunningWork' ) }) diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts index 52609b75590..7f54f5873bc 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts @@ -146,6 +146,58 @@ describe('runtime terminal owner routing', () => { expect(localHasChildren).not.toHaveBeenCalled() }) + // Why these exist: the close guards ask the host to pay for a real child-process read, and the + // environment path used to drop the option before it reached the wire. The host then declined to + // scan and answered `unverifiable`, which the guard reads as running work -- a confirmation + // dialog on every idle close of a remote Windows pane. Found by review on #18591. + it('forwards scanChildProcesses to the PTY owning environment', async () => { + await inspectRuntimeTerminalProcess( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1', + { scanChildProcesses: true } + ) + + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.inspectProcess', + params: { terminal: 'terminal-1', scanChildProcesses: true }, + timeoutMs: 15_000 + }) + }) + + it('forwards scanChildProcesses alongside the incarnation fence', async () => { + await inspectRuntimeTerminalProcess( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1', + { expectedIncarnationId: 'incarnation-1', scanChildProcesses: true } + ) + + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.inspectProcess', + params: { + terminal: 'terminal-1', + expectedIncarnationId: 'incarnation-1', + scanChildProcesses: true + }, + timeoutMs: 15_000 + }) + }) + + it('omits scanChildProcesses when the caller is only polling', async () => { + await inspectRuntimeTerminalProcess( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1' + ) + + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.inspectProcess', + params: { terminal: 'terminal-1' }, + timeoutMs: 15_000 + }) + }) + it('maps an old host inspection to client-only unverifiable', async () => { runtimeCall.mockResolvedValue({ ok: true, diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.ts b/src/renderer/src/runtime/runtime-terminal-inspection.ts index 284496426a0..8c354cdc415 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.ts @@ -138,7 +138,7 @@ export function recordRuntimeTerminalInputForPtyId(ptyId: string, timestamp = Da export async function inspectRuntimeTerminalProcess( settings: Pick | null | undefined, ptyId: string, - options?: { expectedIncarnationId?: string } + options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean } ): Promise { const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) const target = ownerEnvironmentId @@ -148,7 +148,7 @@ export async function inspectRuntimeTerminalProcess( const remote = isRemoteInspectionPtyId(ptyId) if (target.kind !== 'environment' || !terminal) { try { - const result = await (options?.expectedIncarnationId + const result = await (options ? window.api.pty.inspectProcess(ptyId, options) : window.api.pty.inspectProcess(ptyId)) return normalizeInspectionResult(result, remote) @@ -169,7 +169,11 @@ export async function inspectRuntimeTerminalProcess( terminal, ...(options?.expectedIncarnationId ? { expectedIncarnationId: options.expectedIncarnationId } - : {}) + : {}), + // Why forwarded: the close guards pass this so the host pays for a real child-process read. + // Dropped here, the host declines to scan and answers `unverifiable`, which the guard reads + // as running work -- a confirmation dialog on every idle close of a remote Windows pane. + ...(options?.scanChildProcesses === true ? { scanChildProcesses: true } : {}) }, { timeoutMs: 15_000 } ) diff --git a/src/shared/terminal-process-inspection.ts b/src/shared/terminal-process-inspection.ts index 6448cd9580e..c589bb65cdd 100644 --- a/src/shared/terminal-process-inspection.ts +++ b/src/shared/terminal-process-inspection.ts @@ -1,5 +1,15 @@ import type { RemoteForegroundEvidence } from './foreground-process-evidence' +/** + * What the execution host observed about processes running under a PTY's shell. + * + * Separate from `hasChildProcesses` because a boolean cannot hold the third answer. The host that + * could not read its own process table and the host that read it and found nothing both had to + * spell themselves `false`, and every close guard reads `false` as "nothing is running here". + * Windows relays spelled it `false` unconditionally. + */ +export type PtyChildProcessVerdict = 'children' | 'no-children' | 'unverifiable' + /** Reasons the renderer could not observe the execution host. */ export type ClientOnlyUnverifiableReason = | 'transport_loss' @@ -17,6 +27,7 @@ export type ClientOnlyUnverifiableInspection = { verdict: 'unverifiable' reason: string foregroundProcessEvidence?: never + childProcessEvidence?: never authorityGeneration?: never observationEpoch?: never capturedAgeMs?: never @@ -30,6 +41,8 @@ export type HostProcessInspection = { hasChildProcesses: boolean /** Optional on old hosts; the renderer treats an omitted field as old-host unverifiable. */ foregroundProcessEvidence?: RemoteForegroundEvidence + /** Absent on hosts that predate the member, and on answers the host did not pay to observe. */ + childProcessEvidence?: PtyChildProcessVerdict verdict?: never reason?: never } From 886fcf083f99e22de4b6b45f2ce859a3cbcff221 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 4 Sep 2026 05:06:44 -0700 Subject: [PATCH 17/58] fix(runtime): let a scoped worktree listing report the host it could not cover (#18645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(runtime): let a scoped worktree listing report the host it could not cover `orca worktree list --repo ` passed `[]` as `knownHostIds`, so a scoped listing could never report a gap — for any host kind, reachable or not. With zero matched rows both scope lists are empty by construction, and the answer is `{hostIds: [], omittedHostIds: []}`: byte-identical to a repo that genuinely has no worktrees. docs/reference/ssh-execution-boundary.md forbids a listing from implying exactly that. Measured on one runtime with one refusing SSH host, in the same second: unscoped totalCount 0 omittedHostIds ["local","ssh:"] (+ --host selectors) scoped totalCount 0 hostScope {"hostIds":[],"omittedHostIds":[]} The same runtime reports nine omitted hosts unscoped and zero scoped against a live profile, so this is not a subtle inconsistency: it is one runtime giving two contradictory answers about its own coverage. A scoped listing now names the one host the caller asked about. That costs nothing when rows come back — the host lands in `covered`, so it is never reported omitted — and is the whole answer when they do not. Hosts the caller scoped out are still never named, which a test pins, because naming them all is the obvious over-correction. * test(runtime): pin the scoped host derivation for local and executionHostId repos Review flagged that the host-scope cases only covered a connectionId repo. getRepoExecutionHostId reads two spellings, and a scoped listing naming the wrong host would be worse than naming none, so both are pinned. Both fail with the fix reverted. --- .../runtime-managed-worktree-queries.test.ts | 120 +++++++++++++++++- .../runtime-managed-worktree-queries.ts | 12 +- .../runtime/worktree-listing-host-scope.ts | 28 +++- 3 files changed, 151 insertions(+), 9 deletions(-) diff --git a/src/main/runtime/runtime-managed-worktree-queries.test.ts b/src/main/runtime/runtime-managed-worktree-queries.test.ts index 01df53cbd1d..3fb792fcc7c 100644 --- a/src/main/runtime/runtime-managed-worktree-queries.test.ts +++ b/src/main/runtime/runtime-managed-worktree-queries.test.ts @@ -40,14 +40,18 @@ function metadata(overrides: Partial = {}): WorktreeMeta { } } -function queries(store: RuntimeStore): RuntimeManagedWorktreeQueries { +function queries( + store: RuntimeStore, + overrides: Partial[0]> = {} +): RuntimeManagedWorktreeQueries { return new RuntimeManagedWorktreeQueries({ getStore: () => store, listResolved: async () => [], resolveRepo: async () => store.getRepos()[0]!, selectRepos: () => store.getRepos(), scanRepo: async () => ({ ok: true, worktrees: [] }), - listKnownHostIds: () => [] + listKnownHostIds: () => [], + ...overrides }) } @@ -105,3 +109,115 @@ describe('RuntimeManagedWorktreeQueries.listDetected', () => { expect(legacy.worktrees[0]).not.toHaveProperty('visibilitySource') }) }) + +describe('RuntimeManagedWorktreeQueries.list host scope', () => { + // Measured on hardware before this fix, same runtime and same refusing SSH host in the same + // second: the UNSCOPED listing reported `omittedHostIds: ["local","ssh:ssh-scope-refused"]` with + // `--host` selectors, while the SCOPED listing reported `{"hostIds":[],"omittedHostIds":[]}`. + // A listing that covered nothing, reporting no gaps, is indistinguishable from a repo that + // genuinely has no worktrees -- the thing docs/reference/ssh-execution-boundary.md forbids. + function sshStore(): RuntimeStore { + const repo = folderRepo({ + id: 'repo-ssh', + kind: 'git', + connectionId: 'conn-1', + path: '/home/dev/app' + }) + return { + getRepos: () => [repo], + getRepo: () => repo, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: vi.fn(), + getAllWorktreeLineage: () => ({}), + getSettings: () => settings + } as unknown as RuntimeStore + } + + it('names the scoped repo host as omitted when the listing covered nothing', async () => { + const result = await queries(sshStore()).list('repo-ssh', 50) + + expect(result.totalCount).toBe(0) + expect(result.hostScope).toEqual({ + hostIds: [], + omittedHostIds: ['ssh:conn-1'] + }) + }) + + it('does not report the scoped host as omitted once it contributes rows', async () => { + const store = sshStore() + const result = await queries(store, { + listResolved: async () => + [ + { + id: 'repo-ssh::/home/dev/app', + repoId: 'repo-ssh', + path: '/home/dev/app', + hostId: 'ssh:conn-1' + } + ] as never + }).list('repo-ssh', 50) + + expect(result.hostScope?.hostIds).toEqual(['ssh:conn-1']) + expect(result.hostScope?.omittedHostIds).toEqual([]) + }) + + // The caller scoped the listing, so the hosts they excluded must not come back as gaps. + it('never names a host the caller scoped out', async () => { + const scoped = await queries(sshStore(), { + listKnownHostIds: () => ['local', 'ssh:other', 'runtime:elsewhere'] as never + }).list('repo-ssh', 50) + + expect(scoped.hostScope?.omittedHostIds).toEqual(['ssh:conn-1']) + }) + + // `getRepoExecutionHostId` derives the host from two spellings, and a scoped listing that named + // the wrong one would be worse than naming none. These pin both. + it('names the local host for a scoped local repo', async () => { + const repo = folderRepo({ id: 'repo-local', kind: 'git', path: '/workspace/local' }) + const store = { + getRepos: () => [repo], + getRepo: () => repo, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: vi.fn(), + getAllWorktreeLineage: () => ({}), + getSettings: () => settings + } as unknown as RuntimeStore + + const result = await queries(store).list('repo-local', 50) + + expect(result.hostScope?.omittedHostIds).toEqual(['local']) + }) + + it('prefers executionHostId over connectionId for the scoped host', async () => { + const repo = folderRepo({ + id: 'repo-runtime', + kind: 'git', + connectionId: 'conn-legacy', + executionHostId: 'runtime:env-1', + path: '/workspace/runtime' + }) + const store = { + getRepos: () => [repo], + getRepo: () => repo, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: vi.fn(), + getAllWorktreeLineage: () => ({}), + getSettings: () => settings + } as unknown as RuntimeStore + + const result = await queries(store).list('repo-runtime', 50) + + expect(result.hostScope?.omittedHostIds).toEqual(['runtime:env-1']) + }) + + it('still reports every configured host when the listing is unscoped', async () => { + const unscoped = await queries(sshStore(), { + listKnownHostIds: () => ['local', 'ssh:conn-1'] as never + }).list(undefined, 50) + + expect(unscoped.hostScope?.omittedHostIds).toEqual(['local', 'ssh:conn-1']) + }) +}) diff --git a/src/main/runtime/runtime-managed-worktree-queries.ts b/src/main/runtime/runtime-managed-worktree-queries.ts index 5a5812236af..158ab77cdd0 100644 --- a/src/main/runtime/runtime-managed-worktree-queries.ts +++ b/src/main/runtime/runtime-managed-worktree-queries.ts @@ -2,7 +2,7 @@ import type { DetectedWorktreeListResult, Worktree } from '../../shared/worktree import type { Repo } from '../../shared/repo-types' import type { RuntimeWorktreeListResult } from '../../shared/runtime-types' import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' -import { buildWorktreeListingPage } from './worktree-listing-host-scope' +import { buildWorktreeListingPage, listingKnownHostIds } from './worktree-listing-host-scope' import { readWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta' import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership' import type { WorktreeMeta } from '../../shared/worktree/meta-types' @@ -80,7 +80,7 @@ export class RuntimeManagedWorktreeQueries { throw new Error('invalid_limit') } const resolved = await this.deps.listResolved() - const repoId = repoSelector ? (await this.deps.resolveRepo(repoSelector)).id : null + const scopedRepo = repoSelector ? await this.deps.resolveRepo(repoSelector) : null const pathsByRepo = new Map() for (const worktree of resolved) { const paths = pathsByRepo.get(worktree.repoId) ?? [] @@ -100,12 +100,12 @@ export class RuntimeManagedWorktreeQueries { ) const worktrees = resolved.filter( (worktree) => - (!repoId || worktree.repoId === repoId) && + (!scopedRepo || worktree.repoId === scopedRepo.id) && this.isVisible(worktree, matchers.get(worktree.repoId), sourceDefaultsSupported) ) - // Why: a `--repo` listing was scoped by the caller, so naming every configured host as - // omitted would report a gap the caller deliberately excluded. - return buildWorktreeListingPage(worktrees, limit, repoId ? [] : this.deps.listKnownHostIds()) + // See `listingKnownHostIds`: a scoped listing must still name the host it was asked about. + const knownHostIds = listingKnownHostIds(scopedRepo, () => this.deps.listKnownHostIds()) + return buildWorktreeListingPage(worktrees, limit, knownHostIds) } resolveRepoForConnection(selector: string, connectionId?: string | null): Promise { diff --git a/src/main/runtime/worktree-listing-host-scope.ts b/src/main/runtime/worktree-listing-host-scope.ts index 99650882670..e499d7faaf3 100644 --- a/src/main/runtime/worktree-listing-host-scope.ts +++ b/src/main/runtime/worktree-listing-host-scope.ts @@ -1,4 +1,5 @@ -import type { ExecutionHostId } from '../../shared/execution-host' +import type { Repo } from '../../shared/repo-types' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { selectHostBalancedPage } from '../../shared/host-balanced-listing-page' import type { RuntimeListingHostScope } from '../../shared/runtime-listing-host-scope' @@ -62,3 +63,28 @@ export function buildWorktreeListingHostScope(args: { } return { hostIds: [...covered].sort(), omittedHostIds: [...omitted].sort() } } + +/** + * Which hosts a listing claims to have been looking at. + * + * A `--repo` listing was scoped by the caller, so naming every configured host would report gaps + * the caller deliberately excluded. Naming NONE — which is what a scoped listing did before — means + * the scope can never report a gap at all, for any host kind, because `covered` and `omitted` are + * both derived from the returned rows plus this list. A scoped listing whose scan did not succeed + * then answers `{hostIds: [], omittedHostIds: []}`: byte-identical to a repo that genuinely has no + * worktrees, which is the one thing docs/reference/ssh-execution-boundary.md forbids a listing from + * implying. + * + * Measured before the fix, on one runtime with one refusing SSH host, in the same second: the + * unscoped listing reported `omittedHostIds: ["local", "ssh:"]` while the scoped listing + * reported `[]`. + * + * Naming the single host the caller asked about costs nothing when rows come back — it lands in + * `covered`, so it is never reported omitted — and is the whole answer when they do not. + */ +export function listingKnownHostIds( + scopedRepo: Repo | null, + listKnownHostIds: () => Iterable +): Iterable { + return scopedRepo ? [getRepoExecutionHostId(scopedRepo)] : listKnownHostIds() +} From f7e3af254a5e6d5153be4ccf0ebdf38bbb3dff66 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 4 Sep 2026 05:32:19 -0700 Subject: [PATCH 18/58] fix(pty): close the pseudoconsole and dispose the conout worker on Windows self-exit (F24) (#18635) * fix(pty): close the pseudoconsole when a Windows shell exits by itself `ClosePseudoConsole` is the only thing that reaps a ConPTY's console host. node-pty calls it from one place, `PtyKill`, which starts by looking the baton up by id -- and the exit watcher in `SetupExitCallback` erased that baton the moment the shell died. So on the self-exit path (typing `exit`, how panes usually close) the lookup missed, `PtyKill` did nothing at all, and the pseudoconsole was never closed. The baton now survives until BOTH the shell has exited and `kill()` has run; whichever arrives second frees it. `PtyKill` copies `hpc` out under the lock and closes it afterwards, guards `TerminateProcess` on a shell handle the watcher may already have closed, and duplicates that handle rather than reordering, so upstream's close-then-terminate sequence is unchanged. Measured on Windows 11, 20 self-exit cycles driven exactly as Orca drives them (`onExit -> destroy()`), handles bucketed by NT object type: relay spawn (no useConptyDll) 225 -> 285 (+1 Process +2 File/term) after 219 -> 219 FLAT desktop spawn (useConptyDll) 239 -> 439 (+1 Process +2 Thread +5 File/term) after 235 -> 395 (+2 Thread +4 File/term) The desktop residue is a separate defect in the `useConptyDll` branch of `WindowsPtyAgent.kill()`, which disposes the conout worker only from an `_outSocket.on('data')` handler -- and no data arrives after the shell has gone. Fixing that line as well takes the desktop to 222 -> 222 FLAT, but it lives in the `kill()` hunk owned by F23, so it is left to that change. Refs F24. * fix(pty): dispose the conout worker when a Windows shell exits by itself Second, independent defect on the same self-exit path, and the larger half of the desktop's leak. The `useConptyDll` branch of `WindowsPtyAgent.kill()` disposed the conout worker only from an `_outSocket.on('data')` handler -- and once the shell has gone no more data ever arrives, so the worker was never disposed. The non-DLL branch three lines above already disposed unconditionally, which is why only the desktop (the only spawner that sets `useConptyDll`) hit it. Measured on Windows 11, 20 cycles, handles bucketed by NT object type, totals: self-exit, relay spawn 225 -> 285 now 219 -> 219 FLAT self-exit, desktop spawn 239 -> 439 now 222 -> 222 FLAT explicit kill, relay spawn 225 -> 285 now 219 -> 219 FLAT explicit kill, desktop spawn 235 -> 395 now 219 -> 219 FLAT Neither fix alone is enough on the desktop: the pseudoconsole close is worth +1 Process +1 File per terminal, this dispose +2 Thread +4 File. The relay asset (config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs) deliberately gets no counterpart: the relay takes the non-DLL branch, where the dispose is already unconditional. Its reconstruction table needs the new hunk though, or un-applying the desktop hunks no longer yields published node-pty. Taken over from F23 at win-relay-qa's request after they verified that the desktop never executes the non-DLL branch F23 was scoped around. Refs F24. * fix(pty): harden PtyKill against a failed handle duplication and a missing DLL Both from review of #18635. DuplicateHandle's result was dropped. On the live explicit-kill path a failed duplication left hShellDup null, which the guard below could not tell apart from the self-exit case, so TerminateProcess was skipped and the shell kept running after its pane closed -- a worse outcome than the handle leak this patch exists to fix. The failure now terminates through handle->hShell under the lock, where it is valid and where TerminateProcess does not block. The only cost is that the rare path kills before the console closes instead of after. LoadConptyDll is now resolved BEFORE any baton state is touched, matching what PtyConnect already does for the same reason. It throws when conpty.dll is missing, and a throw after consoleClosed was set would strand the pseudoconsole permanently: the retry finds the work claimed and does nothing. Also corrects three comments the earlier commits made stale: - the ptyJobMutex note still said PtyKill reads the table unlocked - PtyListJobProcessIds said the baton is gone once the shell exits; it now outlives the shell, and the nulled hJob is what makes the answer null - windows-pty-job.ts said node-pty drops its handle record on exit Re-measured on Windows 11 with the rebuilt binary, 20 cycles, all four paths still flat: self-exit relay 219->219, self-exit desktop 222->222, explicit-kill relay 219->219, explicit-kill desktop 219->219. Both explicit-kill runs report 22/22 shells exited, so the kill still lands. Refs F24. --- config/patches/node-pty@1.1.0.patch | 170 +++++++++++++++--- ...de-pty-windows-pty-teardown-patch.test.mjs | 18 ++ pnpm-lock.yaml | 6 +- ...-pty-self-exit-pseudoconsole-close.test.ts | 134 ++++++++++++++ src/main/windows/windows-pty-job.ts | 6 +- 5 files changed, 308 insertions(+), 26 deletions(-) create mode 100644 src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch index ff474f7d95e..8f5045b932a 100644 --- a/config/patches/node-pty@1.1.0.patch +++ b/config/patches/node-pty@1.1.0.patch @@ -603,7 +603,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..2ae787c5bd4f3eba470584dc658a01a5 } #endif diff --git a/src/win/conpty.cc b/src/win/conpty.cc -index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a6a4082ce 100644 +index 7b286d3d644c26141df516929703aa6e129df4b2..4aed260dd68e6a171dcfd349e9a7c5c97209248e 100644 --- a/src/win/conpty.cc +++ b/src/win/conpty.cc @@ -18,6 +18,7 @@ @@ -614,7 +614,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a #include #include #include -@@ -44,12 +45,29 @@ struct pty_baton { +@@ -44,12 +45,39 @@ struct pty_baton { HANDLE hOut; HPCON hpc; @@ -630,22 +630,32 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a + // refused to create or assign one (an outer job without breakaway rights), + // in which case callers fall back to their pre-job behaviour. + HANDLE hJob = nullptr; ++ ++ // Orca: teardown needs BOTH the shell's death and an explicit kill() before ++ // the baton can be freed, so each side records that it has run. Whichever ++ // arrives second frees it. Freeing on the shell's death alone -- what this ++ // file did before -- destroyed the only record of `hpc` while ++ // ClosePseudoConsole was still owed, which is why a self-exiting shell ++ // leaked its pseudoconsole and the console host it reaps (#18601 / F24). ++ bool shellExited = false; ++ bool consoleClosed = false; pty_baton(int _id, HANDLE _hIn, HANDLE _hOut, HPCON _hpc) : id(_id), hIn(_hIn), hOut(_hOut), hpc(_hpc) {}; }; static std::vector> ptyHandles; -+// Orca: guards the job accessors below against the exit watcher thread. It does -+// NOT make the whole table safe -- PtyResize/PtyClear/PtyKill read it unlocked, -+// as they always have -- but it closes the window this patch opened, where the -+// watcher can close hShell/hJob and free the baton between a lookup and its use. ++// Orca: guards the job accessors below, and PtyKill, against the exit watcher ++// thread. It does NOT make the whole table safe -- PtyResize and PtyClear still ++// read it unlocked, as they always have -- but it closes the window this patch ++// opened, where the watcher can close hShell/hJob and free the baton between a ++// lookup and its use. +// Handle VALUES are recycled aggressively, so an unguarded read could pass the +// shell-pid check against an unrelated process and terminate the wrong job. +static std::mutex ptyJobMutex; static volatile LONG ptyCounter; static pty_baton* get_pty_baton(int id) { -@@ -102,8 +120,27 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) { +@@ -102,8 +130,31 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) { // Get process exit code. GetExitCodeProcess(baton->hShell, (LPDWORD)(&exit_event->exit_code)); // Clean up handles @@ -665,9 +675,13 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a + // Why inside the lock: erasing frees the baton the job accessors hold a + // pointer to. Note remove_pty_baton must not be an assert() argument -- + // NDEBUG would compile the call away and leak every baton. -+ const bool removed = remove_pty_baton(baton->id); -+ assert(removed); -+ (void)removed; ++ baton->shellExited = true; ++ if (baton->consoleClosed) { ++ const bool removed = remove_pty_baton(baton->id); ++ assert(removed); ++ (void)removed; ++ } ++ // Else PtyKill has not run yet and still owns hpc. It frees the baton. + } + // Why the lock ends here: BlockingCall below waits on the JS thread, and the + // JS thread can be waiting on ptyJobMutex inside PtyTerminateJob. Holding @@ -675,7 +689,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a auto status = tsfn.BlockingCall(exit_event, callback); // In main thread switch (status) { -@@ -409,6 +446,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { +@@ -409,6 +460,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { throw errorWithCode(info, "UpdateProcThreadAttribute failed"); } @@ -691,7 +705,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a PROCESS_INFORMATION piClient{}; fSuccess = !!CreateProcessW( nullptr, -@@ -416,7 +462,10 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { +@@ -416,7 +476,10 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { nullptr, // lpProcessAttributes nullptr, // lpThreadAttributes false, // bInheritHandles VERY IMPORTANT that this is false @@ -703,7 +717,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a envArg, // lpEnvironment mutableCwd.get(), // lpCurrentDirectory &siEx.StartupInfo, // lpStartupInfo -@@ -426,8 +475,47 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { +@@ -426,8 +489,47 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { throw errorWithCode(info, "Cannot create process"); } @@ -753,7 +767,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a if (useConptyDll && fLoadedDll) { PFNRELEASEPSEUDOCONSOLE const pfnReleasePseudoConsole = (PFNRELEASEPSEUDOCONSOLE)GetProcAddress( -@@ -440,6 +528,8 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { +@@ -440,6 +542,8 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { // Update handle handle->hShell = piClient.hProcess; @@ -762,7 +776,91 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a // Close the thread handle to avoid resource leak CloseHandle(piClient.hThread); -@@ -567,6 +657,143 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) { +@@ -544,29 +648,215 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) { + int id = info[0].As().Int32Value(); + const bool useConptyDll = info[1].As().Value(); + +- const pty_baton* handle = get_pty_baton(id); ++ // Orca: resolve the DLL BEFORE touching any baton state, for the same reason ++ // PtyConnect does it before creating anything. LoadConptyDll throws when ++ // conpty.dll is missing, and a throw after consoleClosed was set would strand ++ // the pseudoconsole permanently: the retry would find the work already ++ // claimed and do nothing. Only the useConptyDll path can throw here; the ++ // other returns kernel32. ++ HANDLE hLibrary = LoadConptyDll(info, useConptyDll); ++ PFNCLOSEPSEUDOCONSOLE pfnClosePseudoConsole = nullptr; ++ if (hLibrary != nullptr) { ++ pfnClosePseudoConsole = (PFNCLOSEPSEUDOCONSOLE)GetProcAddress( ++ (HMODULE)hLibrary, ++ useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole"); ++ } + +- if (handle != nullptr) { +- HANDLE hLibrary = LoadConptyDll(info, useConptyDll); +- bool fLoadedDll = hLibrary != nullptr; +- if (fLoadedDll) +- { +- PFNCLOSEPSEUDOCONSOLE const pfnClosePseudoConsole = (PFNCLOSEPSEUDOCONSOLE)GetProcAddress( +- (HMODULE)hLibrary, +- useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole"); +- if (pfnClosePseudoConsole) +- { +- pfnClosePseudoConsole(handle->hpc); ++ // Orca: the baton now outlives the shell, so this runs on a self-exited pty ++ // too -- that is the whole point. Take what we need under the lock: the ++ // watcher thread nulls hShell the moment the shell dies, and TerminateProcess ++ // on a handle it just closed is an invalid-handle operation. Duplicating ++ // rather than reordering keeps upstream's close-then-terminate sequence. ++ HPCON hpc = nullptr; ++ HANDLE hShellDup = nullptr; ++ bool owed = false; ++ { ++ std::lock_guard guard(ptyJobMutex); ++ pty_baton* handle = get_pty_baton(id); ++ // Why the consoleClosed check: a second kill() would otherwise close the ++ // same pseudoconsole twice. Upstream relied on the baton being gone. ++ if (handle != nullptr && !handle->consoleClosed) { ++ hpc = handle->hpc; ++ owed = true; ++ handle->consoleClosed = true; ++ // Null hShell means a self-exited pty, where there is nothing to kill. ++ if (useConptyDll && handle->hShell != nullptr) { ++ if (!DuplicateHandle(GetCurrentProcess(), handle->hShell, GetCurrentProcess(), ++ &hShellDup, 0, FALSE, DUPLICATE_SAME_ACCESS)) { ++ // Why terminate here instead of skipping: a failed duplication leaves ++ // hShellDup null, which is indistinguishable from the self-exit case, ++ // and skipping would leave the shell RUNNING after its pane closed -- ++ // a worse outcome than the leak this all exists to fix. hShell is ++ // valid under this lock and TerminateProcess does not block, so the ++ // only cost is that this rare path kills before the console closes. ++ hShellDup = nullptr; ++ TerminateProcess(handle->hShell, 1); ++ } ++ } ++ if (handle->shellExited) { ++ const bool removed = remove_pty_baton(id); ++ assert(removed); ++ (void)removed; + } ++ // Else the shell is still running and the watcher frees the baton. + } +- if (useConptyDll) { +- TerminateProcess(handle->hShell, 1); ++ } ++ ++ // Why outside the lock: ClosePseudoConsole blocks until the conout side has ++ // drained, and the watcher must be able to take the lock while it does. ++ if (owed) { ++ if (pfnClosePseudoConsole) ++ { ++ pfnClosePseudoConsole(hpc); ++ } ++ if (hShellDup != nullptr) { ++ TerminateProcess(hShellDup, 1); ++ CloseHandle(hShellDup); + } + } + return env.Undefined(); } @@ -808,9 +906,11 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a + * Orca: the pids still alive in this pty's tree, straight from the kernel. + * + * Descendant liveness for a tree that is still tracked, including children that -+ * detached from the console. Once the shell exits the baton is gone, so this -+ * returns null rather than an empty list -- null means "no answer", never -+ * "they died". Also returns null when no job was assigned. ++ * detached from the console. Once the shell exits the watcher nulls hJob, which ++ * ownsShell rejects, so this returns null rather than an empty list -- null ++ * means "no answer", never "they died". (The baton itself now outlives the ++ * shell, until kill() runs; hJob is what makes the answer null.) Also returns ++ * null when no job was assigned. + * + * Does not include the ConPTY console host: CreatePseudoConsole spawns it + * before this job exists, so it is not a member and ClosePseudoConsole is what @@ -906,7 +1006,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a /** * Init */ -@@ -577,6 +804,9 @@ Napi::Object init(Napi::Env env, Napi::Object exports) { +@@ -577,6 +867,9 @@ Napi::Object init(Napi::Env env, Napi::Object exports) { exports.Set("resize", Napi::Function::New(env, PtyResize)); exports.Set("clear", Napi::Function::New(env, PtyClear)); exports.Set("kill", Napi::Function::New(env, PtyKill)); @@ -917,7 +1017,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a }; diff --git a/lib/windowsPtyAgent.js b/lib/windowsPtyAgent.js -index a358ffb..fb3a96f 100644 +index a358ffb177357e177661033c1b092f9c9d0e5f5a..26c2a4c58799ce649f5113131e4c52f7ed2d87ad 100644 --- a/lib/windowsPtyAgent.js +++ b/lib/windowsPtyAgent.js @@ -136,6 +136,9 @@ var WindowsPtyAgent = /** @class */ (function () { @@ -930,6 +1030,20 @@ index a358ffb..fb3a96f 100644 this._outSocket.readable = false; this._getConsoleProcessList().then(function (consoleProcessList) { consoleProcessList.forEach(function (pid) { +@@ -154,9 +157,10 @@ var WindowsPtyAgent = /** @class */ (function () { + // Close the input write handle to signal the end of session. + this._inSocket.destroy(); + this._ptyNative.kill(this._pty, this._useConptyDll); +- this._outSocket.on('data', function () { +- _this._conoutSocketWorker.dispose(); +- }); ++ // Orca: dispose unconditionally, as the non-DLL branch above does. ++ // Waiting for another 'data' event leaks the conout worker on every ++ // self-exiting shell, because no more data ever arrives (F24). ++ this._conoutSocketWorker.dispose(); + } + } + else { diff --git a/lib/windowsTerminal.js b/lib/windowsTerminal.js index 3c38f89..e20b3e6 100644 --- a/lib/windowsTerminal.js @@ -1015,7 +1129,7 @@ index 3c38f89..e20b3e6 100644 \ No newline at end of file +//# sourceMappingURL=windowsTerminal.js.map diff --git a/src/windowsPtyAgent.ts b/src/windowsPtyAgent.ts -index d705444..ce611b8 100644 +index d7054449516f0c9a62af351c2caa17331206d530..0c28a32e2e1db2b3f208ddde8443cd4e67bb1ad6 100644 --- a/src/windowsPtyAgent.ts +++ b/src/windowsPtyAgent.ts @@ -143,6 +143,9 @@ export class WindowsPtyAgent { @@ -1028,6 +1142,20 @@ index d705444..ce611b8 100644 this._outSocket.readable = false; this._getConsoleProcessList().then(consoleProcessList => { consoleProcessList.forEach((pid: number) => { +@@ -159,9 +162,10 @@ export class WindowsPtyAgent { + // Close the input write handle to signal the end of session. + this._inSocket.destroy(); + (this._ptyNative as IConptyNative).kill(this._pty, this._useConptyDll); +- this._outSocket.on('data', () => { +- this._conoutSocketWorker.dispose(); +- }); ++ // Orca: dispose unconditionally, as the non-DLL branch above does. ++ // Waiting for another 'data' event leaks the conout worker on every ++ // self-exiting shell, because no more data ever arrives (F24). ++ this._conoutSocketWorker.dispose(); + } + } else { + // Because pty.kill closes the handle, it will kill most processes by itself. diff --git a/src/windowsTerminal.ts b/src/windowsTerminal.ts index 13f6c6d..eda63c8 100644 --- a/src/windowsTerminal.ts diff --git a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs index 380cdd44c01..64fb1b056b8 100644 --- a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs +++ b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs @@ -38,6 +38,24 @@ const DESKTOP_HUNKS = { ' this._outSocket.readable = false;', '' ].join('\n') + ], + // The useConptyDll branch, which only the DESKTOP runs -- the relay takes the + // non-DLL branch above, where the dispose is already unconditional. Listed here + // so un-applying still yields published; the relay asset needs no counterpart. + [ + [ + ' // Orca: dispose unconditionally, as the non-DLL branch above does.', + " // Waiting for another 'data' event leaks the conout worker on every", + ' // self-exiting shell, because no more data ever arrives (F24).', + ' this._conoutSocketWorker.dispose();', + '' + ].join('\n'), + [ + " this._outSocket.on('data', function () {", + ' _this._conoutSocketWorker.dispose();', + ' });', + '' + ].join('\n') ] ], 'windowsTerminal.js': [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a045833577f..d7481f2e556 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,7 +116,7 @@ patchedDependencies: '@xterm/addon-webgl@0.20.0-beta.299': 94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e '@xterm/xterm@6.1.0-beta.303': 98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673 - node-pty@1.1.0: e262847f57a1d4d3f2287a843822f7dcf3c9d8655892b07a69eba464e1317eaa + node-pty@1.1.0: 7cc9d45f3d2c38f142490d0805e75db55f0eef5174ad41c4b52abc5fbe079ad1 importers: @@ -157,7 +157,7 @@ importers: version: 3.3.1 node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=e262847f57a1d4d3f2287a843822f7dcf3c9d8655892b07a69eba464e1317eaa) + version: 1.1.0(patch_hash=7cc9d45f3d2c38f142490d0805e75db55f0eef5174ad41c4b52abc5fbe079ad1) posthog-node: specifier: ^5.33.3 version: 5.33.3 @@ -12195,7 +12195,7 @@ snapshots: node-int64@0.4.0: {} - node-pty@1.1.0(patch_hash=e262847f57a1d4d3f2287a843822f7dcf3c9d8655892b07a69eba464e1317eaa): + node-pty@1.1.0(patch_hash=7cc9d45f3d2c38f142490d0805e75db55f0eef5174ad41c4b52abc5fbe079ad1): dependencies: node-addon-api: 7.1.1 diff --git a/src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts b/src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts new file mode 100644 index 00000000000..5f19772530a --- /dev/null +++ b/src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts @@ -0,0 +1,134 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * A shell that exits by itself must still close its pseudoconsole. + * + * `ClosePseudoConsole` is the only thing that reaps a ConPTY's console host — + * Orca's own job-ownership patch says so, because `CreatePseudoConsole` spawns + * that host before the per-pty job exists and it is therefore not a job member. + * Upstream node-pty calls it from exactly one place, `PtyKill`, which begins by + * looking the baton up by id — and the exit watcher in `SetupExitCallback` + * erased the baton the moment the shell died. So on the self-exit path (typing + * `exit`, which is how panes usually close) that lookup missed, `PtyKill` did + * nothing at all, and the pseudoconsole was never closed. + * + * There is a SECOND, independent defect on the same path: the `useConptyDll` + * branch of `WindowsPtyAgent.kill()` disposed the conout worker only from an + * `_outSocket.on('data')` handler, and no more data arrives once the shell has + * gone — so that worker leaked too. The non-DLL branch beside it already + * disposed unconditionally. The desktop always sets `useConptyDll`, so it hit + * both; the relay sets neither and hit only the first. + * + * Measured on Windows 11 / awin, 20 cycles, handles bucketed by NT object type, + * totals before -> after: + * + * self-exit, relay spawn 225 -> 285 becomes 219 -> 219 FLAT + * self-exit, desktop spawn 239 -> 439 becomes 222 -> 222 FLAT + * explicit kill, relay spawn 225 -> 285 becomes 219 -> 219 FLAT + * explicit kill, desktop spawn 235 -> 395 becomes 219 -> 219 FLAT + * + * Neither fix alone is enough on the desktop: the pseudoconsole close is worth + * +1 Process +1 File per terminal, the dispose +2 Thread +4 File. + * + * WHY THIS IS A PATCH-CONTENT PIN AND NOT A BEHAVIOURAL TEST: the defect is + * only observable as a per-NT-type handle count, which needs + * `NtQuerySystemInformation(SystemExtendedHandleInformation)`. Nothing in the + * repo can read that, and the cheaper Windows-observable proxies do not + * discriminate — the console host process is reaped either way (the leak is a + * handle to an already-exited object, not an orphaned process), and the + * `\\.\pipe\conpty-*` entries disappear either way. Both were measured and + * rejected as assertions rather than assumed. So this pins the mechanism + * instead, which is the real risk: a future resync of the vendored patch + * silently dropping the hunk. + */ + +const PATCH = readFileSync(join(__dirname, '../../../config/patches/node-pty@1.1.0.patch'), 'utf8') + +describe('node-pty patch: pseudoconsole close on the self-exit path', () => { + it('does not let the exit watcher free the baton while the close is still owed', () => { + // Pinned as one block: the erase must stay INSIDE the consoleClosed guard. + // Upstream ran it unconditionally, which is the line that caused the leak, + // and a resync that re-flattens this is the failure mode worth catching. + expect(PATCH).toContain( + [ + '+ baton->shellExited = true;', + '+ if (baton->consoleClosed) {', + '+ const bool removed = remove_pty_baton(baton->id);', + '+ assert(removed);', + '+ (void)removed;', + '+ }' + ].join('\n') + ) + }) + + it('closes the pseudoconsole from PtyKill even after the shell has exited', () => { + // hpc is copied out under the lock, so the close survives the baton's removal. + expect(PATCH).toContain('+ hpc = handle->hpc;') + expect(PATCH).toContain('+ pfnClosePseudoConsole(hpc);') + }) + + it('resolves the ConPTY DLL before it claims the close', () => { + // LoadConptyDll throws when conpty.dll is missing. Throwing after + // consoleClosed was set would strand the pseudoconsole for good: the retry + // finds the work claimed and does nothing. + const dllResolve = PATCH.indexOf('+ HANDLE hLibrary = LoadConptyDll(info, useConptyDll);') + const claim = PATCH.indexOf('+ handle->consoleClosed = true;') + expect(dllResolve).toBeGreaterThan(-1) + expect(claim).toBeGreaterThan(-1) + expect(dllResolve).toBeLessThan(claim) + }) + + it('reaches hShell only under the null check the watcher can trip', () => { + // Pinned as one block. The watcher nulls hShell on exit, and upstream + // dereferenced it unconditionally; every remaining use — the duplication and + // the failure fallback below it — must stay inside this guard. + const guarded = PATCH.slice( + PATCH.indexOf('+ if (useConptyDll && handle->hShell != nullptr) {'), + PATCH.indexOf('+ if (handle->shellExited) {') + ) + expect(guarded).not.toBe('') + expect(guarded).toContain('DuplicateHandle(GetCurrentProcess(), handle->hShell') + expect(guarded).toContain('TerminateProcess(handle->hShell, 1);') + // No ADDED line outside that guard may terminate through hShell. Removed + // (`-`) lines still carry upstream's unguarded call, which is the point. + const strayAdds = PATCH.replace(guarded, '') + .split('\n') + .filter((line) => line.startsWith('+') && line.includes('TerminateProcess(handle->hShell')) + expect(strayAdds).toEqual([]) + }) + + it('still kills the shell when DuplicateHandle fails', () => { + // A null hShellDup is indistinguishable from the self-exit case, so a + // swallowed failure would leave the shell running after its pane closed — + // a worse outcome than the leak this patch exists to fix. + expect(PATCH).toContain( + [ + '+ hShellDup = nullptr;', + '+ TerminateProcess(handle->hShell, 1);', + '+ }' + ].join('\n') + ) + }) + + it('keeps the close idempotent so a second kill cannot double-close', () => { + expect(PATCH).toContain('+ if (handle != nullptr && !handle->consoleClosed) {') + expect(PATCH).toContain('+ handle->consoleClosed = true;') + }) +}) + +describe('node-pty patch: conout worker disposal on the self-exit path', () => { + // The desktop's larger half: 8 of its 10 leaked handles per terminal. + it('disposes the conout worker unconditionally in the useConptyDll branch', () => { + expect(PATCH).toContain('+ this._conoutSocketWorker.dispose();') + // The data handler is what never fired once the shell had gone. + expect(PATCH).toContain("- this._outSocket.on('data', function () {") + expect(PATCH).toContain('- _this._conoutSocketWorker.dispose();') + }) + + it('applies the same change to the TypeScript source the patch also carries', () => { + expect(PATCH).toContain('+ this._conoutSocketWorker.dispose();') + expect(PATCH).toContain("- this._outSocket.on('data', () => {") + }) +}) diff --git a/src/main/windows/windows-pty-job.ts b/src/main/windows/windows-pty-job.ts index 193b1d8825a..169db375f3b 100644 --- a/src/main/windows/windows-pty-job.ts +++ b/src/main/windows/windows-pty-job.ts @@ -118,8 +118,10 @@ export function terminatePtyJob(proc: IPty): JobTerminationOutcome { /** * Pids still alive in a PTY's tree, or null when there is no answer. * - * Measured on Windows 11: once the shell exits, node-pty drops its handle - * record and closes the job, so a terminated tree reports **null**, not `[]`. + * Measured on Windows 11: once the shell exits, node-pty closes the job, so a + * terminated tree reports **null**, not `[]`. (Its handle record now outlives + * the shell until `kill()` runs — see config/patches/node-pty@1.1.0.patch — but + * the nulled job handle is what makes the answer null either way.) * Null therefore means "unverifiable" in the sense of * docs/reference/ssh-execution-boundary.md — this build has no job support, * the terminal is not a ConPTY, or it is no longer tracked. It is never From a5c6f402f4e6fe85a7f92960eefb314e5fe9fd45 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:37:10 +0000 Subject: [PATCH 19/58] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index ef8ebb61bb4..c240c965fee 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 38m + + downloads: 39m @@ -15,7 +15,7 @@ downloads downloads - 38m - 38m + 39m + 39m From 14e4031948f51251c6ae3e329cf3cf276ef39e40 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 4 Sep 2026 06:08:33 -0700 Subject: [PATCH 20/58] test(pty): make the F24 patch pins catch the regressions they name (#18660) Two of the pins added in #18635 did not discriminate. Found by review of the merged change; both are test-only defects, the fix itself is unaffected. `resolves the ConPTY DLL before it claims the close` searched the whole patch for `HANDLE hLibrary = LoadConptyDll(info, useConptyDll);`. That line occurs twice -- PtyConnect's copy comes first -- so indexOf always matched PtyConnect, and the ordering assertion held no matter where PtyKill resolved the DLL. Verified by simulation: moving PtyKill's resolve back below the claim left the suite green. `reaches hShell only under the null check` used a marker as a slice END bound without checking it existed. If that marker vanished the slice ran to the end of the patch, the stray-line filter found nothing, and the test passed silently. Both now anchor inside the PtyKill hunk only, located by its header's function context rather than line numbers. `indexIn` throws on a missing marker instead of returning -1, so a marker that moves fails the assertion that depends on it rather than making it vacuous. Adds the pin that was missing entirely: PtyKill's half of the two-sided baton free. Without it a self-exit followed by kill() -- the ordinary pane close -- leaks one baton and one entry in the vector get_pty_baton scans linearly. Mutation-tested rather than only revert-tested, because wholesale reverting the patch is what hid this: it fails every assertion for the trivial reason that nothing matches. Simulating each specific regression instead: - move PtyKill's DLL resolve below the claim -> 1 failed (was: 0) - drop PtyKill's baton free, line-count-neutral -> 2 failed (was: 0) Wholesale revert still fails all 9. Refs F24. --- ...-pty-self-exit-pseudoconsole-close.test.ts | 64 ++++++++++++++++--- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts b/src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts index 5f19772530a..1f9cae275c3 100644 --- a/src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts +++ b/src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts @@ -46,6 +46,35 @@ import { describe, expect, it } from 'vitest' const PATCH = readFileSync(join(__dirname, '../../../config/patches/node-pty@1.1.0.patch'), 'utf8') +/** + * Just the `PtyKill` hunk. Several markers below also occur in the `PtyConnect` + * hunk above it, and a bare `indexOf` on the whole patch silently matched the + * wrong one — an assertion that then held regardless of what `PtyKill` did. + */ +const ptyKillHunk = (() => { + // Anchored on the hunk header's function context rather than its line + // numbers, which shift whenever anything above it in the patch changes. + const header = /^@@ .* @@ static Napi::Value PtyKill\(.*$/m.exec(PATCH) + if (!header) { + throw new Error('no PtyKill hunk in config/patches/node-pty@1.1.0.patch') + } + const from = header.index + const next = PATCH.indexOf('\n@@ ', from + 1) + return PATCH.slice(from, next === -1 ? undefined : next) +})() + +/** + * `indexOf` that throws instead of returning -1. A missing marker must fail the + * assertion that depends on it, not quietly make a slice or comparison vacuous. + */ +function indexIn(haystack: string, marker: string): number { + const at = haystack.indexOf(marker) + if (at === -1) { + throw new Error(`marker not found in the PtyKill hunk: ${marker}`) + } + return at +} + describe('node-pty patch: pseudoconsole close on the self-exit path', () => { it('does not let the exit watcher free the baton while the close is still owed', () => { // Pinned as one block: the erase must stay INSIDE the consoleClosed guard. @@ -73,10 +102,15 @@ describe('node-pty patch: pseudoconsole close on the self-exit path', () => { // LoadConptyDll throws when conpty.dll is missing. Throwing after // consoleClosed was set would strand the pseudoconsole for good: the retry // finds the work claimed and does nothing. - const dllResolve = PATCH.indexOf('+ HANDLE hLibrary = LoadConptyDll(info, useConptyDll);') - const claim = PATCH.indexOf('+ handle->consoleClosed = true;') - expect(dllResolve).toBeGreaterThan(-1) - expect(claim).toBeGreaterThan(-1) + // + // Anchored inside PtyKill, not by a bare indexOf: the identical line also + // appears in the PtyConnect hunk, earlier in the file, and matching that one + // made this assertion pass no matter where PtyKill resolved the DLL. + const dllResolve = indexIn( + ptyKillHunk, + '+ HANDLE hLibrary = LoadConptyDll(info, useConptyDll);' + ) + const claim = indexIn(ptyKillHunk, '+ handle->consoleClosed = true;') expect(dllResolve).toBeLessThan(claim) }) @@ -84,11 +118,9 @@ describe('node-pty patch: pseudoconsole close on the self-exit path', () => { // Pinned as one block. The watcher nulls hShell on exit, and upstream // dereferenced it unconditionally; every remaining use — the duplication and // the failure fallback below it — must stay inside this guard. - const guarded = PATCH.slice( - PATCH.indexOf('+ if (useConptyDll && handle->hShell != nullptr) {'), - PATCH.indexOf('+ if (handle->shellExited) {') - ) - expect(guarded).not.toBe('') + const start = indexIn(ptyKillHunk, '+ if (useConptyDll && handle->hShell != nullptr) {') + const end = indexIn(ptyKillHunk, '+ if (handle->shellExited) {') + const guarded = ptyKillHunk.slice(start, end) expect(guarded).toContain('DuplicateHandle(GetCurrentProcess(), handle->hShell') expect(guarded).toContain('TerminateProcess(handle->hShell, 1);') // No ADDED line outside that guard may terminate through hShell. Removed @@ -99,6 +131,20 @@ describe('node-pty patch: pseudoconsole close on the self-exit path', () => { expect(strayAdds).toEqual([]) }) + it('frees the baton from PtyKill when the shell has already exited', () => { + // The other half of the two-sided handshake. Without it a self-exit followed + // by kill() — the ordinary pane close — leaks one baton and one entry in the + // vector get_pty_baton scans linearly, forever. + expect(ptyKillHunk).toContain( + [ + '+ if (handle->shellExited) {', + '+ const bool removed = remove_pty_baton(id);', + '+ assert(removed);', + '+ (void)removed;' + ].join('\n') + ) + }) + it('still kills the shell when DuplicateHandle fails', () => { // A null hShellDup is indistinguishable from the self-exit case, so a // swallowed failure would leave the shell running after its pane closed — From 0d2375a7ff2c805238a4467d7eba2198b3a45d36 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 4 Sep 2026 06:09:07 -0700 Subject: [PATCH 21/58] fix(remote): stop a disclosure list latching the mirror completeness gate (#18619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(remote): stop a disclosure list latching the mirror completeness gate `hostScope.omittedHostIds` was doing two jobs with opposite requirements. As disclosure it must over-name: `omitted-host-scope-selectors.ts` deliberately keeps ids for servers that are no longer paired so a caller can still see the gap, and `docs/reference/ssh-execution-boundary.md` requires a listing to name what it did not cover. As a completeness gate it must name only coverage that was owed and not delivered, or it latches. It latched. `workspaceSessionsByHostId` keeps a partition for every runtime a machine has ever paired with and nothing prunes it, and a mirrored `remote:` row names its peer too — so any client that has ever paired outward publishes a permanently non-empty `omittedHostIds`. `probeHostLiveTerminals` read that as `unverifiable`, `markHostSessionMirrorHydrated` never fired, and panes parked on `parkUntilHostSessionMirrorHydrates` never drained. `hostScopeCensusIsComplete` gives the gate its own answer and leaves the disclosure list alone. A `runtime:` host is never owed coverage by the runtime answering: a paired runtime is a peer with its own control plane reached with `--environment`, and there is no paired-runtime PTY provider for this runtime to have queried. Two other branches stay load-bearing — an absent scope is a host too old to claim one, and a listing that covered no host proves nothing. No wire change: the host publishes byte-identical content and only the client's reading moves, so this reaches the reporter by updating their client alone rather than waiting for their remote. That also avoids a new field's fallback rule, where "absent means complete" would recreate the bug with the polarity flipped. `queried-host-kinds.test.ts` pins the invariant the predicate rests on at its source, because the consolidation moving the SSH path onto orcad is the change most likely to introduce a runtime-backed PTY provider and quietly invalidate it. Fixes #18595 * test(remote): pin the orphan-recovery host-scope gate and narrow the invariant claim The readiness review found the second gate unpinned: reverting `web-session-terminal-orphan-recovery-inventory.ts` alone to the pre-PR expression left the whole renderer suite green, because every existing fixture passes `omittedHostIds: []`. The commit claimed two gates and proved one. Four cases now drive `resolveTerminalOrphanInventory` through a non-empty scope. Reverting that gate alone fails the peer-runtime case. Note the absent-scope case deletes the key rather than passing `undefined`, because `listResult` substitutes its default for `undefined` — routing through the fixture there silently tests the default instead. `queried-host-kinds.test.ts` also claimed more than it caught: a runtime-backed transport registered under an SSH connection id reports as `ssh:` and passes, which is the shape the orcad consolidation is expected to take. It pins the spelling this function emits, which is what the gate keys on, and now says so. * fix(remote): require a legible covered host before believing a census CodeRabbit found a real asymmetry: the predicate refused an omitted host id it could not parse, but accepted an unparseable *covered* id as proof of coverage. `isTerminalListResult` validates only that `hostIds` is an array, so `{hostIds: ['runtime:'], omittedHostIds: ['runtime:env-7']}` was `unverifiable` before this PR and would have become `complete` after it. Taken as "at least one legible covered host" rather than the suggested "every id parses". A host that later gains a kind this client cannot parse would otherwise report an incomplete census forever — which is this bug in a new coat, and the failure mode the predicate exists to prevent. The check exposed four tests publishing `hostIds: ['remote-runtime']`, a bare environment id that `parseExecutionHostId` rejects. No host emits that: a runtime answering `terminal.list` names the execution hosts it covered, which is `local` — verified against a live paired runtime. Those fixtures are corrected to the shape the wire actually carries, which is why the assertions move. --- .../pty/runtime/queried-host-kinds.test.ts | 60 ++++++++++++ ...nal-orphan-recovery-regression-fixtures.ts | 4 +- .../src/runtime/host-live-terminal-probe.ts | 9 +- ...sion-mirror-empty-inventory-settle.test.ts | 83 ++++++++++++++++ ...al-orphan-recovery-host-scope-gate.test.ts | 98 +++++++++++++++++++ ...sion-terminal-orphan-recovery-inventory.ts | 7 +- ...n-terminal-pending-handle-recovery.test.ts | 10 +- src/shared/runtime-listing-host-scope.test.ts | 81 +++++++++++++++ src/shared/runtime-listing-host-scope.ts | 31 +++++- 9 files changed, 370 insertions(+), 13 deletions(-) create mode 100644 src/main/ipc/pty/runtime/queried-host-kinds.test.ts create mode 100644 src/renderer/src/runtime/web-session-terminal-orphan-recovery-host-scope-gate.test.ts create mode 100644 src/shared/runtime-listing-host-scope.test.ts diff --git a/src/main/ipc/pty/runtime/queried-host-kinds.test.ts b/src/main/ipc/pty/runtime/queried-host-kinds.test.ts new file mode 100644 index 00000000000..1f7ce2459d8 --- /dev/null +++ b/src/main/ipc/pty/runtime/queried-host-kinds.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { sshProviders } from '../provider/registry' +import { listProcessesWithHostScopeFromRuntimeController } from './inventory-operations' +import type { PtyRuntimeControllerDeps } from './controller-deps' + +/** + * `hostScopeCensusIsComplete` discounts a `runtime:` host in `omittedHostIds` on the strength of + * one fact about this process: it has no paired-runtime PTY provider, so it never queried that + * host and never owed it coverage. This file pins the producer side of that fact. + * + * What it catches: a new branch here that spells a queried host `runtime:`. Every id this + * function emits is built by `toSshExecutionHostId` or is `LOCAL_EXECUTION_HOST_ID`, so a third + * shape is the observable form of "a runtime host can now answer an inventory" — at which point + * the client predicate would start calling a genuine gap complete. + * + * What it does NOT catch, so do not lean on it: a runtime-backed transport registered under an + * SSH connection id still reports as `ssh:` and passes, which is fine — the predicate only + * discounts the `runtime:` spelling. The consolidation moving the SSH path onto orcad is expected + * to look exactly like that. The other route into `queriedHostIds` is separately fenced to + * `kind === 'ssh'` in `orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts`. + */ +describe('the hosts a PTY inventory can report having queried', () => { + afterEach(() => { + sshProviders.clear() + }) + + it('emits only local and ssh spellings, never a paired-runtime one', async () => { + const listProcesses = vi.fn(async () => []) + sshProviders.set('box-1', { listProcesses } as never) + // A connection id shaped like an environment uuid still has to come back `ssh:`; the spelling + // is what the gate keys on, so a `runtime:` id appearing here is the breakage that matters. + sshProviders.set('a2478221-1d5c-4603-b8bf-b6b728eac9df', { listProcesses } as never) + + const { hostIds } = await listProcessesWithHostScopeFromRuntimeController({ + runtime: null + } as unknown as PtyRuntimeControllerDeps) + + expect(hostIds).toContain('ssh:a2478221-1d5c-4603-b8bf-b6b728eac9df') + expect(new Set(hostIds.map((hostId) => parseExecutionHostId(hostId)?.kind))).toEqual( + new Set(['local', 'ssh']) + ) + }) + + it('drops a provider that threw rather than reporting its host as queried', async () => { + sshProviders.set('box-live', { listProcesses: vi.fn(async () => []) } as never) + sshProviders.set('box-down', { + listProcesses: vi.fn(async () => { + throw new Error('relay unavailable') + }) + } as never) + + const { hostIds } = await listProcessesWithHostScopeFromRuntimeController({ + runtime: { markPtyLivenessUnverifiable: vi.fn() } + } as unknown as PtyRuntimeControllerDeps) + + expect(hostIds).toContain('ssh:box-live') + expect(hostIds).not.toContain('ssh:box-down') + }) +}) diff --git a/src/renderer/src/runtime/__fixtures__/web-session-terminal-orphan-recovery-regression-fixtures.ts b/src/renderer/src/runtime/__fixtures__/web-session-terminal-orphan-recovery-regression-fixtures.ts index 3f4860f2302..da9d55bca1d 100644 --- a/src/renderer/src/runtime/__fixtures__/web-session-terminal-orphan-recovery-regression-fixtures.ts +++ b/src/renderer/src/runtime/__fixtures__/web-session-terminal-orphan-recovery-regression-fixtures.ts @@ -96,7 +96,9 @@ export function listResult( totalCount: terminals.length, truncated: options.truncated ?? false, ...(options.hostScope === undefined - ? { hostScope: { hostIds: [ENVIRONMENT_ID], omittedHostIds: [] } } + ? // A host names the execution hosts it covered, not its own environment id: answering + // `terminal.list` on a paired runtime reports `local` (verified against a live runtime). + { hostScope: { hostIds: ['local'], omittedHostIds: [] } } : { hostScope: options.hostScope }) } } diff --git a/src/renderer/src/runtime/host-live-terminal-probe.ts b/src/renderer/src/runtime/host-live-terminal-probe.ts index 8e5d8a14539..94c29c1e6cc 100644 --- a/src/renderer/src/runtime/host-live-terminal-probe.ts +++ b/src/renderer/src/runtime/host-live-terminal-probe.ts @@ -1,5 +1,6 @@ import type { RuntimeTerminalListResult } from '../../../shared/runtime-types' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { hostScopeCensusIsComplete } from '../../../shared/runtime-listing-host-scope' /** * Asks the host whether ANY terminal is live in an environment, for the one @@ -74,10 +75,12 @@ async function probeHost( if (response.ok === false || !isTerminalListResult(response.result)) { return 'unverifiable' } - // An omitted execution host is an incomplete census. In particular, a relay - // can list its local PTYs while an SSH child host is still starting up. + // A host this listing owed coverage for and did not deliver leaves an incomplete census — a + // relay can list its local PTYs while an SSH child host is still starting up. A peer runtime + // is not such a host: it answers `--environment` for itself, and reading its disclosure entry + // as a gap latched this probe forever (#18595). const hostScope = response.result.hostScope - if (hostScope && hostScope.omittedHostIds.length > 0) { + if (hostScope && !hostScopeCensusIsComplete(hostScope)) { return 'unverifiable' } const { terminals, totalCount } = response.result diff --git a/src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts b/src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts index 8c815968eec..fcbd0c5dc0d 100644 --- a/src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts +++ b/src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts @@ -335,6 +335,89 @@ describe('empty host inventory settling the session mirror', () => { expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(true) }) + // #18595, the reporter's shape: the answering host holds a mirrored row for a peer runtime, so + // that peer is named in `omittedHostIds` for every listing it will ever give. Reading the + // disclosure list as the gate made this probe permanently `unverifiable`, the mirror never + // hydrated, and every remote pane parked forever. + it('settles on a peer runtime it disclosed but never owed coverage for', async () => { + const result = await probeHostLiveTerminals( + 'env-peer-disclosed', + vi.fn(async () => ({ + id: 'peer-disclosed', + ok: true as const, + result: { + terminals: [], + totalCount: 0, + truncated: false, + hostScope: { hostIds: ['local'], omittedHostIds: ['runtime:env-peer'] } + }, + _meta: { runtimeId: 'runtime' } + })) + ) + + expect(result).toBe('none') + }) + + it('reports a live terminal through a peer-runtime disclosure', async () => { + const result = await probeHostLiveTerminals( + 'env-peer-live', + vi.fn(async () => ({ + id: 'peer-live', + ok: true as const, + result: { + terminals: [{ handle: 'terminal-1' }], + totalCount: 1, + truncated: false, + hostScope: { hostIds: ['local'], omittedHostIds: ['runtime:env-peer'] } + }, + _meta: { runtimeId: 'runtime' } + })) + ) + + expect(result).toBe('live') + }) + + it('stays unverifiable for an SSH host the answering runtime did owe coverage for', async () => { + const result = await probeHostLiveTerminals( + 'env-ssh-gap', + vi.fn(async () => ({ + id: 'ssh-gap', + ok: true as const, + result: { + terminals: [], + totalCount: 0, + truncated: false, + hostScope: { hostIds: ['local'], omittedHostIds: ['ssh:box-1'] } + }, + _meta: { runtimeId: 'runtime' } + })) + ) + + expect(result).toBe('unverifiable') + }) + + // A worktree-scoped listing whose target is a runtime host covers nothing and names `local` + // among its omissions. `local` is what keeps this unverifiable — the point is that the + // runtime-only rule does not rescue a listing that answered for nothing. + it('stays unverifiable when the listing covered no host at all', async () => { + const result = await probeHostLiveTerminals( + 'env-covered-nothing', + vi.fn(async () => ({ + id: 'covered-nothing', + ok: true as const, + result: { + terminals: [], + totalCount: 0, + truncated: false, + hostScope: { hostIds: [], omittedHostIds: ['local', 'runtime:env-peer'] } + }, + _meta: { runtimeId: 'runtime' } + })) + ) + + expect(result).toBe('unverifiable') + }) + it('rejects a present malformed host scope as unverifiable', async () => { const result = await probeHostLiveTerminals( 'env-malformed-scope', diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-host-scope-gate.test.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-host-scope-gate.test.ts new file mode 100644 index 00000000000..27d03f4642e --- /dev/null +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-host-scope-gate.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + ENVIRONMENT_ID, + listResult, + makeSnapshot, + makeState +} from './__fixtures__/web-session-terminal-orphan-recovery-regression-fixtures' +import { + clearWebSessionTerminalOrphanRecoveryForTests, + recoverWebSessionTerminalOrphansBeforeApply +} from './web-session-terminal-orphan-recovery' + +/** + * The second consumer of `hostScopeCensusIsComplete`. Pruning here needs two consecutive + * authoritative inventories that omit the surface, so an incomplete census must hold the binding + * open indefinitely — and a peer runtime named in `omittedHostIds` must not be read as one, or + * the ghost binding is retained forever (#18595). + */ +const LEAVES = [{ leafId: 'leaf-1', handle: 'term-ghost' }] + +/** + * `listResult` substitutes a default scope when handed `undefined`, so the absent-scope case has + * to drop the key itself — passing `undefined` through the fixture silently tests the default. + */ +async function recoverTwice(worktree: string, hostScope: Record | null) { + const state = makeState(worktree, LEAVES) + const call = vi.fn(async ({ method }: { method: string }) => { + if (method === 'terminal.list') { + const listed = listResult(worktree, []) + if (hostScope === null) { + delete (listed as { hostScope?: unknown }).hostScope + } else { + listed.hostScope = hostScope as never + } + return { ok: true as const, result: listed } + } + return { ok: false as const, error: { code: 'conflict', message: 'unexpected' } } + }) + await recoverWebSessionTerminalOrphansBeforeApply( + state, + makeSnapshot(worktree, 'epoch-1', LEAVES), + ENVIRONMENT_ID, + { call: call as never } + ) + return recoverWebSessionTerminalOrphansBeforeApply( + state, + makeSnapshot(worktree, 'epoch-2', LEAVES), + ENVIRONMENT_ID, + { call: call as never } + ) +} + +describe('orphan recovery reads the host scope as coverage owed, not as disclosure', () => { + beforeEach(() => { + clearWebSessionTerminalOrphanRecoveryForTests() + }) + + it('prunes a twice-absent binding when the only omission is a peer runtime', async () => { + const settled = await recoverTwice('repo::peer-disclosed', { + hostIds: ['local'], + omittedHostIds: ['runtime:env-peer'] + }) + + expect(settled?.tabs).toEqual([]) + }) + + it('retains the binding when an SSH host this runtime does query went unanswered', async () => { + const settled = await recoverTwice('repo::ssh-gap', { + hostIds: ['local'], + omittedHostIds: ['ssh:box-1'] + }) + + expect(settled?.tabs).toEqual([ + expect.objectContaining({ leafId: 'leaf-1', terminal: 'term-ghost' }) + ]) + }) + + it('retains the binding when the listing covered no host at all', async () => { + const settled = await recoverTwice('repo::covered-nothing', { + hostIds: [], + omittedHostIds: ['local', 'runtime:env-peer'] + }) + + expect(settled?.tabs).toEqual([ + expect.objectContaining({ leafId: 'leaf-1', terminal: 'term-ghost' }) + ]) + }) + + // Why: a host predating `hostScope` (v1.4.187) cannot say what it covered, and absence of the + // claim is never the claim. + it('retains the binding when the host is too old to publish a scope', async () => { + const settled = await recoverTwice('repo::no-scope', null) + + expect(settled?.tabs).toEqual([ + expect.objectContaining({ leafId: 'leaf-1', terminal: 'term-ghost' }) + ]) + }) +}) diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-inventory.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-inventory.ts index 422a741aec8..8dac25b6451 100644 --- a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-inventory.ts +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-inventory.ts @@ -18,6 +18,7 @@ import { type RecoverySurface } from './web-session-terminal-orphan-recovery-surface' import { runInTerminalRecoveryRpcLane } from './web-session-terminal-orphan-recovery-rpc-lane' +import { hostScopeCensusIsComplete } from '../../../shared/runtime-listing-host-scope' type RuntimeCall = (args: { selector: string @@ -163,9 +164,9 @@ export async function resolveTerminalOrphanInventory(args: { listedByHandle.set(terminal.handle, terminal) } } - // Older hosts omit hostScope entirely; an unscoped absence cannot prove a PTY exited. - const hostScopeUnverifiable = - listed.hostScope === undefined || listed.hostScope.omittedHostIds.length > 0 + // Older hosts omit hostScope entirely; an unscoped absence cannot prove a PTY exited. A peer + // runtime named in `omittedHostIds` is disclosure rather than a gap this host owed (#18595). + const hostScopeUnverifiable = !hostScopeCensusIsComplete(listed.hostScope) const dispositions = new Map() const claims: RuntimeTerminalOrphanAdoptionClaim[] = [] for (const surface of inventorySurfaces) { diff --git a/src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts b/src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts index 1411200b14c..cb92d4c8e98 100644 --- a/src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts +++ b/src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts @@ -145,7 +145,7 @@ describe('web session pending terminal handle recovery', () => { topologyRevisions: { [WORKTREE_ID]: 4 }, totalCount: 1, truncated: false, - hostScope: { hostIds: [ENVIRONMENT_ID], omittedHostIds: [] } + hostScope: { hostIds: ['local'], omittedHostIds: [] } } } } @@ -316,7 +316,7 @@ describe('web session pending terminal handle recovery', () => { topologyRevisions: { [WORKTREE_ID]: 4 }, totalCount: 1, truncated: false, - hostScope: { hostIds: [ENVIRONMENT_ID], omittedHostIds: [] } + hostScope: { hostIds: ['local'], omittedHostIds: [] } } } } @@ -394,7 +394,7 @@ describe('web session pending terminal handle recovery', () => { topologyRevisions: { [WORKTREE_ID]: 4 }, totalCount: 1, truncated: false, - hostScope: { hostIds: [ENVIRONMENT_ID], omittedHostIds: [] } + hostScope: { hostIds: ['local'], omittedHostIds: [] } } } } @@ -459,7 +459,7 @@ describe('web session pending terminal handle recovery', () => { terminals: [], totalCount: 0, truncated: false, - hostScope: { hostIds: [ENVIRONMENT_ID], omittedHostIds: [] } + hostScope: { hostIds: ['local'], omittedHostIds: [] } } })) @@ -655,7 +655,7 @@ describe('web session pending terminal handle recovery', () => { terminals: [], totalCount: 0, truncated: false, - hostScope: { hostIds: [ENVIRONMENT_ID], omittedHostIds: [] } + hostScope: { hostIds: ['local'], omittedHostIds: [] } } })) diff --git a/src/shared/runtime-listing-host-scope.test.ts b/src/shared/runtime-listing-host-scope.test.ts new file mode 100644 index 00000000000..a5fd79de454 --- /dev/null +++ b/src/shared/runtime-listing-host-scope.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { hostScopeCensusIsComplete } from './runtime-listing-host-scope' + +/** + * The gate and the disclosure list answer different questions off the same field. These pin the + * cases where they disagree — which is every case that mattered in #18595. + */ +describe('hostScopeCensusIsComplete', () => { + it('reads a peer runtime as disclosure, not as coverage this host owed', () => { + expect( + hostScopeCensusIsComplete({ hostIds: ['local'], omittedHostIds: ['runtime:env-7'] }) + ).toBe(true) + }) + + it('still reads an SSH host as a gap, because this runtime does query those', () => { + expect(hostScopeCensusIsComplete({ hostIds: ['local'], omittedHostIds: ['ssh:box-1'] })).toBe( + false + ) + }) + + it('reads a mixed omission as incomplete on the strength of the SSH host alone', () => { + expect( + hostScopeCensusIsComplete({ + hostIds: ['local'], + omittedHostIds: ['runtime:env-7', 'ssh:box-1'] + }) + ).toBe(false) + }) + + it('calls a clean census complete', () => { + expect(hostScopeCensusIsComplete({ hostIds: ['local', 'ssh:box-1'], omittedHostIds: [] })).toBe( + true + ) + }) + + // Defence in depth: `listKnownExecutionHostIds` always seeds `local`, so a real scope that + // covered nothing also omits `local` and is refused by the runtime-only rule anyway. This + // branch is what stops a scope that answered for no host from ever reading complete if that + // ever stops holding. + it('refuses a listing that covered no host at all', () => { + expect(hostScopeCensusIsComplete({ hostIds: [], omittedHostIds: ['runtime:env-7'] })).toBe( + false + ) + expect(hostScopeCensusIsComplete({ hostIds: [], omittedHostIds: [] })).toBe(false) + }) + + // Why: `hostScope` shipped in v1.4.187. An older host cannot say what it covered, and absence + // of the claim is never the claim — this must stay the first branch. + it('refuses a host too old to publish a scope', () => { + expect(hostScopeCensusIsComplete(undefined)).toBe(false) + }) + + // Why: without this the runtime-only rule trusts a coverage claim it cannot read — the shape + // `{hostIds: ['runtime:'], omittedHostIds: ['runtime:env-7']}` was `unverifiable` before the + // gate changed and must not become `complete` on the strength of an unparseable id. + it('refuses a coverage claim with no legible host in it', () => { + expect( + hostScopeCensusIsComplete({ + hostIds: ['runtime:' as never], + omittedHostIds: ['runtime:env-7'] + }) + ).toBe(false) + }) + + // Why "at least one legible" and not "all legible": a host that later gains a kind this client + // cannot parse must not report an incomplete census forever — that is this bug in a new coat. + it('accepts a coverage claim carrying one legible host beside an unreadable one', () => { + expect( + hostScopeCensusIsComplete({ hostIds: ['local', 'newkind:x' as never], omittedHostIds: [] }) + ).toBe(true) + }) + + it('refuses an unparseable host id rather than discounting it', () => { + expect( + hostScopeCensusIsComplete({ + hostIds: ['local'], + omittedHostIds: ['runtime:' as never] + }) + ).toBe(false) + }) +}) diff --git a/src/shared/runtime-listing-host-scope.ts b/src/shared/runtime-listing-host-scope.ts index 6232b0a4259..97dbb1a8249 100644 --- a/src/shared/runtime-listing-host-scope.ts +++ b/src/shared/runtime-listing-host-scope.ts @@ -1,4 +1,4 @@ -import type { ExecutionHostId } from './execution-host' +import { parseExecutionHostId, type ExecutionHostId } from './execution-host' /** * What a bounded listing did and did not cover, by execution host. An absent scope means the @@ -10,3 +10,32 @@ export type RuntimeListingHostScope = { hostIds: ExecutionHostId[] omittedHostIds: ExecutionHostId[] } + +/** + * Whether the answering runtime enumerated every host its listing owed coverage for. + * + * `omittedHostIds` is a disclosure list and deliberately over-names — `omitted-host-scope-selectors.ts` + * keeps ids for servers that are no longer paired so a caller can still see the gap. That makes it the + * wrong input for a completeness gate, which needs "coverage owed and not delivered". The two jobs pull + * in opposite directions, and reading the disclosure list as the gate latched every remote pane on any + * client that had ever paired outward (#18595). + * + * A `runtime:` host is never owed coverage by the runtime answering: a paired runtime is a peer with its + * own control plane, reached with `--environment`, and this runtime has no paired-runtime PTY provider to + * have queried. Its terminals are its own answer to give, so its presence here is disclosure, not a gap. + */ +export function hostScopeCensusIsComplete(scope: RuntimeListingHostScope | undefined): boolean { + // A host too old to publish a scope cannot claim one; absence is never completeness. + if (scope === undefined) { + return false + } + // A listing that covered no host proves nothing, and an unreadable coverage claim is not a + // claim: `isTerminalListResult` checks only that `hostIds` is an array, so at least one covered + // id has to be legible before the claim can be believed. Deliberately "at least one" rather than + // "all": a host that later gains a kind this client cannot parse would otherwise report an + // incomplete census forever, which is the bug this predicate exists to stop. + if (!scope.hostIds.some((hostId) => parseExecutionHostId(hostId))) { + return false + } + return scope.omittedHostIds.every((hostId) => parseExecutionHostId(hostId)?.kind === 'runtime') +} From 67999dcaae96e22fcd5f9719cdcc37ab4e7ef324 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:05:45 -0700 Subject: [PATCH 22/58] Keep attention glyph knockout white when row is selected (#18679) * Simplify palette attention glyph styling Remove visual styling from the container so the glyph appears as a lightweight overlay on the row icon, not a selection bubble. * Keep attention glyph knockout white when row is selected The glyph now uses a white background (bg-popover) with a ring to create a visual knockout effect that separates it from the icon. This prevents the glyph from inheriting the row selection styling, ensuring it stays visible and distinct regardless of selection state. * Correct attention glyph knockout color description to popover-colored --- .../components/cmd-j/palette-live-status.test.tsx | 15 ++++----------- .../src/components/cmd-j/palette-live-status.tsx | 12 ++---------- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx index a2656104d87..3cc6a9077dd 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx @@ -451,7 +451,7 @@ describe('palette live status', () => { expect(dotLabels()).toEqual(['Needs permission']) }) - it('cuts the pip out of the dialog surface, and out of accent when selected', async () => { + it('keeps the attention glyph knockout popover-colored when its row is selected', async () => { setAgentState('working') await act(async () => { testRoot.render( @@ -469,18 +469,11 @@ describe('palette live status', () => { ) }) - const pip = testContainer.querySelector('[aria-hidden="true"].rounded-full') + const pip = testContainer.querySelector('[aria-hidden="true"]') expect(pip).not.toBeNull() - // Why popover and not background: the CommandDialog surface is --popover (#171717 dark), while - // --background is the app canvas (#0a0a0a) — the mismatch punched a dark halo through each row. expect(pip?.className).toContain('bg-popover') expect(pip?.className).toContain('ring-popover') - expect(pip?.className).not.toContain('bg-background') - expect(pip?.className).toContain( - 'group-data-[selected=true]:bg-[var(--jump-palette-selection-surface)]' - ) - expect(pip?.className).toContain( - 'group-data-[selected=true]:ring-[var(--jump-palette-selection-surface)]' - ) + expect(pip?.className).toContain('rounded-full') + expect(pip?.className).not.toContain('group-data-[selected=true]') }) }) diff --git a/src/renderer/src/components/cmd-j/palette-live-status.tsx b/src/renderer/src/components/cmd-j/palette-live-status.tsx index 432b6c35210..da59663490f 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.tsx @@ -9,7 +9,6 @@ import { buildExplicitEntriesByTabId, type TabPaneInputSources } from '@/components/sidebar/smart-attention' -import { cn } from '@/lib/utils' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import { getLiveAgentStatusByWorktreeId } from '@/lib/worktree-activity-state' import { @@ -255,15 +254,8 @@ export function PaletteRecentTabStatusDot({ {fallback}