Skip to content
C.W.K.
Lesson 02 of 05 · published

A File Channel, Not an API

~15 min · ipc, atomic-write, flock, desired-state, heartbeat

Level 0Closed Lens
0 XP0/45 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

Two Files Instead of a Socket

If you were wiring a Python engine to a Swift helper, the reflex would be an HTTP server in the helper, or a socket, or XPC. Oriel uses two JSON files in a folder under its data root:

  • desired.json, written by the engine: which inputs to open, in which mode and bitrate, into which folder, plus one-shot requests (peeks and gimbal moves), each answered once by its id.
  • status.json, written by the recorder about once a second: its heartbeat, the camera authorization, every video device it can see with the modes each offers, and the state of each open input (frames, measured fps, the open segment, errors).

The recorder reads desired.json and makes the world match it. The engine reads status.json and believes it only while the heartbeat is fresh. This is the desired state pattern you may know from orchestration systems, shrunk to one Mac and two files.

What a File Buys

The module's docstring lists the reasons in one sentence: a file channel needs no port, no socket permission and no Local Network grant, survives either side restarting, and is readable by a human. Each of those mattered.

  • No port, no grant. On recent macOS, a launchd agent that dials a network address can be silently refused by the Local Network privacy gate (track seven meets it from the hands' side). A file on a local disk is outside that gate entirely.
  • Restarts are boring. If the engine restarts in the middle of a session, the recorder keeps recording, because what it was asked to do is still on disk. If the recorder restarts, it reads the same file and reopens the same inputs. Neither side has to replay a connection's history.
  • A human can read it. When something is wrong, cat status.json tells you what the recorder sees and thinks, with no client to write first.

The Three Ways a File Channel Fails

Files are not free. Oriel met all three classic failures, and each one is now a rule in code.

1. Two writers lose an edit. Every change to desired.json is read, edit, write. On 2026-11-03 two camera parks were handed over at the same second; both read the file, both wrote it back, and one camera's move never reached the recorder. Now every edit holds a lock for the whole read-edit-write: a lock per path for threads inside the engine, and flock on a sibling .lock file for other processes, because the command-line tools write the same file. The house sign repeats it as a rule: every desired.json edit holds recorder_link.editing.

2. A failed read becomes an empty write. A read that fails (a file mid-replacement, a disk error) is easy to treat as "nothing asked". Write that back and the recorder stops every recording on its next pass. So a read that is about to be written back is strict: if the file is there and the disk read fails, it raises and nothing is changed. A file that holds no JSON at all still reads as nothing asked in the live code; the atomic replace is what keeps a half-written file from ever being seen. The miniature below is stricter and raises on undecodable JSON too. On the recorder's side the mirror rule holds: a desired.json it cannot decode keeps the last one it read and reports desired_error, where it once stopped every recording.

3. A stale request wakes up. A peek or a move left in the file by an engine that died mid-wait would be answered by the next recorder process, opening and turning a camera that nobody is waiting for, possibly after the gate closed. So the engine clears every one-shot request as it starts, when by definition nobody can be waiting for one.

A Heartbeat That Means Something

status.json is only evidence while it is fresh. If its heartbeat is more than five seconds old, the engine reports the recorder offline and every look refuses with that reason; it never answers from the last thing a dead recorder said. Installers depend on the same heartbeat, and it taught one more lesson: a stopping recorder writes one last status on its way out, so an installer that waited for "a heartbeat after I restarted it" in whole seconds once took build 188's last word for build 189's first. Installers now compare against a sub-second timestamp taken after the old recorder is gone.

Code

One read-edit-write at a time, across threads and processes (trimmed excerpt, backend/recorder_link.py)·python
@contextmanager
def editing(path: Path) -> Iterator[None]:
    """Hold ``path`` for one read-edit-write: a lock per path for this
    process's threads, ``flock`` on a sibling ``.lock`` file for other
    processes. Re-entered by the same thread without waiting on itself."""
    key = str(Path(path).absolute())
    with _LOCKS_GUARD:
        lock = _LOCKS.setdefault(key, threading.RLock())
    with lock:
        ...  # re-entry by the same thread is counted and passes through
        with open(f"{key}.lock", "a+") as handle:
            fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
            try:
                yield
            finally:
                fcntl.flock(handle.fileno(), fcntl.LOCK_UN)


def want(self, input_id: str, spec: dict) -> None:
    self._guard(str(spec.get("device_uid") or ""), "recording")   # the eyes gate first
    with editing(self.desired_path):
        desired = self.desired(strict=True)    # a failed disk read raises, never reads as empty
        desired["inputs"][input_id] = spec
        self._write(desired)                   # atomic replace
A runnable miniature: atomic desired-state writes that never lose an edit·python
import fcntl
import json
import os
import tempfile
import threading
from contextlib import contextmanager
from pathlib import Path

DESIRED = Path(tempfile.mkdtemp()) / "desired.json"
_lock = threading.Lock()


@contextmanager
def editing(path):
    with _lock, open(f"{path}.lock", "a+") as handle:
        fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
        try:
            yield
        finally:
            fcntl.flock(handle.fileno(), fcntl.LOCK_UN)


def read(strict):
    try:
        return json.loads(DESIRED.read_text())
    except FileNotFoundError:
        return {"moves": []}
    except (OSError, ValueError):
        if strict:
            raise  # never write "nothing asked" over a file we could not read
        return {"moves": []}


def write_atomic(value):
    fd, tmp = tempfile.mkstemp(dir=DESIRED.parent)
    with os.fdopen(fd, "w") as handle:
        json.dump(value, handle)
    os.replace(tmp, DESIRED)  # readers see the old file or the new one, never half


def add_move(camera):
    with editing(DESIRED):
        desired = read(strict=True)
        desired["moves"].append({"camera": camera, "pose": "rest"})
        write_atomic(desired)


threads = [threading.Thread(target=add_move, args=(f"cam{i}",)) for i in range(20)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(len(read(strict=False)["moves"]), "of 20 moves kept")  # 20, every time

External links

Exercise

Run the miniature above, then break it on purpose: remove the editing lock from add_move and run it a few times. Count how many of the 20 moves survive. Then put the lock back but change read(strict=True) to swallow errors, write garbage into DESIRED just before the threads start (each run makes a fresh temporary directory, so do it inside the same run), and watch what the next write does to it.
Hint
Without the lock you will often see fewer than 20, and the number changes between runs; add time.sleep(0.001) between the read and the write to widen the window if every run happens to keep all 20. Lost updates are timing-dependent, which is exactly why they ship. With the strict read swallowed, the garbage file is silently replaced by a fresh empty state, which in Oriel would have stopped every recording.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.