astrid-runtime/astrid · error · GatewayError::Internal

workspace capsule manifest changed while it was being read:

Error message

workspace capsule manifest changed while it was being read: {e}

What it means

After parsing the capsule manifest, the handler re-resolves the same manifest path and compares it with the first resolution; if it changed, the manifest was mutated mid-read and the read is rejected as an Internal error. This TOCTOU guard ensures the returned env schema matches a consistent snapshot of the file.

Source

Thrown at crates/astrid-gateway/src/routes/env.rs:392

    let Some(workspace_root) = workspace_root else {
        return Err(GatewayError::NotFound);
    };
    let workspace = workspace_layout
        .resolve(workspace_root)
        .map_err(|e| GatewayError::Internal(anyhow::anyhow!("resolve selected workspace: {e}")))?;
    let manifest_relative = FsPath::new("capsules")
        .join(capsule_id)
        .join("Capsule.toml");
    let workspace_manifest = workspace.resolve_file(&manifest_relative).map_err(|e| {
        GatewayError::Internal(anyhow::anyhow!("resolve workspace capsule manifest: {e}"))
    })?;
    if !workspace_manifest.exists() {
        return Err(GatewayError::NotFound);
    }
    let schema = parse_env_schema(&workspace_manifest)?;
    workspace.resolve_file(&manifest_relative).map_err(|e| {
        GatewayError::Internal(anyhow::anyhow!(
            "workspace capsule manifest changed while it was being read: {e}"
        ))
    })?;
    Ok(schema)
}

#[cfg(test)]
fn parse_env_schema(manifest_path: &FsPath) -> GatewayResult<HashMap<String, EnvFieldSchema>> {
    let text = match std::fs::read_to_string(manifest_path) {
        Ok(t) => t,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            return Err(GatewayError::NotFound);
        },
        Err(e) => {
            return Err(GatewayError::Internal(anyhow::anyhow!(
                "read {}: {e}",
                manifest_path.display()
            )));

View on GitHub (pinned to affd8760f4)

Solutions

  1. Retry the env schema request — a one-shot concurrent write usually resolves on the next read
  2. Pause writers (build/CI jobs, editors with auto-save) that mutate Capsule.toml while the gateway serves reads
  3. Serialize manifest updates through the daemon so writes and gateway reads don't race

Example fix

// before
let schema = parse_env_schema(&workspace_manifest)?;
workspace.resolve_file(&manifest_relative).map_err(|e| {
    GatewayError::Internal(anyhow::anyhow!(
        "workspace capsule manifest changed while it was being read: {e}"
    ))
})?;
// after
let mut attempts = 0;
let schema = loop {
    let result = (|| {
        let workspace_manifest = workspace.resolve_file(&manifest_relative)?;
        let schema = parse_env_schema(&workspace_manifest)?;
        workspace.resolve_file(&manifest_relative)?;
        Ok(schema)
    })();
    match result {
        Ok(s) => break s,
        Err(e) if attempts < 2 => { attempts += 1; continue; }
        Err(e) => return Err(e),
    }
};
Defensive patterns

Strategy: retry

Validate before calling

// Check manifest mtime stability before parsing
let before = fs::metadata(&manifest_path)?.modified()?;
std::thread::sleep(std::time::Duration::from_millis(50));
if fs::metadata(&manifest_path)?.modified()? != before {
    return Err(anyhow::anyhow!("manifest is being written; retry later"));
}

Try / catch

match load_env_schema_from_home(state, capsule_id).await {
    Ok(schema) => schema,
    Err(GatewayError::Internal(e))
        if e.to_string().contains("changed while it was being read") =>
    {
        // transient TOCTOU race; safe to retry after a short backoff
        tokio::time::sleep(Duration::from_millis(100)).await;
        load_env_schema_from_home(state, capsule_id).await
    }
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: The Capsule.toml at capsules/<id>/Capsule.toml is written, replaced, or renamed between the first resolve_file, the exists check, and the post-parse re-resolve during an env schema request.

Common situations: Another process (daemon, editor, CI job) rewriting Capsule.toml concurrently; atomic-replace via rename changing the inode; a container/CI environment where manifests are regenerated on the fly.

Understand the failure class

Background: "failed to read file", EACCES, ENOENT and "could not read <path>" errors: when a program can't read a file from disk — this error's family across 49 libraries.

Related errors


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