Refusing a Lost User Action

A person clicked a button. The framework accepted the click, threw it away, and wrote one Warning five seconds later that reads exactly like routine stream churn. Nothing retried, nothing surfaced, and the next thing that looked for the result saw an absence indistinguishable from "you never clicked."

That is issue #3566, and it is the reason IUserAction exists.

What was measured

MeshWeaver.Education run 34042620439, job 101512796719 — the Store Install button.

15:44:38.03  Click … click action done                                (no error)
15:44:38.06  Navigate to "/Store"                                     ← 20 ms later
15:44:38.205 Circuit connection DOWN … disposing per-circuit portal hub
15:44:38.41  ClickedEvent arrives at e2e-admin/Packages — sync/{id} already gone
15:44:43.41  warn: Dropping ClickedEvent for stream JRGdthy… : no synchronization hub
             found on this hub or any parent — the target stream is gone

InstallPackage was never invoked. Attempt 1 installed nothing, and nothing was red.

Two facts make this more than "the circuit was gone, so of course":

The denominator: Dropping ClickedEvent appears exactly once in that run — that click — and zero times in run 34080328179, where the same install succeeded.

The scope call, and why it went the way it did

The issue names two coherent answers and picks neither. Both were examined; the first turns out not to be implementable as written.

"Deliver it anyway" — measured, and false as stated

routing the event to the target hub without requiring a live sync hub would make the click land

It would not. Three things have to be true for a ClickedEvent to run, and the disposal takes all three away at once:

  1. The handler is LayoutAreaHost.OnClick, registered on the per-stream sync/{id} sub-hub — it is the only ClickedEvent registration in the framework. The owner hub itself (e2e-admin/Packages) has none, so an event routed there would be Ignored, not run.
  2. The action is control.ClickAction, a closure held in the EntityStore snapshot of the LayoutAreaHost that was just disposed with the stream.
  3. The handler's own filter is Stream.ClientId.Equals(delivery.Message.StreamId) — it is scoped to the departed subscriber by construction.

Making the click land therefore means re-materialising a layout area for a subscriber that no longer exists: re-running the view function against state that has since moved, under an AccessContext nobody is holding. That is a different design with its own answer to "whose identity runs this", not a routing tweak. It is deliberately not what this change does.

"Refuse it visibly" — what shipped

The drop stays a drop. What ends is its silence, on both of the audiences that exist:

audience before after
the person, when anything of theirs is still attached nothing a DeliveryFailure to the sender, which a live portal hub raises as the standard error modal (PortalErrorReportingPortalErrorSink)
the operator one Warning worded as stream churn one Error naming the action, the area and the stream, and saying the action did not run and never will

🚨 In the #3566 timeline itself the circuit was already gone, so the modal reaches nobody — and that is honest, not a gap. The person had navigated away; there is no live surface to write to, and inventing one (a cross-session notification for a lost click) would be a feature, not a fix. The other two ways into the same code path — a released read stream and a reaped sync hub on a client that is still there — do have a live sender, and those are exactly the cases where somebody is looking at a page that silently did nothing.

The one line that separates the two classes

public interface IUserAction : IRequest<UserActionAccepted>
{
    string ActionArea { get; }
}

ClickedEvent, BlurEvent and CloseDialogEvent implement it. Nothing else does, and nothing about routing or handling changes — it exists only so that DataExtensions.RefuseStreamMessage can ask one question:

That asymmetry is the whole design. It is also what makes the change measurable: the regression test asserts the refusal and asserts that a data frame on an identically-gone stream still produces nothing (DroppedUserActionIsRefusedTest).

Why ErrorType.Rejected

Not NotFoundPortalErrorReporting swallows a routing NotFound as benign churn, and would swallow this with it. Not ShuttingDown — that is the transient "the address may come back, ride it out" verdict, and this address is not coming back for this stream. Rejected is what it is: the framework explicitly declined to run the action.

Why the log line is Error

Log levels are a production cost model, not a debug dial, so a level is only ever raised with the trade stated. Here it is: a lost user action is work the platform accepted and threw away, and it is rare by construction — once in run 34042620439, zero times in the green run. Neither of the two volume complaints applies: StreamEndedEvent (the "we get tons of this" case, maintainer 2026-09-01) stays at Debug, and data-sync frames stay at Warning. Only the user-action class rises.

The sentence a person reads

