Repository navigation
fix(guard): the observable→Task bridge guard enforced a SPELLING — make it detect the thing - #2762
Conversation
… thing
ObservableToTaskBridgeGuard matched the literal text `.ToTask(` and claimed
src/ was at zero "with no escape hatch". Measured 2026-08-30, that claim was
FALSE as enforced, in two independent ways:
* src/MeshWeaver.Reactive.Assertions/ReactiveWait.cs hand-rolls the bridge
from a TaskCompletionSource + Subscribe — no `.ToTask(` anywhere in it.
* src/MeshWeaver.Mesh.Contract/Services/MeshServiceExtensions.cs declares a
method LITERALLY NAMED ToTask and calls it as
`ToTask<bool>(service.DeleteNode(path), ct)`. The marker's leading `.` —
documented as deliberate — walked straight past it. It was also the UNSAFE
form (bare `new TaskCompletionSource<T>()`), i.e. a live inline-resumption
hazard on hub-reachable in-mesh callers.
Detection is now STRUCTURAL and name-independent: a TCS settled from inside a
Subscribe callback; a Task-returning method over an IObservable<> that builds
its own completion source; an IObserver<> implementation that settles one.
Two tiers, deliberately different in strength:
* UNSAFE form (no RunContinuationsAsynchronously) — ZERO, no register, no
allow file. That flag IS the #2377 defect.
* SAFE form — only the entries in SanctionedBridges, each machine-verified to
still exist, still contain a bridge, and still be the safe form, so an entry
cannot outlive its subject.
Where the line is, stated in the guard and pinned by its tests: this bans
BRIDGING, never the Task type. IIoPool, Orleans grain signatures, a TCS used as
a plain lifecycle signal (MessageHub.hasStarted) and a mere CONSUMER of the
sanctioned bridge (HubDisposalJoin.JoinDisposalAsync) all pass.
Also fixes the one real defect found: MeshServiceExtensions.ToTask now
constructs with RunContinuationsAsynchronously.
Blocking bridges (.Wait() / .GetAwaiter().GetResult()) in production roots get a
seeded ratchet rather than a zero — they did not start clean (9 sites, 5 files),
and BlockingBridgeInTestRatchetGuard had never scanned production, on the stated
grounds that src/ is covered "by the reviews that enforce it". Reviews are not a
gate; that remark is corrected in the same change.
Non-vacuity is proved, not asserted: the guard builds a real temp tree with a
real production root, drops a hand-rolled bridge into it containing no
`.ToTask(` at all, and drives the same scanner over it — asserting the unsafe
fixture is caught AND classified unsafe, the safe one caught AND classified
safe, and a consumer NOT caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR strengthens ObservableToTaskBridgeGuard so it detects observable→Task bridges by structure (e.g., TaskCompletionSource settled from inside Subscribe) rather than by the .ToTask( spelling, and adds a separate ratchet for blocking bridges (.Wait() / .GetAwaiter().GetResult()) in production roots.
Changes:
- Reworked
ObservableToTaskBridgeGuardto structurally detect hand-rolled observable→Taskbridges, distinguish safe vs unsafe forms, and verify a sanctioned-bridge register. - Added a seeded ratchet allow-list for blocking bridges in production roots (
test/ProductionBlockingBridgeSites.allow) and corrected the related test-only guard remarks. - Updated documentation (
RemovingObservableToTaskBridges.md) to capture the “spelling vs thing” lesson, the boundary conditions, and the ratchet model.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/ProductionBlockingBridgeSites.allow | Seeded allow-list for blocking-bridge sites in production roots with a “may only shrink” budget. |
| test/MeshWeaver.Documentation.Test/ObservableToTaskBridgeGuard.cs | Implements structural bridge detection + safe/unsafe classification + sanctioned register + production blocking ratchet. |
| test/MeshWeaver.Documentation.Test/BlockingBridgeInTestRatchetGuard.cs | Updates remarks to reflect production blocking-bridge scanning is now gated elsewhere. |
| src/MeshWeaver.Mesh.Contract/Services/MeshServiceExtensions.cs | Fixes the previously-unsafe TaskCompletionSource construction by using RunContinuationsAsynchronously. |
| src/MeshWeaver.Documentation/Data/Architecture/RemovingObservableToTaskBridges.md | Documents the updated guard approach and rationale (structural detection, boundaries, seeded ratchets). |
Suppressed comments (1)
src/MeshWeaver.Mesh.Contract/Services/MeshServiceExtensions.cs:66
ToTaskclaims to complete with the first emitted value, but the subscription is never disposed afterTrySetResult/TrySetException. That can keep the observable pipeline alive (and keep consuming values/errors) after the returned Task is already completed, leaking resources and potentially hiding late faults. Dispose the subscription on OnNext/OnError/cancellation using aSingleAssignmentDisposableso synchronous-on-subscribe sources are handled safely.
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
var sub = observable.Subscribe(new SingleObserver<T>(tcs));
if (ct.CanBeCanceled)
ct.Register(() => { tcs.TrySetCanceled(); sub.Dispose(); });
return tcs.Task;
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Review finding on #2762, and it is this PR's own defect class one level down: `new\s+TaskCompletionSource` missed the fully-qualified `new System.Threading.Tasks.TaskCompletionSource<T>(…)` (and `global::`) and the target-typed `TaskCompletionSource<T> x = new(…)`. Missing a CONSTRUCTION is worse than missing a call site. IsSafeForm asks "do ALL constructions carry RunContinuationsAsynchronously?", so a construction the regex cannot see makes the answer vacuously YES — the unsafe-form zero rule then passes having checked nothing. Closed by test, not by a claimed regex: the negative control now carries a fully-qualified fixture and a target-typed fixture, and asserts each is BOTH detected AND classified unsafe. Mutation-checked — narrowing the regex back makes the control fail with "the safe-form classifier called QualifiedUnsafeBridge.cs SAFE". No occurrence of either spelling exists in the tree today, so this is pure hardening with no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI red on the merge ref, and the guard was RIGHT. #2750 landed src/MeshWeaver.Fixture/ObservableAwait.cs on main while this PR was open: the test tree's one wait, introduced when 1,538 `.ToTask(` call sites were swept. It is a hand-rolled observable→Task bridge in a production root, and it contains NOT ONE CHARACTER of `.ToTask(` — so the spelling-only rule this PR replaces would have waved it through. Only EveryProductionBridgeIsASanctionedImplementation fired; the unsafe-form rule stayed green because ObservableAwait is correctly built with RunContinuationsAsynchronously. That is exactly the two-tier split behaving as designed. Registered with its reason (same category as ReactiveWait: infrastructure whose purpose is to hand a test a Task, shipping from a production root because MeshWeaver.Fixture is where the test bases live). Merged main to pick it up — CI evaluates the merge ref, so the branch could not see it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
CI status: the guard is green; the one red is the known Run 1 (
|
Conserving the unverified items where the next person will trip on them, rather than leaving them in a session report: 1. A `using` alias (`using Tcs = …TaskCompletionSource<int>;` then `new Tcs()`) still evades the construction detectors — and the consequence is the bad one: IsSafeForm asks "do ALL constructions carry RunContinuationsAsynchronously?", so a construction it cannot see makes the answer vacuously YES. 2. MeshServiceExtensions' SingleObserver.OnCompleted is an empty body, so a source that completes WITHOUT emitting leaves the task pending forever. Deliberately unchanged (behaviour change; belongs with the port), but the shim is now the odd one out — ObservableAwait and ReactiveCompletion both settle on completion. 3. The shim's only real callers are 58 in-mesh Reinsurance sites that compile at RUNTIME, so the RunContinuationsAsynchronously fix was never exercised against a single real caller by any build or test in this repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… budgets #2764 fixed what the guard is SEEDED with (1,548 allowances against 2 actual, 19 stale memex/ entries, so it could not fail). This PR fixes what the guard can SEE. Both are needed; they are orthogonal. Resolution, agreed with the peer: this branch's ObservableToTaskBridgeGuard.cs wins wholesale, then theirs is re-applied for the seeding facts only — ProductionRoots now includes "memex", TotalBudget 1548 -> 2, and the re-seeded test/ObservableToTaskBridgeSites.allow (single entry, DisposalWaitBridgeTest 2). git auto-merged to exactly that; verified rather than trusted. 🚨 memex/ moved to ZERO TOLERANCE with no allow file on the strength of a TEXT marker. Checked it here with all three STRUCTURAL detectors before merging, because a tree emptied of `.ToTask(` is not the same as a tree free of hand-rolled bridges — that distinction is this whole PR. Result: memex/ is clean on every detector — 0 structural bridges, 0 blocking sites, 0 .ToTask(. The zero-tolerance promotion is safe, and now means what it says. Also corrects the root prose the merge made stale, in the guard and the doc: RatchetedRoots no longer claims to hold memex/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ion and ratcheted in tests (#2782) * feat(guard): hand-woven concurrency gates are held at zero in production and ratcheted in tests A SemaphoreSlim / ManualResetEventSlim / AutoResetEvent / CountdownEvent / Monitor.Wait parks a thread. On a hub action block or a grain turn that is the deadlock the mesh cannot recover from — the class /async names alongside await on a hub. AGENTS.md states the rule; nothing enforced it. Two tiers, the shape #2762 established: - ProductionRoots (src, tools, samples, clients, memex) — ZERO, no allow file. The only permitted gates are SanctionedGates entries, VERIFIED not listed: each must still exist and still contain a gate, so an entry cannot outlive its subject. - RatchetedRoots (test) — a seeded inventory that may only shrink. Measured on main's tip: 79 sites across 24 files, every one a ManualResetEventSlim, 30 of them in IoPoolTest.cs. TotalBudget 79. 🚨 The guard found a production violation on its first run that a src/-only grep had missed: tools/MeshWeaver.ThumbnailGenerator. Registered rather than converted, with the premise VERIFIED rather than asserted in prose — its csproj is an Exe referencing Microsoft.Playwright, SixLabors.ImageSharp and System.CommandLine and NOT ONE MeshWeaver assembly, so there is no hub, no grain turn and no IIoPool in that process and the defect cannot occur. The register re-reads that csproj on every run and FAILS if a MeshWeaver ProjectReference ever appears: an exemption whose PREMISE silently stops holding is worse than one whose subject moved, because the subject's absence at least fails loudly. `tools` stays in scope rather than being dropped, so a gate added to a tool that DOES reference the mesh still fails. The self-test plants a real tree and runs the REAL scan over it — .cs, .csx, prose, a string literal and bin/ — rather than matching the regex against strings. That distinction is the defect this repo found in three separate guards today. Verified by MUTATION, each arm proven able to fail and then restored: A new gate in src/ -> RED (production zero) A new file in test/ not in the allow list -> RED (NEW SITE) A listed test file's count grows -> RED (may only shrink) restored -> GREEN 4/4 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * re-seed against main's tip: #2770 added two gates while this PR was open The ratchet caught real drift on its first exposure to a moving main — IoPoolTest.cs 30 -> 32, from #2770 (`a pooled-I/O residual NAMES the leaf that would not unwind`) landing after this branch took its seed. Re-seeded rather than argued with: the allow file's own header says to seed against main's TIP, not the branch point, precisely because CI checks out the merge ref. TotalBudget 79 -> 81, with the reason recorded in the guard's remarks so the next reader does not read 81 as the original measurement. 🚨 This is the ratchet working, not a concession. Those two sites come back out in the IoPool drain-budget change, which lowers the budget to 79 again — the mechanism the file documents (delete a line, lower the budget by exactly that amount, in the same change). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
What was wrong
ObservableToTaskBridgeGuardmatched the literal text.ToTask(and its own remarks claimedsrc/was at zero "with no escape hatch". Measured today, that claim was false as enforced, in two independent ways:src/MeshWeaver.Reactive.Assertions/ReactiveWait.csTaskCompletionSource+Subscribe. Not one character of.ToTask(in it.src/MeshWeaver.Mesh.Contract/Services/MeshServiceExtensions.csToTaskand calls it asToTask<bool>(service.DeleteNode(path), ct). The marker's leading dot — documented as deliberate ("any.ToTask(in this repo is Rx's") — walked straight past a static call to a hand-rolled bridge of exactly the banned name.The second was also the unsafe form (a bare
new TaskCompletionSource<T>()), i.e. a live inline-resumption hazard whose remaining callers are 58 in-mesh MeshWeaver.Reinsurance layout-area sites — hub-reachable code, exactly where an inline resumption wedges a turn.What this changes
Detection is now structural and name-independent. Rename
ToTasktoFirstand it still fires:TaskCompletionSourcesettled from inside aSubscribecallback.Task-returning method over anIObservable<>that builds its own completion source.IObserver<>implementation that settles a completion source (the same bridge with its callbacks extracted into a class).Two tiers, deliberately different in strength:
RunContinuationsAsynchronously) → ZERO. No register, no allow file, no exemption. That flag is the LiveQueryHandoffDropTest fails post-warmup-fix (e2080c4fa) — new information per #2338's own ask #2377 defect.SanctionedBridges, each machine-verified to still exist, still contain a bridge, and still be the safe form — so an entry cannot outlive its subject. Four entries:ReactiveCompletion(the one real implementation),ReactiveWait,MeshServiceExtensions(retained debt, already governed byMeshServiceHasNoTaskShimGuard) andMessageHubGrain(grainTaskis contractual, and it must map an activation fault to a successful classifiedDeliveryFailure, whichObserveCompletioncannot express).The one real defect found is fixed:
MeshServiceExtensions.ToTasknow constructs withRunContinuationsAsynchronously. No signature change, no caller impact.🚨 Where the line is — this bans BRIDGING, never the
TasktypeStated in the guard's remarks and pinned by its tests, because getting it wrong would make the rule unshippable:
IIoPoolis the sanctioned async/IO boundary (measured: it constructs noTaskCompletionSourceat all).Taskby contract.TaskCompletionSourceused as a plain lifecycle signal is not a bridge —MessageHub.hasStartedis settled fromStart()/FailStartup()and is correctly invisible.Task-returning method that merely consumes the sanctioned bridge is not hand-rolling one —HubDisposalJoin.JoinDisposalAsyncpasses, and the negative control asserts it passes.What I deliberately excluded, and why
.Result— overwhelmingly a domain property here (ToolCall.Result,PatchResult.Result). A marker matching it would flag a hundred innocent reads to catch one bridge, and a rule that cries wolf gets suppressed, which is worse than not having it. Narrowing it to ".Resulton a task-returning expression" was considered and rejected: the narrowing needs a call graph to be sound, and a marker that is right most of the time is the same "spelling, not the thing" failure this PR exists to end..GetAwaiter().GetResult()is unambiguous and is matched..Wait(timeout)— matched only with empty parens. The bounded form is legitimate in the two places it appears:IoPool's_gate.Wait(DrainTimeout)(theSemaphoreSlimAGENTS.md sanctions insideIoPool) andHubDisposalJoin's deliberateTask.Wait(TimeSpan). The empty-paren spelling is the unbounded park.TaskCompletionSourceban — would red onMessageHub.hasStartedand onReactiveCompletionitself, i.e. on the very fix every failure message tells authors to use.Deviation from the brief (flagging explicitly)
The brief asked for
.GetAwaiter().GetResult()and.Wait(at zero, no allow file. Production roots do not start clean: 9 sites across 5 files (compiler, Orleans test-base disposal, plugin tester, a sample). Holding them at zero today would red on merge. They get a seeded ratchet (test/ProductionBlockingBridgeSites.allow) that may only shrink. This still closes a real gap:BlockingBridgeInTestRatchetGuardscanstest/only, on the stated grounds thatsrc/is covered "by the reviews that enforce it" — reviews are not a gate, and that remark is corrected in this change.Proof it can actually fail
Non-vacuity is proved, not asserted. The guard builds a real temp tree with a real production root, drops a hand-rolled bridge into it containing no
.ToTask(at all, and drives the same scanner over it — asserting the unsafe fixture is caught and classified unsafe, the safe fixture is caught and classified safe, and a mere consumer is not caught.Mutation A — restore the bare
new TaskCompletionSource<T>()inMeshServiceExtensions:Mutation B — revert detection to the
.ToTask(spelling alone:Deferred, by coordinator decision
Moving
MeshWeaver.Reactive.Assertionstotest/is NOT in this PR. It must land after #2750, which is rewritingtest/MeshWeaver.Reactive.Assertions.Test; a rename underneath it turns a clean merge into a path-rename conflict across 343 files. A peer worktree is also editingReactiveWait.csright now. The register entry carries that follow-up in its reason text, and goes stale — failing loudly — the moment the move lands.Framing note:
ReactiveWaitis exonerated as a design. It introduced no defect.NodeTypeCompileParkTestcarried a latent ordering assumption that Rx's bridge was masking by resuming the test inline;ReactiveWaitcorrectly queues that continuation, so the latent race became visible. Removing a bridge surfaces latent ordering bugs rather than creating them — that is now a section in the doc, and it is the strongest argument for the zero-tolerance rule. The pending move is layering, not a defect fix.Conserved
Doc/Architecture/RemovingObservableToTaskBridgesgains the spelling-vs-thing lesson, the where-the-line-is boundary, the new rule table, and the "removing a bridge surfaces latent bugs" section.No What's New entry — deliberate. This is agent/maintainer-facing tooling with no user-visible behaviour change.
Verification
dotnet build src/MeshWeaver.Mesh.Contract -c Release -warnaserror→0 Warning(s), 0 Error(s)(fresh assembly, not a no-op)dotnet build test/MeshWeaver.Documentation.Test -c Release -warnaserror→0 Warning(s), 0 Error(s)dotnet test test/MeshWeaver.Documentation.Test→ 109/109 passed, includingDocumentationLinkIntegrityTest(theMeshWeaver.Documentationproject rebuilt in the same run, so the edited embedded markdown was re-embedded first)🤖 Generated with Claude Code