Skip to content

fix(engine,mac): release the single-instance flock explicitly (#324) - #326

Merged
charliek merged 4 commits into
mainfrom
feature/plan-024-lock-flake
Aug 10, 2026
Merged

fix(engine,mac): release the single-instance flock explicitly (#324)#326
charliek merged 4 commits into
mainfrom
feature/plan-024-lock-flake

Conversation

@charliek

@charliek charliek commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Plan 024 (pre-release quality for the iced Linux release), PR 1 of 6 — workstream W-C.
This lands first because ci-success is the required check for every other PR in the
plan and this flake reds it in roughly 3 of every 30 main runs.

Closes #324.

What was wrong

close(2) does not release a flock(2). The lock belongs to the open file
description
, not the fd and not the process, so a fork()ed child that inherited
the lock fd keeps the lock alive until that child reaches exec. Rust's File drop
and Swift's Darwin.close(fd) both call only close(2) — so during any fork→exec
window the release is a silent no-op, and the next acquire() sees WouldBlock.

That is exactly the reported symptom: AlreadyHeld(<our own pid>), on both
ubuntu-latest and macos-latest, never reproducible from a filtered local run.
The pid in the message is ours because we wrote it into the lock file ourselves; the
contending holder is our own forked child.

Confirmed at the syscall level with a standalone C program before any Rust was
touched:

close-only:        reacquire -> WOULDBLOCK   (flake reproduced)
LOCK_UN + close:   reacquire -> OK           (fix works)

What changed

7a5720e — commit the reproduction (plan D3.5 requires a committed repro, not a
prose paragraph). tools/repro/single-instance-flake.sh loops the suite with
thread-count and CPU-load knobs and reports a measurable failure rate. --scope workspace mirrors CI's cargo test --workspace --exclude roost-linux; the default
--scope engine is ~60× cheaper per iteration and is where the race actually lives —
only forks from the same test binary can inherit the fd, and those are
roost-engine's own subprocess-spawning tests, not the PTY integration tests (which
are separate binaries). The script classifies each failure as "#324" vs "unrelated"
so an incidental red can't be misread as a reproduction.

dfda3e3 — the fix. flock(LOCK_UN) explicitly before the file closes, in
both implementations. LOCK_UN clears the lock on the description itself, which
every inheriting fd shares, so release becomes unconditional.

49d65dbfs2 → std File::try_lock. Dependency hygiene, explicitly not
presented as the fix (plan D3.3): both call the same syscall with the same
semantics. Not mechanical, though — std signals contention with a TryLockError
variant instead of io::ErrorKind::WouldBlock, so the error match is rewritten.
fs2 is gone from Cargo.toml and Cargo.lock.

Swift rides along

Per the plan's constraint 4 ("fix Swift too where a known fix exists"),
mac/Sources/Roost/SingleInstance.swift had the identical defect — its deinit
only closed the fd, while the app forkpty()s on every PTY spawn. Same one-line fix,
same regression test.

The Swift test deliberately uses raw posix_spawn rather than Foundation's
Process: on Darwin Process spawns with POSIX_SPAWN_CLOEXEC_DEFAULT, which
closes every fd in the child regardless of FD_CLOEXEC — the test passed
vacuously against the unfixed code until I switched it.

Verification

before after
tools/repro/single-instance-flake.sh (400 iterations, -j 64, --load 4) 6/300 failed (2.0%) 0/400 failed
single_instance::tests::drop_releases_even_when_a_forked_child_inherited_the_fd FAILS AlreadyHeld(6147) passes
SingleInstanceTests.releaseOnDeinitSurvivesAForkedChildHoldingTheFD FAILS passes

Both regression tests were verified to fail against their own fix reverted, so
neither is vacuous. cargo test --workspace --exclude roost-linux green;
swift test green (694 tests); cargo clippy --workspace --exclude roost-linux --all-targets -- -D warnings clean.

The post-fix numbers are macOS numbers. Two of the three observed CI failures were on
Linux; the mechanism is identical (flock OFD semantics are the same) but I did not
re-measure the rate on Linux.

Accepted risk, stated plainly

One window survives and cannot be closed from this file: if the process is
SIGKILLed — so Drop/deinit never runs — while a just-forked child has not yet
reached exec, that child's inherited description keeps the lock until it does. The
window is one fork→exec, it self-heals, and closing it would mean changing how every
subprocess in the tree is spawned. Documented in the module docs. Found by the codex
review pass, dispositioned rather than fixed.

Review findings

  • codex, fixed: acquire could drop a bare locked File on the PID-write error
    path, reopening the same close-only hole. The InstanceLock is now constructed
    before anything fallible, so every ? releases through Drop.
  • codex, fixed: the repro script's failure classifier matched the test's name in
    the passing-test listing too, so an unrelated red inside the same binary would
    have been reported as a reproduction. It now keys on libtest's failure-only
    ---- <test> stdout ---- header.
  • codex, fixed: --scope workspace did not mirror CI's --exclude roost-linux.
  • codex, fixed: the spawned child was not reaped on a panic path (now RAII).
  • codex, by design: the new cross-process test would also pass before this fix.
    It asserts a different property — process exit vs RAII drop — and plan D3.4
    deliberately keeps both shapes. The inherited-fd tests are the regression guards.

Also here

InstanceLock::release() is deleted (plan D3, "Also"). It dropped the flock and then
unlinked a path another process may already have opened by name, and it had no caller
outside its own test.

No impact on

Dependencies (net −1), privacy, secrets. Cargo.lock shrinks.

Summary by CodeRabbit

  • Bug Fixes

    • Improved single-instance lock handling and cleanup across supported platforms.
    • Improved recovery when a previous process exits unexpectedly.
    • Prevented lock state from remaining held after inherited process handles are released.
  • Tests

    • Added regression coverage for inherited lock handles and dead-process recovery.
  • Documentation

    • Added guidance and tooling for reproducing intermittent single-instance locking issues.

charliek and others added 3 commits August 10, 2026 00:33
`single_instance::tests::drop_releases_so_next_acquire_succeeds` reds
`ci-success` in roughly 3 of every 30 `main` runs, on both ubuntu-latest
and macos-latest, panicking with `AlreadyHeld(<our own pid>)`.

Root cause: flock(2) locks live on the open file description, not on the
fd or the process. A fork()ed child inherits a duplicate of the lock fd
and keeps that description — and the lock — alive until the fd closes at
exec. Rust's `File` drop calls only close(2), never flock(LOCK_UN), so a
sibling test in the same test binary that spawns a subprocess during the
window we hold the lock makes our drop a no-op and the next acquire()
see WouldBlock. The PID in the message is our own because we wrote it.

This commit only pins the reproduction; the fix is the next commit.

* `tools/repro/single-instance-flake.sh` loops the suite with thread and
  CPU-load knobs and reports a measurable failure rate (6/300 observed
  locally at the defaults). `--scope workspace` mirrors CI's
  `cargo test --workspace --exclude roost-linux`; the default
  `--scope engine` is ~60x cheaper per iteration and is where the race
  actually lives, because only forks from the SAME test binary can
  inherit the fd.
* `drop_releases_even_when_a_forked_child_inherited_the_fd` is the
  deterministic form: it clears FD_CLOEXEC so the child provably keeps
  the description past exec. It is `#[ignore]`d here because it fails by
  design until the fix lands.

codex review findings, all fixed: the failure classifier matched the
test name in the passing-test listing too (now keys on libtest's
failure-only `---- <test> stdout ----` header); `--scope workspace` did
not mirror CI's `--exclude roost-linux`; the spawned child was not
reaped on a panic path (now RAII).

Refs #324.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgxiEBQTqgNjPswKqcs12d
`close(2)` alone does not release a flock. The lock belongs to the open
file description, so a `fork()`ed child that inherited the lock fd keeps
it alive until that child execs — and in that window our release is a
silent no-op. That is #324: `AlreadyHeld(<our own pid>)`, because the pid
in the file is one we wrote ourselves.

Both implementations now `flock(LOCK_UN)` explicitly before closing, and
both grew a regression test that spawns a child holding the inherited fd.
The Swift test uses raw `posix_spawn` rather than Foundation's `Process`:
on Darwin `Process` spawns with POSIX_SPAWN_CLOEXEC_DEFAULT, which closes
every fd in the child regardless of FD_CLOEXEC, so the test passed
vacuously against the unfixed code.

Falsifiable floor (plan 024 D3.5), measured with the repro committed in
the previous commit:

    before   6/300 iterations failed (all classified as #324)
    after    0/400 iterations failed

Both new tests were also verified to fail against their own fix reverted.

Also here:
* `InstanceLock::release()` is deleted (plan D3). It dropped the flock and
  then unlinked a path another process may already have opened by name,
  and it had no caller outside its own test.
* `a_dead_process_releases_the_lock` covers process exit alongside the
  RAII drop test. Plan D3.4 keeps both shapes deliberately — one tests
  `Drop`, the other tests what the UI relies on after a crash.

codex review findings:
* fixed: `acquire` could drop a bare locked `File` on the PID-write error
  path, reopening the same close-only hole. The `InstanceLock` is now
  constructed before anything fallible, so every `?` releases via `Drop`.
* documented, not fixed: if the process is SIGKILLed (so `Drop` never
  runs) while a just-forked child has not yet reached `exec`, that child
  keeps the lock until it does. The window is one fork→exec, it
  self-heals, and closing it would mean changing how every subprocess in
  the tree is spawned. Noted in the module docs.
* by design: the cross-process test would also pass before this fix. It
  asserts a different property (plan D3.4); the inherited-fd tests are
  the regression guards.

Closes #324.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgxiEBQTqgNjPswKqcs12d
fs2 0.4.3 was a single-symbol dependency — `try_lock_exclusive` in one
file — and std has had the same `flock(2)` wrapper since 1.89; the
toolchain is pinned at 1.97.1.

This is dependency hygiene, not a fix (plan 024 D3.3): both call the same
syscall with the same semantics, so nothing about contention changes. The
actual #324 fix was the previous commit's explicit LOCK_UN.

Not mechanical: std signals contention through a `TryLockError` variant
rather than an `io::ErrorKind::WouldBlock`, so the error match is rewritten
rather than renamed. `File::unlock()` is std's flock(LOCK_UN), so `Drop`
keeps its fix.

Gate: `cargo test --workspace --exclude roost-linux` green; the #324 repro
stayed at 0/200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgxiEBQTqgNjPswKqcs12d
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0a86786e-d764-44cf-975c-2f2b8a062b4c

📥 Commits

Reviewing files that changed from the base of the PR and between 49d65db and 5757047.

📒 Files selected for processing (2)
  • mac/Sources/Roost/SingleInstance.swift
  • tools/repro/single-instance-flake.sh
🚧 Files skipped from review as they are similar to previous changes (1)
  • mac/Sources/Roost/SingleInstance.swift

📝 Walkthrough

Walkthrough

The PR replaces Rust fs2 lock handling with explicit flock unlocks. It applies the same cleanup behavior to macOS. It adds regression tests for inherited descriptors and dead processes, plus a configurable reproduction driver and documentation.

Changes

Single-instance locking

Layer / File(s) Summary
Rust lock lifecycle
crates/roost-engine/Cargo.toml, crates/roost-engine/src/single_instance.rs
Removes fs2. Uses File::try_lock and TryLockError. Drop explicitly unlocks the flock. Removes the consuming release method and lock-file unlinking behavior.
Rust lock regression tests
crates/roost-engine/src/single_instance.rs
Adds child cleanup, inherited-descriptor coverage, contention reporting, dead-process recovery, and helper-process coverage.
macOS lock lifecycle
mac/Sources/Roost/SingleInstance.swift, mac/Tests/RoostTests/SingleInstanceTests.swift
Explicitly unlocks the flock before closing the descriptor. Adds cleanup for write failures and a forked-child reacquisition test.
Lock-flake reproduction tools
tools/README.md, tools/repro/*
Adds documentation and a Bash driver with configurable test scope, iterations, threads, CPU load, logging, failure classification, and exit status.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • charliek/roost#87: Established the single-instance locking infrastructure that this PR modifies.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the explicit single-instance flock release fix for both the engine and macOS implementations.
Linked Issues check ✅ Passed The changes address issue #324 by explicitly unlocking descriptors, fixing cleanup paths, and adding regression coverage for inherited descriptors and re-acquisition.
Out of Scope Changes check ✅ Passed The code, tests, reproduction script, and documentation directly support diagnosing, fixing, and validating issue #324.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/plan-024-lock-flake

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mac/Sources/Roost/SingleInstance.swift`:
- Around line 76-84: Update the Darwin.write failure path in SingleInstance
initialization to call roost_flock(fd, LOCK_UN) before Darwin.close(fd). Ensure
the post-lock error cleanup releases the lock explicitly, since
SingleInstance.deinit is not guaranteed to run before initialization completes.

In `@tools/repro/single-instance-flake.sh`:
- Around line 146-148: Update the successful test iteration cleanup in the main
loop of single-instance-flake.sh so the per-iteration log is removed only when
the keep flag is 0. Preserve the existing behavior for failed iterations and
ensure --keep retains successful iteration logs through final cleanup.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7bd55bc0-e565-4578-97e8-470bcabb471d

📥 Commits

Reviewing files that changed from the base of the PR and between 916db38 and 49d65db.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • crates/roost-engine/Cargo.toml
  • crates/roost-engine/src/single_instance.rs
  • mac/Sources/Roost/SingleInstance.swift
  • mac/Tests/RoostTests/SingleInstanceTests.swift
  • tools/README.md
  • tools/repro/README.md
  • tools/repro/single-instance-flake.sh
💤 Files with no reviewable changes (1)
  • crates/roost-engine/Cargo.toml

Comment thread mac/Sources/Roost/SingleInstance.swift
Comment thread tools/repro/single-instance-flake.sh Outdated
* `SingleInstance.acquire` closed the fd without LOCK_UN when the PID
  write failed. No `SingleInstance` exists on that path, so `deinit`'s
  release can't run — the Swift twin of the codex finding already fixed
  on the Rust side.
* The repro script deleted successful iterations' logs even under
  `--keep`, which promises to keep them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgxiEBQTqgNjPswKqcs12d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flaky: single_instance drop_releases_so_next_acquire_succeeds fails ci-success on main (blocks releases)

1 participant