Skip to content

fix: two Connections on one file in one process now lock against each other (#706) - #723

Open
dpsiderius wants to merge 2 commits into
fix/710-change-counterfrom
fix/706-in-process-inode-registry
Open

dpsiderius wants to merge 2 commits into
fix/710-change-counterfrom
fix/706-in-process-inode-registry

Conversation

@dpsiderius

Copy link
Copy Markdown
Contributor

Stacked on #710 (fix/710-change-counter) — merge that first; the reproduction
uses the persistent-oracle-session helper #710 adds.

What

src/vfs/inode_registry.rs — a process-wide registry keyed by
(device, inode), mirroring stock SQLite's unixInodeInfo in
os_unix.c. Every UnixVfsFile/lock guard on the same inode now shares
one real fcntl-backed lock ladder instead of each minting an independent
one, so a same-process lock conflict is refused before a fcntl call
would silently allow it (POSIX record locks are (process, inode)
scoped, not per-handle). src/vfs/shm.rs's WAL_WRITE_LOCK/
WAL_CKPT_LOCK guards get the same arbitration via a smaller
ExclusiveGate sibling — this closes the question #491 left open.

Design and alternatives rejected recorded in ADR-0047, referencing
#491/#412.

Bug found and fixed along the way

Reusing a cached fd regardless of open mode handed a later writer a
read-only fd, so every F_WRLCK step then failed EBADF. Caught by
tests/tiers/tier0.rs::t0_hot_journal_recovers_committed_state. Fixed
with a writable flag and in-place reopen.

Known residual, out of scope, recorded in ADR-0047

WalReadLock's per-slot in-process race — two in-process readers could
still claim the same reader-mark slot. Not data-lossy; flagged as a
follow-up rather than silently dropped.

Before/after (the issue's exact scenario)

A: BEGIN IMMEDIATE; INSERT row 2
B: INSERT row 3            -- before: Ok(1)   after: blocked / busy
A: COMMIT
file contents               -- before: [1, 2], row 3 silently lost
                             -- after:  correct, integrity_check ok

Cross-process locking (verified against the pinned oracle) is unaffected
and stays correct.

Test plan

  • tests/corpus/in_process_lock_registry_test.rs — the issue's exact
    scenario, a cross-process regression guard, a handle close/reopen
    lifecycle test, an N-handle pool progress test.
  • All pre-existing locking tests pass unchanged.
  • make test, make lint, make test-corpus, make check-mvl-limit, make check-mod-files, make assurance all pass.

Note for whoever lands #705 (embedding API): its #[ignore]d ratchet
in_process_connections_lock_against_each_other should be un-ignored once
both this and #705 are merged.

Refs: 013/Req-4, #491, #412, #705, ADR-0047
Closes #706

spend: within estimate (medium-large) -- most went to the arbitration
design and the read/write-mode fd bug it exposed

… other (#706)

Two Connections opened on the same file in one process shared no lock
state: each UnixVfsFile minted its own Rc<RefCell<FileLockState>>, so a
BEGIN IMMEDIATE escalation on one handle never conflicted with a second
handle's own independently-opened fd on the same inode -- POSIX fcntl
locks are scoped to (process, inode), never to the calling process's own
prior lock. A second handle's write reported success and was silently
discarded by the first handle's commit.

src/vfs/inode_registry.rs adds the process-wide (device, inode) registry
stock sqlite3's unixInodeInfo provides: one real fcntl-backed
FileLockState per inode, shared by every Connection on it, with
in-process bookkeeping (shared_holders, write_holder) that refuses a
transition before any fcntl call would silently succeed against another
in-process handle. UnixVfsFile/UnixLockGuard now go through this shared
state instead of each owning an independent lock ladder.

Found and fixed along the way: reusing a cached fd regardless of open
mode handed a later writer a read-only fd, failing every F_WRLCK step
with EBADF -- caught by tier0's hot-journal-recovery test once real
fd-sharing was wired in. SharedInodeLock now tracks writability and
reopens in place the one time a write-needing caller finds a read-only
entry (safe: a read-only opener never locks, so the entry is always
Unlocked at that point).

src/vfs/shm.rs's WAL_WRITE_LOCK/WAL_CKPT_LOCK guards gain the same
in-process arbitration via a smaller ExclusiveGate sibling, closing #491
for real (it was closed COMPLETED with no answer recorded) rather than by
assertion -- WalReadLock's per-slot claim is a documented, non-data-lossy
residual (see ADR-0042).

Tests: tests/corpus/in_process_lock_registry_test.rs proves the issue's
exact scenario, a cross-process regression guard, a handle-close/reopen
lifecycle check, and an N-handle pool progress check, all against real
Pagers/BEGIN IMMEDIATE.

ADR-0042 records the design and alternatives rejected.

Note: PR #705 (the embedding API, src/api.rs, and its #[ignore]d ratchet
in_process_connections_lock_against_each_other) is not merged to main, so
this reproduces the bug with two in-process Pager-backed sessions built
directly over UnixVfs instead. That ratchet should be un-ignored once
both #705 and this land.

spend: within estimate (medium-large) -- most of it went to the
in-process arbitration design and the read/write-mode fd-sharing bug it
surfaced, not the WAL-lock follow-through.
…706)

The Consequences section described the WalReadLock in-process residual
as benign-in-the-safe-direction ("a checkpoint bounds itself more
conservatively than necessary"). That's backwards: active_reader_marks
probes occupancy with a non-blocking F_WRLCK, which POSIX never
conflicts with a lock this same process already holds, so a
same-process reader's mark is invisible to it; checkpoint.rs then folds
the resulting empty mark list into .unwrap_or(total_frames), letting a
checkpoint backfill the whole WAL rather than bounding conservatively.
Also corrected the "dormant" framing: the checkpoint path
(Pager::set_journal_mode -> switch_wal_to_journal ->
checkpoint_passive) is reachable today through PRAGMA journal_mode
round-tripping (verified against the built CLI); what's still missing
for harm is a second in-process reader, which needs the unmerged
embedding API (#705).

No other file cites ADR-0047 yet (only CHANGELOG.md's own "See
ADR-0047" note and the ADR index), so this stays an in-place edit per
CLAUDE.md's uncited-ADR carve-out rather than a superseding ADR.

spend: small, doc-only — a few file reads, a CLI build/repro to verify
reachability, and the rewrite itself.
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.

bug: two Connections on one file in one process do not lock against each other — a write that reports success is silently discarded

1 participant