Skip to content

Commit caefc1f

Browse files
azerupicursoragent
andcommitted
feat: support actions on the Tokio executor
Drive action servers and clients with rcl's action push callbacks (rcl_action_*_set_*_callback) on the event-driven executor. An action is a composite primitive: each internal source (the server's goal/cancel/result services; the client's feedback/status subscriptions and goal/cancel/result clients) registers its own push callback tagged with the ReadyKind flag it satisfies. Because the notifications for one action coalesce into a single mailbox wakeup, the executor accumulates a merged ReadyKind per entity and runs the primitive with it. Goal expiration has no push callback, so the server polls it on a coarse interval. Adds end-to-end tests for a goal round-trip (feedback + result) and for cancellation on the Tokio executor. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 33b13d0 commit caefc1f

6 files changed

Lines changed: 469 additions & 20 deletions

File tree

rclrs/src/action.rs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,122 @@ mod tests {
404404
executor.spin(SpinOptions::default().until_promise_resolved(promise));
405405
}
406406

407+
/// A full goal round-trip (feedback streaming + result) driven by the
408+
/// event-driven Tokio executor, exercising the action push-callback path
409+
/// (`rcl_action_{server,client}_set_*_callback`). The completion flag makes a
410+
/// timeout fail the test rather than pass silently.
411+
#[cfg(feature = "tokio-executor")]
412+
#[test]
413+
fn test_action_success_streaming_tokio() {
414+
use std::sync::{
415+
atomic::{AtomicBool, Ordering},
416+
Arc,
417+
};
418+
419+
let mut executor = Context::default().create_tokio_executor();
420+
421+
let node = executor
422+
.create_node(&format!("test_action_success_tokio_{}", line!()))
423+
.unwrap();
424+
let action_name = format!("test_action_success_tokio_{}_action", line!());
425+
let _action_server = node
426+
.create_action_server(&action_name, |handle| {
427+
fibonacci_action(handle, TestActionSettings::default())
428+
})
429+
.unwrap();
430+
431+
let client = node
432+
.create_action_client::<Fibonacci>(&action_name)
433+
.unwrap();
434+
435+
let order_10_sequence = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55];
436+
let request = client.request_goal(Fibonacci_Goal { order: 10 });
437+
438+
let done = Arc::new(AtomicBool::new(false));
439+
let done_cb = Arc::clone(&done);
440+
let promise = executor.commands().run(async move {
441+
let mut goal_client_stream = request.await.unwrap().stream();
442+
let mut expected_feedback_len = 0;
443+
while let Some(event) = goal_client_stream.next().await {
444+
match event {
445+
GoalEvent::Feedback(feedback) => {
446+
expected_feedback_len += 1;
447+
assert_eq!(feedback.sequence.len(), expected_feedback_len);
448+
}
449+
GoalEvent::Status(_) => {}
450+
GoalEvent::Result((status, result)) => {
451+
assert_eq!(status, GoalStatusCode::Succeeded);
452+
assert_eq!(result.sequence, order_10_sequence);
453+
done_cb.store(true, Ordering::Relaxed);
454+
return;
455+
}
456+
}
457+
}
458+
});
459+
460+
executor.spin(
461+
SpinOptions::default()
462+
.until_promise_resolved(promise)
463+
.timeout(Duration::from_secs(15)),
464+
);
465+
466+
assert!(
467+
done.load(Ordering::Relaxed),
468+
"action goal round-trip did not complete on the Tokio executor",
469+
);
470+
}
471+
472+
/// A goal cancellation driven by the Tokio executor, exercising the action
473+
/// client's cancel-client and the server's cancel-service push callbacks.
474+
#[cfg(feature = "tokio-executor")]
475+
#[test]
476+
fn test_action_cancel_tokio() {
477+
use std::sync::{
478+
atomic::{AtomicBool, Ordering},
479+
Arc,
480+
};
481+
482+
let mut executor = Context::default().create_tokio_executor();
483+
484+
let node = executor
485+
.create_node(&format!("test_action_cancel_tokio_{}", line!()))
486+
.unwrap();
487+
let action_name = format!("test_action_cancel_tokio_{}_action", line!());
488+
let _action_server = node
489+
.create_action_server(&action_name, |handle| {
490+
fibonacci_action(handle, TestActionSettings::slow())
491+
})
492+
.unwrap();
493+
494+
let client = node
495+
.create_action_client::<Fibonacci>(&action_name)
496+
.unwrap();
497+
498+
let request = client.request_goal(Fibonacci_Goal { order: 10 });
499+
500+
let done = Arc::new(AtomicBool::new(false));
501+
let done_cb = Arc::clone(&done);
502+
let promise = executor.commands().run(async move {
503+
let goal_client = request.await.unwrap();
504+
let cancellation = goal_client.cancellation.cancel().await;
505+
assert!(cancellation.is_accepted());
506+
let (status, _) = goal_client.result.await;
507+
assert_eq!(status, GoalStatusCode::Cancelled);
508+
done_cb.store(true, Ordering::Relaxed);
509+
});
510+
511+
executor.spin(
512+
SpinOptions::default()
513+
.until_promise_resolved(promise)
514+
.timeout(Duration::from_secs(15)),
515+
);
516+
517+
assert!(
518+
done.load(Ordering::Relaxed),
519+
"action cancellation did not complete on the Tokio executor",
520+
);
521+
}
522+
407523
#[test]
408524
fn test_action_cancel_rejection() {
409525
let mut executor = Context::default().create_basic_executor();

rclrs/src/action/action_client.rs

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
use super::empty_goal_status_array;
22
use crate::{
3-
log_warn, rcl_bindings::*, CancelResponse, CancelResponseCode, DropGuard, GoalStatus,
4-
GoalStatusCode, GoalUuid, MultiCancelResponse, Node, NodeHandle, QoSProfile, RclPrimitive,
5-
RclPrimitiveHandle, RclPrimitiveKind, RclrsError, ReadyKind, TakeFailedAsNone, ToResult,
6-
Waitable, WaitableLifecycle, ENTITY_LIFECYCLE_MUTEX,
3+
log_warn, rcl_bindings::*, ActionClientReady, CancelResponse, CancelResponseCode, DropGuard,
4+
GoalStatus, GoalStatusCode, GoalUuid, MultiCancelResponse, Node, NodeHandle, QoSProfile,
5+
RclPrimitive, RclPrimitiveHandle, RclPrimitiveKind, RclrsError, ReadyKind, TakeFailedAsNone,
6+
ToResult, Waitable, WaitableLifecycle, ENTITY_LIFECYCLE_MUTEX,
77
};
88
use ros_env::{action_msgs::srv::CancelGoal_Response, builtin_interfaces::msg::Time};
99
use rosidl_runtime_rs::{Action, Message, RmwFeedbackMessage, RmwGoalResponse, RmwResultResponse};
@@ -872,6 +872,115 @@ impl<A: Action> RclPrimitive for ActionClientExecutable<A> {
872872
fn handle(&self) -> crate::RclPrimitiveHandle<'_> {
873873
RclPrimitiveHandle::ActionClient(self.board.handle.lock())
874874
}
875+
876+
fn register_on_ready(
877+
&self,
878+
on_ready: Box<dyn Fn(ReadyKind, usize) + Send + Sync>,
879+
) -> Result<Option<Box<dyn crate::OnReadyHandle>>, RclrsError> {
880+
use crate::executor::event_callback::{CompositeOnReady, OnReadyRegistration};
881+
882+
// An action client bundles two subscriptions (feedback/status) and three
883+
// service clients (goal/cancel/result). Register a push callback on each,
884+
// tagging events with the matching readiness flag so the executor runs the
885+
// right handler.
886+
let on_ready = Arc::new(on_ready);
887+
let handle = &self.board.handle;
888+
let mk = |bits: ActionClientReady| -> Box<dyn Fn(usize) + Send + Sync> {
889+
let on_ready = Arc::clone(&on_ready);
890+
Box::new(move |n| on_ready(ReadyKind::ActionClient(bits), n))
891+
};
892+
893+
let regs: Vec<Box<dyn crate::OnReadyHandle>> = vec![
894+
Box::new(OnReadyRegistration::new(
895+
Arc::clone(handle),
896+
set_action_client_feedback_callback,
897+
mk(ActionClientReady {
898+
feedback: true,
899+
..Default::default()
900+
}),
901+
)?),
902+
Box::new(OnReadyRegistration::new(
903+
Arc::clone(handle),
904+
set_action_client_status_callback,
905+
mk(ActionClientReady {
906+
status: true,
907+
..Default::default()
908+
}),
909+
)?),
910+
Box::new(OnReadyRegistration::new(
911+
Arc::clone(handle),
912+
set_action_client_goal_callback,
913+
mk(ActionClientReady {
914+
goal_response: true,
915+
..Default::default()
916+
}),
917+
)?),
918+
Box::new(OnReadyRegistration::new(
919+
Arc::clone(handle),
920+
set_action_client_cancel_callback,
921+
mk(ActionClientReady {
922+
cancel_response: true,
923+
..Default::default()
924+
}),
925+
)?),
926+
Box::new(OnReadyRegistration::new(
927+
Arc::clone(handle),
928+
set_action_client_result_callback,
929+
mk(ActionClientReady {
930+
result_response: true,
931+
..Default::default()
932+
}),
933+
)?),
934+
];
935+
936+
Ok(Some(Box::new(CompositeOnReady(regs))))
937+
}
938+
}
939+
940+
/// Install (or, with a null callback/user_data, clear) the "on new feedback"
941+
/// push callback on an action client. Encapsulates the handle lock and rcl call.
942+
unsafe fn set_action_client_feedback_callback(
943+
handle: &ActionClientHandle,
944+
callback: rcl_event_callback_t,
945+
user_data: *const std::os::raw::c_void,
946+
) -> rcl_ret_t {
947+
rcl_action_client_set_feedback_subscription_callback(&*handle.lock(), callback, user_data)
948+
}
949+
950+
/// As [`set_action_client_feedback_callback`], for the status subscription.
951+
unsafe fn set_action_client_status_callback(
952+
handle: &ActionClientHandle,
953+
callback: rcl_event_callback_t,
954+
user_data: *const std::os::raw::c_void,
955+
) -> rcl_ret_t {
956+
rcl_action_client_set_status_subscription_callback(&*handle.lock(), callback, user_data)
957+
}
958+
959+
/// As [`set_action_client_feedback_callback`], for the goal service client.
960+
unsafe fn set_action_client_goal_callback(
961+
handle: &ActionClientHandle,
962+
callback: rcl_event_callback_t,
963+
user_data: *const std::os::raw::c_void,
964+
) -> rcl_ret_t {
965+
rcl_action_client_set_goal_client_callback(&*handle.lock(), callback, user_data)
966+
}
967+
968+
/// As [`set_action_client_feedback_callback`], for the cancel service client.
969+
unsafe fn set_action_client_cancel_callback(
970+
handle: &ActionClientHandle,
971+
callback: rcl_event_callback_t,
972+
user_data: *const std::os::raw::c_void,
973+
) -> rcl_ret_t {
974+
rcl_action_client_set_cancel_client_callback(&*handle.lock(), callback, user_data)
975+
}
976+
977+
/// As [`set_action_client_feedback_callback`], for the result service client.
978+
unsafe fn set_action_client_result_callback(
979+
handle: &ActionClientHandle,
980+
callback: rcl_event_callback_t,
981+
user_data: *const std::os::raw::c_void,
982+
) -> rcl_ret_t {
983+
rcl_action_client_set_result_client_callback(&*handle.lock(), callback, user_data)
875984
}
876985

