1use crate::uplink_journal::UplinkJournal;
20use hatchery_core::inner::{decode_inner, encode_inner, InnerKind};
21use hatchery_core::nested::{node_seal_outer, NoiseInitiator, NoiseTransport, PublicKeyBytes};
22use hatchery_core::{encode_udp_data, grow_read_buf, ChannelKind, Frame, FrameError, FrameType, OpenPayload};
23use serde_json::{json, Value};
24use std::collections::{BTreeMap, VecDeque};
25use std::net::SocketAddr;
26use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
27use std::sync::{Arc, Mutex};
28use std::time::Duration;
29use tokio::io::{AsyncReadExt, AsyncWriteExt};
30use tokio::net::TcpStream;
31use tokio::sync::{mpsc, Notify};
32use tokio::task::JoinHandle;
33
34const UPLINK_PING_EVERY: Duration = Duration::from_secs(10);
36const UPLINK_DEAD_AFTER: Duration = Duration::from_secs(35);
38
39#[derive(Clone, Copy, Debug)]
41pub struct UpstreamKeys {
42 pub c2_public: PublicKeyBytes,
43 pub recipient_public: PublicKeyBytes,
44}
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
48pub enum SendPrio {
49 Control = 0,
51 Pty = 1,
53 Mail = 2,
55 Browser = 3,
57}
58
59const WRITE_TIMEOUT: Duration = Duration::from_secs(5);
60
61fn udp_pty_enabled() -> bool {
62 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
63 *ON.get_or_init(|| {
64 !matches!(
66 std::env::var("HATCHERY_PTY_UDP").ok().as_deref(),
67 Some("0") | Some("false") | Some("FALSE") | Some("no")
68 )
69 })
70}
71
72const CHAN_CAP_CONTROL: usize = 64;
73const CHAN_CAP_PTY: usize = 128;
74const CHAN_CAP_MAIL: usize = 16;
75const CHAN_CAP_BROWSER: usize = 8;
76
77struct Queued {
78 prio: SendPrio,
79 frame: Frame,
80}
81
82struct ChanQ {
83 q: VecDeque<Queued>,
84 credit: Option<u32>,
86}
87
88impl ChanQ {
89 fn new() -> Self {
90 Self { q: VecDeque::new(), credit: None }
91 }
92
93 fn cap_for(prio: SendPrio) -> usize {
94 match prio {
95 SendPrio::Control => CHAN_CAP_CONTROL,
96 SendPrio::Pty => CHAN_CAP_PTY,
97 SendPrio::Mail => CHAN_CAP_MAIL,
98 SendPrio::Browser => CHAN_CAP_BROWSER,
99 }
100 }
101}
102
103struct OutState {
104 chans: BTreeMap<u32, ChanQ>,
105}
106
107pub struct NodeUplink {
110 enqueue: mpsc::UnboundedSender<(u32, SendPrio, Frame)>,
111 out_state: Arc<Mutex<OutState>>,
113 routes: Arc<Mutex<BTreeMap<u32, mpsc::UnboundedSender<Vec<u8>>>>>,
114 credits: Arc<Mutex<BTreeMap<u32, u32>>>,
116 credit_pulse: Arc<Notify>,
117 journal: Option<Arc<UplinkJournal>>,
118 alive: Arc<AtomicBool>,
119 dead: Arc<Notify>,
120 tasks: Vec<JoinHandle<()>>,
121 udp_peer: Option<std::net::SocketAddr>,
123 udp: Option<Arc<std::net::UdpSocket>>,
126 udp_node_id: Arc<Mutex<Option<String>>>,
127 udp_sent: AtomicU64,
128}
129
130impl Drop for NodeUplink {
131 fn drop(&mut self) {
132 self.alive.store(false, Ordering::SeqCst);
133 for t in &self.tasks {
134 t.abort();
135 }
136 }
137}
138
139fn send_udp_datagram(
140 peer: std::net::SocketAddr,
141 dg: &[u8],
142 os: Option<&std::net::UdpSocket>,
143) -> Result<(), String> {
144 #[cfg(feature = "wireguard")]
145 {
146 match gate4agent_node_core::kit_wireguard::udp_send(peer, dg) {
147 Ok(()) => return Ok(()),
148 Err(e) if e.kind() == std::io::ErrorKind::InvalidInput => {
149 return Err(format!("udp pty too large for WG MTU: {e}"));
150 }
151 Err(e) => {
152 tracing::debug!(error = %e, "userspace udp_send failed; trying OS");
154 }
155 }
156 }
157 let Some(udp) = os else {
158 return Err("udp data plane unavailable".into());
159 };
160 udp.send(dg).map_err(|e| format!("udp pty send: {e}"))?;
161 Ok(())
162}
163
164fn dial_uplink(addr: SocketAddr) -> std::io::Result<std::net::TcpStream> {
168 #[cfg(feature = "wireguard")]
169 {
170 gate4agent_node_core::kit_wireguard::tcp_connect(addr)
171 }
172 #[cfg(not(feature = "wireguard"))]
173 {
174 std::net::TcpStream::connect(addr)
175 }
176}
177
178impl NodeUplink {
179 pub async fn connect(uplink: SocketAddr, journal: Option<Arc<UplinkJournal>>) -> Result<Arc<Self>, String> {
180 let dial = uplink;
181 let started = std::time::Instant::now();
182 crate::node_log::info(
183 "uplink_connect_attempt",
184 &[("addr_port", dial.port().to_string())],
185 );
186 let std_stream = match tokio::task::spawn_blocking(move || dial_uplink(dial)).await {
187 Ok(Ok(s)) => {
188 crate::node_log::info(
189 "uplink_connect_ok",
190 &[
191 ("addr_port", dial.port().to_string()),
192 ("ms", started.elapsed().as_millis().to_string()),
193 ],
194 );
195 s
196 }
197 Ok(Err(e)) => {
198 crate::node_log::warn(
199 "uplink_connect_fail",
200 &[
201 ("addr_port", dial.port().to_string()),
202 ("error", e.to_string()),
203 ("ms", started.elapsed().as_millis().to_string()),
204 ],
205 );
206 return Err(e.to_string());
207 }
208 Err(e) => {
209 crate::node_log::warn(
210 "uplink_connect_fail",
211 &[
212 ("addr_port", dial.port().to_string()),
213 ("error", e.to_string()),
214 ("ms", started.elapsed().as_millis().to_string()),
215 ],
216 );
217 return Err(e.to_string());
218 }
219 };
220 let _ = std_stream.set_nodelay(true);
221 if let Err(error) = crate::server::enable_tcp_keepalive(&std_stream) {
225 tracing::warn!(cause = %error, "could not enable TCP keepalive on the uplink");
226 }
227 std_stream.set_nonblocking(true).map_err(|e| e.to_string())?;
231 let stream = TcpStream::from_std(std_stream).map_err(|e| e.to_string())?;
232 let udp_peer = Some(dial);
236 let udp = match std::net::UdpSocket::bind("0.0.0.0:0") {
237 Ok(sock) => match sock.connect(dial) {
238 Ok(()) => {
239 let _ = sock.set_nonblocking(true);
240 Some(Arc::new(sock))
241 }
242 Err(_) => None,
243 },
244 Err(_) => None,
245 };
246 tracing::info!(%dial, via = "wireguard-or-os", "uplink UDP data plane ready");
247 let udp_node_id = Arc::new(Mutex::new(None));
248 let (mut rd, mut wr) = stream.into_split();
249 let (enq_tx, mut enq_rx) = mpsc::unbounded_channel::<(u32, SendPrio, Frame)>();
250 let routes: Arc<Mutex<BTreeMap<u32, mpsc::UnboundedSender<Vec<u8>>>>> = Arc::default();
251 let credits: Arc<Mutex<BTreeMap<u32, u32>>> = Arc::default();
252 let credit_pulse = Arc::new(Notify::new());
253 let alive = Arc::new(AtomicBool::new(true));
254 let dead = Arc::new(Notify::new());
255 let wake = Arc::new(Notify::new());
256
257 let out_state = Arc::new(Mutex::new(OutState { chans: BTreeMap::new() }));
258 let born = std::time::Instant::now();
263 let last_rx = Arc::new(AtomicU64::new(0));
264
265 let state_in = Arc::clone(&out_state);
267 let wake_in = Arc::clone(&wake);
268 let alive_in = Arc::clone(&alive);
269 let ingress = tokio::spawn(async move {
270 while let Some((ch, prio, frame)) = enq_rx.recv().await {
271 if !alive_in.load(Ordering::SeqCst) {
272 break;
273 }
274 let mut st = state_in.lock().expect("out");
275 let q = st.chans.entry(ch).or_insert_with(ChanQ::new);
276 let cap = ChanQ::cap_for(prio);
277 if q.q.len() >= cap {
281 tracing::error!(?prio, cap, qlen = q.q.len(), "ciphertext dropped after encrypt (nonce gap); reconnecting uplink");
282 drop(st);
283 alive_in.store(false, Ordering::SeqCst);
284 break;
285 }
286 if prio == SendPrio::Browser
287 && q.q.iter().any(|x| x.prio == SendPrio::Browser)
288 {
289 tracing::error!("browser ciphertext dropped after encrypt (nonce gap); reconnecting uplink");
291 drop(st);
292 alive_in.store(false, Ordering::SeqCst);
293 break;
294 }
295 q.q.push_back(Queued { prio, frame });
296 drop(st);
297 wake_in.notify_one();
298 }
299 });
300
301 let state_wr = Arc::clone(&out_state);
304 let wake_wr = Arc::clone(&wake);
305 let alive_wr = Arc::clone(&alive);
306 let dead_wr = Arc::clone(&dead);
307 let routes_wr = Arc::clone(&routes);
308 let writer = tokio::spawn(async move {
309 loop {
310 if !alive_wr.load(Ordering::SeqCst) {
311 break;
312 }
313 let next = {
314 let mut st = state_wr.lock().expect("out");
315 pick_next(&mut st)
316 };
317 let Some((ch, is_data, frame)) = next else {
318 wake_wr.notified().await;
319 continue;
320 };
321 let Ok(bytes) = frame.encode() else {
322 kill(&alive_wr, &dead_wr, &routes_wr);
323 break;
324 };
325 match tokio::time::timeout(WRITE_TIMEOUT, wr.write_all(&bytes)).await {
326 Ok(Ok(())) => {
327 if is_data {
328 let mut st = state_wr.lock().expect("out");
329 if let Some(q) = st.chans.get_mut(&ch) {
330 if let Some(c) = q.credit.as_mut() {
331 *c = c.saturating_sub(1);
332 }
333 }
334 }
335 }
336 Ok(Err(_)) | Err(_) => {
337 tracing::warn!(%ch, "uplink write timed out or failed; reconnecting");
338 kill(&alive_wr, &dead_wr, &routes_wr);
339 break;
340 }
341 }
342 }
343 });
344
345 let enq_reader = enq_tx.clone();
346 let reader_journal = journal.clone();
347 let reader_routes = Arc::clone(&routes);
348 let alive_rd = Arc::clone(&alive);
349 let dead_rd = Arc::clone(&dead);
350 let state_rd = Arc::clone(&out_state);
351 let wake_rd = Arc::clone(&wake);
352 let credits_rd = Arc::clone(&credits);
353 let credit_pulse_rd = Arc::clone(&credit_pulse);
354 let last_rx_rd = Arc::clone(&last_rx);
355 let reader = tokio::spawn(async move {
356 let mut buf = vec![0u8; 128 * 1024];
357 let mut filled = 0usize;
358 loop {
359 let Ok(n) = rd.read(&mut buf[filled..]).await else { break };
360 if n == 0 {
361 break;
362 }
363 filled += n;
364 last_rx_rd.store(born.elapsed().as_millis() as u64, Ordering::SeqCst);
365 loop {
366 match Frame::decode(&buf[..filled]) {
367 Ok((frame, used)) => {
368 buf.copy_within(used..filled, 0);
369 filled -= used;
370 match frame.frame_type {
371 FrameType::Data => {
372 let tx = reader_routes.lock().expect("routes").get(&frame.channel_id).cloned();
373 if let Some(tx) = tx {
374 let _ = tx.send(frame.payload);
375 }
376 }
377 FrameType::Window => {
378 let credit = frame.seq.min(u64::from(u32::MAX)) as u32;
379 {
380 let mut st = state_rd.lock().expect("out");
381 st.chans.entry(frame.channel_id).or_insert_with(ChanQ::new).credit = Some(credit);
382 }
383 credits_rd.lock().expect("credits").insert(frame.channel_id, credit);
384 credit_pulse_rd.notify_waiters();
385 wake_rd.notify_one();
386 }
387 FrameType::JournalReq => {
388 let Some(j) = reader_journal.as_ref() else { continue };
389 let limit = frame.journal_req_limit().unwrap_or(64) as usize;
390 if let Ok(body) = j.journal().reply_body(frame.channel_id, frame.seq, limit) {
391 if let Ok(reply) = Frame::journal_reply(frame.channel_id, &body) {
392 let _ = enq_reader.send((frame.channel_id, SendPrio::Control, reply));
393 }
394 }
395 }
396 FrameType::GoAway | FrameType::Close => {
397 kill(&alive_rd, &dead_rd, &reader_routes);
398 return;
399 }
400 _ => {}
401 }
402 }
403 Err(FrameError::Truncated) => break,
404 Err(_) => {
405 kill(&alive_rd, &dead_rd, &reader_routes);
406 return;
407 }
408 }
409 }
410 if filled == buf.len() {
411 if grow_read_buf(&mut buf, filled).is_err() {
414 break;
415 }
416 }
417 }
418 kill(&alive_rd, &dead_rd, &reader_routes);
419 });
420
421 let ka_tx = enq_tx.clone();
422 let alive_ka = Arc::clone(&alive);
423 let dead_ka = Arc::clone(&dead);
424 let routes_ka = Arc::clone(&routes);
425 let keepalive = tokio::spawn(async move {
426 let mut nonce = 0u64;
427 let mut tick = tokio::time::interval(UPLINK_PING_EVERY);
428 tick.tick().await;
429 loop {
430 tick.tick().await;
431 if !alive_ka.load(Ordering::SeqCst) {
432 break;
433 }
434 let now = born.elapsed().as_millis() as u64;
435 let idle = now.saturating_sub(last_rx.load(Ordering::SeqCst));
436 if idle > UPLINK_DEAD_AFTER.as_millis() as u64 {
437 tracing::warn!(idle_ms = idle, "uplink silent past keepalive deadline; reconnecting");
438 kill(&alive_ka, &dead_ka, &routes_ka);
439 break;
440 }
441 nonce = nonce.wrapping_add(1);
442 if ka_tx.send((0, SendPrio::Control, Frame::ping(nonce))).is_err() {
443 break;
444 }
445 }
446 });
447
448 Ok(Arc::new(Self {
449 enqueue: enq_tx,
450 out_state: Arc::clone(&out_state),
451 routes,
452 credits,
453 credit_pulse,
454 journal,
455 alive,
456 dead,
457 tasks: vec![ingress, writer, reader, keepalive],
458 udp_peer,
459 udp,
460 udp_node_id,
461 udp_sent: AtomicU64::new(0),
462 }))
463 }
464
465 pub fn udp_sent(&self) -> u64 {
467 self.udp_sent.load(Ordering::Relaxed)
468 }
469
470 pub fn is_alive(&self) -> bool {
471 self.alive.load(Ordering::SeqCst)
472 }
473
474 pub async fn closed(&self) {
476 if !self.is_alive() {
477 return;
478 }
479 self.dead.notified().await;
480 }
481
482 fn enqueue_ok(&self, channel_id: u32, prio: SendPrio) -> bool {
485 let st = self.out_state.lock().expect("out");
486 let Some(q) = st.chans.get(&channel_id) else {
487 return true;
488 };
489 if q.q.len() >= ChanQ::cap_for(prio) {
490 return false;
491 }
492 if prio == SendPrio::Browser && q.q.iter().any(|x| x.prio == SendPrio::Browser) {
493 return false;
494 }
495 true
496 }
497
498 fn push(&self, channel_id: u32, prio: SendPrio, frame: Frame) -> Result<(), String> {
499 if !self.is_alive() {
500 return Err("uplink closed".into());
501 }
502 self.enqueue
503 .send((channel_id, prio, frame))
504 .map_err(|_| "uplink closed".to_owned())
505 }
506
507 pub async fn open(
509 self: &Arc<Self>,
510 node_id: &str,
511 channel_id: u32,
512 kind: ChannelKind,
513 keys: UpstreamKeys,
514 handshake_timeout: Duration,
515 ) -> Result<NodeSession, String> {
516 let (down_tx, mut down_rx) = mpsc::unbounded_channel::<Vec<u8>>();
517 self.routes.lock().expect("routes").insert(channel_id, down_tx);
518 self.credits.lock().expect("credits").remove(&channel_id);
519 {
520 let mut g = self.udp_node_id.lock().expect("udp_node_id");
521 if g.is_none() {
522 *g = Some(node_id.to_owned());
523 }
524 }
525 let open = Frame::open(channel_id, &OpenPayload { channel_kind: kind, node_id: node_id.to_owned() })
526 .map_err(|e| e.to_string())?;
527 self.push(channel_id, SendPrio::Control, open)?;
528
529 tokio::time::timeout(handshake_timeout, async {
532 loop {
533 if !self.is_alive() {
534 return Err("uplink closed".to_owned());
535 }
536 if self.credits.lock().expect("credits").get(&channel_id).copied().unwrap_or(0) > 0 {
537 return Ok(());
538 }
539 tokio::select! {
540 _ = self.credit_pulse.notified() => {}
541 _ = self.closed() => return Err("uplink closed".to_owned()),
542 }
543 }
544 })
545 .await
546 .map_err(|_| "window credit timeout after OPEN".to_owned())??;
547
548 let mut initiator = NoiseInitiator::new(keys.recipient_public).map_err(|e| e.to_string())?;
549 let msg1 = initiator.write_message1().map_err(|e| e.to_string())?;
550 self.send_inner(channel_id, keys, SendPrio::Control, &encode_inner(InnerKind::Handshake1, &msg1), false)?;
551
552 let msg2 = tokio::time::timeout(handshake_timeout, async {
553 loop {
554 let Some(bytes) = down_rx.recv().await else { return Err("uplink closed".to_owned()) };
555 if let Some((InnerKind::Handshake2, body)) = decode_inner(&bytes) {
556 return Ok(body.to_vec());
557 }
558 }
559 })
560 .await
561 .map_err(|_| "handshake timeout".to_owned())??;
562 let transport = initiator.read_message2(&msg2).map_err(|e| e.to_string())?;
563 Ok(NodeSession {
564 channel_id,
565 keys,
566 default_prio: match kind {
567 ChannelKind::M4a => SendPrio::Mail,
568 ChannelKind::G4aControl | ChannelKind::G4aPty | ChannelKind::G4aEvents => SendPrio::Pty,
569 ChannelKind::Vault | ChannelKind::Observation => SendPrio::Mail,
570 },
571 down: down_rx,
572 transport,
573 journal: self.journal.clone(),
574 uplink: Arc::clone(self),
575 udp: false,
576 })
577 }
578
579 fn send_inner(&self, channel_id: u32, keys: UpstreamKeys, prio: SendPrio, inner: &[u8], udp_on: bool) -> Result<(), String> {
580 if let Some(j) = &self.journal {
581 j.append(channel_id, inner)?;
582 }
583 let sealed = node_seal_outer(keys.c2_public, inner).map_err(|e| e.to_string())?;
584 let frame = Frame::data(channel_id, 0, sealed).map_err(|e| e.to_string())?;
585 if udp_on && prio == SendPrio::Pty && udp_pty_enabled() {
590 if let (Some(peer), Some(nid)) = (
591 self.udp_peer,
592 self.udp_node_id.lock().expect("udp_node_id").clone(),
593 ) {
594 match encode_udp_data(&nid, &frame) {
595 Ok(dg) => {
596 send_udp_datagram(peer, &dg, self.udp.as_deref())?;
598 self.udp_sent.fetch_add(1, Ordering::Relaxed);
599 return Ok(());
600 }
601 Err(e) => return Err(format!("udp encode: {e}")),
602 }
603 }
604 }
605 self.push(channel_id, prio, frame)
606 }
607}
608
609fn kill(alive: &AtomicBool, dead: &Notify, routes: &Mutex<BTreeMap<u32, mpsc::UnboundedSender<Vec<u8>>>>) {
610 if alive.swap(false, Ordering::SeqCst) {
611 routes.lock().expect("routes").clear();
612 dead.notify_waiters();
613 }
614}
615
616fn pick_next(st: &mut OutState) -> Option<(u32, bool, Frame)> {
618 let mut best: Option<(SendPrio, u32, usize)> = None;
622 for (&ch, cq) in &st.chans {
623 let first_data = cq.q.iter().position(|it| matches!(it.frame.frame_type, FrameType::Data));
624 for (i, item) in cq.q.iter().enumerate() {
625 let needs_credit = matches!(item.frame.frame_type, FrameType::Data);
626 if needs_credit && Some(i) != first_data {
627 continue;
628 }
629 if needs_credit {
630 match cq.credit {
631 Some(0) => continue,
632 None => {
633 if item.prio > SendPrio::Control {
636 continue;
637 }
638 }
639 Some(_) => {}
640 }
641 }
642 let cand = (item.prio, ch, i);
643 if best.is_none_or(|b| cand.0 < b.0 || (cand.0 == b.0 && cand.1 < b.1)) {
644 best = Some(cand);
645 }
646 }
647 }
648 let (prio, ch, idx) = best?;
649 let cq = st.chans.get_mut(&ch)?;
650 let item = cq.q.remove(idx)?;
651 debug_assert_eq!(item.prio, prio);
652 let is_data = matches!(item.frame.frame_type, FrameType::Data);
653 Some((ch, is_data, item.frame))
654}
655
656pub struct NodeSession {
657 channel_id: u32,
658 keys: UpstreamKeys,
659 default_prio: SendPrio,
660 down: mpsc::UnboundedReceiver<Vec<u8>>,
661 transport: NoiseTransport,
662 journal: Option<Arc<UplinkJournal>>,
663 uplink: Arc<NodeUplink>,
664 udp: bool,
665}
666
667impl NodeSession {
668 pub fn udp_available(&self) -> bool {
670 self.uplink.udp_peer.is_some() && udp_pty_enabled()
671 }
672
673 pub fn set_udp(&mut self, on: bool) {
676 self.udp = on && self.udp_available();
677 }
678
679 pub fn udp(&self) -> bool {
680 self.udp
681 }
682
683 pub async fn connect(
684 uplink: SocketAddr,
685 node_id: &str,
686 channel_id: u32,
687 kind: ChannelKind,
688 keys: UpstreamKeys,
689 journal: Option<Arc<UplinkJournal>>,
690 handshake_timeout: Duration,
691 ) -> Result<Self, String> {
692 let link = NodeUplink::connect(uplink, journal).await?;
693 link.open(node_id, channel_id, kind, keys, handshake_timeout).await
694 }
695
696 pub fn uplink(&self) -> Arc<NodeUplink> {
697 Arc::clone(&self.uplink)
698 }
699
700 pub async fn send(&mut self, plaintext: &[u8]) -> Result<(), String> {
701 self.send_prio(plaintext, self.default_prio).await
702 }
703
704 pub async fn send_prio(&mut self, plaintext: &[u8], prio: SendPrio) -> Result<(), String> {
705 if !self.uplink.is_alive() {
706 return Err("uplink closed".into());
707 }
708 if !self.uplink.enqueue_ok(self.channel_id, prio) {
711 if prio == SendPrio::Browser {
712 return Ok(()); }
714 return Err("uplink send queue full".into());
715 }
716 let ct = self.transport.encrypt(plaintext).map_err(|e| e.to_string())?;
717 let inner = encode_inner(InnerKind::Transport, &ct);
718 self.uplink.send_inner(self.channel_id, self.keys, prio, &inner, self.udp)
719 }
720
721 pub async fn recv(&mut self, timeout: Duration) -> Result<Vec<u8>, String> {
722 tokio::time::timeout(timeout, async {
723 loop {
724 tokio::select! {
725 bytes = self.down.recv() => {
726 let Some(bytes) = bytes else { return Err("uplink closed".to_owned()) };
727 if let Some((InnerKind::Transport, ct)) = decode_inner(&bytes) {
728 return self.transport.decrypt(ct).map_err(|e| e.to_string());
729 }
730 }
731 _ = self.uplink.closed() => return Err("uplink closed".to_owned()),
732 }
733 }
734 })
735 .await
736 .map_err(|_| "reply timeout".to_owned())?
737 }
738
739 pub async fn call(&mut self, op: &str, payload: &Value, timeout: Duration) -> Result<Value, String> {
740 let req = serde_json::to_vec(&json!({ "op": op, "payload": payload })).map_err(|e| e.to_string())?;
741 self.send_prio(&req, SendPrio::Mail).await?;
742 let reply = self.recv(timeout).await?;
743 let reply: Value = serde_json::from_slice(&reply).map_err(|e| e.to_string())?;
744 let status = reply.get("status").and_then(Value::as_u64).unwrap_or(500);
745 let body = reply.get("body").cloned().unwrap_or(Value::Null);
746 if status == 200 {
747 Ok(body)
748 } else {
749 let msg = body.get("error").and_then(Value::as_str).unwrap_or("error");
750 Err(format!("{status}: {msg}"))
751 }
752 }
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758
759 #[test]
760 fn pick_prefers_control_across_channels_but_keeps_data_order_within_one() {
761 let mut st = OutState { chans: BTreeMap::new() };
762 let a = st.chans.entry(1).or_insert_with(ChanQ::new);
763 a.credit = Some(10);
764 a.q.push_back(Queued { prio: SendPrio::Browser, frame: Frame::data(1, 0, vec![1]).unwrap() });
765 a.q.push_back(Queued { prio: SendPrio::Control, frame: Frame::data(1, 0, vec![2]).unwrap() });
766 let b = st.chans.entry(2).or_insert_with(ChanQ::new);
767 b.credit = Some(10);
768 b.q.push_back(Queued { prio: SendPrio::Pty, frame: Frame::data(2, 0, vec![3]).unwrap() });
769 let order: Vec<u8> = std::iter::from_fn(|| pick_next(&mut st).map(|(_, _, f)| f.payload[0])).collect();
771 assert_eq!(order, vec![3, 1, 2]);
772 }
773
774 #[test]
775 fn pick_holds_bulk_until_window() {
776 let mut st = OutState { chans: BTreeMap::new() };
777 let q = st.chans.entry(2).or_insert_with(ChanQ::new);
778 q.credit = None;
779 q.q.push_back(Queued {
780 prio: SendPrio::Control,
781 frame: Frame::data(2, 0, vec![1]).unwrap(),
782 });
783 q.q.push_back(Queued {
784 prio: SendPrio::Mail,
785 frame: Frame::data(2, 0, vec![9]).unwrap(),
786 });
787 let (_, _, f) = pick_next(&mut st).unwrap();
788 assert_eq!(f.payload, vec![1]);
789 assert!(pick_next(&mut st).is_none());
790 st.chans.get_mut(&2).unwrap().credit = Some(4);
791 let (_, _, f) = pick_next(&mut st).unwrap();
792 assert_eq!(f.payload, vec![9]);
793 }
794}