feat(wsl-mover): pause a moving project's terminals through Sleep and prove they exited (D1b-5d) (#28820)

* feat(wsl-mover): pause a moving project's terminals through Sleep and prove they exited (D1b-5d)

* fix(wsl-mover): gate again under the fence, swap journal phases atomically, keep sessions frozen through commit (D1b-5d)

* test(wsl-mover): the fence e2e walks the journal one phase at a time, as a move writes it (D1b-5d)
This commit is contained in:
Neil
2026-10-14 13:07:50 -07:00
committed by GitHub
parent 5976c4628d
commit 8f83f6b076
37 changed files with 1306 additions and 100 deletions
@@ -0,0 +1,112 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorktreeSleepRequest } from '../../shared/worktree-sleep-request'
const ipcEmitter = new EventEmitter()
vi.mock('electron', () => ({
ipcMain: {
on: (channel: string, listener: (...args: unknown[]) => void) =>
ipcEmitter.on(channel, listener),
removeListener: (channel: string, listener: (...args: unknown[]) => void) =>
ipcEmitter.removeListener(channel, listener)
}
}))
const { requestWorktreeSleepFromRenderer, worktreeSleepTimeoutMs } =
await import('./worktree-sleep-request-relay')
class FakeContents extends EventEmitter {
destroyed = false
readonly sent: WorktreeSleepRequest[] = []
isDestroyed(): boolean {
return this.destroyed
}
send(_channel: string, request: WorktreeSleepRequest): void {
this.sent.push(request)
}
}
class FakeWindow extends EventEmitter {
readonly webContents = new FakeContents()
isDestroyed(): boolean {
return false
}
}
const respond = (sender: unknown, response: Record<string, unknown>): void => {
ipcEmitter.emit('ui:worktreeSleepResponse', { sender }, response)
}
let window: FakeWindow
beforeEach(() => {
ipcEmitter.removeAllListeners()
window = new FakeWindow()
})
afterEach(() => vi.useRealTimers())
describe('requestWorktreeSleepFromRenderer', () => {
it("resolves with the renderer's failed workspaces once its own window acks", async () => {
const pending = requestWorktreeSleepFromRenderer(window, ['wt-1', 'wt-2'])
const [request] = window.webContents.sent
expect(request?.worktreeIds).toEqual(['wt-1', 'wt-2'])
respond(new FakeContents(), { requestId: request?.requestId })
respond(window.webContents, { requestId: 'other' })
respond(window.webContents, { requestId: request?.requestId, failedWorktreeIds: ['wt-2'] })
await expect(pending).resolves.toEqual({ failedWorktreeIds: ['wt-2'] })
expect(ipcEmitter.listenerCount('ui:worktreeSleepResponse')).toBe(0)
expect(window.listenerCount('closed')).toBe(0)
})
it('rejects with the error the renderer reports', async () => {
const pending = requestWorktreeSleepFromRenderer(window, ['wt-1'])
respond(window.webContents, {
requestId: window.webContents.sent[0]?.requestId,
error: 'persist failed'
})
await expect(pending).rejects.toThrow('persist failed')
})
it.each([
['the window closes', (target: FakeWindow) => target.emit('closed')],
['the renderer dies', (target: FakeWindow) => target.webContents.emit('render-process-gone')],
['the renderer reloads', (target: FakeWindow) => target.webContents.emit('did-start-loading')]
])('rejects renderer_unavailable when %s', async (_name, lose) => {
const pending = requestWorktreeSleepFromRenderer(window, ['wt-1'])
lose(window)
await expect(pending).rejects.toThrow('renderer_unavailable')
expect(ipcEmitter.listenerCount('ui:worktreeSleepResponse')).toBe(0)
expect(window.webContents.listenerCount('destroyed')).toBe(0)
expect(window.webContents.listenerCount('did-start-loading')).toBe(0)
})
it('rejects renderer_unavailable up front when the renderer is already gone', async () => {
window.webContents.destroyed = true
await expect(requestWorktreeSleepFromRenderer(window, ['wt-1'])).rejects.toThrow(
'renderer_unavailable'
)
expect(window.webContents.sent).toEqual([])
})
it('times out instead of waiting forever', async () => {
vi.useFakeTimers()
const pending = requestWorktreeSleepFromRenderer(window, ['wt-1'])
let done = false
const settled = expect(pending.finally(() => (done = true))).rejects.toThrow(
'worktree_sleep_timeout'
)
await vi.advanceTimersByTimeAsync(29_999)
expect(done).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await settled
expect(ipcEmitter.listenerCount('ui:worktreeSleepResponse')).toBe(0)
})
it('scales the wait with the workspaces, capped at five minutes', () => {
expect(worktreeSleepTimeoutMs(0)).toBe(20_000)
expect(worktreeSleepTimeoutMs(3)).toBe(50_000)
expect(worktreeSleepTimeoutMs(28)).toBe(300_000)
expect(worktreeSleepTimeoutMs(500)).toBe(300_000)
})
})
@@ -0,0 +1,110 @@
import { randomUUID } from 'node:crypto'
import { ipcMain } from 'electron'
import type { BrowserWindow } from 'electron'
import type {
WorktreeSleepRequest,
WorktreeSleepResponse
} from '../../shared/worktree-sleep-request'
const WORKTREE_SLEEP_BASE_TIMEOUT_MS = 20_000
const WORKTREE_SLEEP_PER_WORKSPACE_MS = 10_000
const WORKTREE_SLEEP_MAX_TIMEOUT_MS = 5 * 60_000
/** One request sleeps every workspace of a project in turn, so the wait grows with them. */
export function worktreeSleepTimeoutMs(workspaceCount: number): number {
return Math.min(
WORKTREE_SLEEP_BASE_TIMEOUT_MS + WORKTREE_SLEEP_PER_WORKSPACE_MS * workspaceCount,
WORKTREE_SLEEP_MAX_TIMEOUT_MS
)
}
export type WorktreeSleepResult = { failedWorktreeIds: string[] }
/** The parts of a BrowserWindow the relay touches. */
export type WorktreeSleepRendererWindow = {
isDestroyed(): boolean
readonly webContents: {
isDestroyed(): boolean
send(channel: string, request: WorktreeSleepRequest): void
once(event: 'destroyed', listener: () => void): unknown
once(event: 'render-process-gone', listener: () => void): unknown
once(event: 'did-start-loading', listener: () => void): unknown
removeListener(event: 'destroyed', listener: () => void): unknown
removeListener(event: 'render-process-gone', listener: () => void): unknown
removeListener(event: 'did-start-loading', listener: () => void): unknown
}
once(event: 'closed', listener: () => void): unknown
removeListener(event: 'closed', listener: () => void): unknown
}
// Why: no call site passes a real window yet; keep the narrow type honest against Electron's.
const browserWindowFits: BrowserWindow extends WorktreeSleepRendererWindow ? true : never = true
void browserWindowFits
/**
* Sleep runs in the renderer, which owns the slept-pane records; this waits until it has slept
* the workspaces and persisted their session. Rejects `renderer_unavailable` or
* `worktree_sleep_timeout`; there is no headless fallback.
*/
export async function requestWorktreeSleepFromRenderer(
mainWindow: WorktreeSleepRendererWindow,
worktreeIds: readonly string[]
): Promise<WorktreeSleepResult> {
if (mainWindow.isDestroyed()) {
throw new Error('renderer_unavailable')
}
const webContents = mainWindow.webContents
if (webContents.isDestroyed()) {
throw new Error('renderer_unavailable')
}
const requestId = randomUUID()
return new Promise<WorktreeSleepResult>((resolve, reject) => {
let settled = false
const onRendererUnavailable = (): void => finish(new Error('renderer_unavailable'))
const finish = (outcome: Error | WorktreeSleepResult): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
ipcMain.removeListener('ui:worktreeSleepResponse', onResponse)
mainWindow.removeListener('closed', onRendererUnavailable)
webContents.removeListener('destroyed', onRendererUnavailable)
webContents.removeListener('render-process-gone', onRendererUnavailable)
webContents.removeListener('did-start-loading', onRendererUnavailable)
if (outcome instanceof Error) {
reject(outcome)
} else {
resolve(outcome)
}
}
const timeout = setTimeout(
() => finish(new Error('worktree_sleep_timeout')),
worktreeSleepTimeoutMs(worktreeIds.length)
)
const onResponse = (event: Electron.IpcMainEvent, response: WorktreeSleepResponse): void => {
// Why: request ids are visible to renderer code; only the selected window may answer.
if (event.sender !== webContents || response.requestId !== requestId) {
return
}
finish(
response.error
? new Error(response.error)
: { failedWorktreeIds: response.failedWorktreeIds ?? [] }
)
}
ipcMain.on('ui:worktreeSleepResponse', onResponse)
mainWindow.once('closed', onRendererUnavailable)
webContents.once('destroyed', onRendererUnavailable)
webContents.once('render-process-gone', onRendererUnavailable)
// A reload discards the renderer's pending Sleep, so it would never answer.
webContents.once('did-start-loading', onRendererUnavailable)
const request: WorktreeSleepRequest = { requestId, worktreeIds: [...worktreeIds] }
try {
webContents.send('ui:worktreeSleepRequest', request)
} catch {
finish(new Error('renderer_unavailable'))
}
})
}