Skip to main content

stryke/
agent.rs

1//! `stryke agent` — Persistent load testing agent for distributed stress testing.
2//!
3//! ## Overview
4//!
5//! The agent runs as a daemon, connects to a controller via TCP, and awaits commands.
6//! When the controller sends a FIRE command, the agent executes stress workloads until
7//! TERMINATE is received. Designed for enterprise load testing of distributed clusters.
8//!
9//! ## Config file
10//!
11//! Default: `~/.config/stryke/agent.toml`
12//!
13//! ```toml
14//! [controller]
15//! host = "controller.example.com"
16//! port = 9999
17//!
18//! [limits]
19//! max_temp = 85       # auto-terminate if CPU temp exceeds (Celsius)
20//! max_duration = 3600 # max seconds per stress session
21//!
22//! [agent]
23//! name = "node-01"    # optional, defaults to hostname
24//! ```
25//!
26//! ## Wire protocol
27//!
28//! Same framing as remote_wire: `[u64 LE length][u8 kind][bincode payload]`
29//!
30//! ```text
31//! controller                      agent
32//!     │                             │
33//!     │◄──── AGENT_HELLO ───────────│  (hostname, cores, memory)
34//!     │───── AGENT_HELLO_ACK ──────►│  (session_id, config overrides)
35//!     │                             │
36//!     │───── FIRE ─────────────────►│  (workload type, duration, intensity)
37//!     │◄──── METRICS ───────────────│  (cpu%, temp, memory, hashes/sec)
38//!     │◄──── METRICS ───────────────│
39//!     │───── TERMINATE ────────────►│
40//!     │◄──── TERM_ACK ──────────────│  (final stats)
41//!     │                             │
42//!     │───── SHUTDOWN ─────────────►│
43//!     │                             └─ exit 0
44//! ```
45
46use serde::{Deserialize, Serialize};
47use std::io::{Read, Write};
48use std::net::TcpStream;
49use std::path::PathBuf;
50use std::sync::atomic::{AtomicBool, Ordering};
51use std::sync::Arc;
52use std::time::{Duration, Instant};
53
54/// Agent protocol frame kinds
55pub mod frame_kind {
56    /// `AGENT_HELLO` constant.
57    pub const AGENT_HELLO: u8 = 0x10;
58    /// `AGENT_HELLO_ACK` constant.
59    pub const AGENT_HELLO_ACK: u8 = 0x11;
60    /// `FIRE` constant.
61    pub const FIRE: u8 = 0x12;
62    /// `METRICS` constant.
63    pub const METRICS: u8 = 0x13;
64    /// `TERMINATE` constant.
65    pub const TERMINATE: u8 = 0x14;
66    /// `TERM_ACK` constant.
67    pub const TERM_ACK: u8 = 0x15;
68    /// `SHUTDOWN` constant.
69    pub const SHUTDOWN: u8 = 0x16;
70    /// `STATUS` constant.
71    pub const STATUS: u8 = 0x17;
72    /// `STATUS_RESP` constant.
73    pub const STATUS_RESP: u8 = 0x18;
74    /// Controller → agent: arbitrary stryke source to run against the agent's persistent VM.
75    pub const EVAL: u8 = 0x19;
76    /// Agent → controller: result of an EVAL frame (success output or error message).
77    pub const EVAL_RESULT: u8 = 0x1A;
78    /// Agent → controller: optional auth token sent right after AGENT_HELLO.
79    /// Controllers in `:cloistered` mode reject any agent whose AUTH token
80    /// isn't in their accepted-token set. Payload: bincode-serialized AgentAuth.
81    pub const AGENT_AUTH: u8 = 0x1B;
82    /// `ERROR` constant.
83    pub const ERROR: u8 = 0xFF;
84}
85
86/// Bumped to 2 when the `EVAL` / `EVAL_RESULT` frame kinds were added so an old
87/// (v1) agent refuses the handshake against a new controller and vice versa,
88/// rather than silently hanging on an unrecognised frame.
89pub const AGENT_PROTO_VERSION: u32 = 2;
90
91/// Agent configuration (from TOML file)
92#[derive(Debug, Clone, Serialize, Deserialize, Default)]
93pub struct AgentConfig {
94    /// `controller` field.
95    #[serde(default)]
96    pub controller: ControllerConfig,
97    /// `limits` field.
98    #[serde(default)]
99    pub limits: LimitsConfig,
100    /// `agent` field.
101    #[serde(default)]
102    pub agent: AgentIdentity,
103}
104/// `ControllerConfig` — see fields for layout.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct ControllerConfig {
107    /// `host` field.
108    #[serde(default = "default_host")]
109    pub host: String,
110    /// `port` field.
111    #[serde(default = "default_port")]
112    pub port: u16,
113}
114
115fn default_host() -> String {
116    "localhost".to_string()
117}
118fn default_port() -> u16 {
119    9999
120}
121
122impl Default for ControllerConfig {
123    fn default() -> Self {
124        Self {
125            host: default_host(),
126            port: default_port(),
127        }
128    }
129}
130/// `LimitsConfig` — see fields for layout.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct LimitsConfig {
133    /// `max_temp` field.
134    #[serde(default = "default_max_temp")]
135    pub max_temp: u32,
136    /// `max_duration` field.
137    #[serde(default = "default_max_duration")]
138    pub max_duration: u64,
139}
140
141fn default_max_temp() -> u32 {
142    85
143}
144fn default_max_duration() -> u64 {
145    3600
146}
147
148impl Default for LimitsConfig {
149    fn default() -> Self {
150        Self {
151            max_temp: default_max_temp(),
152            max_duration: default_max_duration(),
153        }
154    }
155}
156/// `AgentIdentity` — see fields for layout.
157#[derive(Debug, Clone, Serialize, Deserialize, Default)]
158pub struct AgentIdentity {
159    /// `name` field.
160    #[serde(default)]
161    pub name: Option<String>,
162}
163
164/// Hello message from agent to controller
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct AgentHello {
167    /// `proto_version` field.
168    pub proto_version: u32,
169    /// `stryke_version` field.
170    pub stryke_version: String,
171    /// `hostname` field.
172    pub hostname: String,
173    /// `cores` field.
174    pub cores: usize,
175    /// `memory_bytes` field.
176    pub memory_bytes: u64,
177    /// `agent_name` field.
178    pub agent_name: Option<String>,
179}
180
181/// Acknowledgment from controller
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct AgentHelloAck {
184    /// `session_id` field.
185    pub session_id: u64,
186    /// `accepted` field.
187    pub accepted: bool,
188    /// `message` field.
189    pub message: String,
190}
191
192/// Optional AUTH token frame — sent by an agent right after AGENT_HELLO when
193/// the `STRYKE_AGENT_TOKEN` env var is set. Controllers in `:cloistered`
194/// mode require this; controllers in open mode ignore it.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct AgentAuth {
197    /// `token` field.
198    pub token: String,
199}
200
201/// Fire command — start stress test
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct FireCommand {
204    /// `workload` field.
205    pub workload: WorkloadType,
206    /// `duration_secs` field.
207    pub duration_secs: f64,
208    pub intensity: f64, // 0.0-1.0, percentage of cores to use
209}
210/// `WorkloadType` — see variants.
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub enum WorkloadType {
213    /// `Cpu` variant.
214    Cpu,
215    Memory {
216        bytes: u64,
217    },
218    Io {
219        dir: String,
220        iterations: u64,
221    },
222    /// `Combined` variant.
223    Combined,
224    Custom {
225        code: String,
226    },
227}
228
229/// Metrics report from agent
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct AgentMetrics {
232    /// `cpu_percent` field.
233    pub cpu_percent: f64,
234    /// `memory_used` field.
235    pub memory_used: u64,
236    /// `hashes_per_sec` field.
237    pub hashes_per_sec: u64,
238    /// `elapsed_secs` field.
239    pub elapsed_secs: f64,
240    /// `state` field.
241    pub state: AgentState,
242}
243/// `AgentState` — see variants.
244#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
245pub enum AgentState {
246    /// `Idle` variant.
247    Idle,
248    /// `Armed` variant.
249    Armed,
250    /// `Firing` variant.
251    Firing,
252    /// `Terminated` variant.
253    Terminated,
254}
255
256/// Termination acknowledgment with final stats
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct TermAck {
259    /// `total_hashes` field.
260    pub total_hashes: u64,
261    /// `total_duration` field.
262    pub total_duration: f64,
263    /// `peak_cpu` field.
264    pub peak_cpu: f64,
265}
266
267/// `EVAL` frame payload: source to be parsed and run against the agent's persistent
268/// `VMHelper`. Package globals (`$main::name`) and `sub` declarations carry across
269/// frames so successive `eval` commands compose into a remote REPL session.
270/// (Per-frame lexical `my`/`our` bindings are scoped to their parse unit — the same
271/// constraint a Perl `-de0` debugger session has — use `$main::name` to persist.)
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct EvalCommand {
274    /// `code` field.
275    pub code: String,
276}
277
278/// `EVAL_RESULT` frame payload: outcome of an `EvalCommand`. `ok=true` carries the
279/// stringified return value of the script; `ok=false` carries the error message.
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct EvalResult {
282    /// `ok` field.
283    pub ok: bool,
284    /// `output` field.
285    pub output: String,
286}
287
288/// Stateless EVAL handler — extracted so tests can exercise it without spinning up
289/// the full controller/agent connect dance. Takes a payload, the persistent
290/// interpreter, and writes the `EVAL_RESULT` frame straight back to `stream`.
291pub fn handle_eval_frame<W: Write>(
292    stream: &mut W,
293    interp: &mut crate::vm_helper::VMHelper,
294    payload: &[u8],
295) -> std::io::Result<()> {
296    let result = match bincode::deserialize::<EvalCommand>(payload) {
297        Ok(cmd) => match crate::parse_and_run_string(&cmd.code, interp) {
298            Ok(v) => EvalResult {
299                ok: true,
300                output: v.to_string(),
301            },
302            Err(e) => EvalResult {
303                ok: false,
304                output: format!("{}", e),
305            },
306        },
307        Err(e) => EvalResult {
308            ok: false,
309            output: format!("malformed EVAL frame: {}", e),
310        },
311    };
312    let bytes = bincode::serialize(&result).expect("serialize EvalResult");
313    write_frame(stream, frame_kind::EVAL_RESULT, &bytes)
314}
315
316/// Read a framed message from a stream
317pub fn read_frame<R: Read>(r: &mut R) -> std::io::Result<(u8, Vec<u8>)> {
318    let mut len_buf = [0u8; 8];
319    r.read_exact(&mut len_buf)?;
320    let len = u64::from_le_bytes(len_buf) as usize;
321    if len < 1 {
322        return Err(std::io::Error::new(
323            std::io::ErrorKind::InvalidData,
324            "empty frame",
325        ));
326    }
327    let mut payload = vec![0u8; len];
328    r.read_exact(&mut payload)?;
329    let kind = payload[0];
330    Ok((kind, payload[1..].to_vec()))
331}
332
333/// Write a framed message to a stream
334pub fn write_frame<W: Write>(w: &mut W, kind: u8, payload: &[u8]) -> std::io::Result<()> {
335    let total_len = 1 + payload.len();
336    w.write_all(&(total_len as u64).to_le_bytes())?;
337    w.write_all(&[kind])?;
338    w.write_all(payload)?;
339    w.flush()
340}
341
342/// Get default config path
343pub fn default_config_path() -> PathBuf {
344    dirs::config_dir()
345        .unwrap_or_else(|| PathBuf::from("."))
346        .join("stryke")
347        .join("agent.toml")
348}
349
350/// Load config from file or return defaults
351pub fn load_config(path: Option<&str>) -> AgentConfig {
352    let config_path = path.map(PathBuf::from).unwrap_or_else(default_config_path);
353
354    if config_path.exists() {
355        match std::fs::read_to_string(&config_path) {
356            Ok(content) => match toml::from_str(&content) {
357                Ok(config) => {
358                    eprintln!("stryke agent: loaded config from {}", config_path.display());
359                    return config;
360                }
361                Err(e) => {
362                    eprintln!(
363                        "stryke agent: config parse error {}: {}",
364                        config_path.display(),
365                        e
366                    );
367                }
368            },
369            Err(e) => {
370                eprintln!("stryke agent: cannot read {}: {}", config_path.display(), e);
371            }
372        }
373    }
374
375    eprintln!("stryke agent: using default config (controller=localhost:9999)");
376    AgentConfig::default()
377}
378
379/// Get system hostname
380fn get_hostname() -> String {
381    hostname::get()
382        .map(|h| h.to_string_lossy().to_string())
383        .unwrap_or_else(|_| "unknown".to_string())
384}
385
386/// Get CPU core count
387fn get_cores() -> usize {
388    std::thread::available_parallelism()
389        .map(|p| p.get())
390        .unwrap_or(1)
391}
392
393/// Get total system memory (approximate)
394fn get_memory() -> u64 {
395    // Simple heuristic — real implementation would use sysinfo crate
396    // For now, return a placeholder based on typical server memory
397    16 * 1024 * 1024 * 1024 // 16GB default
398}
399
400/// Run the stress workload — pins ALL cores to 100% TDP
401fn run_workload(
402    workload: &WorkloadType,
403    duration_secs: f64,
404    terminate: Arc<AtomicBool>,
405) -> (u64, f64) {
406    use sha2::{Digest, Sha256};
407    use std::sync::atomic::AtomicU64;
408
409    let start = Instant::now();
410    let duration = Duration::from_secs_f64(duration_secs);
411    let num_cores = std::thread::available_parallelism()
412        .map(|p| p.get())
413        .unwrap_or(1);
414
415    match workload {
416        WorkloadType::Cpu | WorkloadType::Combined => {
417            let total_hashes = AtomicU64::new(0);
418
419            std::thread::scope(|s| {
420                for _ in 0..num_cores {
421                    let term = Arc::clone(&terminate);
422                    let counter = &total_hashes;
423                    s.spawn(move || {
424                        let mut local_count: u64 = 0;
425                        let mut data = [0u8; 64];
426
427                        while start.elapsed() < duration && !term.load(Ordering::Relaxed) {
428                            for _ in 0..1000 {
429                                let hash = Sha256::digest(data);
430                                data[..32].copy_from_slice(&hash);
431                                local_count += 1;
432                            }
433                        }
434
435                        counter.fetch_add(local_count, Ordering::Relaxed);
436                    });
437                }
438            });
439
440            (
441                total_hashes.load(Ordering::Relaxed),
442                start.elapsed().as_secs_f64(),
443            )
444        }
445        WorkloadType::Memory { bytes } => {
446            let bytes_per_core = *bytes as usize / num_cores;
447
448            std::thread::scope(|s| {
449                for core_id in 0..num_cores {
450                    let term = Arc::clone(&terminate);
451                    s.spawn(move || {
452                        if term.load(Ordering::Relaxed) {
453                            return;
454                        }
455                        let mut buf: Vec<u8> = vec![0u8; bytes_per_core];
456                        for i in (0..bytes_per_core).step_by(4096) {
457                            if term.load(Ordering::Relaxed) {
458                                break;
459                            }
460                            buf[i] = ((i + core_id) & 0xff) as u8;
461                        }
462                        std::hint::black_box(&buf);
463                    });
464                }
465            });
466
467            (*bytes, start.elapsed().as_secs_f64())
468        }
469        WorkloadType::Io { dir, iterations } => {
470            use std::fs;
471            use std::io::Write as IoWrite;
472
473            let total_bytes = AtomicU64::new(0);
474            let iters_per_core = *iterations as usize / num_cores;
475
476            std::thread::scope(|s| {
477                for core_id in 0..num_cores {
478                    let term = Arc::clone(&terminate);
479                    let counter = &total_bytes;
480                    let dir = dir.clone();
481                    s.spawn(move || {
482                        let io_data = vec![0xABu8; 1_000_000];
483                        for i in 0..iters_per_core {
484                            if term.load(Ordering::Relaxed) {
485                                break;
486                            }
487                            let path = format!("{}/stryke_stress_{}_{}", dir, core_id, i);
488                            if let Ok(mut f) = fs::File::create(&path) {
489                                let _ = f.write_all(&io_data);
490                            }
491                            let _ = fs::read(&path);
492                            let _ = fs::remove_file(&path);
493                            counter.fetch_add(io_data.len() as u64, Ordering::Relaxed);
494                        }
495                    });
496                }
497            });
498
499            (
500                total_bytes.load(Ordering::Relaxed),
501                start.elapsed().as_secs_f64(),
502            )
503        }
504        WorkloadType::Custom { code: _ } => {
505            // TODO: execute custom stryke code
506            (0, start.elapsed().as_secs_f64())
507        }
508    }
509}
510
511/// Main agent loop
512pub fn run_agent(config_path: Option<&str>) -> i32 {
513    run_agent_with_overrides(config_path, None, None)
514}
515
516/// Drive the agent loop with **fully explicit** controller + name — used by the
517/// `agent(...)` stryke builtin. Skips the config-file load entirely so a script
518/// invoking `agent("controller.local:9999", "node-01")` is completely
519/// self-describing and doesn't depend on `~/.config/stryke/agent.toml`.
520pub fn run_agent_with_explicit(host: &str, port: u16, name: Option<&str>) -> i32 {
521    let config = AgentConfig {
522        controller: ControllerConfig {
523            host: host.to_string(),
524            port,
525        },
526        limits: LimitsConfig::default(),
527        agent: AgentIdentity {
528            name: name.map(|s| s.to_string()),
529        },
530    };
531    run_agent_with_config(config)
532}
533
534/// Main agent loop with CLI overrides
535pub fn run_agent_with_overrides(
536    config_path: Option<&str>,
537    controller_override: Option<&str>,
538    port_override: Option<u16>,
539) -> i32 {
540    let mut config = load_config(config_path);
541
542    if let Some(host) = controller_override {
543        config.controller.host = host.to_string();
544    }
545    if let Some(port) = port_override {
546        config.controller.port = port;
547    }
548
549    run_agent_with_config(config)
550}
551
552/// Shared agent session: connect to controller, handshake, then run the frame
553/// loop (FIRE / TERMINATE / STATUS / EVAL / SHUTDOWN) until the controller
554/// disconnects or sends SHUTDOWN. Caller supplies the fully-resolved config;
555/// no file I/O or CLI parsing happens here.
556fn run_agent_with_config(config: AgentConfig) -> i32 {
557    let addr = format!("{}:{}", config.controller.host, config.controller.port);
558
559    eprintln!("stryke agent: connecting to controller at {}", addr);
560
561    let mut stream = match TcpStream::connect(&addr) {
562        Ok(s) => s,
563        Err(e) => {
564            eprintln!("stryke agent: connection failed: {}", e);
565            return 1;
566        }
567    };
568
569    // Set read timeout for non-blocking checks
570    let _ = stream.set_read_timeout(Some(Duration::from_millis(100)));
571
572    // Send AGENT_HELLO
573    let hello = AgentHello {
574        proto_version: AGENT_PROTO_VERSION,
575        stryke_version: env!("CARGO_PKG_VERSION").to_string(),
576        hostname: get_hostname(),
577        cores: get_cores(),
578        memory_bytes: get_memory(),
579        agent_name: config.agent.name.clone(),
580    };
581
582    let hello_bytes = bincode::serialize(&hello).expect("serialize hello");
583    if let Err(e) = write_frame(&mut stream, frame_kind::AGENT_HELLO, &hello_bytes) {
584        eprintln!("stryke agent: failed to send hello: {}", e);
585        return 1;
586    }
587
588    // If the controller is in :cloistered mode, it expects an AGENT_AUTH
589    // frame right after HELLO. We send one unconditionally when the
590    // STRYKE_AGENT_TOKEN env var is set; open-mode controllers ignore
591    // unexpected AUTH frames (they don't read one). Costs nothing if the
592    // controller is open, gates the connection if it's cloistered.
593    if let Ok(token) = std::env::var("STRYKE_AGENT_TOKEN") {
594        if !token.is_empty() {
595            let auth = AgentAuth { token };
596            let auth_bytes = bincode::serialize(&auth).expect("serialize auth");
597            let _ = write_frame(&mut stream, frame_kind::AGENT_AUTH, &auth_bytes);
598        }
599    }
600
601    // Wait for HELLO_ACK
602    let (kind, payload) = match read_frame(&mut stream) {
603        Ok(f) => f,
604        Err(e) => {
605            eprintln!("stryke agent: failed to read hello ack: {}", e);
606            return 1;
607        }
608    };
609
610    if kind != frame_kind::AGENT_HELLO_ACK {
611        eprintln!("stryke agent: unexpected frame kind: {}", kind);
612        return 1;
613    }
614
615    let ack: AgentHelloAck = match bincode::deserialize(&payload) {
616        Ok(a) => a,
617        Err(e) => {
618            eprintln!("stryke agent: failed to parse hello ack: {}", e);
619            return 1;
620        }
621    };
622
623    if !ack.accepted {
624        eprintln!("stryke agent: rejected by controller: {}", ack.message);
625        return 1;
626    }
627
628    eprintln!(
629        "stryke agent: connected (session_id={}, cores={}, hostname={})",
630        ack.session_id,
631        get_cores(),
632        get_hostname()
633    );
634    eprintln!("stryke agent: awaiting commands...");
635
636    // Disable read timeout for blocking reads
637    let _ = stream.set_read_timeout(None);
638
639    // Main command loop
640    let terminate = Arc::new(AtomicBool::new(false));
641    #[allow(unused_assignments)]
642    let mut state = AgentState::Idle;
643    // Persistent interpreter — state from one EVAL frame survives to the next so
644    // successive `eval` commands compose into a remote REPL session.
645    let mut interp = crate::vm_helper::VMHelper::new();
646    let mut session_start: Option<Instant> = None;
647    let mut total_hashes: u64 = 0;
648    let mut peak_cpu: f64 = 0.0;
649
650    loop {
651        let (kind, payload) = match read_frame(&mut stream) {
652            Ok(f) => f,
653            Err(e) => {
654                if e.kind() == std::io::ErrorKind::UnexpectedEof {
655                    eprintln!("stryke agent: controller disconnected");
656                } else {
657                    eprintln!("stryke agent: read error: {}", e);
658                }
659                break;
660            }
661        };
662
663        match kind {
664            frame_kind::FIRE => {
665                let cmd: FireCommand = match bincode::deserialize(&payload) {
666                    Ok(c) => c,
667                    Err(e) => {
668                        eprintln!("stryke agent: invalid FIRE command: {}", e);
669                        continue;
670                    }
671                };
672
673                eprintln!(
674                    "stryke agent: FIRE received (duration={}s, intensity={})",
675                    cmd.duration_secs, cmd.intensity
676                );
677
678                #[allow(unused_assignments)]
679                {
680                    state = AgentState::Firing;
681                }
682                session_start = Some(Instant::now());
683                terminate.store(false, Ordering::Relaxed);
684
685                // Run workload in a separate thread so we can handle TERMINATE
686                let term_clone = Arc::clone(&terminate);
687                let workload = cmd.workload.clone();
688                let duration = cmd.duration_secs;
689
690                let handle =
691                    std::thread::spawn(move || run_workload(&workload, duration, term_clone));
692
693                // Wait for completion or termination
694                let (hashes, elapsed) = handle.join().unwrap_or((0, 0.0));
695                total_hashes += hashes;
696
697                // Send final metrics
698                let metrics = AgentMetrics {
699                    cpu_percent: 100.0, // Was at max
700                    memory_used: 0,
701                    hashes_per_sec: if elapsed > 0.0 {
702                        (hashes as f64 / elapsed) as u64
703                    } else {
704                        0
705                    },
706                    elapsed_secs: elapsed,
707                    state: AgentState::Idle,
708                };
709
710                let metrics_bytes = bincode::serialize(&metrics).expect("serialize metrics");
711                let _ = write_frame(&mut stream, frame_kind::METRICS, &metrics_bytes);
712
713                state = AgentState::Idle;
714                eprintln!(
715                    "stryke agent: workload complete ({} hashes in {:.2}s)",
716                    hashes, elapsed
717                );
718            }
719
720            frame_kind::TERMINATE => {
721                eprintln!("stryke agent: TERMINATE received");
722                terminate.store(true, Ordering::Relaxed);
723
724                let elapsed = session_start
725                    .map(|s| s.elapsed().as_secs_f64())
726                    .unwrap_or(0.0);
727                let term_ack = TermAck {
728                    total_hashes,
729                    total_duration: elapsed,
730                    peak_cpu,
731                };
732
733                let ack_bytes = bincode::serialize(&term_ack).expect("serialize term_ack");
734                let _ = write_frame(&mut stream, frame_kind::TERM_ACK, &ack_bytes);
735
736                state = AgentState::Idle;
737                total_hashes = 0;
738                peak_cpu = 0.0;
739                session_start = None;
740            }
741
742            frame_kind::STATUS => {
743                let metrics = AgentMetrics {
744                    cpu_percent: if state == AgentState::Firing {
745                        100.0
746                    } else {
747                        0.0
748                    },
749                    memory_used: 0,
750                    hashes_per_sec: 0,
751                    elapsed_secs: session_start
752                        .map(|s| s.elapsed().as_secs_f64())
753                        .unwrap_or(0.0),
754                    state,
755                };
756
757                let metrics_bytes = bincode::serialize(&metrics).expect("serialize metrics");
758                let _ = write_frame(&mut stream, frame_kind::STATUS_RESP, &metrics_bytes);
759            }
760
761            frame_kind::EVAL => {
762                eprintln!("stryke agent: EVAL received ({} bytes)", payload.len());
763                if let Err(e) = handle_eval_frame(&mut stream, &mut interp, &payload) {
764                    eprintln!("stryke agent: failed to write EVAL_RESULT: {}", e);
765                }
766            }
767
768            frame_kind::SHUTDOWN => {
769                eprintln!("stryke agent: SHUTDOWN received, exiting");
770                terminate.store(true, Ordering::Relaxed);
771                break;
772            }
773
774            _ => {
775                eprintln!("stryke agent: unknown frame kind: {}", kind);
776            }
777        }
778    }
779
780    eprintln!("stryke agent: disconnected");
781    0
782}
783
784/// Print agent help
785pub fn print_help() {
786    println!("stryke agent — Distributed load testing agent");
787    println!();
788    println!("USAGE:");
789    println!("    stryke agent [OPTIONS]");
790    println!();
791    println!("OPTIONS:");
792    println!("    -c, --config PATH    Config file (default: ~/.config/stryke/agent.toml)");
793    println!("    --controller HOST    Controller address (overrides config)");
794    println!("    --port PORT          Controller port (overrides config)");
795    println!("    --help               Print this help");
796    println!();
797    println!("CONFIG FILE:");
798    println!("    ~/.config/stryke/agent.toml");
799    println!();
800    println!("    [controller]");
801    println!("    host = \"controller.example.com\"");
802    println!("    port = 9999");
803    println!();
804    println!("    [limits]");
805    println!("    max_temp = 85");
806    println!("    max_duration = 3600");
807    println!();
808    println!("    [agent]");
809    println!("    name = \"node-01\"");
810    println!();
811    println!("EXAMPLE:");
812    println!("    stryke agent                           # use config file");
813    println!("    stryke agent --controller 10.0.0.1     # connect to specific host");
814}
815
816#[cfg(test)]
817mod tests {
818    use super::*;
819    use std::io::Cursor;
820    use std::net::{TcpListener, TcpStream};
821    use std::thread;
822
823    /// `AgentHello` round-trips through bincode with every field intact —
824    /// including `Option<String>` agent_name for both Some/None shapes. This is
825    /// the FIRST frame of every connection; any schema drift here breaks
826    /// every v1↔v2 controller↔agent handshake silently. The TCP loopback test
827    /// covers the protocol layer end-to-end; this pin isolates the serialization
828    /// layer so a regression in either gets a focused diagnostic instead of a
829    /// generic "handshake timeout" failure.
830    #[test]
831    fn agent_hello_bincode_roundtrip_preserves_all_fields() {
832        let hello = AgentHello {
833            proto_version: AGENT_PROTO_VERSION,
834            stryke_version: "0.14.30".to_string(),
835            hostname: "lab-node-07.example.com".to_string(),
836            cores: 32,
837            memory_bytes: 256u64 * 1024 * 1024 * 1024, // 256 GiB
838            agent_name: Some("gpu-worker-α".to_string()), // unicode in name
839        };
840        let bytes = bincode::serialize(&hello).expect("serialize Some-name");
841        let back: AgentHello = bincode::deserialize(&bytes).expect("deserialize");
842        assert_eq!(back.proto_version, AGENT_PROTO_VERSION);
843        assert_eq!(back.stryke_version, "0.14.30");
844        assert_eq!(back.hostname, "lab-node-07.example.com");
845        assert_eq!(back.cores, 32);
846        assert_eq!(back.memory_bytes, 256u64 * 1024 * 1024 * 1024);
847        assert_eq!(back.agent_name.as_deref(), Some("gpu-worker-α"));
848
849        // None branch — agent with no `[agent] name` in config.
850        let anon = AgentHello {
851            proto_version: AGENT_PROTO_VERSION,
852            stryke_version: "0.14.30".to_string(),
853            hostname: "anon".to_string(),
854            cores: 1,
855            memory_bytes: 0,
856            agent_name: None,
857        };
858        let bytes = bincode::serialize(&anon).unwrap();
859        let back: AgentHello = bincode::deserialize(&bytes).unwrap();
860        assert!(back.agent_name.is_none(), "None must round-trip as None");
861    }
862
863    /// `AgentHelloAck` round-trips. Same rationale as the HELLO pin — this is
864    /// the second frame of every connection, and a schema break here means
865    /// every agent fails to interpret the controller's accept/reject signal.
866    #[test]
867    fn agent_hello_ack_bincode_roundtrip_preserves_all_fields() {
868        let ack = AgentHelloAck {
869            session_id: u64::MAX,
870            accepted: false,
871            message: "incompatible proto_version: agent=1 controller=2".to_string(),
872        };
873        let bytes = bincode::serialize(&ack).expect("serialize ack");
874        let back: AgentHelloAck = bincode::deserialize(&bytes).expect("deserialize ack");
875        assert_eq!(back.session_id, u64::MAX);
876        assert!(!back.accepted);
877        assert_eq!(
878            back.message,
879            "incompatible proto_version: agent=1 controller=2"
880        );
881    }
882
883    /// `EvalCommand` and `EvalResult` round-trip cleanly through bincode (the
884    /// serializer the rest of the agent protocol already uses).
885    #[test]
886    fn eval_command_result_bincode_roundtrip() {
887        let cmd = EvalCommand {
888            code: "1 + 2".to_string(),
889        };
890        let bytes = bincode::serialize(&cmd).unwrap();
891        let back: EvalCommand = bincode::deserialize(&bytes).unwrap();
892        assert_eq!(back.code, "1 + 2");
893
894        let res = EvalResult {
895            ok: false,
896            output: "die at -e line 1".to_string(),
897        };
898        let bytes = bincode::serialize(&res).unwrap();
899        let back: EvalResult = bincode::deserialize(&bytes).unwrap();
900        assert!(!back.ok);
901        assert_eq!(back.output, "die at -e line 1");
902    }
903
904    /// `handle_eval_frame` against an in-memory writer: success path produces a
905    /// well-formed `EVAL_RESULT` whose payload deserializes to `ok=true` with the
906    /// stringified return value.
907    #[test]
908    fn handle_eval_frame_success_writes_eval_result() {
909        let mut interp = crate::vm_helper::VMHelper::new();
910        let cmd = EvalCommand {
911            code: "21 * 2".to_string(),
912        };
913        let payload = bincode::serialize(&cmd).unwrap();
914
915        let mut out = Vec::new();
916        handle_eval_frame(&mut out, &mut interp, &payload).expect("write EVAL_RESULT");
917
918        let mut cur = Cursor::new(out);
919        let (kind, body) = read_frame(&mut cur).expect("read back");
920        assert_eq!(kind, frame_kind::EVAL_RESULT);
921        let r: EvalResult = bincode::deserialize(&body).unwrap();
922        assert!(r.ok, "expected ok=true, got {:?}", r);
923        assert_eq!(r.output, "42");
924    }
925
926    /// Error path: a parse failure becomes `ok=false` carrying the formatted error.
927    #[test]
928    fn handle_eval_frame_error_writes_eval_result_with_ok_false() {
929        let mut interp = crate::vm_helper::VMHelper::new();
930        let cmd = EvalCommand {
931            code: "this is not valid stryke @@@".to_string(),
932        };
933        let payload = bincode::serialize(&cmd).unwrap();
934
935        let mut out = Vec::new();
936        handle_eval_frame(&mut out, &mut interp, &payload).expect("write");
937
938        let mut cur = Cursor::new(out);
939        let (kind, body) = read_frame(&mut cur).expect("read back");
940        assert_eq!(kind, frame_kind::EVAL_RESULT);
941        let r: EvalResult = bincode::deserialize(&body).unwrap();
942        assert!(!r.ok, "expected ok=false on parse failure, got {:?}", r);
943        assert!(!r.output.is_empty(), "error output must not be empty");
944    }
945
946    /// State across EVAL frames: the `VMHelper` persists, so subs defined in one
947    /// frame remain callable, and package globals (`$main::name`) carry their
948    /// values to the next frame. This pin proves the REPL semantics the
949    /// controller relies on.
950    ///
951    /// Note: `my`/`our` lexical bindings are scoped to their parse_and_run_string
952    /// call (the parse unit is the scope), so cross-frame persistence requires
953    /// package-qualified names. That's the same constraint a Perl `-de0` REPL has.
954    #[test]
955    fn successive_eval_frames_share_persistent_vm_state() {
956        let mut interp = crate::vm_helper::VMHelper::new();
957
958        // Frame 1: define a package global + a sub.
959        let cmd1 = EvalCommand {
960            code: "$main::counter = 100; sub bump { $main::counter + 7 } $main::counter"
961                .to_string(),
962        };
963        let mut out1 = Vec::new();
964        handle_eval_frame(&mut out1, &mut interp, &bincode::serialize(&cmd1).unwrap()).unwrap();
965        let (_, body1) = read_frame(&mut Cursor::new(out1)).unwrap();
966        let r1: EvalResult = bincode::deserialize(&body1).unwrap();
967        assert!(r1.ok, "frame 1 must succeed, got {:?}", r1);
968        assert_eq!(r1.output, "100");
969
970        // Frame 2: the package global AND the sub from frame 1 must still be live.
971        let cmd2 = EvalCommand {
972            code: "bump()".to_string(),
973        };
974        let mut out2 = Vec::new();
975        handle_eval_frame(&mut out2, &mut interp, &bincode::serialize(&cmd2).unwrap()).unwrap();
976        let (_, body2) = read_frame(&mut Cursor::new(out2)).unwrap();
977        let r2: EvalResult = bincode::deserialize(&body2).unwrap();
978        assert!(r2.ok, "frame 2 must succeed, got {:?}", r2);
979        assert_eq!(
980            r2.output, "107",
981            "frame 2 must call the sub defined in frame 1 and see $main::counter"
982        );
983    }
984
985    /// Full TCP round-trip: a real `TcpListener` accepts one connection, an
986    /// "agent" thread runs `handle_eval_frame` against the frame, the client side
987    /// gets back the expected `EVAL_RESULT`. This pins the wire path the
988    /// controller's `eval_all` walks at runtime, sans the controller setup.
989    #[test]
990    fn tcp_loopback_eval_roundtrip() {
991        let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback");
992        let addr = listener.local_addr().unwrap();
993
994        let agent_handle = thread::spawn(move || {
995            let (mut server, _) = listener.accept().expect("accept");
996            let mut interp = crate::vm_helper::VMHelper::new();
997            let (kind, payload) = read_frame(&mut server).expect("read EVAL");
998            assert_eq!(kind, frame_kind::EVAL);
999            handle_eval_frame(&mut server, &mut interp, &payload).expect("reply");
1000        });
1001
1002        let mut client = TcpStream::connect(addr).expect("connect");
1003        let cmd = EvalCommand {
1004            code: "join(\",\", 1:5)".to_string(),
1005        };
1006        let body = bincode::serialize(&cmd).unwrap();
1007        write_frame(&mut client, frame_kind::EVAL, &body).expect("send EVAL");
1008
1009        let (kind, payload) = read_frame(&mut client).expect("read EVAL_RESULT");
1010        assert_eq!(kind, frame_kind::EVAL_RESULT);
1011        let r: EvalResult = bincode::deserialize(&payload).unwrap();
1012        assert!(r.ok, "expected ok=true, got {:?}", r);
1013        assert_eq!(r.output, "1,2,3,4,5");
1014
1015        agent_handle.join().expect("agent thread");
1016    }
1017}