1use std::time::{Duration, Instant};
10
11use axum::extract::ws::Message;
12use futures_util::SinkExt;
13
14use crate::delivery::delivery_turn;
15use crate::transport::envelope::ServerMessage;
16use crate::transport::{WRITE_BOUND, write_within};
17use crate::{SeatUpdate, SessionUpdate};
18
19pub const FLUSH_INTERVAL: Duration = Duration::from_millis(16);
24
25pub const FLUSH_CEILING: Duration = Duration::from_millis(100);
30
31#[derive(Default)]
33pub struct Batch {
34 held: Vec<SeatUpdate>,
35 opened: Option<Instant>,
37 quiet: Option<Instant>,
39}
40
41impl Batch {
42 pub fn push(&mut self, at: Instant, frame: SeatUpdate) {
48 if self.held.is_empty() {
49 self.opened = Some(at);
50 }
51 self.quiet = Some(at + FLUSH_INTERVAL);
52 self.held.push(frame);
53 }
54
55 pub fn due(&self) -> Option<Instant> {
61 let opened = self.opened?;
62 let quiet = self.quiet?;
63 Some(quiet.min(opened + FLUSH_CEILING))
64 }
65
66 pub fn take(&mut self) -> Vec<SeatUpdate> {
68 self.opened = None;
69 self.quiet = None;
70 std::mem::take(&mut self.held)
71 }
72}
73
74fn merge(last: &mut SessionUpdate, update: &SessionUpdate) -> bool {
86 let SessionUpdate::ChatAppended { key, source, msg } = update else {
87 return false;
88 };
89 let forge_primitives::Message::ThinkingTokens {
90 estimated_tokens,
91 estimated_tokens_delta,
92 uuid,
93 session_id,
94 extras,
95 } = msg
96 else {
97 return false;
98 };
99 let SessionUpdate::ChatAppended { key: held_key, source: held_source, msg: held_msg } = last
100 else {
101 return false;
102 };
103 if held_key != key || held_source != source {
106 return false;
107 }
108 let forge_primitives::Message::ThinkingTokens { estimated_tokens_delta: held_delta, .. } =
109 held_msg
110 else {
111 return false;
112 };
113
114 let summed = held_delta.saturating_add(*estimated_tokens_delta);
115 *held_msg = forge_primitives::Message::ThinkingTokens {
119 estimated_tokens: *estimated_tokens,
120 estimated_tokens_delta: summed,
121 uuid: uuid.clone(),
122 session_id: session_id.clone(),
123 extras: extras.clone(),
124 };
125 true
126}
127
128pub async fn flush<S>(socket: &mut S, frames: Vec<SeatUpdate>) -> anyhow::Result<()>
136where
137 S: futures_util::Sink<Message, Error = axum::Error> + Unpin,
138{
139 if frames.is_empty() {
140 return Ok(());
141 }
142 for (frame, span) in folded(frames) {
143 let update = std::sync::Arc::unwrap_or_clone(frame.update);
144 if let Some(key) = update.slot().cloned()
156 && let Some((msg, source)) = delivery_turn(&update, &key)
157 {
158 feed(
159 socket,
160 ServerMessage::Update {
161 update: Box::new(SessionUpdate::ChatAppended {
162 key,
163 msg,
164 source: Some(source),
165 }),
166 seq: None,
167 span: 0,
168 },
169 )
170 .await?;
171 }
172 feed(socket, ServerMessage::Update { update: Box::new(update), seq: frame.seq, span })
173 .await?;
174 }
175 write_within(WRITE_BOUND, async { socket.flush().await.map_err(anyhow::Error::from) }).await?;
176 Ok(())
177}
178
179fn folded(frames: Vec<SeatUpdate>) -> Vec<(SeatUpdate, u64)> {
188 let mut run: Vec<(SeatUpdate, u64)> = Vec::with_capacity(frames.len());
189 for frame in frames {
190 if let Some((last, span)) = run.last_mut()
191 && merge(std::sync::Arc::make_mut(&mut last.update), &frame.update)
192 {
193 *span = span.saturating_add(1);
197 continue;
198 }
199 run.push((frame, 1));
200 }
201 run
202}
203
204async fn feed<S>(socket: &mut S, message: ServerMessage) -> anyhow::Result<()>
208where
209 S: futures_util::Sink<Message, Error = axum::Error> + Unpin,
210{
211 let text = serde_json::to_string(&message)?;
212 write_within(WRITE_BOUND, async {
213 socket.feed(Message::Text(text.into())).await.map_err(anyhow::Error::from)
214 })
215 .await?;
216 Ok(())
217}
218
219#[cfg(test)]
220mod tests {
221 use std::pin::Pin;
222 use std::task::{Context, Poll};
223 use std::time::{Duration, Instant};
224
225 use forge_primitives::SessionSlot;
226 use forge_primitives::cloud::service_status::ServiceSeverity;
227 use futures_util::Sink;
228
229 use super::*;
230 use crate::SessionUpdate;
231 use crate::transport::envelope::ServerMessage;
232
233 fn status(message: &str) -> SessionUpdate {
235 SessionUpdate::ServiceStatus {
236 severity: ServiceSeverity::Warning,
237 message: message.to_owned(),
238 }
239 }
240
241 fn frame(update: SessionUpdate) -> SeatUpdate {
244 SeatUpdate { seq: Some(1), update: std::sync::Arc::new(update) }
245 }
246
247 fn names(frames: &[SeatUpdate]) -> Vec<&str> {
249 frames
250 .iter()
251 .map(|frame| match &*frame.update {
252 SessionUpdate::ServiceStatus { message, .. } => message.as_str(),
253 other => panic!("a batch carries what was put in it, and this is {other:?}"),
254 })
255 .collect()
256 }
257
258 #[derive(Default)]
263 struct Recording {
264 fed: Vec<Message>,
265 flushes: usize,
266 }
267
268 impl Sink<Message> for Recording {
269 type Error = axum::Error;
270
271 fn poll_ready(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
272 Poll::Ready(Ok(()))
273 }
274
275 fn start_send(mut self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> {
276 self.fed.push(item);
277 Ok(())
278 }
279
280 fn poll_flush(
281 mut self: Pin<&mut Self>,
282 _: &mut Context<'_>,
283 ) -> Poll<Result<(), Self::Error>> {
284 self.flushes += 1;
285 Poll::Ready(Ok(()))
286 }
287
288 fn poll_close(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
289 Poll::Ready(Ok(()))
290 }
291 }
292
293 fn sent(sink: &Recording) -> Vec<SeatUpdate> {
295 sink.fed
296 .iter()
297 .map(|frame| {
298 let Message::Text(text) = frame else {
299 panic!("the stream crosses as text, and this is {frame:?}")
300 };
301 let ServerMessage::Update { update, seq, .. } =
302 serde_json::from_str(text.as_str()).expect("a frame a client decodes")
303 else {
304 panic!("every frame a batch writes is an update");
305 };
306 SeatUpdate { seq, update: update.into() }
307 })
308 .collect()
309 }
310
311 #[test]
314 fn an_arrival_holds_the_batch_open_for_another_interval() {
315 let base = Instant::now();
316 let mut batch = Batch::default();
317
318 batch.push(base, frame(status("one")));
319 assert_eq!(
320 batch.due(),
321 Some(base + FLUSH_INTERVAL),
322 "a lone update goes out one interval after it lands",
323 );
324
325 let later = base + Duration::from_millis(5);
326 batch.push(later, frame(status("two")));
327 assert_eq!(
328 batch.due(),
329 Some(later + FLUSH_INTERVAL),
330 "and the update behind it holds the batch open from its own arrival",
331 );
332 }
333
334 #[test]
338 fn a_stream_that_never_goes_quiet_goes_out_at_the_ceiling() {
339 let base = Instant::now();
340 let mut batch = Batch::default();
341
342 let mut at = base;
343 for step in 0..=20 {
344 batch.push(at, frame(status(&format!("frame {step}"))));
345 at += Duration::from_millis(5);
346 }
347
348 assert_eq!(
349 batch.due(),
350 Some(base + FLUSH_CEILING),
351 "the ceiling decides, not the last arrival, so a busy batch is never held past it",
352 );
353 }
354
355 #[test]
358 fn taking_a_batch_hands_back_its_updates_in_order_and_clears_it() {
359 let base = Instant::now();
360 let mut batch = Batch::default();
361 for name in ["one", "two", "three"] {
362 batch.push(base, frame(status(name)));
363 }
364
365 let taken = batch.take();
366
367 assert_eq!(
368 names(&taken),
369 ["one", "two", "three"],
370 "the batch keeps the order the core emitted",
371 );
372 assert!(
373 batch.take().is_empty(),
374 "and taking it leaves nothing for a second flush to write",
375 );
376 assert_eq!(batch.due(), None, "with nothing held there is no deadline to keep");
377 }
378
379 #[tokio::test]
382 async fn a_batch_goes_out_as_its_updates_and_one_flush() {
383 let base = Instant::now();
384 let mut batch = Batch::default();
385 for name in ["one", "two", "three"] {
386 batch.push(base, frame(status(name)));
387 }
388 let mut sink = Recording::default();
389
390 flush(&mut sink, batch.take()).await.expect("the batch writes");
391
392 assert_eq!(sink.flushes, 1, "a batch is one flush on the socket, not one per update");
393 assert_eq!(
394 names(&sent(&sink)),
395 ["one", "two", "three"],
396 "and every update crosses as its own frame, in the order it was emitted",
397 );
398 }
399
400 struct NeverFlushes;
403
404 impl Sink<Message> for NeverFlushes {
405 type Error = axum::Error;
406
407 fn poll_ready(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
408 Poll::Ready(Ok(()))
409 }
410
411 fn start_send(self: Pin<&mut Self>, _: Message) -> Result<(), Self::Error> {
412 Ok(())
413 }
414
415 fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
416 Poll::Pending
417 }
418
419 fn poll_close(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
420 Poll::Ready(Ok(()))
421 }
422 }
423
424 #[tokio::test(start_paused = true)]
430 async fn a_batch_whose_flush_never_completes_ends_at_the_write_bound() {
431 let mut sink = NeverFlushes;
432 let outcome = tokio::time::timeout(
433 crate::transport::WRITE_BOUND * 2,
434 flush(&mut sink, vec![frame(status("one"))]),
435 )
436 .await
437 .expect("the batch ends at the write bound rather than never");
438 let error = outcome.expect_err("a flush that never completes must not hold the connection");
439 assert!(
440 error.downcast_ref::<crate::transport::WriteStalled>().is_some(),
441 "the batch ends on the write bound rather than on a sink error: {error:?}",
442 );
443 }
444
445 struct FullBuffer;
449
450 impl Sink<Message> for FullBuffer {
451 type Error = axum::Error;
452
453 fn poll_ready(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
454 Poll::Pending
455 }
456
457 fn start_send(self: Pin<&mut Self>, _: Message) -> Result<(), Self::Error> {
458 Ok(())
459 }
460
461 fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
462 Poll::Pending
463 }
464
465 fn poll_close(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
466 Poll::Ready(Ok(()))
467 }
468 }
469
470 #[tokio::test(start_paused = true)]
474 async fn a_batch_whose_feed_cannot_take_a_frame_ends_at_the_write_bound() {
475 let mut sink = FullBuffer;
476 let outcome = tokio::time::timeout(
477 crate::transport::WRITE_BOUND * 2,
478 flush(&mut sink, vec![frame(status("one"))]),
479 )
480 .await
481 .expect("the feed ends at the write bound rather than never");
482 let error =
483 outcome.expect_err("a frame the buffer cannot take must not hold the connection");
484 assert!(
485 error.downcast_ref::<crate::transport::WriteStalled>().is_some(),
486 "the feed ends on the write bound rather than on a sink error: {error:?}",
487 );
488 }
489
490 fn token(seat: &SessionSlot, running: u64, delta: i64, uuid: &str) -> SessionUpdate {
495 SessionUpdate::ChatAppended {
496 key: seat.clone(),
497 source: None,
498 msg: forge_primitives::Message::ThinkingTokens {
499 estimated_tokens: running,
500 estimated_tokens_delta: delta,
501 uuid: uuid.to_owned(),
502 session_id: "s".to_owned(),
503 extras: serde_json::Map::new(),
504 },
505 }
506 }
507
508 fn carried(update: &SessionUpdate) -> Option<&serde_json::Value> {
511 match update {
512 SessionUpdate::ChatAppended {
513 msg: forge_primitives::Message::ThinkingTokens { extras, .. },
514 ..
515 } => extras.get("carried"),
516 _ => None,
517 }
518 }
519
520 fn naming(update: &SessionUpdate) -> (&str, &str) {
523 match update {
524 SessionUpdate::ChatAppended {
525 msg: forge_primitives::Message::ThinkingTokens { uuid, session_id, .. },
526 ..
527 } => (uuid, session_id),
528 other => panic!("a token frame names itself, and this is {other:?}"),
529 }
530 }
531
532 fn describe(update: &SessionUpdate) -> String {
534 match update {
535 SessionUpdate::ChatAppended {
536 key,
537 msg:
538 forge_primitives::Message::ThinkingTokens {
539 estimated_tokens,
540 estimated_tokens_delta,
541 ..
542 },
543 ..
544 } => format!(
545 "{}: {estimated_tokens} running, {estimated_tokens_delta} grown",
546 key.label(),
547 ),
548 SessionUpdate::ChatAppended { key, .. } => {
549 format!("{}: a frame that is not a counter", key.label())
550 }
551 SessionUpdate::TurnCancelled { key } => format!("{}: cancelled", key.label()),
552 other => format!("{other:?}"),
553 }
554 }
555
556 #[tokio::test]
560 async fn a_run_of_token_appends_leaves_as_one_frame_carrying_their_sum() {
561 let seat = SessionSlot::lead("Org", "forge");
562 let mut sink = Recording::default();
563
564 let mut last = token(&seat, 30, 30, "c");
570 if let SessionUpdate::ChatAppended {
571 msg: forge_primitives::Message::ThinkingTokens { extras, .. },
572 ..
573 } = &mut last
574 {
575 extras.insert("carried".to_owned(), serde_json::json!(true));
576 }
577 flush(
578 &mut sink,
579 vec![
580 frame(token(&seat, 200, 200, "a")),
581 frame(token(&seat, 250, 50, "b")),
582 frame(last),
583 ],
584 )
585 .await
586 .expect("the batch writes");
587
588 assert_eq!(sink.flushes, 1, "the run is one flush, as any batch is");
589 assert_eq!(sink.fed.len(), 1, "and one frame where the CLI sent three");
590 let Message::Text(text) = &sink.fed[0] else {
594 panic!("the stream crosses as text, and this is {:?}", sink.fed[0])
595 };
596 let ServerMessage::Update { seq, span, .. } =
597 serde_json::from_str(text.as_str()).expect("a frame a client decodes")
598 else {
599 panic!("every frame a batch writes is an update");
600 };
601 assert_eq!(seq, Some(1), "the survivor keeps the run's oldest number");
602 assert_eq!(span, 3, "and carries how many of the seat's numbers it stands for");
603 assert_eq!(
604 describe(&sent(&sink)[0].update),
605 "lead: 30 running, 280 grown",
606 "carrying the last arrival's running value and the deltas summed",
607 );
608 assert_eq!(
609 naming(&sent(&sink)[0].update),
610 ("c", "s"),
611 "and the id of the arrival the run ended on, which is the one the frame stands for",
612 );
613 assert_eq!(
614 carried(&sent(&sink)[0].update),
615 Some(&serde_json::json!(true)),
616 "and a fact the surviving arrival carried must survive the fold's rebuild",
617 );
618 }
619
620 #[tokio::test]
627 async fn only_consecutive_token_appends_for_one_seat_merge() {
628 let seat = SessionSlot::lead("Org", "forge");
629 let elsewhere = SessionSlot::lead("Org", "other");
630 let mut sink = Recording::default();
631
632 flush(
633 &mut sink,
634 vec![
635 frame(token(&seat, 200, 200, "a")),
636 frame(SessionUpdate::TurnCancelled { key: seat.clone() }),
637 frame(token(&seat, 250, 50, "b")),
638 frame(token(&seat, 30, 30, "c")),
639 frame(token(&elsewhere, 10, 10, "d")),
640 ],
641 )
642 .await
643 .expect("the batch writes");
644
645 let said: Vec<String> = sent(&sink).iter().map(|frame| describe(&frame.update)).collect();
646 assert_eq!(
647 said,
648 [
649 "lead: 200 running, 200 grown",
650 "lead: cancelled",
651 "lead: 30 running, 80 grown",
652 "lead: 10 running, 10 grown",
653 ],
654 "the run the cancelled frame interrupted, and another seat's counter, both stay whole",
655 );
656 }
657}