From 4a3c5a2f12dc76d8a60dc1e984c3c83b1dc5ff1d Mon Sep 17 00:00:00 2001 From: Farnam Date: Wed, 28 Oct 2026 11:49:13 +0330 Subject: [PATCH 01/10] test(boot): reproduce non-port startup errors entering port-recovery path --- app/src/lib/bootCheck/index.test.ts | 44 +++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/app/src/lib/bootCheck/index.test.ts b/app/src/lib/bootCheck/index.test.ts index b4c18e54b47..90757f40e10 100644 --- a/app/src/lib/bootCheck/index.test.ts +++ b/app/src/lib/bootCheck/index.test.ts @@ -224,6 +224,50 @@ 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('auto-recovery succeeds: start fails, recovery succeeds, second start succeeds', async () => { const appVersion = (await import('../../utils/config')).APP_VERSION; From 882e950df06cc4dd432bc09ed379faecec2edef3 Mon Sep 17 00:00:00 2001 From: Farnam Date: Wed, 28 Oct 2026 11:49:16 +0330 Subject: [PATCH 02/10] fix(boot): attempt port recovery only for actual listener bind failures --- app/src/lib/bootCheck/index.ts | 36 ++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/app/src/lib/bootCheck/index.ts b/app/src/lib/bootCheck/index.ts index ee4cf82da86..ad047cd530b 100644 --- a/app/src/lib/bootCheck/index.ts +++ b/app/src/lib/bootCheck/index.ts @@ -187,6 +187,17 @@ 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) { log('[boot-check] start_core_process failed — attempting port-conflict auto-recovery'); try { const recovery = await transport.recoverPortConflict(); @@ -261,8 +281,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 +301,7 @@ export async function runBootCheck( return { kind: 'unreachable', reason: 'Local core did not respond in time', - portConflict: startFailed, + portConflict: false, }; } From ae5abed7f8dd192ba3bb36ae1b9bc1c0ee18bbaf Mon Sep 17 00:00:00 2001 From: Farnam Date: Wed, 28 Oct 2026 12:12:40 +0330 Subject: [PATCH 03/10] style(boot): apply canonical Prettier layout for startup classification --- app/src/lib/bootCheck/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/lib/bootCheck/index.ts b/app/src/lib/bootCheck/index.ts index ad047cd530b..eb1158ae1c7 100644 --- a/app/src/lib/bootCheck/index.ts +++ b/app/src/lib/bootCheck/index.ts @@ -193,8 +193,12 @@ async function checkVersion(callRpc: BootCheckTransport['callRpc']): Promise Date: Wed, 28 Oct 2026 12:12:44 +0330 Subject: [PATCH 04/10] style(boot): apply canonical Prettier layout for startup classification --- app/src/lib/bootCheck/index.test.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/app/src/lib/bootCheck/index.test.ts b/app/src/lib/bootCheck/index.test.ts index 90757f40e10..2111ff50ba5 100644 --- a/app/src/lib/bootCheck/index.test.ts +++ b/app/src/lib/bootCheck/index.test.ts @@ -228,23 +228,22 @@ describe('runBootCheck — port conflict auto-recovery', () => { 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' - ) - ), + 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, - }); + expect(result).toMatchObject({ kind: 'unreachable', portConflict: false }); if (result.kind === 'unreachable') { expect(result.reason).toContain('legacy sqlite storage must be upgraded by the host'); } From dedf3453c1a1621c2eb24d874690ff96155a6469 Mon Sep 17 00:00:00 2001 From: Farnam Date: Wed, 28 Oct 2026 13:47:53 +0330 Subject: [PATCH 05/10] test(boot): stale-listener takeover failures must stay in port recovery --- app/src/lib/bootCheck/index.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app/src/lib/bootCheck/index.test.ts b/app/src/lib/bootCheck/index.test.ts index 2111ff50ba5..1646634c1ee 100644 --- a/app/src/lib/bootCheck/index.test.ts +++ b/app/src/lib/bootCheck/index.test.ts @@ -267,6 +267,32 @@ describe('runBootCheck — port conflict auto-recovery', () => { }); }); + it.each([ + 'failed to signal stale openhuman pid 123: permission denied', + 'failed to force-kill stale openhuman pid 123: permission denied', + 'port 7788 rebounded to pid 456 after terminating pid 123; refusing to kill a different process', + ])('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('auto-recovery succeeds: start fails, recovery succeeds, second start succeeds', async () => { const appVersion = (await import('../../utils/config')).APP_VERSION; From 8baf0752eb4a474c67148284a253ad370dead419 Mon Sep 17 00:00:00 2001 From: Farnam Date: Wed, 28 Oct 2026 13:48:04 +0330 Subject: [PATCH 06/10] fix(boot): classify confirmed stale-listener takeover failures as port conflicts --- app/src/lib/bootCheck/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/lib/bootCheck/index.ts b/app/src/lib/bootCheck/index.ts index eb1158ae1c7..035c9f024b7 100644 --- a/app/src/lib/bootCheck/index.ts +++ b/app/src/lib/bootCheck/index.ts @@ -196,7 +196,7 @@ function isPortBindingFailure(error: string): boolean { /\bport\s+\d+\s+(?:is\s+)?(?:occupied|busy|in use|remained bound|did not become free)\b/i.test( error ) || - /\b(?:no fallback ports available|no available port|port excluded by os|stale-listener pid|failed to bind core listener|could not determine pid bound to port)\b/i.test( + /\b(?:no fallback ports available|no available port|port excluded by os|stale-listener pid|failed to signal stale openhuman pid|failed to force-kill stale openhuman pid|failed to bind core listener|could not determine pid bound to port|port \\d+ rebounded to pid)\b/i.test( error ) ); From 5ad7c7426e1ee38a3b45d22edfa87551a5c69342 Mon Sep 17 00:00:00 2001 From: Farnam Date: Wed, 28 Oct 2026 13:48:16 +0330 Subject: [PATCH 07/10] fix(boot): use numeric port pattern in stale-listener classifier --- app/src/lib/bootCheck/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/lib/bootCheck/index.ts b/app/src/lib/bootCheck/index.ts index 035c9f024b7..e1edca5e972 100644 --- a/app/src/lib/bootCheck/index.ts +++ b/app/src/lib/bootCheck/index.ts @@ -196,7 +196,7 @@ function isPortBindingFailure(error: string): boolean { /\bport\s+\d+\s+(?:is\s+)?(?:occupied|busy|in use|remained bound|did not become free)\b/i.test( error ) || - /\b(?:no fallback ports available|no available port|port excluded by os|stale-listener pid|failed to signal stale openhuman pid|failed to force-kill stale openhuman pid|failed to bind core listener|could not determine pid bound to port|port \\d+ rebounded to pid)\b/i.test( + /\b(?:no fallback ports available|no available port|port excluded by os|stale-listener pid|failed to signal stale openhuman pid|failed to force-kill stale openhuman pid|failed to bind core listener|could not determine pid bound to port|port \d+ rebounded to pid)\b/i.test( error ) ); From ac7f04dd19fcaab0ee5f870d6eef978fc720f3f1 Mon Sep 17 00:00:00 2001 From: Farnam Date: Wed, 28 Oct 2026 13:48:27 +0330 Subject: [PATCH 08/10] fix(boot): preserve native core startup error chain for actionable diagnostics --- crates/openhuman-app/src/core_process.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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!( From 0e17dbea9faf574efd527f020cf86b95943fd47b Mon Sep 17 00:00:00 2001 From: Farnam Date: Wed, 28 Oct 2026 14:06:13 +0330 Subject: [PATCH 09/10] test(boot): never broadly reap after the port gains a different owner --- app/src/lib/bootCheck/index.test.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/app/src/lib/bootCheck/index.test.ts b/app/src/lib/bootCheck/index.test.ts index 1646634c1ee..39dc87b0a4e 100644 --- a/app/src/lib/bootCheck/index.test.ts +++ b/app/src/lib/bootCheck/index.test.ts @@ -270,7 +270,6 @@ describe('runBootCheck — port conflict auto-recovery', () => { it.each([ 'failed to signal stale openhuman pid 123: permission denied', 'failed to force-kill stale openhuman pid 123: permission denied', - 'port 7788 rebounded to pid 456 after terminating pid 123; refusing to kill a different process', ])('keeps stale-listener recovery available after %s', async diagnostic => { const recoverPortConflict = vi.fn().mockResolvedValue({ success: false, @@ -293,6 +292,26 @@ describe('runBootCheck — port conflict auto-recovery', () => { }); }); + 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; From 9c115f8264845f8cb86cadeece0ec6d69778789d Mon Sep 17 00:00:00 2001 From: Farnam Date: Wed, 28 Oct 2026 14:06:28 +0330 Subject: [PATCH 10/10] fix(boot): report rebound ownership without invoking broad process reaper --- app/src/lib/bootCheck/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/lib/bootCheck/index.ts b/app/src/lib/bootCheck/index.ts index e1edca5e972..4bf4c31be09 100644 --- a/app/src/lib/bootCheck/index.ts +++ b/app/src/lib/bootCheck/index.ts @@ -202,6 +202,12 @@ function isPortBindingFailure(error: string): boolean { ); } +/** A rebound proves a different PID now owns the port; never invoke the + * legacy broad process reaper in this case. Preserve the diagnostic for the UI. */ +function isPortReboundFailure(error: string): boolean { + return /\bport\s+\d+\s+rebounded\s+to\s+pid\b/i.test(error); +} + // --------------------------------------------------------------------------- // Main entry point // --------------------------------------------------------------------------- @@ -256,7 +262,7 @@ export async function runBootCheck( } // If start failed, attempt port-conflict auto-recovery before giving up. - if (startError !== null && 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();