Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for Actions #49

Closed
theseankelly opened this issue Mar 27, 2020 · 12 comments · Fixed by #108
Closed

Support for Actions #49

theseankelly opened this issue Mar 27, 2020 · 12 comments · Fixed by #108

Comments

@theseankelly
Copy link
Member

No description provided.

@shschaefer
Copy link

As described in public at ROS World 2021, it was our team's intent to work on this. Before checking in any code, it would be great to understand if anyone else is working on this, like your team @hoffmann-stefan.

@hoffmann-stefan
Copy link
Member

hoffmann-stefan commented Jan 13, 2022

@shschaefer We are currently not working on this library as we wait which of the open PRs (#87/#84 or #89/#90) get chosen and how they are modified by review. Doing something now would result into huge merge conflicts. (Unless we absolutly need some bugfix or little feature for an internal project) After that we would take another look which features we need, Actions are certainly up on the list. So if no one else would implement them we sooner or later would need and implement them. But If someone else want's to do them first we won't complain. Certanly I would like to do a code review while the PR adding support for actions gets discussed.

But before starting implementing Actions I would like to make sure that the following headlines from #90 are implemented/discussed in the main branch, to avoid touching nearly every other line in the actions code again when doing those afterwards:

  • Remove interfaces that expose internal implementation details
  • Use SafeHandles for memory managment
  • Improve error handling

@shschaefer
Copy link

Thanks @hoffmann-stefan . The work on Actions was near complete last fall before our overlapping PRs were submitted. I am simply dusting the code off and completing it. I have looked to see what the impact of changing Esteve's existing pattern which I tried to follow to what you have done. Most are trivial with the exception of using SafeHandles. There is a pile of additional work on error handling that exists in my PR and follow on work I have done to my variant of Services and with the Actions work. I will see how to fold that in.

I am loathe to remove some of the interfaces as you have done as it creates more difficulty in using mocking frameworks and other interface driven patterns which are common in the C# community. Though I agree with not exposing internal implementation details, not have a top level interface or wrapper for certain functions can be problematic.

@hoffmann-stefan
Copy link
Member

@shschaefer Ok, thanks for the information and your work on this.

I already thought that the interface change can be controversial. I realy like the arguments made in the book Framework Design Guidlines (.NET specific) about this. Though I know many use mocking frameworks (which need interfaces) to test theire code. I have not used a mocking framework yet, somehow got away with it or didn't have code structured that way that would make it nececary. Im not completly opossed to having interfaces here, but after reading and understanding the chaptor on this my default is not havig interfaces for this kind of library code. Also I sometimes watch the .NET Api Review streams, some time ago there was an intressting discussion on interfaces in the base class library, see video link in this comment: dotnet/runtime#45593 (comment). Though I see we may not have the same constraints as the .NET base class library.

Maybe we should discuss this with an concrete method signitures how we can remove implementation details in interfaces without removing them completly (as compared to the main branch) or how we could add interfaces back in (compared to #90). Like should Node.CreatePublisher<T>() return the interface or the abstract base class? Are/will there methods in rcldotnet which would take an interface and how would they get to the implementation details (handle)?

Online snippet of the chapter for interfaces: https://docs.microsoft.com/en-us/dotnet/standard/design-guidelines/abstractions-abstract-types-and-interfaces

@shschaefer
Copy link

@hoffmann-stefan , your observation is spot on that we are quite far away from the constraints of the BCL. However, it is also true that this is not service code where the IoC pattern has proven its worth and driven many of the interface based test patterns. I often find that the interface pattern allows me to move quickly, then refactor as the abstractions become clear.

I have completed a first pass of Action support in the ms-iot fork, including minimal introduction of SafeHandles, added basic ROS logging support, etc... There is a good bit of work to be done to improve error handling and native memory safety. There is cost and complexity with using SafeHandles that I am working to digest.

I am hopeful that we can get the main branch unstuck. Much of refactoring necessary to move Action support is mechanical. Would prefer to just get it right.

@hoffmann-stefan
Copy link
Member

hoffmann-stefan commented Mar 23, 2022

@shschaefer, I looked at your current published code in the dev/actions
branch, as we are nearing the point where we need actions as well in our team.
I know this is an early prototype, but maybe there are some directions we can
discuss before continuing the implementation.

Thoughts on the API of ActionClients

I'm not sure about the ActionClient<TGoalRequest, TGoalResponse, TResultRequest, TResultResponse, TFeedback>
generic type parameters. I would
rather like something like ActionClient<TAction, TGoal, TResult, TFeedback> as
there are only the types that the user defined (in the *.action file) that
need to be passed in. The request and response types are an implementation
detail of actions.

So I did an initial try on defining an API surface and doing some early method
definitions in my working directory, but without implementing them, except
sketching a few key methods, but nothing that compiles as a whole yet. But this
gives me enough confidence that this API should be possible to implement much
the same as the services are implemented in #90, but with some additional
tricks. Maybe we can discuss this API proposal down below.

Porting stuff one direction or the other

I have completed a first pass of Action support in the ms-iot fork, including minimal introduction of SafeHandles, added basic ROS logging support, etc... There is a good bit of work to be done to improve error handling and native memory safety. There is cost and complexity with using SafeHandles that I am working to digest.

I'm not sure why you are trying to port my changes over to your code, while the
direction in the main branch is not clear yet. The cost and complexity with
SafeHandles is already digested over here (#90) and it should be easy to
continue on top of my commits in the same pattern.

What are the things you don't like about my PR that you do that much work to
port some ideas I implemented over there to your branch?

Did I miss something that is in your branch (#84) that is not in my branch?

  • I know of the QoS parameters, but they should be easy to add on top of Add support for services and improve memory managment and error handling #90.
  • TF2 is a separate package so this would not need to be merged (maybe only
    some git filter-branch trickery to preserve history of that folder)
  • Support for Hololens shouldn't be touched by my commits either, there are
    only changes in the CI and cmake files?

You did copy a lot of code line by line I did write initially without preserving
my carefully crafted and focused history on implementing one concept after the
other. By doing this the attribution of my code as git commit author gets lost
as well. As you contribute in a different license this may even defacto violate
the license of my contributions, but I'm not a lawyer and I don't know if this
is really the case or not. But I can say that I don't like this fact...

I don't think you did this on purpose or wanted to do something bad, but it may
be a good time to step back and collect the pros and cons how this should be
merged together. Which direction is the easiest to port the changes, where is
less stuff missing, how big is the effort to be done on each side to get all
features? If there is stuff missing can this be ported easily on top of the
other branch as they are completely separate and independent files?

I think most of the additional features should be easier to port on top of my
branch/commits than the other way round, and that would as well preserve my
history for collections and services including the work done on memory
management and error handling. Unless I missed something, see the questions
above.

@esteve: Maybe you have some comments here?

Planning how work should continue on this

As we need actions in the near future I can do the offer to implement the
proposed API (including changes from the discussion) preferably on top of #90 or
an merged main branch. I still need to ask for the time to implement this, but
my "boss" already knows that this could be coming and didn't say no right away
;).

But there are other facts I may be missing (see section above), so I wanted to
discuss this here.

@shschaefer, @ooeygui: Did you already continue on not published branches? Have
you any plans to continue on actions and maybe can get some inspiration from the
proposed API? What do you think about the proposed API? Hopefully we can agree
on an way forward here.

API Proposal

Generated Action Message Types

But now back to my proposed API for actions:

To support actions we need to generate additional interface types. This are the
interfaces that will be implemented by the classes that are generated for
actions.

IRosActionSendGoalRequest<TGoal>, IRosActionGetResultResponse<TResult> and
IRosActionFeedbackMessage<TFeedback> reduce the needed number of type
parameters later on. As C# does not (yet) support existential types additional
static methods will be implemented in the IRosActionDefinition to work around
that fact. We don't really need to reference the concrete Types in user code as
this is only used as an implementation detail.

These interfaces mimic the pattern used by the automatically generated messages.

public interface IRosActionDefinition<TGoal, TResult, TFeedback>
    where TGoal : IRosMessage, new()
    where TResult : IRosMessage, new()
    where TFeedback : IRosMessage, new()
{
    // must be implemented on deriving types, gets called via reflection
    // (static abstract interface members are not supported yet.)
    // public static abstract IntPtr __GetTypeSupport();

    // These methods and the used interfaces are a workaround as existential types are not (yet) supported in .NET/C#
    // See https://github.com/dotnet/csharplang/issues/5556 for the issue of adding existential types to C#.

    // public static abstract IRosActionSendGoalRequest<TGoal> __CreateSendGoalRequest();
    // public static abstract SafeHandle __CreateSendGoalRequestHandle();
    
    // public static abstract IRosActionSendGoalResponse __CreateSendGoalResponse();
    // public static abstract SafeHandle __CreateSendGoalResponseHandle();
    
    // public static abstract IRosActionGetResultRequest __CreateGetResultRequest();
    // public static abstract SafeHandle __CreateGetResultRequestHandle();
    
    // public static abstract IRosActionGetResultResponse<TResult> __CreateGetResultResponse();
    // public static abstract SafeHandle __CreateGetResultResponseHandle();
    
    // public static abstract IRosActionFeedbackMessage<TFeedback> __CreateFeedbackMessage();
    // public static abstract SafeHandle __CreateFeedbackMessageHandle();
}

public interface IRosActionSendGoalRequest<TGoal> : IRosMessage
    where TGoal : IRosMessage, new()
{
    unique_identifier_msgs.msg.UUID Goal_id { get; set; } // Dependent on decision in https://github.com/ros2-dotnet/ros2_dotnet/issues/91.
    TGoal Goal { get; set; }
}

public interface IRosActionSendGoalResponse : IRosMessage
{
    bool Accepted { get; set; }
    builtin_interfaces.msg.Time Stamp { get; set; }
}

public interface IRosActionGetResultRequest : IRosMessage
{
    unique_identifier_msgs.msg.UUID Goal_id { get; set; }
}

public interface IRosActionGetResultResponse<TResult> : IRosMessage
    where TResult : IRosMessage, new()
{
    sbyte Status { get; set; }
    TResult Result { get; set; }
}

public interface IRosActionFeedbackMessage<TFeedback> : IRosMessage
    where TFeedback : IRosMessage, new()
{
    unique_identifier_msgs.msg.UUID Goal_id { get; set; }
    TFeedback Feedback { get; set; }
}

Strictly speaking the interfaces may not even be needed, but they should make this nicer.
The first thing I sketched out was using some construct and deconstruct methods to "hide the types".

public interface IRosActionDefinition<TGoal, TResult, TFeedback>
    where TGoal : IRosMessage, new()
    where TResult : IRosMessage, new()
    where TFeedback : IRosMessage, new()
{
    // Not part of the proposal, but an alternative considered.

    // public static abstract IRosMessage CreateSendGoalRequest(UUID goal_id, TGoal goal);
    // public static abstract void DeconstructSendGoalResponse(IRosMessage response, out bool accepted, out Time stamp);
    // ...
}

This are for example the generated classes for the Fibonacci action:

public class Fibonacci_Goal : IRosMessage { /* ... */ }
public class Fibonacci_Result : IRosMessage { /* ... */ }
public class Fibonacci_Feedback : IRosMessage { /* ... */ }

public class Fibonacci_SendGoal_Request : IRosMessage, IRosActionSendGoalRequest<Fibonacci_Goal> { /* ... */ }
public class Fibonacci_SendGoal_Response : IRosMessage, IRosActionSendGoalResponse { /* ... */ }

public sealed class Fibonacci_SendGoal : IRosServiceDefinition<Fibonacci_SendGoal_Request, Fibonacci_SendGoal_Response> { /* ... */ }

public class Fibonacci_GetResult_Request : IRosMessage, IRosActionGetResultRequest { /* ... */ }
public class Fibonacci_GetResult_Response : IRosMessage, IRosActionGetResultResponse<Fibonacci_Result> { /* ... */ }

public sealed class Fibonacci_GetResult : IRosServiceDefinition<Fibonacci_GetResult_Request, Fibonacci_GetResult_Response> { /* ... */ }

public class Fibonacci_FeedbackMessage : IRosMessage, IRosActionFeedbackMessage<Fibonacci_Feedback> { /* ... */ }

public sealed class Fibonacci : IRosActionDefinition<Fibonacci_Goal, Fibonacci_Result, Fibonacci_Feedback> { /* ... */ }

This is not part of the API but an internal implementation trick used already in
#90. This caches the reflection of the pseudo static members of
IRosActionDefinition.

internal static class ActionDefinitionStaticMemberCache<TAction, TGoal, TResult, TFeedback>
    where TAction : IRosActionDefinition<TGoal, TResult, TFeedback>
    where TGoal : IRosMessage, new()
    where TResult : IRosMessage, new()
    where TFeedback : IRosMessage, new()
{
    static ActionDefinitionStaticMemberCache()
    {
        // Get pseudo static interface implementations via reflection.
    }

    // Call cached pseudo static interface implementation methods:
    public static IntPtr GetTypeSupport() => throw null;

    public static IRosActionSendGoalRequest<TGoal> CreateSendGoalRequest() => throw null;
    public static SafeHandle CreateSendGoalRequestHandle() => throw null;

    public static IRosActionSendGoalResponse CreateSendGoalResponse() => throw null;
    public static SafeHandle CreateSendGoalResponseHandle() => throw null;

    public static IRosActionGetResultRequest CreateGetResultRequest() => throw null;
    public static SafeHandle CreateGetResultRequestHandle() => throw null;

    public static IRosActionGetResultResponse<TResult> CreateGetResultResponse() => throw null;
    public static SafeHandle CreateGetResultResponseHandle() => throw null;

    public static IRosActionFeedbackMessage<TFeedback> CreateFeedbackMessage() => throw null;
    public static SafeHandle CreateFeedbackMessageHandle() => throw null;
}

Action Client

This should be more or less a direct port of the python API here: https://github.dev/ros2/rclpy/blob/master/rclpy/rclpy/action/client.py

API definition

public partial class Node
{
    public ActionClient<TAction, TGoal, TResult, TFeedback> CreateActionClient<TAction, TGoal, TResult, TFeedback>(string actionName)
        where TAction : IRosActionDefinition<TGoal, TResult, TFeedback>
        where TGoal : IRosMessage, new()
        where TResult : IRosMessage, new()
        where TFeedback : IRosMessage, new()
    {
        IntPtr typesupport = ActionDefinitionStaticMemberCache<TAction, TGoal, TResult, TFeedback>.GetTypeSupport();
        // ...
    }
}

public enum ActionGoalStatus
{
    // see definition here: https://github.com/ros2/rcl_interfaces/blob/master/action_msgs/msg/GoalStatus.msg

    // <summary>
    // Indicates status has not been properly set.
    // </summary>
    Unknown = 0,

    // <summary>
    // The goal has been accepted and is awaiting execution.
    // </summary>
    Accepted = 1,

    // <summary>
    // The goal is currently being executed by the action server.
    // </summary>
    Executing = 2,

    // <summary>
    // The client has requested that the goal be canceled and the action server has
    // accepted the cancel request.
    // </summary>
    Canceling = 3,

    // <summary>
    // The goal was achieved successfully by the action server.
    // </summary>
    Succeeded = 4,

    // <summary>
    // The goal was canceled after an external request from an action client.
    // </summary>
    Canceled = 5,

    // <summary>
    // The goal was terminated by the action server without an external request.
    // </summary>
    Aborted = 6,
}

public abstract class ActionClientGoalHandle
{
    // Only allow internal subclasses.
    internal ActionClientGoalHandle()
    {
    }

    public abstract Guid GoalId { get; }

    public abstract bool Accepted { get; }

    public abstract Time Stamp { get; }

    public abstract ActionGoalStatus Status { get; }
}

public sealed class ActionClientGoalHandle<TAction, TGoal, TResult, TFeedback> : ActionClientGoalHandle
    where TAction : IRosActionDefinition<TGoal, TResult, TFeedback>
    where TGoal : IRosMessage, new()
    where TResult : IRosMessage, new()
    where TFeedback : IRosMessage, new()
{
    // No public constructor.
    internal ActionClientGoalHandle()
    {
        // ...
    }

    public override Guid GoalId { get; }

    public override bool Accepted { get; }

    public override Time Stamp { get; }

    public override ActionGoalStatus Status { get; }

    public async Task CancelGoalAsync() => throw null;

    public async Task<TResult> GetResultAsync() => throw null;
}

public abstract class ActionClient
{
    // Only allow internal subclasses.
    internal ActionClient()
    {
    }
}

public sealed class ActionClient<TAction, TGoal, TResult, TFeedback> : ActionClient
    where TAction : IRosActionDefinition<TGoal, TResult, TFeedback>
    where TGoal : IRosMessage, new()
    where TResult : IRosMessage, new()
    where TFeedback : IRosMessage, new()
{
    // No public constructor.
    internal ActionClient(SafeActionClientHandle handle, Node node)
    {
        // ...
    }

    public bool ServerIsReady() => throw null;

    // Add an optional CancellationToken parameter later on (useful in the service `Client.SendRequestAsync()` and all other public async methods as well)
    // But this may be after initial implementation.

    public Task<ActionClientGoalHandle<TAction, TGoal, TResult, TFeedback>> SendGoalAsync(TGoal goal) => throw null;
    public Task<ActionClientGoalHandle<TAction, TGoal, TResult, TFeedback>> SendGoalAsync(TGoal goal, Action<TFeedback> feedbackCallback) => throw null;
}

Usage Example

var node = RCLDotnet.CreateNode("test_node");

var actionClient = node.CreateActionClient<Fibonacci, Fibonacci_Goal, Fibonacci_Result, Fibonacci_Feedback>("fibonacci_action");

var goalHandle = await actionClient.SendGoalAsync(
    new Fibonacci_Goal() { Order = 10 },
    (Fibonacci_Feedback feedback) => Console.WriteLine( "Feedback: " + string.Join(", ", feedback.Sequence)));

Fibonacci_Result actionResult = await goalHandle.GetResultAsync();

Console.WriteLine("Result: " + string.Join(", ", actionResult.Sequence));
// or
await goalHandle.CancelGoalAsync();

Note: For this example to work there needs to be an Thread/Task that runs
RCLDotnet.Spin(). I have some vague idea how this would work nicely with some kind of
RCLDotnet.SpinAsync(callback, cancellationToken) that uses an RCLDotnet specific
SynchronizationContext
to synchronize all callbacks in the same thread, but this is is may be a separate issue.

Action Service

Left open for now, but should use the same overall handling of generic type arguments as client.
Maybe define this after the ActionClient API surface is discussed.

@shschaefer
Copy link

Thanks @hoffmann-stefan , it is good to hear that your team needs to use this work. I have expressed since the first post here, the desire to avoid a repeat of PR#84-90 which I feel we are on course to do again. If you feel that my current work in a secondary staging branch that I indicated would include your code to resolve mechanical differences and ease upstream merging needs direct attribution to you, I am fine with doing so.

As you requested, I am happy to step back and figure out a better way. It looks like the CI issue may be resolved soon and we can merge PR #90 then iterate, branch, etc... to better collaborate now that you may have time to also work on this.

I have continued the implementation with the goal of getting it to a production-ready state. There are simply too many unhandled exception paths and other issues all the way down to use of the RCL libraries. I have resolved the generics issue similar to how you proposed hear to simply have IActionClient/Server<TAction>. I initially attempted to do so without reflection or dynamics, but instead deferred it in order to create a compilable, working implementation that could easily be refactored. The ActionClient is easier as you have seen in your attempt to sketch an API from mine. The ActionServer is more complex.

Porting direction

I have not questioned porting my code on top of PR #90. I would like to avoid another reimplementation of my work without attribution. I am directly offering to take your feedback and complete the work to upstream my contribution.

Much of what you have proposed is inline with my working branch - post resolving the generics issue. As I noted in my branch, circular dependencies in compilation prevented the use of UUID and other base interfaces from within the rcldotnet_common branch. There were trivial fixes to that available. My first pass left this intact without disturbing the project structure.

API Feedback

In regards to library form, I tried to follow the RCLCPP Action implementation pattern instead of the Python pattern. And then recently to begin to make it more idiomatic C#. I have made my opinion on that known in feedback to PR #91.

Early on I did not find it desirable to expose a goal handle to ActionClient(s). The RCL APIs do not separate feedback messages, nor does the RCLCPP impl, for example. But it is a simple lookup to forward messages tagged with Goal ID. I wonder what porting an existing node that relies on this would look like? Later, I found it simplifying for handling status and other callbacks. Your API definition does not reflect the status callback, but it is trivially added as much of the rest matches what I have implemented modulo naming, SendGoalRequestAsync vs. SendGoalAsync.

I also found no reason to create a shadow enum for goal status. It is generated as action_msgs.msg.GoalStatus and can be cleanly serialized into and out of its status array.

With the server, I have implemented the core pattern of a node with callbacks as shown in the RCLCPP tutorials. One each for handling goal requests, goal execution and cancellation. There is an ActionServerGoalHandle class which wraps impl details within the server. It is exported to the implementor for use in the execution handler.

General feedback

When it comes to async and threading, there is a lot more to interaction with the underlying wait set implementation. Creating an async spin is something that ROS2 has tried to avoid with its use of executors and refactoring towards more general synchronization structures like wait sets and timers. I ran into many of these issues when attempting to complete a working build.

@hoffmann-stefan
Copy link
Member

hoffmann-stefan commented Mar 29, 2022

@shschaefer: This took some time to write a response, but I hope the length of
this post explains why ;)

I have expressed since the first post here, the desire to avoid a repeat of PR#84-90 which I feel we are on course to do again.

For now we should be fine here, I did not start implementing this. Discussing
the API for this feature is the way to avoid this.

Though I have to be honest, depending on when this will be finished (or at least
in a somewhat usable state compatible to the other APIs in #90) we can use this.
On the other hand if this isn't ready when we need the features in our internal
code I may not have a choice but implement this myself in order to avoid
delaying the development of our internal stuff/products.

I can't demand that you finish this in time for us. And I hope you understand me
if I would start implementing this for the reason written above. This is not an
easy situation to be in...

Thats why I made the offer in my last comment that I could implement this
proposed API for actions. This would be another way out of this situation
without demanding work from you.

In the case that I implement this for our internal products I would continue
sharing the code in the open and finding a way to contribute this back or
helping integrating your/other implementations into the main branch, including
updating our internal code afterwards to be on the main branch of this project
once actions are merged (with all the qualities described in my first post in
this issue).

If you feel that my current work in a secondary staging branch that I indicated would include your code to resolve mechanical differences and ease upstream merging needs direct attribution to you, I am fine with doing so.

If this stays a secondary staging branch I don't mind. But for me that was more
work than I would imagine one doing for such a branch if he knew everything
would change pretty much later on. My impression was this is the continuation of
the work in #84 with the intention to be proposed as PR later on.

I initially attempted to do so without reflection or dynamics, but instead deferred it in order to create a compilable, working implementation that could easily be refactored.

Here we have different styles/priorities of working on such things. You wanted
to get this working somehow in a codebase you know and than
optimizing/refactoring the code to be the one you like. My thoughts are that
this should be doable, let's start right away with implementing the API I wan't
to use as soon as possible, to avoid having to change to much later on.

Not really knowing how you work lead me into believing that this was more than a
test branch.

The ActionClient is easier as you have seen in your attempt to sketch an API from mine.

I did sketch the API based on rclpy, not based on your branch. See technical
stuff below for hints why my API proposal more in line with rclpy/rclcpp
than your branch.

When I looked at your code I was mainly concerned which APIs/functionality did
you implement, not how they where implemented in detail. There I saw that they
are not in line with what I imagined based on the APIs of rclpy and rclcpp.
So I made the proposal for the API and addressed some other things as well.

I have not questioned porting my code on top of PR #90. I would like to avoid another reimplementation of my work without attribution. I am directly offering to take your feedback and complete the work to upstream my contribution.

Thanks, I hope I didn't give to much feedback below in this posts ;) It's a lot
of stuff I address here, I hope I made it as clear as possible with providing my
sources on where I have that information from. I also hope that this is seen as
constructive criticism, it's often hard to argue other opinions on technical
stuff while not sounding rude.

Technical stuff

Generic parameters

I have resolved the generics issue similar to how you proposed hear to simply have IActionClient/Server.

How would ActionServer<TAction> work without casting the messages later on in the callbacks?

The minimal solution I have found for this is like
ActionServer<TAction, TGoal, TResult, TFeedback>.
Maybe TAction can be removed, but it takes away the possibility to do some
helper methods on the IActionDefinition side. TGoal, TResult and
TFeedback are the only types that the user of the API should care. The
generated TSendGoalRequest and so on are only an implementation detail the
user shouldn't need to know. But if we want to expose them we could do so with
the IRosActionSendGoalRequest<TGoal> interfaces without introducing new type
parameters.

Cyclic references

As I noted in my branch, circular dependencies in compilation prevented the use of UUID and other base interfaces from within the rcldotnet_common branch. There were trivial fixes to that available. My first pass left this intact without disturbing the project structure.

Concerning the issue with cyclic references I didn't look that much into the
details of your code, and missed that part.
After thinking about this I think know what you mean:
unique_identifier_msgs.msg.UUID and IRosMessage are in separate assemblies,
so this cycle is there:

  • unique_identifier_msgs.msg.UUID in the unique_identifier_msgs assembly
    references IRosMessage in the rcldotnet_common assembly.
  • IRosActionSendGoalRequest<TGoal> in the rcldotnet_common assembly
    references unique_identifier_msgs.msg.UUID in the
    unique_identifier_msgs assembly.
public interface IRosActionSendGoalRequest<TGoal> : IRosMessage
    where TGoal : IRosMessage, new()
{
    unique_identifier_msgs.msg.UUID Goal_id { get; set; } // Dependent on decision in https://github.com/ros2-dotnet/ros2_dotnet/issues/91.
    TGoal Goal { get; set; }
}
  • Is this the only issue (excluding similar interfaces for other messages) here
    or is there still some other cycle left that you found out?
  • Fow now I would remove the problematic properties from the interface and use
    reflection (like you did) in combination with an static generic cache class to
    avoid doing the reflection every time. For now this library depends on
    reflection, but it should be cached to avoid causing bad performance.

edit from 2022-06-24: This proposed reflection cache does not work, see comment: #49 (comment)

public interface IRosActionSendGoalRequest<TGoal> : IRosMessage
    where TGoal : IRosMessage, new()
{
    // NOTE why this can not be added and maybe provide short alternatives (Provide other types for UUID and Time, use newer language features...)
    // NOTE to use RosActionSendGoalRequestReflectionCache instead
    // unique_identifier_msgs.msg.UUID Goal_id { get; set; } // Dependent on decision in https://github.com/ros2-dotnet/ros2_dotnet/issues/91.
    TGoal Goal { get; set; }
}

internal static class RosActionSendGoalRequestReflectionCache<TGoal>
    where TGoal : IRosMessage, new()
{
    static RosActionSendGoalRequestReflectionCache()
    {
        // Get Getter/Setter-Methods for properties not referenceable by the Interface via reflection and cache them.
    }

    // Call cached getter/Setter methods:
    
    public static UUID GetGoalId(IRosActionSendGoalRequest<TGoal> sendGoalRequest) => throw null;
    public static void SetGoalId(IRosActionSendGoalRequest<TGoal> sendGoalRequest, UUID value) => throw null;
}
  • Thank you for the notice on this topic!

Don't expose implementation details in the API

Much of what you have proposed is inline with my working branch

[...] as much of the rest matches what I have implemented modulo naming, SendGoalRequestAsync vs. SendGoalAsync.

There is not only a naming difference between your Action.ClientSendGoalRequestAsync() and my proposed ActionClient.SendGoalAsync(), they also take different parameters:

  • Action.ClientSendGoalRequestAsync() Takes a $ACTION_SendGoal_Request
  • ActionClient.SendGoalAsync() Takes a $ACTION_Goal

The user shouldn't know that there is an $ACTION_SendGoal_Request type as it
is only an implementation detail. rclpy and rclcpp both hide this type as
well in the equivalent methods.

Python and C++ RCL APIs

In regards to library form, I tried to follow the RCLCPP Action implementation pattern instead of the Python pattern.

I don't see that much difference between rclcpp and rclpy in that regard.
Both have for example an ActionClient.SendGoalAsync() like method returning an
future (called Task in C#) of an ActionClientGoalHandle. The only
difference is that the C++ version takes an additional callback for the result,
which we can be added to the API proposal as well if you like.

Skimming over the the other action client and client goal handle classes doesn't
show much difference either, same api but in different languages with minor
modifications.

And then recently to begin to make it more idiomatic C#. I have made my
opinion on that known in feedback to PR #91.

Idiomatic C# is way more than the naming conventions, #91 only talks about the
naming convention aspect of generated properties.

Most of this is taking the Python and C++ method signatures and translating them
to C# as directly as possible. The result is pretty standard C# code as well.
See async section below for additional work to make this library more idiomatic
C# by providing additional APIs on top of the direct ports.

Client goal handle and feedback callback

Early on I did not find it desirable to expose a goal handle to ActionClient(s). The RCL APIs do not separate feedback messages, nor does the RCLCPP impl, for example. But it is a simple lookup to forward messages tagged with Goal ID.

C++ has a goal handle as well (std::shared_future is equivalent to Task<T> in .NET):
https://github.com/ros2/rclcpp/blob/492770c12f5427effdb7f24a03aebc55b2532d63/rclcpp_action/include/rclcpp_action/client.hpp#L495-L512

  std::shared_future<typename GoalHandle::SharedPtr>
  async_send_goal(const Goal & goal, const SendGoalOptions & options = SendGoalOptions())

This is not the job of the rcl library as its only a minimal layer that provides
the core implementation, forwarding the feedback messages is done in rclpy and
rclcpp, which both do this to callbacks on a per goal_id basis.

I wonder what porting an existing node that relies on this would look like? Later, I found it simplifying for handling status and other callbacks.

I wonder if there exists a node that does handle feedback callbacks differently
as on a per goal_id basis? I can't find an API in both rclpy and rclcpp that
does provide an callback across all goals of an action client. Or do you mean
something different and I misunderstood this?

Status topic in the client

Your API definition does not reflect the status callback, but it is trivially added [...]

On the ROS2 design document this status topic is described as only for debugging
tools. But it seems that both rclpy and rclcpp subscribe to it and provide
this as status field in the client goal handle. I included the Status property
in the ActionGoalHandle class, but initially thought it was only assigned with
the get result response. But as you said this is trivially added but there
wouldn't be an difference in the API anyways. Thanks for pointing this out!

from http://design.ros2.org/articles/actions.html (may be out of date):

Goal Status Topic

Direction: server publishes
Content: list of in-progress goals with goal ID, time accepted, and an enum indicating the status

This topic is published by the server to broadcast the status of goals it has accepted. The purpose of the topic is for introspection; it is not used by the action client. Messages are published when transitions from one status to another occur.

Status enum

I also found no reason to create a shadow enum for goal status. It is generated as action_msgs.msg.GoalStatus and can be cleanly serialized into and out of its status array.

I like enums better and would say this is the idiomatic way to expose this in
C#. It does assist IntelliSense to provide auto completions and I find it
generally nicer to have an enum instead of some loosely coupled constants (from
the standpoint of the compiler). Otherwise we would only rely on documentation
to guide the user of the API where the definitions for those values are and what
they mean. But that said I could live without an extra enum here, but I would
prefer having it.

Server API

With the server, I have implemented the core pattern of a node with callbacks as shown in the RCLCPP tutorials. One each for handling goal requests, goal execution and cancellation. There is an ActionServerGoalHandle class which wraps impl details within the server. It is exported to the implementor for use in the execution handler.

Pretty much all I wrote about the API until now was without looking into the
action server part in detail. I think I try to do some API specification and
usage examples (hopefully later this week) based on the classes and methods in
rclpy and rclcpp like I did with the client for the server as well. To get
some more knowledge how the server side works.

Async

When it comes to async and threading, there is a lot more to interaction with the underlying wait set implementation. Creating an async spin is something that ROS2 has tried to avoid with its use of executors and refactoring towards more general synchronization structures like wait sets and timers. I ran into many of these issues when attempting to complete a working build.

Could you please describe this issues with async you have in more detail? At
least for the normal Client.SendRequest this did map pretty cleanly from the
implementation of rclpy and rclcpp as soon as I understood that Python/C++
future maps to Task/TaskCompletionSource. I currently don't see an reason
why this would be different for action client or services.

I'm not sure what you mean with ROS2 avoiding an async spin?
Here is a doc page describing the benefits of async methods:
https://docs.ros.org/en/rolling/How-To-Guides/Sync-Vs-Async.html
Quote taken from that page:

The C++ service call API is only available in async, so the comparisons and examples in this guide pertain to Python services and clients. The definition of async given here generally applies to C++, with some exceptions.

I would only provide the async versions for methods in rcldotnet for now, adding
blocking ones can be done later without much effort using
.GetAwaiter().GetResult() on the returned task, tough I'm not sure if we should
provide such methods.

https://docs.ros.org/en/rolling/Concepts/About-ROS-2-Client-Libraries.html#language-specific-functionality

For example, threading models used by “spin” functions will have implementations that are specific to the language of the client library.

To adopt this to idiomatic C# we would not only provide callbacks but also use
async methods returning Tasks where it is applicable (and in many cases both
can be done). To be able to use other C# async methods we should provide an
SynchronizationContext that adopts other Tasks to continue in the thread(s)
managed by the executor (for now the Spin() method event loop).

// There needs to be another overload of the Service constructor that takes a async callback for this
public async Task<std.srv.Empty_Response> HandleSomeService(std.srv.Empty_Request request, CancellationToken cancellationToken)
{
    Console.WriteLine("This gets executed in the thread that called `RCLDotnet.Spin(...)`");

    
    // The cancellation token would "fire" if the executor does shut down for example on `CTRL-C`. So all running async callbacks would stop as soon as possible.

    // some method that uses the builtin HttpClient https://docs.microsoft.com/en-us/dotnet/api/system.net.http.httpclient.getasync?view=net-6.0
    var httpResponse = await GetSomeHttpResponse(cancellationToken);

    Console.WriteLine("This gets executed in the thread that called `RCLDotnet.Spin(...)` because of the SynchronizationContext " +
        "(implicit thread local on the thread that starts the await).");

    return new std.srv.Empty_Response();
}

HttpClient doesn't know anything about the guard conditions used to implement
this "signal and return to spin thread", this will be abstracted away by the
SynchronizationContext. If we didn't provide an SynchronizationContext the
part of the method after the await would run in some random background thread,
forcing the user of the API to use additional synchronization primitives like
mutexes or concurrent queues to make this thread safe (This example doesn't
access state, but if it would it would be atomic between await statements in
case of an SingleThreadedExecutor or a CallbackGroup).

rclpy supports something like that using
Executor.create_task(...),
not sure tough if it supports the python version of the await keyword in that
combination.

@hoffmann-stefan
Copy link
Member

hoffmann-stefan commented Apr 4, 2022

@shschaefer: As written in the last post I did some investigation on how the action server APIs work and compared rclpy and rclcpp. There are differences like you said before between them, all mainly around how the callbacks are handled. I documented the differences here and provide an C# mapping of both libraries as a starting point for discussion how the API should look like. You started with the callback pattern of the rclcpp tutorials, which I also think is the right starting point after looking over this (see below for more words on this).

public abstract class ActionServerGoalHandle
{
    // Only allow internal subclasses.
    internal ActionServerGoalHandle()
    {
    }

    public abstract Guid GoalId { get; }

    public abstract bool IsActive { get; }

    public abstract bool IsCanceling { get; }

    public abstract bool IsExecuting { get; }

    public abstract ActionGoalStatus Status { get; }

    // In `rclpy` this calls the `executeCallback` after setting the state to executing.
    // In `rclcpp` this does not call the `executeCallback` (as there is none) but sets the state for the explicit `AcceptAndDefer` `GoalResponse`.
    public void Execute() => throw null;
    
    internal abstract SafeActionServerGoalHandle Handle { get; }
}

public sealed class ActionServerGoalHandle<TAction, TGoal, TResult, TFeedback> : ActionServerGoalHandle
        where TAction : IRosActionDefinition<TGoal, TResult, TFeedback>
        where TGoal : IRosMessage, new()
        where TResult : IRosMessage, new()
        where TFeedback : IRosMessage, new()
{
    // No public constructor.
    internal ActionServerGoalHandle(
        // ...
    )
    {
    }

    // `rclpy` uses the name `Request`, but the name from `rclcpp` `Goal` fits better.
    public TGoal Goal { get; }

    public override Guid GoalId => throw null;

    public override bool IsActive => throw null;

    public override bool IsCanceling=> throw null;

    public override bool IsExecuting => throw null;

    public override ActionGoalStatus Status => throw null;

    public void PublishFeedback(TFeedback feedback) => throw null;

    
    // Decide wich (or both?) of these methods should be exposed.

    // "rclpy style"
    public void Succeed() => throw null;

    public void Abort() => throw null;

    public void Canceled() => throw null;

    // "rclcpp style"
    public void Succeed(TResult result) => throw null;

    public void Abort(TResult result) => throw null;

    public void Canceled(TResult result) => throw null;
}

public enum GoalResponse
{
    Default = 0, // Invalid value
    Reject = 1,

    // `rclpy` doesn't provide the option to defer the the execution (unless you override the `acceptCallback`)
    Accept = 2,

    // Alternative from `rclcpp`
    AcceptAndExecute = 2,
    AcceptAndDefer = 3, // Gives the option to call `GoalHandle.Execute()` later in the `acceptedCallback`.
}

public enum CancelResponse
{
    Default = 0, // Invalid value
    Reject = 1,
    Accept = 2,
}

public abstract class ActionServer
{
    // Only allow internal subclasses.
    internal ActionServer()
    {
    }

    internal abstract SafeActionServerHandle Handle { get; }
}

public sealed class ActionServer<TAction, TGoal, TResult, TFeedback> : ActionServer
    where TAction : IRosActionDefinition<TGoal, TResult, TFeedback>
    where TGoal : IRosMessage, new()
    where TResult : IRosMessage, new()
    where TFeedback : IRosMessage, new()
{
    // No public constructor.
    internal ActionServer(
        // ...
    )
    {
    }
}

The main difference would be in creating the ActionServer as this defines which callbacks are provided. There are differences between rclpy, rclcpp and the SimpleActionServer from navigation2.

rclpy style callbacks

Api

public partial sealed class Node
{
    private static void DefaultAcceptedCallback(ActionServerGoalHandle goalHandle)
    {
        // This calls the provided `executeCallback` in an executor task (in `rclpy`)
        goalHandle.Execute();
    }

    private static GoalResponse DefaultGoalCallback<TGoal>(TGoal goal)
    {
        return GoalResponse.Accept;
    }

    private static CancelResponse DefaultCancelCallback(ActionServerGoalHandle goalHandle)
    {
        return CancelResponse.Reject;
    }

    public ActionServer<TAction, TGoal, TResult, TFeedback> CreateActionServer<TAction, TGoal, TResult, TFeedback>(
        string actionName,
        Func<ActionServerGoalHandle<TAction, TGoal, TResult, TFeedback>, Task<TResult>> executeCallback,
        Func<TGoal, GoalResponse> goalCallback = null,
        Action<ActionServerGoalHandle<TAction, TGoal, TResult, TFeedback>> acceptedCallback = null,
        Func<ActionServerGoalHandle<TAction, TGoal, TResult, TFeedback>, CancelResponse> cancelCallback = null
    )
        where TAction : IRosActionDefinition<TGoal, TResult, TFeedback>
        where TGoal : IRosMessage, new()
        where TResult : IRosMessage, new()
        where TFeedback : IRosMessage, new()
    {
        if (goalCallback == null)
            goalCallback = DefaultGoalCallback;
        
        if (acceptedCallback == null)
            acceptedCallback = DefaultAcceptedCallback;
        
        if (cancelCallback == null)
            cancelCallback = DefaultCancelCallback;
        
        // ...
    }
}

Usage

private void Main()
{
    // ...
    var actionServer = node.CreateActionServer<Fibonacci, Fibonacci_Goal, Fibonacci_Result, Fibonacci_Feedback>("fibonacci_action", ExecuteCallbackAsync);
}

private async Task<Fibonacci_Result> ExecuteCallbackAsync(ActionServerGoalHandle<Fibonacci, Fibonacci_Goal, Fibonacci_Result, Fibonacci_Feedback> goalHandle)
{
    var feedbackMsg = new Fibonacci_Feedback();

    feedbackMsg.Sequence = new List<int> { 0, 1 };

    for (int i = 1; i < goalHandle.Goal.Order; i++)
    {
        if (goalHandle.IsCancelRequested)
        {
             var result = new Fibonacci_Result();
            result.Sequence = feedbackMsg.Sequence;

            // NOTE: canceled call and returning the result are separate.
            goalHandle.Canceled();
            return result;
        }

        feedbackMsg.Sequence.Add(feedbackMsg.Sequence[i] + feedbackMsg.Sequence[i - 1]);

        goalHandle.PublishFeedback(feedbackMsg);

        await Task.Delay(1000);
    }

    
    var result = new Fibonacci_Result();
    result.Sequence = feedbackMsg.Sequence;
    
    // NOTE: succeed call and returning the result are separate.
    goalHandle.Succeed();
    return result;
}

rclcpp style callbacks

Mirrors rclcpp, but with additional defaults for goalCallback and cancelCallback.

Api

public partial sealed class Node
{
    private static GoalResponse DefaultGoalCallback<TGoal>(Guid goalId, TGoal goal)
    {
        return GoalResponse.AcceptAndExecute;
    }

    private static CancelResponse DefaultCancelCallback(ActionServerGoalHandle goalHandle)
    {
        return CancelResponse.Reject;
    }

    public ActionServer<TAction, TGoal, TResult, TFeedback> CreateActionServer<TAction, TGoal, TResult, TFeedback>(
        string actionName,
        // Make this an async callback? (`Func<GoalHandle, Task>`)
        // Make this an async callback which returns the `TResult` (`Func<GoalHandle, Task<TResult>>`)? In that case this would be more or less an `executeCallback` from `rclpy`.
        Action<ActionServerGoalHandle<TAction, TGoal, TResult, TFeedback>> acceptedCallback
        // This has an additional `goalId` parameter compared to `rclpy`.
        Func<Guid, TGoal, GoalResponse> goalCallback = null,
        Func<ActionServerGoalHandle<TAction, TGoal, TResult, TFeedback>, CancelResponse> cancelCallback = null
    )
        where TAction : IRosActionDefinition<TGoal, TResult, TFeedback>
        where TGoal : IRosMessage, new()
        where TResult : IRosMessage, new()
        where TFeedback : IRosMessage, new()
    {
        if (goalCallback == null)
            goalCallback = DefaultGoalCallback;
        
        if (cancelCallback == null)
            cancelCallback = DefaultCancelCallback;
        
        // ...
    }
}

Usage

private void Main()
{
    // ...
    var actionServer = node.CreateActionServer<Fibonacci, Fibonacci_Goal, Fibonacci_Result, Fibonacci_Feedback>("fibonacci_action", AcceptCallback);

    // This is the reason why I would like to provide an async `acceptCallback` 
    // version (later on) so that the user of the API doesn't need to do the 
    // dance to convert to async methods without getting this wrong. It also 
    // enables `rcldotnet` to handle exceptions in an consistent way with the 
    // other callbacks (which would need to be defined in some other 
    // discussion, log exception vs. shutdown the executor vs. make this
    // configurable)
}

private void AcceptCallback(ActionServerGoalHandle<Fibonacci, Fibonacci_Goal, Fibonacci_Result, Fibonacci_Feedback> goalHandle)
{
    var task = AcceptCallbackAsync(goalHandle);
    _ = task; // can't be awaited here, waiting synchronously would cause a deadlock here. (In case of an "SingleThreadedExecutor")
}

private async Task AcceptCallbackAsync(ActionServerGoalHandle<Fibonacci, Fibonacci_Goal, Fibonacci_Result, Fibonacci_Feedback> goalHandle)
{
    try
    {
        // User needs to handle logging exceptions or shuting down the node explicitly.
        await AcceptCallbackAsyncInner(goalHandle)
    }
    catch (Exception ex)
    {
        // log this or shutdown the node
    }
}

private async Task AcceptCallbackAsyncInner(ActionServerGoalHandle<Fibonacci, Fibonacci_Goal, Fibonacci_Result, Fibonacci_Feedback> goalHandle)
{
    var feedbackMsg = new Fibonacci_Feedback();

    feedbackMsg.Sequence = new List<int> { 0, 1 };

    for (int i = 1; i < goalHandle.Goal.Order; i++)
    {
        if (goalHandle.IsCancelRequested)
        {
             var result = new Fibonacci_Result();
            result.Sequence = feedbackMsg.Sequence;

            // NOTE: canceled call and returning the result are combined.
            goalHandle.Canceled(result);
        }

        feedbackMsg.Sequence.Add(feedbackMsg.Sequence[i] + feedbackMsg.Sequence[i - 1]);

        goalHandle.PublishFeedback(feedbackMsg);

        await Task.Delay(1000);
    }

    
    var result = new Fibonacci_Result();
    result.Sequence = feedbackMsg.Sequence;
    
    // NOTE: succeed call and returning the result are combined.
    goalHandle.Succeed(result);
}

Navigation 2 SimpleActionServer

See header file here: https://github.com/ros-planning/navigation2/blob/084f1475499804a105020bbd33cc306ced1056d1/nav2_util/include/nav2_util/simple_action_server.hpp#L1

It was discussed in the last ROS2 Client Libraries Working Group meeting (2022-03-24) to move this into rclcpp or its own library.

I compared compared this briefly to the additional handling of rclpy but this does something different, despite the execution callback being called the same. The execution callback in SimpleActionServer is more like a "main loop" handling more than one goal over its lifetime, in rclpy the execution callback is restricted to the execution of one goal. There may be other differences but at that point i didn't look further for now as this seems like an helper type that can be looked at later on after the initial implementation.

Different versions off the callbacks

There could/will be multiple versions of the callbacks that could be combined in different combinations.

I think this is the biggest open question which of the different possible callback types we would provide. Though this should only be expanded later, in the first version start with one, maybe two CreateActionServer() variants.

To avoid exploding the overloads it could be beneficial to provide an ActionServerOptions type which gets the callbacks in a "builder style".

The differences would be:

  • taking the goalId vs. not (only for the goalCallback)
  • async vs. not
  • taking an cancellationToken vs. not
  • returning the result as Task<TResult> vs calling the method on the GoalHandle which takes the TResult.
  • ...

The simplest would be to start with the rclcpp style acceptCallback, as this is the lowest level. Then we can investigate how to extend this to some sort of executeCallback on top or only make an async version of the acceptCallback which is pretty much the same.
As the rclcpp style acceptCallback doesn't return an value we would implement the GoalHandle.Succeed(TResult result) style methods first.

Future idea: Handling of cancellation

Maybe there is some future possibility to integrate the cancelling of an goal with the .NET handling of CancellationTokens. This could make this nicely composable with other async tasks. For example cancel the await for an response of an ROS client or an Task.Delay(), so the cleanup code would run faster.

Some thoughts/notes:

  • Provide a acceptedCallback/executeCallback that passes an CancellationToken which reflects gets activated when the goal was cancelled.
  • Catch an OperationCancelled exception from the executeCallback/acceptedCallback and convert this to an abort of the goal from the server.
    • Open Question: How to define the return value TResult in this case?
  • Stopping the executor would cancel all "in flight callbacks" as well when provided an CancellationToken, but this is something not restricted to actions.

@shschaefer
Copy link

Hi @hoffmann-stefan , as I indicated earlier, I started bottoms up instead of tops down to understand the complexity of the action implementation as opposed to services and topics which are quite straightforward and less intertwined with the asynchronous behaviors of the underlying libraries. I started with the cpp libraries to keep the problem manageable before defining a desirable API surface. Through we are not far off when it comes to the API design, my prototyping revealed many things which you are discovering and assures me of what is possible.

Since we are not commenting on my PR, but instead re-hashing the API defined there, I will begin with the most cornerstone API of the ActionServer, creation from the Node class. Contrary to your allowance of null callbacks, I kept these explicit as the semantics of your defaults are not quite correct. Accepting a cancel request, for example, implies that your code can actually handle a cancellation. It would be more appropriate to REJECT the cancel request as a default. It may also seem reasonable to accept and execute a goal when asked, but this directly implies that your server can handle multiple, concurrent goal requests. I believe that making these choices explicit is better API design. Your example of the nav2 SimpleActionServer shows the pattern very clearly. We can easily add nice library or helper classes to make simple actions easy to implement without cluttering the basic API with numerous opaque overloads.

With enumerated values, my preference for idiomatic in structure but not naming shows itself. Trying to rename and redefine constants that are defined in header files like ACCEPT_AND_EXECUTE adds little value. Looking at a log coming from code invoked using this library, I would hope that the same constants are used in code. The addition of default or sentinel values, though semantically complete, are not valid. They will require effort to keep consistent in behavior and debugging. If we were to do it, I would prefer to see a name like INVALID_RESPONSE = 0 with GoalResponse for example. Make the fact that they are not valid explicit and in the developer's face. Maybe we should also give feedback to the ROS maintainers that general lack of default values with enums cause inconsistencies in implementation.

It appears that you are starting to see how the use of different executors makes the asynchronous problem more complicated than just enabling async methods. We will need to be very explicit about how one would spin asynchronously with a single threaded executor and a seemingly benign wait. I do want to implement CancellationToken more thoroughly and potentially the SynchronizationContext pattern, but know that it would not have been effective to get a working implementation to start that way. I like the idea of having the core callback APIs accept an optional CancellationToken. I also agree that it would be nice to handle cancellation of execution, though I think we will need to do some lower level work to make that consistent.

If you look at my example code in RCLDotNetActionServer.cs, it implements the exact code in your usage example except that it makes the goal and cancel callbacks explicit. The remainder are different patterns for handling errors and exceptions which should be standardized to clean up the code examples.

I am partial to making the core API an idiomatic C# wrapper of the C++ action pattern. Additions to be more inline with the Python style seem either out of place or better served in layered helper classes. Additions to make the code more readable or simple to implement seem righteous, but having many overloads of the same functions to look like multiple API sets does not feel right. Let's prefer that layered approach.

@hoffmann-stefan
Copy link
Member

Contrary to your allowance of null callbacks, I kept these explicit as the semantics of your defaults are not quite correct. Accepting a cancel request, for example, implies that your code can actually handle a cancellation. It would be more appropriate to REJECT the cancel request as a default. It may also seem reasonable to accept and execute a goal when asked, but this directly implies that your server can handle multiple, concurrent goal requests. I believe that making these choices explicit is better API design.

Sorry about that, this was a copy-paste error while translating this from the python code here (did copy the goal callback to the cancel one without noticing the different return value). I changed this in my post to avoid getting this wrong again later on.

@hoffmann-stefan
Copy link
Member

hoffmann-stefan commented Jun 24, 2022

Some changes I found while implementing this (only ActionClient until this comment) in #94:

  • Rename ActionClient.ServiceIsReady() to ActionClient.ServerIsReady(), move to base class as well.
  • Some other small renames in the implementation details for the generated messages.
  • The proposed RosActionSendGoalRequestReflectionCache does not work as you can't reference the concrete SendGoalRequest type for the generic parameter.
    • Found another possibility to get around the cyclic references using properties with the base type IRosMessage instead of UUID or Time
      • see 37845b0
      • This completely avoids reflection to get to the GoalId
        and Stamp only casts are needed.

@hoffmann-stefan hoffmann-stefan linked a pull request May 19, 2023 that will close this issue
4 tasks
Project Status automation moved this from On Deck to Done Aug 15, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
Development

Successfully merging a pull request may close this issue.

3 participants