diff --git a/src/main/index.ts b/src/main/index.ts index e3dac35a3d2..2bf30539392 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -137,6 +137,7 @@ import { import { maybeRedirectAppImageCliLaunch } from './startup/appimage-cli-redirect' import { maybeRedirectPackagedCliEntryLaunch } from './startup/packaged-cli-entry-redirect' import { startFirstWindowStartupServices } from './startup/first-window-startup-services' +import { recoverLegacyWorkerTerminalsForRendererStartup } from './startup/legacy-worker-renderer-recovery' import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reconciliation-startup-barrier' import { getDevInstanceIdentity } from './startup/dev-instance-identity' import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' @@ -728,15 +729,17 @@ if (hasSingleInstanceLock) { headlessBrowserDisplayAvailable = ensureVirtualDisplayForHeadlessServe({ isServeMode }) } -ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { - // Why: restored WSL terminals get a bounded chance to receive launcher repairs before window rendering proceeds. - await Promise.all([ +ipcMain.handle('app:awaitFirstWindowStartupServices', () => + recoverLegacyWorkerTerminalsForRendererStartup({ firstWindowStartupServicesReady, + managedWslCliStartupBarrierReady, localPtyProviderStartupReady, - managedWslCliStartupBarrierReady - ]) - await runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true }) -}) + reconcile: () => runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true }), + onDeferredRecoveryError: (error) => { + console.warn('[orchestration] legacy worker provider-ready recovery failed', error) + } + }) +) // Why: the renderer pulls this once its ui:openSettings listener attaches, so a Settings request queued before mount isn't lost. ipcMain.handle('ui:consumePendingOpenSettings', (event) => diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 6c5564e3e19..6ce212289e8 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -73,6 +73,11 @@ describe('startup ordering', () => { expect(desktopStartup).not.toContain('await managedWslCliReconciliationReady') expect(barrier).toContain('managedWslCliStartupBarrierReady') expect(barrier).not.toContain('managedWslCliReconciliationReady') + expect(barrier).toContain('recoverLegacyWorkerTerminalsForRendererStartup({') + expect(barrier).toContain('localPtyProviderStartupReady,') + expect(barrier).toContain( + 'reconcile: () => runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true })' + ) }) it('exposes managed WSL reconciliation status to headless serve clients and diagnostics', () => { diff --git a/src/main/startup/legacy-worker-renderer-recovery.test.ts b/src/main/startup/legacy-worker-renderer-recovery.test.ts new file mode 100644 index 00000000000..143c1bade93 --- /dev/null +++ b/src/main/startup/legacy-worker-renderer-recovery.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest' +import { recoverLegacyWorkerTerminalsForRendererStartup } from './legacy-worker-renderer-recovery' + +describe('legacy worker renderer recovery', () => { + it('hydrates after bounded barriers while provider startup remains pending', async () => { + let resolveFirstWindow!: () => void + let resolveWslBarrier!: () => void + let resolveProvider!: () => void + const firstWindowReady = new Promise((resolve) => { + resolveFirstWindow = resolve + }) + const wslBarrierReady = new Promise((resolve) => { + resolveWslBarrier = resolve + }) + const providerReady = new Promise((resolve) => { + resolveProvider = resolve + }) + const reconcile = vi.fn().mockResolvedValue(undefined) + const startup = recoverLegacyWorkerTerminalsForRendererStartup({ + firstWindowStartupServicesReady: firstWindowReady, + managedWslCliStartupBarrierReady: wslBarrierReady, + localPtyProviderStartupReady: providerReady, + reconcile, + onDeferredRecoveryError: vi.fn() + }) + + resolveFirstWindow() + await Promise.resolve() + expect(reconcile).not.toHaveBeenCalled() + + resolveWslBarrier() + await startup + expect(reconcile).toHaveBeenCalledTimes(1) + + resolveProvider() + await vi.waitFor(() => expect(reconcile).toHaveBeenCalledTimes(2)) + }) + + it('retries after initial recovery when the provider is already ready', async () => { + const reconcile = vi.fn().mockResolvedValue(undefined) + + await recoverLegacyWorkerTerminalsForRendererStartup({ + firstWindowStartupServicesReady: Promise.resolve(), + managedWslCliStartupBarrierReady: Promise.resolve(), + localPtyProviderStartupReady: Promise.resolve(), + reconcile, + onDeferredRecoveryError: vi.fn() + }) + + await vi.waitFor(() => expect(reconcile).toHaveBeenCalledTimes(2)) + }) + + it('contains provider startup rejection after initial recovery', async () => { + const providerError = new Error('provider failed') + const reconcile = vi.fn().mockResolvedValue(undefined) + let reportError!: (error: unknown) => void + const reportedError = new Promise((resolve) => { + reportError = resolve + }) + + await recoverLegacyWorkerTerminalsForRendererStartup({ + firstWindowStartupServicesReady: Promise.resolve(), + managedWslCliStartupBarrierReady: Promise.resolve(), + localPtyProviderStartupReady: Promise.reject(providerError), + reconcile, + onDeferredRecoveryError: reportError + }) + + await expect(reportedError).resolves.toBe(providerError) + expect(reconcile).toHaveBeenCalledTimes(1) + }) + + it('contains deferred recovery rejection', async () => { + const recoveryError = new Error('recovery failed') + const reconcile = vi.fn().mockResolvedValueOnce(undefined).mockRejectedValueOnce(recoveryError) + let reportError!: (error: unknown) => void + const reportedError = new Promise((resolve) => { + reportError = resolve + }) + + await recoverLegacyWorkerTerminalsForRendererStartup({ + firstWindowStartupServicesReady: Promise.resolve(), + managedWslCliStartupBarrierReady: Promise.resolve(), + localPtyProviderStartupReady: Promise.resolve(), + reconcile, + onDeferredRecoveryError: reportError + }) + + await expect(reportedError).resolves.toBe(recoveryError) + expect(reconcile).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/startup/legacy-worker-renderer-recovery.ts b/src/main/startup/legacy-worker-renderer-recovery.ts new file mode 100644 index 00000000000..7de5deeb7cf --- /dev/null +++ b/src/main/startup/legacy-worker-renderer-recovery.ts @@ -0,0 +1,29 @@ +type LegacyWorkerRendererRecoveryOptions = { + firstWindowStartupServicesReady: Promise + managedWslCliStartupBarrierReady: Promise + localPtyProviderStartupReady: Promise + reconcile: () => Promise | undefined + onDeferredRecoveryError: (error: unknown) => void +} + +export async function recoverLegacyWorkerTerminalsForRendererStartup( + options: LegacyWorkerRendererRecoveryOptions +): Promise { + const providerStartupResult = options.localPtyProviderStartupReady.then( + () => ({ ok: true as const }), + (error: unknown) => ({ ok: false as const, error }) + ) + await Promise.all([ + options.firstWindowStartupServicesReady, + options.managedWslCliStartupBarrierReady + ]) + await options.reconcile() + void providerStartupResult + .then(async (result) => { + if (!result.ok) { + throw result.error + } + await options.reconcile() + }) + .catch(options.onDeferredRecoveryError) +}