error.userActionNotRun, resolved from the catalog against the acting user's AccessContext.Locale — the one carried by the delivery being refused — never an ambient culture, which on Blazor Server is the container's and identical for every simultaneous viewer. See Localization.

The ordering fix that followed

The visible refusal closed the silent-failure half, but it did not stop an accepted action losing a race with circuit teardown. That second half is issue #3986 and is now an acknowledgement protocol:

  1. IUserAction is an IRequest<UserActionAccepted>.
  2. The Blazor sync hub uses Observe to register the response callback before it posts the click, blur, or dialog dismissal.
  3. The owner-side LayoutAreaHost posts UserActionAccepted only after its stream-scoped handler has accepted the action.
  4. A circuit close reaches the sync hub's existing Quiescing phase and sees that callback as pending. It therefore keeps the stream subscription alive until the receipt lands, then disposes normally.

There is no retry, grace extension, timer, or second disposal gate. The receipt makes the accepted action part of the lifecycle mechanism the hub already drains. An action whose stream was genuinely gone before it arrived is still refused by the path documented above.

🚨 Step 4 was only half true, and the half that was missing is the one that loses the click

A pending callback is drained in Quiescing, and Quiescing is a phase of the HUB. So the acknowledgement orders ahead of the release only if the release is posted from a point that comes after Quiescing. It was not.

The release is one line — the UnsubscribeRequest that destroys the owner-side sync/{id} sub-hub, registered in JsonSynchronizationStream.CreateExternalClient. It was registered on the stream:

reduced.RegisterForDisposal(new AnonymousDisposable(
    () => hub.Post(new UnsubscribeRequest(reduced.StreamId), o => o.WithTarget(owner))));

and SynchronizationStream.Dispose() disposes its registrants synchronously, and deliberately before Hub.Dispose() — that ordering is #1613's own fix and is correct for what it was for (it is what removes the pending SubscribeRequest callback promptly). Its cost here is that the whole disposal ordering runs before the hub has a phase in which to wait. So the two teardown routes behaved differently:

route what disposes first did the receipt order ahead?
the per-circuit portal hub disposes its hosted sync/{id} the HUB — streamDisposables run from its DisposeImpl in ShutDown yes, Quiescing came first
the STREAM is disposed directly — a workspace eviction, ReclaimIfUnheld, EvictClientSubscriptions, a consumer's .Finally(stream.Dispose) the STREAM — synchronously, ahead of Hub.Dispose() no

The second route is not an edge: released read stream is one of the three ways into RefuseStreamMessage this page already names, and it is the one where the person is still sitting in front of the page.

The fix is where the line is registered, not what it does. It now goes on the stream's hub, so it runs from DisposeImpl in ShutDown — strictly after Quiescing — on both routes:

var release = new AnonymousDisposable(
    () => hub.Post(new UnsubscribeRequest(reduced.StreamId), o => o.WithTarget(owner)));
if (reducedHub is not null) reducedHub.RegisterForDisposal(release);
else                        reduced.RegisterForDisposal(release);   // no hub left to wait in

Nothing new waits, nothing is delayed "to be safe": the release simply sits behind the drain the hub already performs.

The sender is a surface, not a call shape — stream.SubmitUserAction(...)

The ordering above is only armed if the sender registered the callback, which Post does not do. So the acknowledged send is a named surface — UserActionSubmission.SubmitUserAction, an ISynchronizationStream extension — rather than an Observe incantation copied into every view that raises a click. It carries the acting user's AccessContext (a user action must; the sync hub has no identity of its own), owns its own subscription, and hands a refusal to the caller as the already-localized error.userActionNotRun sentence.

🚨 And a second thing the refusal was doing, which nobody had measured

A refusal is a DeliveryFailure posted back to the sender, and the sender of a click is the stream's own sync/{id} hub — whose ConfigureSynchronizationHub carries a blanket DeliveryFailure handler that answers OnError for anything that is not a transient ShuttingDown. So a bare Post of a click that cannot be delivered does not merely lose the click: it faults the whole synchronization stream, and every view bound to that mirror dies with it. Measured on a real fixture — the stream terminated with

DeliveryFailureException: Your last action (“ProbeArea/Button”) did not run — the view it was
sent from had already closed. Nothing was changed; please try again.

