fix(terminal): a slept pane wakes in its own folder, with its prompt on a fresh row (#34172)

* fix(terminal): a slept native pane wakes in its own folder, with its prompt on a fresh row

On a WSL host (orcad on Linux, bash, no OSC 7) Sleep then Wake reopened the
pane in the project root with the new prompt joined to the restored one:
- Sleep recorded the shell's folder only for WSL bridge panes. A native
  shell's checkpoint kept its spawn folder, so Wake started there. Sleep now
  reads the live shell's cwd (OSC 7, else its process cwd) for the final
  checkpoint.
- The runtime's copy of a cold-restored pane, which a paired host's desktop
  (and a phone) paints, was seeded without the fresh-shell viewport the window
  adds, so the new prompt landed on the last restored row.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(terminal): skip the process cwd lookup at Sleep on Windows

The lookup falls back to lsof, which Windows lacks, so every native pane
Sleep would spawn a missing binary. Windows keeps the OSC 7 cwd only.

* fix(terminal): bound the Sleep folder read and drop deleted folders

The live cwd read now gets min(1.5s, 25% of the remaining Sleep deadline),
the same share as the WSL read, so a busy daemon can't eat the final
checkpoint's window. A /proc cwd ending in ' (deleted)' maps to null.

* test(terminal): pin the Sleep folder read budget without the wall clock

* test(restore): read cold-restored history from scrollback above the fresh viewport

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-26 18:44:59 -07:00
committed by GitHub
co-authored by Claude
parent 6e3c10c835
commit 5ad2d09ee4
9 changed files with 372 additions and 14 deletions
@@ -0,0 +1,98 @@
import './mock-descendant-sweep'
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type * as pty from 'node-pty'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { loadNodePtyForTests } from '../orcad/orcad-node-slot-fixture'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import {
startDaemonAdapterHarness,
type DaemonAdapterHarness
} from './daemon-pty-adapter-test-harness'
import { getHistorySessionDirName } from './history-paths'
import { createDaemonPtySubprocessHandle } from './pty-subprocess/subprocess-handle'
const describePosix = process.platform === 'win32' ? describe.skip : describe
const SESSION_ID = 'wt-1::/project@@sleep-cwd'
// verify-15b: a WSL host pane (orcad on Linux) woke in the project root after `cd deep`.
describePosix('a real bash slept and woken by the daemon', () => {
let nodePty: typeof pty
let harness: DaemonAdapterHarness | null = null
let shells: pty.IPty[] = []
let root = ''
let adapter: DaemonPtyAdapter | null = null
beforeAll(async () => {
nodePty = await loadNodePtyForTests()
})
afterEach(async () => {
for (const shell of shells.splice(0)) {
try {
shell.kill('SIGKILL')
} catch {
// Already exited.
}
}
adapter?.dispose()
await harness?.server.shutdown()
rmSync(harness?.dir ?? '', { recursive: true, force: true })
rmSync(root, { recursive: true, force: true })
})
it('wakes in the folder bash was in, though bash prints no OSC 7', async () => {
root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-sleep-cwd-')))
const deep = join(root, 'deep')
mkdirSync(deep)
const spawnedCwds: string[] = []
let output = ''
harness = await startDaemonAdapterHarness((opts) => {
const cwd = opts.cwd ?? root
spawnedCwds.push(cwd)
const shell = nodePty.spawn('/bin/bash', ['--norc', '--noprofile', '-i'], {
cwd,
cols: 80,
rows: 24,
env: { TERM: 'xterm-256color', PATH: '/usr/bin:/bin', PS1: 'PROMPT$ ' }
})
shell.onData((data) => {
output += data
})
shells.push(shell)
return createDaemonPtySubprocessHandle({
process: shell,
shellPath: '/bin/bash',
spawnCwd: cwd,
env: {},
startupCommandDeliveredInShellArgs: false,
reportsChildExitStatus: true,
sessionId: SESSION_ID,
startupAgentRecognition: null
})
})
const historyDir = join(harness.dir, 'history')
adapter = new DaemonPtyAdapter({
socketPath: harness.socketPath,
tokenPath: harness.tokenPath,
historyPath: historyDir
})
await adapter.spawn({ cols: 80, rows: 24, cwd: root, sessionId: SESSION_ID })
await vi.waitFor(() => expect(output).toContain('PROMPT$'), { timeout: 5000 })
adapter.write(SESSION_ID, 'cd deep && echo IN-DEEP\n')
await vi.waitFor(() => expect(output).toMatch(/IN-DEEP\r?\n/), { timeout: 5000 })
await adapter.shutdown(SESSION_ID, { immediate: true, keepHistory: true })
const saved = JSON.parse(
readFileSync(
join(historyDir, getHistorySessionDirName(SESSION_ID), 'checkpoint.json'),
'utf8'
)
)
expect(saved.cwd).toBe(deep)
await adapter.spawn({ cols: 80, rows: 24, cwd: root, sessionId: SESSION_ID })
expect(spawnedCwds.at(-1)).toBe(deep)
})
})
@@ -0,0 +1,179 @@
import './mock-descendant-sweep'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { join } from 'node:path'
import { readFileSync, realpathSync, rmSync } from 'node:fs'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonClient } from './client'
import type { DaemonServer } from './daemon-server'
import { getHistorySessionDirName } from './history-paths'
import { createMockSubprocess, startDaemonAdapterHarness } from './daemon-pty-adapter-test-harness'
import type * as DaemonHealthModule from './daemon-health'
import type * as DaemonTccAttributionModule from './daemon-tcc-attribution'
import type * as ProcessCwdModule from '../providers/process-cwd'
const {
getMacDaemonSystemResolverHealthMock,
getMacDaemonTccAttributionHealthMock,
resolveProcessCwdSpy
} = vi.hoisted(() => ({
resolveProcessCwdSpy: vi.fn(),
getMacDaemonSystemResolverHealthMock: vi.fn(
async (): Promise<'unknown' | 'unhealthy'> => 'unknown'
),
getMacDaemonTccAttributionHealthMock: vi.fn(
async (): Promise<'intact' | 'severed' | 'unknown'> => 'unknown'
)
}))
vi.mock('../providers/process-cwd', async (importOriginal) => {
const actual = await importOriginal<typeof ProcessCwdModule>()
resolveProcessCwdSpy.mockImplementation(actual.resolveProcessCwd)
return { ...actual, resolveProcessCwd: resolveProcessCwdSpy }
})
vi.mock('./daemon-health', async (importOriginal) => {
const actual = await importOriginal<typeof DaemonHealthModule>()
return {
...actual,
getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock
}
})
vi.mock('./daemon-tcc-attribution', async (importOriginal) => {
const actual = await importOriginal<typeof DaemonTccAttributionModule>()
return {
...actual,
getMacDaemonTccAttributionHealth: getMacDaemonTccAttributionHealthMock
}
})
// verify-15b: a WSL host's pane (orcad on Linux, bash, no OSC 7) woke in the project root after `cd deep`.
describe('a native shell slept and woken', () => {
let dir: string
let socketPath: string
let tokenPath: string
let server: DaemonServer
let adapter: DaemonPtyAdapter
let spawnedCwds: (string | undefined)[]
let lastSubprocess: ReturnType<typeof createMockSubprocess>
beforeEach(async () => {
spawnedCwds = []
const harness = await startDaemonAdapterHarness((opts) => {
spawnedCwds.push(opts.cwd)
// A real pid, so the daemon's cwd lookup reads this process's folder, as for a live shell.
lastSubprocess = Object.assign(createMockSubprocess(), { pid: process.pid })
return lastSubprocess
})
dir = harness.dir
socketPath = harness.socketPath
tokenPath = harness.tokenPath
server = harness.server
adapter = harness.adapter
getMacDaemonSystemResolverHealthMock.mockResolvedValue('unknown')
getMacDaemonTccAttributionHealthMock.mockResolvedValue('unknown')
})
afterEach(async () => {
resolveProcessCwdSpy.mockClear()
adapter?.dispose()
await server?.shutdown()
rmSync(dir, { recursive: true, force: true })
})
it('wakes in the folder the shell was in at Sleep, not where it was spawned', async () => {
const historyDir = join(dir, 'history')
const sessionId = 'wt-1::/project@@sleep1'
const shellFolder = realpathSync(process.cwd())
const history = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
await history.spawn({ cols: 80, rows: 24, cwd: dir, sessionId })
lastSubprocess._simulateData('user@host:~/project$ cd deep\r\nuser@host:~/project/deep$ ')
await history.shutdown(sessionId, { immediate: true, keepHistory: true })
const saved = JSON.parse(
readFileSync(join(historyDir, getHistorySessionDirName(sessionId), 'checkpoint.json'), 'utf8')
)
expect(realpathSync(saved.cwd)).toBe(shellFolder)
await history.spawn({ cols: 80, rows: 24, cwd: dir, sessionId })
expect(realpathSync(spawnedCwds.at(-1)!)).toBe(shellFolder)
history.dispose()
})
// The process lookup execFile()s lsof, which Windows lacks; Sleep there keeps only the OSC 7 cwd.
it('on Windows, Sleep without OSC 7 never looks up the process', async () => {
const realPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })
try {
const historyDir = join(dir, 'history')
const sessionId = 'wt-1::/project@@sleep-win'
const history = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
await history.spawn({ cols: 80, rows: 24, cwd: dir, sessionId })
lastSubprocess._simulateData('PS C:\\project> cd deep\r\nPS C:\\project\\deep> ')
await history.shutdown(sessionId, { immediate: true, keepHistory: true })
const saved = JSON.parse(
readFileSync(
join(historyDir, getHistorySessionDirName(sessionId), 'checkpoint.json'),
'utf8'
)
)
expect(resolveProcessCwdSpy).not.toHaveBeenCalled()
expect(saved.cwd).toBe(dir)
history.dispose()
} finally {
Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true })
}
})
async function sleepAndWake(sessionId: string, deadlineMs?: number) {
const historyDir = join(dir, 'history')
const history = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
await history.spawn({ cols: 80, rows: 24, cwd: dir, sessionId })
lastSubprocess._simulateData('user@host:~/project$ ')
await history.shutdown(sessionId, { immediate: true, keepHistory: true, deadlineMs })
const saved = JSON.parse(
readFileSync(join(historyDir, getHistorySessionDirName(sessionId), 'checkpoint.json'), 'utf8')
)
await history.spawn({ cols: 80, rows: 24, cwd: dir, sessionId })
history.dispose()
return { savedCwd: saved.cwd, wokeCwd: spawnedCwds.at(-1) }
}
it('wakes in the spawn folder when the shell folder was deleted', async () => {
resolveProcessCwdSpy.mockResolvedValueOnce(`${dir}/deep (deleted)`)
const { savedCwd, wokeCwd } = await sleepAndWake('wt-1::/project@@sleep-deleted')
expect(resolveProcessCwdSpy).toHaveBeenCalled()
expect(savedCwd).toBe(dir)
expect(wokeCwd).toBe(dir)
})
it('wakes in the spawn folder when the shell folder is unreadable', async () => {
resolveProcessCwdSpy.mockResolvedValueOnce('')
const { savedCwd, wokeCwd } = await sleepAndWake('wt-1::/project@@sleep-unreadable')
expect(resolveProcessCwdSpy).toHaveBeenCalled()
expect(savedCwd).toBe(dir)
expect(wokeCwd).toBe(dir)
})
it('gives a hung folder read a quarter of the Sleep deadline, then checkpoints', async () => {
// Freeze only the clock, so the read's budget is exact while socket timers stay real.
vi.useFakeTimers({ toFake: ['Date'] })
const requestSpy = vi.spyOn(DaemonClient.prototype, 'request')
try {
const now = Date.now()
resolveProcessCwdSpy.mockImplementationOnce(() => new Promise<string>(() => {}))
const { savedCwd } = await sleepAndWake('wt-1::/project@@sleep-hung', now + 2_000)
expect(resolveProcessCwdSpy).toHaveBeenCalled()
const cwdTimeouts = requestSpy.mock.calls
.filter(([type]) => type === 'getCwd')
.map((c) => c[2])
expect(cwdTimeouts).toEqual([500])
expect(savedCwd).toBe(dir)
} finally {
requestSpy.mockRestore()
vi.useRealTimers()
}
})
})
+27 -2
View File
@@ -4,7 +4,7 @@ import { GET_SIZE_PROTOCOL_VERSION } from './daemon-protocol-version'
import { readDaemonAppliedPtySize, type DaemonAppliedPtySize } from './daemon-pty-applied-size'
import { FinalCheckpointWaitExpiredError } from './daemon-pty-lifecycle-errors'
import { DaemonPtySessionInput } from './daemon-pty-session-input'
import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline'
import { remainingDaemonRequestTimeoutMs, teardownReadBudgetMs } from './daemon-request-deadline'
import type { ColdRestoreInfo } from './history-reader'
import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd'
import { readDaemonWslSleepCwd } from './daemon-wsl-sleep-cwd'
@@ -15,6 +15,8 @@ import type { PtySpawnResult } from '../providers/pty-provider-contract'
export const LIVENESS_PROBE_TIMEOUT_MS = 2_000
const MAX_TOMBSTONES = 1000
// A busy daemon must not eat the final checkpoint's share of the Sleep deadline.
const LIVE_SLEEP_CWD_BUDGET_MS = 1_500
export abstract class DaemonPtySessionControl extends DaemonPtySessionInput {
protected abstract adoptListedSessionForSleep(
@@ -137,7 +139,7 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput {
},
opts.deadlineMs
)
: null
: await this.readLiveSleepCwd(id, opts.deadlineMs)
const committed = await this.runExclusiveCheckpoint(
async () => {
// Set inside: a caller past its deadline leaves this checkpoint running without it.
@@ -278,6 +280,29 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput {
}
}
/**
* A native shell's folder at Sleep (OSC 7, else its process's cwd). Bash prints no OSC 7, so
* without this the checkpoint keeps the spawn folder and Wake opens there (e.g. on a Linux host).
*/
private async readLiveSleepCwd(id: string, deadlineMs?: number): Promise<string | null> {
// Windows has no process-cwd lookup (it would spawn a missing lsof); the snapshot's OSC 7 cwd stands.
const budget = teardownReadBudgetMs(LIVE_SLEEP_CWD_BUDGET_MS, deadlineMs)
if (process.platform === 'win32' || budget <= 0 || !this.activeSessionIds.has(id)) {
return null
}
try {
const { cwd } = await this.client.request<{ cwd: string | null }>(
'getCwd',
{ sessionId: id },
budget
)
// A removed folder reads back from /proc as `<path> (deleted)`, which no shell can return to.
return cwd && !cwd.endsWith(' (deleted)') ? cwd : null
} catch {
return null
}
}
async getInitialCwd(id: string): Promise<string> {
return this.initialCwds.get(id) ?? ''
}
@@ -4,6 +4,13 @@ export function remainingDaemonRequestTimeoutMs(
return deadlineMs === undefined ? undefined : Math.max(1, deadlineMs - Date.now())
}
// A read ahead of teardown gets at most a quarter of what remains, leaving the rest for checkpoint and kill.
export function teardownReadBudgetMs(capMs: number, deadlineMs: number | undefined): number {
return Math.floor(
Math.min(capMs, deadlineMs === undefined ? capMs : (deadlineMs - Date.now()) * 0.25)
)
}
// The caller's wait is bounded without cancelling durable work, which keeps running and committing.
export async function awaitDaemonWorkWithinCallerDeadline(
work: Promise<void>,
+2 -7
View File
@@ -4,22 +4,17 @@ import {
readWslGuestPaneProcesses,
readWslGuestPaneShellCwd
} from '../wsl/wsl-guest-pane-processes'
import { teardownReadBudgetMs } from './daemon-request-deadline'
// Reserve teardown time for the final checkpoint and kill when a distro does not answer.
const READ_BUDGET_MS = 3_000
const READ_SHARE_OF_DEADLINE = 0.25
/** The shell's folder in the bridge's UNC spelling, which wake reads; else null. */
export async function readDaemonWslSleepCwd(
pane: { paneKey: string; wslDistro: string; live: boolean },
deadlineMs?: number
): Promise<string | null> {
const budget = Math.floor(
Math.min(
READ_BUDGET_MS,
deadlineMs === undefined ? READ_BUDGET_MS : (deadlineMs - Date.now()) * READ_SHARE_OF_DEADLINE
)
)
const budget = teardownReadBudgetMs(READ_BUDGET_MS, deadlineMs)
if (process.platform !== 'win32' || budget <= 0) {
return null
}
@@ -0,0 +1,43 @@
// verify-15b: a WSL host pane's wake joined the new prompt onto the restored one.
import { Terminal } from '@xterm/headless'
import { describe, expect, it, vi } from 'vitest'
import type { PtySpawnResult } from '../../../providers/pty-provider-contract'
import type { OrcaRuntimeService } from '../../../runtime/orca-runtime'
import { seedHeadlessTerminalFromSpawnResult } from './terminal-spawn-restore'
async function rowsAfter(seed: string, liveOutput: string, rows: number): Promise<string[]> {
const terminal = new Terminal({ cols: 40, rows, scrollback: 100, allowProposedApi: true })
await new Promise<void>((resolve) => terminal.write(seed + liveOutput, resolve))
const lines: string[] = []
for (let i = 0; i < terminal.buffer.active.length; i++) {
lines.push(terminal.buffer.active.getLine(i)?.translateToString(true).trimEnd() ?? '')
}
terminal.dispose()
return lines.filter((line) => line !== '')
}
describe('seeding the host copy from a cold restore', () => {
it('parks the cursor below the restored history, so the new shell’s prompt has its own row', async () => {
const seedHeadlessTerminal = vi.fn()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seed reads only seedHeadlessTerminal from the runtime.
const runtime = { seedHeadlessTerminal } as unknown as OrcaRuntimeService
const result: PtySpawnResult = {
id: 'host-pty',
coldRestore: {
scrollback: 'user@host:~/p8$ cd deep\r\nuser@host:~/p8/deep$ ',
cwd: '/home/user/p8/deep',
cols: 40,
rows: 6
}
}
seedHeadlessTerminalFromSpawnResult(runtime, result, null)
const [, seed] = seedHeadlessTerminal.mock.calls[0]!
expect(await rowsAfter(seed, 'user@host:~/p8/deep$ ', 6)).toEqual([
'user@host:~/p8$ cd deep',
'user@host:~/p8/deep$',
'user@host:~/p8/deep$'
])
})
})
@@ -2,6 +2,7 @@ import type { PtySpawnResult } from '../../../providers/pty-provider-contract'
import type { OrcaRuntimeService } from '../../../runtime/orca-runtime'
import { pendingByPaneKey, rendererSerializerReadiness } from './serializer-state'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { buildFreshShellViewportBlankingSequence } from '../../../../shared/terminal-fresh-shell-viewport'
export function seedHeadlessTerminalFromSpawnResult(
runtime: OrcaRuntimeService | undefined,
@@ -36,7 +37,12 @@ export function seedHeadlessTerminalFromSpawnResult(
typeof result.coldRestore.cols === 'number' && typeof result.coldRestore.rows === 'number'
? { cols: result.coldRestore.cols, rows: result.coldRestore.rows }
: undefined
runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, size, {
// A fresh shell follows the restored history: park the cursor below it, as the window does,
// or a view painted from this copy (a paired host's desktop, a phone) joins the new prompt.
const seed = `${result.coldRestore.scrollback}${buildFreshShellViewportBlankingSequence(
size?.rows ?? 24
)}`
runtime.seedHeadlessTerminal(result.id, seed, size, {
cwd: result.coldRestore.cwd,
oscLinks: result.coldRestore.oscLinks,
preferProviderIfExisting: true
@@ -20,6 +20,7 @@ import { registerPtyHandlers } from './register-handlers'
import { clearProviderPtyState } from './provider/state-cleanup'
import { getPtyIdForPaneKey } from './pane/key-state'
import { setLocalPtyProvider } from './provider/registry'
import { buildFreshShellViewportBlankingSequence } from '../../../shared/terminal-fresh-shell-viewport'
vi.mock('electron', () => import('../pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('../pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
@@ -110,10 +111,11 @@ describe('registerPtyHandlers', () => {
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
// Restored history, then the fresh shell's viewport below it (verify-15b `$ $`).
expect(runtime.seedHeadlessTerminal).toHaveBeenNthCalledWith(
1,
'pty-cold-restore',
'restored history\r\n',
`restored history\r\n${buildFreshShellViewportBlankingSequence(43)}`,
{ cols: 132, rows: 43 },
{ cwd: '/projects/restored', oscLinks, preferProviderIfExisting: true }
)
@@ -123,7 +125,7 @@ describe('registerPtyHandlers', () => {
expect(runtime.seedHeadlessTerminal).toHaveBeenNthCalledWith(
2,
'pty-legacy-cold-restore',
'legacy history\r\n',
`legacy history\r\n${buildFreshShellViewportBlankingSequence(24)}`,
undefined,
{ cwd: '/projects/legacy', oscLinks: undefined, preferProviderIfExisting: true }
)
@@ -66,8 +66,11 @@ describe.each(['ipc', 'runtime'])('%s restored scrollback', (controller) => {
runtime.onPtyData(binding.ptyId, 'live output\r\n', Date.now())
gate.finish.resolve()
await pending
const snapshot = await runtime.serializeMainTerminalBuffer(binding.ptyId)
expect(snapshot?.data).toContain('restored history')
// Cold restore parks restored history above a blank viewport, so read it from scrollback.
const snapshot = await runtime.serializeMainTerminalBuffer(binding.ptyId, {
scrollbackRows: 1_000
})
expect(snapshot?.data.split('restored history')).toHaveLength(2)
expect(snapshot?.data).toContain('live output')
expect(snapshot?.data.indexOf('restored history')).toBeLessThan(
snapshot?.data.indexOf('live output') ?? -1