[Rust Frontend] Wait for all utility calls to finish (#52671)

Signed-off-by: Connor Carpenter <connorc@nvidia.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: Bugen Zhao <i@bugenzhao.com>

Connor Carpenter committed Aug 18, 2026 at 11:23 UTC d75136c030cc62973dc470d1981199be8de47d62
4 files changed +187 -85
rust/src/engine-core-client/src/client.rs
+57 -61
@@ -5,7 +5,7 @@ use std::collections::BTreeMap;
5 use std::sync::Arc;
6 use std::time::Duration;
7
8 -use futures::future::{join_all, try_join_all};
8 +use futures::future::join_all;
9 use itertools::Itertools;
10 use serde::Serialize;
11 use serde_json::Value as JsonValue;
@@ -450,6 +450,11 @@ impl EngineCoreClient {
450 self.config.transport_mode.data_parallel_size()
451 }
452
453 + #[cfg(test)]
454 + pub(crate) fn pending_utility_call_count(&self) -> usize {
455 + self.inner.pending_utility_call_count()
456 + }
457 +
458 /// Return the engine-side indices connected to this client.
459 pub fn engine_indices(&self) -> Vec<u32> {
460 self.engines
@@ -596,6 +601,16 @@ impl EngineCoreClient {
601 let (engine_id, rx) =
602 self.inner.register_request(request_id.clone(), lora_name, data_parallel_rank)?;
603
604 + // Construct the output stream first before actually sending the request to the engine.
605 + // This ensures that cancelling the future will properly clean up the request registration
606 + // via `Drop` of the stream.
607 + let stream = EngineCoreOutputStream::new(
608 + request_id.clone(),
609 + engine_id.engine_index().unwrap_or(0),
610 + self.abort_tx.clone(),
611 + rx,
612 + );
613 +
614 let result: Result<()> = async {
615 if let Some(coordinator) = self.coordinator.as_ref() {
616 let snapshot = coordinator.snapshot();
@@ -622,12 +637,7 @@ impl EngineCoreClient {
637 return Err(error);
638 }
639
625 - Ok(EngineCoreOutputStream::new(
626 - request_id,
627 - engine_id.engine_index().unwrap_or(0),
628 - self.abort_tx.clone(),
629 - rx,
630 - ))
640 + Ok(stream)
641 }
642
643 /// Abort currently in-flight requests by request ID.
@@ -651,8 +661,9 @@ impl EngineCoreClient {
661 }
662
663 /// Call a typed utility method on all connected engines, returning one
654 - /// decoded result per connected engine if all calls succeed or an error
655 - /// if any call fails.
664 + /// decoded result per connected engine if all calls succeed. The client
665 + /// waits for every engine outcome before returning an error so callers can
666 + /// safely compensate partially applied mutations.
667 ///
668 /// Callers should pass utility arguments using Rust tuple semantics so the
669 /// encoded payload matches Python's `(client_index, call_id,
@@ -669,62 +680,47 @@ impl EngineCoreClient {
680 "sending utility request"
681 );
682
672 - // Phase 1: allocate one call id per engine and build the per-engine
673 - // request payloads up-front. Any failure here (registry closed, encode
674 - // error) must roll back the call ids already allocated so they do not
675 - // leak in the utility registry until shutdown.
676 - let mut pending_calls = Vec::with_capacity(self.engines.len());
677 - let mut prepared_sends = Vec::with_capacity(self.engines.len());
678 - for engine in &self.engines {
679 - let (call_id, rx) = match self.inner.allocate_and_register_utility_call() {
680 - Ok(pair) => pair,
681 - Err(err) => {
682 - self.inner.unregister_utility_calls(pending_calls.iter().map(|(id, _)| *id));
683 - return Err(err);
684 - }
685 - };
686 - let request = match EngineCoreUtilityRequest::new(
687 - self.config.client_index,
688 - call_id,
689 - method,
690 - &args,
691 - ) {
692 - Ok(request) => request,
693 - Err(err) => {
694 - self.inner.unregister_utility_calls(
695 - pending_calls.iter().map(|(id, _)| *id).chain(std::iter::once(call_id)),
696 - );
697 - return Err(err);
698 - }
699 - };
700 - pending_calls.push((call_id, rx));
701 - prepared_sends.push((&engine.engine_id, request));
683 + /// Removes utility waiters if a call future is cancelled before completion.
684 + struct UtilityCallGuard<'a> {
685 + inner: &'a ClientInner,
686 + call_ids: Vec<u64>,
687 }
688
704 - // Phase 2: dispatch every utility request concurrently. `try_join_all`
705 - // fails fast on the first transport error and drops the remaining send
706 - // futures; any engines that already received the request will reply,
707 - // but those replies are simply dropped because we roll back the call
708 - // ids below.
709 - let send_futures = prepared_sends.iter().map(|(engine_id, request)| {
710 - self.inner.send_to_engine(engine_id, EngineCoreRequestType::Utility, request)
711 - });
712 - if let Err(err) = try_join_all(send_futures).await {
713 - self.inner.unregister_utility_calls(pending_calls.iter().map(|(id, _)| *id));
714 - return Err(err);
689 + impl Drop for UtilityCallGuard<'_> {
690 + fn drop(&mut self) {
691 + self.inner.unregister_utility_calls(self.call_ids.drain(..));
692 + }
693 }
694
717 - // Phase 3: wait for all engines to respond and preserve the per-engine
718 - // result list.
719 - let futures = pending_calls.into_iter().map(|(call_id, rx)| async move {
720 - rx.await
721 - .map_err(|_| Error::UtilityCallClosed {
722 - method: method.to_string(),
723 - call_id,
724 - })??
725 - .into_typed_result(method)
726 - });
727 - try_join_all(futures).await
695 + let mut call_guard = UtilityCallGuard {
696 + inner: self.inner.as_ref(),
697 + call_ids: Vec::with_capacity(self.engines.len()),
698 + };
699 +
700 + let mut prepared_calls = Vec::with_capacity(self.engines.len());
701 + for engine in &self.engines {
702 + let (call_id, rx) = self.inner.allocate_and_register_utility_call()?;
703 + call_guard.call_ids.push(call_id);
704 + let request =
705 + EngineCoreUtilityRequest::new(self.config.client_index, call_id, method, &args)?;
706 + prepared_calls.push((&engine.engine_id, call_id, rx, request));
707 + }
708 +
709 + let outcomes = join_all(prepared_calls.into_iter().map(
710 + |(engine_id, call_id, rx, request)| async move {
711 + self.inner
712 + .send_to_engine(engine_id, EngineCoreRequestType::Utility, &request)
713 + .await?;
714 + rx.await
715 + .map_err(|_| Error::UtilityCallClosed {
716 + method: method.to_string(),
717 + call_id,
718 + })??
719 + .into_typed_result(method)
720 + },
721 + ))
722 + .await;
723 + outcomes.into_iter().collect()
724 }
725
726 /// Call a utility method on all connected engines and return the shared
rust/src/engine-core-client/src/client/imp.rs
+5
@@ -118,6 +118,11 @@ impl ClientInner {
118 self.utility_reg.lock().unregister_many(call_ids);
119 }
120
121 + #[cfg(test)]
122 + pub fn pending_utility_call_count(&self) -> usize {
123 + self.utility_reg.lock().len()
124 + }
125 +
126 /// Undo a request registration when `add_request()` fails.
127 pub fn rollback_request(&self, request_id: &str) {
128 let _ = self.request_reg.lock().remove(request_id);
rust/src/engine-core-client/src/client/state.rs
+5
@@ -441,6 +441,11 @@ impl UtilityRegistry {
441 self.utility_calls.contains_key(&call_id)
442 }
443
444 + #[cfg(test)]
445 + pub fn len(&self) -> usize {
446 + self.utility_calls.len()
447 + }
448 +
449 pub fn is_closed(&self) -> bool {
450 self.closed
451 }
rust/src/engine-core-client/src/tests/client.rs
+120 -24
@@ -1431,23 +1431,20 @@ async fn is_sleeping_wrapper_sends_typed_request_and_returns_typed_response() {
1431 }
1432
1433 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1434 -async fn call_utility_failure_message_surfaces_as_error() {
1434 +async fn call_utility_waits_for_all_engines_before_returning_error() {
1435 init_tracing();
1436 let ipc = IpcNamespace::new().unwrap();
1437 let handshake_address = ipc.handshake_endpoint();
1438 - let engine_id = b"engine-utility-fail".to_vec();
1439 -
1440 - let (shutdown_tx, engine_task) = spawn_mock_engine_task(
1438 + let (failure_sent_tx, failure_sent_rx) = oneshot::channel();
1439 + let (shutdown_tx_0, engine_task_0) = spawn_mock_engine_task(
1440 handshake_address.clone(),
1442 - engine_id.clone(),
1443 - |dealer, push| {
1441 + EngineId::from_engine_index(0).into_frame().to_vec(),
1442 + move |dealer, push| {
1443 Box::pin(async move {
1444 let utility = recv_engine_message(dealer).await;
1446 - assert_eq!(utility[0].as_ref(), &[0x03]);
1445 let payload = decode_value(&utility[1]);
1446 let call_id =
1447 payload.as_array().and_then(|array| array[1].as_u64()).expect("call_id");
1450 -
1448 send_outputs(
1449 push,
1450 UtilityCallOutput {
@@ -1462,35 +1459,134 @@ async fn call_utility_failure_message_surfaces_as_error() {
1459 .into(),
1460 )
1461 .await;
1462 + let _ = failure_sent_tx.send(());
1463 + })
1464 + },
1465 + );
1466 + let (second_received_tx, second_received_rx) = oneshot::channel();
1467 + let (release_second_tx, release_second_rx) = oneshot::channel();
1468 + let (shutdown_tx_1, engine_task_1) = spawn_mock_engine_task(
1469 + handshake_address.clone(),
1470 + EngineId::from_engine_index(1).into_frame().to_vec(),
1471 + move |dealer, push| {
1472 + Box::pin(async move {
1473 + let utility = recv_engine_message(dealer).await;
1474 + let payload = decode_value(&utility[1]);
1475 + let call_id =
1476 + payload.as_array().and_then(|array| array[1].as_u64()).expect("call_id");
1477 + let _ = second_received_tx.send(());
1478 + let _ = release_second_rx.await;
1479 + send_outputs(
1480 + push,
1481 + UtilityCallOutput {
1482 + engine_index: 1,
1483 + timestamp: 0.0,
1484 + output: UtilityOutput {
1485 + call_id: call_id.into(),
1486 + failure_message: None,
1487 + result: Some(utility_result_value(true)),
1488 + },
1489 + }
1490 + .into(),
1491 + )
1492 + .await;
1493 })
1494 },
1495 );
1496
1469 - let client = connect_client_with_ipc(
1470 - handshake_test_config(
1471 - handshake_address,
1472 - 1,
1473 - "test-model",
1474 - Duration::from_secs(2),
1475 - 0,
1476 - None,
1477 - ),
1478 - &ipc,
1479 - )
1480 - .await;
1497 + let client = std::sync::Arc::new(
1498 + connect_client_with_ipc(
1499 + handshake_test_config(
1500 + handshake_address,
1501 + 2,
1502 + "test-model",
1503 + Duration::from_secs(2),
1504 + 0,
1505 + None,
1506 + ),
1507 + &ipc,
1508 + )
1509 + .await,
1510 + );
1511 + let call_client = client.clone();
1512 + let call =
1513 + tokio::spawn(async move { call_client.call_utility::<bool, _>("test_mutation", ()).await });
1514
1482 - let error = client.call_utility::<bool, _>("is_sleeping", ()).await.unwrap_err();
1515 + failure_sent_rx.await.unwrap();
1516 + second_received_rx.await.unwrap();
1517 + timeout(Duration::from_secs(2), async {
1518 + while client.pending_utility_call_count() != 1 {
1519 + tokio::task::yield_now().await;
1520 + }
1521 + })
1522 + .await
1523 + .expect("wait for first engine failure");
1524 + assert!(!call.is_finished());
1525 +
1526 + let _ = release_second_tx.send(());
1527 + let error = call.await.unwrap().unwrap_err();
1528 assert!(matches!(
1529 error,
1530 Error::UtilityCallFailed {
1531 method,
1532 message,
1533 ..
1489 - } if method == "is_sleeping" && message == "boom"
1534 + } if method == "test_mutation" && message == "boom"
1535 ));
1536
1492 - let _ = shutdown_tx.send(());
1493 - engine_task.await.unwrap();
1537 + let _ = shutdown_tx_0.send(());
1538 + let _ = shutdown_tx_1.send(());
1539 + engine_task_0.await.unwrap();
1540 + engine_task_1.await.unwrap();
1541 + let client = std::sync::Arc::try_unwrap(client)
1542 + .unwrap_or_else(|_| panic!("utility task retained client after completion"));
1543 + client.shutdown().await.unwrap();
1544 +}
1545 +
1546 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1547 +async fn cancelled_utility_call_unregisters_waiter() {
1548 + init_tracing();
1549 + let ipc = IpcNamespace::new().unwrap();
1550 + let handshake_address = ipc.handshake_endpoint();
1551 + let (received_tx, received_rx) = oneshot::channel();
1552 + let (_shutdown, engine_task) = spawn_mock_engine_task(
1553 + handshake_address.clone(),
1554 + vec![0x00, 0x00],
1555 + |dealer, _push| {
1556 + Box::pin(async move {
1557 + let _utility = recv_engine_message(dealer).await;
1558 + let _ = received_tx.send(());
1559 + std::future::pending::<()>().await;
1560 + })
1561 + },
1562 + );
1563 + let client = std::sync::Arc::new(
1564 + connect_client_with_ipc(
1565 + handshake_test_config(
1566 + handshake_address,
1567 + 1,
1568 + "test-model",
1569 + Duration::from_secs(2),
1570 + 0,
1571 + None,
1572 + ),
1573 + &ipc,
1574 + )
1575 + .await,
1576 + );
1577 + let call_client = client.clone();
1578 + let call =
1579 + tokio::spawn(async move { call_client.call_utility::<bool, _>("add_lora", ()).await });
1580 +
1581 + received_rx.await.unwrap();
1582 + assert_eq!(client.pending_utility_call_count(), 1);
1583 + call.abort();
1584 + assert!(call.await.unwrap_err().is_cancelled());
1585 + assert_eq!(client.pending_utility_call_count(), 0);
1586 +
1587 + engine_task.abort();
1588 + let client = std::sync::Arc::try_unwrap(client)
1589 + .unwrap_or_else(|_| panic!("utility task retained client after cancellation"));
1590 client.shutdown().await.unwrap();
1591 }
1592