Changelog
All notable changes to SignalARRR will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
[5.2.2] - 2026-10-10
Fixed
- A backplane stopped during its first heartbeat failed the shutdown (#88): the heartbeat loop treated a cancellation as shutdown only once it was ticking; the first iteration, run before the first tick, was not covered. A host stopped while that iteration was still in flight — a test host, a command-line run, a container restarted right after it came up, most likely on a cold start — got an
OperationCanceledExceptionout ofStopAsync(with the Postgres backplane: "Query was cancelled",57014), which a test host reports as a cleanup failure. The rest of the shutdown was skipped with it: the node stayed registered until its heartbeat lapsed, and the transport was not stopped. A stop during the first iteration is now a shutdown like any other, and the shutdown deregisters the node and stops the transport however the heartbeat loop ended.
[5.2.1] - 2026-10-04
Fixed
- A backplane stopped twice at once threw a NullReferenceException (#85):
StopAsyncchecked the heartbeat fields, waited, and read them again — a second, overlapping call found them already cleared by the first. A test host reports that as a cleanup failure of the whole test class. Stopping is now one shutdown shared by every caller: the first call runs it, every further one waits for the same one and returns only when it is done; a later stop is a no-op, and a restart begins afresh. The Postgres and Redis transports take their fields over before they wait, so they cannot be caught half torn down either. One way to get two stops is registering the backplane twice —AddSignalARRRPostgresBackplaneorAddSignalARRRRedisBackplanecalled once in the application and again in a test setup — which registered the same instance twice as a hosted service; it is now hosted once however often it is added.
[5.2.0] - 2026-09-30
Changed
- Swift and Kotlin send the connection token as a header on the transport, not in the URL: the WebSocket upgrade, the SSE stream and its posts and every Long Polling request now carry it as
Authorizationheader, like negotiate already did, instead of anaccess_tokenquery item. A token in a URL ends up in proxy access logs and error reports; a native client has no reason to put it there, only a browser does, because JavaScript cannot set a header on a WebSocket upgrade or anEventSource. The token is still fetched on every connect and reconnect. Standard ASP.NET Core authentication reads the header, so nothing changes on the server; one that reads the token only from the URL can be kept working with the newtransportCredentialoption set to.query(Swift) orTransportCredential.QUERY(Kotlin). UseSignalARRRAccessTokenValidationno longer overwrites anAuthorizationheader: it copied theaccess_tokenquery parameter into the header on every SignalR request, replacing a header the client had sent itself. It now only fills in a missing header; a request that carries one keeps it. The middleware is now documented, together with a table of where each client sends the connection token.
Added
WithConnectionBoundSchemes(...)on the server options builder:ConnectionBoundSchemeswas a list onSignalARRRServerOptions, butAddSignalARRRhands out the builder, which had no way to reach it — the documentedoptions.ConnectionBoundSchemes.Add(...)did not compile, and the only way in was casting the builder to the options. The authorization guide now uses the builder method.transportCredentialoption in the Swift and Kotlin clients (TransportCredential.header/.query), choosing where the transport carries the connection token. Defaults to the header.- The same credential options in every client:
connectionCredential,messageCredential, andcredentialfor one credential covering both —WithConnectionCredential,WithMessageCredentialandWithCredentialin .NET and the .NET Framework client, which gainsCreate(builder, options => ...)for them. The clients used five names for two things (AccessTokenProvider,accessTokenFactory,accessTokenProvider,WithAuthorization/authorization,messageAccessTokenProvider) and two different defaults: .NET and TypeScript kept the two credentials apart, Swift and Kotlin quietly used one for both — so the same hub behaved differently depending on the client, for instance one that could not answerChallengeAuthenticationduring a stream because its message credential was never set. The new options behave the same everywhere and couple nothing implicitly: setting one of the two sets only that one. A credential set in two places — a former option next to a new one, or the connection credential through both SignalR and SignalARRR — is an error instead of a silent choice: atCreate/createin .NET, TypeScript and Kotlin, fromstart()in Swift, whosecreatedoes not throw. In .NET and TypeScript the connection credential becomes SignalR'sAccessTokenProvider/accessTokenFactory, so it needs the overload where SignalARRR builds the connection —Create(builder => ...)in .NET, the newHARRRConnection.create(url, options, configure?)in TypeScript. The client guides and the authorization guide show the same table for every client: which credential does what, where it travels, and what happens when it expires. - Error codes in the TypeScript and Swift clients: the machine-readable
Codeof the error envelope, which .NET and Kotlin already exposed, so an app can tellunauthorizedfrommethod_not_foundwithout matching on the message. TypeScript'sinvoke()now rejects withcodeandnormalizedCodenext totypeandmessage(HARRRInvocationError), andHARRRErrorCodes/normalizeErrorCodeare exported; Swift'sHARRRErrorgainedcode,versionandnormalizedCode, withHARRRErrorCodes. Both accept an envelope by the same test as .NET and Kotlin — a version, a code, or a concrete type — so one that carries only a code is no longer dropped. - The HTTP status of a rejected negotiate in every client:
statusCodeon Kotlin'sNegotiationFailedException, on Swift'sSignalRError, and on the error TypeScript'sstart()rejects with — SignalR's JavaScript client mentions the status only in its message. .NET already had it asHttpRequestException.StatusCode. A 401 or 403 there means the connection credential was missing or rejected. headersoption in the Swift client: extra headers on the negotiate request and every transport request, as Kotlin has withheadersand .NET and TypeScript through SignalR's connection options. A header namedAuthorizationtakes the place of the connection token's.- Kotlin client (
dev.cocoar:signalarrr,dev.cocoar:signalarrr-ksp) for Android and the JVM — the fifth client, built the way the Swift one is: a full SignalR client of its own (negotiate, WebSockets, Server-Sent Events and Long Polling with fallback, JSON and MessagePack, keep-alive, server timeout, automatic reconnection) rather than a wrapper around Microsoft's Java client, which has no reconnect, no SSE and leaks RxJava into the API. Coroutines andFloware the async model:invokeandsendsuspend,streamreturns a coldFlowwhose cancellation cancels the server-side stream. The full SignalARRR surface is there — token challenges, structured errors with codes (HARRRException.code), server-to-client handlers withonServerMethod,registerInterfaceand stream handlers, HTTP stream references in both directions (ByteArray,File,InputStreamarguments are uploaded,Streamparameters arrive as bytes), and cancellation propagation in the idiomatic form: the handler's coroutine is cancelled when the server cancels, sodelayand every other suspending call just stops.@HubProxyinterfaces get a typed proxy generated at build time by a KSP processor;ProxyKindaddresses contract interfaces,ServerMethodsclasses and hub methods alike, and Kotlin's camelCase members map to .NET's PascalCase by default. The plainSignalRClientunderneath talks to any SignalR hub. Verified by 66 integration tests against the shared test server over all three transports, and by the unit suite. Documented under the new Kotlin Client section.
Deprecated
- The former credential options, which keep working with their former behaviour:
WithAuthorization(.NET) andauthorization(TypeScript) — the message credential, nowWithMessageCredential/messageCredential;messageAccessTokenProvider(Kotlin), which also covered the connection unlessaccessTokenProviderwas set — nowcredential, ormessageCredentialfor the message alone; andaccessTokenFactory:on Swift'sHARRRConnection.create(url:), one factory for both — nowoptions: HARRRConnectionOptions(credential:). In the .NET Framework client, theaccessTokenProviderparameter ofHARRRConnection.Createand of the constructor — despite its name the message credential, not SignalR's connection token — nowWithMessageCredential.
Fixed
- The Postgres backplane demanded the newest Npgsql for each target framework: the package was built against the central Npgsql version, which follows the target framework, so on net10.0 it required Npgsql 10 — and an application held on Npgsql 9 by Marten could not add it without a downgrade error (
NU1605). The backplane now builds against Npgsql 8.0.6 on every framework, the lowest it needs, and asks forNpgsql >= 8.0.6; the application picks the version. The integration tests still run it on Npgsql 8, 9 and 10. - Warning CS0436 in projects that see a referenced assembly's internal proxies: a test project of an API, with the API's
InternalsVisibleTo, got a second, locally generated proxy for each of the API's contracts next to the API's own, and a CS0436 per contract. The generator now leaves a contract out when the referenced assembly already contains its proxy and that proxy is visible; the referenced assembly registers it in its own module initializer, as before. - A project referencing both
Cocoar.SignalARRR.ClientandCocoar.SignalARRR.Serverdid not compile: each package carried its own copy of the source generator, so a project with both — typically an ASP.NET integration test project — ran the generator twice, got every proxy twice and failed with duplicate type errors (CS0101,CS0111). Client and Server no longer bundle the generator; both depend on theCocoar.SignalARRR.SourceGeneratorpackage instead, which NuGet resolves once however many packages ask for it. Nothing changes for projects that reference only one of the two, and contract projects that reference onlyCocoar.SignalARRR.Contractsstill get no generator and contain only their interfaces. Workarounds that removed one package's copy of the analyzer before compiling can be dropped. - The docs described the wrong credential in two places: file transfers are authenticated with the message credential, not the connection's
AccessTokenProvider/accessTokenFactory, and aChallengeAuthenticationis answered with the message credential too. - The Swift client could not connect over Long Polling: the server answers the first poll after negotiate at once and without data, which completes the connection on its side. The .NET, TypeScript and Kotlin clients send that poll when they open the transport; the Swift client did not, so its handshake read received the empty answer and failed with "Incomplete handshake response". Nothing had exercised Swift's Long Polling end to end until the transport tests of this release.
[5.1.0] - 2026-09-05
Added
Cluster subjects (
AddSignalARRRClusterSubject<T>("name"),IClusterSubject<T>): an observable whose events reach subscribers on every node. The backplane routed the target of a send but never saw the source of a server stream: a hub method returning anIObservable<T>fed by an in-process subject streamed only what its own process raised, and applications in the subscribe style had to build a relay themselves. A cluster subject relays each event over the backplane transport to the same-named subject on the other nodes. Once locally, once remotely, never echoed; in order per publishing node; fire-and-forget for the producer (PublishAsyncfor the awaited variant); the event type is fixed at registration so no type name travels on the wire and a mixed-build cluster drops what it cannot read, with a warning. Without a backplane it is a plain local subject. Delivery is the backplane's — transient with Redis, replayed after a subscription drop with the Postgres catch-up. The envelope kind is appended last, so an older node ignores it.Postgres backplane: publishes are serialized, so the catch-up cursor is exact. Message ids come from a sequence and are assigned at
INSERT, but a row becomes visible atCOMMIT; two concurrent publishes could therefore commit in the opposite order of their ids, and a node whose cursor had passed the higher id would never replay the lower one if it committed during that node's subscription gap. Every publish now goes through apublishfunction the schema script creates, which takes a transaction-scoped advisory lock before inserting and notifying — ids are assigned in commit order, the window is closed, and the cross-node order of messages matches the id order. No throughput is lost: Postgres serializes committingNOTIFYtransactions anyway. Upgrading from 5.1.0-beta.2 withWithAutoCreateSchema(false): runSignalARRRPostgresBackplaneSchema.GetCreateScript()again; it adds the function and is idempotent. With automatic schema creation nothing needs doing.PostgreSQL backplane: catch-up after a subscription drop. Every envelope now goes through the
messagestable, and each node keeps a cursor — the id of the last message it saw. When its listener connection drops and comes back, the node resubscribes first and then replays every row past its cursor addressed to it, in id order, before resuming live delivery; rows that arrive both ways are recognized by id and handed on once. A push that fell into the gap arrives late rather than never, for every kind of traffic the backplane carries. An outage longer thanMessageRetention(new option, default five minutes) is reported as a gap — a warning and thesignalarrr.backplane.catch_up.gapscounter — instead of being passed over silently.WithCatchUp(false)opts out and restores the Redis contract: inline notifications, no replay. Two more counters,signalarrr.backplane.listener.reconnectsandsignalarrr.backplane.messages.replayed, make the behaviour visible. The listener session now carriesapplication_name=signalarrr-backplane-listener:{nodeId}, so it can be told apart inpg_stat_activity. Themessagestable gainedorigin_node_idandtarget_node_id; the create script adds them to a table from 5.1.0-beta.1.PostgreSQL backplane (
Cocoar.SignalARRR.Server.Backplane.Postgres,AddSignalARRRPostgresBackplane): the same cluster behaviour as the Redis package — cross-node broadcasts, targeted calls, cluster queries, groups and presence — over the PostgreSQL primary an application already has, for deployments whose only stateful dependency is Postgres.LISTEN/NOTIFYcarries the traffic; the connection registry and node heartbeats are rows, and liveness is judged on the database clock so nodes need not agree on the time. Envelopes above the 8 kB notification limit travel through an unlogged table in the same transaction as the notification, in arrival order, invisibly to the caller. The schema is created on first start (WithAutoCreateSchema(false)plusSignalARRRPostgresBackplaneSchema.GetCreateScript()for migration-managed databases) and doubles as the isolation unit for applications sharing a database. Documented limits: primary only, a direct connection for the listener (no transaction-pooling PgBouncer), and a throughput ceiling in the low thousands of cross-node messages per second against Redis's hundreds of thousands — two orders of magnitude above what an application-level backplane sees in practice. The full multi-node test suite runs against both providers.Every documentation page carries a one-line description, so the docs site's
llms.txtis now an index an assistant can choose pages from rather than a bare list of links, and the site's "LLM Docs" link points at that index instead of the full dump.The agent skill in
Cocoar.SignalARRR.Serveris generated from the documentation (website/scripts/sync-skill.mjs, verified in CI): a hand-writtenSKILL.mdheader with the package table and the mistakes an assistant makes without the docs, plus every guide and reference page as a reference file, indexed by the same descriptions that feedllms.txt. It ships in askills/folder at the package root — not undercontentFiles, which would turn it into 26 linked files in every consuming project's Solution Explorer — and agentskills-cli installs it from there:agentskills-cli add Cocoar.SignalARRR.Server. The previous skill described 4.0 and linked reference files that did not exist.
Removed
- The server package no longer copies its skill into your repository on every build. Until now a
.targetsfile in the package wroteskills/signalarrr/into.claude/skills/and.github/skills/of any consuming project that had those folders — a silent write into your source tree, frozen at whatever the package contained. Install the skill deliberately instead (see above); files it already copied are yours to keep or delete.
Changed
- A lost Postgres listener connection is a warning, not an error. It is routine — a proxy idle-timeout, a failover, a network blip — and the node is resubscribed within a second, with catch-up replaying whatever was missed; logging it as an error with a stack trace fired every alert on every idle-timeout. The warning names the cause; the error, with the exception, is reserved for reconnects that keep failing (third consecutive attempt, about 3.5 s in).
Fixed
- A node restarting under a stable node id kept its predecessor's registrations alive: the new process refreshed the heartbeat, so the connections a crashed predecessor had registered under the same id were never swept — they were routed to, forever, and never answered. Both backplanes now clear whatever the store holds under their node id on startup; a fresh process serves no connections.
Internal
- The transport-independent half of the Redis backplane — envelope routing, invoke/response correlation, cluster query collection by node identity, the heartbeat loop with self-eviction recovery, and the stale-node sweep — moved into an internal base class in the server assembly. The Redis package now implements only its transport and its store; the Postgres package implements the same two and nothing else. Behaviour is unchanged; the contracts stay internal (AF-3).
[5.0.0] - 2026-08-30
Results of a full code review of the server and the .NET client. Several entries below are security fixes for paths that were reachable by any connected client — two of them without authenticating at all.
Most of this needs nothing from you. What does is collected in the migration guide, grouped by whether it is likely to affect you: the reshaped WithHub<T>() query and the moved entry points touch nearly everyone, a handful of removals only matter if you used the feature, and the rest is infrastructure that was never meant to be application-facing.
Security
- Inherited infrastructure members were registered as anonymously callable RPC endpoints:
ServerMethodsclasses were scanned with a bareGetMethods(), which also returns inherited members. Forclass Rooms : ServerMethods<MyHub>that registeredRooms.get_ClientContext,Rooms.set_Logger,Rooms.ToStringand the rest. None of them carry[Authorize], so authorization returned success — andget_ClientContextreturns the caller'sClientContext, i.e. principal, claims, client certificate, remote IP and attributes. The scan now takes only declared methods, excluding property accessors and anything declared onServerMethodsorobject. Members inherited from a user-defined intermediate base class are still included. [Authorize]was resolved fromDeclaringType, so inherited methods lost their protection: methods are registered from the concrete class, so for[Authorize] class AdminMethods : BaseCrudthe inherited method resolved against the undecorated base and came back unrestricted. Resolution now starts atReflectedTypeand walks its base chain.- Interface dispatch authorized the contract while the implementation ran: the stored
MethodInfowas the interface declaration, so[Authorize]on the implementing class was invisible and everyInterface|Methodcall was unauthorized unless the attribute happened to sit on the contract. The interface cache now resolves to the implementing method, and attributes on both sides are honoured. - Custom
IAuthorizeDataattributes were ignored: only the concreteAuthorizeAttributewas collected, althoughIAuthorizeDatais the ASP.NET Core contract. A custom attribute implementing it was silently skipped, leaving the method unrestricted.[AllowAnonymous]is likewise now detected throughIAllowAnonymousat method, contract, type and hub level — previously a class-level[AllowAnonymous]was ignored. - A client could escalate itself out of token expiry: connecting with a short-lived bearer token and then answering an authentication challenge with an empty string moved the connection to transport-level mode, where revalidation approved it against its own cached principal — which is authenticated by construction. Since the mode persists for the connection, expiry and revocation were never enforced again. Escalation now requires credentials genuinely bound to the connection (client certificate, or Negotiate/NTLM/Kerberos/Windows/Certificate), and revalidation additionally rejects any identity whose ticket states an expiry that has passed.
- The file transfer endpoints were anonymous:
downloadanduploadare ordinary HTTP endpoints, so the hub's[Authorize]never applied to them — and neither did a.RequireAuthorization()chained onto the mapping call, which configured the hub alone. Both ways of securing a hub now reach them: the hub type's authorization data is copied onto the endpoints, andMapSignalARRRHubreturns a builder that fans every convention out to the hub and its two transfer endpoints. That return type is nowIHubEndpointConventionBuilderrather than SignalR's concreteHubEndpointConventionBuilder; chaining is unchanged, since the methods people chain are extensions on the interface. - An upload slot could be consumed by a connection that did not request it: a slot recorded no owner, so any client that learned the URL — they travel through reverse-proxy and ingress access logs as a matter of routine — could name it as a
Streamargument and have another client's bytes bound into a method running under its own principal. Slots are now bound to the connection that requested them andWaitForUploadrejects anyone else, reporting a foreign slot exactly as it reports one that does not exist so the check cannot be used to probe which URLs are live. The upload POST itself still cannot be bound this way, because an HTTP request carries no connection identity — treat the URLs as secrets and do not log them. - A client could allocate upload slots without limit:
RequestUploadSlotis an ordinary hub method with no quota, and each slot pinned a dictionary entry and aTaskCompletionSourcefor the full expiration window whether or not anything was uploaded — so a loop grew server memory faster than the sweep could reclaim it, and disconnecting did not help because slots were not released with their connection. There is now a per-connection cap (MaxUploadSlotsPerConnection, default 32,WithMaxUploadSlotsPerConnection, 0 to disable) reported to the client as the newupload_slot_limit_reachedcode, and a connection's outstanding slots are cancelled when it disconnects. - Upload requests were fully buffered before the slot was validated: an anonymous POST to a non-existent slot id still forced the entire request body into memory. The slot is now checked first, and the copy is bounded by
MaxUploadSizeByteswhile copying rather than trustingContent-Length, which may be absent or wrong. - One client could hijack another's stream:
StreamItemToServerandStreamCompleteToServerlooked the stream up by id alone, with no check against the calling connection. Anyone who learned another connection's stream id could inject forged items or abort the stream with an error. Streams now record their owning connection and reject anything else. - Unauthenticated clients could stall the whole server through the type cache: an incoming
Interface|Methodname was resolved with a helper that, on a miss, scanned every loaded assembly twice and then cached the name — including misses — permanently, behind one process-wide lock. That lookup runs before the authorization check, so a loop of random names cost a full multi-assembly scan per message while holding the lock, serializing dispatch on all connections, and grew the cache without bound. Resolution now goes against the interfaces that were actually registered. - Client-supplied generic type arguments went unchecked into
MakeGenericMethod: an unresolvable name became anullelement and constraints were never validated, so the caller chose which type a generic method operated on and each distinct value-type instantiation permanently JITted native code that is never reclaimed. Arity, resolvability and constraints are now validated. - Overloads silently overwrote each other at registration: every registry was keyed by the method name alone, so for
GetById(string)/GetById(Guid)whichever overloadGetMethods()returned last won — an order that is explicitly unspecified — and because the binder coerces arguments, calling the lost overload usually still succeeded against the winner: wrong behavior with no exception anywhere. Security-relevant because the surviving overload can carry different[Authorize]data than the one the caller believes is checked. TwoServerMethodsclasses for the same hub sharing a[MessageName], and two base interfaces contributing the same member to a registered contract, collided the same way, as did two interface types sharing a wire name. Methods are now indexed by name and argument count — the only discriminator the wire has, since messages carry no parameter types — and any two methods that would be reachable under the same name and argument count are a hard error at startup instead of a silent overwrite. - BREAKING — an unexpected exception no longer carries its detail to the caller: every exception escaping a hub method was serialized into the error contract with its full .NET type name, its
Messageand its cause chain five levels deep, and passed through to whoever made the call. For the codes the pipeline recognizes that is correct — those messages are ours. For everything else, folded tointernal, it meant aSqlExceptionnaming the host and database, aFileNotFoundExceptionnaming an absolute path, or a DI resolution failure spelling out the internal type graph reaching any caller authorized to invoke the method. This is exactly what SignalR'sEnableDetailedErrors=falsedefault prevents, and passing the contract through bypassed it with no way to opt back in.internalnow travels as a fixed sentence plus a short correlation id, and the exception is logged on the server under that same id — the detail moves from where the caller can read it to where the operator can. Unaffected: application errors thrown asnew HARRRException(code, message), which are meant for the client and travel verbatim, and the recognized codes (unauthorized,timeout,cancelled,argument_binding_failed, the resolution codes,no_client_responded), which keep their message and cause chain. If a specific failure should reach the client, throw it as aHARRRExceptionand say so.
Fixed
Per-message re-authentication ran against a context with nothing on it: re-validating a message credential means handing
IAuthenticationService.AuthenticateAsyncanHttpContext, and a message over an open socket is not an HTTP request — so one was fabricated, carrying theAuthorizationheader and nothing else. No host, no scheme, noItems. That is enough for a handler validating a self-contained token against statically configured keys, and nothing at all for one that needs to know where the request arrived: a multi-tenant scheme resolving its trusted issuers from the host, an introspection endpoint that differs per tenant, anything reading what middleware stamped before authentication ran. Those failed closed, correctly, so the connection was denied from the moment the auth cache lapsed — and the information was never lost,ClientContextreached for the connection's request and took only its service provider. It now captures where the connection arrived and replays it onto both fabricated contexts, the one used for authentication and the one used for policy evaluation. A snapshot rather than theHttpContextitself, which belongs to its request. Reported by AlertHub.A hub with its own contract lost it as soon as a
ServerMethodsclass had one too: the interface registry is keyed by hub type, but theServerMethodsbranch looked its entry up by theServerMethodsclass — a key nothing is ever stored under. Every class therefore built a fresh collection and overwrote the hub's entry, so all that survived was whatever registered last, and everything else answeredInterface '…' is not registered.at call time. The shape this breaks is the one the server-methods guide recommends: split a hub across domain classes, each with its own contract. Everything for one hub now accumulates in one collection, which also makes a wire-name collision between two classes the startup error it was already documented to be, instead of the loser silently disappearing.A finished client-side invocation never released its cancellation sources: both .NET clients create a linked
CancellationTokenSourceper call and one perCancellationTokenparameter. The call-level one was removed from the dictionary but never disposed, and the per-parameter one was never even removed unless the server happened to cancel it.CreateLinkedTokenSourceregisters a callback on its parent, and the parent here is the connection lifetime, so every source left behind stayed attached for as long as the connection lived — the client-side mirror of the server defect fixed earlier in this release. A handler taking(…, CancellationToken)on a long-lived connection grew the list on every push, and nothing failed while it happened. Sources are now disposed when the invocation ends; a handler returningIAsyncEnumerablekeeps its token, because the stream is consumed after the call returns.Streamarguments were silently not uploaded on half the invoke paths: only the genericInvokeCoreAsync<TResult>andSendCoreAsyncreplaced aStreamargument with an uploadedStreamReference. The void overload, theType-based one and the three streaming ones handed the liveStreamto the hub protocol serializer instead — MessagePack threw, JSON emitted a{"CanRead":true,…}object the server then failed to bind. SoInvokeAsync("SaveFile", stream)failed whileInvokeAsync<bool>("SaveFile", stream)worked, for the same call.Disconnecting an unregistered connection threw out of the teardown path:
OnDisconnectedAsynclogged the client's IP from theClientContextthatUnRegisterreturned, which isnullwhen the connection was never registered or when the callback runs twice. WithLogClientIpAddressesenabled and debug logging on, that dereference threw anNullReferenceExceptionfrom inside teardown, where the surrounding catch rethrows. Found by the compiler as soon as the lookup was typed honestly, which is the point of having done so.The solution did not build: NuGet audit flagged
Microsoft.AspNetCore.SignalR.Protocols.MessagePackin all pinned versions (GHSA-f8h2-vmm9-qhj6), which withTreatWarningsAsErrorsfailed the restore of both integration test projects — so the integration suite, the bulk of the test coverage, never ran. Bumped to the patched 8.0.29 / 9.0.18 / 10.0.10, andMessagePackto 2.5.302 to match what those packages depend on.An
async voidcancellation callback could terminate the process:CancellationToken.Registertakes anAction, so theasynclambda registered for remote token cancellation compiled toasync void. It fires when the caller cancels — normally because the client is gone, which is exactly when the notification fails — and the exception was then raised on the thread pool with nobody to observe it.All hub logging went nowhere:
HARRR.Loggerwas assignedNullLogger.Instancein the constructor and never reassigned, so everyLogError/LogDebugin the hub base class was a no-op. Invocation failures, connect/disconnect failures and stream write errors were silently lost unless a consumer sethub.Loggerthemselves, which was undocumented.A client could park thread pool threads indefinitely: a server method with a
Streamparameter waited for the client to upload it through a blockingRunSync, and on the non-streaming invoke path with neither a cancellation token nor a timeout. Requesting an upload slot, invoking the method and never uploading parked the dispatching thread permanently; repeated, this exhausts the pool. Parameter binding is now asynchronous, receivesConnectionAborted, and the wait has a configurable timeout.Upload slots never expired:
RequestUploadSlotis a plain hub method, and slots were removed only when a wait completed successfully while the cleanup sweep covered downloads only. Calling it in a loop grew the dictionary for the lifetime of the process. Slots now carry a timestamp, are swept, and are released regardless of outcome.A second hub instance was constructed for every invocation: for methods declared on the hub, a throwaway hub was built via
ActivatorUtilitiesalthough the live instance was already at hand. That bypassedIHubActivatorand hub filters, re-ran the hub constructor per message, made state established inOnConnectedAsyncinvisible to the method, and leaked —HubisIDisposable, butActivatorUtilitiesdoes not register instances for scope disposal.Short client-to-server streams silently returned no items:
CompleteStreamremoved the stream entry before completing the writer, whileReadStreamis an async iterator whose lookup does not run until the firstMoveNextAsync. A fast stream delivered everything before the server began enumerating, the lookup then found nothing, and the caller received an empty stream with no error at all.A client disconnecting mid-stream left the server waiting forever: nothing completed the channels owned by a departing connection, so the server task awaiting one stayed parked for the process lifetime, holding the channel and everything buffered in it.
OnDisconnectedAsyncnow completes them.AddSignalARRRthrew at startup for aServerMethodsclass with an intermediate base class: the hub type was read asBaseType.GenericTypeArguments[0], which is empty unlessServerMethods<THub>is the direct base, so the whole application failed to start with anIndexOutOfRangeException. The inheritance chain is now walked properly, and abstract classes are skipped.Failures from individual invoke candidates were destroyed: two empty
catchblocks discarded the real cause — authorization denied, deserialization failure, client timeout were all indistinguishable — and the caller saw only a generic "No client responded". Causes are now logged and attached as theInnerException/AggregateException.Three
Console.WriteLinecalls in library code wrote past the logging pipeline to stdout; replaced withILogger.A deserialization failure could put a credential in the log in clear text: the error message echoed the received value verbatim. It now names the types involved instead.
The cleanup timer callback could terminate the process: an unhandled exception on a timer thread is fatal, and disposing a stream during the sweep can throw.
Stream and upload keys were lowercased with the current culture: under a Turkish locale
Ilowercases toı, so a slot created by one request could not be found by the next. NowToLowerInvariant.A transient Redis error ended the backplane heartbeat permanently: the heartbeat loop caught only
OperationCanceledException, and the priming calls sat outside thetryentirely, so a singleRedisConnectionException— routine during a failover or a one-second network blip — ended the loop for good. Nothing observed the faulted task, so nothing logged it and nothing restarted it. The consequence was not local: once the heartbeat key expired, every other node treated this one as dead and deleted all of its connection registrations while it went on serving those very connections. It became invisible cluster-wide until restarted. Every iteration is now guarded and the loop keeps ticking.A node evicted from the registry could not recover: registrations were written once at connect time and never re-asserted, while liveness was judged solely by another node's view of a TTL key — and the cleanup that follows is destructive. A stop-the-world GC, thread pool starvation or a partial network partition longer than
NodeTimeoutwas enough for another node to wipe every registration belonging to this one. The node then refreshed its heartbeat and looked healthy again, but all of its connections were gone from the hub, group, user and attribute indexes permanently;CleanupNodeIfDeadAsyncskips the local node, so it could not even repair itself. A node now detects that its own heartbeat key had vanished and re-registers its live connections.One unresponsive node made a whole cluster query fail: a query completed only once every expected node had reported back, and there is no per-node deadline and no way to force an answer. A single node that was restarting, wedged, or unable to resolve the requested types therefore made
InvokeAllAsyncthrow after the fullInvokeTimeout— discarding the local results and every other node's answers with it. It now returns what arrived and logs which nodes stayed silent. Part of the same defect sat on the answering side: the type resolution check returned before thetry, so thefinallythat publishesInvokeQueryCompletednever ran and a node that could not resolve the types — during a rolling deployment, or when a second application shares the same Redis and channel prefix — silently stalled every cluster query until it timed out.Cluster query responders were counted, not identified: the node count was sampled before the request was published, so any node subscribing in that window — routine during scale-out or a rolling restart — also received the request and also answered. The extra decrement drove the counter to zero early: with two nodes counted and three answering, the query completed after the two fastest and silently omitted the third node's clients. A successful-looking result with missing entries is worse than a timeout, because nothing indicates it happened. Completion is now tracked by node id, and an answer from a node the query never waited on is ignored. Also fixes the live result list being handed to the caller while stragglers were still appending to it, which could throw "Collection was modified" mid-enumeration.
Resolving the access token could deadlock a UI client: both .NET clients resolved the token provider with a blocking wait on every outgoing call. On a single-threaded
SynchronizationContext— WPF, WinForms, MAUI — the provider's continuation is posted back to the very thread that sits in that wait, so the first call with an asynchronous token provider hung the UI thread permanently, before anything touched the wire. On servers it pinned a thread-pool thread per call.ClientRequestMessagegainsWithAuthorizationAsync, and every send path awaits it.Every server-to-client call left a cancellation callback on the caller's token:
CancellationToken.Registerreturns a registration that unhooks the callback, and three places dropped it — the per-call id inServerProxyCreatorHelperand the per-parameter ids inMethodArgumentPreparer, on the invoke, send and stream paths. For a request-scoped token that is harmless, but server code that wants "cancel everything for this connection" builds the opposite: one long-lived token. Each call then hung another callback on it, each closing over theClientContext— so the list grew without bound and the context, along with its DI scope, could never be collected (DI-6). The registrations are now owned by the call and unhooked when it ends; on the stream path, where the method returns long before the work does, they are unhooked when the stream ends however it ends. The broadcast callback inBroadcastArgumentRulesis deliberately still not disposed: a fire-and-forget broadcast stays cancellable after the send returns, so there is no later moment that could dispose it without taking that away.Stream expiry was decided by the wall clock: both stream managers stored
DateTime.UtcNowat creation and compared it againstDateTime.UtcNowminus the timeout. That clock is not monotonic and is not guaranteed to advance between two adjacent statements, so a correction backwards kept expired transfers and leaked streams alive, one forwards disposed live ones early, and a zero idle timeout came down to whether the clock happened to tick. Ages are measured withStopwatchtimestamps now.Transport-authenticated clients were denied every call once the auth cache expired: on the ordinary call path,
SignalARRRAuthentication.Authorizere-authenticated from scratch against a freshly builtDefaultHttpContext— one that carries nothing of the connection's original request: no cookie, no Negotiate handshake, no header. Only a client certificate survived, because it is copied across explicitly. So a Windows-, Negotiate-, NTLM- or Kerberos-authenticated client worked forAuthCacheDuration(default three minutes) and was rejected on every[Authorize]call after that — while a stream on the same connection kept running, because the per-element re-authentication goes through a different method that trusts the revalidated principal. Same credential, same connection, two answers. Revalidation is now the authentication for a connection-bound credential on both paths, and it extends the cache instead of re-running on every message — which for a certificate meant chain building, possibly with CRL/OCSP network I/O, on the dispatch path. The credential's own lifetime is still enforced: a principal whose ticket states an expiry that has passed is rejected regardless of scheme.No client authenticated the file-transfer endpoints:
/download/{id}and/upload/{id}are ordinary HTTP endpoints, and this release made them carry the hub's authorization — they used to be anonymous, with the URL as the only credential. But every client sent them a bare request:new HttpClient()on both .NET clients,fetch(url)in TypeScript, a plainURLRequestin Swift. So a hub with[Authorize]answered 401 to everyStreamargument and everyStreamreturn value, which is to say file transfer stopped working entirely for token-authenticated applications, and worked in a browser only same-origin, by accident, becausefetchsends cookies there. All four clients now send the connection's credential, using the server's own convention: a credential without a space is sent asBearer <token>, one carrying its own scheme is sent verbatim. Two of the requests also ignored their response status — a rejected upload produced a stream reference pointing at nothing, and the failure surfaced later as an unrelated server error — so they check it now.Swift: the connection itself was never authenticated:
SignalRWebSocketClienttook no credential at all. Negotiate went out as a bare request and the transport URL carried only the connection token, so the connection was always anonymous —accessTokenFactoryfed the per-message field and nothing else. A hub with[Authorize]on the class rejected the client at/negotiatewith 401, and it worked only where authorization sat on methods, carried by SignalARRR's own message credential. The client now sends the credential as anAuthorizationheader on negotiate and as theaccess_tokenquery item on the transport URL, matching what SignalR does for WebSocket and SSE.HARRRConnection.create(url:)passes one factory to both; construct the client yourself to give the connection and the messages different credentials.A client with no credential was challenged once per streamed element: authorization is re-checked for every element of a running stream, and a challenge is a round trip to the client. A connection that authenticates only its transport answers every one of them with an empty string, so asking again per element bought nothing and cost a round trip each time. It used to be self-limiting only because the empty answer killed the stream. The connection now remembers that answer and stops asking, until a message does arrive carrying a credential — a user signing in mid-connection, say — which makes it worth asking again. The expiry stated on the principal is still checked per element; it is the asking that was wasteful, not the checking.
A client that sends no credential was denied once the auth cache expired: with neither a message credential nor a scheme recognised as connection-bound, the ordinary call path refused every
[Authorize]call from the moment the cache lapsed — three minutes in by default — and a running stream died mid-flight at its next element. The denial caught nothing: the connection is authenticated, the principal it negotiated with is right there, and SignalR itself honours that principal for the life of the socket. It hit valid sessions exactly as hard as expired ones, which is an availability failure wearing a security posture; an application that wants access to stop after a fixed window setsAuthCacheDurationand configures a credential. SignalARRR now falls back to the connection's own principal — but enforces the expiry that principal states, which plain SignalR never looks at again once negotiate is past. So the fallback is weaker than either configured mode and still stronger than SignalR. Revocation goes unnoticed there; nothing can notice it without a credential to re-check.TypeScript client — an asynchronous
accessTokenFactorybroke every call the client made: the factory was typed and called as() => string, although SignalR's own contract isstring | Promise<string>and every OAuth-backed application returns the promise. Typing it away did not make it synchronous: the promise object was serialized intoClientRequestMessage.Authorization, arrived at the server as{}, and the message — whoseAuthorizationis astring— failed to bind. Everyinvoke,sendandstreamon that connection died withError binding arguments, which names the wrong thing entirely; server-to-client calls kept working, since they carry no such message, so the connection looked healthy and the failure read like an authorization problem in the application. The factory is now awaited on all three paths, exactly as both .NET clients and the Swift client have always done. Reported by AlertHub against 5.0.0-beta.1; it affected every published version.Two consequences worth knowing.
stream()still returns itsIStreamResultsynchronously, but the stream is opened only once the token is in hand, so a factory that rejects is reported to the subscriber'serror()instead of being thrown out ofstream()— forinvokeandsendit rejects the returned promise, as before. And a token challenge was never affected: SignalR awaits the return value of a client-result handler, so a promise resolved correctly there.TypeScript client — binary arguments were not uploaded on the stream path:
invokeandsendreplace aBlob,ArrayBufferorBufferargument with a stream reference and upload the bytes over HTTP, butstreamskipped that step and sent the object raw, because the message had to be built synchronously. It now prepares its arguments like the other two, matchingPrepareStreamArgumentsin the .NET client andbuildClientRequestin the Swift one.
Removed
BREAKING —
ClientRequestMessage.WithAuthorization(Func<Task<string>>): the blocking overload behind the deadlock above. Keeping it for binary compatibility would keep the deadlock reachable, and this release breaks compatibility anyway. UseWithAuthorizationAsync, orWithAuthorization(string)for a token that is already at hand — that overload is untouched.BREAKING —
ClientManager.GetHARRRClients<T>()andGetHARRRClients<T>(Func<ClientContext, bool>):[Obsolete]since 4.x, with a message that already named this release.WithHub<T>()replaces the first and additionally reaches the whole cluster rather than the local node's clients alone. For the second, be aware that a LINQ.Where(...)chained ontoWithHub<T>()enumerates locally, exactly like the removed overload did; to filter across the cluster use the query's own filters (WithGroup,WithUser,WithAttribute).BREAKING — the message credential is no longer taken from SignalR's: the .NET client reflected two levels into SignalR's private fields (
_connectionFactory→_httpConnectionOptions) to readAccessTokenProvider, and the TypeScript client readconnection._options.accessTokenFactoryoff the object. Both then used it to fillClientRequestMessage.Authorizationon every call. That conflated two genuinely different things — the credential that authenticates the connection, checked by[Authorize]on the hub class at negotiate, and the one that authenticates each message, checked by[Authorize]on a method — so they could never be configured apart, and a credential meant for the connection alone, such as a single-use ticket, was resent with every message. It was also a load-bearing dependency on another package's internals: the field names are not API, and the TypeScript side had already produced one silent wire bug this release.Configure the message credential explicitly:
WithAuthorizationon the .NET options builder (also acceptingFunc<string>andstring),authorizationonHARRRConnectionOptionsin TypeScript (a factory, sync or async, or a string). Pass the same credential to both settings when it is the same credential — the common case, and what the guides now show. A client authenticated only at the transport keeps working until the server's auth cache expires and is rejected after that, so a token-based client must set this. The .NET Framework client always took its provider explicitly and is unchanged; Swift takes one onHARRRConnection.createand now also has a separate one onSignalRWebSocketClientfor the connection.
Changed
Contract wire names can be declared with
[MessageName]: on the interface path the C# identifiers were the protocol — the name on the wire was alwaysNamespace.IInterface|MethodName, and nothing honoured the attribute. For .NET-to-.NET that is invisible, since both ends compile against the same contract. For a TypeScript or Swift client, which writes the name out as a string, a C# rename or a namespace move broke it silently at runtime, with no compiler, test or log line in the way.[MessageName]now sets either half — on the interface for the first, on a member for the second — and both are optional, so a contract without attributes behaves exactly as before. A declared name replaces the C# name rather than aliasing it, because an alias would keep renames working and leave the coupling in place. Names containing|are rejected at registration, as are two members renamed onto the same wire name and argument count. There is deliberately no version mechanic:[MessageName("getById.v2")]is just a name, and whatv2means belongs to the application. Client-to-server calls on hub methods andServerMethodsclasses are unaffected — they have always honoured the attribute. See the new Contract Wire Names guide.Cocoar.SignalARRR.Contractsnow depends onCocoar.SignalARRR.Common:[MessageName]lives in Common, while a shared-contracts project is documented as referencing Contracts alone — so the attribute was unreachable in exactly the assemblies that declare contracts. No namespace changed.BREAKING —
ClientManager.GetClientByIdis typedClientContext?: it was declared non-nullable and returnednull!for a connection this node does not have — so a consumer with nullable reference types enabled got no warning and an unexplainedNullReferenceExceptioninstead, at a spot the compiler had just promised could not be null. A miss is ordinary here: the connection may have dropped a moment ago, or, with a backplane, live on another node this method does not see. The internalIHARRRClientManager.GetClientandUnRegisterare annotated the same way. Existing call sites that already null-check keep working unchanged; ones that dereference the result now getCS8602exactly where they were relying on a promise the library could not keep.HARRR.ClientContextstays non-nullable — inside a hub method the connection is registered by definition — but reading it beforeOnConnectedAsynchas registered it now throws with an explanation rather than anNullReferenceExceptionfurther down.BREAKING —
WithHub<T>()returnsIClientQueryinstead ofIEnumerable<ClientContext>: a query is a description of a target set that the backplane resolves across the cluster, but it was typed as a sequence — and the cluster scope lived in metadata that LINQ silently dropped. An innocuous.Where(...)or.ToList()therefore turned a cluster-wide broadcast into a node-local one, with no error and no warning; the guide taught exactly that, in an example captioned "notify all admins". Enumerating a query was misleading on its own terms too: it only ever yielded this node's connections, because the others have noClientContextin this process.SendAsync,InvokeAllAsync,InvokeOneAsyncand theWith*filters are now instance methods onIClientQuery, so.Where(...).SendAsync(...)no longer compiles rather than silently under-delivering.WithLocalFilter(predicate)— which existed but was unreachable from outside the assembly — is the deliberate replacement, and it says in its name that it confines the query to one node;LocalClients()is now the only way to reachClientContextobjects. To narrow a query that should still span the cluster, filter on group, user or attribute, which the connection registry can answer for every node. The dispatch logic itself is unchanged. As a side effect the seven operators no longer appear on everyIEnumerable<ClientContext>in user code, and the fluent API works without importingCocoar.SignalARRR.Server.ExtensionMethods.BREAKING —
HARRRConnectionExtensionsis rewritten, and its arity ladder stops at four: the file was 2,426 lines of decompiledMicrosoft.AspNetCore.SignalR.Client.HubConnectionExtensions, complete with the JetBrains decompiler banner, the source assembly's MVID and a developer's local NuGet path — re-licensed under this project's Apache-2.0 header. It is now this project's own code, written againstHARRRConnection's public surface, at 326 lines. The names and shapes still match SignalR's, so calling code reads the same. What is gone: handler registration above four type arguments (was eight), calls above four arguments (was ten), and theInvokeCoreAsync/StreamAsChannelCoreAsyncextension methods, which were shadowed by identically-shaped instance methods onHARRRConnectionand could never be reached. Past four arguments, use theobject[]core methods or — better — a typed contract throughGetTypedMethods<T>(), which the ~60 removed overloads had buried in the completion list.BREAKING — the Redis backplane moved to its own package,
Cocoar.SignalARRR.Server.Backplane.Redis:Cocoar.SignalARRR.ServerreferencedStackExchange.Redisunconditionally while only the backplane used it, so every single-node consumer — the majority — carried roughly a megabyte plus its transitive closure, and inherited its advisory surface, for a feature they never switched on. Add the new package if you callAddSignalARRRRedisBackplane; that method, its options builder and their namespace are unchanged, so the only edit is thePackageReference. Nothing else moves: the backplane contracts stayinternalin the server assembly and the new package reaches them throughInternalsVisibleTo, which keeps promoting them to a public extension point a separate decision rather than a side effect of this split.BREAKING — the ~75 MVC result clones on
HttpContextare gone:HttpContextWriteActionExtensionsreimplementedControllerBaseas extension methods —Ok,NotFound,BadRequest,ValidationProblem,Filein 24 overloads,PhysicalFilein 8, down toLocalRedirectPermanentPreserveMethod— none of it related to RPC, all of it landing on everyHttpContextin any file that importsCocoar.SignalARRR.Server.ExtensionMethods. The library used exactly one of them,Ok(), which is two lines that need no MVC; that call site now writes the status code and flushes directly. If you were using these,Microsoft.AspNetCore.Http.ResultsandControllerBaseare the framework's own answers. Note this does not change the package graph, contrary to how the finding was written: the server takes aFrameworkReferenceonMicrosoft.AspNetCore.Apprather than a package reference on MVC, and still uses[FromServices]for parameter binding. What it removes is 75 members of public API surface.Version-qualified type names split a cluster during a rolling deployment: the backplane put
AssemblyQualifiedNameon the wire and resolved it withType.GetType, which binds on the full assembly identity — name, version, culture and public key token. A node one build ahead or behind therefore resolved its peers' hub and result types tonull, silently. Worse, the same string is interpolated into the Redis index keys ({prefix}:hub:{type}:connectionsand the group, user and attribute equivalents) and compared ordinally, so two versions wrote into disjoint keyspaces and filtered each other's registrations out — a cluster quietly split in two, with no resolution failure to point at. Names are now written without build identity and resolved by partial assembly name. Note that plainFullNamewould not have sufficed: for a closed generic it still embeds the fully qualified name of each type argument, version included, so every generic result type would have stayed broken.A faulting stream left every consumer of a
ChannelReaderwaiting forever: the pump behind each generated proxy method returningChannelReader<T>ran without error handling, so a dropped connection or a server-side exception skipped the completion entirely. The channel was never completed, consumers waited for an item that could not arrive for the lifetime of the process, and the exception went unobserved on top. It now completes with the failure, so the reader rethrows it. The .NET Framework client had the same pump with afinally, so it did not hang — it completed successfully regardless, turning a faulted stream into one that merely looked finished; that is fixed too. The cancellation token now also stops the enumeration rather than only the writes.A server stream could not be cancelled: SignalR passes its stream token to
GetAsyncEnumerator, andStreamingResultdiscarded it — the enumerator was obtained without it, and aChannelReadersource was turned into an enumerable in the constructor, before any token existed. When a client aborted a stream or vanished, the server loop stayed parked inMoveNextAsyncand the producer behind it kept running. Iterators marked[EnumeratorCancellation]were silently not cancellable.Inherited interface members were proxied but never registered:
GetMethodson an interface returns only declared members, unlike on a class. The source generator walksAllInterfaces, so the proxy forIDerived : IBaseimplementsIBase's methods and puts them on the wire asNs.IDerived|BaseMethod— a name nothing had registered. Every call to an inherited contract member ended in "Method 'BaseMethod' not found!", with nothing pointing at inheritance as the cause. Declared members still take precedence over inherited ones of the same name.A cancellation token passed to a client method never arrived: the generated proxy left the token out of the arguments while the client's binder still consumed a slot for it, so binding ran off the end of the array and the call failed outright —
IndexOutOfRangeExceptiondeep inside the binder for something as ordinary asTask<string> Wait(int seconds, CancellationToken ct). TheDispatchProxydid the opposite and passed the raw token through, so each of the two binders was wrong for one of the two proxy flavours. A token now travels as a reference when a call goes out to a client, and not at all when it goes in to the server, which binds its own.Cancellation reached the .NET clients only: the reference carried an id of its own while the message carried an unrelated
CancellationGuid. The .NET client registered under the former and the TypeScript and Swift clients under the latter, so a cancellation addressed to one could never reach the other. Each token argument now carries the id it is actually cancelled by, which also keeps two token parameters independently cancellable.The client's argument-count guard could never fire:
@params.Count < paramsPositionsat above the index access and compared the wrong way round, so a short argument list produced a bareIndexOutOfRangeExceptioninstead of a message naming the parameter.BREAKING —
HARRRConnection.OnServerRequest(...)and its five overloads are gone: they registered handlers into a dictionary nothing ever read, so every server-to-client call by bare method name ended in "Method 'X' not found!" — and on the fire-and-forget path that error was swallowed into a client-side log line, so nothing happened at all.RegisterInterfaceis and was the working path. The .NET Framework client carried the same two overloads over the same never-read dictionary, and they are gone there too — that client is meant to track the .NET client, and the removal had only been done on one of the two.Cancellation tokens are rejected on remote-backplane client calls: they cannot be delivered there — the cancellation notification has no way to reach the recipients — and silently dropping the argument shifts every following one. A
NotSupportedExceptionnaming the method is the honest interim. Broadcasts initially shared this rejection; they now deliver cancellation (see Added).BREAKING (other SDKs) — remote reference arguments now carry a
__typemarker: an argument that is a handle rather than a value — a cancellation token, a stream — is sent as{ "__type": "cancellationToken", "Id": … }or{ "__type": "stream", "Uri": … }. Receivers previously had to guess from the shape, and guessed wrong on ordinary data: the TypeScript client treated any object with a stringIdas a cancellation token, so a payload like{ Id: "user-42", Name: "Ada" }was swapped for a token and never reached the handler. The .NET clients were shielded by knowing the parameter types; TypeScript and Swift were not.The marker only helps in one direction. A client updated to this version works against an older server, because an unmarked reference is still accepted. An already-published TypeScript or Swift client will break against a server on this version: both rejected anything with more fields than the bare reference, so the extra property makes them stop recognising references altogether — silently dropping cancellation and turning stream arguments into raw objects. Update the npm and Swift packages together with the server.
BREAKING —
MethodInfoExtensions.GetAuthorizeData()returnsIReadOnlyList<IAuthorizeData>instead ofList<AuthorizeAttribute>. Required to honour custom authorization attributes at all. AGetAuthorizationPlan()extension is also available, exposing the resolved[AllowAnonymous]state alongside the authorization data.BREAKING —
ServerStreamManager:CreateStreamandCompleteStreamtake the owning connection id, andWriteItembecameWriteItemAsync. The buffer is now bounded (1024 items by default) and the write is awaited, so a full buffer throttles that connection instead of allowing unbounded growth.BREAKING —
ServerPushStreamManager.WaitForUploadrequires a timeout. It waits on something only the client can supply, so a caller has to state a deadline.HARRR.StreamItemToServerreturnsTaskso that the bounded stream buffer can apply backpressure.InvokeOneAsyncnow throws consistently when no client responds: the local path returnednullbehind a null-forgiving operator while the cluster path threw, so the same failure produced either aNullReferenceExceptionat an unrelated frame or a clean exception, depending on whether a backplane was configured. Callers that inadvertently relied on thenullreturn will now see an exception.A missing token no longer surfaces as
ArgumentNullException: it previously threw with the message passed as the parameter name, which clients keying off the error type to refresh a token could not recognise. It now returns an authentication challenge.Microsoft.Extensions.DependencyInjectionis pinned per target framework (8.0.1 / 9.0.18 / 10.0.10) instead of a single framework-independent version. A .NET 8 application no longer has itsMicrosoft.Extensions.*packages uplifted above its shared framework.Dispatch resolves by name and argument count, and default values now work from every client: overloads whose argument counts differ are both callable — the count of the incoming message picks the method, from any client, since that is how many arguments the caller passed. A method with trailing default values is reachable under the whole range of counts and the server binder fills the omitted ones from the parameter metadata, which makes C# default values usable from TypeScript and Swift for the first time (previously: "not enough arguments"). Which parameters count is direction-dependent per the cancellation-token contract above: going in to the server,
CancellationTokenand[FromServices]parameters occupy no argument slot; going out to a client, every declared parameter does. Overloads with the same argument count cannot be told apart on the wire and fail at startup — rename one, or use[MessageName]on hub andServerMethodsmethods. The wire format is unchanged.[MessageName]on an interface member is a startup error instead of doing nothing: nothing on the interface dispatch path honours the attribute — not the registration, not either proxy flavour, not the source generator; the name on the wire is alwaysNamespace.IInterface|MethodName. Setting it therefore silently did nothing, which for a TypeScript/Swift caller writing that wire string by hand is a trap. Registration now fails with an explanation. The attribute keeps working on hub andServerMethodsmethods; renaming the wire independently of the C# name is a protocol feature of its own, deliberately not smuggled into this guard.Surplus arguments are rejected instead of silently ignored: the argument count is now the dispatch key, so a count no registered method accepts fails with an error naming the accepted counts — previously extra arguments were dropped without a trace.
BREAKING —
ISignalARRRMethodsCollection.GetMethodInformationsandISignalARRRInterfaceCollection.GetInvokeInformationtake the argument count of the incoming message. Both types are server infrastructure rather than application-facing API.BREAKING —
HARRRException(string, string)changed meaning: the second-string constructor now takes an application errorcodeinstead of a display type name. It had no callers inside the library; consumers using it get the new semantics.Resolution failures are typed: the method/interface registries throw
MethodResolutionException(carryingmethod_not_found/invalid_argument_count) instead of a bareException, and "no client responded" is aNoClientRespondedException(derives fromInvalidOperationException; multiple collected failures now sit in an innerAggregateExceptioninstead of being the thrown type). Exact-typeAssert.Throws/is-checks on the old types need updating;catchblocks on the base types keep working.System.Text.Jsonpinned at 8.0.6 (was 8.0.5) — required by the newMicrosoft.AspNetCore.SignalR.Commonreference of the shared package.The per-message dispatch path no longer re-does constant work (P-1/P-4/P-6/P-7): loggers come from a process-wide per-category cache instead of
LoggerFactory.CreateLogger— which serializes on a process-wide lock and ran twice per message;ServerMethodsinstances are wired through a cast and plain assignments instead of five name-based reflective property sets; parameter binding runs off a per-method plan instead of re-cloningGetParameters()and re-instantiating[FromServices]attributes for every parameter of every message; and the per-element stream authorization check exits through the cached authorization plan without allocating. The auth-cache clock is UTC now —DateTime.Nowconverted through the local time zone on every read of a per-message hot path (certificate validity checks deliberately stay local time, X509 lifetimes are local).The
SIGNALARRR_DIAGNOSTICS_LOG_FILEfile logger is gone: it wrote six synchronousFile.AppendAllTextper connection lifecycle behind one global lock, slept a thread-pool thread on contention — and a third consecutiveIOExceptionescaped its filter and terminated the connection over a log-file problem. The timings it captured are now thesignalarrr.server.connection.setup.duration/.teardown.durationhistograms.BREAKING — the entry points moved to the namespaces the rest of ASP.NET Core uses:
AddSignalARRRandAddSignalARRRRedisBackplaneare inMicrosoft.Extensions.DependencyInjection, hub mapping is inMicrosoft.AspNetCore.Builder, instead ofCocoar.SignalARRR.Server.ExtensionMethods. Both namespaces are already in scope in an unmodifiedProgram.cs, so the methods now show up in completion where every other library puts them — previouslybuilder.Services.Add…offered nothing and a hello-world needed fourusings to find its own entry point. Sharing a namespace with everyone else makes a name likeServiceCollectionExtensionsambiguous at the call site, so the classes carry the product name:SignalARRRServiceCollectionExtensionsandSignalARRREndpointRouteBuilderExtensions. On the same groundsMapHARRRController<THub>is nowMapSignalARRRHub<THub>— it maps a hub, and "controller" means something else in ASP.NET Core. The empty and never-referencedMapHARRRControllerOptionsis deleted; the options overload has always taken SignalR's ownHttpConnectionDispatcherOptions. Drop theusing Cocoar.SignalARRR.Server.ExtensionMethods;line unless you also use the client-query extensions, which stay where they are for now.BREAKING —
ClientAttributeswraps a dictionary instead of deriving from one: it inheritedDictionary<string, StringValues>and hid the inherited indexer with anewone returningstring?. The same object answered the same key two ways —nullthroughClientAttributes,KeyNotFoundExceptionthrough the dictionary it was — with nothing at the call site to say which one you were holding. Inheriting also putAdd,RemoveandClearon what is a read model of the connection request. It is now a sealedIReadOnlyDictionary<string, StringValues>with one indexer that keeps the interface's contract. Thestring?lookup lives on asGetString(key); use it wherever you relied onattributes[key]beingnullfor an absent attribute.Has(key),Has(key, value), enumeration and case-insensitive matching are unchanged.BREAKING — the wire method names are constants:
MethodNames.InvokeServerMessageand.CancelTokenFromServerhad public setters, so any consumer could rewrite two protocol names process-wide at runtime — after which every other SDK on the connection was still listening for the old ones. The class isstaticand all ten names areconst.BREAKING — naming defects:
MethodArgumentPreperator→MethodArgumentPreparer(the typo, andinternalnow), the client'sHARRRContext→ClientConnectionContext(internal; deliberately not theConnectionContextthe review proposed, which would collide withMicrosoft.AspNetCore.Connections.ConnectionContext— the same kind of collision this entry exists to remove),ClientCollectionResult<T>→ClientResult<T>(it holds one result, not a collection), and the server's internalObservableExtensions→SignalARRRObservableExtensions, which collided withSystem.ObservableExtensionsfrom System.Reactive.BREAKING — types that were never meant to be application-facing are
internal: on the clientMessageHandler,StreamReferenceResolver,ClientConnectionContextandHubConnectionExtensions— the last one reflects into SignalR's private fields, and being public implied that was supported; on the serverSignalARRRAuthentication,MethodArgumentPreparer,EndpointExtensionsand theInvokeDownload/InvokeUploadhandlers behind the file-transfer endpoints.HARRRConnection's constructor goes with them, becauseClientConnectionContextstands in its signature —HARRRConnection.Create(...)is and always was the way to build one.TypeHelperstays public, as astatic class.
Added
ClientContext.Abort(): drops a connection. There was no way to do this at all, so an application that learned a session had been revoked could only refuse each call while the socket stayed up — a client that believes it is connected while the server serves it nothing, which for a presence display derived from live connections means showing a revoked session as present. Suggested by AlertHub, who had built it outside the library.ITransportAuthRevalidationServicespeaksRevalidationResult: BREAKING for implementations, which returnedTask<bool>. The result carries an outcome —Valid,Deny, orAbort— and, when valid, how long the verdict may be cached. The outcome makes the paragraph above reachable from the place that learns the credential is dead. The window addressesAuthCacheDurationbeing one number for the whole server while credentials on the same hub differ: a browser cookie and a reference token whose revocation should be noticed within seconds do not want the same cadence, and previously the configuration was set for the strictest case and everything else paid for it. Aboolstill converts implicitly, soreturn true;keeps compiling.Broadcast calls can carry cancellation tokens, and cancelling reaches every recipient: a
CancellationTokenargument on a broadcast — previously rejected because the notification could not be routed — now travels as a token reference in its declared slot, exactly like on single-client calls, and cancelling the server-side token sendsCancelTokenFromServerto the same recipient set the call went to, across nodes. The wire format is unchanged; clients already understand both pieces. The cancellation route is frozen at send time, so for a resolved connection set the cancellation reaches exactly the callees. Rolling deployments: an older node ignores the new (additive) backplane field and delivers a cancellation as a regular call, which the receiving client fails to resolve — degraded, not corrupted; upgrade nodes together to get cross-node cancellation.A long-running fire-and-forget call no longer blocks the connection: both .NET clients executed fire-and-forget server-to-client calls inline in SignalR's receive loop, so one long-running call held up every later server-to-client message on that connection — including
CancelTokenFromServerfor its own token, making exactly the calls one wants to cancel the ones that could not be. Fire-and-forget messages now run chained off the loop: arrival-order execution is preserved, the receive loop stays free. (The invoke path never had the problem — SignalR dispatches client-result invocations off the loop.)Cancellation follows intent, not plumbing (N-2/N-3): a client method with a
CancellationTokenparameter now observes its own connection ending — previously it was bound toCancellationToken.Noneand kept running into the void after a disconnect, while a server method in the same situation observedConnectionAborted. Every token the .NET clients bind (plain, per-callCancellationGuid, per-token reference) links to the connection lifetime via SignalR'sClosedevent; no separate abort source is invented, so Stateful Reconnect semantics are preserved. On the server, the two directions of intent are separated: the invoke paths keep bindingConnectionAborted(the answer would reach nobody), but the send path now bindsApplicationStopping— a fire-and-forget caller asked for the work ("write to the DB", "queue the job"), and that work no longer stops because the connection happened to drop afterwards. The internal upload wait keepsConnectionAbortedon both paths: waiting for an upload from a client that already disconnected must die with the connection (the V-2 scenario).Operator visibility (O-8):
services.AddSignalARRRHealthChecks()registers a health check (namesignalarrr) reporting stream/upload bookkeeping and — with a backplane — store reachability (PING), heartbeat freshness (stale → Degraded, before the cluster declares the node dead) and whether the heartbeat loop is alive at all (faulted → Unhealthy), plus the active node list. Stream bookkeeping is countable now (ServerStreamManager.ActiveStreamCount, pending download/upload-slot counts) with matching instruments (signalarrr.server.active_streams,.streams.reaped,.upload_slots.swept) — leaked streams used to be unmeasurable and invisible. A reaper removes streams that were created but never consumed (StreamIdleTimeout, default 10 minutes,WithStreamIdleTimeout); streams that are actually being read are never touched.A wire error contract (O-7): every failed call carries a structured
HARRRErrorwith a machine-readableCode— the field clients branch on, since a .NET type name means nothing to a TypeScript or Swift client. One code per pipeline stage:unauthorized,method_not_found,invalid_argument_count,argument_binding_failed,cancelled,timeout,no_client_responded, andinternalfor "the invoked method itself threw". Application code puts its own codes on the wire withthrow new HARRRException("room_full", "The room is full")— user codes travel verbatim and are never overwritten. The envelope carries aVersionand nests the cause chain asInnerError(previously onlyGetBaseException()survived, discarding every intermediate step). Clients must treat unknown codes asinternal, so the set can grow additively.HARRRRemoteException: the .NET clients now throw it for failed remote calls — previously the caller got a bareHubExceptionwhoseMessagewas a raw JSON string, and nothing in any client ever parsed it. It derives fromHubException, so existingcatch (HubException)blocks keep working;Messagebecomes the human-readable remote message, and the structured error is the typedErrorproperty. The backplane rehydrates the same type — previously a failed cross-node invoke surfaced asInvalidOperationExceptionwhile the same failure on a single node was aHubException.Fire-and-forget error semantics documented (N-1, deliberate): a
void/Taskclient method that throws reports the error only to the client-side log —SendAsynccompletes successfully on the server. This is a property of the fire-and-forget path, not a bug: callers that need failures useTask<T>. An error back-channel was considered and deliberately deferred.Log correlation (O-5): every invocation now runs inside a log scope carrying
SignalARRRConnectionId,SignalARRRMethodandSignalARRRInvocationId— on the server and while the .NET client handles a server-to-client call — so anything user code logs inside an invoked method is attributable, and two concurrent calls to the same method on the same connection stay distinguishable. The invocation id is a new additiveInvocationIdfield onClientRequestMessage(assigned once per invocation by the .NET client; raw callers may omit it), server-to-client calls reuse the message's existingId, and both also appear as thesignalarrr.invocation_idspan tag.SignalARRRServerOptions.LogClientIpAddresses(default false) withWithClientIpLogging(): client IP addresses are personal data and no longer appear in log messages unless the consumer opts in — previously every connect/disconnect debug line and one error line carried them unconditionally.Distributed tracing and metrics (
ActivitySource/Metername:Cocoar.SignalARRR): spans around client→server invocations (all four entry points), server→client calls and their client-side handling — and, most importantly, the messages now carry a W3CTraceParent/TraceState, so both directions of an RPC join one trace instead of each side starting its own; the fields are additive and older SDKs simply ignore them. Everything is opt-in: without a registered listener each instrumentation point is a no-op. Cancellation is recorded as an outcome (signalarrr.outcome=cancelled), not an error. Initial instruments:signalarrr.server.invocation.duration,signalarrr.server.active_connections,signalarrr.server.connection.setup.duration/.teardown.duration(per phase),signalarrr.backplane.heartbeat.failures(the previously invisible signal that a node keeps serving while the cluster is about to declare it dead),signalarrr.backplane.nodes.sweptandsignalarrr.backplane.self_evictions.SignalARRRServerOptions.StreamUploadTimeout(default 2 minutes),MaxUploadSizeBytes(default 100 MB),UploadSlotExpiration(default 10 minutes) andMaxUploadSlotsPerConnection(default 32, 0 to disable), with matchingWithStreamUploadTimeout,WithMaxUploadSize,WithUploadSlotExpirationandWithMaxUploadSlotsPerConnectionbuilder methods.HARRRErrorCodes.UploadSlotLimitReached(upload_slot_limit_reached): a connection asked for more unused upload slots than it may hold. Its own code rather than theinternalbucket because it is actionable — finish or abandon an upload and retry. Clients that predate it treat it asinternal, which the contract requires of any unknown code.HARRRConnectionimplementsIAsyncDisposable:DisposeAsync()was already there andawait usingalready compiled — through the compiler's pattern-based disposal, which is why nothing pointed at the missing interface. A DI container, though, disposes what it holds only throughIDisposable/IAsyncDisposable, so a connection registered as a singleton was never disposed and itsHubConnectionoutlived host shutdown. Purely additive; no call site changes.Regression tests: coverage for endpoint registration, authorization metadata resolution, transport credential classification, interface name resolution, overload and default-value dispatch, trace propagation across the wire in both directions, invocation-id correlation, error-message hygiene (a value that fails to deserialize is never echoed), upload slot lifetime and stream ownership, plus cluster resilience (a node evicted while still serving connections, and a cluster query whose peer never answers) and the test fixtures' own infrastructure: the server-URL handshake and the transport-auth debug endpoints. The suite grew from 98 to 268 tests.
SignalARRRServerOptions.ConnectionBoundSchemes: declares additional authentication schemes as bound to the connection, alongside the built-in Negotiate, NTLM, Kerberos, Windows and Certificate. Cookies are what this exists for. They are deliberately not auto-detected — a cookie identity is indistinguishable from a bearer identity once it is aClaimsPrincipal, and assuming the wrong one is how a bearer token could previously escape its own expiry — so a cookie-authenticated connection is otherwise treated as message-level, challenged for a token it does not have, and refused from minute three onwards. Adding the scheme is the application stating that the credential lasts as long as the connection; register anITransportAuthRevalidationServiceto check more than the ticket's stated expiry.
Documentation
- Stateful reconnect is no longer an open question (D-1): the README said SignalARRR's connection-bound state under Stateful Reconnect was unverified. It is now tested — an integration test severs the transport underneath a live connection with a TCP proxy, and asserts that the connection is resumed rather than replaced: same
ConnectionId, no connection-loss events, no re-negotiation on the wire, and theClientManagerregistration with its group membership still in place afterwards. Two things worth writing down that the test had to learn the hard way: a resume raises noReconnecting/Reconnectedevents, because the connection never counts as lost — waiting for one waits forever; and the client'sServerTimeoutmust stay above the server's keep-alive interval, or the client times out on a healthy idle connection and falls back to a full reconnect, which looks exactly like the feature being broken. A plain SignalR hub runs the same sequence as a control, so a failure can be attributed. In-flight stream ownership and outstanding upload slots are still not covered, and the README now says so specifically instead of declaring the whole combination unvalidated. - Every interface declared on a hub or a
ServerMethodsclass is a public RPC surface, and that was written down nowhere. Registration does not distinguish an interface meant as a contract from one implemented for dependency injection or testing:class OrderMethods : ServerMethods<AppHub>, IOrderRepositoryputsDeleteAllon the wire.[SignalARRRContract]does not narrow it — the attribute marks an interface for the source generator, and gating registration on it would break the DynamicProxy, .NET Framework, TypeScript and Swift clients, whose contracts legitimately carry no attribute, while failing silently when someone forgets it. Authorization still applies as before, so this is surface and not a bypass. The server-methods guide now says so and shows the alternative — hold the collaborator, do not implement its interface — and the registration code carries the same reasoning. - The TypeScript and Swift guides documented a handler name that cannot fire: every example registered a bare method name —
onServerMethod('ReceiveMessage', …)— but a contract call arrives asinterface|method, and both clients match the name exactly and drop a miss without a word. So the documented way of receiving server-to-client calls silently did nothing, in the getting-started guide, the TypeScript and Swift client guides, the cancellation guide and the client comparison. All of them now use the wire name, and say that it is matched exactly. - The backplane contracts say what they are (AF-3):
ISignalARRRBackplaneandISignalARRRConnectionRegistryare registered withTryAddSingleton, which reads as an invitation to substitute your own — but they areinternal, so nobody outside the assembly can. TheTryAddturns out to be load-bearing for a different reason: the Redis package swaps its implementation in withReplace, and a consumer callingAddSignalARRRRedisBackplanebeforeAddSignalARRRwould otherwise get the disabled default appended afterwards and silently win, leaving a configured cluster running single-node. So the registration stays as it is and now says why, the interfaces state that they are not a supported extension point and what opening them would cost, and the backplane guide says the same to readers.ITransportAuthRevalidationServiceis the counter-example and is public, because that one is meant to be replaced. Three tests pin the order-independence down — it had none, and the obvious "fix" for this finding would have broken it. - The fire-and-forget error contract is documented (N-1): whether a failing client handler reaches the server depends on the contract member's return type —
Task<T>andTtravel asInvokeServerRequestand surface a throwing handler as aHubException, whilevoidandTasktravel asInvokeServerMessage, where the error is logged on the client and nowhere else. That is not a gap that could be closed by propagating harder: the server's send completes when the message reaches the transport, before the client runs the method, so by the time the handler fails no caller is left to tell. It was simply never written down, and the failure is invisible in production — for a browser or mobile client the only record sits on someone else's machine. Now stated in the .NET client guide (with the two paths side by side), in the TypeScript and Swift guides, onSendAsyncin the client manager guide, and in the README's semantics section, each with the same advice: give the member a return value when the server needs to know it worked. - Connection loss semantics are documented (README): pending invocations fail immediately on any drop and are not safely retryable (at-most-once uncertainty — retry only idempotent operations); SignalR's reconnect features are reachable through the pass-through builders, with the explicit caveat that SignalARRR's connection-bound state under Stateful Reconnect is not yet test-verified; and the stream upload wait and backplane invoke timeout run independently of connection state.
- The website no longer documents
OnServerRequest: an entire chapter of the .NET client guide, plus the cancellation and HTTP stream guides and the API reference, still taught an API this release removes — and which never worked in the first place. Those examples are rewritten ontoRegisterInterfaceand contract interfaces. The removal is called out where the chapter used to be, rather than silently dropped, since anyone arriving from an older version needs to know the method is gone and that there is no ad-hoc replacement. On()is documented as what it is: the .NET client guide showedconnection.On<string, string>("ReceiveMessage", ...)in a chapter about receiving server-to-client contract calls, which reads as if it handles them. It does not:On()passes through toHubConnection.Onand matches raw SignalR method names, while a contract call arrives inside anInvokeServerRequest/InvokeServerMessageenvelope with the real name (MyApp.IChatClient|ReceiveMessage) nested in it. A registration for the bare name never fires, silently. The section now says so and uses an example that actually matches a rawSendAsync.
Fixed (build and CI)
- The PR validation matrix could not build the solution: it passed
--frameworktodotnet build, which setsTargetFrameworkas a global property and thereby overrides the framework of every project — including thenetstandard2.0-only source generator and .NET Framework client, whose restore assets have no such target (NETSDK1005). The solution is now built once and the matrix selects the framework for the test run only, which is what it exists for. - Cross-node backplane tests raced against registry convergence: they waited for local registration and then immediately targeted connections on another node. A connection is registered locally before it is written to the distributed registry, so the owning node reports it while other nodes still cannot see it — measured at around 100 ms under test load. Since a broadcast is a one-shot, anything invisible at that moment never receives it. Five tests now wait for convergence on the node that performs the targeting, including for the connections asserted not to receive anything: those assertions previously passed for the wrong reason.
- The .NET Framework test fixture deadlocked the server it was starting:
ServerFixtureredirects the test server's stdout and never reads it — stderr was read only after the process had already exited.dotnet runwrites its restore and build output well before the server starts listening, and once the pipe buffer fills the child blocks in its own write, so it never reaches the line that publishes its URL. All 15 tests in the collection then failed with a baredid not start within 120 secondsthat named no cause. Whether it triggers depends on how chatty that build output is, which is why CI never saw it:src/Nuget.configlists only nuget.org but has no<clear/>, so a machine-level package source merges in and every project emits anNU1507warning — enough to cross the buffer several times over. Both pipes are now drained asynchronously, and the timeout reports what the child actually printed.
[4.3.2]
Security
- MessagePack downgraded from 3.1.4 to 2.5.301 to patch CVE-2026-48109 / GHSA-hv8m-jj95-wg3x (high-severity LZ4 decompression out-of-bounds read / DoS), which was failing the build via NuGet audit (
NU1903). The 2.5.x branch also matches the MessagePack version thatMicrosoft.AspNetCore.SignalR.Protocols.MessagePackdepends on (2.5.187), so consumers using the SignalR MessagePack hub protocol are no longer forced onto a cross-major MessagePack version. Only basic serialization APIs are used, so there is no functional change.
[4.3.1]
Fixed
- Fire-and-forget
sendserver methods silently did nothing (e.g. group joins):HARRR.SendMessageran the target method in a detachedTask.Runafter SignalR had already disposed theHub, so any access to the hub-injectedContext/Clients/Groups(such asGroups.AddToGroupAsync) silently failed and the method body never took effect. The method is now awaited within the hub invocation, keeping theHubalive for its duration. Affects every client — the typed proxy routes anyvoid/Task-returning server method through thissendpath, so this was hit without explicitly choosingsend. - (Swift) Negotiate/WebSocket URL broke when the hub URL carried a query string: both the negotiate URL and the WebSocket transport URL were built by string concatenation, producing a broken URL like
.../hub/sync?user=x?id=...(HTTP 400 on negotiate). Rebuilt withURLComponentsso existing query parameters are preserved and/negotiateis inserted into the path. - (Swift) Connection hung ~30s against
localhost/IPv6: whenlocalhostresolved to IPv6 (::1) against an IPv4-only server there was no Happy-Eyeballs fallback for the WebSocket upgrade. Negotiate now fast-fails on a configurable timeout instead of waiting on the OS connect timeout, and the127.0.0.1workaround is documented.
Changed
- (Swift) Undispatched server→client calls now log instead of failing silently: mirrors the .NET client. When a server→client call arrives for which no handler is registered, the client logs a warning pointing to
onServerMethod(...)— the most common cause is registering a SignalARRR contract withon(...)(which only handles raw SignalR events). Theon(...)vsonServerMethod(...)distinction is now documented on both APIs.
Added
- Regression tests: a .NET integration test proving a
send-routed server method joins a group against a liveHub(fails on the old fire-and-forget behaviour), and Swift unit tests covering query-string-safe transport URL construction.
[4.3.0]
Added
- Redis-compatible cluster backplane: SignalARRR server can now scale out across multiple nodes using a Redis-compatible backend such as Redis, Valkey, or Garnet.
- Distributed routing and targeting: cross-node server-to-client send/invoke by connection, cluster-wide broadcast, remote group joins/leaves, cluster-wide
InvokeAllAsync/InvokeOneAsync, and distributedWithUser(...)/WithAttribute(...). - Cluster presence APIs:
ClientManagernow exposes cluster-aware connection and user presence snapshots by hub, user, group, and attribute. - Active stale-node cleanup: heartbeat-driven cleanup now actively removes dead-node registrations after crashes instead of relying only on lazy lookup cleanup.
- Multi-node hardening tests: added coverage for reconnect, remote group leave, ordered cross-node push delivery, and continued operation after node crash cleanup.
Documentation
- Added cluster/backplane documentation for setup, supported distributed operations, and cluster semantics.
[4.2.3]
Fixed
- Streaming methods returning interface types threw on invocation: Updated
Cocoar.Reflectensionsfrom 1.0.0 to 1.0.2, fixing a type compatibility check that rejected interface return types (e.g.IAsyncEnumerable<T>) when cast toobject. This causedStreamMessageto fail with "Method returns a Type of 'IAsyncEnumerable`1[System.Object]' which is not implicitly castable to System.Object".
[4.2.2]
Fixed
- Server-to-Client push crash with null arguments:
nullarguments for reference type parameters (e.g.,string?) caused aMissingMethodExceptionin the client's message dispatcher (Activator.CreateInstance(typeof(string))), silently killing the entire handler invocation. This was the root cause of server-to-client push failures in ConfigHub and any other consumer passingnullto typed proxy methods. - Silent error swallowing in client message handler:
MessageHandler.InvokeServerMessage()had acatch { // ignored }block that silently swallowed all exceptions during server-to-client dispatch. Replaced withILogger.LogError()so developers can diagnose failed pushes.
Added
- 4 new server-to-client push integration tests: Cross-assembly tests using
ITestServerPushClientfrom a separate shared-models assembly — tests fire-and-forget push, invoke with return value, null parameter handling, and multi-client broadcast.
[4.2.1]
Fixed
- Source Generator not included in NuGet packages: The
Cocoar.SignalARRR.SourceGenerator.dllwas never bundled in the NuGet packages due to a broken MSBuild pack target — the generator only worked with local project references, not when consumed via NuGet. Fixed by using static<None Pack="true">items instead of a dynamic target. - Source Generator now ships in Server and Client packages: The generator is bundled in
Cocoar.SignalARRR.ServerandCocoar.SignalARRR.Client(where proxies are actually used), not inCocoar.SignalARRR.Contracts(which is only for attributes). Consumer projects get proxy generation automatically by referencing Server or Client — no extra package reference needed.
[4.2.0]
Added
- Transport-Level Authentication: Support for client certificates (mTLS), cookies, and Windows/Negotiate authentication alongside existing token-based auth. The server auto-detects the authentication mode per client and re-validates transport credentials server-side when the auth cache expires — no challenge round-trip needed.
AuthenticationModeenum:None,MessageLevel,TransportLevel— exposed onClientContext.AuthModefor per-client auth mode inspectionClientContext.ClientCertificate: Stores the client certificate from the TLS handshake for server-side re-validationITransportAuthRevalidationService: Pluggable interface for custom transport-auth re-validation logic (e.g., custom CRL endpoints, OCSP stapling, session store checks)DefaultTransportAuthRevalidationService: Built-in implementation with certificate expiry checks, X509 chain validation (CRL/OCSP), and custom validator callback support- Certificate validation options:
WithCertificateRevocationCheck(bool),WithCertificateRevocationMode(X509RevocationMode),WithCustomCertificateValidator(Func<X509Certificate2, bool>)onSignalARRRServerOptionsBuilder - Mixed-mode authentication: A single hub can serve both token-based and certificate-based clients simultaneously
- Certificate refresh on reconnect:
ClientContextupdates transport credentials (cert + principal) when a client reconnects with a new certificate - 9 new integration tests for transport-level auth: cert auth, auto-detection, cache expiry with server-side re-validation, expired cert rejection, AllowAnonymous bypass, and mixed-mode scenarios
Fixed
- Source Generator cross-assembly discovery: The Source Generator now discovers
[SignalARRRContract]interfaces in referenced assemblies (not just in the current compilation's source code). This fixes the common scenario where contract interfaces are defined in a shared library and referenced by server/client projects. The generator only scans assemblies that referenceCocoar.SignalARRR.Contracts, avoiding unnecessary work.
[4.1.0]
Added
- Multi-targeting: All library packages now support
net8.0,net9.0, andnet10.0 Cocoar.SignalARRR.Client.FullFramework: New client package targetingnetstandard2.0for .NET Framework 4.6.2+ — typed proxies viaDispatchProxy, streaming (viaMicrosoft.Bcl.AsyncInterfacespolyfill), server-to-client RPC, cancellation, file transfer, and optional MessagePack support- Framework-conditional package versions: ASP.NET Core packages automatically resolve to the correct version per target framework (8.0.x / 9.0.x / 10.0.x), preventing transitive
MissingMethodExceptionon older runtimes - CI matrix testing: PR validation now tests on .NET 8, 9, and 10 across Ubuntu, Windows, and macOS
- Typed broadcasts:
WithHub<T>().WithGroup().SendAsync<T>()— chainable LINQ-style API for typed fire-and-forget sends to groups, filtered clients, and all clients - Group tracking:
ClientManager.AddToGroupAsync/RemoveFromGroupAsyncsyncs both SignalR groups andClientContext.Groupsfor queryable group membership - Typed multi-client invoke:
InvokeAllAsync<T, TResult>()(parallel, all results) andInvokeOneAsync<T, TResult>()(first responder wins) onIEnumerable<ClientContext> - Documentation: "vs. gRPC vs. REST" comparison page, updated client comparison with all four clients, MessagePack install instructions
Changed
- MessagePack is now optional:
Microsoft.AspNetCore.SignalR.Protocols.MessagePackis no longer a dependency ofCocoar.SignalARRR.Client. Install it separately and call.AddMessagePackProtocol()when needed — same as with raw SignalR. Cocoar.SignalARRR.Commonnow also targetsnetstandard2.0(withSystem.Text.JsonNuGet polyfill)
Fixed
- SignalR 10.0.5 compatibility: Client-side
InvokeServerMessagehandler now properly awaits async operations instead of fire-and-forget (was causing connection drops on SignalR 10.0.5) npm versionin CI: Added--allow-same-versionflag to prevent failures when calculated version matchespackage.json
[4.0.0]
Swift Client — Complete Rewrite (v4)
The Swift client has been rewritten from scratch. Microsoft's signalr-client-swift has been replaced by a native implementation with no external dependencies.
Breaking Changes (Swift)
HARRRConnection.create { builder in ... }replaced byHARRRConnection.create(url:)with named parametersasSignalRHubConnection()removed — no underlyingHubConnectionexists anymoreinvoke<T>andstream<T>now requireT: Decodableconstraint
Added (Swift)
- Native SignalR client (
SignalRWebSocketClient) — replacessignalr-client-swift, zero external dependencies - MessagePack protocol —
hubProtocol: .messagepackparameter, fully implemented without any library - Multiple transports — WebSockets, Server-Sent Events, Long Polling with automatic fallback
- Automatic reconnection — configurable
ReconnectPolicywith custom retry delays - Handshake timeout —
handshakeTimeoutparameter, defaults to 15s UnauthorizedException— thrown when the server rejects authenticationos_loglogging —logLevel: SignalRLogLevelparameter, visible in Xcode and Console.appHubProtocolKind— public enum.json/.messagepackfor protocol selectionTransportType— public enum.webSockets/.serverSentEvents/.longPolling- Concurrent handler dispatch — each server→client invocation runs in its own
Task, fixing the fundamental concurrency bug insignalr-client-swiftwhereCancelTokenFromServercould be blocked by a running handler - 31 integration tests — including 5 new MessagePack tests covering invoke, guid, send, echo, and streaming
Fixed (Swift)
CancelTokenFromServernow dispatched immediately even while other handlers are running (was blocked by Actor serialization insignalr-client-swift)- Cancellation tests complete in ~0.2s instead of timing out at 30s
Breaking Changes
- Target framework changed from
netstandard2.0tonet10.0 ImpromptuInterfaceremoved — replaced by source-generated proxies and opt-inDispatchProxyfallback- Proxy creation now requires
[SignalARRRContract]attribute on interfaces (or referenceCocoar.SignalARRR.DynamicProxyfor runtime fallback) - Custom
IAuthenticatorinterface removed — use ASP.NET Core authentication handlers and[Authorize]policies instead - HTTP Proxy pass-through feature removed (deferred to Phase 2)
netstandard2.0target dropped — all packages now targetnet10.0(except SourceGenerator which targetsnetstandard2.0per Roslyn requirements)- Hub-level
[Authorize]inheritance restored: if the Hub class has[Authorize], ServerMethods classes inherit it automatically (behavior change from v2.x where this was disabled)
Added
- Source Generator: Compile-time proxy generation from
[SignalARRRContract]interfaces — zero reflection, AOT-friendly Cocoar.SignalARRR.Contracts: Single-reference meta-package for shared interface projects (attribute + generator + ProxyGenerator)Cocoar.SignalARRR.DynamicProxy: Opt-inDispatchProxy-based runtime fallback for plugin/dynamic scenarios- CancellationToken server-to-client propagation: Server can pass
CancellationTokento client methods and cancel remotely ServerProxyCreatorHelper.StreamAsync<T>: Server can requestIAsyncEnumerable<T>streams from client methodsServerStreamManager: Channel-based stream correlation for server-initiated client streamsStreamItemToServer/StreamCompleteToServerhub methods for client-to-server stream item deliveryClientManagertyped extensions:GetTypedMethods<T>(connectionId)for server-to-client RPC from outside hub context- Authorization integration tests: Tests for authenticated calls, unauthenticated rejection, and hub-level auth inheritance
TreatWarningsAsErrors: Enabled globally viaDirectory.Build.props
Removed
ImpromptuInterfacedependencynetstandard2.0target / polyfill packages (Microsoft.Bcl.AsyncInterfaces,System.Threading.Channels, etc.)SignalARRRDynamicProxy.csandStreamingType.csfrom ProxyGenerator (replaced by DispatchProxy package)- Non-generic
Invoke(Type returnType, ...)overloads fromClientProxyCreatorHelperandServerProxyCreatorHelper(unused; DynamicProxy dispatches to generic methods) - Old
RegisterMethodsclient-side registration API (replaced byRegisterInterface) - Custom
IAuthenticatorinterface andTryAuthenticate/SetAuthDataonClientContext - Dead commented-out code throughout the codebase
[2.1.2] - Previous Release
Features
- Split hub methods across multiple classes via
ServerMethods<T> - Method-level authorization with
[Authorize]attribute - Continuous token validation with automatic challenge/refresh
- Server-to-client RPC with response awaiting
- Support for
IObservable<T>,IAsyncEnumerable<T>, andChannelReader<T>streaming - Type-safe client proxies from interfaces
- Multi-platform support (Server, .NET Client, TypeScript Client)