PT-4159: Rebuild S/R write gate without thread affinity#2564
Merged
rolfheij-sil merged 3 commits intoJul 16, 2026
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PT-4159 — proposed follow-up to #2555, targeting that PR's branch so the diff shows only the write-gate rewrite.
Summary
Rebuilds
SendReceiveWriteLockfrom aReaderWriterLockSlimwrapper into a single-atomic-word design: an armed flag, an in-flight write count, and a 30-bit arm generation packed into onelong, with every transition (enter/exit write, arm, disarm) a singleInterlockedread-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.SetSyncingreturns the generation as an arm token (never0, sodefaultis 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 parameterlessClear()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/Clearon one thread, noawaitacross 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 onSetSyncing, plus findings 4, 5, 6, 11, 20, the_writeLockHeldredundancy thread, and theNoRecursiontrap 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-projectIsBlocked, inert in public core. The activation API keeps itsSetSyncing/Clearshape (SetSyncingnow returns the arm token;Clear(token)is the bracket-scoped variant). The contract:SetSyncing/Clearmay run on any threads; anawaitacross 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 syncawaitand dispose on another thread; double-dispose and over-release are guarded no-opsEnterWritedoes not crash (noNoRecursiontrap), 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 (theSetBookUsfmInScopedelegation pattern) is the required shape for delegating writesSpinWait.SpinUntiland ends early (with a warning) if the arm is cleared or replaced mid-drain; the timeout warning reports the stuck in-flight count; optionalSetSyncing(ids, throwOnDrainTimeout: true)rolls the arm back and throwsTimeoutExceptioninstead of proceeding degraded, so a scheduler can retry/defer/notifyStringComparer.OrdinalIgnoreCasereplaces hand-rolled case foldingSendReceiveWriteLockTests.cs: new-contract tests, written first wherever a red was observable (nesting while unarmed and while armed, cross-threadClearwith the armed premise asserted, cross-thread scope dispose, null-id tolerance, token round-trip / stale-token no-op / idempotent re-Clear, prompt return when aClearlands mid-drain, throw-on-timeout rollback, recovery while a write is stuck including the straggler's own decrement). Assertions targetInFlightWriteCount/ArmedProjectIdsso 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,FakeSynchelper 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-bytrailers.Testing
LockRecursionExceptionon nesting, cross-threadClearInvalidOperationException,SynchronizationLockExceptionon cross-thread dispose, NRE on a null id) and against the unpatched drain (aClearmid-drain burned the fullDrainTimeout)dotnet testc-sharp suite: 1511 passed / 0 failed / 6 skipped (baseline 1500; delta is exactly the net new tests); includes the untouchedSendReceiveWriteLockCoverageTestsenforcement scan and all gate wiring testsClear(token); nothing enforces this, and parameterlessClear()force-disarms any live sync (it is the crash-recovery hammer). Decide whether the scheduler opts intothrowOnDrainTimeout: 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 tomainand rebase. Squash-merge is fine for this PR (no template changes).🤖 Generated with Claude Code
This change is