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

➕ IPC functionality #5

Merged
merged 10 commits into from
Feb 10, 2025
Merged

➕ IPC functionality #5

merged 10 commits into from
Feb 10, 2025

Conversation

serges147
Copy link
Collaborator

@serges147 serges147 commented Feb 7, 2025

Note! Everything marked with ➕ symbol will be gradually reverted/uncommented in the next PRs. It's done like this to break all the ready functionality into smaller PRs. This PR is mostly about IPC, but you still can have a sneak peak at what is coming.

  • SDK IPC
    • supports several types of socket based connections:
      • Unix abstract domain - default one (under default org.opencyphal.ocvsmd.ipc name)
      • Unix socket - based on file system socket inode (like /var/run/ocvsmd.sock)
      • TCP socket (both IPv4 and IPv6) - allows potentially use SDK across different machines
    • Our own DSDL (de)serialization is in use to wire our ipc messages.
  • Added logging at both server (the daemon) and client sides of SDK (using 3rd party headers only SPDLOG project).
    • Server side uses log file max size limiting and rotation policy.
    • Log levels could be changed via command line params, as well as config file (next pr).
    • The most important log entries (like start and stop of the daemon, fact of client connection) goes also to the syslog.
  • GitHub action now builds c++ 14, 17 and 20; as well as GCC and Clang.

@serges147 serges147 self-assigned this Feb 7, 2025
@@ -0,0 +1,25 @@
The MIT License (MIT)
Copy link
Collaborator Author

