Files
alknet/docs/research/references/nats.rs/nats-server/08-test-harness.md

9.4 KiB
Raw Blame History

nats-server Test Harness

This document covers the nats-server crate — a test harness for spawning real NATS server instances in integration tests.

Location: nats-server/src/lib.rs
Version: 0.1.0
License: Apache-2.0
Dependencies: lazy_static, regex, serde_json, nuid, rand, tokio-retry

What It Is

The nats-server crate is not a NATS server implementation. It is a thin test harness that:

  • Spawns the Go-based nats-server binary as a child process
  • Configures it for test use (dynamic ports, temp storage, log files)
  • Discovers the client URL from the server's INFO protocol message
  • Cleans up resources (JetStream storage, logs, PID files) on Drop
  • Supports single servers and 3-node clusters

The actual NATS server must be installed separately (Go binary from github.com/nats-io/nats-server).

Server Struct

pub struct Server {
    inner: Inner,
}

struct Inner {
    cfg: String,          // Config file path
    id: String,           // Unique server ID (NUID)
    port: Option<String>, // Explicit port (None = dynamic)
    child: Child,         // Child process handle
    logfile: PathBuf,     // Log file path in temp dir
    pidfile: PathBuf,     // PID file path in temp dir
}

Public API

run_server

pub fn run_server(cfg: &str) -> Server

Starts a single NATS server with optional config file.

  • Uses dynamic port (-1 flag) for parallel test execution
  • Stores JetStream data in temp directory
  • Writes logs to temp file: nats-server-<id>.log
  • Writes PID to temp file: nats-server-<id>.pid
  • If cfg is non-empty, passes -c <cfg> to the server

Example:

let server = nats_server::run_server("tests/configs/jetstream.conf");
let client = async_nats::connect(server.client_url()).await.unwrap();

run_basic_server

pub fn run_basic_server() -> Server

Starts a server with no config (bare minimum). Equivalent to run_server("").

run_server_with_port

pub fn run_server_with_port(cfg: &str, port: Option<&str>) -> Server

Starts a server with an explicit port. If None, uses dynamic port.

run_cluster

pub fn run_cluster<'a, C: IntoConfig<'a>>(cfg: C) -> Cluster

Starts a 3-node cluster with the given config.

  • Allocates 3 random port ranges (base, base+100, base+200)
  • Configures cluster routes between nodes
  • Each node gets: --cluster nats://127.0.0.1:<cluster_port>, --routes <other_routes>, --cluster_name cluster, -n nodeN
  • Waits 2 seconds for cluster formation and leader election

The IntoConfig trait allows passing either a single config string (applied to all 3 nodes) or an array of 3 configs (one per node):

// Same config for all nodes
let cluster = run_cluster("configs/jetstream.conf");

// Different configs per node
let cluster = run_cluster(["node1.conf", "node2.conf", "node3.conf"]);

Cluster Struct

pub struct Cluster {
    pub servers: Vec<Server>,
}

impl Cluster {
    pub fn client_url(&self) -> String {
        self.servers[0].client_url()
    }
}

Server Methods

impl Server {
    pub fn restart(&mut self)
    pub fn client_url(&self) -> String
    pub fn client_port(&self) -> u16
    pub fn client_url_with(&self, user: &str, pass: &str) -> String
    pub fn client_url_with_token(&self, token: &str) -> String
    pub fn client_pid(&self) -> usize
}

restart()

Kills the current server process, waits for it to exit, then restarts with the same config, port, and ID. Used for testing reconnection behavior.

client_url()

Connects to the server's TCP port, reads the INFO line, parses the JSON, and constructs a URL:

  • nats://localhost:<port> for non-TLS
  • tls://localhost:<port> for TLS-required servers

Polls the log file (up to 10 seconds) to discover the client address, since the port may be dynamically allocated.

client_pid()

Reads the PID file and returns the server process ID. Used for sending signals.

set_lame_duck_mode

pub fn set_lame_duck_mode(s: &Server)

Sends the lame duck mode signal to the server:

nats-server --signal ldm=<pid>

is_port_available

pub fn is_port_available(port: usize) -> bool

Tests if a TCP port is available by attempting to bind to it.

Server Lifecycle

Spawning

The do_run function constructs and spawns the server process:

