diff --git a/src/core/observability.rs b/src/core/observability.rs index 05e2cf15e..cbfaca1d8 100644 --- a/src/core/observability.rs +++ b/src/core/observability.rs @@ -226,6 +226,29 @@ pub enum ExpectedErrorKind { /// `is_network_unreachable_message` anchors miss the inner OS message. ChannelSupervisorRestart, ConfigLoadTimedOut, + /// The subconscious engine's SQLite schema init couldn't open its database + /// file at all — a host-filesystem condition, not a code bug. Two canonical + /// renderings, both bound to the user's local FS: + /// + /// - `SQLITE_CANTOPEN` (14): `unable to open the database file` — the + /// `subconscious/` dir or DB file isn't writable/openable (permissions, + /// a vanished mount, a read-only volume). + /// - `SQLITE_IOERR_SHMMAP` (4618): `I/O error within the xShmMap method` — + /// the filesystem can't back WAL's mmap'd `-shm` segment (network mounts, + /// FUSE, some sandboxed/synced macOS paths). + /// + /// The `xShmMap` case is now *prevented* at the source by + /// `subconscious::store::apply_journal_mode`, which degrades WAL to a + /// rollback journal that needs no shared memory (issue #3231). This kind + /// demotes the *residual* genuine `CANTOPEN` failures — where even opening + /// the file fails — which the user must resolve locally (fix permissions, + /// remount, free the volume) and which Sentry has no remediation path for. + /// + /// Anchored to the subconscious schema/open envelope plus the SQLite + /// cant-open / shared-memory IO text, so transient `database is locked` + /// contention (handled by the store's busy-retry loop) and unrelated DB + /// failures in other domains still reach Sentry. + SubconsciousSchemaUnavailable, } pub fn expected_error_kind(message: &str) -> Option { @@ -344,6 +367,9 @@ pub fn expected_error_kind(message: &str) -> Option { if is_whatsapp_data_sqlite_busy_message(&lower) { return Some(ExpectedErrorKind::WhatsAppDataSqliteBusy); } + if is_subconscious_schema_unavailable_message(&lower) { + return Some(ExpectedErrorKind::SubconsciousSchemaUnavailable); + } if is_disk_full_message(&lower) { return Some(ExpectedErrorKind::DiskFull); } @@ -409,6 +435,26 @@ fn is_whatsapp_data_sqlite_busy_message(lower: &str) -> bool { || lower.contains("error code 5") } +/// Match subconscious-engine SQLite schema-init failures caused by the host +/// filesystem being unable to open the DB file (`SQLITE_CANTOPEN` / +/// `SQLITE_IOERR_SHMMAP`). Anchored to the subconscious open/DDL envelope so it +/// can't demote unrelated DB failures, and deliberately scoped to cant-open / +/// shared-memory IO text — *not* `database is locked`, which the store retries +/// and which (if persistent) is a real contention signal worth surfacing. +/// +/// See [`ExpectedErrorKind::SubconsciousSchemaUnavailable`]. +fn is_subconscious_schema_unavailable_message(lower: &str) -> bool { + let in_subconscious_envelope = lower.contains("subconscious schema ddl") + || lower.contains("failed to open subconscious db"); + if !in_subconscious_envelope { + return false; + } + lower.contains("unable to open the database file") + || lower.contains("xshmmap") + || lower.contains("error code 14") + || lower.contains("error code 4618") +} + fn is_embedding_backend_auth_failure(lower: &str) -> bool { lower.contains("embedding api error") && lower.contains("401") @@ -1503,6 +1549,26 @@ fn report_expected_message(kind: ExpectedErrorKind, message: &str, domain: &str, "[observability] {domain}.{operation} skipped expected config-load timeout: {message}" ); } + ExpectedErrorKind::SubconsciousSchemaUnavailable => { + // Host-filesystem condition: SQLite couldn't open the subconscious + // DB file (CANTOPEN / xShmMap). The WAL-fallback in + // `subconscious::store` already prevents the shared-memory variant; + // what reaches here is a genuine local open failure the user must + // fix on their machine (permissions, remount, free the volume) — + // Sentry has no remediation path. Demote at `warn!` so a sustained + // spike still shows in operator dashboards without turning every + // affected session into a Sentry error event. Drops TAURI-RUST-8WM. + // Do not include the raw `message`: it can embed the absolute + // subconscious DB path (home dir / username). Mirror the + // metadata-only demotions (`DiskFull`, `FilesystemUserPathInvalid`) + // and log only domain/operation/kind — no PII in the breadcrumb. + tracing::warn!( + domain = domain, + operation = operation, + kind = "subconscious_schema_unavailable", + "[observability] {domain}.{operation} skipped expected subconscious schema DB-unavailable error" + ); + } } } @@ -2528,6 +2594,46 @@ mod tests { } } + #[test] + fn classifies_subconscious_schema_unavailable_errors() { + for raw in [ + // SQLITE_IOERR_SHMMAP (4618) — the original escalating issue (#3231). + "failed to run subconscious schema DDL: disk I/O error: Error code 4618: I/O error within the xShmMap method (trying to open a new shared-memory segment)", + // SQLITE_CANTOPEN (14) — sibling variant from the user report. + "failed to run subconscious schema DDL: unable to open database file: Error code 14: Unable to open the database file", + // Failure surfaced at the open step rather than the DDL step. + "failed to open subconscious DB: /home/u/.openhuman/subconscious/subconscious.db: unable to open the database file", + // Wrapped in outer RPC context — classifier runs on the full chain. + "rpc.invoke_method failed: failed to run subconscious schema DDL: disk I/O error: Error code 4618", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::SubconsciousSchemaUnavailable), + "should classify subconscious schema DB-unavailable: {raw}" + ); + } + } + + #[test] + fn does_not_classify_subconscious_lock_or_unrelated_open_failures() { + for raw in [ + // Transient busy/locked is retried by the store and, if persistent, + // is a real contention signal — must NOT be demoted here. + "failed to run subconscious schema DDL: database is locked", + // Cant-open text without the subconscious envelope must not match. + "failed to open memory DB: unable to open the database file", + // Subconscious envelope but a non-FS error (e.g. malformed SQL) stays + // a real bug worth reporting. + "failed to run subconscious schema DDL: near \"CREAT\": syntax error", + ] { + assert_ne!( + expected_error_kind(raw), + Some(ExpectedErrorKind::SubconsciousSchemaUnavailable), + "must not classify as subconscious schema unavailable: {raw}" + ); + } + } + #[test] fn does_not_classify_unrelated_sqlite_lock_messages_as_whatsapp_busy() { for raw in [ diff --git a/src/openhuman/subconscious/store.rs b/src/openhuman/subconscious/store.rs index 2576fbd18..87ca9dfa3 100644 --- a/src/openhuman/subconscious/store.rs +++ b/src/openhuman/subconscious/store.rs @@ -81,6 +81,18 @@ fn open_and_initialize(db_path: &Path) -> Result { conn.busy_timeout(BUSY_TIMEOUT) .context("configure subconscious busy_timeout")?; + // Choose a journal mode this filesystem can actually back *before* running + // the table DDL. WAL is preferred for read/write concurrency, but it + // eagerly opens an mmap-backed `-shm` shared-memory segment; on network + // mounts, FUSE, and some sandboxed/synced macOS paths that mmap fails with + // SQLITE_IOERR_SHMMAP (4618) or SQLITE_CANTOPEN (14). Previously the + // `PRAGMA journal_mode = WAL` lived inside `SCHEMA_DDL`, so such a failure + // aborted the whole schema-init batch and hung the Intelligence tab + // (issue #3231 / TAURI-RUST-8WM). WAL is a performance optimization, not a + // correctness requirement, so degrade to a rollback journal that needs no + // shared memory rather than failing init. + apply_journal_mode(&conn); + conn.execute_batch(SCHEMA_DDL) .context("failed to run subconscious schema DDL")?; @@ -91,6 +103,66 @@ fn open_and_initialize(db_path: &Path) -> Result { Ok(conn) } +/// Set the SQLite journal mode for the subconscious DB, preferring WAL but +/// gracefully degrading to a rollback journal when the filesystem can't back +/// WAL's shared-memory segment. +/// +/// `PRAGMA journal_mode = WAL` opens (and mmaps) the `-shm` file eagerly, so a +/// filesystem that can't support shared memory surfaces the failure here as +/// `SQLITE_IOERR_SHMMAP` / `SQLITE_CANTOPEN` rather than at first write. We try +/// WAL, and on any error — or if the engine silently refuses to switch (e.g. +/// in-memory DBs report `memory`) — fall back to `TRUNCATE`, which uses an +/// ordinary rollback journal and needs no shared memory. Failures are logged, +/// never propagated: a working rollback journal is strictly better than a +/// failed schema init, and the caller's DDL/queries work identically either +/// way. +/// +/// Logs use a stable `db = "subconscious.db"` identifier rather than the +/// absolute path — the workspace path embeds the user's home dir / username, so +/// emitting it would leak PII into the breadcrumb stream. +fn apply_journal_mode(conn: &Connection) { + match query_journal_mode(conn, "WAL") { + Ok(mode) if mode.eq_ignore_ascii_case("wal") => return, + Ok(mode) => { + tracing::warn!( + target: "openhuman::subconscious::store", + db = "subconscious.db", + requested = "wal", + effective = %mode, + "[subconscious::store] WAL journal mode not honoured; falling back to TRUNCATE" + ); + } + Err(e) => { + tracing::warn!( + target: "openhuman::subconscious::store", + db = "subconscious.db", + error = %format!("{e}"), + "[subconscious::store] WAL journal mode failed (filesystem can't back -shm); falling back to TRUNCATE" + ); + } + } + + if let Err(e) = query_journal_mode(conn, "TRUNCATE") { + tracing::warn!( + target: "openhuman::subconscious::store", + db = "subconscious.db", + error = %format!("{e}"), + "[subconscious::store] TRUNCATE journal fallback also failed; continuing with engine default" + ); + } +} + +/// Run `PRAGMA journal_mode = ` and return the resulting mode string. +/// +/// `mode` is a fixed internal SQLite keyword (`WAL`, `TRUNCATE`, …), never user +/// input. PRAGMA statements don't accept bound parameters, so inlining the +/// keyword in the statement text is the only option and is safe here. +fn query_journal_mode(conn: &Connection, mode: &str) -> rusqlite::Result { + conn.query_row(&format!("PRAGMA journal_mode = {mode}"), [], |row| { + row.get::<_, String>(0) + }) +} + fn is_sqlite_busy(err: &anyhow::Error) -> bool { if let Some(rusqlite::Error::SqliteFailure(sqlite_err, _)) = err.downcast_ref::() @@ -104,9 +176,12 @@ fn is_sqlite_busy(err: &anyhow::Error) -> bool { msg.contains("database is locked") || msg.contains("database table is locked") } +// NOTE: `PRAGMA journal_mode` is intentionally *not* part of this batch — it is +// applied separately via `apply_journal_mode` so a filesystem that can't back +// WAL's `-shm` segment degrades to a rollback journal instead of aborting the +// whole schema init (issue #3231). Keep journal-mode selection out of here. const SCHEMA_DDL: &str = " PRAGMA foreign_keys = ON; - PRAGMA journal_mode = WAL; -- Legacy tables retained for backward compatibility with existing DBs. -- No longer written to or read from. diff --git a/src/openhuman/subconscious/store_tests.rs b/src/openhuman/subconscious/store_tests.rs index 01a1a535f..12a333669 100644 --- a/src/openhuman/subconscious/store_tests.rs +++ b/src/openhuman/subconscious/store_tests.rs @@ -34,3 +34,66 @@ fn schema_ddl_creates_tables() { .unwrap(); assert!(count >= 4); } + +#[test] +fn schema_ddl_has_no_journal_mode_pragma() { + // Journal-mode selection must stay out of the DDL batch so a filesystem + // that can't back WAL's `-shm` segment degrades via `apply_journal_mode` + // instead of aborting schema init (issue #3231 / TAURI-RUST-8WM). + assert!( + !SCHEMA_DDL.to_ascii_lowercase().contains("journal_mode"), + "SCHEMA_DDL must not set journal_mode — it is applied separately with a WAL fallback" + ); +} + +#[test] +fn open_and_initialize_creates_usable_db_on_real_fs() { + // A real on-disk DB exercises the actual journal-mode path (in-memory DBs + // can never be WAL). The DB must be usable for reads/writes afterward. + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("subconscious.db"); + + let conn = open_and_initialize(&db_path).unwrap(); + set_last_tick_at(&conn, 99.5).unwrap(); + assert_eq!(get_last_tick_at(&conn).unwrap(), 99.5); + + // On a normal local filesystem WAL succeeds; assert we landed on a valid + // persistent journal mode (wal when supported, otherwise the truncate + // fallback — never an unusable state). + let mode: String = conn + .query_row("PRAGMA journal_mode", [], |row| row.get(0)) + .unwrap(); + assert!( + matches!(mode.to_ascii_lowercase().as_str(), "wal" | "truncate"), + "expected wal or truncate journal mode, got {mode}" + ); +} + +#[test] +fn with_connection_creates_parent_dir_and_db() { + // `with_connection` must create the `subconscious/` subdir under a fresh + // workspace and initialize a working DB end-to-end. + let workspace = tempfile::tempdir().unwrap(); + let tick = with_connection(workspace.path(), |conn| { + set_last_tick_at(conn, 7.0)?; + get_last_tick_at(conn) + }) + .unwrap(); + assert_eq!(tick, 7.0); + assert!(workspace + .path() + .join("subconscious") + .join("subconscious.db") + .exists()); +} + +#[test] +fn apply_journal_mode_falls_back_without_panicking() { + // In-memory DBs always report `memory` and can never switch to WAL, so this + // drives the fallback branch of `apply_journal_mode`. It must not panic and + // must leave the connection fully usable for the table DDL. + let conn = Connection::open_in_memory().unwrap(); + apply_journal_mode(&conn); + conn.execute_batch(SCHEMA_DDL).unwrap(); + assert_eq!(get_last_tick_at(&conn).unwrap(), 0.0); +}