Skip to content

Several improvements around parameters - #663

Open
azerupi wants to merge 3 commits into
ros2-rust:mainfrom
azerupi:params/hardening
Open

Several improvements around parameters#663
azerupi wants to merge 3 commits into
ros2-rust:mainfrom
azerupi:params/hardening

Conversation

@azerupi

@azerupi azerupi commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

This PR has 3 changes that improve parameter handling.

1. Prevent panic for parameter values that can't be represented in the Rust type

Problem

Right now on main, users of rclrs can implement the public ParameterVariant trait for arbitrary Rust types. This is for example very useful to implement string-backed enums.

However the code in rclrs that writes the parameter only checks that the kind is correct before storing the value. However the functions that read the value (MandatoryParameter::get, OptionalParameter::get, ReadOnlyParameter::get, both subscribe() closures, and both on_change wrappers) assume it can infallibly convert to the Rust type.

pub fn get(&self) -> T {
    self.value.read().unwrap().clone().try_into().ok().unwrap()
}

So let's imagine we have this user code

    #[derive(Clone, Debug, PartialEq)]
    enum Switch {
        On,
        Off,
    }

    impl From<Switch> for ParameterValue {
        fn from(value: Switch) -> Self {
            ParameterValue::String(
                match value {
                    Switch::On => "on",
                    Switch::Off => "off",
                }
                .into(),
            )
        }
    }

    impl TryFrom<ParameterValue> for Switch {
        type Error = ParameterValueError;

        fn try_from(value: ParameterValue) -> Result<Self, Self::Error> {
            match value {
                ParameterValue::String(s) => match s.as_ref() {
                    "on" => Ok(Switch::On),
                    "off" => Ok(Switch::Off),
                    other => Err(ParameterValueError::Invalid(format!(
                        "unknown Switch '{other}', expected one of: on, off"
                    ))),
                },
                _ => Err(ParameterValueError::TypeMismatch),
            }
        }
    }

    impl ParameterVariant for Switch {
        type Range = ();

        fn kind() -> ParameterKind {
            ParameterKind::String
        }
    }

It will only work if the string value is "on" or "off", anything else will result in a panic since we are only validating the kind. Any string can be stored in the parameter.

$ ros2 param set /my_node switch banana
Set parameter successful

This is pretty bad because any remote client on the ROS graph could crash a node that implements this.

Fix 7c9cac1

To fix this, we are now checking the conversion before storing the value. Any value that doesn't pass the conversion is does not get stored. In the same way other built-in checks run (e.g. ranges).

$ ros2 param set /my_node switch banana
Setting parameter failed: Parameter value is not valid for this parameter's type: unknown Switch 'banana', expected one of: on, off

and param.get() still returns the previous value because the new value was never stored.

2. Define constraints at ParameterVariant implementation

Gap

ROS 2 parameter descriptors carry constraints in IntegerRange, FloatingPointRange or additional_constraints (free text).

Right now, for custom fields, for example the previously defined Switch enum the constraints have to be passed at each declaration of a parameter using this type. There is also nothing that keeps those multiple constraint declarations in sync with the type, adding a new enum variant will silently make the declared constraints drift unless all of them are updated. And if you forget to declare the constraints, ros2 param describe gives you no information.

Fix 3f27dcc

We add a new method on ParameterVariant that allows the the constraints to be declared where the type is defined as a ROS parameter. It has a default implementation that returns None.

This allows a type to specify its constraints. If the user declaring the parameter adds explicit constraints they will be used otherwise we default to the type's constraints.

3. Support common Rust types as parameters

Gap

A parameter could only be declared with the Rust type that most directly represents one of the nine ROS 2 types: bool, i64, f64, Arc, Arc<[u8]>, Arc<[bool]>, Arc<[i64]>, Arc<[f64]>, Arc<[Arc]>. This is convenient for the parameter machinery (rclrs) but inconvenient for the application (user).

Fix 4f098d4

We implement ParameterVariant for many common Rust types to allow them to be used as parameters.

Category Types ROS 2 kind
Strings String, PathBuf String
Narrow integers i8, i16, i32, u8, u16, u32 Integer
Narrow float f32 Double
Durations DurationSecs, DurationMillis (new types around Duration) Double / Integer
Arrays Vec<T> of every scalar above, plus Vec<i64>, Vec<f64>, Vec<bool>, Vec<u8>, Vec<String> matching array kind

Note:

  • Vec<u8> is a byte array, not an integer array. ROS 2 has a dedicated byte array type. It's the one Rust array type that doesn't follow its scalar (u8 alone is an Integer).
  • u64, usize, i128 and u128 are deliberately absent. ROS 2's integer type is i64 and conversions from those types could silently produce values that did not match the intent.
  • I decided to create newtypes around Duration to be able to differentiate between seconds (Float) and milliseconds (Integer).

