diff --git a/src/main/index.ts b/src/main/index.ts index 6409efdb900..b6e02ef3acc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -31,6 +31,8 @@ import { profileStateDesktopRecoveryArgs } from './startup/profile-state-recover import { ProfileStateRecoveryRequiredError } from './persistence/profile-state/profile-state-recovery-required' import { getProfileUserDataPath } from './orca-profiles/profile-storage-paths' import { leaseAppImageGenerationForCurrentProcess } from './cli/appimage-generation-lease' +import { wakeRememberedWslHosts } from './wsl/wsl-host-early-wake' +import { wslHostsGateOpen } from './wsl/wsl-host-gate' function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { return openMainWindowController(options) @@ -103,6 +105,10 @@ const preflightReady = runMainProcessPreflight({ // Skip userData writes after losing the single-instance lock. if (preflightReady) { void leaseAppImageGenerationForCurrentProcess() + // Why this early: a cold distro takes seconds to boot; starting now overlaps it with app startup. + if (process.platform === 'win32' && wslHostsGateOpen()) { + wakeRememberedWslHosts(app.getPath('userData')) + } app.on('open-url', (event, url) => { if (!parseSkillShareId(url)) { return diff --git a/src/main/ipc/wsl-host-handlers.ts b/src/main/ipc/wsl-host-handlers.ts index 289ecbc8679..1b78a8233fb 100644 --- a/src/main/ipc/wsl-host-handlers.ts +++ b/src/main/ipc/wsl-host-handlers.ts @@ -223,9 +223,8 @@ export function registerWslHostHandlers(args: WslHostHandlerArgs): void { wsl.setVisibleWork(visibleWork.report(windowId, ids)) }) // The user switched to a workspace on this host: the focus entry that may boot its distro. - ipcMain.handle('wslHosts:focusHost', (_event, request: { environmentId: string }) => { - if (typeof request?.environmentId === 'string') { - wsl.focusHost(request.environmentId) - } + ipcMain.handle('wslHosts:focusHost', (_event, request: { environmentId: string | null }) => { + // null: the user is on a workspace that isn't on a WSL host. + wsl.focusHost(typeof request?.environmentId === 'string' ? request.environmentId : null) }) } diff --git a/src/main/wsl/wsl-host-early-wake.test.ts b/src/main/wsl/wsl-host-early-wake.test.ts new file mode 100644 index 00000000000..779d9e39b28 --- /dev/null +++ b/src/main/wsl/wsl-host-early-wake.test.ts @@ -0,0 +1,67 @@ +import { existsSync, 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 { writeWslHostDeployment } from '../../shared/wsl-host-deployment-store' +import { + forgetWslHostToWake, + forgetWslHostsToWake, + rememberWslHostsToWake, + wakeRememberedWslHosts +} from './wsl-host-early-wake' + +describe('WSL host early wake', () => { + let userDataPath: string + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-wsl-wake-')) + writeWslHostDeployment(userDataPath, 'wsl:ubuntu', { + kind: 'wsl', + distro: 'Ubuntu', + localPort: 41000, + remotePort: 6768, + environmentId: 'wsl:ubuntu', + createdAt: 1 + }) + }) + afterEach(() => rmSync(userDataPath, { recursive: true, force: true })) + + it('wakes only the remembered distros that are still WSL hosts', () => { + rememberWslHostsToWake(userDataPath, ['Ubuntu', 'Removed']) + const spawn = vi.fn() + expect(wakeRememberedWslHosts(userDataPath, spawn)).toEqual(['Ubuntu']) + expect(spawn).toHaveBeenCalledExactlyOnceWith('Ubuntu') + }) + + it('wakes nothing once the open work is gone', () => { + rememberWslHostsToWake(userDataPath, ['Ubuntu']) + rememberWslHostsToWake(userDataPath, []) + const spawn = vi.fn() + expect(wakeRememberedWslHosts(userDataPath, spawn)).toEqual([]) + expect(spawn).not.toHaveBeenCalled() + }) + + it('wakes nothing without a list, or from an unreadable one', () => { + const spawn = vi.fn() + expect(wakeRememberedWslHosts(userDataPath, spawn)).toEqual([]) + writeFileSync(join(userDataPath, 'orca-wsl-host-wake.json'), '{"version":1,"distros":["a b"]}') + expect(wakeRememberedWslHosts(userDataPath, spawn)).toEqual([]) + expect(spawn).not.toHaveBeenCalled() + }) + + it('deletes the list when WSL hosts are turned off, so the next launch wakes nothing', () => { + rememberWslHostsToWake(userDataPath, ['Ubuntu']) + forgetWslHostsToWake(userDataPath) + expect(existsSync(join(userDataPath, 'orca-wsl-host-wake.json'))).toBe(false) + const spawn = vi.fn() + expect(wakeRememberedWslHosts(userDataPath, spawn)).toEqual([]) + // Nothing to delete is fine too. + expect(() => forgetWslHostsToWake(userDataPath)).not.toThrow() + }) + + it('forgets a removed host even while its record is still on disk', () => { + rememberWslHostsToWake(userDataPath, ['Ubuntu']) + forgetWslHostToWake(userDataPath, 'ubuntu') + const spawn = vi.fn() + expect(wakeRememberedWslHosts(userDataPath, spawn)).toEqual([]) + }) +}) diff --git a/src/main/wsl/wsl-host-early-wake.ts b/src/main/wsl/wsl-host-early-wake.ts new file mode 100644 index 00000000000..4568e8062f7 --- /dev/null +++ b/src/main/wsl/wsl-host-early-wake.ts @@ -0,0 +1,126 @@ +/** + * Boots the distro of the WSL host the last run had focused, at the very start of the next launch, + * so the boot (seconds on a slow PC) overlaps the app's own startup instead of following it. Only + * the focused host is listed (E9: a launch boots the focused host, never others with saved tabs). + */ +import { rmSync } from 'node:fs' +import { join } from 'node:path' +import { z } from 'zod' +import { spawnProcess } from '@orca/process-host' +import { writeSecureJsonFileWithinLimit } from '../../shared/bounded-secure-json-file' +import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' +import { readWslHostDeployments } from '../../shared/wsl-host-deployment-store' +import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' +import { WslDistroNameSchema } from '../../shared/wsl-host-deployment-link' +import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' +import { wslDistroHolderKey } from './wsl-distro-holder-record' +import { resolveWslExecutablePath } from './wsl-executable-path' + +const WAKE_FILE = 'orca-wsl-host-wake.json' +const MAX_WAKE_FILE_BYTES = 16 * 1024 + +const WakeListSchema = z.object({ version: z.literal(1), distros: z.array(WslDistroNameSchema) }) + +function wakeFile(userDataPath: string): string { + return join(userDataPath, WAKE_FILE) +} + +function readWakeList(userDataPath: string): string[] { + try { + const { buffer } = readNodeFileSyncWithinLimit(wakeFile(userDataPath), MAX_WAKE_FILE_BYTES) + return WakeListSchema.parse(JSON.parse(buffer.toString('utf8'))).distros + } catch { + return [] + } +} + +/** Saves the distro of the host the user is focused on (or none); called on each focus entry. */ +export function rememberWslHostsToWake(userDataPath: string, distros: readonly string[]): void { + const next = [...new Set(distros)].sort() + const current = readWakeList(userDataPath) + if (current.length === next.length && current.every((distro, i) => distro === next[i])) { + return + } + try { + writeSecureJsonFileWithinLimit( + wakeFile(userDataPath), + { version: 1, distros: next }, + MAX_WAKE_FILE_BYTES + ) + } catch (error) { + console.warn('[wsl-hosts] Could not save the distros to wake at next launch:', error) + } +} + +/** + * WSL hosts turned off: the file is the only thing the next launch reads (before settings load), + * so it goes now, synchronously. If it can't be deleted it is emptied instead, and if that fails + * too the failure is logged; a missing or unreadable file wakes nothing. + */ +export function forgetWslHostsToWake(userDataPath: string): void { + try { + rmSync(wakeFile(userDataPath), { force: true }) + } catch (error) { + console.warn('[wsl-hosts] Could not delete the early-wake list; emptying it:', error) + rememberWslHostsToWake(userDataPath, []) + } +} + +/** A removed host is never woken early, even before its record is gone. */ +export function forgetWslHostToWake(userDataPath: string, distro: string): void { + const key = wslDistroHolderKey(distro) + const remembered = readWakeList(userDataPath) + if (remembered.some((entry) => wslDistroHolderKey(entry) === key)) { + rememberWslHostsToWake( + userDataPath, + remembered.filter((entry) => wslDistroHolderKey(entry) !== key) + ) + } +} + +export type WslWakeSpawn = (distro: string) => void + +function spawnWake(distro: string): void { + const child = spawnProcess({ + program: resolveWslExecutablePath(), + args: buildWslExecArgs(distro, ['true']), + cwd: resolveWslInteropSpawnCwd(), + env: { ...process.env, WSL_UTF8: '1' }, + stdio: 'ignore' + }) + child.on('error', () => {}) + child.unref() +} + +/** + * Starts booting each remembered distro that is still a registered WSL host. Fire and forget: the + * connect that follows does the real work and reports any failure. + */ +export function wakeRememberedWslHosts( + userDataPath: string, + spawn: WslWakeSpawn = spawnWake +): string[] { + const remembered = readWakeList(userDataPath) + if (remembered.length === 0) { + return [] + } + let registered: Set + try { + registered = new Set( + readWslHostDeployments(userDataPath).deployments.map((record) => + wslDistroHolderKey(record.distro) + ) + ) + } catch { + return [] + } + const woken = remembered.filter((distro) => registered.has(wslDistroHolderKey(distro))) + for (const distro of woken) { + try { + spawn(distro) + } catch (error) { + console.warn(`[wsl-hosts] Could not start waking ${distro}:`, error) + } + } + return woken +} diff --git a/src/main/wsl/wsl-host-service.test.ts b/src/main/wsl/wsl-host-service.test.ts index 565b2e9c59d..8fb53267679 100644 --- a/src/main/wsl/wsl-host-service.test.ts +++ b/src/main/wsl/wsl-host-service.test.ts @@ -14,6 +14,7 @@ import type { WslHostServerStop, WslHostStatus } from '../../shared/wsl-host-typ import { OrcadHostUnsupportedError } from '../orcad/orcad-host-unavailable' import type { WslHostDistroCandidate } from './wsl-host-distro-list' import { WslHostService, type WslHostConnector } from './wsl-host-service' +import { wakeRememberedWslHosts } from './wsl-host-early-wake' import { WslOrcadLaunchDeferredError } from './wsl-orcad-connection-errors' import type { WslOrcadConnection } from './wsl-orcad-connection' @@ -670,6 +671,36 @@ describe('WslHostService', () => { expect(knownWhileAdding).toBe(true) }) + it('remembers only the focused host for an early boot next launch (E9)', async () => { + const connector = makeConnector([UBUNTU]) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + const spawn = vi.fn() + // Open work alone is not remembered. + service.setVisibleWork(['wsl:ubuntu']) + expect(wakeRememberedWslHosts(userDataPath, spawn)).toEqual([]) + service.focusHost('wsl:ubuntu') + expect(wakeRememberedWslHosts(userDataPath, spawn)).toEqual(['Ubuntu']) + service.focusHost(null) + expect(wakeRememberedWslHosts(userDataPath, spawn)).toEqual([]) + service.focusHost('wsl:ubuntu') + enabled = false + await service.enabledChanged(false) + expect(wakeRememberedWslHosts(userDataPath, spawn)).toEqual([]) + expect(spawn).toHaveBeenCalledOnce() + }) + + it('forgets the early-wake host when it is removed', async () => { + const connector = makeConnector([UBUNTU]) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + service.focusHost('wsl:ubuntu') + await service.remove('wsl:ubuntu') + // Re-registering the distro must not bring the old early wake back. + await service.add({ distro: 'Ubuntu' }) + expect(wakeRememberedWslHosts(userDataPath, vi.fn())).toEqual([]) + }) + it('adopts holders at startup only when a WSL host exists', async () => { const connector = makeConnector([UBUNTU]) const service = makeService(connector) diff --git a/src/main/wsl/wsl-host-service.ts b/src/main/wsl/wsl-host-service.ts index a13f5b048af..aeea02b3b90 100644 --- a/src/main/wsl/wsl-host-service.ts +++ b/src/main/wsl/wsl-host-service.ts @@ -29,6 +29,11 @@ import { import type { WslOrcadConnection } from './wsl-orcad-connection' import { registerWslHost } from './wsl-host-registration' import type { WslOrcadConnectIntent, WslOrcadConnectionStatus } from './wsl-orcad-connection-status' +import { + forgetWslHostToWake, + forgetWslHostsToWake, + rememberWslHostsToWake +} from './wsl-host-early-wake' /** `stopped` and `unreachable` rest on a confirmed running list; `unavailable` could not read it. */ export type WslHostReachability = 'available' | 'unreachable' | 'stopped' | 'unavailable' @@ -188,6 +193,7 @@ export class WslHostService { await connector.releaseHolder(record.distro) } removeWslHostEnvironment(this.deps.userDataPath(), environmentId) + forgetWslHostToWake(this.deps.userDataPath(), record.distro) this.visible.delete(environmentId) this.parked.delete(environmentId) } finally { @@ -211,6 +217,7 @@ export class WslHostService { return } this.disableGeneration += 1 + forgetWslHostsToWake(this.deps.userDataPath()) for (const record of this.records()) { await connector.disconnect(record.distro) await connector.releaseHolder(record.distro) @@ -276,14 +283,18 @@ export class WslHostService { * The user switched to a workspace on this host (or launch restored it focused): the one * non-explicit act that may boot its distro, and it lifts a park. */ - focusHost(environmentId: string): void { - const record = this.recordForEnvironment(environmentId) - if (!record || !this.connector || !this.canRun() || this.removing.has(environmentId)) { + focusHost(environmentId: string | null): void { + const record = environmentId ? this.recordForEnvironment(environmentId) : null + const runnable = + record && this.connector && this.canRun() && !this.removing.has(record.environmentId) + // The next launch starts booting only this distro, early (none when off WSL or not runnable). + rememberWslHostsToWake(this.deps.userDataPath(), runnable ? [record.distro] : []) + if (!record || !runnable || !this.connector) { return } - this.parked.delete(environmentId) + this.parked.delete(record.environmentId) void this.connector - .reachability(record.distro, environmentId) + .reachability(record.distro, record.environmentId) .then((reachability) => reachability === 'available' ? undefined : this.connector?.connect(record.distro) ) diff --git a/src/preload/api/wsl-hosts-api.ts b/src/preload/api/wsl-hosts-api.ts index 1a339a2b04e..7a3bd7cb5fa 100644 --- a/src/preload/api/wsl-hosts-api.ts +++ b/src/preload/api/wsl-hosts-api.ts @@ -23,7 +23,7 @@ export type WslHostsPreloadApi = { /** The WSL hosts an open project or terminal needs; only these may boot a stopped distro. */ setVisibleWork: (args: { environmentIds: string[] }) => Promise /** The user switched to a workspace on this host; may boot its distro (E9). */ - focusHost: (args: { environmentId: string }) => Promise + focusHost: (args: { environmentId: string | null }) => Promise } const wslHostsApi: WslHostsPreloadApi = { diff --git a/src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge-focus.test.ts b/src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge-focus.test.ts index 246c1e9dc11..3e4b757ab5a 100644 --- a/src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge-focus.test.ts +++ b/src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge-focus.test.ts @@ -19,7 +19,7 @@ const api = { onStatusChanged: vi.fn(() => () => {}), getStatuses: vi.fn(async () => []), setVisibleWork: vi.fn(async (_args: { environmentIds: string[] }) => {}), - focusHost: vi.fn(async (_args: { environmentId: string }) => {}) + focusHost: vi.fn(async (_args: { environmentId: string | null }) => {}) } function seed(experimentalWslHosts: boolean, activeWorktreeId: string): void { @@ -70,7 +70,12 @@ describe('WSL host focus entries (E9)', () => { // Away and back: the visible set never changes (its tabs stay), but the return is an act. useAppStore.setState({ activeWorktreeId: LOCAL_WORKTREE }) useAppStore.setState({ activeWorktreeId: WSL_WORKTREE }) - expect(api.focusHost).toHaveBeenCalledTimes(2) + expect(api.focusHost.mock.calls).toEqual([ + [{ environmentId: WSL_ENV }], + // Off WSL: the next launch has no host to start early. + [{ environmentId: null }], + [{ environmentId: WSL_ENV }] + ]) expect(api.setVisibleWork).toHaveBeenCalledTimes(1) unsubs.forEach((unsub) => unsub()) }) diff --git a/src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge.ts index bbb35be1c2d..7a08ac156b9 100644 --- a/src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge.ts @@ -102,8 +102,8 @@ export function registerWslHostIpcBridge(unsubs: (() => void)[]): void { // Switching to a workspace on a WSL host (or launch restoring one) is the user acting on it. if (entry !== focusEntry) { focusEntry = entry - if (focusedHost && typeof api.focusHost === 'function') { - void api.focusHost({ environmentId: focusedHost }).catch((error) => { + if (typeof api.focusHost === 'function') { + void api.focusHost({ environmentId: focusedHost ?? null }).catch((error) => { console.error('Failed to report the focused WSL host:', error) }) }