PT-4214/PT-4210 cleanup: declare the sync-block seam (getAutoSyncBlocking, onSyncWriteLockChanged) + public breakSyncLock stub - #2612
Conversation
Declares in the paratext-bible-send-receive type seam what core PR #2574 shipped undeclared (held back to avoid colliding with #2570's seam re-home, which has now merged): - SyncWriteLockSnapshot payload type (the C# SendReceiveBlockState wire shape) - paratextBibleSendReceive.getAutoSyncBlocking command - paratextBibleSendReceive.onSyncWriteLockChanged network event All three are tagged @experimental. The renderer auto-sync-blocking service now consumes the seam instead of its interim local types: imports nothing locally-duplicated, subscribes through the typed getNetworkEvent overload, and sends the init consult through the typed sendCommand instead of an untyped requestNoRetry (identical wire behavior from the renderer, where the no-retry flag never crossed the wire anyway). The fail-safe payload validation stays, since older or off-contract cores remain possible. The legacy onAutoSyncBlockingChanged event was never declared in core, so there is nothing to remove; the test asserting non-subscription is kept. Regenerating papi.d.ts produces no changes - the src/@types seam is not part of papi.d.ts generation. Claude-Session: https://claude.ai/code/session_019MhYcp1XVTQ7JHiA4n2qaV Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
paratextBibleSendReceive.breakSyncLock so far existed only in the private Paratext 10 Studio patch, deviating from the public/private seam convention where core ships the public scaffolding (stub + declared contract) and the patch only fills in the implementation. - ParatextProjectSendReceiveService: register the command and add a BreakSyncLock stub throwing PlatformUnimplementedException, following the cancelSync/syncProjects pattern and phrasing. The signature matches the patch's implementation (List<string> projectIds -> Task<Dictionary<string, bool>>) so the patch converts to a body fill; the stub is non-async to avoid CS1998, so the patch also adds the async keyword. The patch-only s_sendReceiveTimeout registration argument stays in the patch, like syncProjects. - Seam d.ts: declare the command in CommandHandlers, copied from the Send/Receive extension's own declaration, plus the seam's standard PlatformUnimplementedException @throws note. Tagged @experimental. No core registration-list test or doc enumerates these commands, so nothing else to extend. Claude-Session: https://claude.ai/code/session_019MhYcp1XVTQ7JHiA4n2qaV Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er, breakSyncLock doc clarifications - breakSyncLock and getAutoSyncBlocking registrations now pass ExperimentalMethodDocumentation so the x-experimental marker reaches the wire (OpenRPC rpc.discover), per the Experimental APIs standard. - Update the editor-mount re-query tracking pointer from the closed PT-4214 to its surviving ticket PT-4265. - breakSyncLock docs (TS + C# XML) now state it breaks the SERVER-side repository lock and is unrelated to the local in-process write gate reported by onSyncWriteLockChanged / getAutoSyncBlocking. - Document breakSyncLock's empty-array behavior (verified against the real implementation): empty input is a no-op — empty result map, server never contacted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019MhYcp1XVTQ7JHiA4n2qaV
…ts reference Adds a LocalParatextProjects constructor parameter (bound into a property by the closed-source Paratext 10 Studio overlay patch, like pdpFactory and appInfo) so the patch can call NotifyProjectsChanged after a sync rewrites Settings.xml in place — the file watcher's own docs say such in-place rewrites must be notified inline by their writer. Part of the PT-4158 deferred-items cleanup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019MhYcp1XVTQ7JHiA4n2qaV
timothy-mccormack
left a comment
There was a problem hiding this comment.
Review: 26 findings (max-effort pass)
Ran a nine-angle parallel review over this PR plus the two companion PRs (paratext-10-studio#168, paratext-bible-internal-extensions#192), the patched core in temp-build, and the Auto-Sync PRD.
No merge blocker. I built and ran both sides rather than reasoning about them: dotnet build 0 errors, 69/69 C# tests, 14/14 vitest on auto-sync-blocking-service.test.ts, tsc --noEmit clean in all six changed files (3 pre-existing errors elsewhere from unbuilt generated artifacts), eslint clean, and PT10 patch #168 still applies. Everything below is contract accuracy, test coverage, and cleanup.
The 26 inline comments are numbered by priority: 1-11 are worth fixing before merge, 12-18 (numbered 15-18 here) are worth fixing, 19-30 (numbered 20-30 here) are cleanup. Four further findings belong to studio#168 and one to ext#192; they're posted on those PRs rather than here.
The three I'd act on first
onSyncWriteLockChangedgets the TSDoc@experimentalbut not thex-experimentalwire marker — six of nine review angles raised this independently. Marking two of the three new surfaces is worse than marking none: a consumer readingrpc.discoverconcludes the event is stable.- The
breakSyncLockwire docs describe Paratext 10 Studio behavior in a build that always throws — including a security-flavored claim ("the server only permits breaking a lock you own") that public core has no code path to produce or verify. - The
getAutoSyncBlockingdoc names two unreachable failure modes and omits the reachable one — with the downstream cost that the real failure is deliberately logged atdebug.
Verified and explicitly not findings
Recording these so they don't get re-litigated:
- The
requestNoRetry→sendCommandswitch is behaviorally inert, not a retry-cost regression:RpcClient.requesttakes only two params and dropsskipRetry, and main re-dispatches throughrequestWithRetryeither way. The old call already paid the full retry loop; the wire message is byte-identical. (The direction is still right — ifRpcClientis ever fixed to forward the flag, the old call would drop the seed on attempt 1. Worth one sentence noting the retry is now deliberate, so a future cleanup doesn't revert it.) - No TS2717 collision with the extension's vendored copy — extension repos'
typeRootsdeliberately omit core'ssrc/@types, and the twobreakSyncLocksignatures are byte-identical anyway. - No duplicate
breakSyncLockregistration — the extension only calls it; the command it registers is the differently-namedbreakSyncLocksAndRetry. - The sync-throwing
Task<T>stub does not break JSON-RPC — verified with a StreamJsonRpc 2.22.11 harness (see comment 25). - The 5th registration costs ~0 wall-clock (all five round-trips overlap) and the docs payload is 1001 bytes once per process.
- The
paratextProjectsctor param is not redundant —_paratextProjectsisprotectedon the factory base, so a non-subclass can't reach it.
Out of scope but shouldn't get lost
tsconfig.json:40's "!**/*.test.{ts,tsx}" entry is inert — TypeScript include doesn't support ! negation, so all 101 test files are in the typecheck program. Harmless today (it's what makes comment 15 actionable), but the entry states an intent the build doesn't honor.
🤖 Generated with Claude Code
…reshAndNotifyProjectsChanged, init snapshot emit - breakSyncLock's stub registration now passes the indefinite S/R timeout itself (internal s_sendReceiveTimeout, matching the P10S overlay's name so the two unify): S/R commands are multi-minute server operations, so the request timeout must outlive them. Inert in plain Platform.Bible since the stub throws immediately. - New public virtual LocalParatextProjects.RefreshAndNotifyProjectsChanged(): RefreshScrTexts + NotifyProjectsChanged as one primitive for writers whose on-disk changes are invisible to the non-recursive project-directory watcher (in-place Settings.xml rewrites, mid-clone states). - Moved the notify-after-sync integration rationale from the SyncProjects stub body (which the overlay replaces wholesale) to the constructor comment, and pointed it at the new primitive. - SendReceiveBlockNotifierService.InitializeAsync now emits the current gate snapshot once after registration, so subscribers converge after a backend restart; tests cover the idle and already-armed cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pBpGFRLUt3fioxfz45jnU
…tions, stub fault mode, renderer hardening - New OpenRpcSingleNotificationDocumentation/OpenRpcNotificationDocumentation records (C# mirror of TS SingleNotificationDocumentation) and the onSyncWriteLockChanged registration now sends them as network:registerEvent's second argument, carrying the x-experimental wire marker (review 1). - SendReceiveBlockNotifierService registers its event and command in parallel via Task.WhenAll, so a stalled main can't serialize two full request timeouts onto the startup barrier (17). - BreakSyncLock stub delivers its PlatformUnimplementedException through the returned task, matching the patched async implementation's fault mode (25). - SyncProjects' dev placeholder uses the PapiClient property instead of the raw ctor param, clearing the CS9124 double-capture warning (27). - Renderer auto-sync-blocking-service: malformed-snapshot warnings now name their source (event vs init query) and latch per source (4); projectIds validation uses .every(isString) (24); the init-consult failure docs describe the real cold-start race instead of unreachable "older cores / extension absent" scenarios, and the catch logs warn instead of debug since any rejection is anomalous on an in-repo seam (3); same reword on the seam's getAutoSyncBlocking declaration. - Doc-assertion tests per the VersificationConversionServiceTests precedent: getAutoSyncBlocking + the event registration docs in the notifier tests, and a new ParatextProjectSendReceiveServiceStubDocsTests for breakSyncLock (named to avoid the P10S overlay's test file names) (5). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pBpGFRLUt3fioxfz45jnU
…seam re-sync guard, mock typing
- breakSyncLock wire summary gains the build-agnostic availability sentence
("Only implemented in Paratext 10 Studio; throws
PlatformUnimplementedException elsewhere") and the registration uses the
named documentation: argument (2, 29); dropped the comment that only
restated the x-experimental marker (23).
- Seam d.ts: `false` means "not broken — an attempt may not have been made;
a later retry can succeed" (6a); keys are upper-cased, index with
upper-cased ids (7); null/blank ids are skipped and omitted (8); the
header NOTE now also forbids a re-sync downgrading richer declarations
that exist in both copies (10).
- auto-sync-blocking-service: seed + event handler carry
"typed-by-the-seam but untrusted wire data" pinning comments (16); the
free-floating payload-type comment is now a one-line pointer to the seam
declaration (21); noted that plain sendCommand (retry-on-timeout) is
deliberate now that the command is declared — requestNoRetry was only a
workaround for the missing declaration (S1).
- Tests: the two well-formed snapshot mocks are pinned with
`satisfies SyncWriteLockSnapshot`; deliberately-malformed ones stay
untyped (15).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pBpGFRLUt3fioxfz45jnU
…D2b) Replace the CS9113 pragma (and its unread-parameter rationale comment) with read-only auto-properties PdpFactory / AppInfo / ParatextProjects in the PapiClient property's style, using exactly the names the Paratext 10 Studio patch's own bindings use so the patch can drop its binding hunk and rely on these. The params are now consumed by the initializers, so no suppression is needed; a guard comment marks the properties as patch-only reads that must not be removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pBpGFRLUt3fioxfz45jnU
Round-2 verification: 21 of 26 addressed, 5 consciously droppedRe-reviewed Verified on
Fixed (21)Wire marker for Three went past what was asked, and better: the notification docs got factored into a reusable Two I re-verified specifically because they could plausibly have been subtly wrong:
Dropped (5) — all previously flagged low or optional(9) the C# No objection to any of these. Details in the threads.
|
|
🤖 Claude: Round processed — all 26 inline items plus the review-body notes; every claim was verified against the tip before acting. Shipped in four commits ( Round 2: your verification table matches ours on every point — items 1/5/11/15/18 are confirmed on both sides, and 20/22/26/30 close with the same rationale we'd drafted (the patch side of 20 did land: |
timothy-mccormack
left a comment
There was a problem hiding this comment.
Review from a multi-angle pass over the diff (ten independent finder angles plus a verification pass), cross-traced against PapiClient, network.service.ts, async-variable.ts, rpc.model.ts, SendReceiveWriteLock, and the paired PRs in paratext-10-studio and paratext-bible-internal-extensions.
The PR's own verification claims hold up. dotnet build 0 errors and no new warnings; C# suite 1556/1556; vitest 15/15 on the changed test file; tsc -p tsconfig.json reports no error in any changed file. One concern that came up in review — that swapping requestNoRetry for the generic sendCommand breaks typechecking in auto-sync-blocking-service.test.ts — I could not reproduce: the test file is in the tsc program (the "!**/*.test.ts" include entry is a no-op, as tsc only honours ! in exclude), and a full run emits nothing for it.
Six inline comments below are the ones I'd hold the PR on. Three are one-line fixes, two are decisions.
Blocker with no line to anchor to: merge ordering
The committed patch on paratext-10-studio main carries the deleted #pragma warning restore CS9113 as hunk context, and separately adds its own "command:paratextBibleSendReceive.breakSyncLock" registration and a BreakSyncLock body — both of which this PR now adds to core. Merging this PR first makes git apply --3way produce conflict markers and then CS0111 duplicate members, breaking every Studio build until paratext-10-studio#168 lands. #168 is still a draft. These two have to go in lockstep, and nothing in this PR signals the dependency to a core maintainer reviewing it on its own merits.
Non-blocking (24) — follow-up material, not merge blockers
Correctness-adjacent
ThreadingUtils.RunTask— the continuation bindsContinueWith(Action<Task,object?>, object? state, TaskScheduler), soTaskContinuationOptions.OnlyOnFaultedlands instateand a continuation is scheduled on success too. Pre-existing, and harmless because the body re-checkst.Exception != null— but the new comment atSendReceiveBlockNotifierService.cs:178sells this as a fault-only continuation, which isn't true.ObserveTaskLoggingErrorsToStderruses the correct overload.auto-sync-blocking-service.ts:61—filter(...).length === lengthtoevery(isString)silently accepts sparse arrays (everyskips holes;filtercounted them). Not reachable from JSON wire data, but this is the function whose stated job is validating untrusted payloads.SendReceiveBlockNotifierService.cs:145—Task.WhenAll(Task, Task<bool>)bindsparams Task[], discarding the result.RegisterRequestHandlerAsyncsignals failure by returning false, never throwing, so a failed command registration ships silently — while the event arm two lines up logs its rejection.ParatextProjectSendReceiveService.cs:192—BreakSyncLockis the only handler returningTask<T>.PapiClient.SendRequestAsync<T>short-circuits local handlers with(T)handler.DynamicInvoke(...)and no await, so an in-process caller getsInvalidCastException; the void overload discards the faulted task entirely. The CS1998 justification is a red herring — a non-asyncTask<T>method may simplythrow.SendReceiveBlockNotifierService.cs:110—InitializeAsyncis non-idempotent:BlockStateChanged += …on a process-wide static with no guard and no unsubscribe. A second init doubles every transition and adds another baseline emit.index.d.ts:442— the d.ts documents an empty-array no-op ("resolves to an empty map without contacting the server") that the stub cannot exhibit; it faults for every input including[]. The@paramand@throwsclauses contradict each other.ParatextProjectSendReceiveService.cs:192—List<string>is non-nullable under<Nullable>enable</Nullable>while both docs promise "null/blank ids are skipped". SiblingSyncProjectsusesString[]?.index.d.ts:445— the new upper-cased-keys contract isn't honoured by the only consumer (same-user-lock-prompt.tsindexes with rawpendingkeys). Latent only because upstream ids happen to be upper-case.
Test integrity
9. DummyPapiClient.RegisterRequestHandlerAsync discards the timeout argument with no accessor, so deleting s_sendReceiveTimeout, from the registration passes the whole suite — the riskiest new value in the PR is untestable.
10. RefreshAndNotifyProjectsChanged has no test and no override, despite being virtual "so tests can substitute the ParatextData refresh".
11. ParatextProjectSendReceiveServiceStubDocsTests constructs and InitializeAsynces the class the Studio overlay replaces. In the patched build that body does registry/Hg init and sets a static failure string that then poisons later patched S/R tests — order-dependent failures caused by a file in the public repo.
12. SendReceiveBlockNotifierServiceTests.cs:39 — _ = Client.NextSentEvent; throws when empty and now sits in SetUp; if the init emit changes, all twelve tests fail there with "No sent events to dequeue." Two other fixtures already keep private DrainEventQueue() copies — the helper belongs on DummyPapiClient.
13. auto-sync-blocking-service.test.ts:102 — two synchronous tests assert exact logger.warn counts while a rejected sendCommand is guaranteed pending. They pass only because no microtask checkpoint intervenes; adding any await breaks them silently.
14. DummyPapiClient._sentRequests is a plain Queue<> while this PR introduces real concurrency — the class already upgraded _sentEvents and _documentationByRequestType to concurrent types for exactly this reason.
15. s_blockStateChangedEventDocumentation is a static instance whose every property is { get; set; } rather than init — process-lifetime mutable state handed to the serializer, where ExperimentalMethodDocumentation.Create allocates fresh per registration.
Conventions
16. ParatextProjectSendReceiveServiceStubDocsTests.cs:6 — block-style namespace. .editorconfig sets csharp_style_namespace_declarations = file_scoped:warning; 288/290 files under c-sharp/ are file-scoped. Silently unenforced off ai/* branches, and the whole body is over-indented as a result.
17. ParatextProjectSendReceiveService.cs:26 — internal with no consumer outside the class; the only non-private s_ field in the tree (the other 39 are private, and wider-visibility statics use PascalCase); 103 chars against the 100-char limit, which csharpier won't reflow because the overflow is a trailing comment.
18. Two #region Protected properties and methods with the identical name (:80, :102). Pre-existing, but this PR grows the first from one member to four, entrenching it. Three-line deletion.
19. Public-core comments describe the closed-source patch's internals — :91 documents "the patch's shared sync wrapper (the single helper both the manual and the scheduled sync command paths funnel through)", and the new test file enumerates the overlay's test-file naming scheme. These can't be verified from core and rot silently.
Reuse / simplification
20. RefreshAndNotifyProjectsChanged duplicates OnProjectDirectoriesChanged — one should delegate to the other. That single change also closes both inline blockers below.
21. OpenRpcNotificationDocumentation re-declares all four members of OpenRpcMethodDocumentation verbatim, attributes included, where the TS source models it structurally as Omit<Method, 'result'>. Description on the new record is never assigned anywhere.
22. The event's param is hand-rolled where Param(...) — already using static-imported in that file and used 60 lines below — returns exactly the same descriptor. There's also no ExperimentalNotificationDocumentation factory, so the event path hand-writes what the method path has a helper for, and no PapiClient.RegisterEventAsync wrapper, so network:registerEvent is assembled inline.
23. Positional null for requestTimeout at SendReceiveBlockNotifierService.cs:150 vs named documentation: in the sibling file — the same call written two ways in one PR, and the two spellings of "nothing special here" carry opposite meanings.
24. Prose duplication: the breakSyncLock contract exists in 3-4 near-verbatim copies and has already drifted in this commit (only the TS copy mentions null/blank ids and that false means "not broken", not "attempt failed"); the PT-4265 caveat is restated five times across three files; the non-recursive-watcher rationale three times, while being deleted from SyncProjects — the one place an implementer of that method would read it.
Separately worth a decision: the OpenRPC schemas published here are weaker than the TS contract they mirror (array with no items, object with no additionalProperties), so rpc.discover can't actually validate the seam it exists to describe. And getAutoSyncBlocking / onSyncWriteLockChanged / SyncWriteLockSnapshot are core-owned but declared inside the extension's module namespace, so the file's own "re-sync from upstream" rule has no clause protecting them.
…eout, awaited baseline emit, doc truth-up Four approved fixes from timothy-mccormack's 2026-07-29 review round: 1. LocalParatextProjects: RefreshAndNotifyProjectsChanged now carries the best-effort contract itself (refresh in try, notify after catch — a refresh throw must not suppress the notify) plus the class-convention _disposed early-return guard, and OnProjectDirectoriesChanged delegates to it so the inline and watcher paths share one funnel. New test pins the delegation. 2. ParatextProjectSendReceiveService: the syncProjects registration now carries s_sendReceiveTimeout like breakSyncLock, and the field's comment covers both rationales (multi-minute whole-project syncs; the lock-break's potentially N x ~100s server calls) and states that 0 is deliberately unbounded — only a lost response on a live socket waits forever, a risk every S/R command shares. 3. SendReceiveBlockNotifierService: the init baseline snapshot emit is now AWAITED (best-effort, logged on failure) so the startup barrier (Program.cs's critical Task.WhenAll) guarantees the baseline emit precedes any command-driven gate arm's event; InitializeAsync doc notes the ordering guarantee. 4. Docs, three sites (TS seam NetworkEvents entry, the C# class doc, auto-sync-blocking-service.ts): "plain Platform.Bible never emits it" was falsified by the baseline emit — now phrased as "the gate never arms in plain PB; the only plain-PB emission is a single not-blocking baseline snapshot at backend (re)start". BlockStateChanged never firing in plain PB remains stated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187X1kRaKEf634749ZDqh5A
|
Re-reviewed at 3178580. Detail is in the six threads above; short version: Satisfied (4/6): the refresh/notify funnel + Needs one more pass (1): the awaited baseline emit. Awaiting it is a genuine improvement, but the new Correction from me (1): in my Nothing else in the 24 non-blocking items was expected to move in this round, and the paratext-10-studio#168 lockstep dependency is unchanged (still a draft). |
…@PARAM docs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187X1kRaKEf634749ZDqh5A
|
The round-4 refresh/notify funnel looks right — One related race is still open in the same file, and it gets easier to hit now that the funnel is meant to be called from arbitrary worker threads rather than just papi-request threads and the watcher timer.
if (_disposed) // :248 — read here
return;
lock (_notifyLock) // :250 — lock taken here
{
_notifyDebounceTimer ??= new Timer(_ => EmitProjectsChanged());
_notifyDebounceTimer.Change(s_notifyDebounce, Timeout.InfiniteTimeSpan);
}
The second one is the nastier variant since it survives past Moving the Happy to open this as a separate issue if you'd rather keep this PR focused. |
Task.WhenAll does not order its members, and the S/R command registrations share the startup barrier with this service, so a mid-barrier command can arm the gate and emit before the baseline — the previous doc claimed the barrier made that impossible. State what actually holds: the emit attempt completes before the barrier returns; post-barrier arms emit strictly behind it (single connection, FIFO); a mid-barrier arm that beats the baseline is reflected in it (GetBlockState() is a live read at emit time) rather than overwritten by a stale not-blocking snapshot; late subscribers seed via getAutoSyncBlocking. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187X1kRaKEf634749ZDqh5A
NotifyProjectsChanged and ScheduleProjectDirectoriesChanged checked _disposed before taking their debounce lock, while Dispose tore the timers down outside those locks - so a call racing Dispose could Change an already-disposed timer (ObjectDisposedException) or, worse, construct a brand-new timer after Dispose that nothing ever disposes and that later emits against a disposing PapiClient. Move each _disposed check inside its debounce lock and dispose each timer under that same lock, making check-then-schedule atomic with the teardown (deadlock-free: no timer callback holds these locks while waiting on Dispose, and Timer.Dispose() does not block on in-flight callbacks). Also give EmitProjectsChanged a _disposed back-off as a second line of defence for a callback already in flight at disposal. The race window (between check and lock) is not reachable deterministically from the existing test seams, so the invariant is documented on Dispose instead of pinned by a test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187X1kRaKEf634749ZDqh5A
A null projectIds array is out of contract (the type is string[]); implementations treat it like the empty array (no-op). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187X1kRaKEf634749ZDqh5A
|
🤖 Claude: Answering the re-review roll-up and the The two open halves are answered in-thread:
Four post-review commits are on the branch. Three are doc-only: On the |
|
Re-reviewed at e14e830. All six of my blockers are now resolved or downgraded — nothing from my review is holding this PR.
Two things outstanding, neither a code objection to this PR:
The other 23 non-blocking items from my review body stand as follow-up material, not merge conditions. |
…completes A sync (or any early writer) completing before Initialize() has set up the ScrTextCollection would refresh an uninitialised collection and broadcast a bogus project-list change. Guard alongside the existing _disposed early-out with the same lock-free read Initialize's own fast path uses; Initialize's scan supersedes any call skipped here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
timothy-mccormack
left a comment
There was a problem hiding this comment.
Approving at b5d544a. All six blockers from my review round are resolved, and the two commits since my last pass closed the remaining open item and a non-blocking one on top of it.
Re-reviewed since e14e830 — three commits I hadn't seen when I said this looked ready, so I went through them rather than approving the older head:
b5d544acloses the last open item from the_disposedthread:RefreshAndNotifyProjectsChangednow no-ops untilInitialize()completes, so an early writer can't refresh an uninitialisedScrTextCollectionand broadcast a bogus project-list change. The accompanying test is well built — it proves the absence of an event over a 3x-debounce window and carries a control (NotifyProjectsChangedstill emits pre-Initialize) so the zero-count assertion can't pass because of a client that records nothing. That's the failure mode most "assert nothing happened" tests miss.f2c01e0goes beyond anything I raised: moving the_disposedreads inside_notifyLock/_projectChangeLock, and disposing each timer under the same lock its scheduler checks under, makes check-then-schedule genuinely atomic with teardown. The previous shape really couldChangea disposed timer, or construct a fresh one afterDisposethat nothing would ever dispose. I worked the deadlock argument through independently and it holds: the locks are never nested, no timer callback holds either lock while waiting onDispose, and parameterlessTimer.Dispose()doesn't block on in-flight callbacks. The added_disposedcheck inEmitProjectsChangedis the right second line of defence.d3bec7bresolves the null-array contradiction I raised in the review body — the@paramdoc no longer promises tolerance thestring[]type doesn't admit, and instead states null is out of contract with lenient implementations.
CI is green across all six checks on this head.
Two notes that do not affect the approval.
Merging still has a hard external gate. paratext-10-studio#168 is still a draft. repo-patches/paranext-core.patch on that repo's main carries the deleted CS9113 pragma as hunk context and adds its own breakSyncLock registration and body, both of which this PR now adds to core — so landing this first produces git apply --3way conflicts and then CS0111 duplicate members, breaking every Studio build and the patch-apply CI until #168 lands. This approval is of the code, not a signal to merge ahead of that.
One footnote, deliberately not a request. The new if (!_isInitialized) return; is a lock-free read of a non-volatile field. That exactly matches Initialize()'s own double-checked fast path, which is why I'm not asking for a change — it's the established pattern here, the CLR's implemented memory model gives stores release semantics on the platforms .NET targets, and the benign direction (reading stale false) merely skips a refresh that Initialize's own scan supersedes. Worth knowing it rests on that rather than on volatile, if this area is ever revisited.
The remaining items from my review body stand as follow-up, not merge conditions. If any get scheduled, my order would be the discarded RegisterRequestHandlerAsync result (a failed registration currently ships silently), the RunTask fault-only continuation being mis-bound repo-wide, and the breakSyncLock upper-case key contract having no consumer-side enforcement — that last one now filed on paratext-bible-internal-extensions#192.
Nice work on the review rounds — the funnel and the timer-disposal fix both came back better than what I asked for.
Post-merge-window cleanup from the PT-4158 triage (2026-07-27). Two stacked commits:
1. Declare the held-back sync-block seam (PT-4214)
getAutoSyncBlocking(command),onSyncWriteLockChanged(event) and theirSyncWriteLockSnapshotpayload type are now declared insrc/@types/paratext-bible-send-receive/index.d.ts. They shipped in #2574 served from C#, but were deliberately left undeclared to avoid colliding with #2570's seam re-home; #2570 has merged, so the reason is gone (flagged in the 2026-07-24 PT-4214 code-state audit). The renderer'sauto-sync-blocking-servicedrops its local type duplicate and its untypedrequestNoRetryworkaround in favor of the typedsendCommand/ non-deprecatedgetNetworkEventoverload; its fail-safe unknown-payload validation is unchanged. All new declarations carry@experimental.2. Public
breakSyncLockstub (PT-4210 Task 3)paratextBibleSendReceive.breakSyncLockexisted only in the Paratext 10 Studio patch, deviating from the public/private seam conventioncancelSync/syncProjectsfollow. This adds the public C# stub (PlatformUnimplementedException, sibling-identical phrasing) and the@experimentald.ts declaration, wire-signature-matched to the real implementation ((projectIds: string[]) => Promise<{ [projectId: string]: boolean }>). Two deliberate deviations from the patch: the stub is non-async(an async throw-only body would emit CS1998; the private body-fill re-addsasync), and the indefinite-timeout registration arg stays patch-side (it is a patch-only constant, as forsyncProjects).Pairing: paranext/paratext-10-studio#168 converts the patch from command-registration to stub body-fill and must merge only after this PR. No behavior change in plain Platform.Bible — the stub throws exactly like its siblings.
Verification: typecheck 0 errors (core + all workspaces); vitest auto-sync-blocking suites 35/35;
dotnet build0 errors, no new warnings;dotnet test --filter SendReceive69/69; papi.d.ts regen run → zero diff (the ambient seam is not part of generation); eslint/prettier/csharpier/cspell clean.🤖 Generated with Claude Code
https://claude.ai/code/session_019MhYcp1XVTQ7JHiA4n2qaV
This change is
Review fix round (2026-07-27, tip
d12946c999b): self-review (/review-paratext, 4 passes) found 0 critical findings. Applied: wire-side experimental docs (ExperimentalMethodDocumentation.Create) on thebreakSyncLockandgetAutoSyncBlockingregistrations;breakSyncLockdocs now state it targets the server repository lock (not the local write gate) and that an empty array is a no-op (verified against the private implementation); the stale PT-4214 tracking comment inauto-sync-blocking-service.tsnow points at PT-4265. Re-verified: build 0 errors, 69/69 filtered C# tests, typecheck clean, 14/14 renderer tests.Follow-up (2026-07-27, tip
4fcaa9f7e96): adds aLocalParatextProjectsconstructor parameter (patch-bound per the documented CS9113 convention in this class) so the Paratext 10 Studio patch can notify project-list consumers after a sync completes — in-placeSettings.xmlrewrites and mid-clone states are invisible to the non-recursive directory watcher (per its own doc comment), leaving the app-lifetime toolbar picker and open New Tab views stale. No behavior change in plain Platform.Bible (the stub never uses it). The paired patch-side call lands in paranext/paratext-10-studio#168. Build 0 errors, 69/69 filtered C# tests.Review round 2 (2026-07-28, tip
1159cb1de06): all 26 inline findings + review notes processed (verified-then-acted). Highlights: stub-side indefinite timeout (kills the patch's fragile registration hunk); public virtualLocalParatextProjects.RefreshAndNotifyProjectsChanged()+ retargeted stub instruction; init-time snapshot emit; wirex-experimentalon the event via new notification-doc records;Task.FromExceptionstub; per-source payload-validation latch; cold-start-race doc rewrite (+warn); doc-assertion tests (new stub-docs test file); contract docs (upper-cased keys, skipped ids,false= not-broken); seam-header re-sync guard; CS9124 eliminated; and D2(b) — core-declared patch-bound properties replacing the CS9113 pragma. Build 0 errors, 73/73 filtered C#, 57/57 renderer tests.