Skip to content

A late actuator exits its caller, which is worst in the loops most likely to hit it #237

Description

@jimsynz

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_tuiset_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_policyActuatorCommand.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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions