Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
},
"metadata": {
"description": "Persistent memory for Claude Code and Codex",
"version": "1.0.4"
"version": "1.0.5"
},
"plugins": [
{
"name": "muninn",
"source": "./plugin",
"description": "Persistent memory for Claude Code and Codex that never serves a replaced decision, and turns CLAUDE.md / AGENTS.md rules into permission rules and deny hooks. Local: one binary, SQLite, no cloud, no model in the read path.",
"version": "1.0.4",
"version": "1.0.5",
"author": {
"name": "Muninn contributors"
},
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ Names like v17 or v38 in these entries are test runs, numbered in the order they
they are not releases. Releases are the numbered headings, such as 0.2.0. See
[how to read the test names](docs/technical-overview.md#how-to-read-the-test-names).

## 1.0.5 — 2026-10-08

- **A write hook could give up on a store it had to migrate.** Opening the store for writing
starts with `PRAGMA journal_mode=WAL`. On a file that is not in WAL mode yet, a new one or
an empty file where the store should be, SQLite needs an exclusive lock for that switch and
takes it without calling the busy handler. With a second writer in the file, such as the
background `maintain` a session start launches, the pragma failed at once with "database is
locked" instead of waiting the 700 ms a hook allows. The hook exited 0 and the store stayed
without its schema until the next write. The switch now retries inside the same wait. This
is the fault test that failed on four of master's CI runs, with a RED status or "no such
table: meta" after the migration: a new test opens an empty file from four threads at once,
and it failed in under 0.1 s on each of 8 runs before the change.

## 1.0.4 — 2026-10-02

- **On Windows, typing `muninn` opened "Select an app to open 'muninn'".** The session start
Expand Down
16 changes: 8 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ members = [
]

[workspace.package]
version = "1.0.4"
version = "1.0.5"
edition = "2021"
license = "AGPL-3.0-only"
repository = "https://github.com/ilien-dev/muninn"
Expand Down
59 changes: 57 additions & 2 deletions crates/muninn-core/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,10 @@ impl Db {
conn.execute_batch("PRAGMA query_only=1; PRAGMA temp_store=MEMORY;")?;
}
Mode::ReadWrite => {
set_wal(&conn, busy_ms)?;
conn.execute_batch(
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; \
PRAGMA temp_store=MEMORY; PRAGMA foreign_keys=ON;",
"PRAGMA synchronous=NORMAL; PRAGMA temp_store=MEMORY; \
PRAGMA foreign_keys=ON;",
)?;
}
}
Expand Down Expand Up @@ -272,6 +273,32 @@ impl Db {
}
}

/// Put the file in WAL mode, waiting up to `busy_ms` for another writer.
///
/// A file that is not in WAL mode yet (new, or an empty file where the store should be)
/// needs an exclusive lock for the switch, and SQLite takes that lock without consulting
/// the busy handler: with a second writer in the file the pragma fails at once with
/// SQLITE_BUSY, whatever `busy_timeout` says. A hook racing the detached `maintain` on
/// such a file gave up there and left the store unmigrated. The wait is done here instead.
fn set_wal(conn: &Connection, busy_ms: u64) -> Result<()> {
let t = std::time::Instant::now();
let mut attempt = 0;
loop {
match conn.execute_batch("PRAGMA journal_mode=WAL;") {
Ok(()) => return Ok(()),
Err(e)
if e.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy)
&& t.elapsed() < Duration::from_millis(busy_ms) =>
{
let step = BUSY_BACKOFF_MS[attempt.min(BUSY_BACKOFF_MS.len() - 1)];
std::thread::sleep(Duration::from_millis(step));
attempt += 1;
}
Err(e) => return Err(e.into()),
}
}
}

/// Epoch milliseconds. `MUNINN_FAKE_NOW_MS` overrides it for tests (clock faults).
pub fn now_ms() -> i64 {
if let Ok(v) = std::env::var("MUNINN_FAKE_NOW_MS") {
Expand Down Expand Up @@ -303,6 +330,34 @@ mod tests {
assert!(err.is_err(), "read-only handle must not write");
}

/// An empty file where the store should be, and several writers opening it at once: a
/// hook and the detached `maintain` its SessionStart spawned. Every one of them has to
/// come back with a migrated store.
#[test]
fn writers_racing_to_open_an_empty_store_all_migrate_it() {
for _ in 0..50 {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("m.db");
std::fs::write(&p, b"").unwrap();
let gate = std::sync::Arc::new(std::sync::Barrier::new(4));
let handles: Vec<_> = (0..4)
.map(|_| {
let (p, gate) = (p.clone(), gate.clone());
std::thread::spawn(move || {
gate.wait();
Db::open(&p, Mode::ReadWrite).map(|db| db.schema_version())
})
})
.collect();
for h in handles {
match h.join().unwrap() {
Ok(v) => assert_eq!(v.unwrap(), SCHEMA_VERSION),
Err(e) => panic!("a racing writer failed to open the store: {e}"),
}
}
}
}

/// An index added after a store was created has to reach that store, or the query it was
/// added for scans instead. `record_recent` and `record_heir` are both younger than any
/// store in use: the catalogue's page and its `replaces #n` read them, and without them
Expand Down
2 changes: 1 addition & 1 deletion plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "muninn",
"version": "1.0.4",
"version": "1.0.5",
"description": "Persistent memory for Claude Code and Codex that never serves a replaced decision, and turns CLAUDE.md / AGENTS.md rules into permission rules and deny hooks. Local: one binary, SQLite, no cloud, no model in the read path.",
"author": {
"name": "ilien",
Expand Down
2 changes: 1 addition & 1 deletion plugin/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "muninn",
"version": "1.0.4",
"version": "1.0.5",
"description": "Persistent memory for Claude Code and Codex that never serves a replaced decision, and turns CLAUDE.md / AGENTS.md rules into permission rules and deny hooks. Local: one binary, SQLite, no cloud, no model in the read path.",
"author": {
"name": "ilien",
Expand Down
Loading