astrid-runtime/astrid · warning

shutdown stage gateway.lifecycle_fence: a successor gateway…

Error message

shutdown stage gateway.lifecycle_fence: a successor gateway lifecycle remains active

What it means

After confirming the gateway process is dead, remove_dead_gateway_markers tries to acquire the gateway lifecycle fence; if another process already holds it (try_acquire_gateway_lifecycle returns None), a successor gateway lifecycle is active and this cleanup must not proceed.

Solutions

  1. Do nothing: the successor lifecycle owns cleanup — treat this as benign and skip
  2. Retry later once the other client finishes its startup/shutdown
  3. Audit for multiple concurrent astrid clients sharing one state directory and serialize them
  4. If the lock holder is a dead process, clear the lock/state files manually and retry

Example fix

// before
remove_dead_gateway_markers(&record).await?;
// after: treat fenced cleanup as benign
match remove_dead_gateway_markers(&record).await {
    Err(e) if e.to_string().contains("lifecycle_fence") => {
        // successor lifecycle active; nothing to clean
    },
    other => other?,
}
Defensive patterns

Strategy: fallback

Validate before calling

if try_acquire_gateway_lifecycle()?.is_none() {
    // successor lifecycle active; skip cleanup entirely
    return Ok(());
}

Try / catch

match remove_dead_gateway_markers(&record).await {
    Err(e) if e.to_string().contains("lifecycle_fence") => {
        // benign: another generation owns cleanup
    },
    other => other?,
}

Prevention

When it happens

Trigger: Two concurrent shutdown/startup sequences: another astrid client holds the gateway lifecycle lock while this one attempts marker cleanup via wait_for_gateway or stop_ready_gateway.

Common situations: Multiple terminals/IDE windows each running an astrid MCP client; a startup raced this shutdown and legitimately fenced it out; a crashed process left the lock held (depending on lock implementation).

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of astrid-runtime/astrid@affd8760f4 (2026-09-09). Data as JSON: /api/errors/a601aaffe9f65032. Report an issue: GitHub.

Appendix: source

Thrown at crates/astrid-cli/src/commands/mcp/lifecycle.rs:717

            .context("failed to read control frame")?;
        if byte == b'\n' {
            return Ok(line);
        }
        line.push(byte);
    }
    anyhow::bail!("MCP gateway control frame is missing or too large")
}

async fn remove_dead_gateway_markers(record: &GatewayReady) -> Result<()> {
    if crate::commands::daemon_control::is_process_alive(record.pid) {
        anyhow::bail!(
            "shutdown stage gateway.process_reap: PID {} is still alive",
            record.pid
        );
    }
    let lifecycle = try_acquire_gateway_lifecycle()?;
    let Some(lifecycle) = lifecycle else {
        anyhow::bail!(
            "shutdown stage gateway.lifecycle_fence: a successor gateway lifecycle remains active"
        );
    };
    let socket = gateway_socket_path()?;
    match astrid_core::local_transport::connect_outcome(&socket)
        .await
        .context("shutdown stage gateway.listener_probe")?
    {
        astrid_core::local_transport::ConnectOutcome::Connected(_) => {
            anyhow::bail!(
                "shutdown stage gateway.listener_absence: a gateway is still accepting connections"
            );
        },
        astrid_core::local_transport::ConnectOutcome::Absent => {},
        astrid_core::local_transport::ConnectOutcome::Stale => {
            astrid_core::local_transport::remove_stale_endpoint(&socket)
                .context("shutdown stage gateway.listener_cleanup")?;
        },

View on GitHub (pinned to affd8760f4)