fix(subconscious): degrade WAL journal mode to fix Intelligence tab hang on shm-incompatible filesystems (#3233)

This commit is contained in:
Mega Mind
2026-06-03 02:57:57 +05:30
committed by GitHub
parent b156392198
commit 235430d37e
3 changed files with 245 additions and 1 deletions
+106
View File
@@ -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<ExpectedErrorKind> {
@@ -344,6 +367,9 @@ pub fn expected_error_kind(message: &str) -> Option<ExpectedErrorKind> {
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 [
+76 -1
View File
@@ -81,6 +81,18 @@ fn open_and_initialize(db_path: &Path) -> Result<Connection> {
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<Connection> {
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 = <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<String> {
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::<rusqlite::Error>()
@@ -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.
+63
View File
@@ -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);
}