atuinsh/atuin · critical

output-capture delete task panicked

Error message

output-capture delete task panicked

What it means

This message is the `.expect()` on the `JoinHandle` returned by `spawn_blocking` inside `FjallBackendInner::remove` (crates/atuin-daemon/src/output_capture/backend/fjall/mod.rs:132). It means the blocking task performing the batched key deletion panicked before returning its Result. The closure's own fallible paths return `DeleteOutputError::Storage`, so this panic almost always comes from the `unreachable!("a blind remove performs no reads, so it can never conflict")` branch firing on `fjall::Conflict`, or a task-abort JoinError — not from ordinary delete failures.

Source

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

        let dirty = self.dirty.clone();
        tokio::task::spawn_blocking(move || {
            let mut tx = db.write_tx().map_err(|err| DeleteOutputError::Storage(Box::new(err)))?;
            for key in keys {
                tx.remove(&keyspace, key);
            }
            match tx.commit().map_err(|err| DeleteOutputError::Storage(Box::new(err)))? {
                Ok(()) => {
                    dirty.store(true, Ordering::Release);
                    Ok(())
                }
                // fjall only reports conflicts for transactions that read; this one never does.
                Err(fjall::Conflict) => {
                    unreachable!("a blind remove performs no reads, so it can never conflict")
                }
            }
        })
        .await
        .expect("output-capture delete task panicked")
    }

    /// Deletes the oldest stored entries until their values total at least `reclaim_bytes`,
    /// returning the number of bytes actually freed.
    async fn reclaim(&self, reclaim_bytes: u64) -> Result<u64, DeleteOutputError> {
        if reclaim_bytes == 0 {
            return Ok(0);
        }

        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)))?;
            let mut freed: u64 = 0;

            for guard in keyspace.inner().iter() {
                let (key, value) =

View on GitHub (pinned to c0c717ab04)

Solutions

  1. Check the fjall crate version against the one atuin-daemon pinned; if a newer fjall changed Conflict semantics for read-free transactions, upgrade/downgrade to a compatible version
  2. Report/inspect the JoinError payload to confirm whether the panic is the `unreachable!()` Conflict branch — if so it is a fjall internal assumption bug, not user error
  3. Retry the removal after restarting the daemon; transient conflict behavior may not recur
  4. Delete the affected entries individually instead of in one batched transaction to isolate which key triggers the conflict

Example fix

// before
Err(fjall::Conflict) => {
    unreachable!("a blind remove performs no reads, so it can never conflict")
}
// after: surface the conflict instead of panicking
Err(fjall::Conflict) => Err(DeleteOutputError::Storage(
    "unexpected conflict on blind remove".into(),
)),
Defensive patterns

Strategy: retry

Validate before calling

// Pre-check that the ids exist so the remove path does the minimal
// blind transaction; empty ids short-circuit before any task spawn.
let existing: Vec<_> = ids.into_iter().filter(|id| capture_store.get(*id).ok().flatten().is_some()).collect();

Type guard

fn is_clean_result(r: &Result<(), DeleteOutputError>) -> bool {
    !matches!(r, Err(DeleteOutputError::Storage(_)))
}

Try / catch

// Run the removal in a supervised child task; a panic becomes a
// retryable JoinError instead of killing the caller.
match tokio::spawn(backend.remove(ids.clone())).await {
    Ok(Ok(())) => {},
    Ok(Err(e)) | Err(_) => schedule_retry(ids, e),
}

Prevention

When it happens

Trigger: Calling the backend's `remove(ids)` when: (1) fjall unexpectedly reports `Conflict` on the blind (read-free) remove transaction, hitting the `unreachable!()`; (2) `tx.remove`/keyspace operations panic inside fjall; (3) the blocking task is cancelled at shutdown (JoinError) which `.expect()` also turns into this panic.

Common situations: Bulk-deleting output captures for pruned history entries; garbage collection racing heavy concurrent writers on the same keyspace under a fjall version whose optimistic transactions behave differently than assumed; daemon shutdown mid-operation.

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/57a604a41eca2838. Report an issue: GitHub.