Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions app/src/lib/bootCheck/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
),
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;

Expand Down
46 changes: 40 additions & 6 deletions app/src/lib/bootCheck/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,27 @@ async function checkVersion(callRpc: BootCheckTransport['callRpc']): Promise<Ver
}
}

/** Only a confirmed bind/port failure warrants reaping or port recovery.
* Storage, config and runtime failures must retain their original diagnosis.
* An arbitrary startup failure is NOT evidence that another process owns a port. */
function isPortBindingFailure(error: string): boolean {
return (
/\b(?:eaddrinuse|addrinuse|wsaeaddrinuse|address already in use|port in use)\b/i.test(error) ||
/\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(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
error
)
);
}

/** 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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -222,17 +243,26 @@ export async function runBootCheck(
if (mode.kind === 'local') {
log('[boot-check] local mode — starting core process');

let startFailed = false;
let startError: string | null = null;
try {
await invokeCmd<void>('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();
Expand Down Expand Up @@ -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.
Expand All @@ -277,7 +311,7 @@ export async function runBootCheck(
return {
kind: 'unreachable',
reason: 'Local core did not respond in time',
portConflict: startFailed,
portConflict: false,
};
}

Expand Down
2 changes: 1 addition & 1 deletion crates/openhuman-app/src/core_process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
Loading