diff --git a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts index 7d894746866..34652890885 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts @@ -1,4 +1,8 @@ -import { consumeReopenAfterSleep } from '@/components/sidebar/workspace-actions/sleep/browser-tab-sleep' +import { + noteSleptRemotePageAnswered, + sleptRemotePageMissingAction, + useReopenSleptRemotePage +} from './use-slept-remote-page' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/workspace/ownership/worktree-runtime-owner' @@ -60,6 +64,7 @@ export function useRemoteBrowserPageLifecycle({ const mountedRef = useRef(true) const isActiveRef = useRef(isActive) const currentBrowserTabIdRef = useRef(browserTab.id) + const currentWorktreeIdRef = useRef(browserTab.worktreeId) const currentBrowserTabUrlRef = useRef(browserTab.url) const runtimeWorktree = useMemo(() => toRuntimeWorktreeSelector(worktreeId), [worktreeId]) const runtimeWorktreeRef = useRef(runtimeWorktree) @@ -72,11 +77,19 @@ export function useRemoteBrowserPageLifecycle({ useLayoutEffect(() => { currentBrowserTabIdRef.current = browserTab.id + currentWorktreeIdRef.current = browserTab.worktreeId currentBrowserTabUrlRef.current = browserTab.url activeRuntimeEnvironmentIdRef.current = activeRuntimeEnvironmentId isActiveRef.current = isActive runtimeWorktreeRef.current = runtimeWorktree - }, [activeRuntimeEnvironmentId, browserTab.id, browserTab.url, isActive, runtimeWorktree]) + }, [ + activeRuntimeEnvironmentId, + browserTab.id, + browserTab.url, + browserTab.worktreeId, + isActive, + runtimeWorktree + ]) if (!lifecycleRef.current) { lifecycleRef.current = new RemoteBrowserStreamLifecycle({ @@ -107,7 +120,13 @@ export function useRemoteBrowserPageLifecycle({ // Every transition invalidates the pane notice; stale direct feedback would outrank the stream’s failure beside Reconnect. setPaneNotice(null) }, - applyTabInfo: (tab) => streamBridgeRef.current.applyTabInfo(tab), + applyTabInfo: (tab) => { + noteSleptRemotePageAnswered({ + id: currentBrowserTabIdRef.current, + worktreeId: currentWorktreeIdRef.current + }) + streamBridgeRef.current.applyTabInfo(tab) + }, clearFrame: () => streamBridgeRef.current.clearFrame(), handleFrameBytes: (token, bytes) => streamBridgeRef.current.handleFrameBytes(token, bytes), closeMissingRemotePage: (remotePageId) => @@ -119,6 +138,13 @@ export function useRemoteBrowserPageLifecycle({ } const lifecycle = lifecycleRef.current + const isMounted = useCallback(() => mountedRef.current, []) + useReopenSleptRemotePage( + { id: browserTab.id, worktreeId: browserTab.worktreeId }, + lifecycle, + isMounted + ) + const runtimeTarget = useCallback(() => { return activeRuntimeEnvironmentId ? ({ @@ -148,15 +174,16 @@ export function useRemoteBrowserPageLifecycle({ state.removeRemoteBrowserPageHandle(browserTab.id, remotePageId) } lifecycle.abandonRemotePage() - // Slept with its VM (NEW-01): the tab stays. While asleep it waits; once the workspace is - // open again it opens a new host page at its URL. - if (state.asleepBrowserWorktreeIds[browserTab.worktreeId]) { + // Slept with its VM (NEW-01, LC2-P1): wait, or reopen at its URL, instead of closing. + const action = sleptRemotePageMissingAction({ + id: browserTab.id, + worktreeId: browserTab.worktreeId + }) + if (action !== 'close') { clearStreamFrame() - return - } - if (consumeReopenAfterSleep(browserTab.id)) { - clearStreamFrame() - lifecycle.open() + if (action === 'reopen') { + lifecycle.open() + } return } if (remoteViewportTimerRef.current !== null) { diff --git a/src/renderer/src/components/browser-pane/stream-remote/use-slept-remote-page.ts b/src/renderer/src/components/browser-pane/stream-remote/use-slept-remote-page.ts new file mode 100644 index 00000000000..837dcf5f72c --- /dev/null +++ b/src/renderer/src/components/browser-pane/stream-remote/use-slept-remote-page.ts @@ -0,0 +1,61 @@ +// A host-run browser page and its workspace's sleep (NEW-01, LC2-P1). Pages die with the VM; the +// tab stays and gets a new host page at its URL once the sandbox is up for good, including after +// a wake pressed while the suspend was still being committed. +import { useEffect, useRef } from 'react' +import { useAppStore } from '@/store' +import { useIsWorktreeSuspendCommitted } from '@/lib/worktree-sleep-view' +import { + isAwaitingReopenAfterSleep, + isBrowserSandboxUp, + missingRemotePageAction, + readSleepInFlight, + settleReopenAfterSleep +} from '@/components/sidebar/workspace-actions/sleep/browser-tab-sleep' + +type Page = { id: string; worktreeId: string } + +/** What to do with a page the host reports missing: wait, reopen it, or close the tab. */ +export function sleptRemotePageMissingAction(page: Page): 'wait' | 'reopen' | 'close' { + const state = useAppStore.getState() + return missingRemotePageAction({ + awaitingReopen: isAwaitingReopenAfterSleep(page.id), + asleep: Boolean(state.asleepBrowserWorktreeIds[page.worktreeId]), + sandboxUp: isBrowserSandboxUp(state, page.worktreeId, readSleepInFlight(page.worktreeId)) + }) +} + +/** The host answered for this page with the sandbox up for good: it is no longer owed. */ +export function noteSleptRemotePageAnswered(page: Page): void { + if ( + isBrowserSandboxUp(useAppStore.getState(), page.worktreeId, readSleepInFlight(page.worktreeId)) + ) { + settleReopenAfterSleep(page.id) + } +} + +/** Opens a page owed since sleep the moment the sandbox comes up (nothing else may ask again). */ +export function useReopenSleptRemotePage( + page: Page, + lifecycle: { open: () => unknown; tokens: { remotePage: string | null } }, + isMounted: () => boolean +): void { + const suspendCommitted = useIsWorktreeSuspendCommitted(page.worktreeId) + const sandboxUp = useAppStore( + (state) => + !suspendCommitted && + isBrowserSandboxUp(state, page.worktreeId, readSleepInFlight(page.worktreeId)) + ) + const wasUpRef = useRef(sandboxUp) + useEffect(() => { + const cameUp = sandboxUp && !wasUpRef.current + wasUpRef.current = sandboxUp + if ( + cameUp && + isMounted() && + isAwaitingReopenAfterSleep(page.id) && + lifecycle.tokens.remotePage === null + ) { + lifecycle.open() + } + }, [isMounted, lifecycle, page.id, sandboxUp]) +} diff --git a/src/renderer/src/components/sidebar/workspace-actions/sleep/browser-tab-sleep-wake-race.test.ts b/src/renderer/src/components/sidebar/workspace-actions/sleep/browser-tab-sleep-wake-race.test.ts new file mode 100644 index 00000000000..f6a0c6d003a --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-actions/sleep/browser-tab-sleep-wake-race.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const store = vi.hoisted(() => ({ + state: { + browserTabsByWorktree: { + wt: [ + { id: 'tab-a', worktreeId: 'wt' }, + { id: 'tab-b', worktreeId: 'wt' } + ] + }, + browserPagesByWorkspace: { 'tab-a': [{ id: 'page-a' }], 'tab-b': [{ id: 'page-b' }] }, + asleepBrowserWorktreeIds: {} + } +})) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => store.state, + setState: () => {} + } +})) +vi.mock('@/store/slices/browser/cleanup/browser-webview-cleanup', () => ({ + destroyWorktreeBrowserGuests: () => {} +})) + +const { + isAwaitingReopenAfterSleep, + isBrowserSandboxUp, + missingRemotePageAction, + resetReopenAfterSleepForTest, + settleReopenAfterSleep, + sleepWorktreeBrowserTabs +} = await import('./browser-tab-sleep') + +type Vm = 'running' | 'suspended' +const WT = 'wt' + +/** One moment of the race: the VM record, a wake in flight, and the window's sleep flags. */ +function moment(args: { + vm: Vm + waking?: boolean + asleepMark?: boolean + sleepIntent?: boolean + goingToSleep?: boolean + suspendCommitted?: boolean +}) { + const state = { + ephemeralVmRuntimesByWorkspaceId: { [WT]: { status: args.vm } }, + wakingEphemeralVmWorkspaceIds: args.waking ? { [WT]: true } : {}, + asleepBrowserWorktreeIds: args.asleepMark ? { [WT]: 1 } : {} + } + const sleep = { + sleepIntent: Boolean(args.sleepIntent), + goingToSleep: Boolean(args.goingToSleep), + suspendCommitted: Boolean(args.suspendCommitted) + } + return { + up: isBrowserSandboxUp(state, WT, sleep), + onMissing: (pageId: string) => + missingRemotePageAction({ + awaitingReopen: isAwaitingReopenAfterSleep(pageId), + asleep: Boolean(args.asleepMark), + sandboxUp: isBrowserSandboxUp(state, WT, sleep) + }) + } +} + +beforeEach(() => { + resetReopenAfterSleepForTest() +}) + +describe('wake pressed while the suspend is still being committed (LC2-P1)', () => { + it('keeps both tabs: they wait through the race and reopen once the sandbox is up', () => { + sleepWorktreeBrowserTabs(WT, 1) + expect(isAwaitingReopenAfterSleep('page-a')).toBe(true) + + // +8 s: "Wake to continue" pressed. The asleep mark is cleared, but main is still + // committing the suspend and the VM record still reads "running": hosts pages vanish. + const pressed = moment({ vm: 'running', sleepIntent: true, suspendCommitted: true }) + expect(pressed.up).toBe(false) + expect(pressed.onMissing('page-a')).toBe('wait') + expect(pressed.onMissing('page-b')).toBe('wait') + + // ~4 s later the VM is suspended, then waking: still nothing opens or closes. + expect(moment({ vm: 'suspended' }).onMissing('page-a')).toBe('wait') + expect(moment({ vm: 'suspended', waking: true }).onMissing('page-b')).toBe('wait') + expect(isAwaitingReopenAfterSleep('page-a')).toBe(true) + expect(isAwaitingReopenAfterSleep('page-b')).toBe(true) + + // Live: the missing pages reopen in place instead of closing their tabs. + const live = moment({ vm: 'running' }) + expect(live.up).toBe(true) + expect(live.onMissing('page-a')).toBe('reopen') + expect(live.onMissing('page-b')).toBe('reopen') + // A second "missing" before the new page answers still reopens, never closes. + expect(live.onMissing('page-a')).toBe('reopen') + + // The new host page answered: from now on a missing page is a real close. + settleReopenAfterSleep('page-a') + expect(live.onMissing('page-a')).toBe('close') + expect(live.onMissing('page-b')).toBe('reopen') + }) + + it('an ordinary sleep and wake still waits while asleep and reopens after', () => { + sleepWorktreeBrowserTabs(WT, 1) + expect(moment({ vm: 'suspended', asleepMark: true }).onMissing('page-a')).toBe('wait') + expect(moment({ vm: 'running' }).onMissing('page-a')).toBe('reopen') + }) + + it('a tab that never slept closes when its host page is gone, as before', () => { + expect(moment({ vm: 'running' }).onMissing('page-x')).toBe('close') + }) +}) diff --git a/src/renderer/src/components/sidebar/workspace-actions/sleep/browser-tab-sleep.ts b/src/renderer/src/components/sidebar/workspace-actions/sleep/browser-tab-sleep.ts index fc62a3a8d2e..4592b2fe66f 100644 --- a/src/renderer/src/components/sidebar/workspace-actions/sleep/browser-tab-sleep.ts +++ b/src/renderer/src/components/sidebar/workspace-actions/sleep/browser-tab-sleep.ts @@ -6,6 +6,8 @@ // wake. The mark lives outside the layout (asleepBrowserWorktreeIds), so sleep writes no layout. import { useAppStore } from '@/store' import { destroyWorktreeBrowserGuests } from '@/store/slices/browser/cleanup/browser-webview-cleanup' +import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' +import { isWorktreeGoingToSleep, isWorktreeSuspendCommitted } from '@/lib/worktree-sleep-view' import type { BrowserWorkspace } from '../../../../../../shared/browser-workspace-types' type BrowserTabsByWorktree = Record @@ -46,9 +48,81 @@ export function selectAwakeBrowserTabsByWorktree(state: { // reported missing after the wake, its tab opens a new page at the same URL instead of closing. const reopenAfterSleep = new Set() -/** True once per slept page: the pane recreates the host page instead of closing the tab. */ -export function consumeReopenAfterSleep(browserPageId: string): boolean { - return reopenAfterSleep.delete(browserPageId) +/** + * A slept page still owed a new host page. It stays owed until a host page answers for it again + * (a new one opened, or the old one still there), however many times "missing" is reported on + * the way: a wake that races the suspend reports it while the VM is still going down (LC2-P1). + */ +export function isAwaitingReopenAfterSleep(browserPageId: string): boolean { + return reopenAfterSleep.has(browserPageId) +} + +/** A host page answered for this tab again: from now on a missing page is a real close. */ +export function settleReopenAfterSleep(browserPageId: string): void { + reopenAfterSleep.delete(browserPageId) +} + +export function resetReopenAfterSleepForTest(): void { + reopenAfterSleep.clear() +} + +type SandboxState = { + ephemeralVmRuntimesByWorkspaceId?: Record + wakingEphemeralVmWorkspaceIds?: Record + asleepBrowserWorktreeIds?: Record +} + +/** This window's view of a sleep in flight for one workspace (workspace-sleep-follower). */ +export type SleepInFlight = { + /** Sleep started and has not ended (also while the user presses Wake to continue). */ + sleepIntent: boolean + goingToSleep: boolean + /** Main is committing the suspend: the VM still reads "running" but is going down. */ + suspendCommitted: boolean +} + +export function readSleepInFlight(worktreeId: string): SleepInFlight { + return { + sleepIntent: hasWorktreeSleepIntent(worktreeId), + goingToSleep: isWorktreeGoingToSleep(worktreeId), + suspendCommitted: isWorktreeSuspendCommitted(worktreeId) + } +} + +/** + * The workspace's host can run pages that will last: no sleep in flight (a wake pressed while + * the suspend is still being committed included), not marked asleep, not waking, and its VM (if + * it has one) running. A page opened before all of this holds would die with the suspend. + */ +export function isBrowserSandboxUp( + state: SandboxState, + worktreeId: string, + sleep: SleepInFlight +): boolean { + const runtime = state.ephemeralVmRuntimesByWorkspaceId?.[worktreeId] + return ( + !sleep.sleepIntent && + !sleep.goingToSleep && + !sleep.suspendCommitted && + !state.asleepBrowserWorktreeIds?.[worktreeId] && + !state.wakingEphemeralVmWorkspaceIds?.[worktreeId] && + (runtime === undefined || runtime.status === 'running') + ) +} + +/** What a pane does when its host page is reported missing (NEW-01, LC2-P1). */ +export function missingRemotePageAction(args: { + awaitingReopen: boolean + asleep: boolean + sandboxUp: boolean +}): 'wait' | 'reopen' | 'close' { + if (args.asleep) { + return 'wait' + } + if (args.awaitingReopen) { + return args.sandboxUp ? 'reopen' : 'wait' + } + return 'close' } /** Sleep: drop the pages, keep the tabs, mark the workspace's tabs asleep. */