877986
/// Manage the lifecycle of an `rcl_action_client_t`, including managing its dependencies

rclrs/src/action/action_server.rs

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
use super::empty_goal_status_array;
22
use crate::{
3-
action::GoalUuid, error::ToResult, rcl_bindings::*, ActionGoalReceiver, CancelResponseCode,
4-
DropGuard, GoalStatusCode, Node, NodeHandle, QoSProfile, RclPrimitive, RclPrimitiveHandle,
5-
RclPrimitiveKind, RclrsError, ReadyKind, TakeFailedAsNone, Waitable, WaitableLifecycle,
6-
ENTITY_LIFECYCLE_MUTEX,
3+
action::GoalUuid, error::ToResult, rcl_bindings::*, ActionGoalReceiver, ActionServerReady,
4+
CancelResponseCode, DropGuard, GoalStatusCode, Node, NodeHandle, QoSProfile, RclPrimitive,
5+
RclPrimitiveHandle, RclPrimitiveKind, RclrsError, ReadyKind, TakeFailedAsNone, Waitable,
6+
WaitableLifecycle, ENTITY_LIFECYCLE_MUTEX,
77
};
88
use futures::future::BoxFuture;
99
use ros_env::action_msgs::srv::CancelGoal_Response;
@@ -665,6 +665,81 @@ impl<A: Action> RclPrimitive for ActionServerExecutable<A> {
665665
fn handle(&self) -> RclPrimitiveHandle<'_> {
666666
RclPrimitiveHandle::ActionServer(self.board.handle.lock())
667667
}
668+
669+
fn register_on_ready(
670+
&self,
671+
on_ready: Box<dyn Fn(ReadyKind, usize) + Send + Sync>,
672+
) -> Result<Option<Box<dyn crate::OnReadyHandle>>, RclrsError> {
673+
use crate::executor::event_callback::{CompositeOnReady, OnReadyRegistration};
674+
675+
// An action server bundles three services (goal/cancel/result). Register
676+
// a push callback on each, tagging events with the matching readiness
677+
// flag so the executor runs the right handler. Goal expiration has no rcl
678+
// push callback and is driven separately (a periodic poll in the executor).
679+
let on_ready = Arc::new(on_ready);
680+
let handle = &self.board.handle;
681+
let mk = |bits: ActionServerReady| -> Box<dyn Fn(usize) + Send + Sync> {
682+
let on_ready = Arc::clone(&on_ready);
683+
Box::new(move |n| on_ready(ReadyKind::ActionServer(bits), n))
684+
};
685+
686+
let regs: Vec<Box<dyn crate::OnReadyHandle>> = vec![
687+
Box::new(OnReadyRegistration::new(
688+
Arc::clone(handle),
689+
set_action_server_goal_callback::<A>,
690+
mk(ActionServerReady {
691+
goal_request: true,
692+
..Default::default()
693+
}),
694+
)?),
695+
Box::new(OnReadyRegistration::new(
696+
Arc::clone(handle),
697+
set_action_server_cancel_callback::<A>,
698+
mk(ActionServerReady {
699+
cancel_request: true,
700+
..Default::default()
701+
}),
702+
)?),
703+
Box::new(OnReadyRegistration::new(
704+
Arc::clone(handle),
705+
set_action_server_result_callback::<A>,
706+
mk(ActionServerReady {
707+
result_request: true,
708+
..Default::default()
709+
}),
710+
)?),
711+
];
712+
713+
Ok(Some(Box::new(CompositeOnReady(regs))))
714+
}
715+
}
716+
717+
/// Install (or, with a null callback/user_data, clear) the "on new goal request"
718+
/// push callback on an action server. Encapsulates the handle lock and rcl call.
719+
unsafe fn set_action_server_goal_callback<A: Action>(
720+
handle: &ActionServerHandle<A>,
721+
callback: rcl_event_callback_t,
722+
user_data: *const std::os::raw::c_void,
723+
) -> rcl_ret_t {
724+
rcl_action_server_set_goal_service_callback(&*handle.lock(), callback, user_data)
725+
}
726+
727+
/// As [`set_action_server_goal_callback`], for the cancel service.
728+
unsafe fn set_action_server_cancel_callback<A: Action>(
729+
handle: &ActionServerHandle<A>,
730+
callback: rcl_event_callback_t,
731+
user_data: *const std::os::raw::c_void,
732+
) -> rcl_ret_t {
733+
rcl_action_server_set_cancel_service_callback(&*handle.lock(), callback, user_data)
734+
}
735+
736+
/// As [`set_action_server_goal_callback`], for the result service.
737+
unsafe fn set_action_server_result_callback<A: Action>(
738+
handle: &ActionServerHandle<A>,
739+
callback: rcl_event_callback_t,
740+
user_data: *const std::os::raw::c_void,
741+
) -> rcl_ret_t {
742+
rcl_action_server_set_result_service_callback(&*handle.lock(), callback, user_data)
668743
}
669744

670745
/// Manage the lifecycle of an `rcl_action_server_t`, including managing its dependencies

rclrs/src/executor/event_callback.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ pub(crate) struct OnReadyRegistration<H: Send + Sync + 'static> {
7676

7777
impl<H: Send + Sync + 'static> OnReadyHandle for OnReadyRegistration<H> {}
7878

79+
/// Bundles several [`OnReadyHandle`]s into one, for composite primitives (action
80+
/// servers/clients) that register a push callback per internal source. Dropping
81+
/// it drops every contained registration, deregistering each callback.
82+
// The Vec is never read — dropping it drops (and thus deregisters) every
83+
// contained registration, which is the entire purpose of holding them.
84+
pub(crate) struct CompositeOnReady(#[allow(dead_code)] pub(crate) Vec<Box<dyn OnReadyHandle>>);
85+
86+
impl OnReadyHandle for CompositeOnReady {}
87+
7988
impl<H: Send + Sync + 'static> OnReadyRegistration<H> {
8089
/// Register `on_ready` to be called by the middleware whenever the entity
8190
/// becomes ready. `set_callback` locks `handle` and installs the trampoline.

0 commit comments

Comments
 (0)