Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-git/tests/ssh_liveness_leaks.rs

//! **Nothing is left behind** when a dropped or silent link ends a transfer: no
//! parked thread, no open descriptor, no socket sitting in CLOSE-WAIT.
//!
//! The incident (cov-13 finding 4) left all three: six pushes' threads parked
//! forever, their sockets in CLOSE-WAIT with 245 bytes unread. An error that
//! surfaces while the bridge's runtime threads or its socket live on is half a
//! fix — a long-lived caller (gunnar's replication, the kit) would still
//! accumulate them, one per bad link.
//!
//! ONE test in its own binary on purpose: thread and descriptor counts are
//! per-PROCESS, and a sibling test running alongside would move them.

mod liveness_common;

use std::time::Duration;

use liveness_common::*;
use nornir_git::ssh::{connect_exec_with, parse_ssh_url, SshAuth, SshLiveness};

const WATCHDOG: Duration = Duration::from_secs(25);

fn fast(key: &std::path::Path) -> SshAuth {
    SshAuth::key(key).with_liveness(SshLiveness {
        connect: Duration::from_secs(3),
        keepalive_interval: Duration::from_secs(1),
        keepalive_max: 3,
        inactivity: Duration::from_secs(5),
        stall: Duration::from_secs(8),
    })
}

fn push_into(url: String, auth: SshAuth) -> Result<String, String> {
    let loc = parse_ssh_url(&url).map_err(|e| format!("{e:#}"))?;
    let mut ch = connect_exec_with(&loc, &auth, "sink", &[]).map_err(|e| format!("connect: {e:#}"))?;
    let (r, w) = ch.io();
    read_n(r, 6).map_err(|e| format!("ready: {e}"))?;
    match write_until_error(w, 1 << 30) {
        Ok(n) => Err(format!("wrote {n} bytes into a dead link and was told nothing")),
        Err(e) => Ok(e.to_string()),
    }
    // `ch` drops here: the bridge's runtime, its threads and its socket go with it.
}

/// One healthy round trip, so everything a first connection initialises lazily
/// is in the baseline rather than counted as a leak.
fn warm_up(url: String, auth: SshAuth) {
    let loc = parse_ssh_url(&url).expect("url");
    let mut ch = connect_exec_with(&loc, &auth, "sink", &[]).expect("warm-up connect");
    let (r, _) = ch.io();
    read_n(r, 6).expect("warm-up ready");
}

#[test]
fn a_dead_link_leaves_no_thread_no_descriptor_and_no_close_wait_socket() {
    let td = tempfile::tempdir().expect("tempdir");
    let srv = TestServer::start(td.path(), ServerPlan { answer_after: None });
    let proxy = std::sync::Arc::new(Proxy::start(srv.addr));
    let key = mint_key(td.path());

    warm_up(proxy.url("/x"), fast(&key));
    proxy.close_all();
    // Let the warm-up's sockets on all three parties close.
    std::thread::sleep(Duration::from_millis(500));
    let (t0, f0) = (threads(), fds());

    for (name, mode) in [("silent link mid-push", Mode::Silent), ("hang-up mid-push", Mode::Close)] {
        proxy.arm(1 << 20, mode);
        let (url, auth) = (proxy.url("/x"), fast(&key));
        let (out, took) = within(WATCHDOG, name, move || push_into(url, auth));
        let msg = out.unwrap_or_else(|e| panic!("{name}: the push must fail, not succeed: {e}"));
        eprintln!("{name}: ended after {took:?}: {msg}");
        proxy.disarm();
        // The CLIENT must already be clean: its socket is not left in
        // CLOSE-WAIT. (The proxy still holds its side of a silent link here.)
        assert!(
            settle(Duration::from_secs(3), || close_wait_to(proxy.port) == 0),
            "{name}: {} client socket(s) to the proxy left in CLOSE-WAIT",
            close_wait_to(proxy.port)
        );
        // Now let the proxy and the server drop their halves, and count.
        proxy.close_all();
        let clean = settle(Duration::from_secs(5), || threads() <= t0 && fds() <= f0);
        assert!(
            clean,
            "{name}: leaked — threads {} (baseline {t0}), descriptors {} (baseline {f0})",
            threads(),
            fds()
        );
    }
}