mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
* fix(ssh): fence stale kills and retired pane replay * fix(ssh): support cancellable interactive authentication * fix(ssh): await remote catalog before snapshot adoption * fix(pty): contain Windows ConPTY input failures * fix(power): avoid redundant macOS display blocking * perf(editor): narrow markdown override subscriptions * fix(quick-open): close directory handles after reads * refactor(linux): remove unused proc socket scanner * fix(usage): apply flat Sonnet 4.6 pricing * ci: prime Node next native test cache * docs(skills): resolve snapshot cleanup data path * fix(ssh): recover install locks after host reboot * test(ssh): recognize boot-aware install locks * test(ssh): prove previous-boot lock recovery live * test(wire): pin pre-metadata release coverage * fix(terminal): preserve remote tab ownership through recovery races * test(runtime): fence replaced terminal handles in agent guard * fix(ssh): preserve remote snapshot authority across polls * fix(pty): contain late ConPTY output EPIPE * test(pty): register Windows exit watcher before kill * fix: close SSH and tab readiness race gaps * fix(tabs): retain headless order and placeholder titles * fix(build): avoid parallel electron-vite config race * test(windows): avoid MSYS temp path rewriting * test(windows): avoid killing exited PTY * fix(pty): avoid late ConPTY input teardown race * fix(terminal): sync reconnect error ownership after commit * fix(runtime): use canonical worktree identity comparison * test(ssh): assert complete cold-hydration baseline * test(windows): invoke quoted retention fixture via PowerShell * test(windows): read ConPTY grid through mode con * fix(terminal): publish PTY replacements atomically * fix(terminal): infer stale identity on reattach * fix(terminal): fence stale pane PTY callbacks * fix(terminal): fence stale pane binds after rebind * fix(terminal): reject stale pane transport callbacks * fix(terminal): fence mirrored reattach spawn callbacks * fix(terminal): replace stale pane PTYs on remount * fix(ci): size the Windows launcher-compile test budget from measurement `native-smoke (windows-latest)` fails ~4.5% of runs on `preserves a multiline argument through the compiled remote launcher` with "Test timed out in 15000ms" — on unrelated PRs, for reasons that have nothing to do with them. Across 176 sampled attempts it is the only red that job produced, and it hit seven different PRs in two days: #16900, #16904, #16915, #16955 (twice), #16979, #17014, #17085. The test is six process creations: powershell.exe forks csc.exe, then the freshly compiled orca.exe forks node.exe, twice. Hosted Windows runners periodically slow process creation down, and this test amplifies that far harder than anything else in the job. Comparing the 80 attempts where it ran under 3s against the 12 where it ran over 12s, its own median goes 2198ms -> 15917ms (7.2x) while the same file's powershell-only test moves 556 -> 686ms (1.2x), the cmd.exe and Git Bash process tests in the neighbouring file move 1.4x, and the other 35 files put together move 1.5x. Measured across those 176 attempts: 1881ms to 35438ms, p50 4264ms, correlation +0.881 with the job's total Vitest duration. 8 of 176 (4.5%) exceeded the 15s cap; 2 of 176 (1.1%) also exceeded the shared 30s testTimeout, so deleting the override and inheriting the config is not enough on its own. 60s clears all 176 with 1.7x headroom on the worst. This is slow, not hung. Every body here is synchronous spawnSync, so Vitest cannot interrupt one — the timer fires only after the body returns and the reported duration is real elapsed time. That is why a failure reads `× ... 22464ms` under `Test timed out in 15000ms`. The work finished; the stopwatch was short. Seven reruns at one identical head measured 2053 / 4680 / 5551 / 8732 / 13506 / 14868 / 21937ms — the last of those would have been red on code that had not changed. The 15s came from #8897, which raised this test off Vitest's built-in 5s default because the job then ran bare `pnpm vitest run`. #8909 landed 3h27m later and pointed the job at config/vitest.config.ts, which is the real fix for that. The constant stayed behind and has been the binding budget ever since. * fix(terminal): fence stale remount reattach ownership * fix(terminal): reconcile mounted pane identity after replacement * fix(terminal): fence stale reattach fallback ownership * fix(terminal): fence deferred SSH reattach ownership * fix(terminal): fence stale split pane ownership callbacks * fix(terminal): keep stale spawns from consuming startup --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
324 lines
14 KiB
TypeScript
324 lines
14 KiB
TypeScript
import { spawnSync } from 'node:child_process'
|
|
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
|
|
import { test, expect } from './helpers/orca-app'
|
|
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
|
import { waitForActivePanePtyId, waitForActiveTerminalManager } from './helpers/terminal'
|
|
import { createRemoteTerminalTab } from './helpers/docker-ssh-relay-terminal-tabs'
|
|
import {
|
|
cleanupDockerSshRelayTarget,
|
|
execDockerSshRelayTargetControlCommand,
|
|
shellQuote,
|
|
startDockerSshRelayTarget,
|
|
type DockerSshRelayTarget
|
|
} from './helpers/docker-ssh-relay-target'
|
|
import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection'
|
|
import { createRestartSession } from './helpers/orca-restart'
|
|
|
|
const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
|
|
const BASELINE_TAB_COUNT = 3
|
|
/** Where the relay persists a target's workspace snapshot inside the fixture container. */
|
|
const REMOTE_SNAPSHOT_DIR = '/root/.orca/sessions'
|
|
|
|
test.use({ seedTestRepo: false })
|
|
|
|
async function readWorktreeTabIds(page: Page, worktreeId: string): Promise<string[]> {
|
|
return page.evaluate(
|
|
(id) => (window.__store?.getState().tabsByWorktree[id] ?? []).map((tab) => tab.id),
|
|
worktreeId
|
|
)
|
|
}
|
|
|
|
async function isTargetHydrated(page: Page, targetId: string): Promise<boolean> {
|
|
return page.evaluate(
|
|
(id) => window.__store?.getState().remoteWorkspaceHydratedTargetIds.has(id) === true,
|
|
targetId
|
|
)
|
|
}
|
|
|
|
async function readTargetSyncPhase(page: Page, targetId: string): Promise<string | undefined> {
|
|
return page.evaluate(
|
|
(id) => window.__store?.getState().remoteWorkspaceSyncStatusByTargetId[id]?.phase,
|
|
targetId
|
|
)
|
|
}
|
|
|
|
/** Poll until the worktree's tabs stop changing, so a late seed cannot slip past the sample. */
|
|
async function waitForSettledTabIds(page: Page, worktreeId: string): Promise<string[]> {
|
|
let latest: string[] = []
|
|
let previousKey = ''
|
|
let agreements = 0
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
latest = await readWorktreeTabIds(page, worktreeId)
|
|
const key = latest.join()
|
|
agreements = key === previousKey ? agreements + 1 : 0
|
|
previousKey = key
|
|
return agreements
|
|
},
|
|
{ timeout: 60_000, intervals: [1_000], message: 'the tab set never stopped changing' }
|
|
)
|
|
.toBeGreaterThanOrEqual(3)
|
|
return latest
|
|
}
|
|
|
|
function findRemoteSnapshotPath(target: DockerSshRelayTarget): string | null {
|
|
const listing = execDockerSshRelayTargetControlCommand(
|
|
target,
|
|
`ls -1 ${REMOTE_SNAPSHOT_DIR}/*.json 2>/dev/null || true`
|
|
).trim()
|
|
return listing.split('\n').find((line) => line.endsWith('.json')) ?? null
|
|
}
|
|
|
|
async function waitForUploadedRemoteSnapshot(target: DockerSshRelayTarget): Promise<string> {
|
|
let snapshotPath: string | null = null
|
|
await expect
|
|
.poll(
|
|
() => {
|
|
snapshotPath = findRemoteSnapshotPath(target)
|
|
return snapshotPath
|
|
},
|
|
{ timeout: 60_000, message: 'the relay never persisted a workspace snapshot' }
|
|
)
|
|
.not.toBeNull()
|
|
return snapshotPath!
|
|
}
|
|
|
|
/**
|
|
* Replace the relay's snapshot file with a FIFO so `workspace.get` blocks on open.
|
|
*
|
|
* Why a FIFO and not a stall injected into the app: the relay reads this path with `readFileSync`,
|
|
* so an unopened FIFO stalls the real RPC exactly where a stalled link would, and writing to it
|
|
* later releases that same call with the real bytes. A test-only hook would drift from the
|
|
* production path silently, which is how this class of bug survives in the first place.
|
|
*/
|
|
function blockRemoteWorkspaceGet(target: DockerSshRelayTarget, snapshotPath: string): string {
|
|
const saved = execDockerSshRelayTargetControlCommand(target, `cat ${snapshotPath}`)
|
|
execDockerSshRelayTargetControlCommand(
|
|
target,
|
|
`rm -f ${snapshotPath} && mkfifo -m 600 ${snapshotPath}`
|
|
)
|
|
return saved
|
|
}
|
|
|
|
/**
|
|
* Tab ids the relay actually persisted, or null when the bytes are not parseable JSON.
|
|
*
|
|
* The spec replays this capture verbatim, so bytes that carry no tab make every downstream count
|
|
* meaningless: an empty session places nothing, therefore reports nothing unplaced, therefore
|
|
* hydrates cleanly and replaces the worktree's tabs with none. That is indistinguishable from the
|
|
* regression this test exists to catch, so the capture has to be checked before it is trusted.
|
|
*/
|
|
function capturedSnapshotTabIds(saved: string): string[] | null {
|
|
try {
|
|
const parsed = JSON.parse(saved) as {
|
|
session?: { tabsByWorktreePath?: Record<string, { id?: unknown }[]> }
|
|
}
|
|
return Object.values(parsed.session?.tabsByWorktreePath ?? {})
|
|
.flat()
|
|
.map((tab) => tab?.id)
|
|
.filter((id): id is string => typeof id === 'string')
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function unblockRemoteWorkspaceGet(
|
|
target: DockerSshRelayTarget,
|
|
snapshotPath: string,
|
|
saved: string
|
|
): void {
|
|
// Detached: a FIFO write blocks until the reader drains it, which must not stall the test.
|
|
spawnSync('docker', [
|
|
'exec',
|
|
'-d',
|
|
target.containerName,
|
|
'bash',
|
|
'--noprofile',
|
|
'--norc',
|
|
'-c',
|
|
`printf '%s' ${shellQuote(saved)} > ${snapshotPath} && rm -f ${snapshotPath} && printf '%s' ${shellQuote(saved)} > ${snapshotPath}`
|
|
])
|
|
}
|
|
|
|
async function connectAndSeedTabs(
|
|
page: Page,
|
|
target: DockerSshRelayTarget
|
|
): Promise<{ targetId: string; repoId: string; worktreeId: string; tabIds: string[] }> {
|
|
const remote = await connectDockerSshRelayTarget(page, target)
|
|
await expect.poll(() => waitForActiveWorktree(page), { timeout: 30_000 }).toBe(remote.worktreeId)
|
|
await waitForActiveTerminalManager(page, 60_000)
|
|
await waitForActivePanePtyId(page, 60_000)
|
|
while ((await readWorktreeTabIds(page, remote.worktreeId)).length < BASELINE_TAB_COUNT) {
|
|
await createRemoteTerminalTab(page, remote.worktreeId)
|
|
}
|
|
return { ...remote, tabIds: await waitForSettledTabIds(page, remote.worktreeId) }
|
|
}
|
|
|
|
async function flushSessionBeforeQuit(page: Page, targetId: string): Promise<void> {
|
|
await page.evaluate(() => window.dispatchEvent(new Event('beforeunload')))
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
page.evaluate(async (id) => {
|
|
const persisted = await window.api.session.get()
|
|
return persisted.activeConnectionIdsAtShutdown?.includes(id) === true
|
|
}, targetId),
|
|
{ timeout: 15_000, message: 'the active SSH target was not persisted before quit' }
|
|
)
|
|
.toBe(true)
|
|
}
|
|
|
|
test.describe('SSH cold hydration gap tab seeding', () => {
|
|
test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.')
|
|
test.skip(process.platform === 'win32', 'Docker SSH restore uses POSIX SSH tooling.')
|
|
|
|
// Why this shape: worktree activation seeds an initial terminal from a predicate that knows
|
|
// nothing about host authority, so a relaunch that activates the worktree before the host
|
|
// snapshot lands can add a tab the host never had. Stalling `workspace.get` holds that window
|
|
// open for as long as the assertions need instead of racing a local relay.
|
|
test('adds no tab when the host workspace snapshot stalls across a relaunch', async (// oxlint-disable-next-line no-empty-pattern -- This restart test owns every Electron launch.
|
|
{}, testInfo) => {
|
|
test.setTimeout(600_000)
|
|
const restart = createRestartSession(testInfo)
|
|
let target: DockerSshRelayTarget | null = null
|
|
let app: ElectronApplication | null = null
|
|
try {
|
|
target = startDockerSshRelayTarget(testInfo)
|
|
const firstLaunch = await restart.launch()
|
|
app = firstLaunch.app
|
|
await waitForSessionReady(firstLaunch.page)
|
|
const remote = await connectAndSeedTabs(firstLaunch.page, target)
|
|
expect(remote.tabIds).toHaveLength(BASELINE_TAB_COUNT)
|
|
const snapshotPath = await waitForUploadedRemoteSnapshot(target)
|
|
await flushSessionBeforeQuit(firstLaunch.page, remote.targetId)
|
|
await restart.close(app)
|
|
app = null
|
|
|
|
const saved = blockRemoteWorkspaceGet(target, snapshotPath)
|
|
// Precondition, not an expectation about the product: everything below reads the bytes this
|
|
// capture holds, so a capture that never recorded the baseline has to fail here and name
|
|
// itself rather than surface later as a tab count the product appears to have lost.
|
|
const capturedTabIds = capturedSnapshotTabIds(saved)
|
|
expect(
|
|
capturedTabIds,
|
|
`the captured host snapshot ${snapshotPath} is not parseable JSON, so replaying it proves nothing: ${JSON.stringify(saved.slice(0, 200))}`
|
|
).not.toBeNull()
|
|
expect(
|
|
capturedTabIds,
|
|
`the captured host snapshot ${snapshotPath} contains ${capturedTabIds?.length ?? 0} tab(s), but the seeded baseline has ${BASELINE_TAB_COUNT}`
|
|
).toHaveLength(BASELINE_TAB_COUNT)
|
|
expect(
|
|
remote.tabIds.filter((id) => !(capturedTabIds ?? []).includes(id)),
|
|
`the captured host snapshot ${snapshotPath} holds ${capturedTabIds?.length ?? 0} tab(s) and is missing part of the ${BASELINE_TAB_COUNT}-tab baseline this test seeded, so the bytes it replays are not the workspace the assertions below describe`
|
|
).toEqual([])
|
|
const relaunch = await restart.launch()
|
|
app = relaunch.app
|
|
const page = relaunch.page
|
|
// No PTY wait here: the stalled read holds the relay's only thread, so nothing else it serves
|
|
// can complete either. That is the point — this is the window a resumed laptop sits in.
|
|
await waitForSessionReady(page, 60_000)
|
|
await expect
|
|
.poll(() => waitForActiveWorktree(page), { timeout: 60_000 })
|
|
.toBe(remote.worktreeId)
|
|
// Proves the intended branch was taken rather than the symptom merely being absent: the
|
|
// target must still be unhydrated while the snapshot has not arrived.
|
|
expect(
|
|
await isTargetHydrated(page, remote.targetId),
|
|
'the target hydrated despite a stalled workspace.get, so this never entered the gap'
|
|
).toBe(false)
|
|
const duringStall = await waitForSettledTabIds(page, remote.worktreeId)
|
|
|
|
unblockRemoteWorkspaceGet(target, snapshotPath, saved)
|
|
await expect
|
|
.poll(() => isTargetHydrated(page, remote.targetId), {
|
|
timeout: 120_000,
|
|
message: 'the target never hydrated after the snapshot was released'
|
|
})
|
|
.toBe(true)
|
|
const afterHydration = await waitForSettledTabIds(page, remote.worktreeId)
|
|
|
|
const growth = `baseline=${remote.tabIds.length} duringStall=${duringStall.length} afterHydration=${afterHydration.length}`
|
|
expect(duringStall.slice().sort(), `tabs changed inside the stall window: ${growth}`).toEqual(
|
|
remote.tabIds.slice().sort()
|
|
)
|
|
expect(
|
|
afterHydration.slice().sort(),
|
|
`tabs changed once the stalled snapshot landed: ${growth}`
|
|
).toEqual(remote.tabIds.slice().sort())
|
|
} finally {
|
|
if (app) {
|
|
await restart.close(app)
|
|
}
|
|
await restart.dispose()
|
|
cleanupDockerSshRelayTarget(target)
|
|
}
|
|
})
|
|
|
|
// Why a second profile and not another restart: the seeding predicate is
|
|
// `renderableTabCount === 0 && !Object.hasOwn(tabsByWorktree, worktreeId)`, and a restart always
|
|
// restores that key from local state, so the second term is never false. A client that has never
|
|
// held this workspace — a re-added host, a cleared profile, a second machine — is the ordinary
|
|
// way a user reaches a host that already owns tabs with no local row for them.
|
|
test('adopts host tabs after their worktree catalog paths resolve', async (// oxlint-disable-next-line no-empty-pattern -- This restart test owns every Electron launch.
|
|
{}, testInfo) => {
|
|
test.setTimeout(600_000)
|
|
const seeding = createRestartSession(testInfo)
|
|
const fresh = createRestartSession(testInfo)
|
|
let target: DockerSshRelayTarget | null = null
|
|
let seedingApp: ElectronApplication | null = null
|
|
let freshApp: ElectronApplication | null = null
|
|
try {
|
|
target = startDockerSshRelayTarget(testInfo)
|
|
const firstLaunch = await seeding.launch()
|
|
seedingApp = firstLaunch.app
|
|
await waitForSessionReady(firstLaunch.page)
|
|
const remote = await connectAndSeedTabs(firstLaunch.page, target)
|
|
expect(remote.tabIds).toHaveLength(BASELINE_TAB_COUNT)
|
|
await waitForUploadedRemoteSnapshot(target)
|
|
await flushSessionBeforeQuit(firstLaunch.page, remote.targetId)
|
|
await seeding.close(seedingApp)
|
|
seedingApp = null
|
|
|
|
const freshLaunch = await fresh.launch()
|
|
freshApp = freshLaunch.app
|
|
await waitForSessionReady(freshLaunch.page, 60_000)
|
|
// seedInitialTab: false so every tab counted below is one the product produced — the helper's
|
|
// own convenience tab would otherwise be indistinguishable from a spurious seed.
|
|
const rejoined = await connectDockerSshRelayTarget(freshLaunch.page, target, {
|
|
seedInitialTab: false
|
|
})
|
|
await expect
|
|
.poll(() => waitForActiveWorktree(freshLaunch.page), { timeout: 60_000 })
|
|
.toBe(rejoined.worktreeId)
|
|
// The host snapshot can beat this fresh client's worktree catalog. The apply waits on the
|
|
// catalog publication instead of claiming success with an empty projection or seeding a
|
|
// replacement tab.
|
|
await expect
|
|
.poll(() => isTargetHydrated(freshLaunch.page, rejoined.targetId), {
|
|
timeout: 120_000,
|
|
message: 'the fresh client never adopted the host snapshot after catalog resolution'
|
|
})
|
|
.toBe(true)
|
|
const rejoinedTabIds = await waitForSettledTabIds(freshLaunch.page, rejoined.worktreeId)
|
|
const settledPhase = await readTargetSyncPhase(freshLaunch.page, rejoined.targetId)
|
|
console.log(
|
|
`[late-host-tab-adoption] hydrated=true phase=${settledPhase} tabs=${rejoinedTabIds.length}`
|
|
)
|
|
|
|
expect(rejoinedTabIds.slice().sort()).toEqual(remote.tabIds.slice().sort())
|
|
expect(settledPhase).toBe('synced')
|
|
} finally {
|
|
if (freshApp) {
|
|
await fresh.close(freshApp)
|
|
}
|
|
if (seedingApp) {
|
|
await seeding.close(seedingApp)
|
|
}
|
|
await fresh.dispose()
|
|
await seeding.dispose()
|
|
cleanupDockerSshRelayTarget(target)
|
|
}
|
|
})
|
|
})
|