Skip to content

Commit 0dd9e5b

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 bf732c4 commit 0dd9e5b

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
@@ -443,6 +443,122 @@ mod tests {
443443
executor.spin(SpinOptions::default().until_promise_resolved(promise));
444444
}
445445

446+
/// A full goal round-trip (feedback streaming + result) driven by the
447+
/// event-driven Tokio executor, exercising the action push-callback path
448+
/// (`rcl_action_{server,client}_set_*_callback`). The completion flag makes a
449+
/// timeout fail the test rather than pass silently.
450+
#[cfg(feature = "tokio-executor")]
451+
#[test]
452+
fn test_action_success_streaming_tokio() {
453+
use std::sync::{
454+
atomic::{AtomicBool, Ordering},
455+
Arc,
456+
};
457+
458+
let mut executor = Context::default().create_tokio_executor();
459+
460+
let node = executor
461+
.create_node(&format!("test_action_success_tokio_{}", line!()))
462+
.unwrap();
463+
let action_name = format!("test_action_success_tokio_{}_action", line!());
464+
let _action_server = node
465+
.create_action_server(&action_name, |handle| {
466+
fibonacci_action(handle, TestActionSettings::default())
467+
})
468+
.unwrap();
469+
470+
let client = node
471+
.create_action_client::<Fibonacci>(&action_name)
472+
.unwrap();
473+
474+
let order_10_sequence = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55];
475+
let request = client.request_goal(Fibonacci_Goal { order: 10 });
476+
477+
let done = Arc::new(AtomicBool::new(false));
478+
let done_cb = Arc::clone(&done);
479+
let promise = executor.commands().run(async move {
480+
let mut goal_client_stream = request.await.unwrap().stream();
481+
let mut expected_feedback_len = 0;
482+
while let Some(event) = goal_client_stream.next().await {
483+
match event {
484+
GoalEvent::Feedback(feedback) => {
485+
expected_feedback_len += 1;
486+
assert_eq!(feedback.sequence.len(), expected_feedback_len);
487+
}
488+
GoalEvent::Status(_) => {}
489+
GoalEvent::Result((status, result)) => {
490+
assert_eq!(status, GoalStatusCode::Succeeded);
491+
assert_eq!(result.sequence, order_10_sequence);
492+
done_cb.store(true, Ordering::Relaxed);
493+
return;
494+
}
495+
}
496+
}
497+
});
498+
499+
executor.spin(
500+
SpinOptions::default()
501+
.until_promise_resolved(promise)
502+
.timeout(Duration::from_secs(15)),
503+
);
504+
505+
assert!(
506+
done.load(Ordering::Relaxed),
507+
"action goal round-trip did not complete on the Tokio executor",
508+
);
509+
}
510+
511+
/// A goal cancellation driven by the Tokio executor, exercising the action
512+
/// client's cancel-client and the server's cancel-service push callbacks.
513+
#[cfg(feature = "tokio-executor")]
514+
#[test]
515+
fn test_action_cancel_tokio() {
516+
use std::sync::{
517+
atomic::{AtomicBool, Ordering},
518+
Arc,
519+
};
520+
521+
let mut executor = Context::default().create_tokio_executor();
522+
523+
let node = executor
524+
.create_node(&format!("test_action_cancel_tokio_{}", line!()))
525+
.unwrap();
526+
let action_name = format!("test_action_cancel_tokio_{}_action", line!());
527+
let _action_server = node
528+
.create_action_server(&action_name, |handle| {
529+
fibonacci_action(handle, TestActionSettings::slow())
530+
})
531+
.unwrap();
532+
533+
let client = node
534+
.create_action_client::<Fibonacci>(&action_name)
535+
.unwrap();
536+
537+
let request = client.request_goal(Fibonacci_Goal { order: 10 });
538+
539+
let done = Arc::new(AtomicBool::new(false));
540+
let done_cb = Arc::clone(&done);
541+
let promise = executor.commands().run(async move {
542+
let goal_client = request.await.unwrap();
543+
let cancellation = goal_client.cancellation.cancel().await;
544+
assert!(cancellation.is_accepted());
545+
let (status, _) = goal_client.result.await;
546+
assert_eq!(status, GoalStatusCode::Cancelled);
547+
done_cb.store(true, Ordering::Relaxed);
548+
});
549+
550+
executor.spin(
551+
SpinOptions::default()
552+
.until_promise_resolved(promise)
553+
.timeout(Duration::from_secs(15)),
554+
);
555+
556+
assert!(
557+
done.load(Ordering::Relaxed),
558+
"action cancellation did not complete on the Tokio executor",
559+
);
560+
}
561+
446562
#[test]
447563
fn test_action_cancel_rejection() {
448564
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-
Clock, DropGuard, GoalStatusCode, Node, NodeHandle, QoSProfile, RclPrimitive,
5-
RclPrimitiveHandle, RclPrimitiveKind, RclrsError, ReadyKind, TakeFailedAsNone, Waitable,
6-
WaitableLifecycle, ENTITY_LIFECYCLE_MUTEX,
3+
action::GoalUuid, error::ToResult, rcl_bindings::*, ActionGoalReceiver, ActionServerReady,
4+
CancelResponseCode, Clock, DropGuard, GoalStatusCode, Node, NodeHandle, QoSProfile,
5+
RclPrimitive, RclPrimitiveHandle, RclPrimitiveKind, RclrsError, ReadyKind, TakeFailedAsNone,
6+
Waitable, WaitableLifecycle, ENTITY_LIFECYCLE_MUTEX,
77
};
88
use futures::future::BoxFuture;
99
use ros_env::action_msgs::srv::CancelGoal_Response;
@@ -669,6 +669,81 @@ impl<A: Action> RclPrimitive for ActionServerExecutable<A> {
669669
fn handle(&self) -> RclPrimitiveHandle<'_> {
670670
RclPrimitiveHandle::ActionServer(self.board.handle.lock())
671671
}
672+
673+
fn register_on_ready(
674+
&self,
675+
on_ready: Box<dyn Fn(ReadyKind, usize) + Send + Sync>,
676+
) -> Result<Option<Box<dyn crate::OnReadyHandle>>, RclrsError> {
677+
use crate::executor::event_callback::{CompositeOnReady, OnReadyRegistration};
678+
679+
// An action server bundles three services (goal/cancel/result). Register
680+
// a push callback on each, tagging events with the matching readiness
681+
// flag so the executor runs the right handler. Goal expiration has no rcl
682+
// push callback and is driven separately (a periodic poll in the executor).
683+
let on_ready = Arc::new(on_ready);
684+
let handle = &self.board.handle;
685+
let mk = |bits: ActionServerReady| -> Box<dyn Fn(usize) + Send + Sync> {
686+
let on_ready = Arc::clone(&on_ready);
687+
Box::new(move |n| on_ready(ReadyKind::ActionServer(bits), n))
688+
};
689+
690+
let regs: Vec<Box<dyn crate::OnReadyHandle>> = vec![
691+
Box::new(OnReadyRegistration::new(
692+
Arc::clone(handle),
693+
set_action_server_goal_callback::<A>,
694+
mk(ActionServerReady {
695+
goal_request: true,
696+
..Default::default()
697+
}),
698+
)?),
699+
Box::new(OnReadyRegistration::new(
700+
Arc::clone(handle),
701+
set_action_server_cancel_callback::<A>,
702+
mk(ActionServerReady {
703+
cancel_request: true,
704+
..Default::default()
705+
}),
706+
)?),
707+
Box::new(OnReadyRegistration::new(
708+
Arc::clone(handle),
709+
set_action_server_result_callback::<A>,
710+
mk(ActionServerReady {
711+
result_request: true,
712+
..Default::default()
713+
}),
714+
)?),
715+
];
716+
717+
Ok(Some(Box::new(CompositeOnReady(regs))))
718+
}
719+
}
720+
721+
/// Install (or, with a null callback/user_data, clear) the "on new goal request"
722+
/// push callback on an action server. Encapsulates the handle lock and rcl call.
723+
unsafe fn set_action_server_goal_callback<A: Action>(
724+
handle: &ActionServerHandle<A>,
725+
callback: rcl_event_callback_t,
726+
user_data: *const std::os::raw::c_void,
727+
) -> rcl_ret_t {
728+
rcl_action_server_set_goal_service_callback(&*handle.lock(), callback, user_data)
729+
}
730+
731+
/// As [`set_action_server_goal_callback`], for the cancel service.
732+
unsafe fn set_action_server_cancel_callback<A: Action>(
733+
handle: &ActionServerHandle<A>,
734+
callback: rcl_event_callback_t,
735+
user_data: *const std::os::raw::c_void,
736+
) -> rcl_ret_t {
737+
rcl_action_server_set_cancel_service_callback(&*handle.lock(), callback, user_data)
738+
}
739+
740+
/// As [`set_action_server_goal_callback`], for the result service.
741+
unsafe fn set_action_server_result_callback<A: Action>(
742+
handle: &ActionServerHandle<A>,
743+
callback: rcl_event_callback_t,
744+
user_data: *const std::os::raw::c_void,
745+
) -> rcl_ret_t {
746+
rcl_action_server_set_result_service_callback(&*handle.lock(), callback, user_data)
672747
}
673748

674749
/// 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
@@ -77,6 +77,15 @@ pub(crate) struct OnReadyRegistration<H: Send + Sync + 'static> {
7777

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

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

0 commit comments

Comments
 (0)