1use 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
128fn 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 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 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 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 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 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}