mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
fix(boot): unblock cold-boot core start on dev hosts (#1324)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user