aion-server 0.30.0

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
//! Red-first specimens for the pid record itself: the cross-version read
//! contract, the absent/unreadable distinction, and the identity compare that
//! `remove_if_matches` now uses.

use super::{IncarnationState, pid_file_path, read, remove_if_matches};
use crate::control::test_records::{TestResult, dead_record, own_serving_record, plant};

/// The record is a cross-version contract (module-header ruling): a record
/// written by 0.25.1 — addresses present as bare strings, no `state`, no
/// stage fields — must READ, with every missing field as its honest default.
/// A newer CLI refusing to parse an older running server's record would make
/// that server un-stoppable at exactly the moment (`aion update`) the verb
/// matters most.
///
/// The `state` default is the load-bearing one: `Serving`, because a record
/// written by a build that claimed only at BIND could only ever have existed
/// after the bind. Defaulting to `Booting` would make every pre-upgrade
/// server read as mid-boot forever, and `aion server status` would exit 1
/// against a healthy one.
#[test]
fn a_zero_twenty_five_shaped_record_reads_as_serving() -> TestResult {
    let home = tempfile::tempdir()?;
    let path = pid_file_path(home.path());
    std::fs::create_dir_all(path.parent().ok_or("pid path must have a parent")?)?;
    let old_shape = serde_json::json!({
        "pid": 93400,
        "started_at_unix_secs": 1_000_000,
        "binary_sha256": "0".repeat(64),
        "version": "0.25.1",
        "commit": "older-build",
        "http_address": "127.0.0.1:8080",
        "grpc_address": "127.0.0.1:50051",
        "drain_timeout_seconds": 45,
    });
    std::fs::write(&path, format!("{old_shape}\n"))?;

    let record = read(home.path())?.ok_or("the 0.25.1-shaped record must read")?;
    assert_eq!(
        record.state,
        IncarnationState::Serving,
        "a record from a build that claimed only at bind was, by construction, \
         serving when it was written"
    );
    assert_eq!(
        record.http_address,
        Some("127.0.0.1:8080".parse()?),
        "the recorded addresses must survive the Option widening"
    );
    assert_eq!(record.grpc_address, Some("127.0.0.1:50051".parse()?));
    assert_eq!(record.drain_timeout_seconds, 45);
    assert_eq!(record.stage, None, "an older record reports no stage");
    assert_eq!(record.stage_seq, 0);
    assert_eq!(record.stage_updated_at_unix_secs, 0);
    Ok(())
}

/// A record predating `drain_timeout_seconds` still reads, with the window as
/// its honest default — which patience resolution treats as "no recorded
/// window" and falls through to config.
#[test]
fn a_record_predating_the_drain_window_field_still_reads() -> TestResult {
    let home = tempfile::tempdir()?;
    let path = pid_file_path(home.path());
    std::fs::create_dir_all(path.parent().ok_or("pid path must have a parent")?)?;
    let old_shape = serde_json::json!({
        "pid": 93400,
        "started_at_unix_secs": 1_000_000,
        "binary_sha256": "0".repeat(64),
        "version": "0.22.0",
        "commit": "older-build",
        "http_address": "127.0.0.1:8080",
        "grpc_address": "127.0.0.1:50051",
    });
    std::fs::write(&path, format!("{old_shape}\n"))?;
    let record = read(home.path())?.ok_or("the old-shape record must read")?;
    assert_eq!(record.drain_timeout_seconds, 0);
    Ok(())
}

/// Reading distinguishes absence from unreadability: absent is `Ok(None)`,
/// present-but-corrupt is a typed error naming the remedy.
#[test]
fn read_distinguishes_absent_from_unreadable() -> TestResult {
    let home = tempfile::tempdir()?;
    assert_eq!(read(home.path())?, None, "no file is a state, not an error");
    let path = pid_file_path(home.path());
    std::fs::create_dir_all(path.parent().ok_or("pid path must have a parent")?)?;
    std::fs::write(&path, "not json")?;
    let error = match read(home.path()) {
        Err(error) => error,
        Ok(record) => return Err(format!("corrupt file must refuse, got {record:?}").into()),
    };
    assert!(
        error.to_string().contains("does not parse"),
        "the refusal must say why: {error}"
    );
    Ok(())
}

/// 🔴 The identity-compare fix, as a specimen.
///
/// `remove_if_matches` used to compare WHOLE RECORDS. The record now mutates
/// while its incarnation lives — every boot stage bumps `stage_seq` — so the
/// stop verb, which reads the record BEFORE it signals and reconciles it
/// AFTER the process is gone, holds a copy that differs from the file in
/// exactly the fields a long boot moves. Under whole-record equality that
/// reconciliation silently removed nothing, leaving a dead server's record
/// behind every boot that took long enough to report progress.
///
/// The specimen is deliberately built from a stage-churned file rather than
/// from the fix: plant a record, then move only the stage fields, then
/// reconcile with the ORIGINAL copy.
#[test]
fn remove_if_matches_compares_incarnation_identity_not_the_whole_record() -> TestResult {
    let home = tempfile::tempdir()?;
    let before_signal = own_serving_record(None, None)?;

    let mut churned = before_signal.clone();
    churned.stage = Some("wal-recovery".to_owned());
    churned.stage_detail = Some("materializing shard 17 of 64".to_owned());
    churned.stage_seq = 41;
    churned.stage_updated_at_unix_secs = 1_700_000_000;
    churned.state = IncarnationState::Draining;
    plant(home.path(), &churned)?;
    assert_ne!(
        churned, before_signal,
        "the specimen only means something if the two records genuinely differ"
    );

    assert!(
        remove_if_matches(home.path(), &before_signal)?,
        "a record that names the SAME incarnation must reconcile, however far its \
         stage has moved since the caller read it"
    );
    assert_eq!(read(home.path())?, None);
    Ok(())
}

/// The other half of the same rule: identity is what must match. A record
/// naming a DIFFERENT incarnation is never removed, no matter how similar
/// every other field is.
#[test]
fn remove_if_matches_refuses_a_foreign_incarnation() -> TestResult {
    let home = tempfile::tempdir()?;
    let planted = own_serving_record(None, None)?;
    plant(home.path(), &planted)?;

    let stranger = dead_record()?;
    assert!(
        !remove_if_matches(home.path(), &stranger)?,
        "a foreign incarnation must not remove this home's record"
    );
    assert_eq!(
        read(home.path())?.as_ref(),
        Some(&planted),
        "the planted record must survive untouched"
    );

    assert!(remove_if_matches(home.path(), &planted)?);
    assert!(
        !remove_if_matches(home.path(), &planted)?,
        "an absent file removes nothing"
    );
    Ok(())
}