Skip to content

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 OperationCanceledException out of StopAsync (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): StopAsync checked 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 — AddSignalARRRPostgresBackplane or AddSignalARRRRedisBackplane called 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 Authorization header, like negotiate already did, instead of an access_token query 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 an EventSource. 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 new transportCredential option set to .query (Swift) or TransportCredential.QUERY (Kotlin).
  • UseSignalARRRAccessTokenValidation no longer overwrites an Authorization header: it copied the access_token query 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: ConnectionBoundSchemes was a list on SignalARRRServerOptions, but AddSignalARRR hands out the builder, which had no way to reach it — the documented options.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.
  • transportCredential option 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, and credential for one credential covering both — WithConnectionCredential, WithMessageCredential and WithCredential in .NET and the .NET Framework client, which gains Create(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 answer ChallengeAuthentication during 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: at Create/create in .NET, TypeScript and Kotlin, from start() in Swift, whose create does not throw. In .NET and TypeScript the connection credential becomes SignalR's AccessTokenProvider/accessTokenFactory, so it needs the overload where SignalARRR builds the connection — Create(builder => ...) in .NET, the new HARRRConnection.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 Code of the error envelope, which .NET and Kotlin already exposed, so an app can tell unauthorized from method_not_found without matching on the message. TypeScript's invoke() now rejects with code and normalizedCode next to type and message (HARRRInvocationError), and HARRRErrorCodes/normalizeErrorCode are exported; Swift's HARRRError gained code, version and normalizedCode, with HARRRErrorCodes. 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: statusCode on Kotlin's NegotiationFailedException, on Swift's SignalRError, and on the error TypeScript's start() rejects with — SignalR's JavaScript client mentions the status only in its message. .NET already had it as HttpRequestException.StatusCode. A 401 or 403 there means the connection credential was missing or rejected.
  • headers option in the Swift client: extra headers on the negotiate request and every transport request, as Kotlin has with headers and .NET and TypeScript through SignalR's connection options. A header named Authorization takes 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 and Flow are the async model: invoke and send suspend, stream returns a cold Flow whose cancellation cancels the server-side stream. The full SignalARRR surface is there — token challenges, structured errors with codes (HARRRException.code), server-to-client handlers with onServerMethod, registerInterface and stream handlers, HTTP stream references in both directions (ByteArray, File, InputStream arguments are uploaded, Stream parameters arrive as bytes), and cancellation propagation in the idiomatic form: the handler's coroutine is cancelled when the server cancels, so delay and every other suspending call just stops. @HubProxy interfaces get a typed proxy generated at build time by a KSP processor; ProxyKind addresses contract interfaces, ServerMethods classes and hub methods alike, and Kotlin's camelCase members map to .NET's PascalCase by default. The plain SignalRClient underneath 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) and authorization (TypeScript) — the message credential, now WithMessageCredential/messageCredential; messageAccessTokenProvider (Kotlin), which also covered the connection unless accessTokenProvider was set — now credential, or messageCredential for the message alone; and accessTokenFactory: on Swift's HARRRConnection.create(url:), one factory for both — now options: HARRRConnectionOptions(credential:). In the .NET Framework client, the accessTokenProvider parameter of HARRRConnection.Create and of the constructor — despite its name the message credential, not SignalR's connection token — now WithMessageCredential.

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 for Npgsql >= 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.Client and Cocoar.SignalARRR.Server did 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 the Cocoar.SignalARRR.SourceGenerator package 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 only Cocoar.SignalARRR.Contracts still 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 a ChallengeAuthentication is 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 an IObservable<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 (PublishAsync for 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 at COMMIT; 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 a publish function 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 committing NOTIFY transactions anyway. Upgrading from 5.1.0-beta.2 with WithAutoCreateSchema(false): run SignalARRRPostgresBackplaneSchema.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 messages table, 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 than MessageRetention (new option, default five minutes) is reported as a gap — a warning and the signalarrr.backplane.catch_up.gaps counter — 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.reconnects and signalarrr.backplane.messages.replayed, make the behaviour visible. The listener session now carries application_name=signalarrr-backplane-listener:{nodeId}, so it can be told apart in pg_stat_activity. The messages table gained origin_node_id and target_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/NOTIFY carries 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) plus SignalARRRPostgresBackplaneSchema.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.txt is 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.Server is generated from the documentation (website/scripts/sync-skill.mjs, verified in CI): a hand-written SKILL.md header 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 feed llms.txt. It ships in a skills/ folder at the package root — not under contentFiles, 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 .targets file in the package wrote skills/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: ServerMethods classes were scanned with a bare GetMethods(), which also returns inherited members. For class Rooms : ServerMethods<MyHub> that registered Rooms.get_ClientContext, Rooms.set_Logger, Rooms.ToString and the rest. None of them carry [Authorize], so authorization returned success — and get_ClientContext returns the caller's ClientContext, i.e. principal, claims, client certificate, remote IP and attributes. The scan now takes only declared methods, excluding property accessors and anything declared on ServerMethods or object. Members inherited from a user-defined intermediate base class are still included.
  • [Authorize] was resolved from DeclaringType, so inherited methods lost their protection: methods are registered from the concrete class, so for [Authorize] class AdminMethods : BaseCrud the inherited method resolved against the undecorated base and came back unrestricted. Resolution now starts at ReflectedType and walks its base chain.
  • Interface dispatch authorized the contract while the implementation ran: the stored MethodInfo was the interface declaration, so [Authorize] on the implementing class was invisible and every Interface|Method call 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 IAuthorizeData attributes were ignored: only the concrete AuthorizeAttribute was collected, although IAuthorizeData is the ASP.NET Core contract. A custom attribute implementing it was silently skipped, leaving the method unrestricted. [AllowAnonymous] is likewise now detected through IAllowAnonymous at 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: download and upload are 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, and MapSignalARRRHub returns a builder that fans every convention out to the hub and its two transfer endpoints. That return type is now IHubEndpointConventionBuilder rather than SignalR's concrete HubEndpointConventionBuilder; 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 Stream argument 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 and WaitForUpload rejects 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: RequestUploadSlot is an ordinary hub method with no quota, and each slot pinned a dictionary entry and a TaskCompletionSource for 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 new upload_slot_limit_reached code, 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 MaxUploadSizeBytes while copying rather than trusting Content-Length, which may be absent or wrong.
  • One client could hijack another's stream: StreamItemToServer and StreamCompleteToServer looked 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|Method name 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 a null element 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 overload GetMethods() 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. Two ServerMethods classes 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 Message and 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 to internal, it meant a SqlException naming the host and database, a FileNotFoundException naming 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's EnableDetailedErrors=false default prevents, and passing the contract through bypassed it with no way to opt back in. internal now 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 as new 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 a HARRRException and say so.

