net-cli 0.45.0

Unified `net-mesh` command-line tool for the Net mesh
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Real subprocess startup cancellation and NDJSON lifetime controls.
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};

async fn exercise(stall: bool, listen: bool) {
    let dir = tempfile::tempdir().unwrap();
    let fixture = dir
        .path()
        .join(format!("mcp-fixture{}", std::env::consts::EXE_SUFFIX));
    let compile = tokio::process::Command::new("rustc")
        .arg("--edition=2021")
        .arg(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/tests/fixtures/wrap_startup.rs"
        ))
        .arg("-o")
        .arg(&fixture)
        .kill_on_drop(true)
        .output()
        .await
        .unwrap();
    assert!(
        compile.status.success(),
        "{}",
        String::from_utf8_lossy(&compile.stderr)
    );
    let config = dir.path().join("config.toml");
    std::fs::write(&config, "").unwrap();
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o600)).unwrap();
    }
    let identity = dir.path().join("operator.toml");
    let generated = tokio::process::Command::new(assert_cmd::cargo::cargo_bin("net-mesh"))
        .env_remove("NET_MESH_CONFIG")
        .env_remove("NET_MESH_PROFILE")
        .arg("--config")
        .arg(&config)
        .args(["identity", "generate", "--out"])
        .arg(&identity)
        .output()
        .await
        .unwrap();
    assert!(generated.status.success());
    let mesh = net_sdk::MeshBuilder::new("127.0.0.1:0", &[0x42; 32])
        .unwrap()
        .build()
        .await
        .unwrap();
    mesh.start().expect("mesh start");
    let control = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let mut cmd = tokio::process::Command::new(assert_cmd::cargo::cargo_bin("net-mesh"));
    cmd.env_remove("NET_MESH_CONFIG")
        .env_remove("NET_MESH_PROFILE")
        .arg("--config")
        .arg(&config)
        .args([
            "--timeout",
            "2s",
            "--output",
            "ndjson",
            "wrap",
            "fixture",
            "--identity",
        ])
        .arg(&identity);
    if listen {
        cmd.arg("--listen");
    } else {
        cmd.args([
            "--bind",
            "127.0.0.1:0",
            "--node-addr",
            &mesh.local_addr().to_string(),
            "--node-id",
            &mesh.node_id().to_string(),
            "--node-pubkey",
            &hex::encode(mesh.public_key()),
        ]);
    }
    let mut child = cmd
        .args(["--psk-hex", &"42".repeat(32), "--"])
        .arg(&fixture)
        .arg(control.local_addr().unwrap().to_string())
        .arg(if stall { "stall" } else { "serve" })
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .kill_on_drop(true)
        .spawn()
        .unwrap();
    let (mut socket, _) = tokio::time::timeout(Duration::from_secs(5), control.accept())
        .await
        .unwrap()
        .unwrap();
    let mut ready = [0; 6];
    tokio::time::timeout(Duration::from_secs(5), socket.read_exact(&mut ready))
        .await
        .unwrap()
        .unwrap();
    assert_eq!(&ready, b"ready\n");
    if stall {
        let output = tokio::time::timeout(Duration::from_secs(5), child.wait_with_output())
            .await
            .unwrap()
            .unwrap();
        assert_eq!(
            output.status.code(),
            Some(7),
            "{}",
            String::from_utf8_lossy(&output.stderr)
        );
        assert!(output.stdout.is_empty());
        let closed = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut [0]))
            .await
            .unwrap();
        assert!(
            matches!(closed, Ok(0))
                || matches!(closed, Err(ref e) if e.kind() == std::io::ErrorKind::ConnectionReset),
            "{closed:?}"
        );
        assert!(!String::from_utf8_lossy(&output.stderr).contains(&"42".repeat(32)));
    } else {
        let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
        let row = tokio::time::timeout(Duration::from_secs(5), lines.next_line())
            .await
            .unwrap()
            .unwrap()
            .unwrap();
        let row: serde_json::Value = serde_json::from_str(&row).unwrap();
        assert_eq!(row["event"], "wrapped");
        assert_eq!(row["tools"].as_array().unwrap().len(), 1);
        tokio::time::sleep(Duration::from_millis(2200)).await;
        assert!(child.try_wait().unwrap().is_none());
        socket.write_all(b"x").await.unwrap();
        let row = tokio::time::timeout(Duration::from_secs(5), lines.next_line())
            .await
            .unwrap()
            .unwrap()
            .unwrap();
        let row: serde_json::Value = serde_json::from_str(&row).unwrap();
        assert_eq!(row["event"], "server_exited");
        let output = tokio::time::timeout(Duration::from_secs(5), child.wait_with_output())
            .await
            .unwrap()
            .unwrap();
        assert!(
            output.status.success(),
            "{}",
            String::from_utf8_lossy(&output.stderr)
        );
        assert!(lines.next_line().await.unwrap().is_none());
    }
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn startup_timeout_kills_unresponsive_wrapped_child() {
    exercise(true, false).await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn wrapped_provider_outlives_startup_budget_and_emits_ndjson() {
    exercise(false, false).await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn listener_startup_timeout_kills_child_without_readiness() {
    exercise(true, true).await;
}