This was only possible after fix n°1 because now we have many Rust types that can't represent all values that could be stored in the ROS type. But since we now check the conversion before storing, this is totally fine!

The narrow integers define an IntegerRange that allow to describe the parameter constraints.

Before:

let wheels: MandatoryParameter<Arc<[Arc<str>]>> = node
    .declare_parameter("wheels")
    .default_string_array(["left", "right"])
    .mandatory()?;
let timeout_s: MandatoryParameter<f64> = /* ... and convert by hand at every use */;

After:

let wheels:  MandatoryParameter<Vec<String>>  = node.declare_parameter("wheels")
    .default(vec!["left".to_string(), "right".to_string()]).mandatory()?;
let device:  MandatoryParameter<PathBuf>      = node.declare_parameter("device")
    .default(PathBuf::from("/dev/ttyUSB0")).mandatory()?;
let timeout: MandatoryParameter<DurationSecs> = node.declare_parameter("timeout")
    .default(DurationSecs(Duration::from_millis(2500))).mandatory()?;
let port:    MandatoryParameter<u16>          = node.declare_parameter("port")
    .default(8080).mandatory()?;

Beyond the ergonomics provided here, this is the groundwork for future PRs that provide a derive proc-macro based API similar to clap.

azerupi added 3 commits August 6, 2026 02:07
A ParameterKind does not uniquely identify a Rust type. Several types can
share one, and a value of the right kind can still be unrepresentable in
the type a parameter was declared with. validate_parameter_setting only
compared kind discriminants and Parameters::set only compared T::kind(),
while every getter assumes the stored value converts back:

    self.value.read().unwrap().clone().try_into().ok().unwrap()

So a remote SetParameters call could store a value that passes validation
and then panic the node on the next read. This is reachable today by
anyone implementing the public ParameterVariant trait for a type whose
conversion from ParameterValue is partial, and becomes unavoidable once
narrow integer and enum parameter types exist.

Capture the declared type's check as a fn pointer at declaration time and
run it on both write paths, before the range check and before the custom
validate callback (whose type-erasure wrapper depends on the value being
convertible). The check is the type's own TryFrom, so there is nothing
extra for a type to implement and no way for it to accept a value that a
later read would then reject. ParameterValueError::Invalid carries the
reason the conversion gave back to the caller, which the parameter
service puts in the response so the operator who made the call can see
why the value was refused.
additional_constraints in a parameter descriptor was only ever what the
declaration passed to .constraints(). For a type with a closed set of
valid values, such as a string-backed enum, that meant every declaration
site restating the type's rules, with nothing keeping the text in sync as
variants are added -- and an empty descriptor field when the call was
forgotten, leaving operators no way to discover what a value may be.

ParameterVariant::type_constraints() lets a type describe itself once. A
declaration that sets no constraints of its own inherits it, so
`ros2 param describe` reports the valid values for free. An explicit
.constraints() still takes precedence for rules that belong to one
declaration rather than to the type.
A parameter could only be declared with the Rust type that most directly
represents one of the nine ROS 2 parameter types, so a list of names had
to be handled as Arc<[Arc<str>]> and a port number as i64 even where the
application would otherwise use Vec<String> and u16.

Add ParameterVariant for String, PathBuf, Vec<String>, Vec<i64>,
Vec<f64>, Vec<bool>, Vec<u8>, f32 and the integer types that fit in an
i64 (i8, i16, i32, u8, u16, u32). Conversions back from a stored value
can be partial -- not every i64 is a u16 -- which the type check added in
the previous commit rejects before it can be stored, so such a parameter
can never hold a value of the wrong type.

Every scalar type also has a Vec form, using whichever ROS 2 array type
holds the scalar's representation, so a Vec<u16> is an integer array whose
elements are each checked against the range of a u16. Reading an element
goes through the scalar type's own conversion, so what an element may be
is decided in one place, and a rejection says which element was at fault.
Vec<u8> is the one exception: a sequence of bytes is a ROS 2 byte array
rather than an integer array.

Ranges are expressed in the parameter's own type: range = 1024..=49151 on
a u16 parameter means what it appears to. A bound the declaration leaves
open is filled in from the type's own limits, so the narrowing reaches the
descriptor's IntegerRange where `ros2 param describe` and rqt_reconfigure
can read it, rather than only being discovered by having a value rejected.

DurationSecs and DurationMillis carry a Duration as a double or an
integer. Duration itself is not a parameter type, since the unit it would
be stored in is not something to leave implicit in a robotics
configuration. Both wrappers deref to Duration and report their unit in
the descriptor's constraints.

u64, usize, i128 and u128 are deliberately unsupported: every way of
storing a value above i64::MAX in a ROS 2 parameter silently produces a
number the application did not ask for.
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.

1 participant