Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Task Lifecycle

Beyond new() and process(), Copper tasks have a full lifecycle with optional hooks for setup, teardown, and non-critical work. Understanding this lifecycle helps you put the right code in the right place.

The full lifecycle

new()  →  start()  →  [ preprocess() → process() → postprocess() ]  →  stop()
                       └──────────── repeats every cycle ────────────┘

Lifecycle methods

MethodWhenThreadWhat to do here
new()Once, at constructionMainRead config, initialize state
start()Once, before the first cycleMainOpen file handles, initialize hardware, allocate buffers
preprocess()Every cycle, before process()Best-effortHeavy prep work: decompression, FFT, parsing
process()Every cycleCritical pathCore logic. Keep it fast. No allocations.
postprocess()Every cycle, after process()Best-effortTelemetry, non-critical logging, statistics
stop()Once, after the last cycleMainCleanup: close files, stop hardware, free resources

The two threads

Copper splits each cycle across two execution contexts:

Critical path thread

This is where process() runs. Tasks execute back-to-back in the order determined by the task graph topology. The runtime minimizes latency and jitter on this thread. You must avoid allocations, system calls, and anything that could block.

Best-effort thread

This is where preprocess() and postprocess() run. The runtime schedules these to minimize interference with the critical path. You can safely do heavier work here: I/O, allocations, logging, network calls.

What if preprocess() is late?

The critical path never waits for the best-effort thread. If preprocess() takes longer than expected and hasn’t finished when the critical path is ready, process() runs anyway – with whatever data is available from the previous cycle (or nothing, if it’s the first one).

This is intentional: in a real-time system, a late result is a wrong result. It’s better to run your control loop on slightly stale data than to miss a deadline. Your process() should handle this gracefully:

#![allow(unused)]
fn main() {
fn preprocess(&mut self, _clock: &RobotClock) -> CuResult<()> {
    // Heavy work on the best-effort thread -- might be slow
    self.decoded_image = Some(decode_jpeg(&self.raw_buffer));
    Ok(())
}

fn process(&mut self, _clock: &RobotClock, input: &Self::Input<'_>,
           output: &mut Self::Output<'_>) -> CuResult<()> {
    // Use whatever is ready. If preprocess was late, decoded_image
    // still holds the previous cycle's result (or None on first cycle).
    if let Some(ref image) = self.decoded_image {
        output.set_payload(run_inference(image));
    }
    Ok(())
}
}

The same applies to postprocess(): if it falls behind, the next cycle’s process() still runs on time.

Example: when to use each method

Imagine an IMU driver task:

new()           → Read the SPI bus config from ComponentConfig
start()         → Open the SPI device, configure the sensor registers
preprocess()    → (not needed for this task)
process()       → Read raw bytes from SPI, convert to ImuReading, set_payload()
postprocess()   → Log statistics (sample rate, error count)
stop()          → Close the SPI device

Or a computer vision task:

new()           → Load the model weights
start()         → Initialize the inference engine
preprocess()    → Decode the JPEG image from the camera (heavy, OK on best-effort thread)
process()       → Run inference on the decoded image, output detections
postprocess()   → Update FPS counter, send telemetry
stop()          → Release GPU resources

All lifecycle methods are optional

new() and process() are required – everything else has a default no-op implementation. You only implement the lifecycle methods you need:

#![allow(unused)]
fn main() {
impl CuTask for MyTask {
    type Resources<'r> = ();
    type Input<'m> = input_msg!(MyPayload);
    type Output<'m> = output_msg!(MyPayload);

    // Required: constructor
    fn new(_config: Option<&ComponentConfig>, _resources: Self::Resources<'_>) -> CuResult<Self>
    where Self: Sized {
        Ok(Self {})
    }

    // Required: core logic
    fn process(&mut self, _clock: &RobotClock, input: &Self::Input<'_>,
               output: &mut Self::Output<'_>) -> CuResult<()> {
        // your core logic
        Ok(())
    }

    // Optionally implement any of these:
    // fn start(&mut self, clock: &RobotClock) -> CuResult<()> { ... }
    // fn stop(&mut self, clock: &RobotClock) -> CuResult<()> { ... }
    // fn preprocess(&mut self, clock: &RobotClock) -> CuResult<()> { ... }
    // fn postprocess(&mut self, clock: &RobotClock) -> CuResult<()> { ... }
}
}

Freeze and thaw (state snapshots)

Copper automatically logs every message flowing between tasks. But messages alone aren’t enough to reproduce a task’s behavior – you also need its internal state.

Consider a PID controller that accumulates error over time. If you want to replay from minute 7 of a 10-minute run to debug a crash, you need to know what the accumulated error was at minute 7. Without state snapshots, you’d have to replay from the very start and wait 7 minutes to get there.

That’s what freeze and thaw solve. The Freezable trait gives each task two hooks:

  • freeze() – Save the task’s internal state. Called periodically by the runtime to create “keyframes.”
  • thaw() – Restore the task’s state from a saved snapshot.

These are not part of the per-cycle loop. They run at a much lower rate and are independent of the critical path:

         ┌─── cycle ───┐  ┌─── cycle ───┐        ┌─── cycle ───┐
... ─── process() ─── process() ─── ... ─── process() ─── ...
                              │                         │
                          freeze()                  freeze()
                        (keyframe)                (keyframe)

Think of it like a video codec: process() runs every frame, while freeze() saves a keyframe at a low rate. During replay, the runtime jumps to the nearest keyframe before minute 7, restores every task’s state via thaw(), and replays from there – no need to start from the beginning.

For stateless tasks (like our simple MySource, MyTask, MySink), the empty impl Freezable is fine – there’s nothing to snapshot. We’ll cover how to implement freeze and thaw for stateful tasks in the Advanced Task Features chapter.