Skip to main content

netget/pipe/
mod.rs

1//! Pipes: connect one instance's events to another instance's input.
2//!
3//! Today every server/client is an island. A *pipe* is a deterministic tap that
4//! forwards one server's network events into another server's input, so an
5//! operator (or the LLM, via a `create_pipe` action) can wire services together
6//! without an LLM round-trip per hop.
7//!
8//! The wiring is data, not code:
9//!
10//! ```json
11//! { "from": <server_id>, "on": "http_request",
12//!   "to": <server_id>, "as": "send_tcp_data",
13//!   "map": { "data": "{method} {path}\n" } }
14//! ```
15//!
16//! When server `from` raises the `on` event, the `map` templates are rendered
17//! against the event's structured data (`{method}`, `{path}`, `{headers.host}`,
18//! …) and the resulting payload is delivered into server `to`. This reuses the
19//! existing event system ([`crate::protocol::event_type::Event`], tapped once in
20//! `call_llm`) rather than adding any per-protocol glue.
21//!
22//! ## Delivery model (first slice)
23//!
24//! A server has no *inbound* action executor — its input arrives on a real
25//! socket — so a pipe feeds a server sink by opening a short TCP connection to
26//! the target's listen address and writing the mapped payload bytes. The payload
27//! comes from the mapped `data` field, honouring an optional `encoding`
28//! (`"utf8"` default, or `"hex"`), exactly like `send_tcp_data`. This works for
29//! any TCP-listening sink (TCP, and by raw bytes any text protocol); richer
30//! per-protocol handshakes and *client* sinks (delivered through their own
31//! outbound executor) are deliberately left as future work — see the module-level
32//! notes in the task report.
33//!
34//! ## Safety
35//!
36//! * **Cycle refusal.** Adding a pipe whose graph edge `from -> to` would close a
37//!   loop (including the self-loop `A -> A`) is refused by [`would_create_cycle`],
38//!   so the obvious `A -> B -> A` flood cannot be declared.
39//! * **Bounded fan-out.** Deliveries run under a process-wide semaphore
40//!   ([`MAX_INFLIGHT_DELIVERIES`]). A high-rate source feeding a slow sink does
41//!   not queue without bound: once the in-flight cap is reached, further
42//!   deliveries are **dropped with a `warn!`**, never buffered, so memory stays
43//!   bounded. Per-payload size is capped at [`MAX_PAYLOAD_BYTES`].
44//! * **Lifecycle.** Pipes touching a server are torn down when that server
45//!   closes, mirroring scheduled-task scoping (see
46//!   `AppStateInner::teardown_server`).
47
48use crate::protocol::event_type::Event;
49use crate::state::app_state::AppState;
50use crate::state::server::ServerStatus;
51use crate::state::ServerId;
52use anyhow::{anyhow, bail, Result};
53use serde::{Deserialize, Serialize};
54use serde_json::Value;
55use std::collections::BTreeMap;
56use std::io::Write;
57use std::net::SocketAddr;
58use std::sync::{Arc, LazyLock};
59use std::time::Duration;
60use tokio::io::AsyncWriteExt;
61use tokio::sync::Semaphore;
62use tracing::{debug, warn};
63
64/// Maximum concurrent in-flight pipe deliveries, process-wide. Deliveries beyond
65/// this are dropped-with-log rather than queued, so a slow sink cannot make a
66/// fast source exhaust memory.
67pub const MAX_INFLIGHT_DELIVERIES: usize = 128;
68
69/// Cap on a single rendered payload. A pipe is for log lines and small records,
70/// not bulk transfer.
71pub const MAX_PAYLOAD_BYTES: usize = 64 * 1024;
72
73/// Limit the intermediate template representation as well as the decoded payload.
74/// This allows separated hex while preventing repeated substitutions from allocating
75/// arbitrarily large strings before the payload limit can run.
76pub const MAX_RENDERED_BYTES: usize = 1024 * 1024;
77
78const DELIVERY_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
79const DELIVERY_WRITE_TIMEOUT: Duration = Duration::from_secs(2);
80
81/// Process-wide bound on concurrent deliveries. A single NetGet process owns all
82/// pipes, so a global cap is exactly the right granularity.
83static PIPE_INFLIGHT: LazyLock<Arc<Semaphore>> =
84    LazyLock::new(|| Arc::new(Semaphore::new(MAX_INFLIGHT_DELIVERIES)));
85
86/// Identifier for a pipe. Allocated from the same unified counter as servers and
87/// clients so ids never collide across kinds.
88pub type PipeId = u32;
89
90/// A declared pipe: forward `from`'s `on` events into `to` as `as_action`,
91/// rendering `map` templates against the event data.
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93pub struct PipeSpec {
94    /// Assigned pipe id.
95    pub id: PipeId,
96    /// Source server id whose events are tapped.
97    pub from: u32,
98    /// Event type id to tap, e.g. `"http_request"`.
99    pub on: String,
100    /// Target server id that receives the mapped input.
101    pub to: u32,
102    /// Action the mapping emulates on the target, e.g. `"send_tcp_data"`.
103    /// Advisory for the socket-delivery slice: the bytes come from the mapped
104    /// `data`/`encoding` fields regardless of the action name.
105    #[serde(rename = "as")]
106    pub as_action: String,
107    /// Field -> template. Templates reference source event fields with
108    /// `{field}` / `{dotted.path}`.
109    pub map: BTreeMap<String, String>,
110}
111
112/// Would adding edge `from -> to` create a directed cycle among `existing`
113/// pipes? Detects the self-loop `from == to` and any `to ->* from` path.
114pub fn would_create_cycle(existing: &[PipeSpec], from: u32, to: u32) -> bool {
115    if from == to {
116        return true;
117    }
118    // Reachable set from `to` following existing from->to edges; a cycle forms
119    // iff `from` is already reachable from `to`.
120    let mut stack = vec![to];
121    let mut seen = vec![to];
122    while let Some(node) = stack.pop() {
123        for e in existing.iter().filter(|e| e.from == node) {
124            if e.to == from {
125                return true;
126            }
127            if !seen.contains(&e.to) {
128                seen.push(e.to);
129                stack.push(e.to);
130            }
131        }
132    }
133    false
134}
135
136/// Render a `{field}` / `{dotted.path}` template against structured event data.
137/// A missing field renders empty (and is not an error — the payload is still
138/// well-formed). Literal braces are not supported in this first slice.
139struct RenderBuffer(Vec<u8>);
140
141impl Write for RenderBuffer {
142    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
143        if bytes.len() > MAX_RENDERED_BYTES.saturating_sub(self.0.len()) {
144            return Err(std::io::Error::other(format!(
145                "pipe rendered mapping exceeds the {MAX_RENDERED_BYTES}-byte cap"
146            )));
147        }
148        self.0.extend_from_slice(bytes);
149        Ok(bytes.len())
150    }
151
152    fn flush(&mut self) -> std::io::Result<()> {
153        Ok(())
154    }
155}
156
157fn render_template(mut tmpl: &str, data: &Value) -> Result<String> {
158    let mut out = RenderBuffer(Vec::new());
159    while let Some(open) = tmpl.find('{') {
160        out.write_all(tmpl[..open].as_bytes())?;
161        let rest = &tmpl[open + 1..];
162        let Some(close) = rest.find('}') else {
163            // Preserve an unterminated placeholder verbatim.
164            out.write_all(tmpl[open..].as_bytes())?;
165            return Ok(String::from_utf8(out.0)?);
166        };
167        let mut value = Some(data);
168        for segment in rest[..close].trim().split('.') {
169            value = value.and_then(|v| v.get(segment));
170        }
171        match value {
172            Some(Value::String(value)) => out.write_all(value.as_bytes())?,
173            None | Some(Value::Null) => {}
174            // Serialize directly into the bounded buffer; to_string() would allocate
175            // an entire arbitrarily large object before applying the limit.
176            Some(value) => serde_json::to_writer(&mut out, value)?,
177        }
178        tmpl = &rest[close + 1..];
179    }
180    out.write_all(tmpl.as_bytes())?;
181    Ok(String::from_utf8(out.0)?)
182}
183
184/// Decode a rendered `data` string into wire bytes per `encoding`.
185///
186/// Mirrors `send_tcp_data`: `"utf8"` (default) sends the characters as-is,
187/// `"hex"` decodes hex (tolerating whitespace, `:` separators and a leading
188/// `0x`). Any other value is an error rather than a silent guess.
189fn decode_payload(data: &str, encoding: &str) -> Result<Vec<u8>> {
190    match encoding {
191        "utf8" | "" => Ok(data.as_bytes().to_vec()),
192        "hex" => {
193            let cleaned: String = data
194                .trim()
195                .trim_start_matches("0x")
196                .chars()
197                .filter(|c| !c.is_ascii_whitespace() && *c != ':')
198                .collect();
199            hex::decode(&cleaned).map_err(|e| {
200                anyhow!(
201                    "pipe payload declared encoding \"hex\" but is not valid hex ({e}); \
202                     for text, use \"utf8\" or omit encoding"
203                )
204            })
205        }
206        other => bail!(
207            "invalid pipe payload encoding {other:?}; valid values are \"utf8\" (default) and \"hex\""
208        ),
209    }
210}
211
212/// Render a spec's mapping into the payload bytes to deliver to a socket sink.
213///
214/// Requires the mapping to produce a `data` field (the bytes); `encoding` is
215/// optional. Kept separate from delivery so it is unit-testable without a socket.
216pub fn render_payload(spec: &PipeSpec, event_data: &Value) -> Result<Vec<u8>> {
217    let data_tmpl = spec.map.get("data").ok_or_else(|| {
218        anyhow!(
219            "pipe #{} map must produce a 'data' field to deliver to a socket sink",
220            spec.id
221        )
222    })?;
223    let data = render_template(data_tmpl, event_data)?;
224    let encoding = spec
225        .map
226        .get("encoding")
227        .map(|t| render_template(t, event_data))
228        .transpose()?
229        .unwrap_or_else(|| "utf8".to_string());
230    let bytes = decode_payload(&data, &encoding)?;
231    if bytes.len() > MAX_PAYLOAD_BYTES {
232        bail!(
233            "pipe #{} payload is {} bytes, over the {}-byte cap",
234            spec.id,
235            bytes.len(),
236            MAX_PAYLOAD_BYTES
237        );
238    }
239    Ok(bytes)
240}
241
242/// The deliverable address of a server sink: its listen address, but only while
243/// it is actually running.
244fn deliverable_addr(status: &ServerStatus, local_addr: Option<SocketAddr>) -> Option<SocketAddr> {
245    match status {
246        ServerStatus::Running => local_addr,
247        _ => None,
248    }
249}
250
251/// Open a short TCP connection to `addr` and write `payload`, with timeouts.
252async fn deliver(addr: SocketAddr, payload: &[u8]) -> Result<()> {
253    let mut stream = tokio::time::timeout(
254        DELIVERY_CONNECT_TIMEOUT,
255        tokio::net::TcpStream::connect(addr),
256    )
257    .await
258    .map_err(|_| anyhow!("connect to {addr} timed out"))??;
259
260    tokio::time::timeout(DELIVERY_WRITE_TIMEOUT, stream.write_all(payload))
261        .await
262        .map_err(|_| anyhow!("write to {addr} timed out"))??;
263    // Flush and shut down the write side so the sink sees a clean message.
264    let _ = stream.flush().await;
265    let _ = stream.shutdown().await;
266    Ok(())
267}
268
269/// Tap point: called once per event in `call_llm`, before any handler runs, so a
270/// pipe fires regardless of how the source server answers its own peer (and even
271/// if the source's LLM call later fails).
272///
273/// This does no network I/O inline — it resolves the target address and renders
274/// the payload cheaply, then hands the actual connect+write to a bounded
275/// background task. It never blocks the source's response path and never holds a
276/// lock across an `.await` doing I/O.
277pub async fn dispatch_pipes(state: &AppState, from: ServerId, event: &Event) {
278    let specs = state.pipes_matching(from, event.id()).await;
279    if specs.is_empty() {
280        return;
281    }
282
283    for spec in specs {
284        let payload = match render_payload(&spec, &event.data) {
285            Ok(p) => p,
286            Err(e) => {
287                warn!("pipe #{}: mapping failed, dropping: {}", spec.id, e);
288                continue;
289            }
290        };
291
292        // Resolve the current target address (a target may have gone away since
293        // the pipe was declared — that is a clean runtime drop, not an error).
294        let to_id = ServerId::new(spec.to);
295        let addr = match state.get_server(to_id).await {
296            Some(s) => match deliverable_addr(&s.status, s.local_addr) {
297                Some(a) => a,
298                None => {
299                    warn!(
300                        "pipe #{}: target server #{} is not running/bound, dropping delivery",
301                        spec.id, spec.to
302                    );
303                    continue;
304                }
305            },
306            None => {
307                warn!(
308                    "pipe #{}: target server #{} no longer exists, dropping delivery",
309                    spec.id, spec.to
310                );
311                continue;
312            }
313        };
314
315        // Bounded fan-out: acquire a permit or drop-with-log. Never queue.
316        let permit = match PIPE_INFLIGHT.clone().try_acquire_owned() {
317            Ok(p) => p,
318            Err(_) => {
319                warn!(
320                    "pipe #{}: {} deliveries already in flight (cap), dropping this one",
321                    spec.id, MAX_INFLIGHT_DELIVERIES
322                );
323                continue;
324            }
325        };
326
327        let pipe_id = spec.id;
328        tokio::spawn(async move {
329            let _permit = permit; // released when the delivery finishes
330            match deliver(addr, &payload).await {
331                Ok(()) => debug!(
332                    "pipe #{}: delivered {} bytes to server #{} ({})",
333                    pipe_id,
334                    payload.len(),
335                    to_id.as_u32(),
336                    addr
337                ),
338                Err(e) => warn!("pipe #{}: delivery to {} failed: {}", pipe_id, addr, e),
339            }
340        });
341    }
342}
343
344/// Execute a `create_pipe` / `remove_pipe` action emitted by the LLM or an
345/// operator. Handled here (and dispatched from the action executor) rather than
346/// as a `CommonAction` variant, keeping the enum and its many exhaustive matches
347/// untouched. `default_from` is the server context the action ran in; a
348/// `create_pipe` may omit `from` to mean "this server".
349pub async fn execute_pipe_action(
350    action_name: &str,
351    action: &Value,
352    state: &AppState,
353    default_from: Option<ServerId>,
354) -> Result<()> {
355    match action_name {
356        "create_pipe" => {
357            let on = action
358                .get("on")
359                .and_then(|v| v.as_str())
360                .ok_or_else(|| anyhow!("create_pipe requires an 'on' event type"))?
361                .to_string();
362            let to = action
363                .get("to")
364                .and_then(json_as_u32)
365                .ok_or_else(|| anyhow!("create_pipe requires a numeric 'to' server id"))?;
366            let as_action = action
367                .get("as")
368                .and_then(|v| v.as_str())
369                .ok_or_else(|| anyhow!("create_pipe requires an 'as' action"))?
370                .to_string();
371            let from =
372                match action.get("from") {
373                    Some(value) => ServerId::new(json_as_u32(value).ok_or_else(|| {
374                        anyhow!("create_pipe requires a numeric 'from' server id")
375                    })?),
376                    None => default_from.ok_or_else(|| {
377                        anyhow!("create_pipe has no 'from' and no server context to default to")
378                    })?,
379                };
380            let map: BTreeMap<String, String> = match action.get("map") {
381                Some(Value::Object(obj)) => obj
382                    .iter()
383                    .map(|(k, v)| {
384                        v.as_str()
385                            .map(|s| (k.clone(), s.to_string()))
386                            .ok_or_else(|| {
387                                anyhow!("create_pipe map field {k:?} must be a string template")
388                            })
389                    })
390                    .collect::<Result<_>>()?,
391                _ => bail!("create_pipe requires a 'map' object of string templates"),
392            };
393            let id = state
394                .add_pipe(from, on, ServerId::new(to), as_action, map)
395                .await?;
396            debug!("create_pipe: created pipe #{}", id);
397            Ok(())
398        }
399        "remove_pipe" => {
400            let pipe_id = action
401                .get("pipe_id")
402                .and_then(json_as_u32)
403                .ok_or_else(|| anyhow!("remove_pipe requires a numeric 'pipe_id'"))?;
404            if state.remove_pipe(pipe_id).await.is_none() {
405                bail!("remove_pipe: no pipe #{pipe_id}");
406            }
407            Ok(())
408        }
409        other => bail!("unknown pipe action {other:?}"),
410    }
411}
412
413/// Accept a u32 from either a JSON number or a numeric string (the LLM emits
414/// both).
415fn json_as_u32(v: &Value) -> Option<u32> {
416    v.as_u64()
417        .and_then(|n| u32::try_from(n).ok())
418        .or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))
419}