fn do_run(cfg: &str, port: Option<&str>, id: Option<String>) -> Inner {
    let id = id.unwrap_or_else(|| nuid::next().to_string());
    let logfile = env::temp_dir().join(format!("nats-server-{id}.log"));
    let pidfile = env::temp_dir().join(format!("nats-server-{id}.pid"));
    let store_dir = env::temp_dir().join(format!("store-dir-{id}"));

    let mut cmd = Command::new("nats-server");
    cmd.arg("--store_dir").arg(store_dir.as_path())
       .arg("-p");

    match port {
        Some(port) => cmd.arg(port),
        None => cmd.arg("-1"),  // Dynamic port
    };

    cmd.arg("-l").arg(logfile.as_os_str())
       .arg("-P").arg(pidfile.as_os_str());

    if !cfg.is_empty() {
        cmd.arg("-c").arg(cfg);
    }

    let child = cmd.spawn().unwrap();
    // ...
}

Key flags:

  • --store_dir — JetStream storage directory in temp
  • -p -1 — Dynamic port allocation (or explicit port)
  • -l — Log file path
  • -P — PID file path
  • -c — Config file path

Cleanup (Drop)

impl Drop for Server {
    fn drop(&mut self) {
        self.inner.child.kill().unwrap();
        self.inner.child.wait().unwrap();

        if let Ok(log) = fs::read_to_string(self.inner.logfile.as_os_str()) {
            // Clean up JetStream storage directory if found in log
            if let Some(caps) = SD_RE.captures(&log) {
                let sd = caps.get(1).map_or("", |m| m.as_str());
                fs::remove_dir_all(sd).ok();
            }
            // Remove log file
            fs::remove_file(self.inner.logfile.as_os_str()).ok();
        }
    }
}

The regex SD_RE matches the "Store Directory" line in the server log:

.+\sStore Directory:\s+"([^"]+)"

Client URL Discovery

The client_addr method polls the log file to find the server's listen address:

fn client_addr(&self) -> String {
    for _ in 0..100 {  // 100 iterations × 500ms = 50s max
        match fs::read_to_string(self.inner.logfile.as_os_str()) {
            Ok(l) => {
                if let Some(cre) = CLIENT_RE.captures(&l) {
                    return cre.get(1).unwrap().as_str()
                        .replace("0.0.0.0", "localhost");
                } else {
                    thread::sleep(Duration::from_millis(500));
                }
            }
            _ => thread::sleep(Duration::from_millis(500)),
        }
    }
    panic!("no client addr info");
}

The regex CLIENT_RE matches:

.+\sclient connections on\s+(\S+)

After finding the address, client_url() connects to it and parses the INFO JSON to get the port and TLS requirements.

Cluster Setup

The run_cluster_node_with_port function spawns a single cluster node:

fn run_cluster_node_with_port(
    cfg: &str,
    port: Option<&str>,
    routes: Vec<usize>,
    name: String,
    cluster_name: String,
    cluster: usize,
) -> Server

Additional flags for cluster nodes:

  • --routes nats://127.0.0.1:<port1>,nats://127.0.0.1:<port2> — routes to other cluster members
  • --cluster nats://127.0.0.1:<cluster_port> — cluster listen address
  • --cluster_name <name> — cluster name for grouping
  • -n <name> — server name

Port allocation for a cluster:

Base port: random in 3000..50000
Node 1: client_port=base,   cluster_port=base+1
Node 2: client_port=base+100, cluster_port=base+101
Node 3: client_port=base+200, cluster_port=base+201

Each port is checked for availability with is_port_available(), including the +1 cluster port.

JetStream Config

Location: configs/jetstream.conf

jetstream: {
  strict: true,
  max_mem_store:  8MiB,
  max_file_store: 10GiB
}

This is the default test config for JetStream-enabled servers. It enables strict mode and sets memory/file storage limits suitable for testing.

Test Usage Patterns

#[tokio::test]
async fn basic_test() {
    let server = nats_server::run_server("configs/jetstream.conf");
    let client = async_nats::connect(server.client_url()).await.unwrap();
    // ... test logic ...
    // Server cleaned up on drop
}

#[tokio::test]
async fn cluster_test() {
    let cluster = nats_server::run_cluster("configs/jetstream.conf");
    let client = async_nats::connect(cluster.client_url()).await.unwrap();
    // ... test logic ...
}

#[tokio::test]
async fn reconnect_test() {
    let mut server = nats_server::run_server("");
    let client = async_nats::connect(server.client_url()).await.unwrap();
    
    // Restart the server to test reconnection
    server.restart();
    
    // Client should reconnect automatically
    client.publish("test", "data".into()).await.unwrap();
}

Dependencies

Dependency Version Purpose
lazy_static 1.4.0 Static regex initialization
regex 1.7.1 Log parsing (store directory, client address)
url 2 URL manipulation for client_url_with
serde_json 1.0.104 INFO JSON parsing
nuid 0.5 Unique server ID generation
rand 0.10.1 Random port selection
tokio-retry 0.3.0 Exponential backoff for cluster operations

Note: async-nats is only a dev-dependency, used in the crate's own integration tests.