DroppedUserActionIsRefusedTest could not see this: it posts from the client HUB, so its refusal never reaches a stream's handler.

🚨 Registering the callback is not on its own enough, and assuming it was cost one wrong claim. HandleCallbacks runs FIRST in the rule chain and then the chain keeps running, so a matched response reaches the blanket handler as well — the fault still fired. What the match does leave behind is the flag the framework already uses for exactly this: PostOptions.CallbackDispatched, which PortalErrorSink has long consulted so a failure the call site's OnError handled is not also popped as a modal. The sync hub's DeliveryFailure handler simply never adopted it. It does now, as the same one-line filter:

(_, delivery) => !delivery.Properties.ContainsKey(PostOptions.CallbackDispatched)

An un-awaited failure — the subscribe protocol, an RLS denial, a NotFound — still faults the stream exactly as before. Only a failure somebody is already holding is left to them.

The order in which this was found is worth keeping: the "does not fault" half passed in a filtered run and failed in the full suite, because the test's fault probe was a bare Subject and the fault landed before the assertion window opened. A replay-backed subject made the observation honest, and the honest observation falsified the claim. ARefusedActionSurfacesToTheCallerWithoutFaultingTheView pins both halves.

The measurement

UserActionOutlivesStreamReleaseTest asserts both directions against real hubs and a real remote stream, with the owner-side sync/{id} sub-hub's own DisposalCompleted as the instrument:

test asserts goes red on
AnAcceptedActionHoldsTheReleaseUntilTheOwnerAnswers the owner's sub-hub does not die while an action is owed, and does die once it is answered the defect
AnOrdinaryReleaseIsPrompt a release with nothing owed still reaches the owner "never release the stream", which would satisfy the first test alone
AnActionOnALiveStreamStillRuns the acknowledged path still INVOKES the action an ordering guarantee that stopped delivering clicks
ARefusedActionSurfacesToTheCallerWithoutFaultingTheView a refusal reaches the caller as the catalog sentence, and the mirror stays live a refusal that is swallowed, re-worded, or still faults the stream

