diff --git a/app/src/lib/bootCheck/index.test.ts b/app/src/lib/bootCheck/index.test.ts index b4c18e54b47..39dc87b0a4e 100644 --- a/app/src/lib/bootCheck/index.test.ts +++ b/app/src/lib/bootCheck/index.test.ts @@ -224,6 +224,94 @@ describe('runBootCheck — unset mode', () => { // --------------------------------------------------------------------------- describe('runBootCheck — port conflict auto-recovery', () => { + it('does not mistake a legacy SQLite upgrade failure for a port collision', async () => { + const recovery = vi.fn().mockResolvedValue({ success: false, message: 'no port owner' }); + const transport: BootCheckTransport = { + callRpc: vi.fn(), + invokeCmd: vi + .fn() + .mockRejectedValue( + new Error( + 'in-process core server exited before becoming ready: open the storage backend ' + + 'sqlite:C:\\Users\\ACER\\.openhuman\\users\\local-desktop\\workspace\\storage: ' + + 'invalid input: legacy sqlite storage must be upgraded by the host' + ) + ), + recoverPortConflict: recovery, + }; + + const result = await runBootCheck({ kind: 'local' }, transport); + + expect(recovery).not.toHaveBeenCalled(); + expect(result).toMatchObject({ kind: 'unreachable', portConflict: false }); + if (result.kind === 'unreachable') { + expect(result.reason).toContain('legacy sqlite storage must be upgraded by the host'); + } + }); + + it('does not reap processes for unrelated app-engine startup errors', async () => { + const recovery = vi.fn(); + const result = await runBootCheck( + { kind: 'local' }, + { + callRpc: vi.fn(), + invokeCmd: vi.fn().mockRejectedValue(new Error('core runtime initialization failed')), + recoverPortConflict: recovery, + } + ); + expect(recovery).not.toHaveBeenCalled(); + expect(result).toEqual({ + kind: 'unreachable', + reason: 'Failed to start local core: core runtime initialization failed', + portConflict: false, + }); + }); + + it.each([ + 'failed to signal stale openhuman pid 123: permission denied', + 'failed to force-kill stale openhuman pid 123: permission denied', + ])('keeps stale-listener recovery available after %s', async diagnostic => { + const recoverPortConflict = vi.fn().mockResolvedValue({ + success: false, + message: 'listener requires manual recovery', + foreign_owner: { pid: 456, name: 'another process' }, + }); + const result = await runBootCheck( + { kind: 'local' }, + { + callRpc: vi.fn(), + invokeCmd: vi.fn().mockRejectedValue(new Error(diagnostic)), + recoverPortConflict, + } + ); + expect(recoverPortConflict).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + kind: 'unreachable', + portConflict: true, + foreignOwner: { pid: 456, name: 'another process' }, + }); + }); + + it('does not run broad recovery when the port rebounds to a new owner', async () => { + const recovery = vi.fn(); + const diagnostic = + 'port 7788 rebounded to pid 456 after terminating pid 123; refusing to kill a different process'; + const result = await runBootCheck( + { kind: 'local' }, + { + callRpc: vi.fn(), + invokeCmd: vi.fn().mockRejectedValue(new Error(diagnostic)), + recoverPortConflict: recovery, + } + ); + expect(recovery).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + kind: 'unreachable', + portConflict: true, + reason: expect.stringContaining('rebounded to pid 456'), + }); + }); + it('auto-recovery succeeds: start fails, recovery succeeds, second start succeeds', async () => { const appVersion = (await import('../../utils/config')).APP_VERSION; diff --git a/app/src/lib/bootCheck/index.ts b/app/src/lib/bootCheck/index.ts index ee4cf82da86..4bf4c31be09 100644 --- a/app/src/lib/bootCheck/index.ts +++ b/app/src/lib/bootCheck/index.ts @@ -187,6 +187,27 @@ async function checkVersion(callRpc: BootCheckTransport['callRpc']): Promise('start_core_process', {}); log('[boot-check] start_core_process invoked successfully'); } catch (err) { - startFailed = true; + startError = err instanceof Error ? err.message : String(err); logError('[boot-check] start_core_process failed: %o', err); + if (!isPortBindingFailure(startError)) { + // Do not run a destructive port-recovery procedure for a storage + // migration failure, invalid configuration or any other boot error. + return { + kind: 'unreachable', + reason: `Failed to start local core: ${startError}`, + portConflict: false, + }; + } } // If start failed, attempt port-conflict auto-recovery before giving up. - if (startFailed && transport.recoverPortConflict) { + if (startError !== null && transport.recoverPortConflict && !isPortReboundFailure(startError)) { log('[boot-check] start_core_process failed — attempting port-conflict auto-recovery'); try { const recovery = await transport.recoverPortConflict(); @@ -261,8 +291,12 @@ export async function runBootCheck( portConflict: true, }; } - } else if (startFailed) { - return { kind: 'unreachable', reason: `Failed to start local core` }; + } else if (startError !== null) { + return { + kind: 'unreachable', + reason: `Failed to start local core: ${startError}`, + portConflict: true, + }; } // Wait for the embedded core to be reachable. @@ -277,7 +311,7 @@ export async function runBootCheck( return { kind: 'unreachable', reason: 'Local core did not respond in time', - portConflict: startFailed, + portConflict: false, }; } diff --git a/crates/openhuman-app/src/core_process.rs b/crates/openhuman-app/src/core_process.rs index b253fef4ad2..3a3572a8ba6 100644 --- a/crates/openhuman-app/src/core_process.rs +++ b/crates/openhuman-app/src/core_process.rs @@ -401,7 +401,7 @@ impl CoreProcessHandle { } } Err(format!( - "in-process core server exited before becoming ready: {err}" + "in-process core server exited before becoming ready: {err:#}" )) } Err(err) => Err(format!(