mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +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:
@@ -6,6 +6,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
|
||||
'src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts',
|
||||
'src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts',
|
||||
'src/main/runtime/runtime-managed-worktree-metadata-wsl-move.test.ts',
|
||||
'src/main/persistence/moving-wsl-projects/wsl-move-session-freeze.test.ts',
|
||||
'src/main/acp/acp-structured-host-live-listing.test.ts',
|
||||
'src/main/acp/acp-structured-host-configured-default.test.ts',
|
||||
'src/main/pi/rpc-configured-default.test.ts',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { wslMoveCutoverFixture as cutover } from '../../../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
|
||||
import { writeWslMoveCutover } from '../../../persistence/moving-wsl-projects/wsl-move-cutover-journal'
|
||||
import { seedWslMoveCutover } from '../../../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding'
|
||||
import {
|
||||
WslMoveProjectFencedError,
|
||||
loadWslMoveFence,
|
||||
@@ -24,7 +24,7 @@ const accept = (worktreeId: string, sessionId?: string) =>
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-spawn-'))
|
||||
loadWslMoveFence(userDataPath)
|
||||
writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
|
||||
listLiveDaemonPtyIds.mockReset()
|
||||
})
|
||||
afterEach(() => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { closeTestStores, createStore, testState } from '../../persistence-test-
|
||||
import type { Store } from '../../persistence'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import { wslMoveCutoverFixture as cutover } from '../../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
|
||||
import { writeWslMoveCutover } from '../../persistence/moving-wsl-projects/wsl-move-cutover-journal'
|
||||
import { seedWslMoveCutover } from '../../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding'
|
||||
import {
|
||||
WslMoveProjectFencedError,
|
||||
loadWslMoveFence,
|
||||
@@ -83,7 +83,7 @@ describe('project host setups under a WSL move', () => {
|
||||
it('refuses IPC edits to a local setup of a moving repo and leaves others alone', async () => {
|
||||
store.addRepo(repo('repo-1'))
|
||||
store.addRepo(repo('repo-2'))
|
||||
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
registerProjectHostSetupHandlers(new BrowserWindow(), store)
|
||||
const moving = setupFor('repo-1')
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('project host setups under a WSL move', () => {
|
||||
|
||||
it("keeps a setup's own repo frozen once the WSL host owns it", async () => {
|
||||
store.addRepo(repo('repo-1'))
|
||||
writeWslMoveCutover(testState.dir, cutover('move-1', 'destination-committed'))
|
||||
seedWslMoveCutover(testState.dir, cutover('move-1', 'destination-committed'))
|
||||
registerProjectHostSetupHandlers(new BrowserWindow(), store)
|
||||
const owned = setupFor('repo-1')
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('project host setups under a WSL move', () => {
|
||||
|
||||
it('refuses a runtime setup delete for a moving repo', () => {
|
||||
store.addRepo(repo('repo-1'))
|
||||
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
const controller = new RuntimeProjectHostSetupController({
|
||||
getStore: () => store,
|
||||
listRepos: () => store.getRepos(),
|
||||
@@ -145,7 +145,7 @@ describe('project host setups under a WSL move', () => {
|
||||
describe('projects:update once the WSL host owns a repo', () => {
|
||||
it('lets project-wide settings change again', async () => {
|
||||
store.addRepo(repo('repo-1'))
|
||||
writeWslMoveCutover(testState.dir, cutover('move-1', 'destination-committed'))
|
||||
seedWslMoveCutover(testState.dir, cutover('move-1', 'destination-committed'))
|
||||
registerRepoCatalogHandlers(new BrowserWindow(), store)
|
||||
const project = store.getProjects().find((entry) => entry.sourceRepoIds.includes('repo-1'))
|
||||
const updates = { localWindowsRuntimePreference: { kind: 'windows-host' } }
|
||||
@@ -159,7 +159,7 @@ describe('projects:update once the WSL host owns a repo', () => {
|
||||
describe('issue command writes under a WSL move', () => {
|
||||
it('refuses a runtime write to a moving repo before touching the file', async () => {
|
||||
store.addRepo(repo('repo-1'))
|
||||
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
const write = vi.spyOn(issueCommandFile, 'writeIssueCommand')
|
||||
const commands = new RuntimeRepositoryIssueCommand({
|
||||
resolveRepo: async (id) => repo(id),
|
||||
@@ -174,7 +174,7 @@ describe('issue command writes under a WSL move', () => {
|
||||
|
||||
describe('repo settings on another host under a WSL move', () => {
|
||||
it('leaves a same-id repo row on another host writable', async () => {
|
||||
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
const onHost = repo('repo-1', { executionHostId: 'runtime:wsl:ubuntu' })
|
||||
vi.spyOn(store, 'updateRepo').mockReturnValue(onHost)
|
||||
const controller = new RuntimeRepositorySettingsController({
|
||||
|
||||
@@ -7,7 +7,7 @@ import { closeTestStores, createStore, testState } from '../../persistence-test-
|
||||
import type { Store } from '../../persistence'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import { wslMoveCutoverFixture as cutover } from '../../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
|
||||
import { writeWslMoveCutover } from '../../persistence/moving-wsl-projects/wsl-move-cutover-journal'
|
||||
import { seedWslMoveCutover } from '../../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding'
|
||||
import {
|
||||
WslMoveProjectFencedError,
|
||||
loadWslMoveFence,
|
||||
@@ -74,7 +74,7 @@ describe('folderWorkspaces:update under a WSL move', () => {
|
||||
createdFrom: 'folder-scan'
|
||||
})
|
||||
const workspace = store.createFolderWorkspace({ projectGroupId: group.id, name: 'notes' })
|
||||
writeWslMoveCutover(
|
||||
seedWslMoveCutover(
|
||||
testState.dir,
|
||||
cutover('move-1', 'source-fenced', {}, { folderWorkspaceIds: [workspace.id] })
|
||||
)
|
||||
@@ -111,7 +111,7 @@ describe('projects:update under a WSL move', () => {
|
||||
it('refuses a runtime preference change when a source repo is moving', async () => {
|
||||
store.addRepo(repo('repo-1'))
|
||||
store.addRepo(repo('repo-2'))
|
||||
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
registerRepoCatalogHandlers(new BrowserWindow(), store)
|
||||
const projectFor = (repoId: string) =>
|
||||
store.getProjects().find((project) => project.sourceRepoIds.includes(repoId))
|
||||
@@ -129,7 +129,7 @@ describe('projects:update under a WSL move', () => {
|
||||
describe('RuntimeRepositorySettingsController under a WSL move', () => {
|
||||
it('refuses a settings edit to a moving repo and writes nothing', async () => {
|
||||
store.addRepo(repo('repo-1'))
|
||||
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
const notifyReposChanged = vi.fn()
|
||||
const controller = new RuntimeRepositorySettingsController({
|
||||
getStore: () => store,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
|
||||
import { writeWslMoveCutover } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal'
|
||||
import { seedWslMoveCutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding'
|
||||
import {
|
||||
WslMoveProjectFencedError,
|
||||
loadWslMoveFence,
|
||||
@@ -53,7 +53,7 @@ beforeEach(() => {
|
||||
setupWorktreeHandlers()
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-worktree-ipc-'))
|
||||
loadWslMoveFence(userDataPath)
|
||||
writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
|
||||
})
|
||||
afterEach(() => {
|
||||
resetWslMoveFenceForTests()
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Writes a move's record through every phase up to its own, as the journal requires. */
|
||||
import {
|
||||
WSL_MOVE_CUTOVER_PHASES,
|
||||
type WslMoveCutover
|
||||
} from '../../../../shared/wsl-move/wsl-move-cutover'
|
||||
import { writeWslMoveCutover } from '../../moving-wsl-projects/wsl-move-cutover-journal'
|
||||
|
||||
export function seedWslMoveCutover(userDataPath: string, cutover: WslMoveCutover): void {
|
||||
for (const phase of WSL_MOVE_CUTOVER_PHASES) {
|
||||
writeWslMoveCutover(userDataPath, { ...cutover, phase })
|
||||
if (phase === cutover.phase) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import type { ProjectHostSetup, ProjectHostSetupUpdateArgs } from '../../../shared/project-types'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import type { GhAccountBinding } from '../../../shared/github/account-binding'
|
||||
@@ -27,6 +28,17 @@ import type { StoreRuntimeState } from './store-runtime-state'
|
||||
import type { WriteSchedulingOperations } from './write-scheduling'
|
||||
import { scheduleSave } from './write-scheduling'
|
||||
import { pruneDeregisteredRepoUiResidue } from './repo-lifecycle-ui-residue'
|
||||
import { isWslMoveProjectSessionFrozen } from '../moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
// A paused WSL move keeps its local rows for a downgrade.
|
||||
function removeLocalRepoSession(
|
||||
session: WorkspaceSessionState,
|
||||
repoId: string
|
||||
): WorkspaceSessionState {
|
||||
return isWslMoveProjectSessionFrozen({ repoId })
|
||||
? session
|
||||
: removeRepoFromWorkspaceSession(session, repoId)
|
||||
}
|
||||
type RepoLifecycleOperationsRuntime = Pick<
|
||||
StoreRuntimeState,
|
||||
| 'gitUsernameCache'
|
||||
@@ -83,11 +95,10 @@ export class RepoLifecycleOperations {
|
||||
syncProjectHostSetupCompatibilityState(this)
|
||||
pruneWorktreeStateForRepo(this, id, idStillPresent ? hostId : null)
|
||||
if (!idStillPresent) {
|
||||
this[repoLifecycleOperationsContext].runtime.state.workspaceSession =
|
||||
removeRepoFromWorkspaceSession(
|
||||
this[repoLifecycleOperationsContext].runtime.state.workspaceSession,
|
||||
id
|
||||
)
|
||||
this[repoLifecycleOperationsContext].runtime.state.workspaceSession = removeLocalRepoSession(
|
||||
this[repoLifecycleOperationsContext].runtime.state.workspaceSession,
|
||||
id
|
||||
)
|
||||
this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId =
|
||||
removeRepoFromHostWorkspaceSessions(
|
||||
this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId,
|
||||
@@ -121,7 +132,7 @@ export class RepoLifecycleOperations {
|
||||
for (const repoId of orphanRepoIds) {
|
||||
retireLocalWorktreeScanGeneration(repoId)
|
||||
pruneWorktreeStateForRepo(this, repoId, null)
|
||||
state.workspaceSession = removeRepoFromWorkspaceSession(state.workspaceSession, repoId)
|
||||
state.workspaceSession = removeLocalRepoSession(state.workspaceSession, repoId)
|
||||
state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions(
|
||||
state.workspaceSessionsByHostId,
|
||||
repoId
|
||||
|
||||
@@ -17,6 +17,7 @@ import { preserveRuntimeAuthoredWorkspaceSessionFields } from '../runtime-author
|
||||
import { dropClosedTerminalTabs } from '../closed-terminal-tab-write-fence'
|
||||
import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration'
|
||||
import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate'
|
||||
import { isWslMoveWorkspaceSessionFrozen } from '../moving-wsl-projects/wsl-move-scope-guard'
|
||||
import {
|
||||
removeWorkspaceSessionOwner,
|
||||
workspaceSessionPartitionIdsForHost
|
||||
@@ -144,6 +145,10 @@ export function removeWorkspaceSessionOwnerInPartition(
|
||||
if (!hasPersistedWorkspaceSession(owner, resolved)) {
|
||||
return
|
||||
}
|
||||
// A paused WSL move keeps its local rows for a downgrade.
|
||||
if (resolved === LOCAL_EXECUTION_HOST_ID && isWslMoveWorkspaceSessionFrozen(worktreeId)) {
|
||||
return
|
||||
}
|
||||
const current = owner.getWorkspaceSession(resolved)
|
||||
const session = removeWorkspaceSessionOwner(current, worktreeId, {
|
||||
advanceTerminalTopologyRevision: options.advanceTerminalTopologyRevision ?? true
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
withRequiredWorkspaceSessionMaps
|
||||
} from './session-host-partitions'
|
||||
import { dropClosedTerminalTabs } from '../closed-terminal-tab-write-fence'
|
||||
import { keepWslMoveFrozenSessionsForStore } from '../moving-wsl-projects/wsl-move-session-freeze'
|
||||
import { scheduleSave } from './write-scheduling'
|
||||
import { orderTerminalRowsByTabGroups } from '../../../shared/workspace-layout/terminal-row-order'
|
||||
|
||||
@@ -60,7 +61,12 @@ export class SessionSnapshotOperations {
|
||||
if (runtime.durableMutationPhase === 'rollback') {
|
||||
this.assertSnapshotAdmission(true)
|
||||
// The fieldwise rollback already preserves newer edits; renderer rebasing would undo it.
|
||||
this.publishSession(session, resolved)
|
||||
this.publishSession(
|
||||
resolved === LOCAL_EXECUTION_HOST_ID
|
||||
? keepWslMoveFrozenSessionsForStore(session, runtime.state.workspaceSession)
|
||||
: session,
|
||||
resolved
|
||||
)
|
||||
return
|
||||
}
|
||||
if (resolved === LOCAL_EXECUTION_HOST_ID) {
|
||||
@@ -102,6 +108,10 @@ export class SessionSnapshotOperations {
|
||||
if (Object.hasOwn(patch, 'unifiedTabs')) {
|
||||
next = dropClosedTerminalTabs(next)
|
||||
}
|
||||
if (resolved === LOCAL_EXECUTION_HOST_ID) {
|
||||
const { state } = this[sessionSnapshotOperationsContext].runtime
|
||||
next = keepWslMoveFrozenSessionsForStore(next, state.workspaceSession)
|
||||
}
|
||||
this.publishSession(next, resolved)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '../../terminal-scrollback-snapshot-async-migration'
|
||||
import { preserveRuntimeAuthoredWorkspaceSessionFields } from '../runtime-authored-workspace-session-fields'
|
||||
import { dropClosedTerminalTabs } from '../closed-terminal-tab-write-fence'
|
||||
import { keepWslMoveFrozenSessionsForStore } from '../moving-wsl-projects/wsl-move-session-freeze'
|
||||
import { registerPersistedPaneKeyAlias } from '../restoring-sessions/pane-alias-normalization'
|
||||
import {
|
||||
normalizeWorkspaceSessionPaneIdentities,
|
||||
@@ -37,6 +38,7 @@ export function setLocalWorkspaceSession(
|
||||
): void {
|
||||
const context = getSessionSnapshotOperationsContext(owner)
|
||||
const prior = context.runtime.state.workspaceSession
|
||||
session = keepWslMoveFrozenSessionsForStore(session, prior)
|
||||
// Why here and not at the callers: the before-unload stage path writes the renderer's payload
|
||||
// straight through, so a per-caller guard leaves the quit write erasing runtime-authored rows.
|
||||
session = dropClosedTerminalTabs(preserveRuntimeAuthoredWorkspaceSessionFields(session, prior))
|
||||
|
||||
@@ -2,8 +2,12 @@ import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { wslMoveJournalKey } from '../../../shared/wsl-move/wsl-move-cutover'
|
||||
import {
|
||||
WSL_MOVE_CUTOVER_PHASES,
|
||||
wslMoveJournalKey
|
||||
} from '../../../shared/wsl-move/wsl-move-cutover'
|
||||
import { wslMoveCutoverFixture as cutover } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture'
|
||||
import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding'
|
||||
import { orcadMigrationCutoverJournalDirectory } from '../../ssh/orcad-migration-cutover-journal'
|
||||
import {
|
||||
listWslMoveCutoverChain,
|
||||
@@ -47,7 +51,7 @@ describe('WSL move journal', () => {
|
||||
})
|
||||
|
||||
it('refuses a second move into a distro while the first is in flight', () => {
|
||||
writeWslMoveCutover(userDataPath, cutover('move-1', 'quiesced'))
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'quiesced'))
|
||||
expect(() =>
|
||||
writeWslMoveCutover(
|
||||
userDataPath,
|
||||
@@ -57,7 +61,7 @@ describe('WSL move journal', () => {
|
||||
})
|
||||
|
||||
it('chains a delta move after a finished one, and only onto the head', () => {
|
||||
writeWslMoveCutover(userDataPath, cutover('move-1', 'retained'))
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'retained'))
|
||||
expect(() => writeWslMoveCutover(userDataPath, cutover('move-2'))).toThrow(
|
||||
'wsl_move_must_extend_its_chain'
|
||||
)
|
||||
@@ -77,7 +81,7 @@ describe('WSL move journal', () => {
|
||||
})
|
||||
|
||||
it('never moves a record backwards, rebinds it, or advances a superseded one', () => {
|
||||
writeWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed'))
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed'))
|
||||
expect(() => writeWslMoveCutover(userDataPath, cutover('move-1', 'prepared'))).toThrow(
|
||||
'wsl_move_phase_regressed'
|
||||
)
|
||||
@@ -87,6 +91,7 @@ describe('WSL move journal', () => {
|
||||
cutover('move-1', 'retained', {}, { repoIds: ['repo-1', 'repo-2'] })
|
||||
)
|
||||
).toThrow('wsl_move_binding_changed')
|
||||
writeWslMoveCutover(userDataPath, cutover('move-1', 'client-rewritten'))
|
||||
writeWslMoveCutover(userDataPath, cutover('move-1', 'retained'))
|
||||
writeWslMoveCutover(
|
||||
userDataPath,
|
||||
@@ -97,7 +102,7 @@ describe('WSL move journal', () => {
|
||||
})
|
||||
|
||||
it('hands out records a caller cannot edit into the next comparison', () => {
|
||||
writeWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed'))
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed'))
|
||||
const [listed] = listWslMoveCutovers(userDataPath)
|
||||
if (listed) {
|
||||
listed.phase = 'prepared'
|
||||
@@ -122,12 +127,40 @@ describe('WSL move journal', () => {
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('keeps the fence: an in-flight record cannot be removed', () => {
|
||||
writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
|
||||
it('refuses a later phase whose record is missing or was lifted, or that skips a phase', () => {
|
||||
expect(() => writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))).toThrow(
|
||||
'wsl_move_record_missing'
|
||||
)
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
|
||||
removeWslMoveCutover(userDataPath, 'move-1')
|
||||
expect(() => writeWslMoveCutover(userDataPath, cutover('move-1', 'quiesced'))).toThrow(
|
||||
'wsl_move_record_missing'
|
||||
)
|
||||
expect(listWslMoveCutovers(userDataPath)).toEqual([])
|
||||
writeWslMoveCutover(userDataPath, cutover('move-1', 'prepared'))
|
||||
expect(() => writeWslMoveCutover(userDataPath, cutover('move-1', 'quiesced'))).toThrow(
|
||||
'wsl_move_phase_skipped'
|
||||
)
|
||||
})
|
||||
|
||||
it.each(WSL_MOVE_CUTOVER_PHASES.slice(2, -1))('refuses to remove a %s record', (phase) => {
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', phase))
|
||||
expect(() => removeWslMoveCutover(userDataPath, 'move-1')).toThrow('wsl_move_in_flight')
|
||||
expect(listWslMoveCutovers(userDataPath)[0]?.phase).toBe(phase)
|
||||
})
|
||||
|
||||
it('keeps the fence: a record past quiesce cannot be removed', () => {
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'quiesced'))
|
||||
expect(() => removeWslMoveCutover(userDataPath, 'move-1')).toThrow('wsl_move_in_flight')
|
||||
expect(listWslMoveCutovers(userDataPath)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('lifts a fence whose quiesce refused: nothing reached the destination yet', () => {
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
|
||||
removeWslMoveCutover(userDataPath, 'move-1')
|
||||
expect(listWslMoveCutovers(userDataPath)).toEqual([])
|
||||
})
|
||||
|
||||
it('fails closed on a manifest edited without re-signing it', () => {
|
||||
const record = cutover()
|
||||
const edited = {
|
||||
|
||||
@@ -81,7 +81,8 @@ export function listWslMoveCutoverChain(
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable before it returns. A new move into a distro must extend the head of its chain, and only
|
||||
* Durable before it returns. A record starts at `prepared` and then advances one phase at a time
|
||||
* from the one on disk. A new move into a distro must extend the head of its chain, and only
|
||||
* once that head finished: one in-flight move per distro (design, section 4.3).
|
||||
*/
|
||||
export function writeWslMoveCutover(userDataPath: string, cutover: WslMoveCutover): void {
|
||||
@@ -97,6 +98,10 @@ export function writeWslMoveCutover(userDataPath: string, cutover: WslMoveCutove
|
||||
const chain = listWslMoveCutoverChain(userDataPath, parsed.journalKey)
|
||||
const existing = chain.find((entry) => entry.migrationId === parsed.migrationId)
|
||||
const head = chain.at(-1)
|
||||
// Compare-and-swap: a writer whose record was lifted underneath it must not recreate the fence.
|
||||
if (!existing && parsed.phase !== 'prepared') {
|
||||
throw new Error('wsl_move_record_missing')
|
||||
}
|
||||
if (existing) {
|
||||
assertWslMoveUpdate(existing, parsed, head)
|
||||
} else if (!head) {
|
||||
@@ -130,20 +135,34 @@ function assertWslMoveUpdate(
|
||||
if (order(next.phase) < order(existing.phase)) {
|
||||
throw new Error('wsl_move_phase_regressed')
|
||||
}
|
||||
if (order(next.phase) > order(existing.phase) + 1) {
|
||||
throw new Error('wsl_move_phase_skipped')
|
||||
}
|
||||
if (head && head.migrationId !== existing.migrationId && next.phase !== existing.phase) {
|
||||
throw new Error('wsl_move_superseded')
|
||||
}
|
||||
}
|
||||
|
||||
// Before quiesce nothing has reached the destination, so lifting the fence loses nothing.
|
||||
const REMOVABLE_IN_FLIGHT_PHASES: ReadonlySet<WslMoveCutover['phase']> = new Set([
|
||||
'prepared',
|
||||
'source-fenced'
|
||||
])
|
||||
|
||||
/**
|
||||
* Only a record that fences nothing goes: one never fenced (`prepared`) or one finished. A move in
|
||||
* between keeps its fence until it resumes, or until a cancel proves the destination holds nothing.
|
||||
* Only a record whose move has touched nothing beyond the source goes, or one finished. A move
|
||||
* further along keeps its fence until it resumes, or until a cancel proves the destination holds
|
||||
* nothing.
|
||||
*/
|
||||
export function removeWslMoveCutover(userDataPath: string, migrationId: string): void {
|
||||
const record = listWslMoveCutovers(userDataPath).find(
|
||||
(entry) => entry.migrationId === migrationId
|
||||
)
|
||||
if (record && record.phase !== 'prepared' && !isWslMoveCutoverFinished(record)) {
|
||||
if (
|
||||
record &&
|
||||
!REMOVABLE_IN_FLIGHT_PHASES.has(record.phase) &&
|
||||
!isWslMoveCutoverFinished(record)
|
||||
) {
|
||||
throw new Error('wsl_move_in_flight')
|
||||
}
|
||||
journals.remove(userDataPath, migrationId)
|
||||
|
||||
@@ -4,16 +4,30 @@ import { wslMoveFenceFromCutovers } from './wsl-move-fence'
|
||||
|
||||
const ids = (state: ReturnType<typeof wslMoveFenceFromCutovers>) => ({
|
||||
fenced: [...state.fenced.repoIds, ...state.fenced.folderWorkspaceIds],
|
||||
hidden: [...state.hidden.repoIds, ...state.hidden.folderWorkspaceIds]
|
||||
hidden: [...state.hidden.repoIds, ...state.hidden.folderWorkspaceIds],
|
||||
sessionFrozen: [...state.sessionFrozen.repoIds, ...state.sessionFrozen.folderWorkspaceIds]
|
||||
})
|
||||
|
||||
describe('WSL move fence', () => {
|
||||
it.each([
|
||||
['prepared', { fenced: [], hidden: [] }],
|
||||
['source-fenced', { fenced: ['repo-1', 'notes'], hidden: [] }],
|
||||
['blobs-transferred', { fenced: ['repo-1', 'notes'], hidden: [] }],
|
||||
['destination-committed', { fenced: [], hidden: ['repo-1', 'notes'] }],
|
||||
['retained', { fenced: [], hidden: ['repo-1', 'notes'] }]
|
||||
['prepared', { fenced: [], hidden: [], sessionFrozen: [] }],
|
||||
// The quiesce's own Sleep still writes the session here.
|
||||
['source-fenced', { fenced: ['repo-1', 'notes'], hidden: [], sessionFrozen: [] }],
|
||||
['quiesced', { fenced: ['repo-1', 'notes'], hidden: [], sessionFrozen: ['repo-1', 'notes'] }],
|
||||
[
|
||||
'blobs-transferred',
|
||||
{ fenced: ['repo-1', 'notes'], hidden: [], sessionFrozen: ['repo-1', 'notes'] }
|
||||
],
|
||||
// Through commit: the retained local session is what a downgrade restores.
|
||||
[
|
||||
'destination-committed',
|
||||
{ fenced: [], hidden: ['repo-1', 'notes'], sessionFrozen: ['repo-1', 'notes'] }
|
||||
],
|
||||
[
|
||||
'client-rewritten',
|
||||
{ fenced: [], hidden: ['repo-1', 'notes'], sessionFrozen: ['repo-1', 'notes'] }
|
||||
],
|
||||
['retained', { fenced: [], hidden: ['repo-1', 'notes'], sessionFrozen: ['repo-1', 'notes'] }]
|
||||
] as const)('%s', (phase, expected) => {
|
||||
const record = cutover(
|
||||
'move-1',
|
||||
@@ -29,6 +43,10 @@ describe('WSL move fence', () => {
|
||||
cutover('move-1', 'retained', {}, { repoIds: ['repo-1'] }),
|
||||
cutover('move-2', 'quiesced', { supersedesMigrationId: 'move-1' }, { repoIds: ['repo-2'] })
|
||||
])
|
||||
expect(ids(state)).toEqual({ fenced: ['repo-2'], hidden: ['repo-1'] })
|
||||
expect(ids(state)).toEqual({
|
||||
fenced: ['repo-2'],
|
||||
hidden: ['repo-1'],
|
||||
sessionFrozen: ['repo-1', 'repo-2']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
* lives outside the profile store, so an older build rewriting the profile cannot drop it, and the
|
||||
* scope it names is the manifest's own (design, section 4.3).
|
||||
*
|
||||
* - Fenced (read-only): from `source-fenced` until the host owns the projects. Edits, new
|
||||
* terminals and session writes for these rows are refused.
|
||||
* - Fenced (read-only): from `source-fenced` until the host owns the projects. Edits and new
|
||||
* terminals are refused.
|
||||
* - Hidden: once the destination committed. The source rows stay (retained) but are not listed.
|
||||
* - Session frozen: from `quiesced` on, hidden or not, the session stays as the quiesce left it.
|
||||
*/
|
||||
import type { WslMoveCutover, WslMoveCutoverPhase } from '../../../shared/wsl-move/wsl-move-cutover'
|
||||
import { WSL_MOVE_CUTOVER_PHASES } from '../../../shared/wsl-move/wsl-move-cutover'
|
||||
@@ -21,11 +22,15 @@ export type WslMoveFenceState = {
|
||||
fenced: WslMoveScopeIds
|
||||
/** Rows the WSL host now owns: kept for a downgrade, never listed. */
|
||||
hidden: WslMoveScopeIds
|
||||
/** Rows already paused, through commit: their session stays as the quiesce left it. */
|
||||
sessionFrozen: WslMoveScopeIds
|
||||
}
|
||||
|
||||
const phaseIndex = (phase: WslMoveCutoverPhase): number => WSL_MOVE_CUTOVER_PHASES.indexOf(phase)
|
||||
const FENCED_FROM = phaseIndex('source-fenced')
|
||||
const HIDDEN_FROM = phaseIndex('destination-committed')
|
||||
// Why not from `source-fenced`: the quiesce's own Sleep writes the slept panes' session after that.
|
||||
const SESSION_FROZEN_FROM = phaseIndex('quiesced')
|
||||
|
||||
/** Throws when the journal cannot be read: callers keep every WSL project fenced then. */
|
||||
export function resolveWslMoveFence(userDataPath: string): WslMoveFenceState {
|
||||
@@ -37,19 +42,31 @@ export function wslMoveFenceFromCutovers(
|
||||
): WslMoveFenceState {
|
||||
const fenced = { repoIds: new Set<string>(), folderWorkspaceIds: new Set<string>() }
|
||||
const hidden = { repoIds: new Set<string>(), folderWorkspaceIds: new Set<string>() }
|
||||
const sessionFrozen = { repoIds: new Set<string>(), folderWorkspaceIds: new Set<string>() }
|
||||
for (const cutover of cutovers) {
|
||||
const index = phaseIndex(cutover.phase)
|
||||
const target = index >= HIDDEN_FROM ? hidden : index >= FENCED_FROM ? fenced : null
|
||||
if (!target) {
|
||||
continue // `prepared` touches nothing on the source.
|
||||
}
|
||||
const { scopeRepoIds, scopeFolderWorkspaceIds } = cutover.manifest.source
|
||||
for (const id of scopeRepoIds) {
|
||||
target.repoIds.add(id)
|
||||
}
|
||||
for (const id of scopeFolderWorkspaceIds) {
|
||||
target.folderWorkspaceIds.add(id)
|
||||
addScope(target, cutover)
|
||||
// Through commit too: the retained local rows are what a downgrade restores.
|
||||
if (index >= SESSION_FROZEN_FROM) {
|
||||
addScope(sessionFrozen, cutover)
|
||||
}
|
||||
}
|
||||
return { fenced, hidden }
|
||||
return { fenced, hidden, sessionFrozen }
|
||||
}
|
||||
|
||||
function addScope(
|
||||
target: { repoIds: Set<string>; folderWorkspaceIds: Set<string> },
|
||||
cutover: Readonly<WslMoveCutover>
|
||||
): void {
|
||||
const { scopeRepoIds, scopeFolderWorkspaceIds } = cutover.manifest.source
|
||||
for (const id of scopeRepoIds) {
|
||||
target.repoIds.add(id)
|
||||
}
|
||||
for (const id of scopeFolderWorkspaceIds) {
|
||||
target.folderWorkspaceIds.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover'
|
||||
import { wslMoveCutoverFixture as cutover } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture'
|
||||
import type { WslMoveExitProof } from './wsl-move-exit-proof'
|
||||
import {
|
||||
quiesceWslMove,
|
||||
type WslMoveQuiesceDeps,
|
||||
type WslMoveQuiesceScope
|
||||
} from './wsl-move-quiesce'
|
||||
import type { WslMovePaneFacts } from './wsl-move-terminal-gate'
|
||||
|
||||
const WORKTREE = 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-1'
|
||||
const scope: WslMoveQuiesceScope = {
|
||||
workspaceIds: [WORKTREE],
|
||||
workspaceKeys: new Set([WORKTREE]),
|
||||
panes: [{ paneKey: 'tab-1:leaf-1', sessionId: 'session-1' }]
|
||||
}
|
||||
const idlePane: WslMovePaneFacts = {
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
foreground: 'shell-idle',
|
||||
scrollbackBytes: 10
|
||||
}
|
||||
const hints = {
|
||||
hints: [{ paneKey: 'tab-1:leaf-1', cwd: '/home/dev/repo-1' }],
|
||||
unreadablePaneKeys: [],
|
||||
staleCwdPaneKeys: []
|
||||
}
|
||||
|
||||
function harness(overrides: Partial<WslMoveQuiesceDeps> = {}) {
|
||||
const journal: WslMoveCutover['phase'][] = []
|
||||
const removed: string[] = []
|
||||
const steps: string[] = []
|
||||
const deps: WslMoveQuiesceDeps = {
|
||||
gateInputs: async () => ({
|
||||
panes: [idlePane],
|
||||
runningAutomationRunIds: [],
|
||||
activeDispatchIds: []
|
||||
}),
|
||||
sleepWorkspaces: vi.fn(async () => {
|
||||
steps.push(`sleep after ${journal.at(-1)}`)
|
||||
return { failedWorktreeIds: [] }
|
||||
}),
|
||||
livePtyIds: () => [],
|
||||
captureRestoreHints: async () => hints,
|
||||
proveExited: async (): Promise<WslMoveExitProof> => ({ verdict: 'exited' }),
|
||||
writeCutover: (record) => {
|
||||
journal.push(record.phase)
|
||||
},
|
||||
removeCutover: (migrationId) => {
|
||||
removed.push(migrationId)
|
||||
},
|
||||
now: () => new Date('2026-10-14T00:00:00.000Z'),
|
||||
...overrides
|
||||
}
|
||||
return { deps, journal, removed, steps }
|
||||
}
|
||||
|
||||
describe('quiesceWslMove', () => {
|
||||
it('fences, sleeps, proves exit and records the proven sessions', async () => {
|
||||
const { deps, journal, removed, steps } = harness()
|
||||
const result = await quiesceWslMove(cutover(), scope, deps)
|
||||
|
||||
expect(journal).toEqual(['source-fenced', 'quiesced'])
|
||||
expect(steps).toEqual(['sleep after source-fenced'])
|
||||
expect(removed).toEqual([])
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'quiesced',
|
||||
cutover: { phase: 'quiesced', provenExitedSessionIds: ['session-1'] },
|
||||
restoreHints: hints
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses with the blockers before fencing anything', async () => {
|
||||
const { deps, journal } = harness({
|
||||
gateInputs: async () => ({
|
||||
panes: [{ ...idlePane, foreground: 'process-running' }],
|
||||
runningAutomationRunIds: [],
|
||||
activeDispatchIds: []
|
||||
})
|
||||
})
|
||||
const result = await quiesceWslMove(cutover(), scope, deps)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'refused',
|
||||
reason: 'terminals-blocked',
|
||||
blockers: [{ kind: 'process-running', ids: ['tab-1:leaf-1'] }],
|
||||
fenceLifted: true
|
||||
})
|
||||
expect(journal).toEqual([])
|
||||
expect(deps.sleepWorkspaces).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('gates again under the fence and refuses a pane that turned busy, without sleeping', async () => {
|
||||
const phases: (WslMoveCutover['phase'] | undefined)[] = []
|
||||
let calls = 0
|
||||
const { deps, journal, removed } = harness()
|
||||
deps.gateInputs = async () => {
|
||||
phases.push(journal.at(-1))
|
||||
calls += 1
|
||||
const foreground = calls === 1 ? 'shell-idle' : 'process-running'
|
||||
return {
|
||||
panes: [{ ...idlePane, foreground }],
|
||||
runningAutomationRunIds: [],
|
||||
activeDispatchIds: []
|
||||
}
|
||||
}
|
||||
const result = await quiesceWslMove(cutover(), scope, deps)
|
||||
|
||||
expect(phases).toEqual([undefined, 'source-fenced'])
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'refused',
|
||||
reason: 'terminals-blocked',
|
||||
blockers: [{ kind: 'process-running', ids: ['tab-1:leaf-1'] }],
|
||||
fenceLifted: true
|
||||
})
|
||||
expect(deps.sleepWorkspaces).not.toHaveBeenCalled()
|
||||
expect(removed).toEqual(['move-1'])
|
||||
})
|
||||
|
||||
it('refuses a second quiesce of the same distro while the first runs', async () => {
|
||||
let releaseSleep: () => void = () => undefined
|
||||
const first = harness({
|
||||
sleepWorkspaces: () =>
|
||||
new Promise((resolve) => {
|
||||
releaseSleep = () => resolve({ failedWorktreeIds: [] })
|
||||
})
|
||||
})
|
||||
const running = quiesceWslMove(cutover(), scope, first.deps)
|
||||
await vi.waitFor(() => expect(first.journal).toEqual(['source-fenced']))
|
||||
|
||||
const second = harness()
|
||||
await expect(quiesceWslMove(cutover('move-2'), scope, second.deps)).resolves.toEqual({
|
||||
outcome: 'refused',
|
||||
reason: 'quiesce-in-progress',
|
||||
fenceLifted: true
|
||||
})
|
||||
expect(second.journal).toEqual([])
|
||||
|
||||
releaseSleep()
|
||||
await expect(running).resolves.toMatchObject({ outcome: 'quiesced' })
|
||||
await expect(quiesceWslMove(cutover('move-2'), scope, second.deps)).resolves.toMatchObject({
|
||||
outcome: 'quiesced'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['renderer_unavailable', 'renderer-unavailable'],
|
||||
['worktree_sleep_timeout', 'sleep-timed-out'],
|
||||
['persist failed', 'sleep-failed']
|
||||
])('lifts the fence when Sleep rejects with %s', async (message, reason) => {
|
||||
const { deps, journal, removed } = harness({
|
||||
sleepWorkspaces: async () => {
|
||||
throw new Error(message)
|
||||
}
|
||||
})
|
||||
const result = await quiesceWslMove(cutover(), scope, deps)
|
||||
|
||||
expect(result).toMatchObject({ outcome: 'refused', reason, fenceLifted: true })
|
||||
expect(journal).toEqual(['source-fenced'])
|
||||
expect(removed).toEqual(['move-1'])
|
||||
})
|
||||
|
||||
it('lifts the fence and names the workspaces whose Sleep failed', async () => {
|
||||
const { deps, removed } = harness({
|
||||
sleepWorkspaces: async () => ({ failedWorktreeIds: [WORKTREE] })
|
||||
})
|
||||
const result = await quiesceWslMove(cutover(), scope, deps)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'refused',
|
||||
reason: 'sleep-failed',
|
||||
failedWorktreeIds: [WORKTREE]
|
||||
})
|
||||
expect(removed).toEqual(['move-1'])
|
||||
})
|
||||
|
||||
it('refuses a terminal opened in scope after Sleep, without touching the daemon proof', async () => {
|
||||
const proveExited = vi.fn(async (): Promise<WslMoveExitProof> => ({ verdict: 'exited' }))
|
||||
const { deps, journal, removed } = harness({ livePtyIds: () => ['pty-new'], proveExited })
|
||||
const result = await quiesceWslMove(cutover(), scope, deps)
|
||||
|
||||
expect(result).toMatchObject({ reason: 'terminals-live', liveSessionIds: ['pty-new'] })
|
||||
expect(proveExited).not.toHaveBeenCalled()
|
||||
expect(journal).toEqual(['source-fenced'])
|
||||
expect(removed).toEqual(['move-1'])
|
||||
})
|
||||
|
||||
it.each<[WslMoveExitProof, Record<string, unknown>]>([
|
||||
[
|
||||
{ verdict: 'live', sessionIds: ['session-1'] },
|
||||
{ reason: 'terminals-live', liveSessionIds: ['session-1'] }
|
||||
],
|
||||
[
|
||||
{ verdict: 'unverifiable', reason: 'a daemon generation did not answer' },
|
||||
{ reason: 'exit-unverifiable', detail: 'a daemon generation did not answer' }
|
||||
]
|
||||
])('refuses unless the daemon proves exit: %o', async (proof, expected) => {
|
||||
const { deps, journal, removed } = harness({ proveExited: async () => proof })
|
||||
const result = await quiesceWslMove(cutover(), scope, deps)
|
||||
|
||||
expect(result).toMatchObject({ outcome: 'refused', ...expected, fenceLifted: true })
|
||||
expect(journal).toEqual(['source-fenced'])
|
||||
expect(removed).toEqual(['move-1'])
|
||||
})
|
||||
|
||||
it('lifts the fence when reading the restore hints throws', async () => {
|
||||
const { deps, removed } = harness({
|
||||
captureRestoreHints: async () => {
|
||||
throw new Error('disk')
|
||||
}
|
||||
})
|
||||
const result = await quiesceWslMove(cutover(), scope, deps)
|
||||
expect(result).toMatchObject({ reason: 'quiesce-failed', detail: 'disk' })
|
||||
expect(removed).toEqual(['move-1'])
|
||||
})
|
||||
|
||||
it('reports a fence it could not lift', async () => {
|
||||
const { deps } = harness({
|
||||
proveExited: async () => ({ verdict: 'live', sessionIds: ['session-1'] }),
|
||||
removeCutover: () => {
|
||||
throw new Error('wsl_move_in_flight')
|
||||
}
|
||||
})
|
||||
const result = await quiesceWslMove(cutover(), scope, deps)
|
||||
expect(result).toMatchObject({ outcome: 'refused', fenceLifted: false })
|
||||
})
|
||||
|
||||
it('refuses without lifting anything when the fence cannot be written', async () => {
|
||||
const { deps, removed } = harness({
|
||||
writeCutover: () => {
|
||||
throw new Error('wsl_move_already_in_flight')
|
||||
}
|
||||
})
|
||||
const result = await quiesceWslMove(cutover(), scope, deps)
|
||||
expect(result).toMatchObject({ reason: 'fence-failed', fenceLifted: true })
|
||||
expect(removed).toEqual([])
|
||||
expect(deps.sleepWorkspaces).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('only starts from a prepared move', async () => {
|
||||
const { deps } = harness()
|
||||
await expect(quiesceWslMove(cutover('move-1', 'source-fenced'), scope, deps)).rejects.toThrow(
|
||||
'wsl_move_not_prepared'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Pausing a moving project's terminals (mover design, section 3.3): the gate must pass, the fence
|
||||
* goes up, Sleep pauses every workspace in scope, and the daemon must prove each session exited
|
||||
* before the journal says `quiesced`. Nothing is ever ended to make progress.
|
||||
*
|
||||
* A refusal after the fence lifts it again. Workspaces Sleep already paused stay asleep: that is
|
||||
* their ordinary sleeping state, and waking them later restores them from it, so nothing is lost.
|
||||
*/
|
||||
import type { WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover'
|
||||
import type { WslMoveExitProof } from './wsl-move-exit-proof'
|
||||
import type { WslMoveRestoreHintCapture } from './wsl-move-restore-hints'
|
||||
import {
|
||||
assessWslMoveTerminals,
|
||||
type WslMoveGateBlocker,
|
||||
type WslMoveGateInputs
|
||||
} from './wsl-move-terminal-gate'
|
||||
|
||||
export type WslMoveQuiesceScope = {
|
||||
/** Every worktree and folder workspace of the moving projects; each one is slept. */
|
||||
workspaceIds: readonly string[]
|
||||
/** Workspace keys as session ids mint them, for the exit proof. */
|
||||
workspaceKeys: ReadonlySet<string>
|
||||
/** The panes the move carries, with the daemon session each one recorded. */
|
||||
panes: readonly { paneKey: string; sessionId: string }[]
|
||||
}
|
||||
|
||||
export type WslMoveQuiesceDeps = {
|
||||
gateInputs: () => Promise<WslMoveGateInputs>
|
||||
/** Resolves with the workspaces whose Sleep failed; rejects when the renderer cannot answer. */
|
||||
sleepWorkspaces: (workspaceIds: readonly string[]) => Promise<{ failedWorktreeIds: string[] }>
|
||||
/** PTYs this process still holds for the workspaces, daemon-backed or not. */
|
||||
livePtyIds: (workspaceIds: readonly string[]) => readonly string[]
|
||||
captureRestoreHints: (
|
||||
panes: WslMoveQuiesceScope['panes'],
|
||||
distro: string
|
||||
) => Promise<WslMoveRestoreHintCapture>
|
||||
proveExited: (scope: {
|
||||
workspaceKeys: ReadonlySet<string>
|
||||
sessionIds: ReadonlySet<string>
|
||||
}) => Promise<WslMoveExitProof>
|
||||
writeCutover: (cutover: WslMoveCutover) => void
|
||||
removeCutover: (migrationId: string) => void
|
||||
now: () => Date
|
||||
}
|
||||
|
||||
export type WslMoveQuiesceRefusalReason =
|
||||
| 'terminals-blocked'
|
||||
/** Another quiesce for the same distro is still running in this process. */
|
||||
| 'quiesce-in-progress'
|
||||
| 'fence-failed'
|
||||
| 'renderer-unavailable'
|
||||
| 'sleep-timed-out'
|
||||
| 'sleep-failed'
|
||||
| 'terminals-live'
|
||||
| 'exit-unverifiable'
|
||||
| 'journal-failed'
|
||||
/** Anything else that threw after the fence went up. */
|
||||
| 'quiesce-failed'
|
||||
|
||||
export type WslMoveQuiesceResult =
|
||||
| {
|
||||
outcome: 'quiesced'
|
||||
cutover: WslMoveCutover
|
||||
// Not in the journal: the schema has no field for them yet, so the caller carries them on.
|
||||
restoreHints: WslMoveRestoreHintCapture
|
||||
}
|
||||
| {
|
||||
outcome: 'refused'
|
||||
reason: WslMoveQuiesceRefusalReason
|
||||
blockers?: WslMoveGateBlocker[]
|
||||
failedWorktreeIds?: string[]
|
||||
liveSessionIds?: string[]
|
||||
detail?: string
|
||||
/** False only when the journal record could not be removed, so the fence still stands. */
|
||||
fenceLifted: boolean
|
||||
}
|
||||
|
||||
type Refusal = Extract<WslMoveQuiesceResult, { outcome: 'refused' }>
|
||||
|
||||
// Journal keys with a quiesce running in this process; the journal alone cannot order two of them.
|
||||
const quiescingJournalKeys = new Set<string>()
|
||||
|
||||
export async function quiesceWslMove(
|
||||
prepared: WslMoveCutover,
|
||||
scope: WslMoveQuiesceScope,
|
||||
deps: WslMoveQuiesceDeps
|
||||
): Promise<WslMoveQuiesceResult> {
|
||||
if (prepared.phase !== 'prepared') {
|
||||
throw new Error('wsl_move_not_prepared')
|
||||
}
|
||||
if (quiescingJournalKeys.has(prepared.journalKey)) {
|
||||
return { outcome: 'refused', reason: 'quiesce-in-progress', fenceLifted: true }
|
||||
}
|
||||
quiescingJournalKeys.add(prepared.journalKey)
|
||||
try {
|
||||
return await quiesceUnderLock(prepared, scope, deps)
|
||||
} finally {
|
||||
quiescingJournalKeys.delete(prepared.journalKey)
|
||||
}
|
||||
}
|
||||
|
||||
async function quiesceUnderLock(
|
||||
prepared: WslMoveCutover,
|
||||
scope: WslMoveQuiesceScope,
|
||||
deps: WslMoveQuiesceDeps
|
||||
): Promise<WslMoveQuiesceResult> {
|
||||
const gate = assessWslMoveTerminals(await deps.gateInputs())
|
||||
if (gate.verdict === 'blocked') {
|
||||
return {
|
||||
outcome: 'refused',
|
||||
reason: 'terminals-blocked',
|
||||
blockers: gate.blockers,
|
||||
fenceLifted: true
|
||||
}
|
||||
}
|
||||
const fenced: WslMoveCutover = {
|
||||
...prepared,
|
||||
phase: 'source-fenced',
|
||||
updatedAt: deps.now().toISOString()
|
||||
}
|
||||
try {
|
||||
deps.writeCutover(fenced)
|
||||
} catch (error) {
|
||||
return {
|
||||
outcome: 'refused',
|
||||
reason: 'fence-failed',
|
||||
detail: describe(error),
|
||||
fenceLifted: true
|
||||
}
|
||||
}
|
||||
const refuse = (refusal: Omit<Refusal, 'outcome' | 'fenceLifted'>): Refusal => {
|
||||
try {
|
||||
deps.removeCutover(fenced.migrationId)
|
||||
return { outcome: 'refused', ...refusal, fenceLifted: true }
|
||||
} catch (error) {
|
||||
console.warn('[wsl-move] Could not lift the fence after a refused quiesce:', error)
|
||||
return { outcome: 'refused', ...refusal, fenceLifted: false }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// The fence blocks new terminals, not commands typed into open shells; a sub-second window remains.
|
||||
const fencedGate = assessWslMoveTerminals(await deps.gateInputs())
|
||||
if (fencedGate.verdict === 'blocked') {
|
||||
return refuse({ reason: 'terminals-blocked', blockers: fencedGate.blockers })
|
||||
}
|
||||
} catch (error) {
|
||||
return refuse({ reason: 'quiesce-failed', detail: describe(error) })
|
||||
}
|
||||
|
||||
try {
|
||||
const { failedWorktreeIds } = await deps.sleepWorkspaces(scope.workspaceIds)
|
||||
if (failedWorktreeIds.length > 0) {
|
||||
return refuse({ reason: 'sleep-failed', failedWorktreeIds })
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = describe(error)
|
||||
const reason =
|
||||
detail === 'renderer_unavailable'
|
||||
? 'renderer-unavailable'
|
||||
: detail === 'worktree_sleep_timeout'
|
||||
? 'sleep-timed-out'
|
||||
: 'sleep-failed'
|
||||
return refuse({ reason, detail })
|
||||
}
|
||||
|
||||
try {
|
||||
return await proveAndRecord(fenced, scope, deps, refuse)
|
||||
} catch (error) {
|
||||
return refuse({ reason: 'quiesce-failed', detail: describe(error) })
|
||||
}
|
||||
}
|
||||
|
||||
async function proveAndRecord(
|
||||
fenced: WslMoveCutover,
|
||||
scope: WslMoveQuiesceScope,
|
||||
deps: WslMoveQuiesceDeps,
|
||||
refuse: (refusal: Omit<Refusal, 'outcome' | 'fenceLifted'>) => Refusal
|
||||
): Promise<WslMoveQuiesceResult> {
|
||||
// Not the gate's pane probe: a slept pane has no process to ask, so it would read unverifiable.
|
||||
const live = deps.livePtyIds(scope.workspaceIds)
|
||||
if (live.length > 0) {
|
||||
return refuse({ reason: 'terminals-live', liveSessionIds: [...live] })
|
||||
}
|
||||
const distro = fenced.manifest.source.distro
|
||||
const restoreHints = await deps.captureRestoreHints(scope.panes, distro)
|
||||
const sessionIds = new Set(scope.panes.map((pane) => pane.sessionId))
|
||||
// Last, so a session that came back while the hints were read still refuses.
|
||||
const proof = await deps.proveExited({ workspaceKeys: scope.workspaceKeys, sessionIds })
|
||||
if (proof.verdict === 'live') {
|
||||
return refuse({ reason: 'terminals-live', liveSessionIds: proof.sessionIds })
|
||||
}
|
||||
if (proof.verdict === 'unverifiable') {
|
||||
return refuse({ reason: 'exit-unverifiable', detail: proof.reason })
|
||||
}
|
||||
const quiesced: WslMoveCutover = {
|
||||
...fenced,
|
||||
phase: 'quiesced',
|
||||
updatedAt: deps.now().toISOString(),
|
||||
provenExitedSessionIds: [...sessionIds]
|
||||
}
|
||||
try {
|
||||
deps.writeCutover(quiesced)
|
||||
} catch (error) {
|
||||
return refuse({ reason: 'journal-failed', detail: describe(error) })
|
||||
}
|
||||
return { outcome: 'quiesced', cutover: quiesced, restoreHints }
|
||||
}
|
||||
|
||||
function describe(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
@@ -3,11 +3,8 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { wslMoveCutoverFixture as cutover } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture'
|
||||
import {
|
||||
removeWslMoveCutover,
|
||||
writeWslMoveCutover,
|
||||
wslMoveJournalDirectory
|
||||
} from './wsl-move-cutover-journal'
|
||||
import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding'
|
||||
import { removeWslMoveCutover, wslMoveJournalDirectory } from './wsl-move-cutover-journal'
|
||||
import {
|
||||
WslMoveProjectFencedError,
|
||||
assertWslMoveProjectWritable,
|
||||
@@ -67,31 +64,31 @@ describe('wslMoveProjectRefForWorkspace', () => {
|
||||
describe('WSL move scope guard', () => {
|
||||
it('fences nothing before startup loads the journal', () => {
|
||||
resetWslMoveFenceForTests()
|
||||
writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
|
||||
expect(isWslMoveFenceLoaded()).toBe(false)
|
||||
expect(writable('repo-1::/x')).not.toThrow()
|
||||
})
|
||||
|
||||
it('follows a write under another spelling of the same profile folder', () => {
|
||||
loadWslMoveFence(`${userDataPath}/./`)
|
||||
writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
|
||||
expect(writable('repo-1::/x')).toThrow(WslMoveProjectFencedError)
|
||||
})
|
||||
|
||||
it('lifts the fence when the finished move is removed', () => {
|
||||
writeWslMoveCutover(userDataPath, cutover('move-1', 'retained'))
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'retained'))
|
||||
expect(writable('repo-1::/x')).toThrow(WslMoveProjectFencedError)
|
||||
removeWslMoveCutover(userDataPath, 'move-1')
|
||||
expect(writable('repo-1::/x')).not.toThrow()
|
||||
})
|
||||
|
||||
it('never matches a project on another host', () => {
|
||||
writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
|
||||
expect(writable('ssh:target-1|repo-1::/x')).not.toThrow()
|
||||
})
|
||||
|
||||
it('follows journal writes without rereading on every check', () => {
|
||||
writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
|
||||
expect(writable('repo-1::/x')).toThrow(WslMoveProjectFencedError)
|
||||
rmSync(wslMoveJournalDirectory(userDataPath), { recursive: true, force: true })
|
||||
expect(writable('repo-1::/x')).toThrow(WslMoveProjectFencedError)
|
||||
@@ -103,12 +100,12 @@ describe('WSL move scope guard', () => {
|
||||
})
|
||||
|
||||
it('leaves a prepared move untouched', () => {
|
||||
writeWslMoveCutover(userDataPath, cutover('move-1', 'prepared'))
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'prepared'))
|
||||
expect(writable('repo-1::/x')).not.toThrow()
|
||||
})
|
||||
|
||||
it('refuses work in a fenced project and keeps it listed', () => {
|
||||
writeWslMoveCutover(
|
||||
seedWslMoveCutover(
|
||||
userDataPath,
|
||||
cutover('move-1', 'source-fenced', {}, { repoIds: ['repo-1'], folderWorkspaceIds: ['notes'] })
|
||||
)
|
||||
@@ -120,7 +117,7 @@ describe('WSL move scope guard', () => {
|
||||
})
|
||||
|
||||
it('hides and refuses a project the WSL host owns', () => {
|
||||
writeWslMoveCutover(
|
||||
seedWslMoveCutover(
|
||||
userDataPath,
|
||||
cutover('move-1', 'destination-committed', {}, { folderWorkspaceIds: ['notes'] })
|
||||
)
|
||||
@@ -131,7 +128,7 @@ describe('WSL move scope guard', () => {
|
||||
})
|
||||
|
||||
it("keeps the host's own row, which carries the moved project's id", () => {
|
||||
writeWslMoveCutover(
|
||||
seedWslMoveCutover(
|
||||
userDataPath,
|
||||
cutover('move-1', 'destination-committed', {}, { repoIds: ['repo-1'] })
|
||||
)
|
||||
@@ -155,7 +152,7 @@ describe('WSL move scope guard', () => {
|
||||
|
||||
describe('WSL move edit guard', () => {
|
||||
const fenceRepo1AndNotes = () =>
|
||||
writeWslMoveCutover(
|
||||
seedWslMoveCutover(
|
||||
userDataPath,
|
||||
cutover('move-1', 'source-fenced', {}, { repoIds: ['repo-1'], folderWorkspaceIds: ['notes'] })
|
||||
)
|
||||
@@ -200,7 +197,7 @@ describe('WSL move edit guard', () => {
|
||||
})
|
||||
|
||||
it('freezes catalog settings only while a source repo is mid-move', () => {
|
||||
writeWslMoveCutover(
|
||||
seedWslMoveCutover(
|
||||
userDataPath,
|
||||
cutover('move-1', 'destination-committed', {}, { repoIds: ['repo-1'] })
|
||||
)
|
||||
|
||||
@@ -70,7 +70,10 @@ function readFence(userDataPath: string): FenceView {
|
||||
try {
|
||||
return { readable: true, state: resolveWslMoveFence(userDataPath) }
|
||||
} catch (error) {
|
||||
console.warn('[wsl-move] Journal unreadable; keeping WSL projects read-only:', error)
|
||||
console.warn(
|
||||
'[wsl-move] Journal unreadable; keeping WSL projects read-only, sessions unfrozen:',
|
||||
error
|
||||
)
|
||||
return { readable: false }
|
||||
}
|
||||
}
|
||||
@@ -251,6 +254,30 @@ export function isWslMoveWorkspaceBlocked(
|
||||
return !!workspaceId && isWslMoveProjectBlocked(store, wslMoveProjectRefForWorkspace(workspaceId))
|
||||
}
|
||||
|
||||
/** True once a move has paused the project: its session must stay as the quiesce left it. */
|
||||
export function isWslMoveProjectSessionFrozen(ref: WslMoveProjectRef | null): boolean {
|
||||
const fence = loaded?.view
|
||||
return !!ref && !!fence?.readable && inScope(fence.state.sessionFrozen, ref)
|
||||
}
|
||||
|
||||
export function isWslMoveWorkspaceSessionFrozen(workspaceId: string): boolean {
|
||||
return isWslMoveProjectSessionFrozen(wslMoveProjectRefForWorkspace(workspaceId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any move has a frozen session; lets the hot write path skip the per-workspace scan.
|
||||
* An unreadable journal freezes nothing: the edit fence already refuses visibly, and no move can
|
||||
* proceed without a readable journal, so freezing would only discard sessions silently.
|
||||
*/
|
||||
export function hasWslMoveFrozenSessions(): boolean {
|
||||
const fence = loaded?.view
|
||||
if (!fence?.readable) {
|
||||
return false
|
||||
}
|
||||
const { sessionFrozen } = fence.state
|
||||
return sessionFrozen.repoIds.size > 0 || sessionFrozen.folderWorkspaceIds.size > 0
|
||||
}
|
||||
|
||||
export function assertWslMoveWorkspaceWritable(
|
||||
store: ProjectStore | undefined,
|
||||
workspaceId: string | null | undefined
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import type { Store } from '../../persistence'
|
||||
import {
|
||||
closeTestStores,
|
||||
createStore,
|
||||
makeTerminalTab,
|
||||
testState
|
||||
} from '../../persistence-test-harness'
|
||||
import { wslMoveCutoverFixture as cutover } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture'
|
||||
import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding'
|
||||
import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority'
|
||||
import { pruneWorktreeStateForRepo } from '../tracking-repos/repo-worktree-pruning'
|
||||
import { wslMoveJournalDirectory } from './wsl-move-cutover-journal'
|
||||
import { loadWslMoveFence, resetWslMoveFenceForTests } from './wsl-move-scope-guard'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => testState.dir },
|
||||
safeStorage: { isEncryptionAvailable: () => false }
|
||||
}))
|
||||
|
||||
const MOVING = 'repo-1::/home/dev/repo-1'
|
||||
const STAYING = 'repo-2::/home/dev/repo-2'
|
||||
|
||||
function session(title: string, tabType: 'terminal' | 'editor'): WorkspaceSessionState {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
[MOVING]: [makeTerminalTab({ id: 'moving-tab', ptyId: null, worktreeId: MOVING, title })],
|
||||
[STAYING]: [makeTerminalTab({ id: 'staying-tab', ptyId: null, worktreeId: STAYING, title })]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'moving-tab': { root: null, activeLeafId: null, expandedLeafId: null },
|
||||
'staying-tab': { root: null, activeLeafId: null, expandedLeafId: null }
|
||||
},
|
||||
activeTabTypeByWorktree: { [MOVING]: tabType, [STAYING]: tabType }
|
||||
}
|
||||
}
|
||||
|
||||
const titles = (store: Store) => {
|
||||
const { tabsByWorktree } = store.getWorkspaceSession()
|
||||
return {
|
||||
moving: tabsByWorktree[MOVING]?.map((tab) => tab.title),
|
||||
staying: tabsByWorktree[STAYING]?.map((tab) => tab.title)
|
||||
}
|
||||
}
|
||||
|
||||
let store: Store
|
||||
beforeEach(() => {
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'wsl-move-freeze-'))
|
||||
store = createStore()
|
||||
loadWslMoveFence(testState.dir)
|
||||
store.setWorkspaceSession(session('slept', 'terminal'))
|
||||
})
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
resetWslMoveFenceForTests()
|
||||
await closeTestStores()
|
||||
rmSync(testState.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('WSL move session freeze', () => {
|
||||
it("lets the quiesce's own Sleep write while the move is only fenced", () => {
|
||||
seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
store.setWorkspaceSession(session('after-sleep', 'terminal'))
|
||||
expect(titles(store)).toEqual({ moving: ['after-sleep'], staying: ['after-sleep'] })
|
||||
})
|
||||
|
||||
describe('once quiesced', () => {
|
||||
beforeEach(() => {
|
||||
seedWslMoveCutover(testState.dir, cutover('move-1', 'quiesced'))
|
||||
})
|
||||
|
||||
it('keeps the moving workspace as the quiesce left it and takes the rest', () => {
|
||||
store.setWorkspaceSession(session('edited', 'editor'))
|
||||
expect(titles(store)).toEqual({ moving: ['slept'], staying: ['edited'] })
|
||||
expect(store.getWorkspaceSession().activeTabTypeByWorktree).toEqual({
|
||||
[MOVING]: 'terminal',
|
||||
[STAYING]: 'editor'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps it when a write omits the workspace entirely', () => {
|
||||
const without = session('edited', 'editor')
|
||||
delete without.tabsByWorktree[MOVING]
|
||||
store.setWorkspaceSession(without)
|
||||
expect(titles(store)).toEqual({ moving: ['slept'], staying: ['edited'] })
|
||||
})
|
||||
|
||||
it('keeps it through a patch and the before-unload stage', () => {
|
||||
store.patchWorkspaceSession({
|
||||
activeTabTypeByWorktree: { [MOVING]: 'editor', [STAYING]: 'editor' }
|
||||
})
|
||||
expect(store.getWorkspaceSession().activeTabTypeByWorktree).toEqual({
|
||||
[MOVING]: 'terminal',
|
||||
[STAYING]: 'editor'
|
||||
})
|
||||
store.stageWorkspaceSessionBeforeUnload(session('quit', 'editor'))
|
||||
expect(titles(store)).toEqual({ moving: ['slept'], staying: ['quit'] })
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps freezing through commit, so the retained rows survive for a downgrade', () => {
|
||||
seedWslMoveCutover(testState.dir, cutover('move-1', 'destination-committed'))
|
||||
store.setWorkspaceSession(session('edited', 'editor'))
|
||||
expect(titles(store)).toEqual({ moving: ['slept'], staying: ['edited'] })
|
||||
})
|
||||
|
||||
it('freezes nothing, with one warning, when the journal cannot be read', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const directory = wslMoveJournalDirectory(testState.dir)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeFileSync(join(directory, 'move-1.json'), '{not json')
|
||||
loadWslMoveFence(testState.dir)
|
||||
store.setWorkspaceSession(session('edited', 'editor'))
|
||||
expect(titles(store)).toEqual({ moving: ['edited'], staying: ['edited'] })
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WSL move session freeze: other session writers', () => {
|
||||
beforeEach(() => {
|
||||
seedWslMoveCutover(testState.dir, cutover('move-1', 'quiesced'))
|
||||
})
|
||||
|
||||
it('keeps the frozen rows through a failed durable mutation that rolls the session back', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const refuse = () => {
|
||||
throw new Error('disk refused')
|
||||
}
|
||||
const authority = ProfileStateSqliteAuthority.prototype
|
||||
vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(refuse)
|
||||
vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementationOnce(refuse)
|
||||
vi.spyOn(authority, 'writeSerializedState').mockImplementationOnce(refuse)
|
||||
await expect(
|
||||
store.runDurableMutation(() => {
|
||||
store.setWorkspaceSession(session('mutated', 'editor'))
|
||||
return {
|
||||
value: undefined,
|
||||
rollback: () => store.setWorkspaceSession(session('rolled-back', 'editor'))
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('disk refused')
|
||||
expect(titles(store)).toEqual({ moving: ['slept'], staying: ['rolled-back'] })
|
||||
})
|
||||
|
||||
it('keeps the frozen rows when the repo is pruned', () => {
|
||||
const state = getDefaultPersistedState(testState.dir)
|
||||
state.workspaceSession = {
|
||||
...store.getWorkspaceSession(),
|
||||
lastVisitedAtByWorktreeId: { [MOVING]: 1, [STAYING]: 1 }
|
||||
}
|
||||
pruneWorktreeStateForRepo(state, 'repo-1', null, () => undefined)
|
||||
pruneWorktreeStateForRepo(state, 'repo-2', null, () => undefined)
|
||||
expect(Object.keys(state.workspaceSession.tabsByWorktree)).toEqual([MOVING])
|
||||
})
|
||||
|
||||
it('keeps the frozen rows when the workspace owner is removed', () => {
|
||||
store.removeWorkspaceSessionStateForWorktree(MOVING)
|
||||
store.removeWorkspaceSessionStateForWorktree(STAYING)
|
||||
expect(titles(store)).toEqual({ moving: ['slept'], staying: undefined })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Once a move has paused a project, its workspaces' session stays exactly as the quiesce left it:
|
||||
* that slept state is what the move carries to the host, so a later renderer write must neither
|
||||
* change nor drop it. Every other workspace's rows, and the global fields, are taken as written.
|
||||
*/
|
||||
import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from '../../../shared/workspace-session-host-field-ownership'
|
||||
import {
|
||||
mergeWorkspaceSessionArrayField,
|
||||
mergeWorkspaceSessionRecordField
|
||||
} from '../../../shared/workspace-session-host-records'
|
||||
import {
|
||||
filterWorkspaceRows,
|
||||
withoutWorkspaces,
|
||||
workspaceIdsNamedByPartition
|
||||
} from '../../../shared/workspace-session-stranded-partition-adoption'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { hasWslMoveFrozenSessions, isWslMoveWorkspaceSessionFrozen } from './wsl-move-scope-guard'
|
||||
|
||||
export function keepWslMoveFrozenSessions(
|
||||
next: WorkspaceSessionState,
|
||||
prior: WorkspaceSessionState | undefined,
|
||||
isFrozen: (workspaceId: string) => boolean
|
||||
): WorkspaceSessionState {
|
||||
const frozen = new Set<string>()
|
||||
for (const workspaceId of workspaceIdsNamedByPartition(next)) {
|
||||
if (isFrozen(workspaceId)) {
|
||||
frozen.add(workspaceId)
|
||||
}
|
||||
}
|
||||
for (const workspaceId of prior ? workspaceIdsNamedByPartition(prior) : []) {
|
||||
if (isFrozen(workspaceId)) {
|
||||
frozen.add(workspaceId)
|
||||
}
|
||||
}
|
||||
if (frozen.size === 0) {
|
||||
return next
|
||||
}
|
||||
const merged = withoutWorkspaces(next, frozen)
|
||||
if (!prior) {
|
||||
return merged
|
||||
}
|
||||
const kept = filterWorkspaceRows(prior, (workspaceId) => frozen.has(workspaceId))
|
||||
for (const [field, ownership] of Object.entries(WORKSPACE_SESSION_FIELD_OWNERSHIP)) {
|
||||
if (!isSessionField(field) || ownership === 'global' || ownership === 'hostPrivate') {
|
||||
continue
|
||||
}
|
||||
if (ownership === 'worktreeArray') {
|
||||
mergeWorkspaceSessionArrayField(merged, field, kept)
|
||||
} else {
|
||||
mergeWorkspaceSessionRecordField(merged, field, kept)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function isSessionField(field: string): field is keyof WorkspaceSessionState {
|
||||
return Object.hasOwn(WORKSPACE_SESSION_FIELD_OWNERSHIP, field)
|
||||
}
|
||||
|
||||
/** The store's write paths: a no-op unless some move has paused a project. */
|
||||
export function keepWslMoveFrozenSessionsForStore(
|
||||
next: WorkspaceSessionState,
|
||||
prior: WorkspaceSessionState | undefined
|
||||
): WorkspaceSessionState {
|
||||
if (!hasWslMoveFrozenSessions()) {
|
||||
return next
|
||||
}
|
||||
return keepWslMoveFrozenSessions(next, prior, isWslMoveWorkspaceSessionFrozen)
|
||||
}
|
||||
@@ -6,8 +6,15 @@ import {
|
||||
type ExecutionHostId
|
||||
} from '../../../shared/execution-host'
|
||||
import { workspaceSessionPartitionHostId } from '../../../shared/workspace-session-partition-owner'
|
||||
import { isWslMoveWorkspaceSessionFrozen } from '../moving-wsl-projects/wsl-move-scope-guard'
|
||||
import { cloneWorkspaceSessionState, deleteOwnerKeyedSessionFields } from './session-owner-fields'
|
||||
|
||||
/** Owners whose rows may go: a paused WSL move keeps its rows for a downgrade. */
|
||||
export function withoutWslMoveFrozenOwners(ownerKeys: ReadonlySet<string>): ReadonlySet<string> {
|
||||
const kept = [...ownerKeys].filter((ownerKey) => !isWslMoveWorkspaceSessionFrozen(ownerKey))
|
||||
return kept.length === ownerKeys.size ? ownerKeys : new Set(kept)
|
||||
}
|
||||
|
||||
// Scans the pane-key-keyed maps, the close records and the shutdown list once, removing
|
||||
// every entry owned by a key matched by `isRemovedOwner` (or, for pty incarnations, whose
|
||||
// tab was removed). Kept separate from the O(1) deletes so a batch prune scans each
|
||||
@@ -81,6 +88,9 @@ export function removeWorkspaceSessionOwnerEverywhere(
|
||||
ownerKey: string,
|
||||
options: { advanceTerminalTopologyRevision?: boolean } = {}
|
||||
): void {
|
||||
if (isWslMoveWorkspaceSessionFrozen(ownerKey)) {
|
||||
return
|
||||
}
|
||||
state.workspaceSession = removeWorkspaceSessionOwner(state.workspaceSession, ownerKey, options)!
|
||||
const partitions = state.workspaceSessionsByHostId
|
||||
if (!partitions) {
|
||||
@@ -134,8 +144,9 @@ export function removeWorkspaceSessionOwners(
|
||||
|
||||
export function removeWorkspaceSessionOwnersEverywhere(
|
||||
state: Pick<PersistedState, 'workspaceSession' | 'workspaceSessionsByHostId'>,
|
||||
ownerKeys: ReadonlySet<string>
|
||||
allOwnerKeys: ReadonlySet<string>
|
||||
): void {
|
||||
const ownerKeys = withoutWslMoveFrozenOwners(allOwnerKeys)
|
||||
if (ownerKeys.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import type {
|
||||
TerminalLeafMoveRequest,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state'
|
||||
import { planTerminalLeafMove, rekeyMovedLeafProfileRecords } from './terminal-leaf-move'
|
||||
import { assignWorkspaceSessionPartition } from './terminal-topology-membership'
|
||||
import { keepWslMoveFrozenSessionsForStore } from '../moving-wsl-projects/wsl-move-session-freeze'
|
||||
|
||||
// The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings). Wraps the close and
|
||||
// the pane move; the close transform still lives in runtime/ and other writers move here later.
|
||||
@@ -147,6 +148,17 @@ export type TerminalTopologyCommitContext = {
|
||||
}
|
||||
|
||||
/** Writes `next`, returning a restore that puts the prior value back unless a later write replaced it. */
|
||||
// A paused WSL move keeps its local rows as the quiesce left them.
|
||||
function keepFrozen(
|
||||
hostId: ExecutionHostId,
|
||||
session: WorkspaceSessionState,
|
||||
context: TerminalTopologyCommitContext
|
||||
): WorkspaceSessionState {
|
||||
return hostId === LOCAL_EXECUTION_HOST_ID
|
||||
? keepWslMoveFrozenSessionsForStore(session, context.getSession(hostId))
|
||||
: session
|
||||
}
|
||||
|
||||
function writeRestorable<V>(read: () => V, write: (value: V) => void, next: V): () => void {
|
||||
const prior = read()
|
||||
write(next)
|
||||
@@ -173,7 +185,7 @@ function commitLeafMove(
|
||||
writeRestorable(
|
||||
() => context.getSession(hostId),
|
||||
(value) => context.markDirty(assignWorkspaceSessionPartition(state, hostId, value)),
|
||||
session
|
||||
keepFrozen(hostId, session, context)
|
||||
)
|
||||
)
|
||||
const rekeyed = rekeyMovedLeafProfileRecords(state, request)
|
||||
@@ -214,7 +226,7 @@ function commitPaneChange<T>(
|
||||
const restore = writeRestorable(
|
||||
() => context.getSession(hostId),
|
||||
(value) => context.markDirty(assignWorkspaceSessionPartition(context.state, hostId, value)),
|
||||
change.session
|
||||
keepFrozen(hostId, change.session, context)
|
||||
)
|
||||
return { value: change.value, rollback: restore }
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/e
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal'
|
||||
import {
|
||||
removeWorkspaceSessionOwners,
|
||||
withoutWslMoveFrozenOwners
|
||||
} from '../restoring-sessions/session-owner-removal'
|
||||
import {
|
||||
getExecutionHostIdFromWorktreeHostIdentity,
|
||||
getWorktreeIdFromHostIdentity,
|
||||
@@ -105,7 +108,10 @@ export function pruneWorktreeStateForRepo(
|
||||
const pruneLegacyLocalSession = hostId === null || hostId === LOCAL_EXECUTION_HOST_ID
|
||||
const pruneAllHostPartitions = hostId === null
|
||||
if (pruneLegacyLocalSession) {
|
||||
state.workspaceSession = removeWorkspaceSessionOwners(state.workspaceSession, ownerKeysToPrune)!
|
||||
state.workspaceSession = removeWorkspaceSessionOwners(
|
||||
state.workspaceSession,
|
||||
withoutWslMoveFrozenOwners(ownerKeysToPrune)
|
||||
)!
|
||||
}
|
||||
if (state.workspaceSessionsByHostId) {
|
||||
for (const [partitionHostId, session] of Object.entries(state.workspaceSessionsByHostId)) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { closeTestStores, createStore, testState } from '../persistence-test-harness'
|
||||
import type { Store } from '../persistence'
|
||||
import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
|
||||
import { writeWslMoveCutover } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal'
|
||||
import { seedWslMoveCutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding'
|
||||
import {
|
||||
WslMoveProjectFencedError,
|
||||
loadWslMoveFence,
|
||||
@@ -71,7 +71,7 @@ beforeEach(() => {
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'wsl-move-meta-'))
|
||||
store = createStore()
|
||||
loadWslMoveFence(testState.dir)
|
||||
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
})
|
||||
afterEach(async () => {
|
||||
resetWslMoveFenceForTests()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
|
||||
import { writeWslMoveCutover } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal'
|
||||
import { seedWslMoveCutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding'
|
||||
import {
|
||||
WslMoveProjectFencedError,
|
||||
loadWslMoveFence,
|
||||
@@ -107,7 +107,7 @@ describe('adoptRuntimeProvisionedRoot', () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-adopt-'))
|
||||
try {
|
||||
loadWslMoveFence(userDataPath)
|
||||
writeWslMoveCutover(
|
||||
seedWslMoveCutover(
|
||||
userDataPath,
|
||||
cutover('move-1', 'source-fenced', {}, { repoIds: ['repo-vm'] })
|
||||
)
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -73,6 +73,15 @@ export const uiClipboardAndWindowControlsApi = {
|
||||
ipcRenderer.on('ui:sleepWorktree', listener)
|
||||
return () => ipcRenderer.removeListener('ui:sleepWorktree', listener)
|
||||
},
|
||||
onWorktreeSleepRequest: (callback) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, request: Parameters<typeof callback>[0]) =>
|
||||
callback(request)
|
||||
ipcRenderer.on('ui:worktreeSleepRequest', listener)
|
||||
return () => ipcRenderer.removeListener('ui:worktreeSleepRequest', listener)
|
||||
},
|
||||
respondWorktreeSleep: (response) => {
|
||||
ipcRenderer.send('ui:worktreeSleepResponse', response)
|
||||
},
|
||||
onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) =>
|
||||
callback(data)
|
||||
|
||||
@@ -48,6 +48,10 @@ import type {
|
||||
SessionTabCloseRequest,
|
||||
SessionTabCloseResponse
|
||||
} from '../../shared/session-tab-close'
|
||||
import type {
|
||||
WorktreeSleepRequest,
|
||||
WorktreeSleepResponse
|
||||
} from '../../shared/worktree-sleep-request'
|
||||
|
||||
export type CloseActiveTabPayload = { sourceId: string }
|
||||
|
||||
@@ -262,6 +266,8 @@ export type UiCommandEventApi = {
|
||||
onTerminalTabCloseRequest: (callback: (request: TerminalTabCloseRequest) => void) => () => void
|
||||
respondTerminalTabClose: (response: TerminalTabCloseResponse) => void
|
||||
onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void
|
||||
onWorktreeSleepRequest: (callback: (request: WorktreeSleepRequest) => void) => () => void
|
||||
respondWorktreeSleep: (response: WorktreeSleepResponse) => void
|
||||
onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void) => () => void
|
||||
onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void
|
||||
onSystemResumed: (callback: () => void) => () => void
|
||||
|
||||
@@ -48,7 +48,11 @@ vi.mock('@/lib/worktree-sleep-intent', () => ({
|
||||
withWorktreeSleepTeardown: (_worktreeId: string, teardown: () => Promise<unknown>) => teardown()
|
||||
}))
|
||||
|
||||
import { runSleepWorktree, runSleepWorktrees } from './sleep-worktree-flow'
|
||||
import {
|
||||
runSleepWorktree,
|
||||
runSleepWorktrees,
|
||||
sleepWorktreesReportingFailures
|
||||
} from './sleep-worktree-flow'
|
||||
|
||||
describe('runSleepWorktree', () => {
|
||||
beforeEach(() => {
|
||||
@@ -310,6 +314,18 @@ describe('runSleepWorktree', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reports failed worktrees to a caller that answers for them, without a toast', async () => {
|
||||
mocks.state.shutdownWorktreeBrowsers.mockImplementation((worktreeId: string) =>
|
||||
worktreeId === 'wt-1' ? Promise.reject(new Error('first failed')) : Promise.resolve()
|
||||
)
|
||||
|
||||
const outcome = await sleepWorktreesReportingFailures(['wt-1', 'wt-2'])
|
||||
|
||||
expect(outcome.failedWorktreeIds).toEqual(['wt-1'])
|
||||
expect(outcome.errors).toHaveLength(1)
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sleeps multiple worktrees and clears active only once when included', async () => {
|
||||
mocks.state.activeWorktreeId = 'wt-2'
|
||||
|
||||
|
||||
@@ -136,8 +136,34 @@ function describeSleepFailure(error: unknown): string {
|
||||
}
|
||||
|
||||
export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise<void> {
|
||||
const { errors } = await sleepWorktreesReportingFailures(worktreeIds)
|
||||
if (errors.length > 0) {
|
||||
// Why: callers are fire-and-forget; surface actionable teardown failures after restoring retryable UI state.
|
||||
toast.error(
|
||||
worktreeIds.length === 1
|
||||
? translate(
|
||||
'auto.components.sidebar.sleep.worktree.flow.8bc3fc0671',
|
||||
'Failed to sleep workspace'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.sidebar.sleep.worktree.flow.c460fecc4a',
|
||||
'Failed to sleep some workspaces'
|
||||
),
|
||||
{
|
||||
description: errors.join('\n')
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export type WorktreeSleepOutcome = { failedWorktreeIds: string[]; errors: string[] }
|
||||
|
||||
/** The sleep without the toast, for callers that report failures themselves. */
|
||||
export async function sleepWorktreesReportingFailures(
|
||||
worktreeIds: readonly string[]
|
||||
): Promise<WorktreeSleepOutcome> {
|
||||
if (worktreeIds.length === 0) {
|
||||
return
|
||||
return { failedWorktreeIds: [], errors: [] }
|
||||
}
|
||||
const {
|
||||
activeWorktreeId,
|
||||
@@ -214,21 +240,5 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise
|
||||
setActiveWorktree(sleptActiveWorktreeId)
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
// Why: callers are fire-and-forget; surface actionable teardown failures after restoring retryable UI state.
|
||||
toast.error(
|
||||
worktreeIds.length === 1
|
||||
? translate(
|
||||
'auto.components.sidebar.sleep.worktree.flow.8bc3fc0671',
|
||||
'Failed to sleep workspace'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.sidebar.sleep.worktree.flow.c460fecc4a',
|
||||
'Failed to sleep some workspaces'
|
||||
),
|
||||
{
|
||||
description: errors.join('\n')
|
||||
}
|
||||
)
|
||||
}
|
||||
return { failedWorktreeIds: [...failedWorktreeIds], errors }
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import { createRuntimeFilePositionQueue } from './runtime-file-position'
|
||||
import { applyClosedTerminalLeafNotice } from '@/components/terminal-pane/closed-terminal-leaf-notice'
|
||||
import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow'
|
||||
import {
|
||||
runSleepWorktree,
|
||||
sleepWorktreesReportingFailures
|
||||
} from '@/components/sidebar/sleep-worktree-flow'
|
||||
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
||||
import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence'
|
||||
import { revealWorkspaceSurface } from '@/lib/workspace-surface-reveal'
|
||||
@@ -133,6 +136,30 @@ export function registerMobileAndTerminalCloseIpcBridge(
|
||||
})
|
||||
)
|
||||
|
||||
// Why optional: an older preload lingering through an in-place reload lacks the channel.
|
||||
if (window.api.ui.onWorktreeSleepRequest) {
|
||||
unsubs.push(
|
||||
window.api.ui.onWorktreeSleepRequest(({ requestId, worktreeIds }) => {
|
||||
void (async () => {
|
||||
const { failedWorktreeIds } = await sleepWorktreesReportingFailures(worktreeIds)
|
||||
// Main waits on the ack, so the slept state must be durable before it.
|
||||
const state = useAppStore.getState()
|
||||
await persistWorkspaceSessionByHost(
|
||||
window.api.session,
|
||||
buildWorkspaceSessionPayload(state),
|
||||
state
|
||||
)
|
||||
window.api.ui.respondWorktreeSleep({ requestId, failedWorktreeIds })
|
||||
})().catch((error: unknown) => {
|
||||
window.api.ui.respondWorktreeSleep({
|
||||
requestId,
|
||||
error: error instanceof Error ? error.message : 'worktree_sleep_failed'
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
unsubs.push(
|
||||
window.api.ui.onResumeSleepingAgents(({ worktreeId }) => {
|
||||
// Why: a phone opened this worktree; wake its slept agents without changing the desktop's worktree/tab/view.
|
||||
|
||||
@@ -97,6 +97,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [
|
||||
'ui.onToggleStatusBar',
|
||||
'ui.onToggleWorktreePalette',
|
||||
'ui.onWorktreeHistoryNavigate',
|
||||
'ui.onWorktreeSleepRequest',
|
||||
'updater.onClearDismissal',
|
||||
'updater.onStatus',
|
||||
'workspaceSpace.onProgress',
|
||||
@@ -164,6 +165,7 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [
|
||||
'ui.onCloseTerminal',
|
||||
'ui.onTerminalTabCloseRequest',
|
||||
'ui.onSleepWorktree',
|
||||
'ui.onWorktreeSleepRequest',
|
||||
'ui.onResumeSleepingAgents',
|
||||
'updater.onStatus',
|
||||
'updater.onClearDismissal',
|
||||
|
||||
@@ -277,6 +277,8 @@ export function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
|
||||
onTerminalTabCloseRequest: () => noopUnsubscribe,
|
||||
respondTerminalTabClose: () => {},
|
||||
onSleepWorktree: () => noopUnsubscribe,
|
||||
onWorktreeSleepRequest: () => noopUnsubscribe,
|
||||
respondWorktreeSleep: () => {},
|
||||
// Why: paired web is a full renderer that wakes on activation; mobile wake is desktop-host-scoped and never reaches web.
|
||||
onResumeSleepingAgents: () => noopUnsubscribe,
|
||||
onTerminalZoom: () => noopUnsubscribe,
|
||||
|
||||
@@ -266,6 +266,14 @@ function entryWorkspaceResolver(
|
||||
export function withoutWorkspaces(
|
||||
base: WorkspaceSessionState,
|
||||
workspaceIds: ReadonlySet<string>
|
||||
): WorkspaceSessionState {
|
||||
return filterWorkspaceRows(base, (workspaceId) => !workspaceIds.has(workspaceId))
|
||||
}
|
||||
|
||||
/** The scoped rows of the workspaces kept, by the same routing; global fields stay as they are. */
|
||||
export function filterWorkspaceRows(
|
||||
base: WorkspaceSessionState,
|
||||
keepsWorkspace: (workspaceId: string) => boolean
|
||||
): WorkspaceSessionState {
|
||||
const worktreeIdByTabId = buildWorktreeIdByTabId(base)
|
||||
const worktreeIdByFileId = buildWorktreeIdByFileId(base)
|
||||
@@ -273,7 +281,7 @@ export function withoutWorkspaces(
|
||||
for (const field of SESSION_FIELDS) {
|
||||
const workspaceOf = entryWorkspaceResolver(field, worktreeIdByTabId, worktreeIdByFileId)
|
||||
const keeps = (key: string, entry: unknown): boolean =>
|
||||
!workspaceIds.has(normalizeWorkspaceSessionKeyToWorkspaceId(workspaceOf?.(key, entry) ?? ''))
|
||||
keepsWorkspace(normalizeWorkspaceSessionKeyToWorkspaceId(workspaceOf?.(key, entry) ?? ''))
|
||||
const value: unknown = base[field]
|
||||
if (!workspaceOf || !value) {
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Main asks the renderer to Sleep workspaces and waits until their slept state is durable. */
|
||||
export type WorktreeSleepRequest = {
|
||||
requestId: string
|
||||
worktreeIds: string[]
|
||||
}
|
||||
|
||||
export type WorktreeSleepResponse = {
|
||||
requestId: string
|
||||
/** Workspaces whose Sleep failed; they stay awake and retryable. */
|
||||
failedWorktreeIds?: string[]
|
||||
error?: string
|
||||
}
|
||||
@@ -14,6 +14,10 @@ import { waitForSessionReady } from './helpers/store'
|
||||
import { createRestartSession } from './helpers/orca-restart'
|
||||
import { wslMoveCutoverFixture } from '../../src/main/persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
|
||||
import { writeWslMoveCutover } from '../../src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal'
|
||||
import {
|
||||
WSL_MOVE_CUTOVER_PHASES,
|
||||
type WslMoveCutoverPhase
|
||||
} from '../../src/shared/wsl-move/wsl-move-cutover'
|
||||
|
||||
test.use({ seedTestRepo: false })
|
||||
|
||||
@@ -134,7 +138,7 @@ test('a WSL move journal fences the moving project, then hides it once the host
|
||||
await session.close(launched.app)
|
||||
launched = undefined
|
||||
|
||||
const record = (phase: 'source-fenced' | 'destination-committed') =>
|
||||
const record = (phase: WslMoveCutoverPhase) =>
|
||||
wslMoveCutoverFixture(
|
||||
'move-1',
|
||||
phase,
|
||||
@@ -162,7 +166,17 @@ test('a WSL move journal fences the moving project, then hides it once the host
|
||||
)
|
||||
}
|
||||
|
||||
writeWslMoveCutover(session.userDataDir, record('source-fenced'))
|
||||
// The journal only moves forward one phase at a time, as a real move writes it.
|
||||
let journaled = -1
|
||||
const advanceTo = (phase: WslMoveCutoverPhase) => {
|
||||
const target = WSL_MOVE_CUTOVER_PHASES.indexOf(phase)
|
||||
for (journaled += 1; journaled <= target; journaled += 1) {
|
||||
writeWslMoveCutover(session.userDataDir, record(WSL_MOVE_CUTOVER_PHASES[journaled]))
|
||||
}
|
||||
journaled = target
|
||||
}
|
||||
|
||||
advanceTo('source-fenced')
|
||||
launched = await session.launch()
|
||||
await waitForSessionReady(launched.page)
|
||||
await expectMovingRefused(launched.page)
|
||||
@@ -173,7 +187,7 @@ test('a WSL move journal fences the moving project, then hides it once the host
|
||||
await session.close(launched.app)
|
||||
launched = undefined
|
||||
|
||||
writeWslMoveCutover(session.userDataDir, record('destination-committed'))
|
||||
advanceTo('destination-committed')
|
||||
launched = await session.launch()
|
||||
await waitForSessionReady(launched.page)
|
||||
await expectMovingRefused(launched.page)
|
||||
|
||||
Reference in New Issue
Block a user