What happened

The Camera node captured frames perfectly and no node in the swarm could see any of them. ServerCameraProcessor shelled out to rpicam-still, wrote the JPEG under /srv/krill/camera/<nodeId>/, pruned to 50 files — and stopped. It never wrote meta.snapshot, never called update(), and never called succeeded().

So a Camera fanned out nothing: a capture woke no observer and triggered no downstream work. Worse, nothing could even name the file, because the path never landed in any node’s state — an LLM node wired to a Camera received CameraMetaData carrying its config (resolution, enabled) and an empty snapshot. The image existed only out-of-band over GET /camera/{id}/snapshot, an endpoint no processor calls.

This is half of why the “Local LLM Nodes” demo had to fake the camera: a text DataPoint held a written description of the frame, and the real photo was composited into the video as an overlay.

Root cause

The processor was written as a capture-to-disk side effect rather than as a data-producing node. Every other producing node ends the same way — build a Snapshot, update() the node with it, post a SNAPSHOT_UPDATE event — because update()’s publication is the observer wake. The camera skipped that ending entirely.

A second, independent bug sat in the failure path:

1
2
3
4
if (exitCode != 0 || !outputFile.exists()) {
    nodeManager.failed(node, "Failed to capture snapshot (exit=$exitCode)")
}   // <-- no return
logger.i { "...: saved snapshot $filename (${outputFile.length()} bytes)" }

No return. A failed capture recorded the error and then fell straight through to log a saved snapshot and run the prune — so a broken camera reported success on the very next line, and outputFile.length() was called on a file that did not exist.

Also: process.waitFor() ran on the caller’s thread while the injected scope sat unused, so every capture blocked whatever invoked the node.

Fix

The snapshot carries the path, not the bytes. A 1280×720 JPEG is ~200 KB, ~270 KB base64 — putting that in node state would push it through every update(), every SSE frame and every peer sync.

Prevention