Dependency Cycles

When a reaction writes to a port that (directly or indirectly) triggers another reaction, the runtime must establish an ordering between them. It infers this ordering from two sources:

  • Port connections: if reaction A writes to a port connected to reaction B’s trigger, A must execute before B.

  • Reaction order: within the same reactor, reactions registered earlier in assemble() take priority over reactions registered later at the same logical time.

A dependency cycle occurs when these two sources of ordering information contradict each other, making it impossible to find a valid execution order. The runtime detects this at startup and throws an xronos::sdk::ValidationError.

A range sensor example

Consider a Controller reactor that periodically polls a RangeSensor by sending requests through a port and receiving distance readings back through another port. The controller shuts down once the sensor reports an obstacle within a safe threshold.

#include <algorithm>
#include <chrono>
#include <iostream>

#include "xronos/sdk.hh"

namespace sdk = xronos::sdk;

using namespace std::literals::chrono_literals;

static constexpr double kSafeDistanceM = 1.0;
static constexpr double kInitialDistanceM = 5.0;
static constexpr double kDistanceDecrementM = 0.1;

class RangeSensor : public sdk::Reactor {
public:
  using sdk::Reactor::Reactor;
  [[nodiscard]] auto request() const -> const auto& { return request_; }
  [[nodiscard]] auto response() const -> const auto& { return response_; }

private:
  sdk::InputPort<bool> request_{"request", context()};
  sdk::OutputPort<double> response_{"response", context()};
  double distance_{kInitialDistanceM};

  class OnRequestReaction : public sdk::Reaction<RangeSensor> {
    using sdk::Reaction<RangeSensor>::Reaction;
    Trigger<bool> request_trigger{self().request_, context()};
    PortEffect<double> response_effect{self().response_, context()};
    void handler() final {
      self().distance_ = std::max(0.0, self().distance_ - kDistanceDecrementM);
      response_effect.set(self().distance_);
    }
  };

  void assemble() final { add_reaction<OnRequestReaction>("on_request"); }
};

class Controller : public sdk::Reactor {
public:
  using sdk::Reactor::Reactor;
  [[nodiscard]] auto request() const -> const auto& { return request_; }
  [[nodiscard]] auto response() const -> const auto& { return response_; }

private:
  sdk::OutputPort<bool> request_{"request", context()};
  sdk::InputPort<double> response_{"response", context()};
  sdk::PeriodicTimer timer_{"timer", context(), 100ms};
  bool obstacle_detected_{false};

  class HandleResponseReaction : public sdk::Reaction<Controller> {
    using sdk::Reaction<Controller>::Reaction;
    Trigger<double> response_trigger{self().response_, context()};
    ShutdownEffect shutdown_effect{self().shutdown(), context()};
    void handler() final {
      auto elapsed =
          std::chrono::duration_cast<std::chrono::milliseconds>(self().get_time_since_startup());
      double distance = *response_trigger.get();
      std::cout << elapsed << ": range reading = " << distance << " m\n";
      if (distance < kSafeDistanceM && !self().obstacle_detected_) {
        self().obstacle_detected_ = true;
        std::cout << elapsed << ": WARNING — obstacle detected at " << distance
                  << " m\n";
        shutdown_effect.trigger_shutdown();
      }
    }
  };

  class SendRequestReaction : public sdk::Reaction<Controller> {
    using sdk::Reaction<Controller>::Reaction;
    Trigger<void> timer_trigger{self().timer_, context()};
    PortEffect<bool> request_effect{self().request_, context()};
    void handler() final { request_effect.set(true); }
  };

  void assemble() final {
    add_reaction<HandleResponseReaction>("handle_response");
    add_reaction<SendRequestReaction>("send_request");
  }
};

auto main() -> int {
  sdk::Environment env{};
  Controller controller{"controller", env.context()};
  RangeSensor sensor{"sensor", env.context()};
  env.connect(controller.request(), sensor.request());
  env.connect(sensor.response(), controller.response());
  env.execute();
  return 0;
}

RangeSensor exposes a request input and a response output. Each time it receives a request it decrements the simulated distance and writes the new value to response. Controller defines two reactions. HandleResponseReaction is triggered by response and reads the distance. SendRequestReaction is triggered by a periodic timer and writes to request.

Create a CMakeLists.txt in the same directory:

cmake_minimum_required(VERSION 3.28)

project(dependency_cycles LANGUAGES CXX)

set(XRONOS_SDK_VERSION "0.11.2" CACHE STRING "Xronos SDK version")
include(FetchContent)
FetchContent_Declare(
  "xronos-sdk"
  URL https://github.com/xronos-inc/xronos/releases/download/v${XRONOS_SDK_VERSION}/xronos-sdk-${XRONOS_SDK_VERSION}-Linux-${CMAKE_SYSTEM_PROCESSOR}.tar.gz
)
FetchContent_MakeAvailable(xronos-sdk)
set(xronos-sdk_DIR ${xronos-sdk_SOURCE_DIR}/share/cmake/xronos-sdk)

find_package(xronos-sdk)

add_executable(range_sensor range_sensor.cc)
target_link_libraries(range_sensor xronos::xronos-sdk)

Configure, build, and run:

$ cmake -B build
$ cmake --build build --target range_sensor_cycle
$ ./build/range_sensor_cycle

