Skip to content

PT-4159: Rebuild S/R write gate without thread affinity#2564

Merged
rolfheij-sil merged 3 commits into
pt-4159-auto-sync-edit-blockfrom
ai/feature/pt-4159-write-gate-atomic-matt-07-15-2026
Jul 16, 2026
Merged

PT-4159: Rebuild S/R write gate without thread affinity#2564
rolfheij-sil merged 3 commits into
pt-4159-auto-sync-edit-blockfrom
ai/feature/pt-4159-write-gate-atomic-matt-07-15-2026

Conversation

@lyonsil

@lyonsil lyonsil commented Jul 15, 2026

Copy link
Copy Markdown
Member

PT-4159 — proposed follow-up to #2555, targeting that PR's branch so the diff shows only the write-gate rewrite.

Summary

Rebuilds SendReceiveWriteLock from a ReaderWriterLockSlim wrapper into a single-atomic-word design: an armed flag, an in-flight write count, and a 30-bit arm generation packed into one long, with every transition (enter/exit write, arm, disarm) a single Interlocked read-modify-write on that word. The exclusion invariant — no project write can overlap a sync's file-replacement window — now follows from single-location total ordering: a write can only enter by atomically observing not armed while incrementing the count, and arming atomically sets the flag, so every write either drains before the sync proceeds or is rejected with the (SR_EDIT_BLOCKED) sentinel. SetSyncing returns the generation as an arm token (never 0, so default is a safe no-arm sentinel): Clear(token) ends only the bracket that owns the sync slot (a stale token is a logged no-op, so a late or duplicate Clear cannot disarm a newer sync), while parameterless Clear() remains the unconditional crash-recovery path.

Why review this

Part of epic PT-4158 (automatic Send/Receive). A concurrency deep dive on #2555's gate found that its correctness rests on a thread-affinity contract — SetSyncing/Clear on one thread, no await across the bracket or a write scope — that is documented but unenforceable, must be honored by the closed-source PT-4210 activation this repo cannot see or test, and whose every violation ends in a permanently held write lock (process-wide edit rejection until restart). See the discussion threads on #2555, especially the design-level question on SetSyncing, plus findings 4, 5, 6, 11, 20, the _writeLockHeld redundancy thread, and the NoRecursion trap thread — all structurally addressed here.

