fix(boot): unblock cold-boot core start on dev hosts (#1324)

This commit is contained in:
Steven Enamakel
2026-05-07 00:04:14 -07:00
committed by GitHub
parent a6c7b0f874
commit 22946a19db
6 changed files with 175 additions and 30 deletions
@@ -7,6 +7,11 @@ allow = [
"core_rpc_url",
"core_rpc_token",
"restart_core_process",
# `start_core_process` is invoked by BootCheckGate after the user picks
# Local mode, before redux-persist hydrates the rest of the app (#1316).
# Without this allow entry the invoke is rejected with "Command not
# found" and the boot gate stalls.
"start_core_process",
# `restart_app` triggers `app.restart()` so CEF re-initializes against
# the active user's `users/<id>/cef` profile after an identity flip
# (#900). Without this allow entry, the invoke is silently denied by
+21
View File
@@ -104,6 +104,27 @@ impl CoreProcessHandle {
}
pub async fn ensure_running(&self) -> Result<(), String> {
// Idempotent fast path: if we already spawned the embedded server in
// *this* process and it's still alive on the port, the listener is
// us — return Ok without identifying or taking over. Without this,
// a second `start_core_process` call (e.g. HMR re-mounting the boot
// gate) sees its own port as bound, classifies the listener as
// "stale OpenHuman", and walks into the SIGTERM/SIGKILL takeover
// path against itself. (#1130 takeover is meant to recover from
// *external* leftover binaries, not our own in-process spawn.)
{
let guard = self.task.lock().await;
if let Some(task) = guard.as_ref() {
if !task.is_finished() && self.is_rpc_port_open().await {
log::debug!(
"[core] ensure_running: embedded task already running on port {} — no-op",
self.port
);
return Ok(());
}
}
}
if self.is_rpc_port_open().await {
if reuse_existing_listener_enabled() {
log::warn!(
+74
View File
@@ -1354,6 +1354,80 @@ pub fn run() {
return Err("webview_apis bridge failed to start — aborting setup".into());
}
// Purge stray LaunchAgent left over from a prior worktree's
// `service install`. KeepAlive=true on the plist re-spawns the
// daemon after every SIGKILL, fighting `ensure_running`'s
// stale-listener takeover and re-binding port 7788 on cold boot.
// (Symptom: "Failed to start local core: signaled pid <X> but
// port 7788 remained bound after 5000ms".)
//
// Tightly scoped to avoid clobbering a legitimate `service
// install`:
// - dev builds only (`cfg!(debug_assertions)`)
// - skip when this process IS the daemon (`!daemon_mode`)
// - only purge when the plist's ProgramArguments[0] points
// somewhere other than the currently-running executable —
// i.e. a sibling worktree's stale binary, not us.
#[cfg(target_os = "macos")]
if cfg!(debug_assertions) && !daemon_mode {
const STALE_LABEL: &str = "com.openhuman.core";
if let Ok(home) = std::env::var("HOME") {
let plist = std::path::PathBuf::from(&home)
.join("Library")
.join("LaunchAgents")
.join(format!("{STALE_LABEL}.plist"));
let plist_targets_us = std::fs::read_to_string(&plist)
.ok()
.and_then(|contents| {
// ProgramArguments[0] is the first <string>...</string>
// after the <key>ProgramArguments</key> marker. The
// service installer always writes it as an absolute
// path to the openhuman-core binary (see
// src/openhuman/service/macos.rs).
let after_key = contents.split("<key>ProgramArguments</key>").nth(1)?;
let start = after_key.find("<string>")? + "<string>".len();
let rest = &after_key[start..];
let end = rest.find("</string>")?;
Some(std::path::PathBuf::from(rest[..end].trim()))
})
.zip(std::env::current_exe().ok())
.map(|(plist_bin, self_bin)| plist_bin == self_bin)
.unwrap_or(false);
if plist.exists() && !plist_targets_us {
let uid = std::process::Command::new("id")
.arg("-u")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string());
if let Some(uid) = uid {
let target = format!("gui/{uid}/{STALE_LABEL}");
let _ = std::process::Command::new("launchctl")
.arg("bootout")
.arg(&target)
.status();
}
match std::fs::remove_file(&plist) {
Ok(()) => log::warn!(
"[boot] removed stale LaunchAgent plist at {} \
(points at a different binary than this build — \
likely a sibling worktree's `service install`)",
plist.display()
),
Err(err) => log::warn!(
"[boot] failed to remove stale LaunchAgent plist {}: {err}",
plist.display()
),
}
}
}
}
let core_handle =
core_process::CoreProcessHandle::new(core_process::default_core_port());
std::env::set_var("OPENHUMAN_CORE_RPC_URL", core_handle.rpc_url());
+18 -22
View File
@@ -42,9 +42,9 @@ describe('runBootCheck — local mode', () => {
const transport = makeTransport({
callRpc: rpcResponder({
'openhuman.ping': {},
'core.ping': {},
'openhuman.service_status': { installed: false, running: false },
'openhuman.update_version': { version_info: { version: appVersion } },
'openhuman.update_version': { result: { version: appVersion } },
}),
});
@@ -57,9 +57,9 @@ describe('runBootCheck — local mode', () => {
const transport = makeTransport({
callRpc: rpcResponder({
'openhuman.ping': {},
'core.ping': {},
'openhuman.service_status': { installed: true, running: false },
'openhuman.update_version': { version_info: { version: appVersion } },
'openhuman.update_version': { result: { version: appVersion } },
}),
});
@@ -70,9 +70,9 @@ describe('runBootCheck — local mode', () => {
it('returns daemonDetected when service_status shows running=true', async () => {
const transport = makeTransport({
callRpc: rpcResponder({
'openhuman.ping': {},
'core.ping': {},
'openhuman.service_status': { installed: false, running: true },
'openhuman.update_version': { version_info: { version: 'x' } },
'openhuman.update_version': { result: { version: 'x' } },
}),
});
@@ -83,9 +83,9 @@ describe('runBootCheck — local mode', () => {
it('returns outdatedLocal when core version differs from app version', async () => {
const transport = makeTransport({
callRpc: rpcResponder({
'openhuman.ping': {},
'core.ping': {},
'openhuman.service_status': { installed: false, running: false },
'openhuman.update_version': { version_info: { version: '0.0.0-different' } },
'openhuman.update_version': { result: { version: '0.0.0-different' } },
}),
});
@@ -96,7 +96,7 @@ describe('runBootCheck — local mode', () => {
it('returns noVersionMethod when update_version returns -32601', async () => {
const transport = makeTransport({
callRpc: rpcResponder({
'openhuman.ping': {},
'core.ping': {},
'openhuman.service_status': { installed: false, running: false },
'openhuman.update_version': new Error('JSON-RPC error -32601 Method not found'),
}),
@@ -109,7 +109,7 @@ describe('runBootCheck — local mode', () => {
it('returns noVersionMethod on "method not found" text variant', async () => {
const transport = makeTransport({
callRpc: rpcResponder({
'openhuman.ping': {},
'core.ping': {},
'openhuman.service_status': { installed: false, running: false },
'openhuman.update_version': new Error('method not found'),
}),
@@ -155,9 +155,7 @@ describe('runBootCheck — cloud mode', () => {
const appVersion = (await import('../../utils/config')).APP_VERSION;
const transport = makeTransport({
callRpc: rpcResponder({
'openhuman.update_version': { version_info: { version: appVersion } },
}),
callRpc: rpcResponder({ 'openhuman.update_version': { result: { version: appVersion } } }),
});
const result = await runBootCheck(
@@ -169,9 +167,7 @@ describe('runBootCheck — cloud mode', () => {
it('returns outdatedCloud when version differs', async () => {
const transport = makeTransport({
callRpc: rpcResponder({
'openhuman.update_version': { version_info: { version: '0.0.0-old' } },
}),
callRpc: rpcResponder({ 'openhuman.update_version': { result: { version: '0.0.0-old' } } }),
});
const result = await runBootCheck(
@@ -228,9 +224,9 @@ describe('runBootCheck — error and edge branches', () => {
const transport = makeTransport({
callRpc: rpcResponder({
'openhuman.ping': {},
'core.ping': {},
'openhuman.service_status': new Error('rpc transport blew up'),
'openhuman.update_version': { version_info: { version: appVersion } },
'openhuman.update_version': { result: { version: appVersion } },
}),
});
@@ -238,12 +234,12 @@ describe('runBootCheck — error and edge branches', () => {
expect(result.kind).toBe('match');
});
it('treats empty version_info.version as outdatedLocal', async () => {
it('treats empty version as outdatedLocal', async () => {
const transport = makeTransport({
callRpc: rpcResponder({
'openhuman.ping': {},
'core.ping': {},
'openhuman.service_status': { installed: false, running: false },
'openhuman.update_version': { version_info: { version: '' } },
'openhuman.update_version': { result: { version: '' } },
}),
});
@@ -268,7 +264,7 @@ describe('runBootCheck — error and edge branches', () => {
let pingCalls = 0;
const transport: BootCheckTransport = {
callRpc: vi.fn(async (method: string) => {
if (method === 'openhuman.ping') {
if (method === 'core.ping') {
pingCalls += 1;
if (pingCalls === 1) return {};
throw new Error('subsequent failure');
+17 -6
View File
@@ -64,8 +64,12 @@ function isMethodNotFound(err: unknown): boolean {
}
/**
* Poll `openhuman.ping` with exponential back-off until the core responds or
* we exhaust the budget.
* Poll `core.ping` with exponential back-off until the core responds or we
* exhaust the budget. `core.ping` is a Tier-1 dispatcher method (see
* `src/core/dispatch.rs`) that responds before any domain controller is
* registered, which is exactly what we want for a liveness probe — it tells
* us "the HTTP server is up and the dispatcher is wired" without coupling to
* any specific subsystem's readiness.
*
* Returns true when the core is reachable, false on timeout.
*/
@@ -79,7 +83,7 @@ async function waitForCore(
const elapsedAtStart = Date.now() - startedAt;
try {
log('[boot-check] ping attempt elapsed=%dms', elapsedAtStart);
await callRpc('openhuman.ping', {});
await callRpc('core.ping', {});
log('[boot-check] ping succeeded elapsed=%dms', elapsedAtStart);
return true;
} catch {
@@ -131,11 +135,18 @@ type VersionCheckResult = 'match' | 'outdated' | 'noVersionMethod' | 'unreachabl
async function checkVersion(callRpc: BootCheckTransport['callRpc']): Promise<VersionCheckResult> {
try {
const result = await callRpc<{ version_info?: { version?: string } }>(
// `openhuman.update_version` is wrapped by RpcOutcome::single_log
// (see src/openhuman/update/ops.rs + src/rpc/mod.rs::into_cli_compatible_json):
// when logs are present the response shape is `{ result: VersionInfo, logs }`,
// and VersionInfo is `{ version, target_triple, asset_prefix }`. Earlier
// attempts read `result.version_info.version` (no such field) and then
// `result.version` (skipped the RpcOutcome `result` wrapper) — both
// yielded '' and pinned every boot to "outdated local".
const response = await callRpc<{ result?: { version?: string } }>(
'openhuman.update_version',
{}
);
const coreVersion = result?.version_info?.version ?? '';
const coreVersion = response?.result?.version ?? '';
log('[boot-check] version_check app=%s core=%s', APP_VERSION, coreVersion);
if (!coreVersion) {
@@ -164,7 +175,7 @@ async function checkVersion(callRpc: BootCheckTransport['callRpc']): Promise<Ver
*
* Local mode:
* 1. Invoke `start_core_process` Tauri command to spawn the embedded core.
* 2. Poll `openhuman.ping` until reachable (≤10 s).
* 2. Poll `core.ping` until reachable (≤10 s).
* 3. Check for a legacy daemon via `service_status`.
* 4. Version-check via `update_version`.
*
+40 -2
View File
@@ -10,7 +10,6 @@ import {
REGISTER,
REHYDRATE,
} from 'redux-persist';
import defaultStorage from 'redux-persist/lib/storage';
import { IS_DEV } from '../utils/config';
import accountsReducer from './accountsSlice';
@@ -30,7 +29,46 @@ const storage = userScopedStorage;
// coreMode is pre-login and not user-scoped — use plain localStorage so the
// setting survives across user switches without leaking per-user state.
const coreModePersistConfig = { key: 'coreMode', storage: defaultStorage, whitelist: ['mode'] };
// Inline adapter rather than `redux-persist/lib/storage`'s default export,
// which Vite's CJS dep-pre-bundling can resolve to the module namespace
// (then `storage.getItem` is undefined and rehydrate throws on cold boot).
const localStorageAdapter = {
getItem: (key: string) =>
Promise.resolve(
(() => {
try {
return localStorage.getItem(key);
} catch {
return null;
}
})()
),
setItem: (key: string, value: string) =>
Promise.resolve(
(() => {
try {
localStorage.setItem(key, value);
} catch {
/* ignore quota / unavailable */
}
})()
),
removeItem: (key: string) =>
Promise.resolve(
(() => {
try {
localStorage.removeItem(key);
} catch {
/* ignore */
}
})()
),
};
const coreModePersistConfig = {
key: 'coreMode',
storage: localStorageAdapter,
whitelist: ['mode'],
};
const persistedCoreModeReducer = persistReducer(coreModePersistConfig, coreModeReducer);
const channelConnectionsPersistConfig = {