atuinsh/atuin · critical

output-capture read task panicked

Error message

output-capture read task panicked

What it means

This is not a storage error itself: it is the panic surfaced by `.expect()` on the `JoinHandle` of the `tokio::task::spawn_blocking` closure that reads a captured command output from the fjall keyspace (crates/atuin-daemon/src/output_capture/backend/fjall/mod.rs:98). It fires only if that blocking task panicked — e.g. the `.expect("stored value is a valid CommandCapture")` inside the closure when a stored value fails to deserialize, or an unexpected panic in fjall's read path. By the time this message appears, the daemon's async task has already aborted with a panic.

Source

Thrown at crates/atuin-daemon/src/output_capture/backend/fjall/mod.rs:98

        .expect("output-capture write task panicked")
    }

    async fn get(&self, id: HistoryId) -> Result<Option<CommandCapture>, GetOutputError> {
        let keyspace = self.keyspace.clone();
        let key = ActiveSchema::serialize_key(id).expect("history id serialization is infallible");

        tokio::task::spawn_blocking(move || {
            match keyspace.get(key).map_err(|err| GetOutputError::Storage(Box::new(err)))? {
                Some(slice) => {
                    let capture = ActiveSchema::deserialize_value(slice.to_vec())
                        .expect("stored value is a valid CommandCapture");
                    Ok(Some(capture))
                }
                None => Ok(None),
            }
        })
        .await
        .expect("output-capture read task panicked")
    }

    async fn remove(&self, ids: Vec<HistoryId>) -> Result<(), DeleteOutputError> {
        let keys: Vec<_> = ids
            .into_iter()
            .map(|id| {
                ActiveSchema::serialize_key(id).expect("history id serialization is infallible")
            })
            .collect();
        if keys.is_empty() {
            return Ok(());
        }

        let db = self.db.clone();
        let keyspace = self.keyspace.clone();
        let dirty = self.dirty.clone();
        tokio::task::spawn_blocking(move || {
            let mut tx = db.write_tx().map_err(|err| DeleteOutputError::Storage(Box::new(err)))?;

View on GitHub (pinned to c0c717ab04)

Solutions

  1. Back up and inspect the fjall data directory for the offending key; delete the corrupt record for the failing history id so the read returns None instead of a bad value
  2. Check whether the data dir was written by a different atuin-daemon schema version; migrate or start with a fresh data dir (move/remove the old output-capture data directory)
  3. Update atuin-daemon — if the panic stems from deserialize strictness or a fjall bug, a newer release may handle legacy/corrupt values gracefully
  4. Reproduce with tracing/logging enabled to capture the inner panic message (JoinError panic payload), which names the real fault (deserialize vs fjall I/O)

Example fix

// before (inner closure in get)
let capture = ActiveSchema::deserialize_value(slice.to_vec())
    .expect("stored value is a valid CommandCapture");
// after: fail the read instead of panicking the blocking task
let capture = ActiveSchema::deserialize_value(slice.to_vec())
    .map_err(|err| GetOutputError::Storage(Box::new(err)))?;
Defensive patterns

Strategy: try-catch

Validate before calling

// Before reading, confirm the store opens and the id exists;
// corruption is only detectable at read time, so scope the blast radius.
if capture_store.get(id).is_err() {
    tracing::error!(?id, "output capture unreadable; skipping");
}

Type guard

fn is_readable_result(r: &Result<Option<CommandCapture>, GetOutputError>) -> bool {
    matches!(r, Ok(_))
}

Try / catch

// The expect panics the caller's task, so isolate it and treat
// panic as an unreadable-record signal:
let capture = tokio::spawn(backend.get(id)).await;
match capture {
    Ok(Ok(Some(c))) => use_capture(c),
    Ok(Ok(None)) | Err(_) => handle_missing_or_corrupt(id),
}

Prevention

When it happens

Trigger: Calling the backend's `get(id)` (directly or via the output-capture API) when: (1) a stored value at the history-id key fails `ActiveSchema::deserialize_value` — typically corrupt or schema-version-mismatched data on disk; (2) the fjall keyspace `.get()` call itself panics inside the blocking pool; (3) the blocking runtime is being torn down and refuses to spawn the task (JoinError::cancelled is also hit by expect).

Common situations: Upgrading atuin-daemon across a storage schema change where an old fjall data directory contains records written by a previous `SchemaV` format; disk corruption or truncated segment files in the fjall data dir; killing the daemon mid-write leaving a torn value; running on a filesystem with failing sectors.

Understand the failure class

Background: "This is a bug, please report it": internal invariant violations, unreachable panics, and SNH errors explained — this error's family across 47 libraries.

Related errors


AI-assisted analysis of atuinsh/atuin@c0c717ab04 (2026-09-12). Data as JSON: /api/errors/08aefb174dd6d7b8. Report an issue: GitHub.