Lifecycle¶
A NUClear system goes through three distinct phases. Understanding these phases explains why certain operations work in some contexts but not others, and when your code actually runs.
stateDiagram-v2
[*] --> Initialisation : PowerPlant constructed
Initialisation --> Execution : start() called
Execution --> Shutdown : shutdown() called
Shutdown --> [*] : start() returns
Phase 1: Initialisation¶
Single-threaded. Only the main thread is running.
During initialisation, you're building the system — constructing the PowerPlant, installing reactors, and registering reactions. Nothing executes yet. Think of it as wiring up a circuit board before flipping the power switch.
sequenceDiagram
participant Main as Main Thread
participant PP as PowerPlant
participant RA as Reactor A
participant RB as Reactor B
Main->>PP: Construct PowerPlant(config)
Main->>PP: install<ReactorA>()
PP->>RA: Constructor runs
RA->>RA: on<>().then() registers reactions
RA->>RA: emit<Scope::INITIALIZE>() queues data
Main->>PP: install<ReactorB>()
PP->>RB: Constructor runs
RB->>RB: on<>().then() registers reactions
Note over PP: All reactions registered,<br/>initialise queue populated
What Happens¶
- PowerPlant construction — creates the scheduler, stores configuration
- Reactor installation — each
install<T>()call constructs a reactor - Reaction registration —
on<>().then()inside constructors registers interest in events - Regular emissions —
emit(data)during a constructor will trigger any reactions that are already bound. If a reactor installed later has a reaction for that type, it won't receive it (it doesn't exist yet). - Initialise-scoped emissions —
emit<Scope::INITIALIZE>()defers the emission untilstart()is called, guaranteeing all reactors have been installed and all reactions are bound before the data is processed.
Why It Matters¶
- Order matters: reactors are installed sequentially.
A regular
emit()during installation will only trigger reactions that have already been registered by earlier reactors. - No parallelism: constructors run one at a time on the main thread. This is intentional — it avoids race conditions during setup.
Scope::INITIALIZEsolves ordering problems: it defers emissions until all reactors are installed. This is specifically for cases where you need to emit data during startup that must be received by a reactor installed after you (e.g., circular dependencies). In general, it should be treated as a code smell — most of the time a regular emit or emitting during Startup is sufficient.
What You Can Do¶
| Action | Works? | Notes |
|---|---|---|
on<>().then() |
✅ | Register reactions |
emit<Scope::INITIALIZE>(data) |
✅ | Deferred until all reactors installed |
emit(data) (Local scope) |
⚠️ | Triggers already-bound reactions only |
| Access other reactors | ❌ | No guarantee they're installed yet |
Phase 2: Execution¶
Multi-threaded. The system is alive.
sequenceDiagram
participant Main as Main Thread
participant PP as PowerPlant
participant S as Scheduler
participant Pool as Thread Pool
Main->>PP: start()
PP->>S: Flush initialise queue
Note over PP: Deferred emissions now trigger reactions
PP->>PP: emit<Inline>(Startup)
Note over PP: Startup reactions run<br/>single-threaded (inline)
PP->>S: Start thread pools
Note over Pool: General execution begins
loop Normal Operation
Pool->>Pool: Reactions emit data
Pool->>S: New tasks created
S->>Pool: Dispatch by priority
end
Note over Main: Main thread joins pool<br/>(runs MainThread tasks)
What Happens¶
start()is called — this is the transition point- Initialise queue is flushed — all data deferred with
Scope::INITIALIZEis now emitted, triggering any matching reactions - Startup fires single-threaded —
Startupis emitted inline on the main thread. Allon<Startup>reactions execute sequentially before any thread pools are started. This guarantees that initialisation logic in Startup handlers completes before general concurrent execution begins. - Thread pools are created — the default pool and any custom pools spawn their threads
- Normal execution begins — emits create tasks, the scheduler dispatches them across pools
start()blocks — the calling thread becomes the MainThread pool worker, processing tasks until shutdown
The Execution Loop¶
During normal execution, the system runs a continuous cycle:
flowchart LR
A[Reaction emits data] --> B[Scheduler creates tasks<br/>for matching reactions]
B --> C[Tasks queued by priority]
C --> D[Pool threads dequeue<br/>and execute]
D --> A
There's no central tick, no frame loop, no polling. Reactions fire in response to data, and their outputs trigger further reactions. The system is entirely event-driven.
What You Can Do¶
| Action | Works? | Notes |
|---|---|---|
emit(data) |
✅ | Standard local emission |
emit<Scope::NETWORK>(data) |
✅ | Send to other nodes |
emit<Scope::INLINE>(data) |
✅ | Execute reactions immediately in current thread |
on<>().then() |
✅ | Can register new reactions at runtime |
Phase 3: Shutdown¶
Multi-threaded → Single-threaded. The system winds down gracefully.
sequenceDiagram
participant Any as Any Thread
participant PP as PowerPlant
participant S as Scheduler
participant Pool as Thread Pool
Any->>PP: shutdown()
PP->>PP: emit(Shutdown)
Note over Pool: Shutdown reactions fire
PP->>S: stop()
Note over S: Stop accepting new Local tasks
Note over Pool: Running tasks complete
S->>Pool: Join all threads
Note over PP: Thread pools destroyed
Note over PP: start() returns
What Happens¶
shutdown()is called — can be from any thread (often from within a reaction)- Shutdown event is emitted — reactions on
Shutdownfire, giving reactors a chance to clean up - Scheduler stops — no new tasks are generated from Local emits
- In-flight tasks complete — any currently-executing tasks run to completion
- Threads are joined — pool threads finish and are joined back
start()returns — the main thread is released, and the application can exit
Graceful vs Forced¶
plant.shutdown(); // Graceful: let running tasks finish
plant.shutdown(true); // Forced: clear queues, stop immediately
A graceful shutdown waits for all running tasks to complete. A forced shutdown clears the task queues and wakes all threads so they exit as quickly as possible.
What You Can Do¶
| Action | Works? | Notes |
|---|---|---|
emit(data) |
⚠️ | Tasks created but may not execute |
emit<Scope::INLINE>(data) |
✅ | Executes immediately in current thread |
| Persistent pool tasks | ✅ | Persistent pools keep running until drained |
New on<> registrations |
❌ | System is winding down |
Emission Scopes Across Phases¶
Different emission scopes have different behaviour depending on the current phase:
| Scope | Initialisation | Execution | Shutdown |
|---|---|---|---|
INITIALIZE |
✅ Queued for later | ❌ Not applicable | ❌ Not applicable |
| Local (default) | ⚠️ Triggers already-bound reactions | ✅ Normal dispatch | ⚠️ May not execute |
INLINE |
✅ Runs immediately | ✅ Runs immediately | ✅ Runs immediately |
NETWORK |
❌ Network not started | ✅ Sends to peers | ❌ Network shutting down |
Why Three Phases?¶
The phased design exists to solve a fundamental bootstrapping problem: you can't react to messages if the reactions aren't registered yet.
By separating initialisation from execution, NUClear guarantees that:
- All reactors are fully constructed before any data flows
- All reactions are registered before any triggers fire
- The system starts from a known, complete state — not a partially-wired one
This eliminates an entire class of startup race conditions that plague systems where components start in arbitrary order.