Lock-interop spike (#6193)
Verdict: GO for the design of ADR 2026-10-22-1 D-4, on all three operating systems:
- Linux and macOS: the Java writer must call
flock(2)through the Foreign Function and Memory (FFM) API. That takes the very lockkernel.lockstakes withfcntl.flock. - Windows:
FileChannel.lockis enough. It (LockFileEx) andmsvcrt.locking(the C runtime'sLockFile) take byte-range locks that overlap at byte 0. - NO-GO for
FileChannel.lockon Linux: it takes anfcntlrecord lock, which Linux keeps independent offlock. Neither side excludes the other. FileChannel.lockis GO on macOS, because the macOS lock manager makesfcntlandflocklocks conflict. That does not help: Linux needs the FFMflock(2)call anyway, and one Java code path for both POSIX systems is simpler.
Appends from both sides, interleaved under load, land as whole lines. Each line is byte-identical to what its writer wrote, and both the Python status reader and the Java LedgerLineDecoder accept the whole log.
Exclusion matters even though ADR D-4 hands each log over whole, one release at a time: an older CLI, still installed in another checkout of the same repository, keeps appending to a log the service already owns, and both take the lock files under the shared git common dir.
This is the first work package of #6193 (Mission Write API step 3, epic #6117). Nothing here changes the CLI, the event row (the spec-kitty-events #92 envelope is not used) or any command.
What was tested
The harness drives the production code on the Python side, never a copy: kernel.event_log_append.event_log_lock_path, event_log_lock and append_event_log_lines, and kernel.locks.machine_file_lock.
| Part | Where |
|---|---|
| Python peer | scripts/ci/lock_interop_peer.py (unit tests: tests/ci/test_lock_interop_peer.py) |
| Java harness | package org.speckitty.missionstatus.spike.lockinterop in the test sources of the event-log module of services/mission-status-api |
| CI evidence | the lock-interop job of .github/workflows/mission-status-api.yml (ubuntu-24.04, macos-15, windows-2025; GraalVM CE 25.0.2) |
LockInteropSpikeTest runs once per Java lock. java-filechannel is FileChannel.tryLock() over the whole file, on every OS. java-ffm-flock is flock(fd, LOCK_EX | LOCK_NB) through FFM, on Linux and macOS. Each run uses a fresh git repository, so the lock file is the real <git-common-dir>/spec-kitty-locks/event-logs/<digest>.lock. The harness checks four things:
- Controls. Each side acquires the free lock, so a "contended" result is never vacuous. Both sides render a row with the same bytes.
- Cross-holding. Python holds the lock and Java tries it; Java holds it and Python tries it.
- Overlap under load. Three Python processes and the Java writer start together on a barrier. Each repeatedly takes the lock and creates a sentinel file with
O_EXCLinside it. A create that finds the sentinel present is an overlap: two holders inside the critical section at once. Before each round, each side probes the lock without waiting and counts the probes that find it held ("contended"), which proves the writers really met. The count is a lower bound: an overlap outside a holder's 2 ms stay inside the critical section is not seen. - Appends under load. Three Python processes (
append_event_log_lines, one row per call) and the Java writer append at once. The Java writer appends under its lock: it refuses a torn tail, writes the whole line and its LF in one write on anAPPENDchannel, then forces it to disk. The rows are lane transitions in today'sstatus.events.jsonlshape, serialised asjson.dumps(row, sort_keys=True). Then:every line must be one of the submitted rows, byte for byte, and each must appear exactly once;
the file must end with LF and hold no CR (the Windows byte rules of #4182);
the Python reader (
specify_cli.status.store.read_events) must return every row (it reads the file after every writer has finished, as the Java decoder does);LedgerLineDecoder.decodemust classify every line as a status line, with no tail.the lines must switch between the Java writer and a Python writer at least once ("cross-language switches"), so the appends really interleaved.
A run is GO when both cross-holding checks exclude, the Java writer found the lock held by a Python writer at least once, there are zero overlaps, no append is refused, and the appends pass every check above. The job fails when a run records no result (a skipped test or a filter that matches nothing).
Evidence
CI run 40557031791 (head 21751d8); all three lock-interop jobs passed, including the guard that requires one recorded result per Java lock.
| OS | Java lock vs Python lock | Python holds: Java excluded | Java holds: Python excluded | overlaps / rounds | contended (Java / Python) | refused / rows | intact / Python reader / Java reader | switches (cross-language / all) | verdict |
|---|---|---|---|---|---|---|---|---|---|
| linux | java-ffm-flock vs fcntl.flock | true | true | 0 / 1730 | 147 / 105 | 0 / 1268 | true / true / true | 484 / 490 | GO |
| linux | java-filechannel vs fcntl.flock | false | false | 172 / 495 | 0 / 12 | 0 / 743 | true / true / true | 516 / 520 | NO-GO |
| macos | java-ffm-flock vs fcntl.flock | true | true | 0 / 792 | 110 / 109 | 0 / 1950 | true / true / true | 474 / 492 | GO |
| macos | java-filechannel vs fcntl.flock | true | true | 0 / 667 | 99 / 103 | 0 / 1214 | true / true / true | 444 / 453 | GO |
| windows | java-filechannel vs msvcrt.locking | true | true | 0 / 1779 | 187 / 120 | 0 / 3382 | true / true / true | 560 / 567 | GO |
Each row is the lock-interop result: record the test writes to event-log/build/lock-interop/ (artefact lock-interop-<os>) and to the job's step summary. The assertions in LockInteropSpikeTest pin these outcomes, so a JDK or OS change that moves one turns the job red:
- GO for
java-ffm-flockon Linux and macOS, and forjava-filechannelon macOS and Windows; - no exclusion for
java-filechannelon Linux.
The workflow runs the job when the Python peer, src/kernel/locks.py or src/kernel/event_log_append.py change, so a change on the Python lock side re-runs the evidence. The job is not part of mission-status-api-gate.
Without exclusion, the harm is real. In local Linux runs with java-filechannel, the Python append read the log while a Java write was in flight and refused a legitimate append with EVENT_LOG_TORN_TAIL. The critical-section count shows the same race directly: about one overlap in three rounds. O_APPEND keeps each line whole on Linux, so the damage shows up as refusals and races in the torn-tail check, not as torn lines.
What the write service needs (follow-ups, not done here)
- The first handover is not cleared by this spike. ADR 2026-10-22-1 names the glossary logs as the first file the service takes over. This spike proves status-shaped rows. The glossary handover still needs:
- byte parity with
spec_kitty_events.persistence.serialize_record, which writes the glossary rows; - a ruling on the torn-tail policy: the glossary writer repairs a torn tail (it moves the fragment to
<ledger>.tornunder the leaf lock<ledger>.lock,src/glossary/events.py), while the Java append here refuses one; - acceptance of the Java-written log by the glossary reader.
- byte parity with
- Lock path. The harness hands Java the lock path the Python peer computes. The service must compute
<git-common-dir>/spec-kitty-locks/event-logs/<digest>.lockitself.<digest>is the first 32 hex characters of the SHA-256 ofos.path.normcase(resolved anchor path)in UTF-8;normcaselowercases the path and turns/into\on Windows. A parity test againstevent_log_lock_pathmust also cover the git common dir seen from a linked or lane worktree, and the fallback outside a git repository (<anchor>.event-log.lockbeside the log). - The status lock.
status.events.jsonlis not appended through the event-log lock: it keeps the Mission status lock (specify_cli.status.locking,<key>.status.lock). It uses the samekernel.locksprimitive, so the per-OS result above carries over, but its path rule must be ported too before that log is handed over. - Production hardening of the Java lock. Open the lock file with
O_CLOEXECandO_NOFOLLOW, askernel.locksrefuses a symlink (open_no_follow). Make the held lock's release idempotent: closing a raw descriptor number twice can close a reused descriptor and drop another holder's lock. - Append parity. The spike's Java append checks the tail by path, then writes on a second channel. The service should check and write through one descriptor, and cut the log back to its size before the write on a short or failed write, as
append_event_log_linesdoes. - Native image. The FFM downcall was proven on the JVM (GraalVM CE 25.0.2). A native image needs the downcall registered in its reachability metadata. This is not tested here.
- ADR wording. D-4 says the service takes the lock files "with
flock(2)semantics (through FFM)".flock(2)does not exist on Windows. Proposed amendment: "flock(2)through FFM on Linux and macOS,FileChannel.lock(LockFileEx) on Windows". - One holder per JVM.
FileChannel.lockthrowsOverlappingFileLockExceptionwhen a second thread of the same JVM locks the same file.flocktaken through two descriptors in one process blocks the second. ADR D-4 already serialises writes in process, which avoids both.