fix(tauri): own reset_local_data lifecycle in shell (OPENHUMAN-TAURI-AF) (#1769)

This commit is contained in:
CodeGhost21
2026-05-15 19:48:03 -07:00
committed by GitHub
parent ccedd7784c
commit 574d40a40e
5 changed files with 354 additions and 11 deletions
+219
View File
@@ -266,6 +266,224 @@ async fn start_core_process(
state.inner().ensure_running().await
}
/// Reset the user's local OpenHuman data and bounce the embedded core.
///
/// Replaces the prior two-step UI flow that called the core JSON-RPC
/// `openhuman.config_reset_local_data` (in-process removal) followed by
/// `restart_core_process`. The in-process removal failed on Windows with
/// `ERROR_SHARING_VIOLATION` (os error 32) because the running core held
/// open handles to SQLite databases, log files, the Sentry session store,
/// etc. inside the directory it was being asked to delete — see
/// OPENHUMAN-TAURI-AF.
///
/// New order:
///
/// 1. Query the core for the **paths** it would remove (`config_get_data_paths`)
/// while the core is still up — these are derived from the loaded config
/// and the active workspace marker, so the core is authoritative.
/// 2. Acquire the restart lock so a concurrent `restart_core_process` cannot
/// interleave with the remove.
/// 3. Shut down the embedded core. `CoreProcessHandle::shutdown` cancels
/// the cancellation token and awaits the tokio task, which drops the
/// SQLite pool, log writer, etc. — releasing every Windows file handle.
/// 4. Remove the three paths (current data dir, default data dir, active
/// workspace marker) from this process. Missing entries are non-fatal.
/// 5. Restart the embedded core via `ensure_running`.
///
/// Returns `Ok(())` only when the core is back up and the directories are
/// gone (or were already absent). Any step's `Err` short-circuits and
/// surfaces to the UI, which already renders the message as a toast.
#[tauri::command]
async fn reset_local_data(
state: tauri::State<'_, core_process::CoreProcessHandle>,
) -> Result<(), String> {
log::info!("[core] reset_local_data: command invoked from frontend");
// ── 1. Ask the core for the paths it would remove ────────────────────
//
// The core is authoritative for path resolution (it owns config
// loading, the workspace marker, and the staging-vs-prod default-dir
// suffix). Resolve while the core is still up so we don't duplicate
// that logic here.
let paths = fetch_data_paths().await?;
log::info!(
"[core] reset_local_data: paths resolved current={} default={} marker={}",
paths.current_openhuman_dir.display(),
paths.default_openhuman_dir.display(),
paths.active_workspace_marker_path.display()
);
// ── 2. Acquire the restart lock ─────────────────────────────────────
//
// Prevents a concurrent `restart_core_process` from re-spawning the
// embedded server in the middle of the remove step.
let _guard = state.inner().restart_lock().await;
log::debug!("[core] reset_local_data: acquired restart lock");
// ── 3. Shut down the embedded core ──────────────────────────────────
//
// Drops the tokio task, which drops the SQLite pool, log writer, and
// every other RAII owner of a file handle inside the data directory.
// On Windows this is the load-bearing step for OPENHUMAN-TAURI-AF.
state.inner().shutdown().await;
log::info!("[core] reset_local_data: embedded core stopped");
// ── 4. Remove the paths ─────────────────────────────────────────────
//
// Missing entries are non-fatal: the user may already have manually
// cleared the dir, or the marker may not exist for fresh installs.
//
// Capture the first delete error (if any) instead of propagating with
// `?` — we must still restart the embedded core in step 5 so the app
// doesn't end up with the sidecar dead. The original delete error is
// surfaced after the restart attempt.
let delete_result: Result<(), String> = async {
remove_path_if_exists(
&paths.active_workspace_marker_path,
"active workspace marker",
)
.await?;
remove_dir_if_exists(&paths.current_openhuman_dir, "current openhuman dir").await?;
if paths.default_openhuman_dir != paths.current_openhuman_dir {
remove_dir_if_exists(&paths.default_openhuman_dir, "default openhuman dir").await?;
} else {
log::debug!(
"[core] reset_local_data: default dir == current dir; already removed above"
);
}
Ok(())
}
.await;
if let Err(ref e) = delete_result {
log::warn!("[core] reset_local_data: delete step failed: {e}; will still restart core");
}
// ── 5. Restart the embedded core ────────────────────────────────────
//
// Always attempt restart, even if delete failed — otherwise the user
// is left with a dead sidecar. If restart itself fails, prefer the
// original delete error (more actionable) over the restart error.
let restart_result = state.inner().ensure_running().await;
match (&delete_result, &restart_result) {
(Ok(()), Ok(())) => log::info!("[core] reset_local_data: embedded core back up"),
(Err(_), Ok(())) => log::warn!(
"[core] reset_local_data: core restarted but delete step failed; surfacing delete error"
),
(Ok(()), Err(e)) => log::error!("[core] reset_local_data: core restart failed: {e}"),
(Err(_), Err(e)) => log::error!(
"[core] reset_local_data: both delete and restart failed; restart error: {e}"
),
}
delete_result?;
restart_result?;
Ok(())
}
/// Resolved data paths returned by `config_get_data_paths`.
struct ResolvedDataPaths {
current_openhuman_dir: std::path::PathBuf,
default_openhuman_dir: std::path::PathBuf,
active_workspace_marker_path: std::path::PathBuf,
}
/// Call the core's `config_get_data_paths` RPC and parse the response.
async fn fetch_data_paths() -> Result<ResolvedDataPaths, String> {
let url = crate::core_rpc::core_rpc_url_value();
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "openhuman.config_get_data_paths",
"params": {}
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("config_get_data_paths client build failed: {e}"))?;
let req = crate::core_rpc::apply_auth(client.post(&url))?;
let res = req
.json(&body)
.send()
.await
.map_err(|e| format!("config_get_data_paths request failed: {e}"))?;
if !res.status().is_success() {
return Err(format!("config_get_data_paths http {}", res.status()));
}
let envelope: serde_json::Value = res
.json()
.await
.map_err(|e| format!("config_get_data_paths decode failed: {e}"))?;
// JSON-RPC envelope wraps the `RpcOutcome` result twice:
// `{ "result": { "result": { ...paths... }, "logs": [...] } }`.
let inner = envelope
.pointer("/result/result")
.ok_or_else(|| "config_get_data_paths missing /result/result".to_string())?;
let current = inner
.get("current_openhuman_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| "config_get_data_paths missing current_openhuman_dir".to_string())?;
let default = inner
.get("default_openhuman_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| "config_get_data_paths missing default_openhuman_dir".to_string())?;
let marker = inner
.get("active_workspace_marker_path")
.and_then(|v| v.as_str())
.ok_or_else(|| "config_get_data_paths missing active_workspace_marker_path".to_string())?;
Ok(ResolvedDataPaths {
current_openhuman_dir: std::path::PathBuf::from(current),
default_openhuman_dir: std::path::PathBuf::from(default),
active_workspace_marker_path: std::path::PathBuf::from(marker),
})
}
/// Remove a regular file if present. Missing → debug log + Ok.
async fn remove_path_if_exists(path: &std::path::Path, label: &str) -> Result<(), String> {
match tokio::fs::remove_file(path).await {
Ok(()) => {
log::info!(
"[core] reset_local_data: removed {label} at {}",
path.display()
);
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
log::debug!(
"[core] reset_local_data: {label} already absent at {}",
path.display()
);
Ok(())
}
Err(e) => Err(format!(
"Failed to remove {label} at {}: {e}",
path.display()
)),
}
}
/// Remove a directory tree if present. Missing → debug log + Ok.
async fn remove_dir_if_exists(path: &std::path::Path, label: &str) -> Result<(), String> {
match tokio::fs::remove_dir_all(path).await {
Ok(()) => {
log::info!(
"[core] reset_local_data: removed {label} at {}",
path.display()
);
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
log::debug!(
"[core] reset_local_data: {label} already absent at {}",
path.display()
);
Ok(())
}
Err(e) => Err(format!(
"Failed to remove {label} at {}: {e}",
path.display()
)),
}
}
/// Cleanly exit the application.
///
/// Called by the BootCheckGate "Quit" button when the core is unreachable and
@@ -2487,6 +2705,7 @@ pub fn run() {
install_app_update,
restart_core_process,
start_core_process,
reset_local_data,
app_quit,
restart_app,
get_active_user_id,
+46 -3
View File
@@ -1,11 +1,13 @@
import { invoke, isTauri } from '@tauri-apps/api/core';
import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
import { callCoreRpc } from '../../services/coreRpcClient';
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn() }));
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
type TauriInternalsHolder = { __TAURI_INTERNALS__?: { invoke: unknown } };
describe('tauriCommands', () => {
const mockIsTauri = isTauri as Mock;
const mockInvoke = invoke as Mock;
@@ -15,10 +17,20 @@ describe('tauriCommands', () => {
let storeSession: typeof import('../tauriCommands').storeSession;
let openhumanLocalAiStatus: typeof import('../tauriCommands').openhumanLocalAiStatus;
let openhumanServiceStatus: typeof import('../tauriCommands').openhumanServiceStatus;
let prevInternals: TauriInternalsHolder['__TAURI_INTERNALS__'];
beforeEach(async () => {
vi.clearAllMocks();
mockIsTauri.mockReturnValue(true);
// The local `isTauri()` wrapper in `tauriCommands/common.ts` ALSO checks
// `window.__TAURI_INTERNALS__.invoke` to detect the CEF bootstrap gap
// (see OPENHUMAN-REACT-S). Mocking only the upstream `coreIsTauri`
// isn't enough — the wrapper would still return false in tests and
// every helper would hit its `if (!isTauri()) return;` early-exit.
// Stub a minimal internals shape so the wrapper resolves to true.
const holder = window as unknown as TauriInternalsHolder;
prevInternals = holder.__TAURI_INTERNALS__;
holder.__TAURI_INTERNALS__ = { invoke: () => undefined };
const actual = await vi.importActual<typeof import('../tauriCommands')>('../tauriCommands');
getAuthState = actual.getAuthState;
resetOpenHumanDataAndRestartCore = actual.resetOpenHumanDataAndRestartCore;
@@ -27,6 +39,15 @@ describe('tauriCommands', () => {
openhumanServiceStatus = actual.openhumanServiceStatus;
});
afterEach(() => {
const holder = window as unknown as TauriInternalsHolder;
if (prevInternals === undefined) {
delete holder.__TAURI_INTERNALS__;
} else {
holder.__TAURI_INTERNALS__ = prevInternals;
}
});
test('getAuthState maps result shape from core response', async () => {
mockCallCoreRpc.mockResolvedValueOnce({
result: { isAuthenticated: true, user: { id: 'u1' } },
@@ -50,8 +71,30 @@ describe('tauriCommands', () => {
test('resetOpenHumanDataAndRestartCore invokes the destructive Tauri command', async () => {
await resetOpenHumanDataAndRestartCore();
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.config_reset_local_data' });
expect(mockInvoke).toHaveBeenCalledWith('restart_core_process');
// The helper used to call `openhuman.config_reset_local_data` over
// JSON-RPC followed by `restart_core_process`, but the in-process
// remove failed on Windows when the running core held open handles
// inside the data directory (OPENHUMAN-TAURI-AF). The Tauri shell
// now owns the full sequence (stop core → remove paths → restart
// core) behind a single `reset_local_data` command, so no core RPC
// call should reach `callCoreRpc` from this helper.
expect(mockCallCoreRpc).not.toHaveBeenCalled();
expect(mockInvoke).toHaveBeenCalledWith('reset_local_data');
});
test('resetOpenHumanDataAndRestartCore surfaces invoke failures to the caller', async () => {
// Callers (e.g. `clearAllAppData`) treat a thrown error as unrecoverable
// and abort the flow — so the helper must rethrow instead of swallowing
// a `reset_local_data` failure (e.g. Windows `ERROR_SHARING_VIOLATION`
// when a handle outside the embedded core still holds a path).
const boom = new Error('reset_local_data failed');
mockInvoke.mockRejectedValueOnce(boom);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(resetOpenHumanDataAndRestartCore()).rejects.toBe(boom);
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
test('openhumanLocalAiStatus returns upgrade hint on unknown method', async () => {
+15 -8
View File
@@ -261,14 +261,21 @@ export async function resetOpenHumanDataAndRestartCore(): Promise<void> {
console.debug('[core] resetOpenHumanDataAndRestartCore: skipped — not running in Tauri');
return;
}
console.debug(
'[core] resetOpenHumanDataAndRestartCore: invoking openhuman.config_reset_local_data'
);
await callCoreRpc({ method: 'openhuman.config_reset_local_data' });
console.debug(
'[core] resetOpenHumanDataAndRestartCore: local data reset complete, restarting core'
);
await restartCoreProcess();
// Single Tauri command: the shell stops the embedded core (dropping
// every open file handle inside the data directory), removes the
// resolved data paths, then restarts the core. Previously this was a
// two-step `callCoreRpc('config_reset_local_data') + restartCoreProcess()`
// dance, but the core RPC ran the remove *inside* the running core's
// tokio task — on Windows that hit `ERROR_SHARING_VIOLATION` (os error
// 32) because the core still held SQLite / log / Sentry handles open in
// the directory it was trying to delete (OPENHUMAN-TAURI-AF).
console.debug('[core] resetOpenHumanDataAndRestartCore: invoking reset_local_data');
try {
await invoke<void>('reset_local_data');
} catch (err) {
console.error('[core] resetOpenHumanDataAndRestartCore: reset_local_data failed', err);
throw err;
}
console.debug('[core] resetOpenHumanDataAndRestartCore: done');
}
+42
View File
@@ -1088,6 +1088,19 @@ pub fn agent_server_status() -> RpcOutcome<serde_json::Value> {
}
/// Deletes all local data directories and workspace markers.
///
/// Runs **inside the core's tokio task**, which means the running core
/// holds open handles to SQLite databases, log files, the Sentry session
/// store, etc. On Windows, `remove_dir_all` therefore fails with
/// `ERROR_SHARING_VIOLATION` (os error 32) — see OPENHUMAN-TAURI-AF.
///
/// GUI callers must use the Tauri-side `reset_local_data` command instead:
/// it stops the embedded core via `CoreProcessHandle::shutdown` (dropping
/// the file handles), removes the directories from the Tauri host process,
/// and restarts the core. This JSON-RPC method is kept for headless / CLI
/// callers where in-process removal is acceptable (POSIX file semantics
/// tolerate unlinking open files; on Windows the CLI invocation runs
/// without the core attached, so no handle is in the way).
pub async fn reset_local_data() -> Result<RpcOutcome<serde_json::Value>, String> {
let config = load_config_with_timeout().await?;
let current_openhuman_dir = config_openhuman_dir(&config);
@@ -1095,6 +1108,35 @@ pub async fn reset_local_data() -> Result<RpcOutcome<serde_json::Value>, String>
reset_local_data_for_paths(&current_openhuman_dir, &default_openhuman_dir).await
}
/// Reports the resolved paths that `reset_local_data` would remove, without
/// performing any filesystem changes.
///
/// Lets the Tauri-side `reset_local_data` command discover the active
/// workspace dir, the default `~/.openhuman` dir (which can differ when
/// `OPENHUMAN_WORKSPACE` is set or a staging build is in use), and the
/// active workspace marker file **before** the core sidecar is shut down —
/// after which the Tauri shell removes them while no process holds open
/// handles. See OPENHUMAN-TAURI-AF for the Windows file-locking failure
/// that motivated the split.
pub async fn get_data_paths() -> Result<RpcOutcome<serde_json::Value>, String> {
let config = load_config_with_timeout().await?;
let current_openhuman_dir = config_openhuman_dir(&config);
let default_openhuman_dir = default_openhuman_dir();
let active_workspace_marker = active_workspace_marker_path(&default_openhuman_dir);
Ok(RpcOutcome::new(
json!({
"current_openhuman_dir": current_openhuman_dir.display().to_string(),
"default_openhuman_dir": default_openhuman_dir.display().to_string(),
"active_workspace_marker_path": active_workspace_marker.display().to_string(),
}),
vec![format!(
"data paths resolved (current={}, default={})",
current_openhuman_dir.display(),
default_openhuman_dir.display()
)],
))
}
#[cfg(test)]
#[path = "ops_tests.rs"]
mod tests;
+32
View File
@@ -196,6 +196,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("get_meet_settings"),
schemas("agent_server_status"),
schemas("reset_local_data"),
schemas("get_data_paths"),
schemas("get_onboarding_completed"),
schemas("set_onboarding_completed"),
schemas("get_dictation_settings"),
@@ -285,6 +286,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("reset_local_data"),
handler: handle_reset_local_data,
},
RegisteredController {
schema: schemas("get_data_paths"),
handler: handle_get_data_paths,
},
RegisteredController {
schema: schemas("get_onboarding_completed"),
handler: handle_get_onboarding_completed,
@@ -689,6 +694,17 @@ pub fn schemas(function: &str) -> ControllerSchema {
inputs: vec![],
outputs: vec![json_output("result", "Reset result with removed paths.")],
},
"get_data_paths" => ControllerSchema {
namespace: "config",
function: "get_data_paths",
description:
"Resolve the OpenHuman data directories (current workspace, default ~/.openhuman, active workspace marker) that reset_local_data would remove. Read-only — performs no filesystem changes.",
inputs: vec![],
outputs: vec![json_output(
"paths",
"Resolved data paths: current_openhuman_dir, default_openhuman_dir, active_workspace_marker_path.",
)],
},
"get_onboarding_completed" => ControllerSchema {
namespace: "config",
function: "get_onboarding_completed",
@@ -1184,6 +1200,22 @@ fn handle_reset_local_data(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async { to_json(config_rpc::reset_local_data().await?) })
}
fn handle_get_data_paths(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async {
log::debug!("[config][rpc] get_data_paths enter");
match config_rpc::get_data_paths().await {
Ok(outcome) => {
log::debug!("[config][rpc] get_data_paths ok");
to_json(outcome)
}
Err(err) => {
log::warn!("[config][rpc] get_data_paths fail: {err}");
Err(err)
}
}
})
}
fn handle_get_onboarding_completed(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async { to_json(config_rpc::get_onboarding_completed().await?) })
}