Changes

  • SendReceiveWriteLock.cs: the rewrite. Unchanged semantics: fail-fast sentinel rejection, bounded 10 s drain, degraded proceed-on-timeout by default (new writes still rejected), global gate, per-project IsBlocked, inert in public core. The activation API keeps its SetSyncing/Clear shape (SetSyncing now returns the arm token; Clear(token) is the bracket-scoped variant). The contract:
    • SetSyncing/Clear may run on any threads; an await across the bracket is safe; Clear() is idempotent and doubles as crash recovery (a watchdog is now possible — RWLS thread affinity forbade one); Clear(token) makes a late bracket-end a logged no-op instead of a silent disarm of a newer sync
    • write scopes may cross an await and dispose on another thread; double-dispose and over-release are guarded no-ops
    • nested EnterWrite does not crash (no NoRecursion trap), but it is NOT rejection-safe — a sync arming while the outer scope is open rejects the inner call mid-mutation — so one scope per mutation (the SetBookUsfmInScope delegation pattern) is the required shape for delegating writes
    • the drain waits via SpinWait.SpinUntil and ends early (with a warning) if the arm is cleared or replaced mid-drain; the timeout warning reports the stuck in-flight count; optional SetSyncing(ids, throwOnDrainTimeout: true) rolls the arm back and throws TimeoutException instead of proceeding degraded, so a scheduler can retry/defer/notify
    • armed-ness is an explicit flag (closes the empty-batch degraded-path hole); the project set is built before any state is touched (no torn arm); null/empty ids are ignored, and a batch with no valid ids arms fail-safe with a logged warning; StringComparer.OrdinalIgnoreCase replaces hand-rolled case folding
    • no "is the lock held" bookkeeping bool exists to fall out of step with reality
  • SendReceiveWriteLockTests.cs: new-contract tests, written first wherever a red was observable (nesting while unarmed and while armed, cross-thread Clear with the armed premise asserted, cross-thread scope dispose, null-id tolerance, token round-trip / stale-token no-op / idempotent re-Clear, prompt return when a Clear lands mid-drain, throw-on-timeout rollback, recovery while a write is stuck including the straggler's own decrement). Assertions target InFlightWriteCount/ArmedProjectIds so release, double-dispose, and id-filter regressions are directly falsifiable, and the fixtures reset the gate's full state (including the in-flight count) between tests. Two old-contract tests removed, FakeSync helper deleted (no thread affinity → tests arm directly). The concurrency stress test passes unmodified.
  • CLAUDE.md: the "Send/Receive Write Gate" thread-affinity rules replaced with the new contract — token-ended brackets, the nesting hazard, and keep-scopes-tight guidance.
  • ParatextProjectDataProvider.cs: comments updated to the new contract (single-scope delegation is required, not stylistic; the ParatextData write lock is named explicitly to avoid confusion with the gate's scope). Code unchanged.

AI Involvement

AI-assisted — session; an earlier session's URL was not captured. The concurrency analysis, the design, the implementation, and the tests were AI-generated with my direction and review; the TDD red/green evidence and full-suite runs were verified in-session. Commits carry Co-authored-by trailers.

Testing

  • TDD: new-contract tests were observed failing for the expected reasons before the implementation made them pass — against the RWLS design (LockRecursionException on nesting, cross-thread Clear InvalidOperationException, SynchronizationLockException on cross-thread dispose, NRE on a null id) and against the unpatched drain (a Clear mid-drain burned the full DrainTimeout)
  • Full dotnet test c-sharp suite: 1511 passed / 0 failed / 6 skipped (baseline 1500; delta is exactly the net new tests); includes the untouched SendReceiveWriteLockCoverageTests enforcement scan and all gate wiring tests
  • csharpier clean; no TypeScript changes
  • PT-4210 activation review: confirm the patch's bracket usage — end brackets with Clear(token); nothing enforces this, and parameterless Clear() force-disarms any live sync (it is the crash-recovery hammer). Decide whether the scheduler opts into throwOnDrainTimeout: true, and double-check the overlap semantics (one global sync slot; the scheduler must serialize sync runs)

Risk Level

Low–Medium — the gate is inert in public core (nothing calls SetSyncing), so public behavior is unchanged; the risk is confined to the PT-4210-activated path, where the forgiving contract widens what the activation may safely do and token-checked Clears make late bracket-ends harmless.

Merge note

Targets pt-4159-auto-sync-edit-block (#2555). If #2555 squash-merges first, retarget this PR to main and rebase. Squash-merge is fine for this PR (no template changes).

🤖 Generated with Claude Code


This change is Reviewable

lyonsil and others added 3 commits July 15, 2026 15:18
Replace the ReaderWriterLockSlim-based SendReceiveWriteLock with a
single-atomic-word design: an armed flag plus an in-flight write count
packed into one long, every transition an Interlocked RMW on that word.
The exclusion invariant now rests on single-location total ordering
(a write can only enter by atomically observing not-armed while
incrementing; arming atomically sets the flag), with no cross-variable
fences, no held-lock bookkeeping bool, and no thread-affine state.

Motivation (concurrency review follow-ups): the RWLS bracket required
SetSyncing/Clear on one thread with no await between them - a contract
the closed-source PT-4210 activation could not be forced to honor - and
every violation ended in a permanently held write lock (process-wide
edit block until restart). The new gate is forgiving by design:

- SetSyncing/Clear may run on any threads; an await across the bracket
  is safe; Clear is idempotent and doubles as crash recovery
- write scopes may cross awaits and dispose on another thread
- nested EnterWrite is benign (no NoRecursion crash trap)
- double-dispose and over-release are guarded no-ops
- null/empty ids in a batch are ignored; the set is built before any
  state is touched (no torn arm); armed-ness is an explicit flag, so
  the empty-batch degraded-path hole is gone

Semantics preserved: fail-fast rejection with the (SR_EDIT_BLOCKED)
sentinel, bounded 10s drain of in-flight writes, degraded proceed-on-
timeout with new writes still rejected, global gate, per-project
IsBlocked, activation API (SetSyncing/Clear) unchanged, inert in
public core.

Tests: TDD - four new-contract tests written first and observed failing
against the RWLS implementation (LockRecursionException, cross-thread
Clear throw, cross-thread dispose SynchronizationLockException, null-id
NRE), then green after the rewrite. Two old-contract tests removed,
FakeSync helper no longer needed, recovery-while-stuck test added.
Full c-sharp suite: 1503 passed / 0 failed (baseline 1500; delta is
the net new tests).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Address the confirmed/plausible findings from the multi-agent review of
PR #2564:

- Drain now uses SpinWait.SpinUntil and ends early when the arm is
  cancelled (Clear) or replaced (newer SetSyncing) mid-drain, instead of
  burning the full DrainTimeout on a broken premise; the timeout warning
  now reports the stuck in-flight count.
- SetSyncing returns an arm token (8-bit generation packed into bits
  33-40 of the same atomic word); new Clear(token) disarms only the
  bracket that owns the sync slot, so a late/stray Clear from an
  overlapping bracket is a logged no-op instead of silently disarming a
  live sync. Parameterless Clear() stays as the force/crash-recovery
  path.
- New optional SetSyncing(ids, throwOnDrainTimeout): when true, a drain
  timeout rolls the arm back (nothing stays armed, writes flow again,
  no caller cleanup owed) and throws TimeoutException instead of
  proceeding degraded, so the sync scheduler can retry/defer/notify.
  Default false preserves the existing degraded-proceed semantics.
- An all-invalid (null/empty) SetSyncing batch still arms fail-safe but
  now logs a warning naming the IsBlocked incoherence.
- Corrected the false "nesting is benign / gate is re-entrant" claim at
  every doc site (class remarks, EnterWrite, CLAUDE.md,
  ParatextProjectDataProvider): nesting throws mid-mutation if a sync
  arms while the outer scope is open; one scope per mutation is
  required. Added a pinning test for the armed-nesting rejection.
- Tightened the "can never wedge" remark to carve out leaked scopes;
  added internal ResetForTests (full state reset incl. count) used by
  test SetUp/TearDown to contain a leaked scope's blast radius.
- Test falsifiability: dispose/double-dispose/cross-thread-dispose and
  null-id-batch tests now assert InFlightWriteCount/ArmedProjectIds
  (the old re-enter/DoesNotThrow forms could not fail); the straggler
  dispose after a degraded Clear is now asserted; a new test (observed
  red before the fix) pins the prompt return on Clear-mid-drain; the
  cross-thread Clear test now asserts the armed premise before Clearing.
- Doc staleness: refreshed the stress-test summary to the atomic-word
  design (no more read/write-lock wording) and dropped the dangling
  "queued" from the CLAUDE.md gate section.

Full c-sharp suite: 1510 passed / 0 failed / 6 skipped (+7 new tests).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Session-URL: https://claude.ai/code/session_01K7mGuL6RtKen6Rm94KWRP6
Fixes from the independent whole-PR review (pr-2564-review-findings.md):

- Token 0 is never issued: the generation skips 0 on wrap, so callers
  can safely treat default(long) as a "no arm" sentinel (finding #1);
  the generation field is widened from 8 to 30 bits (bits 33-62), making
  a stale-token false-match need ~10^9 intervening arms (#7). New wrap
  test parks the generation at MaxGeneration via a ResetForTests
  overload and asserts the wrapped arm skips 0 and still round-trips
  (#15).
- Drain no longer busy-burns a core: SpinWait.SpinUntil internally
  disables Sleep(1) escalation (measured ~40x the CPU of a SpinOnce
  loop over a 500 ms wait; worse on Windows) — replaced with an
  explicit SpinWait.SpinOnce() deadline loop that settles at ~1 ms
  polling, keeping the disarm/replaced re-check; comment now states the
  escalation accurately (#3).
- Stress test de-vacuumed: the arm/replacement windows use Thread.Sleep
  instead of Thread.SpinWait (which starved writers of the core on
  small CI runners), a rejections-during-replacement counter asserts
  writers actually contended inside the windows that matter, and the
  Task.WaitAll result is asserted (#5, #18).
- Degraded-path test now asserts a LOWER time bound too, so a
  regression that skips the drain wait entirely cannot pass (#6).
- Doc precision: the pure-data set's disagreement window under
  out-of-contract overlap is stated as potentially bracket-long, not
  "transient" (#9); the Volatile.Read comment now states the real
  guarantee (BCL long overload is interlocked on 32-bit) instead of the
  ambiguous atomicity claim (#11); bare <see cref="Clear"/> qualified
  (#20a); stale "TearDown Clear" wording fixed (#20b).
- Cleanup: EmptyProjectIds is a cached static readonly field (declared
  above its reader; the property allocated per access) (#12);
  GenerationOf/CountOf helpers replace inline bit-decodes and the
  drain-failure message fragment is shared between the throw and warn
  paths (#14).

Declined with rationale (see PR discussion): #2/#10 documented
contract, #4 runtime nesting guard (partial coverage; revisit at
PT-4210 activation), #8 Monitor redesign (decided), #13 parameter
design (decided), #16 covered via the stale-token test, #17 the upper
bound guards drain-condition regressions and stays, #19 acknowledged
weak by the review.

Full c-sharp suite: 1511 passed / 0 failed / 6 skipped (+1 new test).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Session-URL: https://claude.ai/code/session_01K7mGuL6RtKen6Rm94KWRP6
@rolfheij-sil
rolfheij-sil merged commit cff082e into pt-4159-auto-sync-edit-block Jul 16, 2026
@rolfheij-sil
rolfheij-sil deleted the ai/feature/pt-4159-write-gate-atomic-matt-07-15-2026 branch July 16, 2026 08:19
rolfheij-sil added a commit that referenced this pull request Jul 16, 2026
…2555)

* PT-4159: auto-sync blocking store (200ms grace, refcount, safety clear)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: auto-sync blocking service (network event subscription)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: auto-sync blocking overlay with single-shot Cancel

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: focus containment + Cancel re-arm on failure (review findings)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: live sync progress on the blocking overlay

While the auto-sync blocking overlay is visible, subscribe to the
Send/Receive extension's paratextBibleSendReceive.onSyncProgress network
event and display PT9-style live progress under the label: a status text
plus a determinate progress bar (platform-bible-react Progress, 0-1
payload fraction scaled to its 0-100 range). Indeterminate progress
(progressValue null/undefined) is shown as text only because the shadcn
Progress component has no indeterminate visual. The subscription is
gated on blocking being visible and the progress state is cleared when
blocking ends so the next episode starts clean. The payload's null is
normalized to undefined at the subscription boundary. The legacy
project-switch overlay passes no progress props and its rendered output
is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: overlay focus yields to modals + dialog semantics (review findings)

- handleBlur no longer re-contains focus when it moves into a layer that
  renders above the overlay (a [data-overlay-host] subtree or an
  [aria-modal="true"] element), so a modal opened mid-sync keeps focus
  instead of ping-ponging with our re-containment. Non-modal escapes
  (relatedTarget null / body / the covered editor) are still re-contained,
  and the microtask fallback applies the same yield check to
  document.activeElement (Radix moves dialog focus asynchronously).
- In cancel mode the container now uses role="dialog" + aria-modal="true"
  + aria-label (from the label already rendered), replacing role="status",
  which stays for the legacy passive project-switch overlay. The legacy
  render is unchanged when the new props are absent.
- Storybook: add WithCancel, WithDeterminateProgress, and
  WithIndeterminateProgress variants for the new presentational props.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: narrow overlay focus-yield to aria-modal layers + async-yield test

isInHigherFocusLayer matched [data-overlay-host] as well as [aria-modal],
but every overlay-host layer (popover, context menu, command palette,
modal dialog) portals its focusable content to document.body, so nothing
focusable is ever a DOM descendant of [data-overlay-host]. Worse, three
of those four layers render at Z_INDEX 400, below this overlay's 499 — if
a future layer ever stopped portaling, the clause would wrongly yield
focus to something this overlay should be covering. Only the modal
dialog genuinely renders above (Z_INDEX_MODAL, 500) and it already
carries aria-modal="true". Narrow the selector to aria-modal only and
rewrite the doc comment to state that real invariant.

Add a test for the microtask-only yield branch: a blur with a null
relatedTarget (so the synchronous check can't see the modal yet) where
focus lands inside an aria-modal container before the queued microtask
re-checks document.activeElement — the overlay must not re-contain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: replace blocking overlay with a headless editor-block driver

Rework the auto-sync block from a full-workspace focus-trapping overlay
into an editing-only block. The store and network-event service are
unchanged (they still provide the 200 ms grace, ref-count, and safety
timeout); only what "blocking" does to the UI changes.

- Add auto-sync-edit-block-driver: subscribes to the surviving blocking
  store and, on visibility change, sets isSyncBlocked on every open
  Scripture editor's web view state (getAllOpenWebViewDefinitionsSync +
  updateWebViewDefinitionSync). While blocking it also flags editors
  opened mid-block via onDidOpenWebView. A missing flag normalizes to
  false so init/unblock never write redundantly. Unit tested.
- app.component: drop the <AutoSyncBlockingOverlay/> mount, init the
  driver instead; keep initAutoSyncBlockingService().
- Delete the auto-sync overlay component and its focus-trap/dialog tests.
- Revert overlay-workspace-updating.component + stories to their legacy
  { label }-only presentational shape (shared with the project-switch
  overlay); drop the cancel/progress/focus-containment additions.
- Remove the now-unused %overlay_autoSyncBlocking% string (en + es).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: freeze editing + show sync banner in the Scripture editor

Consume the driver's isSyncBlocked web-view state in the Scripture
editor: fold it into isReadOnlyEffective so scripture text is frozen, and
block comment/note creation too (that path is gated by
canUserCreateComments, not read-only) — the context-menu item, the
insert-comment hotkey, and the note-caller click path.

- Add SyncBlockedBanner: a slim, non-covering strip across the top of the
  editor pane, shown only when isSyncBlocked && !isReadOnly (a genuine
  viewer shouldn't say "editing paused"). It shows localized text, live
  sync progress (paratextBibleSendReceive.onSyncProgress — the same
  public seam the old overlay used), and a single-shot Cancel that sends
  paratextBibleSendReceive.cancelSync (disabled after click, re-armed on
  rejection). Reuses %general_cancel%.
- Rejection handling: alongside the existing permissions revert-and-notify
  path, recognize the backend write-gate's (SR_EDIT_BLOCKED) sentinel and
  revert with a distinct "editing paused during Send/Receive" warning.
- main.ts factory: always rebuild saved state with isSyncBlocked: false so
  a crash/reload mid-sync can never persist a read-only editor.
- New en + es strings for the banner and the rejection notification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: add inert public C# write-gate for automatic Send/Receive

Add SendReceiveWriteLock: a process-wide, thread-safe registry of the
projects currently being synced by an automatic Send/Receive, so an
editor write can never race the sync's on-disk file replacement.

Inert in open-source Platform.Bible: nothing in public core calls
SetSyncing, so IsBlocked always returns false and no write is ever
rejected — public behavior is unchanged. The Paratext 10 Studio patch
activates it by bracketing each automatic sync with SetSyncing/Clear
(follow-up). Project IDs are case-normalized; the blocked set is an
immutable set behind a volatile reference (lock-free reads).

Wire ThrowIfSyncBlocked() into every project write choke point in
ParatextProjectDataProvider (scripture USFM/USX setters, project
settings, extension data, and the comment mutations create/add/update/
delete/resolve). When blocked, the write throws with a message ending in
the exact sentinel " (SR_EDIT_BLOCKED)" that the editor recognizes.

NUnit tests cover the registry (set/clear/case-insensitive/replace/
null-safe/throw-with-sentinel) plus one gate test proving SetChapterUsfm
is rejected while syncing and succeeds after Clear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: gate ManageBooks/Inventory writes + wiring tests for all gates

Adversarial review of the write-gate found two PAPI-registered services
that write directly to ScrText/project files with no SendReceiveWriteLock
check. Gate them at the wire-method entry (before any mutation or
orchestrator call), consistent with the PDP gates:

- ManageBooksService: deleteBooks, createBooks, importBooks (target
  project), copyBooks and copyCustomVersification (destination project —
  the only project written; the source is read-only). The review named
  the first four; copyCustomVersification is gated too because it mutates
  the destination's custom.vrs and versification table.
- InventoryDataProvider: setInventoryOptionValues (writes project
  settings via SetSetting/RemoveSetting/Save) and setInventoryItemStatus
  (the review named only the former, but inventory.Save() persists
  valid/invalid items into the project's checking settings, so it is
  gated too).

Extend SendReceiveWriteLockGateTests so every reachable gated method is
called once while SetSyncing is active, asserting the sentinel-suffixed
rejection: all 11 ParatextProjectDataProvider write methods and the 5
mutating ManageBooksService wire methods (16 gate tests total, incl. the
original round-trip). The two InventoryDataProvider setters are private
(reachable only via PAPI wire dispatch), so they get no direct wiring
test; the gate line itself is covered by the registry tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: block comment-editor Save while sync-blocked (review findings)

TS review findings on the edit-block rework:

- onCommentEditorSave was ungated: a comment popover opened before the
  block began is not closed by it, and Save flowed to the backend's
  CreateComment, whose (SR_EDIT_BLOCKED) rejection was only logged — no
  user feedback, popover stuck open, pending highlight lingering. Fix
  both layers: early-return with the localized sync-blocked warning when
  isSyncBlocked (popover stays open so the typed text isn't lost), and an
  SR_EDIT_BLOCKED branch in the catch (an exact-moment race past the
  guard) that shows the same warning and discards the pending state via
  onCommentEditorCancel — matching the scripture-path's revert-and-notify
  pattern.
- Factory-guard comment reworded: isSyncBlocked:true does persist into
  the saved layout during a block; what the guard prevents is RESTORING
  a read-only editor from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: mock edit-block driver in app.component test (jsdom matchMedia)

The driver import added to app.component.tsx transitively evaluates
theme.service-host's module-scope window.matchMedia call, which jsdom
does not implement, failing the suite at import time. Mock the driver
at the module boundary, matching share-layout.dialog.test.tsx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: rework SendReceiveWriteLock as a ReaderWriterLockSlim gate

Replace the counter-based write gate with a wrapper over
ReaderWriterLockSlim (NoRecursion), inverting the usual roles: a project
write takes the RWLS read lock (many can run at once, the "reader"
side), and Send/Receive takes the write lock (exclusive, the "writer"
side). This keeps EnterWrite's fail-fast contract exact while adding a
real drain instead of a counter that could never distinguish "the last
writer just left" from "a writer is still mid-mutation".

- EnterWrite(projectId): TryEnterReadLock(0) — zero timeout, so a
  user's write is never queued behind a sync. On failure, throw the
  existing "(SR_EDIT_BLOCKED)" sentinel. On success, a belt-and-
  suspenders check re-consults the armed project-id set (covers the
  window where a sync proceeded unheld after a drain timeout) and
  rejects there too. Dispose releases the read lock.
- SetSyncing: swaps the armed set first (it stays pure data for
  IsBlocked and the rejection message), then TryEnterWriteLock with a
  bounded 10s timeout — that bounded wait *is* the drain of in-flight
  writes, since a writer-preferring RWLS blocks new readers from the
  moment it queues. On timeout it warn-logs and proceeds unheld (a
  private held-flag records this so Clear only calls ExitWriteLock when
  actually held); a repeat SetSyncing without an intervening Clear
  skips re-acquiring an already-held write lock. Clear disarms the set
  and releases the write lock if held.
- Documented at length: both directions of the gate, why the raw RWLS
  isn't exposed (thread affinity, block-by-default readers vs. our
  fail-fast requirement, and "read lock for a write" reading
  confusingly at call sites), the same-thread contract for
  SetSyncing/Clear (violations throw SynchronizationLockException —
  desirably loud), and how this in-process gate differs from the S/R
  server-side repository lock (lockrepo/unlockrepo).
- Converted all 11 ParatextProjectDataProvider write methods, the 5
  mutating ManageBooksService wire methods, and the 2 InventoryDataProvider
  setters to `using var _ = SendReceiveWriteLock.EnterWrite(projectId);`
  as their first statement, so each scope covers the whole mutation.
  SetBookUsx now calls a new un-gated SetBookUsfmInScope instead of the
  public SetBookUsfm, avoiding a same-thread double-EnterWrite (which
  NoRecursion would reject).
- Reworked SendReceiveWriteLockTests.cs for the new call shape: armed →
  EnterWrite throws with the sentinel; an in-flight write held on
  another thread → SetSyncing blocks until it disposes; a drain timeout
  → SetSyncing proceeds unheld and the belt check still rejects;
  Clear() is a safe no-op when never armed; a same-thread
  SetSyncing/Clear round-trip works. The 16 existing gate-wiring tests
  now arm via a small FakeSync background-thread helper (arming and
  writing on the same thread would hit NoRecursion instead of the
  intended sentinel).

Still inert in public core: nothing calls SetSyncing here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: document and enforce the Send/Receive write-gate rule

The RWLS-backed SendReceiveWriteLock (rebuilt earlier this branch) is only
useful if every new project-mutating code path actually calls EnterWrite.
Nothing previously said so, and nothing would catch a miss.

- CLAUDE.md: add a "Send/Receive Write Gate" section stating the rule (gate
  any ScrText/Settings/FileManager/comment/extension-data mutation with
  `using var _ = SendReceiveWriteLock.EnterWrite(projectId);` at its entry
  point), both directions of the gate in one sentence, and that it is
  distinct from the S/R server-side repository lock.
- c-sharp-tests/Projects/SendReceive/SendReceiveWriteLockCoverageTests.cs:
  a coarse file-level scan for direct write patterns (PutText,
  Settings.Save/SetSetting/RemoveSetting, FileManager.Delete) in c-sharp/
  .cs files outside an explicit allowlist. Resolves the source directory by
  walking up from the test assembly location (path-separator-safe, no
  hardcoded relative-path depth), scans files in deterministic order, and
  matches against full file text (not per-line) so formatter-wrapped calls
  are still caught.

  Allowlist: the three already-gated services (ParatextProjectDataProvider,
  ManageBooksService, InventoryDataProvider) plus four ManageBooks
  orchestrator/helper files (ImportBooksOrchestrator,
  ScriptureTemplateService, CopyBooksOrchestrator, DeleteBooksOrchestrator)
  whose write patterns are today only reached through one of those
  services' gated wire methods but aren't gated at their own entry point —
  each marked `// TODO(PT-4210): assess` rather than gated here, per
  instructions not to gate speculatively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: gate CheckRunner writes + harden write-gate (post-review)

Concurrency-review follow-ups on the Send/Receive write gate.

- CheckRunner.DenyCheckResult/AllowCheckResult (papi "denyCheckResult"/
  "allowCheckResult") persisted ErrorMessageDenials into the project folder
  (denials.Save()) with no gate, so a Checks-UI click could write mid-sync.
  Both are synchronous (no await/Task.Run) and call no other gated method,
  so they are safe to bracket: added `using var _ = EnterWrite(projectId);`
  as the first statement of each, plus wiring tests (armed => throw with the
  SR_EDIT_BLOCKED sentinel) invoked via reflection since the methods are
  private and CheckRunner is sealed.

- SendReceiveWriteLockCoverageTests: broadened the scan to match what the
  CLAUDE.md rule promises — a general `.Save(` heuristic (subsumes
  Settings.Save; catches ScrText.Save, denials.Save, inventory.Save,
  XDocument.Save), comment persistence (SaveUser / CommentEditHelper.
  SaveEdits) and raw File.Delete, alongside the existing PutText /
  SetSetting / RemoveSetting / FileManager.Delete. Allowlist now carries a
  one-line reason per entry (gated / TODO(PT-4210) / not-project-data) and
  gains CheckRunner (gated) plus UserProjectSettings, MarblePackage
  Discoverer and RawDirectoryProjectStreamManager (not shared project data
  the S/R merge replaces). Verified detection: an injected ungated .Save()
  in a non-allowlisted file fails the scan; clean tree passes.

- Documented the thread-affinity constraints the gate relies on: a gated
  method must hold its EnterWrite scope synchronously (no await/Task.Run
  across it — the RWLS read lock is thread-affine), and SetSyncing/Clear
  must run on one thread. Added to CLAUDE.md and the EnterWrite/SetSyncing/
  Clear docstrings.

- SetSyncing/Clear: record the arming thread and have Clear throw
  InvalidOperationException on a cross-thread call (defends the held-flag on
  the degraded path, where the RWLS holds nothing to reject). Added a test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: wire up auto-sync blocking service and edit-block driver at startup

Both initAutoSyncBlockingService() and initAutoSyncEditBlockDriver() were
defined and tested but never called anywhere, so the edit-block never
engaged at runtime — live E2E showed typing going through during a
scheduled Send/Receive. Call both once in src/renderer/index.tsx after the
network service and the webview/notification service block are up (their
dependencies), blocking service first, then the driver.

* PT-4159: Rebuild S/R write gate without thread affinity (#2564)

* PT-4159: rebuild S/R write gate without thread affinity

Replace the ReaderWriterLockSlim-based SendReceiveWriteLock with a
single-atomic-word design: an armed flag plus an in-flight write count
packed into one long, every transition an Interlocked RMW on that word.
The exclusion invariant now rests on single-location total ordering
(a write can only enter by atomically observing not-armed while
incrementing; arming atomically sets the flag), with no cross-variable
fences, no held-lock bookkeeping bool, and no thread-affine state.

Motivation (concurrency review follow-ups): the RWLS bracket required
SetSyncing/Clear on one thread with no await between them - a contract
the closed-source PT-4210 activation could not be forced to honor - and
every violation ended in a permanently held write lock (process-wide
edit block until restart). The new gate is forgiving by design:

- SetSyncing/Clear may run on any threads; an await across the bracket
  is safe; Clear is idempotent and doubles as crash recovery
- write scopes may cross awaits and dispose on another thread
- nested EnterWrite is benign (no NoRecursion crash trap)
- double-dispose and over-release are guarded no-ops
- null/empty ids in a batch are ignored; the set is built before any
  state is touched (no torn arm); armed-ness is an explicit flag, so
  the empty-batch degraded-path hole is gone

Semantics preserved: fail-fast rejection with the (SR_EDIT_BLOCKED)
sentinel, bounded 10s drain of in-flight writes, degraded proceed-on-
timeout with new writes still rejected, global gate, per-project
IsBlocked, activation API (SetSyncing/Clear) unchanged, inert in
public core.

Tests: TDD - four new-contract tests written first and observed failing
against the RWLS implementation (LockRecursionException, cross-thread
Clear throw, cross-thread dispose SynchronizationLockException, null-id
NRE), then green after the rewrite. Two old-contract tests removed,
FakeSync helper no longer needed, recovery-while-stuck test added.
Full c-sharp suite: 1503 passed / 0 failed (baseline 1500; delta is
the net new tests).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: harden S/R write gate: drain cancel, Clear(token), doc fixes

Address the confirmed/plausible findings from the multi-agent review of
PR #2564:

- Drain now uses SpinWait.SpinUntil and ends early when the arm is
  cancelled (Clear) or replaced (newer SetSyncing) mid-drain, instead of
  burning the full DrainTimeout on a broken premise; the timeout warning
  now reports the stuck in-flight count.
- SetSyncing returns an arm token (8-bit generation packed into bits
  33-40 of the same atomic word); new Clear(token) disarms only the
  bracket that owns the sync slot, so a late/stray Clear from an
  overlapping bracket is a logged no-op instead of silently disarming a
  live sync. Parameterless Clear() stays as the force/crash-recovery
  path.
- New optional SetSyncing(ids, throwOnDrainTimeout): when true, a drain
  timeout rolls the arm back (nothing stays armed, writes flow again,
  no caller cleanup owed) and throws TimeoutException instead of
  proceeding degraded, so the sync scheduler can retry/defer/notify.
  Default false preserves the existing degraded-proceed semantics.
- An all-invalid (null/empty) SetSyncing batch still arms fail-safe but
  now logs a warning naming the IsBlocked incoherence.
- Corrected the false "nesting is benign / gate is re-entrant" claim at
  every doc site (class remarks, EnterWrite, CLAUDE.md,
  ParatextProjectDataProvider): nesting throws mid-mutation if a sync
  arms while the outer scope is open; one scope per mutation is
  required. Added a pinning test for the armed-nesting rejection.
- Tightened the "can never wedge" remark to carve out leaked scopes;
  added internal ResetForTests (full state reset incl. count) used by
  test SetUp/TearDown to contain a leaked scope's blast radius.
- Test falsifiability: dispose/double-dispose/cross-thread-dispose and
  null-id-batch tests now assert InFlightWriteCount/ArmedProjectIds
  (the old re-enter/DoesNotThrow forms could not fail); the straggler
  dispose after a degraded Clear is now asserted; a new test (observed
  red before the fix) pins the prompt return on Clear-mid-drain; the
  cross-thread Clear test now asserts the armed premise before Clearing.
- Doc staleness: refreshed the stress-test summary to the atomic-word
  design (no more read/write-lock wording) and dropped the dangling
  "queued" from the CLAUDE.md gate section.

Full c-sharp suite: 1510 passed / 0 failed / 6 skipped (+7 new tests).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Session-URL: https://claude.ai/code/session_01K7mGuL6RtKen6Rm94KWRP6

* PT-4159: address external review of the write-gate hardening

Fixes from the independent whole-PR review (pr-2564-review-findings.md):

- Token 0 is never issued: the generation skips 0 on wrap, so callers
  can safely treat default(long) as a "no arm" sentinel (finding #1);
  the generation field is widened from 8 to 30 bits (bits 33-62), making
  a stale-token false-match need ~10^9 intervening arms (#7). New wrap
  test parks the generation at MaxGeneration via a ResetForTests
  overload and asserts the wrapped arm skips 0 and still round-trips
  (#15).
- Drain no longer busy-burns a core: SpinWait.SpinUntil internally
  disables Sleep(1) escalation (measured ~40x the CPU of a SpinOnce
  loop over a 500 ms wait; worse on Windows) — replaced with an
  explicit SpinWait.SpinOnce() deadline loop that settles at ~1 ms
  polling, keeping the disarm/replaced re-check; comment now states the
  escalation accurately (#3).
- Stress test de-vacuumed: the arm/replacement windows use Thread.Sleep
  instead of Thread.SpinWait (which starved writers of the core on
  small CI runners), a rejections-during-replacement counter asserts
  writers actually contended inside the windows that matter, and the
  Task.WaitAll result is asserted (#5, #18).
- Degraded-path test now asserts a LOWER time bound too, so a
  regression that skips the drain wait entirely cannot pass (#6).
- Doc precision: the pure-data set's disagreement window under
  out-of-contract overlap is stated as potentially bracket-long, not
  "transient" (#9); the Volatile.Read comment now states the real
  guarantee (BCL long overload is interlocked on 32-bit) instead of the
  ambiguous atomicity claim (#11); bare <see cref="Clear"/> qualified
  (#20a); stale "TearDown Clear" wording fixed (#20b).
- Cleanup: EmptyProjectIds is a cached static readonly field (declared
  above its reader; the property allocated per access) (#12);
  GenerationOf/CountOf helpers replace inline bit-decodes and the
  drain-failure message fragment is shared between the throw and warn
  paths (#14).

Declined with rationale (see PR discussion): #2/#10 documented
contract, #4 runtime nesting guard (partial coverage; revisit at
PT-4210 activation), #8 Monitor redesign (decided), #13 parameter
design (decided), #16 covered via the stale-token test, #17 the upper
bound guards drain-condition regressions and stays, #19 acknowledged
weak by the review.

Full c-sharp suite: 1511 passed / 0 failed / 6 skipped (+1 new test).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Session-URL: https://claude.ai/code/session_01K7mGuL6RtKen6Rm94KWRP6

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: address renderer review findings (round 2)

Finding 1 (double initialization): initAutoSyncBlockingService and
initAutoSyncEditBlockDriver were wired up both in renderer/index.tsx and
in app.component.tsx's Main useEffects, giving two event subscriptions and
two driver instances. If Main unmounted after a raise, only its cleanup ran
and editors stayed read-only until the 10-min safety timer. Initialize each
in exactly one place (index.tsx, like every other renderer service) and
remove the app.component.tsx wiring. The app.component test's edit-block
driver mock (a jsdom matchMedia workaround for the now-removed import chain)
is dead, so remove it; the smoke test still passes.

Finding 2 (rebuilt editors silently unblock mid-sync): the driver re-applied
isSyncBlocked via onDidOpenWebView but never onDidUpdateWebView, so an
in-place rebuild during a sustained block (reloadWebView / interface-mode
switch / loadLayout) came back editable with the banner gone. While blocking
is active, also subscribe onDidUpdateWebView and re-flag Scripture-editor
webviews whose isSyncBlocked came back falsy. Guarded against self-triggering:
the re-flag's own update event shows isSyncBlocked true, so it is a no-op and
cannot loop. Subscription mirrors the late-join lifecycle (subscribe while
blocked, unsubscribe after).

Finding 21 (duplicated constant): SAFETY_TIMEOUT_MS re-hardcoded
SHUTDOWN_SYNC_TIME_OUT_MS's 10*60*1000 literal. Hoist to a single shared
constant in shared/data/platform.data.ts, used by both shutdown-tasks.ts and
the blocking store; keep the store's comment explaining why it tracks the
shutdown-sync timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: address C# write-gate review findings (round 3)

Finding 24 (EnterWrite placement consistency): InventoryDataProvider's
SetInventoryItemStatus and SetInventoryOptionValues placed their
ArgumentException.ThrowIfNullOrEmpty guards before the write scope, unlike
every other gated site. Surveyed all ~22 gated sites: the overwhelming
majority (ParatextProjectDataProvider x11, CheckRunner x2, ManageBooksService
x5 — the last doing precondition validation inside the scope) open the scope
as the method's first statement, matching the CLAUDE.md rule verbatim. Moved
the two Inventory gates above the guards so code and rule agree; the guards
only validate (no mutation) and EnterWrite already rejects a null project id.

Finding 15 (coverage scan includes bin/obj): the source enumeration greps
bin/ and obj/ build output (*.g.cs, AssemblyInfo.cs); a future source
generator emitting a matched pattern there would fail the test with an
un-allowlistable obj-relative path. Exclude any file under a bin/ or obj/
segment from the scan.

Finding 14 (file-granularity allowlist hole): a whole-file exemption let a new
ungated write added to an already-allowlisted file (e.g. a 12th method in
ParatextProjectDataProvider) stay green. Replaced the blanket per-file skip
with a per-write-site check: each hit must be (a) gated in its enclosing
method (an EnterWrite/EnterSyncWriteScope call above it, found by an
indentation-bounded upward walk — the gate is always the first statement), or
(b) carry an inline `// SR-write-gate: exempt — <reason>` marker (added to the
un-gated SetBookUsfmInScope core and the ManageBooks orchestrators /
RawDirectoryProjectStreamManager, each citing its gated caller +
TODO(PT-4210)), or (c) sit in a not-project-data file (the only remaining
whole-file exemptions: UserProjectSettings, MarblePackageDiscoverer). Comment
lines are skipped so doc references like `inventory.Save()` do not false-fire.
Validated by temporarily injecting an ungated write into
ParatextProjectDataProvider: the test failed on it, then passed once removed.
The test docstring is honest about the heuristic's bounds. Updated the
CLAUDE.md gate section to describe the per-site enforcement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: address extension review findings (round 3)

Finding 23 (duplicated sync-blocked notification): the
{severity:'warning', message:%…error_syncEditBlocked%} payload appeared three
times in platform-scripture-editor.web-view.tsx (usj-save ternary, comment-save
guard, comment catch). Extracted a single notifySyncEditBlocked() helper so the
message/severity cannot drift.

Finding 13 (fire-and-forget notifications.send): the comment-save guard (~1590)
and the sentinel catch (~1657) called send() without await/.catch, so a
rejection from the notification service became an unhandled promise rejection.
Folded both into notifySyncEditBlocked(), which awaits inside try/catch and logs
on failure.

Finding 10 (insert-comment hotkey silent no-op): the hotkey path early-returned
with no feedback while the context-menu item is visibly disabled during a block.
Split the isSyncBlocked case out of the combined guard and show the shared
"editing paused" notice via notifySyncEditBlocked() (reuses the existing
error_syncEditBlocked string — no new key).

Finding 25 (l10n key ordering): the %…_syncEditBlocked_banner% key sat between
error_syncEditBlocked and info; a `sync…` key sorts after switchScriptureView.
Moved it to the correct alphabetical position in both the en and es blocks.

Finding 9 (only the editor translated the sentinel): the write-gate also rejects
ManageBooks, Inventory, and Checks mutations, but those UIs surfaced a raw
technical error or swallowed the failure. Added a shared, reusable
sentinel-detection helper — sync-edit-blocked.util.ts (isSyncEditBlockedError,
mirroring the editor's SYNC_EDIT_BLOCKED_REGEX; kept local to platform-scripture
since all three consumers live there and platform-bible-utils ships committed
dist artifacts) — and wired the friendly "editing paused" notification into all
three real UI routes:
  - ManageBooks: intercept in onMutationResult (the single chokepoint where the
    dialog routes a thrown backend error as a MutationResult error entry) —
    warning toast instead of the raw sentinel at error severity.
  - Inventory: the approved/unapproved save catches only logged (invisible); now
    they notify on the sentinel and still log other errors. Added `papi` import.
  - Checks: handleDenyCheck/handleAllowCheck had no try/catch, so a rejection was
    an unhandled promise rejection with zero UI (buttons are fire-and-forget);
    added a catch that notifies on the sentinel and re-throws anything else.
Added the shared %webView_platformScripture_error_syncEditBlocked% string
(en + es, alphabetized) to platform-scripture's localizedStrings.json. The
editor's existing handling is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: regenerate papi.d.ts for SHUTDOWN_SYNC_TIME_OUT_MS export

The shared constant SHUTDOWN_SYNC_TIME_OUT_MS was added to
shared/data/platform.data but the generated papi.d.ts was not
regenerated, so CI's 'Report file changes' step failed on all
platforms. Regenerated via 'npm run build:types'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PT-4159: fix permanent editor block from stale re-flag subscription on unblock

When a scheduled sync finishes, syncState() called applyToAllEditors(false)
before tearing down the onDidUpdateWebView re-flag subscription (added in
e79699c for the rebuilt-editor case). The driver's own unflag write fires
onDidUpdateWebView synchronously (buffered emitter and this subscription
share the same PapiNetworkEventEmitter instance), so the still-live handler
observed the just-written `false`, passed its "came back unblocked" guard,
and re-flagged every open Scripture editor straight back to blocked — with
no recovery short of a full window reload. Found live in E2E, 2026-07-16.

Fix: unsubscribe both the open- and update-subscriptions before applying the
unblock, so the handler is gone before the unflag write can be observed.

Extends the driver test mocks so updateWebViewDefinitionSync synchronously
dispatches to any registered onDidUpdateWebView handler (mirroring the real
shared-emitter behavior), and adds a regression test that fails against the
unfixed code (verified: [true, false, true]) and passes with the fix
([true, false]).

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Matt Lyons <matt_lyons@sil.org>
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.

2 participants