mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 18:47:40 +00:00
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:
@@ -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'))
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user