The owed-work window is made deterministic rather than raced: the action names a stream id with no sync/{id} on the owner, so the owner holds it for SyncStreamOptions.SyncHubRegistrationGrace (400 ms in the test, well inside the hub's 2 s Quiescing budget) and then refuses — the real reaped-sync-hub shape. Falsified by re-registering the release on the stream and rerunning: AnAcceptedActionHoldsTheReleaseUntilTheOwnerAnswers fails at 200 ms"Expected the observable not to emit … but it emitted ()" — while the other two stay green.

The sender half — where it was, and what moving it actually took

The Blazor senders live in MeshWeaver.Plugins, and until they moved the ordering above was armed but unused: a bare Post registers no callback, so Quiescing had nothing to drain and the release went straight through.

🚨 The list below was re-derived against Plugins main rather than taken on trust, and the denominator is what a reader needs. The instrument is not Stream.Hub.Post — that literal appears nowhere in the repo except inside one comment. The honest denominator is every construction of a type implementing IUserAction (ClickedEvent, BlurEvent, CloseDialogEvent — and those three are the whole set, so the sweep is closed):

grep -rn "new ClickedEvent\|new BlurEvent\|new CloseDialogEvent" \
     --include='*.cs' --include='*.razor' --include='*.json' .

🚨 State the denominator with the count, or the count is a claim about nothing. Measured on Plugins main 2026-09-11 (merge 05fde510): 19 constructions repo-wide, of which 8 in 7 production view files — the number the first pass guessed, reached the second time by a search that could have contradicted it. The remaining 11 are in .Test projects (Markdown.Collaboration.Test ×4, Persistence.Test ×3, Graph.Views.Test ×2, AI.Test ×1, Todo.Test ×1): they post from a test or CLIENT hub rather than from a view, so they are not senders and are correctly left alone. Zero outside src/ — no in-mesh Source/*.cs and no NodeType JSON constructs a user action, which is what closes the half of the sweep dotnet build cannot see. (An earlier revision of this page said six test hits. It was counting .cs under src/ with a narrower pattern; the number is 11.)

file action how it posted before
MeshWeaver.Blazor/BlazorView.razor.cs ClickedEvent — the one every control inherits Stream.HubOrNull() + AccessContext
MeshWeaver.Blazor/Components/FormComponentBase.cs BlurEvent Stream.HubOrNull(), no context
MeshWeaver.Blazor/Components/DialogView.razor.cs CloseDialogEvent, twice (OK and the dismiss path) Stream.HubOrNull(), no context
MeshWeaver.Blazor.Views/Components/DataGridView.razor.cs ClickedEvent carrying a DataGridCellClick payload the PORTAL hub, Stream!, no context
MeshWeaver.Blazor.GoogleMaps/GoogleMapView.razor.cs ClickedEvent the PORTAL hub, Stream!, no context
MeshWeaver.Blazor.AppleMaps/AppleMapView.razor.cs ClickedEvent the PORTAL hub, Stream!, no context
MeshWeaver.Blazor.OpenStreetMap/OpenStreetMapView.razor.cs ClickedEvent the PORTAL hub, Stream!, no context

🚨 "Each already resolves the hub defensively and already stamps the circuit user's AccessContext" was wrong, and the last column is why it matters. Exactly ONE of the eight stamped an identity. Four posted from the portal hub rather than the stream's, where an ambient context happens to be present during an inbound Blazor activity — so moving them to stream.SubmitUserAction moves the sender to a hub that has no identity of its own, and passing the acting user explicitly is not a nicety there but the thing that stops PostPipeline failing closed. Three of those four are [JSInvokable] callbacks, i.e. DEFERRED: CircuitAccessHandler has already nulled the ambient context by the time the browser calls back, so the live AsyncLocals answer nothing and the durable ICircuitContextAccessor.UserContext is what has to answer.

The move is therefore hub.Post(evt, o => …)Stream.SubmitUserAction(evt, ActingUser, SurfaceRefusal), against two new members on BlazorView that every one of the eight shares:

The HubOrNull() guards came OUT rather than being kept: SubmitUserAction answers the hub-released case (#3321 step 3) itself, with the same catalog sentence, which turns the silent return those guards performed into the refusal this whole page is about.

🚨 There was no platform pin to move. MeshWeaver.Plugins has carried none since #3842 — its platform-ref job resolves the newest SEALED core set at run time (scripts/resolve-platform.py), and the repo variable MW_PLATFORM_REF is an incident FREEZE, not a pin. The sender half's real gate is therefore a core RELEASE: it cannot compile until a sealed set carries this commit.

The Plugins-side controls

Core's UserActionOutlivesStreamReleaseTest owns the ORDERING proof, measured against the owner-side sub-hub's DisposalCompleted. What Plugins owes is the SENDER's properties, and UserActionSubmissionFromViewsTest pins them by driving the real BlazorView.OnClick — through the real Blazor renderer, over a real remote stream whose owner is a real layout area host:

test asserts falsified by
AClickWhoseStreamWasReleasedTellsThePersonInsteadOfVanishing the refusal reaches the circuit's sink as the catalog sentence for the ACTING USER's locale, and the action still did not run restoring hub.Post — the sink emits nothing at all
AnOwnerRefusalReachesThePersonAndLeavesTheMirrorLive an owner NACK becomes a sentence, and the stream does not fault restoring hub.Post — the sink is silent AND the stream terminates with DeliveryFailureException
AnOrdinaryClickStillRunsTheActionAndSurfacesNothing POSITIVE: the click still runs, and nothing is put in front of the person removing the submission — the action never fires
AReleaseWithNothingOwedStillReachesTheOwnerPromptly POSITIVE: the owner-side sync/{id} still dies on release removing the release registration — it never dies

Each falsification was built and run, and each reddened on its OWN assertion. The tallies are the part worth keeping, because they are what separates a control from a duplicate:

mutant result
restore hub.Post + the silent return (THE DEFECT) Failed: 3, Passed: 2 — both locales of the refusal theory and the mirror test red with "Expected the observable to emit a value within 36s … The observable emitted nothing at all"; both positive controls green
refuse everything, submit nothing Failed: 1, Passed: 2 — the refusal theory passes in both locales while AnOrdinaryClickStillRunsTheActionAndSurfacesNothing reds. This is the lazy "fix" only the positive control can see
drop the release registration ("never release") Failed: 1, Passed: 1AReleaseWithNothingOwedStillReachesTheOwnerPromptly reds, AnOrdinaryClick… stays green

The middle row is the reason (b) exists at all: a submission path that refused every click satisfies every defect-direction assertion on this page.

What this deliberately does not do

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.