It fails immediately:

$ ./build/range_sensor
[ERROR] There is a dependency cycle involving the following reactions:
  - controller.handle_response
  - sensor.on_request
  - controller.send_request
terminate called after throwing an instance of 'xronos::sdk::ValidationError'
  what():  The reactor program is invalid and cannot be executed.

The cycle arises as follows:

  1. HandleResponseReaction is registered before SendRequestReaction in Controller::assemble(), so the runtime infers that HandleResponseReaction must execute before SendRequestReaction at each timestamp.

  2. But SendRequestReaction writes to controller.request, which is connected to sensor.request, which triggers OnRequestReaction, which writes to sensor.response, which triggers HandleResponseReaction. So SendRequestReaction must execute before HandleResponseReaction.

These two constraints directly contradict each other.

Fix 1: Reorder the reactions

The simplest fix is to register SendRequestReaction before HandleResponseReaction in assemble(). The runtime then infers that SendRequestReaction runs first, which is consistent with the data flow through the ports. The reaction class definitions themselves do not need to move — only the add_reaction call order matters.

void assemble() final {
  add_reaction<SendRequestReaction>("send_request");
  add_reaction<HandleResponseReaction>("handle_response");
}

The program now runs and prints distance readings until an obstacle is detected:

$ ./build/range_sensor
0ms: range reading = 4.9 m
100ms: range reading = 4.8 m
200ms: range reading = 4.7 m
...
3900ms: range reading = 1 m
4000ms: range reading = 0.9 m
4000ms: WARNING — obstacle detected at 0.9 m

Fix 2: Introduce a delay

Reordering reactions is not always possible. In those cases, a delayed connection breaks the cycle by delivering the message at a strictly later time step, removing the same-step ordering constraint.

The following program keeps the original reaction order from range_sensor_cycle.cc (with HandleResponseReaction first) and adds a 1 ms delay to the connection from controller.request to sensor.request:

auto main() -> int {
  sdk::Environment env{};
  Controller controller{"controller", env.context()};
  RangeSensor sensor{"sensor", env.context()};
  env.connect(controller.request(), sensor.request(), 1ms);
  env.connect(sensor.response(), controller.response());
  env.execute();
  return 0;
}
$ ./build/range_sensor
1ms: range reading = 4.9 m
101ms: range reading = 4.8 m
201ms: range reading = 4.7 m
...
3901ms: range reading = 1 m
4001ms: range reading = 0.9 m
4001ms: WARNING — obstacle detected at 0.9 m

Note

Reordering reactions is generally preferred when possible, because it preserves the zero-delay semantics of the connection. Delays change the timing of messages and should be chosen deliberately.

Fix 3: Decouple request and response in the sensor

Another approach is to break the cycle inside RangeSensor itself, without changing the Controller reaction order or adding a connection delay. Instead of responding in the same reaction that receives the request, the sensor uses a ProgrammableTimer to schedule the response 1 ms later. This separates the two concerns into two reactions:

  • SendResponseReaction — triggered by the programmable timer — sends the distance reading.

  • OnRequestReaction — triggered by the request port — updates the distance and schedules the timer.

SendResponseReaction is registered before OnRequestReaction in assemble().

Note

SendResponseReaction must be registered before OnRequestReaction. If their order were reversed, the runtime would infer that Controller first receives and then sends, but also that the sensor first receives and then sends, creating a causality problem.

class RangeSensor : public sdk::Reactor {
public:
  using sdk::Reactor::Reactor;
  [[nodiscard]] auto request() const -> const auto& { return request_; }
  [[nodiscard]] auto response() const -> const auto& { return response_; }

private:
  sdk::InputPort<bool> request_{"request", context()};
  sdk::OutputPort<double> response_{"response", context()};
  sdk::ProgrammableTimer<void> send_timer_{"send_timer", context()};
  double distance_{kInitialDistanceM};

  class SendResponseReaction : public sdk::Reaction<RangeSensor> {
    using sdk::Reaction<RangeSensor>::Reaction;
    Trigger<void> send_timer_trigger{self().send_timer_, context()};
    PortEffect<double> response_effect{self().response_, context()};
    void handler() final { response_effect.set(self().distance_); }
  };

  class OnRequestReaction : public sdk::Reaction<RangeSensor> {
    using sdk::Reaction<RangeSensor>::Reaction;
    Trigger<bool> request_trigger{self().request_, context()};
    ProgrammableTimerEffect<void> send_timer_effect{self().send_timer_, context()};
    void handler() final {
      self().distance_ = std::max(0.0, self().distance_ - kDistanceDecrementM);
      send_timer_effect.schedule(1ms);
    }
  };

  void assemble() final {
    add_reaction<SendResponseReaction>("send_response");
    add_reaction<OnRequestReaction>("on_request");
  }
};

The cycle disappears because OnRequestReaction no longer writes directly to response. Its only effect is scheduling send_timer_, which fires at a strictly later timestamp. The response therefore arrives at the controller in a new timestamp, with no same-step ordering constraint between the two reactors.

$ ./build/range_sensor
1ms: range reading = 4.9 m
101ms: range reading = 4.8 m
201ms: range reading = 4.7 m
...
3901ms: range reading = 1 m
4001ms: range reading = 0.9 m
4001ms: WARNING — obstacle detected at 0.9 m