Skip to main content

aft/subc/
wire.rs

1//! Frame encoding and writer-queue helpers used by the subc transport edge.
2
3use super::{
4    control_flags, fmt, json, mpsc, AtomicUsize, DispatchPathMetrics, ErrorBody, Flags, Frame,
5    FrameType, Ordering, PathBuf, Response, ToolCallResult, Value, CONTROL_SEND_TIMEOUT,
6    RELIABLE_WRITER_RETRY_INITIAL_BACKOFF, RELIABLE_WRITER_RETRY_MAX_BACKOFF,
7};
8
9pub(super) enum WriterEnqueueError {
10    Full(Frame),
11    Closed,
12}
13
14pub(super) fn decrement_counted_channel(counter: &AtomicUsize) {
15    let previous = counter.fetch_sub(1, Ordering::Relaxed);
16    debug_assert!(previous > 0, "counted channel depth underflow");
17}
18
19pub(super) async fn send_counted_channel<T>(
20    tx: &mpsc::Sender<T>,
21    counter: &AtomicUsize,
22    item: T,
23) -> Result<(), mpsc::error::SendError<T>> {
24    counter.fetch_add(1, Ordering::Relaxed);
25    match tx.send(item).await {
26        Ok(()) => Ok(()),
27        Err(error) => {
28            decrement_counted_channel(counter);
29            Err(error)
30        }
31    }
32}
33
34pub(super) fn try_enqueue_writer_frame(
35    tx: &mpsc::Sender<Frame>,
36    metrics: &DispatchPathMetrics,
37    frame: Frame,
38) -> Result<(), WriterEnqueueError> {
39    match tx.try_reserve() {
40        Ok(permit) => {
41            metrics.writer_queued.fetch_add(1, Ordering::Relaxed);
42            permit.send(frame);
43            Ok(())
44        }
45        Err(mpsc::error::TrySendError::Full(())) => {
46            metrics
47                .writer_saturation_count
48                .fetch_add(1, Ordering::Relaxed);
49            Err(WriterEnqueueError::Full(frame))
50        }
51        Err(mpsc::error::TrySendError::Closed(())) => {
52            drop(frame);
53            Err(WriterEnqueueError::Closed)
54        }
55    }
56}
57
58pub(super) async fn send_reliable_writer_frame(
59    tx: &mpsc::Sender<Frame>,
60    metrics: &DispatchPathMetrics,
61    mut frame: Frame,
62    context: &'static str,
63) -> Result<(), SubcError> {
64    let mut warned = false;
65    let mut backoff = RELIABLE_WRITER_RETRY_INITIAL_BACKOFF;
66
67    loop {
68        match try_enqueue_writer_frame(tx, metrics, frame) {
69            Ok(()) => return Ok(()),
70            Err(WriterEnqueueError::Closed) => return Err(SubcError::WriterClosed),
71            Err(WriterEnqueueError::Full(returned_frame)) => {
72                frame = returned_frame;
73            }
74        }
75
76        match tokio::time::timeout(CONTROL_SEND_TIMEOUT, tx.reserve()).await {
77            Ok(Ok(permit)) => {
78                metrics.writer_queued.fetch_add(1, Ordering::Relaxed);
79                permit.send(frame);
80                return Ok(());
81            }
82            Ok(Err(_)) => return Err(SubcError::WriterClosed),
83            Err(_) => {
84                metrics
85                    .writer_saturation_count
86                    .fetch_add(1, Ordering::Relaxed);
87                if !warned {
88                    log::warn!(
89                        "subc attach: writer queue stayed full while sending {context}; retrying reliable frame"
90                    );
91                    warned = true;
92                }
93                tokio::time::sleep(backoff).await;
94                backoff =
95                    std::cmp::min(backoff.saturating_mul(2), RELIABLE_WRITER_RETRY_MAX_BACKOFF);
96            }
97        }
98    }
99}
100
101pub(super) async fn send_frame(
102    tx: &mpsc::Sender<Frame>,
103    metrics: &DispatchPathMetrics,
104    frame: Frame,
105) -> Result<(), SubcError> {
106    match try_enqueue_writer_frame(tx, metrics, frame) {
107        Ok(()) => Ok(()),
108        Err(WriterEnqueueError::Closed) => Err(SubcError::WriterClosed),
109        Err(WriterEnqueueError::Full(frame)) => {
110            match tokio::time::timeout(CONTROL_SEND_TIMEOUT, tx.reserve()).await {
111                Ok(Ok(permit)) => {
112                    metrics.writer_queued.fetch_add(1, Ordering::Relaxed);
113                    permit.send(frame);
114                    Ok(())
115                }
116                Ok(Err(_)) => Err(SubcError::WriterClosed),
117                Err(_) => {
118                    metrics
119                        .writer_saturation_count
120                        .fetch_add(1, Ordering::Relaxed);
121                    Err(SubcError::WriterBackpressureTimeout)
122                }
123            }
124        }
125    }
126}
127
128/// Flatten a tool-call `Response` + server-rendered `text` into the SAME flat
129/// object the standalone NDJSON `tool_call` command puts on the wire:
130/// `{id, success, ...data, text}` (Response flattens `data` to the top level —
131/// protocol.rs — and `response_with_text` merges `text` in). Mirrors
132/// `commands::tool_call::response_with_text` exactly, including its non-object
133/// `data` fallback (data replaced by `{text}`), so the subc `structuredContent`
134/// is byte-identical to the standalone response body. Built field-by-field
135/// rather than via `serde_json::to_value(response)` because `#[serde(flatten)]`
136/// of a non-object `data` would error.
137fn flat_tool_response(response: &crate::protocol::Response, text: &str) -> Value {
138    let mut obj = serde_json::Map::new();
139    obj.insert("id".to_string(), Value::String(response.id.clone()));
140    obj.insert("success".to_string(), Value::Bool(response.success));
141    if let Some(data) = response.data.as_object() {
142        for (key, value) in data {
143            obj.insert(key.clone(), value.clone());
144        }
145    }
146    obj.insert("text".to_string(), Value::String(text.to_string()));
147    Value::Object(obj)
148}
149
150pub(super) fn build_tool_response_frame(
151    ver: u8,
152    route_channel: u16,
153    corr: u64,
154    flags: Flags,
155    result: &ToolCallResult,
156) -> Result<Frame, SubcError> {
157    let is_error = !result.response.success;
158    // `content`/`isError` is the MCP-native surface a GENERIC host reads (and a
159    // generic host ignores `structuredContent`, per the MCP spec). The
160    // FIRST-PARTY AFT plugin instead reads `structuredContent`, which carries
161    // the full flat standalone shape ({id, success, ...data, text}) so every
162    // structured sidecar the plugin drives UI from — status_bar, bg_completions
163    // (in-band drain), preview_diff, code, message, attachments — survives the
164    // route. subc relays the body byte-for-byte, so this reaches the plugin
165    // unchanged. SubcTransport.toolCall re-lifts `structuredContent` straight to
166    // the flat ToolCallResult, so nothing downstream of the transport differs
167    // from the NDJSON path.
168    let payload = json!({
169        "content": [{ "type": "text", "text": result.text.as_str() }],
170        "isError": is_error,
171        "structuredContent": flat_tool_response(&result.response, &result.text),
172    });
173    let body = serde_json::to_vec(&payload).map_err(SubcError::Json)?;
174
175    Frame::build_with_version(ver, FrameType::Response, flags, route_channel, corr, body)
176        .map_err(SubcError::FrameBuild)
177}
178
179pub(super) fn build_error_frame(
180    ver: u8,
181    channel: u16,
182    corr: u64,
183    flags: Flags,
184    code: &str,
185    message: &str,
186) -> Result<Frame, SubcError> {
187    let body = serde_json::to_vec(&ErrorBody {
188        code: code.to_string(),
189        message: message.to_string(),
190    })
191    .map_err(SubcError::Json)?;
192    Frame::build_with_version(ver, FrameType::Error, flags, channel, corr, body)
193        .map_err(SubcError::FrameBuild)
194}
195
196pub(super) fn build_goodbye_frame(ver: u8, channel: u16, corr: u64) -> Result<Frame, SubcError> {
197    Frame::build_with_version(
198        ver,
199        FrameType::Goodbye,
200        control_flags(),
201        channel,
202        corr,
203        Vec::new(),
204    )
205    .map_err(SubcError::FrameBuild)
206}
207
208pub(super) fn response_message(response: &Response, fallback: &str) -> String {
209    response
210        .data
211        .get("message")
212        .and_then(Value::as_str)
213        .map(ToOwned::to_owned)
214        .unwrap_or_else(|| fallback.to_string())
215}
216
217pub(super) fn response_is_fatal_panic(response: &Response) -> bool {
218    !response.success && response.data.get("code").and_then(Value::as_str) == Some("actor_fatal")
219}
220
221#[derive(Debug)]
222pub enum SubcError {
223    Runtime(std::io::Error),
224    ConnectionFile {
225        path: PathBuf,
226        source: subc_transport::ConnectionFileError,
227    },
228    NoEndpoint {
229        path: PathBuf,
230    },
231    InvalidEndpoint {
232        path: PathBuf,
233        endpoint: String,
234    },
235    Connect {
236        endpoint: String,
237        source: std::io::Error,
238    },
239    Auth {
240        endpoint: String,
241        source: subc_transport::AuthError,
242    },
243    FrameIo(subc_transport::FrameIoError),
244    FrameBuild(subc_protocol::FrameBuildError),
245    WriterClosed,
246    WriterBackpressureTimeout,
247    WriterJoin(tokio::task::JoinError),
248    Json(serde_json::Error),
249    ClosedBeforeHelloAck,
250    HelloRejected {
251        body: Option<ErrorBody>,
252    },
253    UnexpectedFrame {
254        ty: FrameType,
255    },
256}
257
258impl fmt::Display for SubcError {
259    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260        match self {
261            Self::Runtime(e) => write!(f, "failed to build subc tokio runtime: {e}"),
262            Self::ConnectionFile { path, source } => {
263                write!(f, "failed to read subc connection file {path:?}: {source}")
264            }
265            Self::NoEndpoint { path } => {
266                write!(f, "subc connection file {path:?} has no endpoints")
267            }
268            Self::InvalidEndpoint { path, endpoint } => {
269                write!(
270                    f,
271                    "subc connection file {path:?} has invalid endpoint {endpoint}"
272                )
273            }
274            Self::Connect { endpoint, source } => {
275                write!(f, "failed to connect to subc endpoint {endpoint}: {source}")
276            }
277            Self::Auth { endpoint, source } => {
278                write!(
279                    f,
280                    "failed to authenticate to subc endpoint {endpoint}: {source}"
281                )
282            }
283            Self::FrameIo(e) => write!(f, "subc frame I/O error: {e}"),
284            Self::FrameBuild(e) => write!(f, "subc frame build error: {e}"),
285            Self::WriterClosed => write!(f, "subc writer task closed"),
286            Self::WriterBackpressureTimeout => write!(
287                f,
288                "subc writer task stayed backpressured while sending a control frame"
289            ),
290            Self::WriterJoin(e) => write!(f, "subc writer task join error: {e}"),
291            Self::Json(e) => write!(f, "subc JSON error: {e}"),
292            Self::ClosedBeforeHelloAck => {
293                write!(f, "subc daemon closed the connection before HelloAck")
294            }
295            Self::HelloRejected { body } => match body {
296                Some(b) => write!(f, "subc rejected ModuleHello: {} ({})", b.code, b.message),
297                None => write!(f, "subc rejected ModuleHello (unparseable error body)"),
298            },
299            Self::UnexpectedFrame { ty } => {
300                write!(f, "subc sent unexpected frame in place of HelloAck: {ty:?}")
301            }
302        }
303    }
304}
305
306impl std::error::Error for SubcError {}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use serde_json::json;
312    use std::sync::Arc;
313    use std::time::{Duration, Instant};
314    use subc_protocol::PROTOCOL_VERSION;
315
316    #[test]
317    fn writer_depth_counter_tracks_enqueued_frames_until_drain() {
318        let metrics = DispatchPathMetrics::new();
319        let (writer_tx, mut writer_rx) = mpsc::channel::<Frame>(8);
320
321        for corr in 1..=3 {
322            let frame = Frame::build(FrameType::Ping, control_flags(), 0, corr, Vec::new())
323                .expect("test frame");
324            assert!(try_enqueue_writer_frame(&writer_tx, &metrics, frame).is_ok());
325        }
326        assert_eq!(metrics.writer_queued.load(Ordering::Relaxed), 3);
327
328        for _ in 0..3 {
329            writer_rx.try_recv().expect("queued writer frame");
330            decrement_counted_channel(&metrics.writer_queued);
331        }
332        assert_eq!(metrics.writer_queued.load(Ordering::Relaxed), 0);
333    }
334
335    #[tokio::test]
336    async fn reliable_writer_send_retries_after_timeout_and_preserves_frame() {
337        let metrics = Arc::new(DispatchPathMetrics::new());
338        let (writer_tx, mut writer_rx) = mpsc::channel::<Frame>(1);
339        writer_tx
340            .try_send(Frame::build(FrameType::Ping, control_flags(), 0, 1, Vec::new()).unwrap())
341            .expect("prefill writer queue");
342
343        let metrics_for_task = Arc::clone(&metrics);
344        let tx_for_task = writer_tx.clone();
345        let send_task = tokio::spawn(async move {
346            send_reliable_writer_frame(
347                &tx_for_task,
348                &metrics_for_task,
349                Frame::build(FrameType::Pong, control_flags(), 0, 2, Vec::new()).unwrap(),
350                "test reliable frame",
351            )
352            .await
353        });
354
355        tokio::time::timeout(Duration::from_secs(2), async {
356            while metrics.writer_saturation_count.load(Ordering::Relaxed) < 2 {
357                tokio::time::sleep(Duration::from_millis(10)).await;
358            }
359        })
360        .await
361        .expect("reliable send should observe a timed-out full writer queue");
362
363        let prefilled = writer_rx.recv().await.expect("prefilled frame");
364        assert_eq!(prefilled.header.corr, 1);
365        let result = tokio::time::timeout(Duration::from_secs(2), send_task)
366            .await
367            .expect("reliable send should finish after writer drains")
368            .expect("reliable send task should not panic");
369        assert!(result.is_ok());
370        let delivered = writer_rx.recv().await.expect("retried reliable frame");
371        assert_eq!(delivered.header.corr, 2);
372    }
373
374    #[test]
375    fn response_is_fatal_panic_only_matches_panic_exclusive_code() {
376        let tool_error = Response::error("request-1", "internal_error", "ordinary tool error");
377        let panic_error = Response::error("request-2", "actor_fatal", "mutating panic");
378
379        assert!(!response_is_fatal_panic(&tool_error));
380        assert!(response_is_fatal_panic(&panic_error));
381    }
382
383    #[tokio::test]
384    async fn control_send_times_out_when_writer_queue_remains_full() {
385        let (writer_tx, _writer_rx) = mpsc::channel::<Frame>(1);
386        let metrics = DispatchPathMetrics::new();
387        writer_tx
388            .try_send(Frame::build(FrameType::Ping, control_flags(), 0, 1, Vec::new()).unwrap())
389            .expect("prefill writer queue");
390        let started = Instant::now();
391
392        let result = send_frame(
393            &writer_tx,
394            &metrics,
395            Frame::build(FrameType::Pong, control_flags(), 0, 2, Vec::new()).unwrap(),
396        )
397        .await;
398
399        assert!(matches!(result, Err(SubcError::WriterBackpressureTimeout)));
400        assert!(
401            started.elapsed() < Duration::from_secs(2),
402            "control send guard should be bounded"
403        );
404    }
405
406    #[test]
407    fn tool_response_frame_carries_flat_standalone_shape_in_structured_content() {
408        use crate::protocol::Response;
409
410        // A response with sidecars the FIRST-PARTY plugin drives UI from
411        // (status_bar, bg_completions, code) plus a normal result field.
412        let response = Response::success(
413            "req-7",
414            json!({
415                "complete": true,
416                "matches": 3,
417                "status_bar": { "errors": 0, "warnings": 1 },
418                "bg_completions": [{ "task_id": "bash-abc" }],
419            }),
420        );
421        let result = ToolCallResult {
422            text: "rendered text".to_string(),
423            response,
424        };
425
426        // The flat shape must equal the standalone NDJSON `tool_call` body:
427        // {id, success, ...data, text}. Build the standalone expectation the
428        // same way commands::tool_call::response_with_text does.
429        let expected_flat = json!({
430            "id": "req-7",
431            "success": true,
432            "complete": true,
433            "matches": 3,
434            "status_bar": { "errors": 0, "warnings": 1 },
435            "bg_completions": [{ "task_id": "bash-abc" }],
436            "text": "rendered text",
437        });
438        assert_eq!(
439            flat_tool_response(&result.response, &result.text),
440            expected_flat,
441            "structuredContent must be byte-identical to the standalone flat response"
442        );
443
444        // The frame body carries the MCP surface for generic hosts AND the flat
445        // sidecar shape under structuredContent for the first-party plugin.
446        let frame =
447            build_tool_response_frame(PROTOCOL_VERSION, 1, 42, control_flags(), &result).unwrap();
448        let body: Value = serde_json::from_slice(&frame.body).unwrap();
449        assert_eq!(body["isError"], json!(false));
450        assert_eq!(body["content"][0]["type"], json!("text"));
451        assert_eq!(body["content"][0]["text"], json!("rendered text"));
452        assert_eq!(body["structuredContent"], expected_flat);
453
454        // A failed response flips isError and still carries the flat shape
455        // (with success:false + code) for the plugin's error path.
456        let err = Response::error_with_data(
457            "req-8",
458            "ambiguous_match",
459            "too many matches",
460            json!({ "candidates": ["a", "b"] }),
461        );
462        let err_result = ToolCallResult {
463            text: "error text".to_string(),
464            response: err,
465        };
466        let err_frame =
467            build_tool_response_frame(PROTOCOL_VERSION, 1, 43, control_flags(), &err_result)
468                .unwrap();
469        let err_body: Value = serde_json::from_slice(&err_frame.body).unwrap();
470        assert_eq!(err_body["isError"], json!(true));
471        assert_eq!(err_body["structuredContent"]["success"], json!(false));
472        assert_eq!(
473            err_body["structuredContent"]["code"],
474            json!("ambiguous_match")
475        );
476        assert_eq!(
477            err_body["structuredContent"]["candidates"],
478            json!(["a", "b"])
479        );
480        assert_eq!(err_body["structuredContent"]["text"], json!("error text"));
481    }
482}