Fixed ​

  • Per-message re-authentication ran against a context with nothing on it: re-validating a message credential means handing IAuthenticationService.AuthenticateAsync an HttpContext, and a message over an open socket is not an HTTP request — so one was fabricated, carrying the Authorization header and nothing else. No host, no scheme, no Items. 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, ClientContext reached 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 the HttpContext itself, which belongs to its request. Reported by AlertHub.

  • A hub with its own contract lost it as soon as a ServerMethods class had one too: the interface registry is keyed by hub type, but the ServerMethods branch looked its entry up by the ServerMethods class — 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 answered Interface '…' 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 CancellationTokenSource per call and one per CancellationToken parameter. 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. CreateLinkedTokenSource registers 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 returning IAsyncEnumerable keeps its token, because the stream is consumed after the call returns.

  • Stream arguments were silently not uploaded on half the invoke paths: only the generic InvokeCoreAsync<TResult> and SendCoreAsync replaced a Stream argument with an uploaded StreamReference. The void overload, the Type-based one and the three streaming ones handed the live Stream to the hub protocol serializer instead — MessagePack threw, JSON emitted a {"CanRead":true,…} object the server then failed to bind. So InvokeAsync("SaveFile", stream) failed while InvokeAsync<bool>("SaveFile", stream) worked, for the same call.

  • Disconnecting an unregistered connection threw out of the teardown path: OnDisconnectedAsync logged the client's IP from the ClientContext that UnRegister returned, which is null when the connection was never registered or when the callback runs twice. With LogClientIpAddresses enabled and debug logging on, that dereference threw an NullReferenceException from 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.MessagePack in all pinned versions (GHSA-f8h2-vmm9-qhj6), which with TreatWarningsAsErrors failed 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, and MessagePack to 2.5.302 to match what those packages depend on.

  • An async void cancellation callback could terminate the process: CancellationToken.Register takes an Action, so the async lambda registered for remote token cancellation compiled to async 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.Logger was assigned NullLogger.Instance in the constructor and never reassigned, so every LogError/LogDebug in the hub base class was a no-op. Invocation failures, connect/disconnect failures and stream write errors were silently lost unless a consumer set hub.Logger themselves, which was undocumented.

  • A client could park thread pool threads indefinitely: a server method with a Stream parameter waited for the client to upload it through a blocking RunSync, 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, receives ConnectionAborted, and the wait has a configurable timeout.

  • Upload slots never expired: RequestUploadSlot is 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 ActivatorUtilities although the live instance was already at hand. That bypassed IHubActivator and hub filters, re-ran the hub constructor per message, made state established in OnConnectedAsync invisible to the method, and leaked — Hub is IDisposable, but ActivatorUtilities does not register instances for scope disposal.

  • Short client-to-server streams silently returned no items: CompleteStream removed the stream entry before completing the writer, while ReadStream is an async iterator whose lookup does not run until the first MoveNextAsync. 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. OnDisconnectedAsync now completes them.

  • AddSignalARRR threw at startup for a ServerMethods class with an intermediate base class: the hub type was read as BaseType.GenericTypeArguments[0], which is empty unless ServerMethods<THub> is the direct base, so the whole application failed to start with an IndexOutOfRangeException. The inheritance chain is now walked properly, and abstract classes are skipped.

  • Failures from individual invoke candidates were destroyed: two empty catch blocks 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 the InnerException/AggregateException.

  • Three Console.WriteLine calls in library code wrote past the logging pipeline to stdout; replaced with ILogger.

  • 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 I lowercases to ı, so a slot created by one request could not be found by the next. Now ToLowerInvariant.

  • A transient Redis error ended the backplane heartbeat permanently: the heartbeat loop caught only OperationCanceledException, and the priming calls sat outside the try entirely, so a single RedisConnectionException — 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 NodeTimeout was 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; CleanupNodeIfDeadAsync skips 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 InvokeAllAsync throw after the full InvokeTimeout — 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 the try, so the finally that publishes InvokeQueryCompleted never 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. ClientRequestMessage gains WithAuthorizationAsync, and every send path awaits it.

  • Every server-to-client call left a cancellation callback on the caller's token: CancellationToken.Register returns a registration that unhooks the callback, and three places dropped it — the per-call id in ServerProxyCreatorHelper and the per-parameter ids in MethodArgumentPreparer, 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 the ClientContext — 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 in BroadcastArgumentRules is 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.UtcNow at creation and compared it against DateTime.UtcNow minus 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 with Stopwatch timestamps now.

  • Transport-authenticated clients were denied every call once the auth cache expired: on the ordinary call path, SignalARRRAuthentication.Authorize re-authenticated from scratch against a freshly built DefaultHttpContext — 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 for AuthCacheDuration (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 plain URLRequest in Swift. So a hub with [Authorize] answered 401 to every Stream argument and every Stream return value, which is to say file transfer stopped working entirely for token-authenticated applications, and worked in a browser only same-origin, by accident, because fetch sends cookies there. All four clients now send the connection's credential, using the server's own convention: a credential without a space is sent as Bearer <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: SignalRWebSocketClient took 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 — accessTokenFactory fed the per-message field and nothing else. A hub with [Authorize] on the class rejected the client at /negotiate with 401, and it worked only where authorization sat on methods, carried by SignalARRR's own message credential. The client now sends the credential as an Authorization header on negotiate and as the access_token query 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 sets AuthCacheDuration and 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 accessTokenFactory broke every call the client made: the factory was typed and called as () => string, although SignalR's own contract is string | Promise<string> and every OAuth-backed application returns the promise. Typing it away did not make it synchronous: the promise object was serialized into ClientRequestMessage.Authorization, arrived at the server as {}, and the message — whose Authorization is a string — failed to bind. Every invoke, send and stream on that connection died with Error 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 its IStreamResult synchronously, but the stream is opened only once the token is in hand, so a factory that rejects is reported to the subscriber's error() instead of being thrown out of stream() — for invoke and send it 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: invoke and send replace a Blob, ArrayBuffer or Buffer argument with a stream reference and upload the bytes over HTTP, but stream skipped that step and sent the object raw, because the message had to be built synchronously. It now prepares its arguments like the other two, matching PrepareStreamArguments in the .NET client and buildClientRequest in 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. Use WithAuthorizationAsync, or WithAuthorization(string) for a token that is already at hand — that overload is untouched.

  • BREAKING — ClientManager.GetHARRRClients<T>() and GetHARRRClients<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 onto WithHub<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 read AccessTokenProvider, and the TypeScript client read connection._options.accessTokenFactory off the object. Both then used it to fill ClientRequestMessage.Authorization on 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: WithAuthorization on the .NET options builder (also accepting Func<string> and string), authorization on HARRRConnectionOptions in 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 on HARRRConnection.create and now also has a separate one on SignalRWebSocketClient for 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 always Namespace.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 what v2 means belongs to the application. Client-to-server calls on hub methods and ServerMethods classes are unaffected — they have always honoured the attribute. See the new Contract Wire Names guide.

  • Cocoar.SignalARRR.Contracts now depends on Cocoar.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.GetClientById is typed ClientContext?: it was declared non-nullable and returned null! for a connection this node does not have — so a consumer with nullable reference types enabled got no warning and an unexplained NullReferenceException instead, 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 internal IHARRRClientManager.GetClient and UnRegister are annotated the same way. Existing call sites that already null-check keep working unchanged; ones that dereference the result now get CS8602 exactly where they were relying on a promise the library could not keep. HARRR.ClientContext stays non-nullable — inside a hub method the connection is registered by definition — but reading it before OnConnectedAsync has registered it now throws with an explanation rather than an NullReferenceException further down.

  • BREAKING — WithHub<T>() returns IClientQuery instead of IEnumerable<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 no ClientContext in this process. SendAsync, InvokeAllAsync, InvokeOneAsync and the With* filters are now instance methods on IClientQuery, 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 reach ClientContext objects. 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 every IEnumerable<ClientContext> in user code, and the fluent API works without importing Cocoar.SignalARRR.Server.ExtensionMethods.

  • BREAKING — HARRRConnectionExtensions is rewritten, and its arity ladder stops at four: the file was 2,426 lines of decompiled Microsoft.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 against HARRRConnection'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 the InvokeCoreAsync/StreamAsChannelCoreAsync extension methods, which were shadowed by identically-shaped instance methods on HARRRConnection and could never be reached. Past four arguments, use the object[] core methods or — better — a typed contract through GetTypedMethods<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.Server referenced StackExchange.Redis unconditionally 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 call AddSignalARRRRedisBackplane; that method, its options builder and their namespace are unchanged, so the only edit is the PackageReference. Nothing else moves: the backplane contracts stay internal in the server assembly and the new package reaches them through InternalsVisibleTo, 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 HttpContext are gone: HttpContextWriteActionExtensions reimplemented ControllerBase as extension methods — Ok, NotFound, BadRequest, ValidationProblem, File in 24 overloads, PhysicalFile in 8, down to LocalRedirectPermanentPreserveMethod — none of it related to RPC, all of it landing on every HttpContext in any file that imports Cocoar.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.Results and ControllerBase are the framework's own answers. Note this does not change the package graph, contrary to how the finding was written: the server takes a FrameworkReference on Microsoft.AspNetCore.App rather 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 AssemblyQualifiedName on the wire and resolved it with Type.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 to null, silently. Worse, the same string is interpolated into the Redis index keys ({prefix}:hub:{type}:connections and 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 plain FullName would 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 ChannelReader waiting forever: the pump behind each generated proxy method returning ChannelReader<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 a finally, 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, and StreamingResult discarded it — the enumerator was obtained without it, and a ChannelReader source 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 in MoveNextAsync and the producer behind it kept running. Iterators marked [EnumeratorCancellation] were silently not cancellable.

  • Inherited interface members were proxied but never registered: GetMethods on an interface returns only declared members, unlike on a class. The source generator walks AllInterfaces, so the proxy for IDerived : IBase implements IBase's methods and puts them on the wire as Ns.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 — IndexOutOfRangeException deep inside the binder for something as ordinary as Task<string> Wait(int seconds, CancellationToken ct). The DispatchProxy did 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 < paramsPosition sat above the index access and compared the wrong way round, so a short argument list produced a bare IndexOutOfRangeException instead 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. RegisterInterface is 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 NotSupportedException naming 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 __type marker: 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 string Id as 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() returns IReadOnlyList<IAuthorizeData> instead of List<AuthorizeAttribute>. Required to honour custom authorization attributes at all. A GetAuthorizationPlan() extension is also available, exposing the resolved [AllowAnonymous] state alongside the authorization data.

  • BREAKING — ServerStreamManager: CreateStream and CompleteStream take the owning connection id, and WriteItem became WriteItemAsync. 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.WaitForUpload requires a timeout. It waits on something only the client can supply, so a caller has to state a deadline.

  • HARRR.StreamItemToServer returns Task so that the bounded stream buffer can apply backpressure.

  • InvokeOneAsync now throws consistently when no client responds: the local path returned null behind a null-forgiving operator while the cluster path threw, so the same failure produced either a NullReferenceException at an unrelated frame or a clean exception, depending on whether a backplane was configured. Callers that inadvertently relied on the null return 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.DependencyInjection is 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 its Microsoft.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, CancellationToken and [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 and ServerMethods methods. 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 always Namespace.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 and ServerMethods methods; 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.GetMethodInformations and ISignalARRRInterfaceCollection.GetInvokeInformation take 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 error code instead 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 (carrying method_not_found / invalid_argument_count) instead of a bare Exception, and "no client responded" is a NoClientRespondedException (derives from InvalidOperationException; multiple collected failures now sit in an inner AggregateException instead of being the thrown type). Exact-type Assert.Throws/is-checks on the old types need updating; catch blocks on the base types keep working.

  • System.Text.Json pinned at 8.0.6 (was 8.0.5) — required by the new Microsoft.AspNetCore.SignalR.Common reference 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; ServerMethods instances 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-cloning GetParameters() 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.Now converted 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_FILE file logger is gone: it wrote six synchronous File.AppendAllText per connection lifecycle behind one global lock, slept a thread-pool thread on contention — and a third consecutive IOException escaped its filter and terminated the connection over a log-file problem. The timings it captured are now the signalarrr.server.connection.setup.duration/.teardown.duration histograms.

  • BREAKING — the entry points moved to the namespaces the rest of ASP.NET Core uses: AddSignalARRR and AddSignalARRRRedisBackplane are in Microsoft.Extensions.DependencyInjection, hub mapping is in Microsoft.AspNetCore.Builder, instead of Cocoar.SignalARRR.Server.ExtensionMethods. Both namespaces are already in scope in an unmodified Program.cs, so the methods now show up in completion where every other library puts them — previously builder.Services.Add… offered nothing and a hello-world needed four usings to find its own entry point. Sharing a namespace with everyone else makes a name like ServiceCollectionExtensions ambiguous at the call site, so the classes carry the product name: SignalARRRServiceCollectionExtensions and SignalARRREndpointRouteBuilderExtensions. On the same grounds MapHARRRController<THub> is now MapSignalARRRHub<THub> — it maps a hub, and "controller" means something else in ASP.NET Core. The empty and never-referenced MapHARRRControllerOptions is deleted; the options overload has always taken SignalR's own HttpConnectionDispatcherOptions. Drop the using Cocoar.SignalARRR.Server.ExtensionMethods; line unless you also use the client-query extensions, which stay where they are for now.

  • BREAKING — ClientAttributes wraps a dictionary instead of deriving from one: it inherited Dictionary<string, StringValues> and hid the inherited indexer with a new one returning string?. The same object answered the same key two ways — null through ClientAttributes, KeyNotFoundException through the dictionary it was — with nothing at the call site to say which one you were holding. Inheriting also put Add, Remove and Clear on what is a read model of the connection request. It is now a sealed IReadOnlyDictionary<string, StringValues> with one indexer that keeps the interface's contract. The string? lookup lives on as GetString(key); use it wherever you relied on attributes[key] being null for an absent attribute. Has(key), Has(key, value), enumeration and case-insensitive matching are unchanged.

  • BREAKING — the wire method names are constants: MethodNames.InvokeServerMessage and .CancelTokenFromServer had 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 is static and all ten names are const.

  • BREAKING — naming defects: MethodArgumentPreperator → MethodArgumentPreparer (the typo, and internal now), the client's HARRRContext → ClientConnectionContext (internal; deliberately not the ConnectionContext the review proposed, which would collide with Microsoft.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 internal ObservableExtensions → SignalARRRObservableExtensions, which collided with System.ObservableExtensions from System.Reactive.

  • BREAKING — types that were never meant to be application-facing are internal: on the client MessageHandler, StreamReferenceResolver, ClientConnectionContext and HubConnectionExtensions — the last one reflects into SignalR's private fields, and being public implied that was supported; on the server SignalARRRAuthentication, MethodArgumentPreparer, EndpointExtensions and the InvokeDownload/InvokeUpload handlers behind the file-transfer endpoints. HARRRConnection's constructor goes with them, because ClientConnectionContext stands in its signature — HARRRConnection.Create(...) is and always was the way to build one. TypeHelper stays public, as a static 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.

  • ITransportAuthRevalidationService speaks RevalidationResult: BREAKING for implementations, which returned Task<bool>. The result carries an outcome — Valid, Deny, or Abort — 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 addresses AuthCacheDuration being 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. A bool still converts implicitly, so return true; keeps compiling.

  • Broadcast calls can carry cancellation tokens, and cancelling reaches every recipient: a CancellationToken argument 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 sends CancelTokenFromServer to 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 CancelTokenFromServer for 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 CancellationToken parameter now observes its own connection ending — previously it was bound to CancellationToken.None and kept running into the void after a disconnect, while a server method in the same situation observed ConnectionAborted. Every token the .NET clients bind (plain, per-call CancellationGuid, per-token reference) links to the connection lifetime via SignalR's Closed event; 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 binding ConnectionAborted (the answer would reach nobody), but the send path now binds ApplicationStopping — 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 keeps ConnectionAborted on 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 (name signalarrr) 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 HARRRError with a machine-readable Code — 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, and internal for "the invoked method itself threw". Application code puts its own codes on the wire with throw new HARRRException("room_full", "The room is full") — user codes travel verbatim and are never overwritten. The envelope carries a Version and nests the cause chain as InnerError (previously only GetBaseException() survived, discarding every intermediate step). Clients must treat unknown codes as internal, so the set can grow additively.

  • HARRRRemoteException: the .NET clients now throw it for failed remote calls — previously the caller got a bare HubException whose Message was a raw JSON string, and nothing in any client ever parsed it. It derives from HubException, so existing catch (HubException) blocks keep working; Message becomes the human-readable remote message, and the structured error is the typed Error property. The backplane rehydrates the same type — previously a failed cross-node invoke surfaced as InvalidOperationException while the same failure on a single node was a HubException.

  • Fire-and-forget error semantics documented (N-1, deliberate): a void/Task client method that throws reports the error only to the client-side log — SendAsync completes successfully on the server. This is a property of the fire-and-forget path, not a bug: callers that need failures use Task<T>. An error back-channel was considered and deliberately deferred.

  • Log correlation (O-5): every invocation now runs inside a log scope carrying SignalARRRConnectionId, SignalARRRMethod and SignalARRRInvocationId — 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 additive InvocationId field on ClientRequestMessage (assigned once per invocation by the .NET client; raw callers may omit it), server-to-client calls reuse the message's existing Id, and both also appear as the signalarrr.invocation_id span tag.

  • SignalARRRServerOptions.LogClientIpAddresses (default false) with WithClientIpLogging(): 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/Meter name: 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 W3C TraceParent/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.swept and signalarrr.backplane.self_evictions.

  • SignalARRRServerOptions.StreamUploadTimeout (default 2 minutes), MaxUploadSizeBytes (default 100 MB), UploadSlotExpiration (default 10 minutes) and MaxUploadSlotsPerConnection (default 32, 0 to disable), with matching WithStreamUploadTimeout, WithMaxUploadSize, WithUploadSlotExpiration and WithMaxUploadSlotsPerConnection builder methods.

  • HARRRErrorCodes.UploadSlotLimitReached (upload_slot_limit_reached): a connection asked for more unused upload slots than it may hold. Its own code rather than the internal bucket because it is actionable — finish or abandon an upload and retry. Clients that predate it treat it as internal, which the contract requires of any unknown code.

  • HARRRConnection implements IAsyncDisposable: DisposeAsync() was already there and await using already 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 through IDisposable/IAsyncDisposable, so a connection registered as a singleton was never disposed and its HubConnection outlived 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 a ClaimsPrincipal, 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 an ITransportAuthRevalidationService to 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 the ClientManager registration 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 no Reconnecting/Reconnected events, because the connection never counts as lost — waiting for one waits forever; and the client's ServerTimeout must 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 ServerMethods class 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>, IOrderRepository puts DeleteAll on 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 as interface|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): ISignalARRRBackplane and ISignalARRRConnectionRegistry are registered with TryAddSingleton, which reads as an invitation to substitute your own — but they are internal, so nobody outside the assembly can. The TryAdd turns out to be load-bearing for a different reason: the Redis package swaps its implementation in with Replace, and a consumer calling AddSignalARRRRedisBackplane before AddSignalARRR would 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. ITransportAuthRevalidationService is 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> and T travel as InvokeServerRequest and surface a throwing handler as a HubException, while void and Task travel as InvokeServerMessage, 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, on SendAsync in 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 onto RegisterInterface and 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 showed connection.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 to HubConnection.On and matches raw SignalR method names, while a contract call arrives inside an InvokeServerRequest/InvokeServerMessage envelope 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 raw SendAsync.

Fixed (build and CI) ​

  • The PR validation matrix could not build the solution: it passed --framework to dotnet build, which sets TargetFramework as a global property and thereby overrides the framework of every project — including the netstandard2.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: ServerFixture redirects the test server's stdout and never reads it — stderr was read only after the process had already exited. dotnet run writes 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 bare did not start within 120 seconds that named no cause. Whether it triggers depends on how chatty that build output is, which is why CI never saw it: src/Nuget.config lists only nuget.org but has no <clear/>, so a machine-level package source merges in and every project emits an NU1507 warning — 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 that Microsoft.AspNetCore.SignalR.Protocols.MessagePack depends 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 send server methods silently did nothing (e.g. group joins): HARRR.SendMessage ran the target method in a detached Task.Run after SignalR had already disposed the Hub, so any access to the hub-injected Context/Clients/Groups (such as Groups.AddToGroupAsync) silently failed and the method body never took effect. The method is now awaited within the hub invocation, keeping the Hub alive for its duration. Affects every client — the typed proxy routes any void/Task-returning server method through this send path, so this was hit without explicitly choosing send.
  • (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 with URLComponents so existing query parameters are preserved and /negotiate is inserted into the path.
  • (Swift) Connection hung ~30s against localhost/IPv6: when localhost resolved 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 the 127.0.0.1 workaround 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 with on(...) (which only handles raw SignalR events). The on(...) vs onServerMethod(...) 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 live Hub (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 distributed WithUser(...) / WithAttribute(...).
  • Cluster presence APIs: ClientManager now 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.Reflectensions from 1.0.0 to 1.0.2, fixing a type compatibility check that rejected interface return types (e.g. IAsyncEnumerable<T>) when cast to object. This caused StreamMessage to 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: null arguments for reference type parameters (e.g., string?) caused a MissingMethodException in 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 passing null to typed proxy methods.
  • Silent error swallowing in client message handler: MessageHandler.InvokeServerMessage() had a catch { // ignored } block that silently swallowed all exceptions during server-to-client dispatch. Replaced with ILogger.LogError() so developers can diagnose failed pushes.

Added ​

  • 4 new server-to-client push integration tests: Cross-assembly tests using ITestServerPushClient from 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.dll was 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.Server and Cocoar.SignalARRR.Client (where proxies are actually used), not in Cocoar.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.
  • AuthenticationMode enum: None, MessageLevel, TransportLevel — exposed on ClientContext.AuthMode for per-client auth mode inspection
  • ClientContext.ClientCertificate: Stores the client certificate from the TLS handshake for server-side re-validation
  • ITransportAuthRevalidationService: 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>) on SignalARRRServerOptionsBuilder
  • Mixed-mode authentication: A single hub can serve both token-based and certificate-based clients simultaneously
  • Certificate refresh on reconnect: ClientContext updates 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 reference Cocoar.SignalARRR.Contracts, avoiding unnecessary work.

[4.1.0] ​

Added ​

  • Multi-targeting: All library packages now support net8.0, net9.0, and net10.0
  • Cocoar.SignalARRR.Client.FullFramework: New client package targeting netstandard2.0 for .NET Framework 4.6.2+ — typed proxies via DispatchProxy, streaming (via Microsoft.Bcl.AsyncInterfaces polyfill), 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 MissingMethodException on 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/RemoveFromGroupAsync syncs both SignalR groups and ClientContext.Groups for queryable group membership
  • Typed multi-client invoke: InvokeAllAsync<T, TResult>() (parallel, all results) and InvokeOneAsync<T, TResult>() (first responder wins) on IEnumerable<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.MessagePack is no longer a dependency of Cocoar.SignalARRR.Client. Install it separately and call .AddMessagePackProtocol() when needed — same as with raw SignalR.
  • Cocoar.SignalARRR.Common now also targets netstandard2.0 (with System.Text.Json NuGet polyfill)

Fixed ​

  • SignalR 10.0.5 compatibility: Client-side InvokeServerMessage handler now properly awaits async operations instead of fire-and-forget (was causing connection drops on SignalR 10.0.5)
  • npm version in CI: Added --allow-same-version flag to prevent failures when calculated version matches package.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 by HARRRConnection.create(url:) with named parameters
  • asSignalRHubConnection() removed — no underlying HubConnection exists anymore
  • invoke<T> and stream<T> now require T: Decodable constraint

Added (Swift) ​

  • Native SignalR client (SignalRWebSocketClient) — replaces signalr-client-swift, zero external dependencies
  • MessagePack protocol — hubProtocol: .messagepack parameter, fully implemented without any library
  • Multiple transports — WebSockets, Server-Sent Events, Long Polling with automatic fallback
  • Automatic reconnection — configurable ReconnectPolicy with custom retry delays
  • Handshake timeout — handshakeTimeout parameter, defaults to 15s
  • UnauthorizedException — thrown when the server rejects authentication
  • os_log logging — logLevel: SignalRLogLevel parameter, visible in Xcode and Console.app
  • HubProtocolKind — public enum .json / .messagepack for protocol selection
  • TransportType — public enum .webSockets / .serverSentEvents / .longPolling
  • Concurrent handler dispatch — each server→client invocation runs in its own Task, fixing the fundamental concurrency bug in signalr-client-swift where CancelTokenFromServer could be blocked by a running handler
  • 31 integration tests — including 5 new MessagePack tests covering invoke, guid, send, echo, and streaming

Fixed (Swift) ​

  • CancelTokenFromServer now dispatched immediately even while other handlers are running (was blocked by Actor serialization in signalr-client-swift)
  • Cancellation tests complete in ~0.2s instead of timing out at 30s

Breaking Changes ​

  • Target framework changed from netstandard2.0 to net10.0
  • ImpromptuInterface removed — replaced by source-generated proxies and opt-in DispatchProxy fallback
  • Proxy creation now requires [SignalARRRContract] attribute on interfaces (or reference Cocoar.SignalARRR.DynamicProxy for runtime fallback)
  • Custom IAuthenticator interface removed — use ASP.NET Core authentication handlers and [Authorize] policies instead
  • HTTP Proxy pass-through feature removed (deferred to Phase 2)
  • netstandard2.0 target dropped — all packages now target net10.0 (except SourceGenerator which targets netstandard2.0 per 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-in DispatchProxy-based runtime fallback for plugin/dynamic scenarios
  • CancellationToken server-to-client propagation: Server can pass CancellationToken to client methods and cancel remotely
  • ServerProxyCreatorHelper.StreamAsync<T>: Server can request IAsyncEnumerable<T> streams from client methods
  • ServerStreamManager: Channel-based stream correlation for server-initiated client streams
  • StreamItemToServer / StreamCompleteToServer hub methods for client-to-server stream item delivery
  • ClientManager typed 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 via Directory.Build.props

Removed ​

  • ImpromptuInterface dependency
  • netstandard2.0 target / polyfill packages (Microsoft.Bcl.AsyncInterfaces, System.Threading.Channels, etc.)
  • SignalARRRDynamicProxy.cs and StreamingType.cs from ProxyGenerator (replaced by DispatchProxy package)
  • Non-generic Invoke(Type returnType, ...) overloads from ClientProxyCreatorHelper and ServerProxyCreatorHelper (unused; DynamicProxy dispatches to generic methods)
  • Old RegisterMethods client-side registration API (replaced by RegisterInterface)
  • Custom IAuthenticator interface and TryAuthenticate/SetAuthData on ClientContext
  • 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>, and ChannelReader<T> streaming
  • Type-safe client proxies from interfaces
  • Multi-platform support (Server, .NET Client, TypeScript Client)

Released under the Apache-2.0 License.