Falls out of #235, where BB.Actuator.set_position/4 became synchronous. Filing it separately because it's a deliberate change to that function's contract rather than part of the change that exposed it.
The behaviour
set_position/4 under the default delivery: :pubsub delivers by GenServer.call, so a :timeout doesn't return — it exits the caller, per GenServer.call/3 semantics. BB.Motion inherits this, and so does anything wrapping it.
Before #235 the same call sites were fire-and-forget publishes. A wedged actuator was invisible; now it's fatal to whoever asked.
Why it matters more than it looks
Porting the ecosystem to #235 turned up the same concern independently in three packages, each a long-lived process that drives a robot in a loop:
bb_tui — set_actuator/4 runs inline in the TUI's event loop. A 5 s stall per jog keypress (doubled over :rpc, once per key autorepeat), and a wedged actuator exits the process that owns the terminal. Its PR chose delivery: :direct specifically to avoid killing the dashboard.
bb_policy — ActuatorCommand.apply/2 runs inside a Runner/Controller tick. A slow actuator can now take the runner down where previously it published and moved on. Its PR declined to invent a timeout, on the grounds that the budget is robot-specific, and documented the knobs instead.
BB.Motion — the fan-out uses Task.async, which links, so one late actuator exits the task and the exit propagates to whoever called move_to/4.
The pattern is consistent: the callers most likely to meet a slow actuator are the ones least able to afford dying, and the only escape offered is delivery: :direct, which gives up refusal reporting altogether. That's the same all-or-nothing the issue behind #235 complained about, one layer out.
The question
Should a timeout come back as {:error, :timeout} instead of exiting, so a control loop can skip a late step and send the next target?
Arguments for: it makes :timeout an outcome like a refusal, which is what a loop wants; it removes the "either block and risk death, or cast and learn nothing" fork; and it matches what the caller can actually do about it — a late step is stale, so dropping it is usually correct.
Arguments against: GenServer.call/3 semantics are what an Elixir developer expects from a blocking call, and hiding an exit behind an error tuple hides a genuinely broken actuator; a caller that ignores {:error, :timeout} in a loop will spin happily against dead hardware; and catching an exit to synthesise a return value is the kind of thing that looks tidy and reads badly.
Worth noting either way: a timeout means "I stopped waiting", not "it didn't happen". The command is already in the actuator's mailbox and will be handled. Whatever shape the answer takes has to say that, or callers will treat a timeout as a rollback.
Not urgent
Nothing is blocked. The downstream PRs each made a local choice and documented it. This wants deciding before the pattern sets, not before the release.
Falls out of #235, where
BB.Actuator.set_position/4became synchronous. Filing it separately because it's a deliberate change to that function's contract rather than part of the change that exposed it.The behaviour
set_position/4under the defaultdelivery: :pubsubdelivers byGenServer.call, so a:timeoutdoesn't return — it exits the caller, perGenServer.call/3semantics.BB.Motioninherits this, and so does anything wrapping it.Before #235 the same call sites were fire-and-forget publishes. A wedged actuator was invisible; now it's fatal to whoever asked.
Why it matters more than it looks
Porting the ecosystem to #235 turned up the same concern independently in three packages, each a long-lived process that drives a robot in a loop:
bb_tui—set_actuator/4runs inline in the TUI's event loop. A 5 s stall per jog keypress (doubled over:rpc, once per key autorepeat), and a wedged actuator exits the process that owns the terminal. Its PR chosedelivery: :directspecifically to avoid killing the dashboard.bb_policy—ActuatorCommand.apply/2runs inside aRunner/Controllertick. A slow actuator can now take the runner down where previously it published and moved on. Its PR declined to invent a timeout, on the grounds that the budget is robot-specific, and documented the knobs instead.BB.Motion— the fan-out usesTask.async, which links, so one late actuator exits the task and the exit propagates to whoever calledmove_to/4.The pattern is consistent: the callers most likely to meet a slow actuator are the ones least able to afford dying, and the only escape offered is
delivery: :direct, which gives up refusal reporting altogether. That's the same all-or-nothing the issue behind #235 complained about, one layer out.The question
Should a timeout come back as
{:error, :timeout}instead of exiting, so a control loop can skip a late step and send the next target?Arguments for: it makes
:timeoutan outcome like a refusal, which is what a loop wants; it removes the "either block and risk death, or cast and learn nothing" fork; and it matches what the caller can actually do about it — a late step is stale, so dropping it is usually correct.Arguments against:
GenServer.call/3semantics are what an Elixir developer expects from a blocking call, and hiding an exit behind an error tuple hides a genuinely broken actuator; a caller that ignores{:error, :timeout}in a loop will spin happily against dead hardware; andcatching an exit to synthesise a return value is the kind of thing that looks tidy and reads badly.Worth noting either way: a timeout means "I stopped waiting", not "it didn't happen". The command is already in the actuator's mailbox and will be handled. Whatever shape the answer takes has to say that, or callers will treat a timeout as a rollback.
Not urgent
Nothing is blocked. The downstream PRs each made a local choice and documented it. This wants deciding before the pattern sets, not before the release.