@serges147 serges147 Feb 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please scroll down (to init.d/ocvsmd file) all these include/spdlog/* files - this is the logging 3rd-party headers. Currently, I dropped them right into repo, but we might reconsider to make it as a submodule. In the next PR there will be one more dependency - headers only TOML parsing library - daemon configuration will be loaded/saved to /etc/ocvsmd/ocvsmd.toml file).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Surely these includes need to be nested into a directory whose name makes it clear that these are third-party components. 3rdparty sounds like a much better name.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pavel-kirienko So, under include/3rdparty/spdlog/*, right?
@thirtytwobits do you agree?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. We should have these elsewhere. Why not a submodule or Cmake external project?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is header-only, right? We don't want any dynamic dependencies with this solution.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, headerS-only (plural). No dynamic dependencies - neither this SPDLOG nor next pr TOML parsing library.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SPDLOG now as a submodule.


} // namespace detail

/// Abstract interface of a result sender.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These sender and receivers are inspired by the P2300 c++ proposal. Not even close of course, but the main idea is that client of our SDK could compose senders (using .then( callback ) or | operator) to build necessary async pipeline, and then sync_wait to wait for result (see src/cli/main.cpp:94).

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was not aware of that proposal. I'm wondering if libcyphal executors and callbacks should be conformed to harmonize with these concepts?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

most probably. but it's still kinda experimental. will see how it goes with next file server functionality of the SDK - this is where I will experiment (in my cli tool) with the composing, like several async sdk calls:

  • add some root path(s) to remote file server
  • launch software update
  • somehow wait for completion (not sure how though...)
  • remove the paths from the file server

@@ -0,0 +1,78 @@
# This is copy/paste of the request part of the `uavcan/node/435.ExecuteCommand.1.3.dsdl` service definition.
Copy link
Collaborator Author

@serges147 serges147 Feb 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are copy/paste from corresponding public DSDL types - unfortunately it's impossible (currently) to reference request or response part of a service inside another dsdl type. @pavel-kirienko said it could be relatively easy to add such support (to pydsdl / nunavut ) in the future. If it happens then we will drop these copies.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this change won't even affect Nunavut at all; it can be confined to PyDSDL only, as the final AST will look the same.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love that we're learning and growing Cyphal here!

@serges147 serges147 marked this pull request as ready for review February 7, 2025 15:59
class FileServer
{
public:
// `errno`-like error code (or anything else we might add in the future).
using Failure = int; // or `cetl::variant<int, ...>`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using numbers for error codes is an anti-pattern

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to agree, but honestly... I don't like these "monstrous" failure variants at libcyphal either.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with both of you. The failure variants are a bit much to handle. Raw integers are delicate. Here's a pattern we use at Prime Air that is both easy but also somewhat structured:

First we decide that at least 4-bytes is needed

static_assert(sizeof(unsigned) >= 4, "32-bits or more is required to compile this software. Sorry AT-TINY.");

then we break up the bytes into some error classes

constexpr std::size_t const ArgumentClassMask = 0xFF;
constexpr std::size_t const ArgumentErrorShift = 8U;
constexpr std::size_t const MemoryErrorShift = 16U;
constexpr std::size_t const FSMErrorShift = 24U;

next we define a scoped and typed enumeration

///
///     * 0x0000000              : success / no-errors
///     * 0x0000001 - 0x000000FF : general error codes
///     * 0x0000100 - 0x0000FF00 : argument error codes
///     * 0x0010000 - 0x00FF0000 : memory error codes
///     * 0x1000000 - 0xFF000000 : finite state-machine error codes.
///
/// While these memory regions don't overlap as defined, each Result value is a discrete enumeration value and
/// cannot contain multiple error codes (i.e. this is _not_ a bitfield). 
enum class Result : unsigned {
    // --[SUCCESS]-------------------------------------------------------------------------------------------
    kSuccess                        = 0U,                           ///< No errors.

    // --[GENERAL ERRORS]----------------------------------------------------------------------------------
    kFailureGeneric                 = 1U,                           ///< A generic failure occurred (no specific reason
                                                                    ///< is known). This might be returned from some
                                                                    ///< external code or because the author of a
                                                                    ///< method was just too lazy to think about the
                                                                    ///< value any harder than this.
    kFailureBusy                    = 2U,                           ///< The operation was busy and on-going. It may be
...
    // --[ARGUMENT (PARAMETER) ERRORS]--------------------------------------------------------------------
    kArgumentErrorInvalidValue      = 1U  << ArgumentErrorShift,    ///< The value provided was invalid
    kArgumentErrorInvalidSize       = 2U  << ArgumentErrorShift,    ///< The size provided was invalid
...

    // --[MEMORY (RESOURCE) ERRORS]-------------------------------------------------------------------------
    kMemoryErrorInvalidAddress      = 1U  << MemoryErrorShift,      ///< The address provided was invalid or null.
    kMemoryErrorInvalidSize         = 2U  << MemoryErrorShift,      ///< The size of the memory was invalid, likely zero
...

etc.

Finally some helpers:


constexpr bool IsSuccess(Result const& result) {
    return to_underlying(result) == 0x00;
}

constexpr bool IsError(Result const& result) {
    return to_underlying(result) != 0x00;
}

constexpr bool IsArgumentError(Result const& result) {
    return (to_underlying(result) & (ArgumentClassMask << ArgumentErrorShift)) != 0;
}

constexpr bool IsMemoryError(Result const& result) {
    return (to_underlying(result) & (ArgumentClassMask << MemoryErrorShift)) != 0;
}

constexpr bool IsFsmError(Result const& result) {
    return (to_underlying(result) & (ArgumentClassMask << FSMErrorShift)) != 0;
}

And voila. Super easy to return something like Result::kSuccess and test IsSuccess(result) but you can also branch on error class. For example:

if (IsError(result)) {
    if (isArgumentError(result)) {
        handleArgumentErrors(result);
    } else if (isMemoryError(result)) {
        handleMemoryErrors(result);
    } else if (isFsmError(result)) {
        handleFsmErrors(result);
    } else {
        handleUnknownErrors(result);
    }
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is also c++ <system_error> facility with its error categories, codes and conditions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't get the point of Scott's idea. We dislike algebraic types because they are cumbersome to handle in C++ due to language's limitations; but then the enum approach also comes with boilerplate in the form of the helper functions. One good thing about variants is that they allow one to arbitrarily customize the auxiliary information provided with each error type, something that is not attainable with numeric errors.

Perhaps we could provide helper functions for our error variants instead, or polyfill std::expected as we planned to do for a while?

Copy link
Collaborator Author

@serges147 serges147 Feb 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allow one to arbitrarily customize the auxiliary information provided

Yes, that is a good thing. What is also good is that if one extends set of possible errors with a new kind/source then most probably related error handling code start fail to compile. One might argue whether it's good or bad... for instance such addition will be a breaking change. IMO it's more good than bad - b/c you have compile time prove that ALL possible cases are handled.

But, at the same time, if we look f.e. at the libcyphal failures, in reality we have only ONE such case (with PlatformError and its attached errno-like error code) - ALL other numerous errors are just empty structs. and in the whole libcyphal we don't have even a single place where we really analyze or make some business logic decision based on what specific error has happened - instead we kinda "wash our hands" and pass it as is to the user provided transient error handler, and let client to deal with it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is expected -- the user's callback is supposed to have the right context to handle errors sensibly.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is true. But for me, who already wrote several demos and now ocvsmd, honestly it was not fun to write such callbacks... I believe that users might be tempted to go with "swallow all" (with passing [] (auto&) { ... } to std::visit) - this will kill the good thing I described above (about "prove that ALL possible cases are handled").

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the benefits of a strongly-typed design outweigh the inconveniences caused by the limited expressivity of the language. In the future we should be able to improve this by switching to better algebraic containers, like std::expected.

/// so the list will be automatically restored on the next daemon start.
/// Returns `cetl::nullopt` on success.
///
virtual cetl::optinal<Failure> push_root(const cetl::string_view path, const bool back);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
virtual cetl::optinal<Failure> push_root(const cetl::string_view path, const bool back);
virtual cetl::optional<Failure> push_root(const cetl::string_view path, const bool back = true);

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo will be fixed but regarding default argument for a virtual function parameter there are following:

@@ -0,0 +1,25 @@
The MIT License (MIT)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Surely these includes need to be nested into a directory whose name makes it clear that these are third-party components. 3rdparty sounds like a much better name.

@@ -0,0 +1,25 @@
The MIT License (MIT)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. We should have these elsewhere. Why not a submodule or Cmake external project?


} // namespace detail

/// Abstract interface of a result sender.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was not aware of that proposal. I'm wondering if libcyphal executors and callbacks should be conformed to harmonize with these concepts?

@@ -0,0 +1,25 @@
The MIT License (MIT)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is header-only, right? We don't want any dynamic dependencies with this solution.

@@ -14,7 +14,7 @@ enable_testing()

set(NO_STATIC_ANALYSIS OFF CACHE BOOL "disable static analysis")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Protip: use cmake presets to create flavors that turn this on and off.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, but I don't want "explode" number of different flavors. that is why f.e. cpp standard is also not part of flavor permutations.

@@ -0,0 +1,78 @@
# This is copy/paste of the request part of the `uavcan/node/435.ExecuteCommand.1.3.dsdl` service definition.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love that we're learning and growing Cyphal here!

class FileServer
{
public:
// `errno`-like error code (or anything else we might add in the future).
using Failure = int; // or `cetl::variant<int, ...>`

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with both of you. The failure variants are a bit much to handle. Raw integers are delicate. Here's a pattern we use at Prime Air that is both easy but also somewhat structured:

First we decide that at least 4-bytes is needed

static_assert(sizeof(unsigned) >= 4, "32-bits or more is required to compile this software. Sorry AT-TINY.");

then we break up the bytes into some error classes

constexpr std::size_t const ArgumentClassMask = 0xFF;
constexpr std::size_t const ArgumentErrorShift = 8U;
constexpr std::size_t const MemoryErrorShift = 16U;
constexpr std::size_t const FSMErrorShift = 24U;

next we define a scoped and typed enumeration

///
///     * 0x0000000              : success / no-errors
///     * 0x0000001 - 0x000000FF : general error codes
///     * 0x0000100 - 0x0000FF00 : argument error codes
///     * 0x0010000 - 0x00FF0000 : memory error codes
///     * 0x1000000 - 0xFF000000 : finite state-machine error codes.
///
/// While these memory regions don't overlap as defined, each Result value is a discrete enumeration value and
/// cannot contain multiple error codes (i.e. this is _not_ a bitfield). 
enum class Result : unsigned {
    // --[SUCCESS]-------------------------------------------------------------------------------------------
    kSuccess                        = 0U,                           ///< No errors.

    // --[GENERAL ERRORS]----------------------------------------------------------------------------------
    kFailureGeneric                 = 1U,                           ///< A generic failure occurred (no specific reason
                                                                    ///< is known). This might be returned from some
                                                                    ///< external code or because the author of a
                                                                    ///< method was just too lazy to think about the
                                                                    ///< value any harder than this.
    kFailureBusy                    = 2U,                           ///< The operation was busy and on-going. It may be
...
    // --[ARGUMENT (PARAMETER) ERRORS]--------------------------------------------------------------------
    kArgumentErrorInvalidValue      = 1U  << ArgumentErrorShift,    ///< The value provided was invalid
    kArgumentErrorInvalidSize       = 2U  << ArgumentErrorShift,    ///< The size provided was invalid
...

    // --[MEMORY (RESOURCE) ERRORS]-------------------------------------------------------------------------
    kMemoryErrorInvalidAddress      = 1U  << MemoryErrorShift,      ///< The address provided was invalid or null.
    kMemoryErrorInvalidSize         = 2U  << MemoryErrorShift,      ///< The size of the memory was invalid, likely zero
...

etc.

Finally some helpers:


constexpr bool IsSuccess(Result const& result) {
    return to_underlying(result) == 0x00;
}

constexpr bool IsError(Result const& result) {
    return to_underlying(result) != 0x00;
}

constexpr bool IsArgumentError(Result const& result) {
    return (to_underlying(result) & (ArgumentClassMask << ArgumentErrorShift)) != 0;
}

constexpr bool IsMemoryError(Result const& result) {
    return (to_underlying(result) & (ArgumentClassMask << MemoryErrorShift)) != 0;
}

constexpr bool IsFsmError(Result const& result) {
    return (to_underlying(result) & (ArgumentClassMask << FSMErrorShift)) != 0;
}

And voila. Super easy to return something like Result::kSuccess and test IsSuccess(result) but you can also branch on error class. For example:

if (IsError(result)) {
    if (isArgumentError(result)) {
        handleArgumentErrors(result);
    } else if (isMemoryError(result)) {
        handleMemoryErrors(result);
    } else if (isFsmError(result)) {
        handleFsmErrors(result);
    } else {
        handleUnknownErrors(result);
    }
}

@serges147 serges147 merged commit d7e318d into main Feb 10, 2025
38 checks passed
@serges147 serges147 deleted the issue/ipc branch February 10, 2025 19:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants