PackageTrack
Sign in Get early access

idct/php-nats-jetstream-client

Async-first NATS + JetStream client for PHP 8.2+

v2.8.0 57K downloads/mo #3600 most downloaded on Packagist ideaconnect/php-nats-jetstream-client

What this package is like to depend on

Last release 15 days ago

08 Aug 2026

Ships fairly regularly

a new release about every 4 weeks

Most releases are documented

notes for 16 of 18 stable releases

Nothing withdrawn

no release was ever pulled

5 months old

18 releases · first in 2026

18 releases in the last 12 months

see the full history below

Release timeline

18 releases · Mar 2026 to Aug 2026
Release Pre-release

Releases

latest 18
  1. v2.8.0 08 Aug 2026
    Release notes

    Minor release. New public API for stopping ordered consumers and watches, plus the outcome of three full review rounds against the NATS.io specification that closed 59 findings, most of them cases where a message could be lost or a consumer could stall silently.

    Upgrading is recommended for anyone using KV or Object Store watches, ordered consumers, pull consumers, WebSocket transport, or drain().

    Added

    • [feature] JetStreamContext::stopOrderedConsumer(int $sid) stops an ordered consumer, or a KV / Object Store watch, even after automatic recreates rotated its internal subscription id, and deletes the server-side ephemeral consumer instead of leaving it to expire. A plain unsubscribe($sid) only ever worked until the first recreate, so this is now the documented way to stop any of them.
    • [feature] KeyValueBucket::bind() resolves a mirrored bucket's read and write prefixes from STREAM.INFO. It is required on any handle that did not itself run create(), including a fresh keyValue() handle in the same process, before reads and write-through behave correctly.
    • [feature] NatsHeaders::get() looks a header up case-insensitively, preferring an exact-case match. Publishers differ in how they canonicalize header names, so an exact-case array lookup could silently miss a header that was in fact present.
    • [feature] ObjectStoreBucket::watch() gained an exactName parameter for watching an object whose own name contains * or >, and ObjectStoreWatchOptions gained an idleHeartbeat argument.
    • [feature] subscribeOrderedConsumer() gained consumerOverrides and onConsumerCreated.

    Fixed, the highlights

    Messages that could be lost, and flows that could stall:

    • KV and Object Store watches are lossless. Both now ride the ordered-consumer machinery, so they detect sequence gaps and replay from the last seen revision instead of turning a slow-consumer drop or a reconnect window into a silent permanent hole. The Object Store watch previously requested no idle heartbeat at all and could hang forever with no signal.
    • A watchdog-triggered recreate that collided with a reconnect used to leave the watchdog latched, stalling the consumer or watch permanently and silently. It now retries with a fresh budget once the connection is open again.
    • The pipelined pull engine no longer treats a transient 503 (no JetStream API responder, which happens during a server restart or a leader election) as terminal. Without an onError handler the worker used to stop for good while handle() resolved normally, so it looked like a clean drain while messages piled up.
    • fetchBatch() and directGetBatch() reply inboxes are exempt from the slow-consumer drop, so a burst of small replies no longer silently loses the head of a fetch on a max_deliver: 1 consumer.
    • KV buckets created with sources now attach the ADR-57 subject transforms, so sourced entries land under this bucket's prefix where reads can actually see them. Mirror buckets write through to the origin instead of publishing into a stream that ingests nothing.
    • Pipelined Object Store uploads verify that chunk acknowledgements arrive in order, so a 503 retry cannot reorder chunks and store an object whose bytes no longer match its digest. Failed uploads purge their partial chunks.
    • Caller-owned push consumers perform the ADR-9 heartbeat gap check, including detecting a consumer replaced server-side, and report the mismatch through the error listener and the logger.
    • A permission-rejected pull reply inbox now fails fast, naming the wildcard permission to grant, instead of polling forever with no signal.
    • drain() and flush() can no longer hang forever against a stalled peer. Drain writes are bounded by the drain budget, handler publishes issued during a drain share that same budget, and drain always reaches the Closed state.

    Protocol and specification correctness:

    • The WebSocket transport enforces RFC 6455 and RFC 7692 strictly: masked server frames, fragmented or oversized control frames, RSV1 without negotiated compression, and handshakes missing the Upgrade headers or echoing an unsolicited extension all fail the connection. A fragmented PING used to splice its continuation into an in-progress data message, which corrupted the payload silently. Control-frame answers are now held as data slots, so a coalesced ping flood collapses to one pong for the newest ping and the mandatory Close echo always goes out.
    • Header values are written to the wire verbatim. The previous trim mutated values carrying a signature or checksum.
    • $SRV discovery responses serialize empty metadata as {} rather than [], which Go-based tooling rejected outright, making a metadata-less service invisible to nats micro ls.
    • Object Store addLink() matches nats.go's guard, watch() encodes exact names, and list() falls back to a leader read on buckets without direct get.
    • A micro-service request whose handler errored and whose error reply then also failed counted two errors, so $SRV.STATS could report more errors than requests.

    Contract changes worth reading before upgrading

    These correct clear bugs, so they are flagged as fixes rather than breaks, but they change what a caller observes:

    • drain() no longer throws when a write fails against a dead socket. It reports the failure through the error listener and still closes cleanly. Previously it threw and left the connection stranded mid-drain.
    • WebSocketFrameCodec::decode() no longer throws on a strictness violation. It returns the frames parsed before the violation and reports the violation through a new by-reference $terminal parameter, so already-decoded data is never discarded. It also rejects masked frames unless you pass allowMasked: true. WebSocketFrameCodec::unmask() is deprecated in favour of that parameter.
    • KV source and mirror names: the bucket alias is always KV_-prefixed, so a bucket literally named KV_x now resolves to its own stream rather than to bucket x. An explicit name, or a bare string entry, keeps the nats.go convention of being used as-is when it already starts with KV_.
    • A mirrored KV bucket handle that did not run create() must call bind() before its reads and writes route correctly.

    Quality gates

    PHPStan level 8, 2025 unit tests, 138 live integration tests, 47 Behat scenarios, 45 runnable examples executed against a live server, ~99.4% combined statement coverage (97% floor enforced in CI) and ~94% Infection covered MSI (90% floor enforced in CI).

    The full per-finding record of the three review rounds, including the ones that turned out to be regressions introduced by an earlier round's fix, is in the issues/ directory.

    Open source →
    Release notes

    Added

    • [feature] JetStreamContext::stopOrderedConsumer(int $sid): Future stops an ordered consumer, or a KV / Object Store watch, even after automatic recreates rotated its internal subscription id, and deletes the server-side ephemeral consumer instead of waiting for it to expire. A plain unsubscribe($sid) only ever worked until the first recreate, so this is now the documented way to stop any of them.
    • [feature] KeyValueBucket::bind(): Future resolves a mirrored bucket's read and write prefixes from STREAM.INFO. It is required on any handle that did not itself run create() (including a fresh keyValue() handle in the same process) before reads and write-through work correctly.
    • [feature] NatsHeaders::get(array $headers, string $name): ?string looks a header up case-insensitively, preferring an exact-case match. Publishers differ in how they canonicalize header names (nats.go canonicalizes on read), so an exact-case array lookup could silently miss.
    • [feature] ObjectStoreBucket::watch() gained an exactName parameter for watching an object whose own name contains * or >, and ObjectStoreWatchOptions gained an idleHeartbeat argument to tune the watch's heartbeat interval.
    • [feature] JetStreamContext::subscribeOrderedConsumer() gained consumerOverrides (extra consumer configuration merged into the created instance) and onConsumerCreated (invoked once with the initial instance's ConsumerInfo, for example to read num_pending).

    Deprecated

    • [docs] WebSocketFrameCodec::unmask() is deprecated. Masked server-to-client frames are now a terminal RFC 6455 violation, so the helper has no production caller left; harnesses that decode client-written frames should use decode(..., allowMasked: true). It still works and is still covered by tests, and will be removed no earlier than the next major release.

    Fixed

    • [bugfix] Ordered consumers (and the KV/OS watches riding on them): the disconnect-collision deferral introduced in this release cycle now clears the heartbeat watchdog's miss latch before returning. Without it, a WATCHDOG-triggered recreate (silent/reaped consumer) whose attempts all burned during a reconnect window left the latch set forever - no frame can arrive on the old inbox to clear it (the consumer was already dead and the recreate deleted it) - so every post-reconnect watchdog tick early-returned and the watch stalled permanently with zero signal. The watchdog now genuinely re-fires two idle intervals after the connection is Open again, as the deferral always promised. The deferral additionally rewinds the adopt-before-await dispatch state (consumer name, deliver inbox, expected sequence) to the pre-episode instance - when the episode's initial delete never took effect server-side, the surviving old consumer's post-reconnect frames pass the name filter and resume in-order delivery immediately instead of silently feeding the watchdog until the next idle heartbeat. The rewind runs ONLY when the episode delivered nothing: a candidate whose create succeeded with a lost reply may already have replayed frames to the handler, and rewinding then would re-admit the survivor's copies of stream sequences the handler already saw (duplicate ordered deliveries) - such an episode keeps the adopted state and falls back to the exactly-once filtered-until-heartbeat recovery. And the deferral decision is LATCHED, not sampled: any attempt observing the connection away from Open marks the episode as a disconnect collision, so a reconnect completing during the orphan-reap awaits can no longer flip it into a spurious terminal "recreate failed" teardown.

    • [bugfix] WebSocket transport hardening to RFC 6455/7692 strictness: the handshake now requires Upgrade: websocket / a Connection token list containing Upgrade, rejects extension responses that were never offered (an unsolicited permessage-deflate used to silently flip compression ON, deflating the CONNECT into a server that never negotiated it), and validates permessage-deflate parameters against the offered no-context-takeover pair - accepting a server-volunteered server_max_window_bits of 8..15 per RFC 7692 7.1.2.1 (in the token or quoted-string spelling, RFC 6455 9.1), since the 15-bit raw inflater decodes any smaller-window stream; the value-less spelling, out-of-range values, and leading zeroes are rejected per the section 7 grammar. Frame-level: masked server-to-client frames, fragmented/oversized control frames (a fragmented PING used to splice its continuation into an in-progress data message - silent payload corruption), and RSV1 without negotiated compression are now terminal protocol violations - all deferred via the #115 pattern, so data frames decoded from the same read are delivered BEFORE the violation surfaces (an oversized declared length, corrupt deflate payload, or fragment-bound overflow previously threw mid-batch and discarded them). The server must also echo server_no_context_takeover when accepting compression (RFC 7692 7.1.1.1) - this per-message-inflate codec cannot decode context takeover - and a one-shot deferred violation surfacing on the heartbeat timer's read now recovers the connection instead of being silently swallowed, reported through the guarded #150 emit path so a throwing user-supplied PSR-3 logger can neither skip the recovery nor escape into the event-loop timer. Note the contract change on the public WebSocketFrameCodec::decode(): it no longer THROWS on a strictness violation - it reports it via a new by-ref $terminal out-param and returns the valid frames parsed before it - and MASKED frames are rejected by default (RFC 6455 5.1 covers server-to-client frames); pass allowMasked: true to decode client-written frames (test harnesses, server-side use). WebSocketFrameCodec::unmask() is now @deprecated (production-dead under the masked-frame rejection; decode(..., allowMasked: true) serves the same audience) but retained - removing a public helper would be a bc-break.

    • [bugfix] A permission-rejected pull reply inbox (_INBOX.JS.PULL.<nuid>.*) now fails handle() fast with a clear error naming the wildcard to grant, instead of polling forever with zero signal (every retire was a silent client-side deadline) - #167's fail-fast generalized via a per-sid subscription-rejection callback (NatsClient::onSubscriptionRejected()).

    • [bugfix] Caller-owned push consumers now perform the ADR-9 heartbeat gap check: each idle heartbeat's Nats-Last-Consumer is compared against the locally delivered consumer sequence and a mismatch is surfaced once per gap episode via the error listener AND logger (nats.go ErrConsumerSequenceMismatch parity). Previously heartbeats kept the silence watchdog quiet while being withheld from the handler, so an ack-none / max_deliver=1 gap was permanent, invisible loss. The check also detects a server-side consumer REPLACEMENT (nats.go any-inequality parity): a heartbeat reporting Nats-Last-Consumer BELOW the session's tracked max surfaces one "consumer appears to have been replaced (sequence regressed)" error and rebases the tracker to the reported value, and a delivered consumer sequence below the tracked max rebases it silently - so gap detection follows the replacement instance instead of being masked by the stale high-water mark until it passed the old maximum.

    • [bugfix] JetStream read resilience: directGetLastForSubjects() treats an all-miss multi_last chunk's lone 404 as "no matches" (ADR-31) instead of discarding every other chunk's results (the KV/OS batched enumerations could throw spuriously when keys were purged mid-enumeration), and the KV getAll() / ObjectStore list() per-subject lookups fall back to the leader STREAM.MSG.GET path on a Direct Get 503 (allow_direct-disabled interop buckets) exactly like get()/info(). The list() fallback queries the leader by the enumerated meta subject VERBATIM, so records stored under non-canonical (unpadded base64url) name tokens by other clients are not silently dropped from the fallback listing.

    • [bugfix] Object Store links and watch patterns: addLink()/addBucketLink() refuse any name already held by a NON-LINK record - live or deleted tombstone alike (the exact nats.go ErrObjectAlreadyExists guard shape; overwriting a live object silently stranded its chunks forever, and allowing the tombstone diverged cross-client on shared buckets) - and addLink() rejects deleted and link-to-link targets (nats.go parity); watch() base64url-encodes an exact-name pattern into the meta-subject filter and rejects wildcard patterns loudly (they can never match encoded name tokens - previously a wildcard watch subscribed successfully and observed nothing), with a new exactName: true parameter to watch an object whose name itself contains * or > (the pattern is then always encoded).

    • [bugfix] Headers interop: NatsHeaders::toWireBlock() emits values VERBATIM (the silent trim mutated signature/checksum-carrying values; nats.go writes caller bytes untouched - the decoder keeps its trim as an inbound tolerance), and the new case-insensitive NatsHeaders::get() accessor bridges lookups against non-canonicalizing publishers.

    • [bugfix] Services/observability polish: endpoint names are validated against the ADR-32 token rules at registration (previously advertised verbatim to conformant tooling); BasicJsonSchemaValidator rejects non-empty JSON lists for "type":"object"; and JetStream client-level errors (emitClientError() - e.g. a terminally dead ordered consumer) now reach the PSR-3 logger as well as the error listener, so a logger-only configuration is no longer blind.

    • [bugfix] KV and Object Store watches are now LOSSLESS: both are rebuilt on the ordered-consumer machinery (nats.go watcher parity), gaining consumer-sequence gap detection with automatic recreate-from-last-revision+1 (a slow-consumer drop or reconnect window is REPLAYED instead of becoming a silent permanent gap - ack-none watch deliveries are never redelivered on their own), flow control, and watchdog-driven recreation of a silent/reaped consumer (which previously surfaced an error at best - or, for the Object Store watch, hung forever with no signal: it never requested an idle heartbeat at all; it now defaults one like KV, tunable via ObjectStoreWatchOptions::$idleHeartbeat). A recreate before the first delivery re-applies the watch's initial deliver policy, so a new/last_per_subject watch never replays from sequence 1. subscribeOrderedConsumer() gained consumerOverrides and onConsumerCreated parameters, and the new JetStreamContext::stopOrderedConsumer(int $sid) stops an ordered consumer / watch even after recreates rotated its internal sid (a plain unsubscribe() only ever worked until the first rotation). A watch stopped via the legacy plain unsubscribe($sid) no longer strands its internal stop-registry entry forever: the watchdog's self-cancel tick releases the entry and best-effort deletes the server-side ephemeral (only the CURRENT timer runs this cleanup - a rotated-out old timer cannot release a consumer that lives on under a new sid). The cleanup latches the stop FIRST, exactly like stopOrderedConsumer() - a recreate parked in its awaits when the tick fires tears its fresh instance down instead of installing a consumer whose stop handle is already gone (which would have been permanently unstoppable). And a stopOrderedConsumer() racing an in-flight recreate whose remaining create attempts then all fail no longer emits a spurious terminal "recreate failed" error for the deliberately stopped consumer - the exhausted episode releases the never-adopted fresh inbox and returns silently.

    • [bugfix] KV buckets created with sources now attach the mandatory ADR-57 subject transforms ({src: "$KV.<src>.>", dest: "$KV.<bucket>.>"}), so sourced entries are re-subjected into the new bucket's prefix and visible to get/getAll/keys/watch - previously they were copied under the origin's subjects where every read path was blind to them (invisible data). Caller-supplied transforms pass through verbatim (full-control sources, e.g. non-KV streams; the name is then not KV_-prefixed). Mirror buckets now set mirror_direct: true and write THROUGH to the origin's prefix (nats.go putPre parity) instead of publishing to their own subject that no stream ingests (503); cross-domain mirrors (domain shorthand → external: {api: "$JS.<domain>.API"}) route writes via the external API prefix and reads via the origin prefix. The new KeyValueBucket::bind() resolves the same prefixes from STREAM.INFO for handles attached to mirror buckets created elsewhere - note this includes a FRESH keyValue() handle in the same process (each handle is independent; only the instance that ran create() is auto-resolved). Mirror read/write prefixes are applied only AFTER the stream create is confirmed server-side - and, symmetrically, the stale-prefix reset in create()/bind() also happens only after the server confirmed the new configuration, atomically with the re-apply. So a failed mirror create can neither leave a fresh handle misdirecting writes at the origin's subjects NOR blind a handle whose mirror the server confirmed earlier (a failed re-create leaves the last confirmed routing intact), and no suspension window exists in which concurrent readers see half-reset prefixes. Also note the name-mapping rules: the bucket alias is KV_-prefixed UNCONDITIONALLY (it explicitly declares a KV bucket, so a bucket legitimately named KV_x maps to its own backing stream KV_KV_x), while a transform-less explicit source name, bare-string entries, and mirror names are KV_-prefixed only when not already (nats.go stream-name idempotence) - the previous used-as-is behavior produced invisible data; supply subject_transforms to source a non-KV stream by its verbatim name.

    • [bugfix] Pipelined Object Store uploads can no longer store a silently corrupted object: the ADR-21 503 retry inside publish() could re-order a retried chunk BEHIND later accepted chunks, and put()/putStream() then published the meta record and reported success for an object whose stream-order reassembly no longer matches its digest (every later read failed with a digest mismatch). The acked stream sequences are now verified strictly increasing in chunk order; a permuted upload aborts before the meta publish with a clear retryable error, and one seq-less ack (a defensive case no real server produces) skips only its own neighbor pairs instead of disabling the check for all remaining chunks. Any failed/aborted upload additionally purges the partial NUID's chunks (nats.go purgePartial parity) - previously they were orphaned in the stream forever, since no meta record ever referenced them.

    • [bugfix] The infinite pipelined pull engine no longer treats a transient 503 (no JetStream API responder - server restarting, JS still wiring up after a reconnect, a leader election window) as terminal. Previously the worker stopped PERMANENTLY, and with no onError configured handle() resolved normally with the processed count - indistinguishable from a clean drain while messages piled up in the stream. 503 is now routine-with-backoff (nats.go Consume() parity; finite/fetch semantics unchanged); the first 503 of a streak fires onError once as an operator signal, re-armed by the next delivery OR by any routine non-503 retire (a 404/408/non-terminal 409 or a client-side deadline proves the JS API answers again) - i.e. one onError per no-responders episode, so a later outage on an idle, never-delivering stream is still reported.

    • [bugfix] The fetchBatch() and directGetBatch() reply inboxes are now slow-consumer-exempt like the mux (#118) and pull-pipeline (#120) inboxes. A burst of small replies above the per-subscription pending cap (default 1024) arriving within one read chunk was silently DropOldest-discarded: a fetch returned with its head missing (permanently lost on a max_deliver: 1 consumer - the server counted every message as delivered), and a Direct Get batch returned a truncated result presented as complete (its replies are never redelivered and the 204 end-of-batch marker still arrived). Memory stays bounded by the requested batch size.

    • [bugfix] drain() and flush() can no longer hang forever on a backpressure-stalled peer: transport writes suspend indefinitely when the send buffer is full (they cannot be cancelled), and drain() cancels the heartbeat FIRST - removing the only escalation that could break such a wedge - so its documented ~requestTimeoutMs bound was unenforceable. Drain writes are now bounded by the drain budget (a wedged write is abandoned; the teardown's socket close fails it out) and any drain write failure falls through to teardown, so drain() always reaches Closed. Note the contract change: drain() no longer THROWS on a dead-socket write failure - it reports it via the error listener and still closes cleanly (previously it threw and left the connection stranded in Draining). The teardown closes the transport BEST-EFFORT like every other terminal path, so a custom transport whose close() throws on an already-broken socket cannot re-strand Draining either. Handler publishes issued while drain() delivers backlog are bounded by the drain's REMAINING budget (not a fresh full requestTimeoutMs each), and a delivery pass stops delivering past its head message once the budget is exhausted, reporting the dropped remainder via the loud "drain deadline exceeded" error - previously K backlog messages whose handlers each published against a wedged transport could extend drain() to ~K x requestTimeoutMs. flush()'s PING write is bounded by the request timeout and surfaces a TimeoutException on a write-side wedge. Implemented at the connection layer - TransportInterface is unchanged, so custom transports are unaffected.

    • [bugfix] The WebSocket transport's ping-answer (pong) and RFC 6455 Close-echo writes are no longer inline in the read fiber: control answers are held as data SLOTS drained by a single writer fiber - a latest-pong slot where a newer ping's payload REPLACES a queued-but-unsent pong (RFC 6455 5.5.3: only the most recent ping needs answering) and a dedicated OP_CLOSE echo slot that is always flushed (RFC 6455 5.5.1 makes the echo mandatory; first Close wins), giving constant memory with no cap that could drop answers. Inline in the read fiber, a pong write that THREW (peer died right after coalescing [MSG][PING] into one read) discarded the already-decoded MSG bytes from the same read - consumed from the buffer, never delivered, never resent - and a pong write that SUSPENDED on a backpressure-stalled peer parked the entire read path behind an outbound control frame (the same suspension class as the WebSocketTransport::close() wedge fixed below). A coalesced ping flood now collapses to one pong answering the NEWEST ping, and the Close echo goes out even behind 16+ coalesced pings.

    • [bugfix] Every $SRV discovery response (PING/INFO/STATS/SCHEMA) now serializes empty metadata maps as JSON objects ({}) instead of arrays ([]), service-level and per-endpoint. ADR-32 types metadata as map[string]string, and Go-based consumers (nats micro ls, nats.go micro) hard-fail unmarshalling [] into a map - rejecting the whole discovery response, so a metadata-less service (the default) was invisible to standard tooling. statsSnapshot() still returns plain PHP arrays; the conversion happens only at the wire boundary.

    • [bugfix] Service reply-publish failures can no longer escape the endpoint callback into the shared dispatch loop (which aborted delivery for every subscription on the connection): the schema-validation error reply is guarded; a requester-controlled non-UTF-8 correlation header no longer makes the JSON error reply throw at encode time (the correlation id is omitted instead - this was remotely triggerable); connection-level reply failures are recorded on the endpoint and swallowed; and a ServiceError code containing CR/LF is collapsed to one line like the description instead of blowing up the header build. A request whose handler errored and whose error-reply publish then ALSO failed counts ONE error (previously two, letting $SRV.STATS report num_errors > num_requests) and no longer emits a late duplicate request_error observer event with a hardcoded code contradicting the handler's own; the reply-publish failure is counted as the request's error only when the handler itself succeeded, and the endpoint's last_error records it in both cases.

    • [bugfix] WebSocketTransport::close() can no longer deadlock the client on a backpressure-stalled peer. The best-effort RFC 6455 Close frame was written inline before the socket close; Amp's socket write() SUSPENDS the calling fiber (it does not throw) while the write buffer is full - e.g. a peer holding the TCP window at zero - so the try/catch never engaged, close() parked forever before reaching the socket close, and since that close is the only thing that errors pending writes out, every recovery/disconnect/drain path awaiting transport->close() wedged permanently (the heartbeat had already self-cancelled once state left Open, so nothing remained to break the cycle). The Close frame now goes out on its own fiber awaited with a 0.25 s bound: a responsive socket still sends it before closing, and a wedged write is abandoned - the socket close then fails it out, waking its fiber. Any other writes still queued behind a stalled buffer at that point are failed out the same way (loudly, via their write futures) instead of being waited on - disconnect() is the documented lossy path and drain() has already flushed and PONG-confirmed before it closes the transport. readLine() now also pins its socket in a local for the whole read loop, so a reader resuming inside the bounded close window cannot deref the already-nulled socket property. Plain-TCP AmpSocketTransport was never affected (its close() does not write).

    • [bugfix] The pipelined pull engine no longer treats a single immediately-answered empty pull as proof of idleness when setDepth() > 1 (#169). Retires run in issue order, so under no_wait with steady traffic below depth*batch a tail pull that raced an already-drained stream would 404 right after a delivering head pull - and that lone empty latched the idle drain, injecting a periodic backoff pause and clamping the next generation to depth 1 (the effective pipeline oscillated depth->1->depth with ~10 ms stalls despite continuously delivering). The engine now counts a ROLLING streak of consecutive empty retires across pulls (reset by any delivery, surviving the engine's continuous refill) and only latches the idle drain once the streak spans one full pipeline width (>= setDepth()), so a delivering pipeline keeps its full depth while a genuinely idle stream still accumulates to the latch and backs off exactly as before. setDepth(1) (and finite setIterations() mode) keep the original latch-on-first-empty behavior unchanged.

    Open source →
  2. v2.7.1 16 Jul 2026
    Release notes

    Patch release — four bug fixes to the request/reply mux inbox (#118) and the pull-pipelining engine (#120).

    Fixed

    • [bugfix] request()/requestMany() now surface a clear, catchable ConnectionException when the shared mux reply-inbox subscription _INBOX.<inbox>.* is rejected by server permissions, instead of hanging every request to a silent timeout (#167). The connection detects the async permission -ERR, drops the dead mux state (so a reconnect does not replay the rejected wildcard SUB), and fails requests fast with a message naming the _INBOX.> wildcard permission required. The in-flight request that triggers the rejection also fails fast.
    • [bugfix] Pull consumers with a priority group under the overflow/prioritized policy now honor setDepth() instead of pulling strictly serially — those policies never emit a Nats-Pin-Id, so the grouped-and-unpinned serialization guard previously disabled pipelining for the whole run. A grouped run now pulls serially only until its first delivery, then fans out. Conversely, a pinned_client group that captured a pin and then LOST it (a 423 cleared it mid-run) now re-serializes its pin re-capture instead of racing pin-less pulls at full depth (#170).
    • [bugfix] requestMany() no longer discards already-collected replies if the mux reply inbox is permission-rejected mid-collection: it returns the partial batch it has, and surfaces the clear permission error only when nothing was collected.

    Full gate: PHPStan level 8, 1746 unit tests. No public API or wire-format change.

    Open source →
    Release notes

    Fixed

    • [bugfix] request()/requestMany() now surface a clear, catchable ConnectionException when the shared mux reply-inbox subscription _INBOX.<inbox>.* is rejected by server permissions, instead of hanging every request to a silent timeout (#167, a follow-up to #118's muxed request inbox). An account without subscribe permission for the reply-inbox wildcard (e.g. _INBOX.>) previously saw all request/reply calls time out with no explanation; the connection now detects the async permission -ERR, drops the dead mux state (so a reconnect does not replay the rejected wildcard SUB), and fails requests fast with a message naming the wildcard permission required. The in-flight request that triggers the rejection also fails fast rather than waiting out its full timeout.
    • [bugfix] Pull consumers with a priority group under the overflow or prioritized policy now honor setDepth() instead of pulling strictly serially. Those policies never emit a Nats-Pin-Id, so the "grouped-and-unpinned" serialization guard (meant to let a pinned_client group capture its pin before fanning out) previously held for the whole run and silently disabled pipelining. A grouped run now pulls serially only until its first delivery, then fans out to the configured depth if no pin was captured. Conversely, a pinned_client group that captured a pin and then LOST it (a 423 stale-pin cleared the pin mid-run) now RE-SERIALIZES its pin re-capture - pulling one at a time until it re-pins - instead of racing pin-less pulls at full depth, matching the bootstrap behavior (#170).
    • [bugfix] requestMany() no longer discards already-collected replies if the mux reply inbox is permission-rejected mid-collection (e.g. a reconnect re-SUB is rejected after some replies arrived): it returns the partial batch it has, and surfaces the clear permission error only when nothing was collected.
    Open source →
  3. v2.7.0 15 Jul 2026
    Release notes

    Features

    • Pull consumer pipelining (#120). Pull consumers now keep several pull requests in flight over one long-lived pull inbox (setDepth(), default 2), issuing the next as soon as one retires — eliminating the per-batch round-trip stall and the SUB/UNSUB churn per pull. Throughput rises from ~1 batch per RTT to overlapping pulls (a large gain on higher-latency links). Reply routing matches how a JetStream server actually delivers: status replies route by a per-pull token, while delivered messages (sent on their original subject) are matched to the oldest in-flight pull; the shared pull inbox is slow-consumer exempt so a slow handler never loses a buffered message.
    • Default pull batch size is now 100 (was 1), with a new setDepth() controlling overlap. setBatching(1) still works and still pipelines. A consumer that relied on the old batch=1 default now fetches up to 100 messages per pull — size it against your MaxAckPending/ack_wait, or call setBatching(1) to restore one-message pulls.

    All existing pull-consumer semantics are preserved: stop()/drain(), the escalating idle backoff, finite setIterations(), pinned priority groups (setGroup()) + 423 stale-pin re-pull, terminal-status onError, and reconnect survival. fetchBatch()/fetchNext() are unchanged.

    Internal

    • Consolidated duplicated boilerplate across the connection, JetStream, and transport layers into shared helpers/traits (#111) — ObjectStore digest/meta-record builders, the JetStream offset-pagination loop, the connect-handshake poll loop, the API-error decode, the Direct Get meta decode (unifying guards that had drifted), and the client TLS-context assembly. No public API or wire-format change.

    Full CI green including live-server E2E, integration/behat, and mutation testing (covered MSI ≥ 90%).

    Open source →
    Release notes

    Changed

    • [feature] Pull consumers now PIPELINE their pull requests (#120). PullConsumerIterator::handle() no longer creates a fresh inbox + SUB, publishes one pull, waits, and UNSUBs per pull. It opens ONE long-lived pull inbox subscription _INBOX.JS.PULL.<base>.* for the whole run and keeps several pulls in flight at once (see setDepth(), default 2), issuing the next pull as soon as one retires - so there is no inter-batch round-trip stall and no SUB/UNSUB churn per pull. The consumer's throughput ceiling rises from ~1 batch per RTT to overlapping pulls (a large gain on higher-latency links). Status replies route by a per-pull token, while delivered messages (which the server sends on their original subject) are matched to the oldest in-flight pull in order; the shared pull inbox is exempt from the slow-consumer drop so a slow handler never loses a buffered message. All existing semantics are preserved: stop()/drain(), the escalating idle backoff, finite setIterations() (still serial and stop-on-any-error), pinned priority groups (setGroup()), 423 stale-pin re-pull, terminal-status onError, and reconnect survival. The single-shot fetchBatch()/fetchNext() primitives are unchanged.
    • [feature] The default pull batch size is now 100 (was 1) and a new setDepth() controls pull overlap (default 2). setBatching(1) still works and still pipelines. A consumer that relied on the old batch=1 default now fetches up to 100 messages per pull - size it against your MaxAckPending and ack_wait; call setBatching(1) to restore one-message pulls.
    • Internal: consolidated duplicated boilerplate across the connection, JetStream, and transport layers into shared helpers/traits (#111) - the ObjectStore digest and meta-record builders, the JetStream offset-pagination loop, the connect-handshake poll loop, the API-error decode, the Direct Get meta decode (unifying guards that had drifted between info()/list()), and the client TLS-context assembly. No public API or wire-format change; behavior is unchanged.
    Open source →
  4. v2.6.0 15 Jul 2026
    Release notes

    A performance-focused minor release: three throughput/efficiency improvements to the JetStream, KV, Object Store, and request/reply paths. No breaking API changes (one account-permission note below).

    Added

    • Configurable transport read chunk sizeNatsOptions::$readChunkSizeBytes (default 128 KiB, up from Amp's 8 KiB) caps a single socket read on both the TCP and WebSocket transports, so large inbound payloads (e.g. an Object Store download) arrive in far fewer chunks — dividing the per-chunk syscall + fiber-spawn + parser-push overhead ~8–32×. It raises only the maximum a read may return; small-message behavior is unchanged (#119).
    • readIncoming() on NatsClient/NatsConnection exposes one read-and-dispatch cycle as an IncomingChunkResult (frame count plus whether bytes were consumed) — the progress signal the wait loops use. processIncoming() is unchanged (#119).

    Changed (all performance, behavior-compatible)

    • Muxed request inbox (#118)request()/requestMany() now share ONE long-lived wildcard subscription _INBOX.<base>.* per connection instead of a fresh subscribe + unsubscribe per call; each request publishes reply-to _INBOX.<base>.<token> and the reply is routed back by token. This removes two control-plane frames and a server-side interest change per request — a throughput win for JetStream, KV, Object Store, and BatchPublisher, which all funnel through request(). Reply delivery, timeouts, no-responders handling, cancellation, and requestMany semantics are unchanged; a delayed/duplicate reply for a completed request can never reach a later one, and the shared reply queue is exempt from the slow-consumer drop. Permission note: the mux inbox subscribes to a wildcard under the inbox prefix, so an account permissioned to subscribe only to exact _INBOX.<...> subjects (not _INBOX.>) will have the mux subscription rejected and request/reply will time out — grant _INBOX.> subscribe permission (the same requirement as nats.go's muxed responder; see #167).
    • Batched, chunked KV/Object Store enumeration (#110)keys()/listKeys() enumerate names via a headers-only last_per_subject consumer instead of downloading every value; getAll() and Object Store list() fetch every record with one batched multi_last Direct Get (ADR-31) on NATS 2.11+ (with a version-gated fallback to the per-subject fan-out), chunked by the server's 1024-result cap and by max_payload so large buckets are enumerated across several requests rather than one the server would reject.
    • Read-path idle-sleep removed on partial-frame progress (#119) — the read-path wait loops (flush(), drain(), request(), requestMany(), JetStream pull-fetch and direct-get-batch, KeyValueBucket::history()/keys(), SubscriptionQueue, Service::run()) no longer pay a 1 ms idle sleep per partial socket chunk. A frame spanning N chunks previously cost ~N ms (~125 ms/MB at the old 8 KiB default); the loops now sleep only on a genuinely idle read. Delivery is byte-for-byte identical.

    Full CI green: PHPStan level 8, unit tests on PHP 8.2–8.5, E2E + coverage on a live NATS 2.12 server, examples, and ≥90% mutation MSI.

    Open source →
    Release notes

    Added

    • [feature] NatsOptions::$readChunkSizeBytes (default 128 KiB, up from Amp's 8 KiB) caps the bytes a single transport socket read may return; both the TCP and WebSocket transports apply it via the Amp socket's chunk size after connecting. Large inbound payloads (e.g. an ObjectStore download) now arrive in far fewer chunks, dividing the per-chunk syscall + fiber-spawn + parser-push overhead ~8-32x. It raises only the MAXIMUM a read may return - the socket still returns just what is available, so small-message behavior is unchanged (#119).
    • [feature] NatsClient::readIncoming() / NatsConnection::readIncoming() expose one read-and-dispatch cycle as an IncomingChunkResult (the frame count PLUS whether the read consumed bytes off the wire), the progress signal the wait loops use to decide whether to yield. processIncoming() is unchanged - it remains the frame-count-only view of the same cycle (#119).

    Changed

    • [feature] KV keys()/listKeys() no longer download every value just to list names (#110). They now enumerate via a last_per_subject + headers_only ephemeral consumer (the metaOnly watch path nats.go's Keys() uses), so only the KV-Operation/Nats-Sequence headers return and DEL/PURGE tombstones are filtered by header alone - no value body crosses the wire. Previously keys() was array_keys(getAll()), which issued one full-value Direct Get per key. The returned set of names is unchanged (deleted keys excluded); the order (stream-sequence order of each key's latest record) remains unspecified, as before. Measured against a 100-key bucket with 4 KiB values: value bytes read dropped from ~423 KiB to ~13 KiB (headers only) and the 100 per-key Direct Get requests became a single consumer.
    • [feature] KV getAll() and Object Store list() fetch every key/object with ONE batched multi_last Direct Get (ADR-31) instead of one Direct Get request per subject, on servers that support it (#110). The batched path is version-gated via the new JetStreamContext::supportsBatchedDirectGet() (NATS 2.11+); older or unparseable-version servers fall back to the unchanged per-subject Direct Get fan-out. The subject list is split into chunks bounded by the server's 1024-result cap and by the negotiated max_payload, so buckets with more than ~1024 keys are enumerated across several batched requests instead of a single oversized one the server would reject. Returned data, tombstone/deleted filtering, and 404/missing handling are identical on both paths. Measured against a 100-key bucket, getAll() issued 1 Direct Get request instead of 100 (the value bytes it must transfer are unchanged, since it returns the values).
    • [feature] The read-path wait loops (flush(), drain(), request(), requestMany(), JetStreamContext pull-fetch and direct-get-batch, KeyValueBucket::history() and keys(), SubscriptionQueue, Service::run()) no longer pay a 1 ms idle sleep on partial-frame progress. A frame spanning N socket chunks previously cost ~N ms (~125 ms/MB at the 8 KiB default; ObjectStore downloads were capped near 8 MB/s) because each partial read reported 0 frames and every loop treated 0 frames as "idle" and slept 1 ms - even when the rest of the payload was already in the kernel buffer. The loops now sleep 1 ms ONLY on a genuinely idle read (an empty read, or another fiber owning the socket) and loop immediately when bytes were consumed but no full frame completed yet. Delivery/enumeration is byte-for-byte identical; each loop's deadline/cancellation bound, the #117 pong-slot flush semantics, #147 frame retention, #148 Connecting-window reads, and #135 parking are preserved, and a truly empty socket still yields the 1 ms so the event loop advances and deadlines fire (#119).
    • [feature] request()/requestMany() now share ONE long-lived muxed reply inbox per connection instead of creating a fresh inbox subscription (SUB + UNSUB) for every call (#118). A single wildcard subscription _INBOX.<base>.* is established lazily on the first request; each request publishes its reply-to as _INBOX.<base>.<token> and the reply is routed back by that per-request token. This removes two control-plane frames and a server-side interest change per request - a large throughput win for JetStream, which funnels every API call, KV, and Object Store operation through request(). Reply delivery, timeouts, no-responders (503) handling, cancellation, and requestMany semantics are unchanged. A delayed or duplicate reply for a completed/timed-out request is discarded by token (it can never reach a later request), and the shared mux queue is exempt from the slow-consumer drop so a reply is never dropped. NOTE (permission requirement): the mux inbox subscribes to a wildcard under the inbox prefix, so an account permissioned to subscribe only to exact _INBOX.<...> subjects (not the _INBOX.>/_INBOX.* wildcard) will have the mux SUB rejected where per-request exact-subject inboxes previously succeeded. Because the client does not block on the SUB's server acknowledgement, that rejection arrives as an asynchronous -ERR (delivered to the error listener), after which every request()/requestMany() on the connection times out (the server has no interest on the wildcard) - a silent regression relative to the pre-mux exact-inbox path for such accounts. Grant _INBOX.> subscribe permission (the same requirement as nats.go's muxed responder) to use this client's request/reply on a restricted account.
    Open source →
  5. v2.5.4 12 Jul 2026
    Release notes

    A hotfix for a message-loss regression introduced in v2.5.3.

    Fixed

    • Ordered-consumer recreate no longer loses the replay under load (#122, regression in the v2.5.3 deliver-inbox rotation). When an ordered consumer recreates after a dropped delivery, the server begins replaying on the rotated inbox the instant the new consumer exists - and those frames are dispatched during the CONSUMER.CREATE request's own read-pump, before its reply is processed. The new instance was adopted (its client-chosen name and the reset expected-sequence) only after the create returned, so the early replay frames were filtered out by the not-yet-adopted consumer name and a later one tripped a spurious gap, cascading into a recreate storm in which only the pre-gap message was delivered. This surfaced as an intermittent, load-dependent failure of the ordered-consumer dropped-delivery recovery. The instance is now adopted before the create await, and a deterministic regression test pins a replay frame arriving ahead of the create reply.

    Verified: reliable under sustained CPU load (12/12, was ~2/12), full CI green (PHPStan level 8, 1611 unit, integration + behat on NATS 2.12, coverage, mutation >= 90% MSI).

    Upgrade recommended for anyone on v2.5.3 using subscribeOrderedConsumer().

    Open source →
    Release notes

    Fixed

    • [bugfix] Ordered-consumer recreate no longer loses the replay under load (a regression in the #122 deliver-inbox rotation, shipped in 2.5.3). The rotation adopted the new instance (its client-chosen name and the reset expected-sequence) only AFTER the CONSUMER.CREATE reply, but the server can begin delivering the replay on the rotated inbox the instant the consumer exists - and those frames are dispatched during the create request's own read-pump, before that reply is processed. The early replay frames were then filtered out by the not-yet-adopted consumer name and a later one tripped a spurious gap, cascading into a recreate storm in which only the pre-gap message was ever delivered (observed as an intermittent, load-dependent failure of the ordered-consumer dropped-delivery recovery). The name is chosen client-side, so the instance is now adopted BEFORE the create await; a new deterministic test pins a replay frame arriving ahead of the create reply, and the recovery now passes reliably under sustained CPU load (#122).
    Open source →
  6. v2.5.3 12 Jul 2026
    Release notes

    A correctness release resolving the remaining reconnect/JetStream/WebSocket findings from the review lineage (issues #115, #121, #122, #165, #166). Every fix landed with a falsifiability-checked regression test and adversarial review; the tree is green on PHPStan level 8, 1610 unit tests, integration + behat against NATS 2.12, 97.9% coverage, and the >= 90% MSI mutation gate. All changes are backward-compatible bug fixes (patch release). See CHANGELOG.md for the full per-issue detail.

    JetStream ordered consumers

    • The ordered-consumer recreate now rotates its deliver inbox (#122). Reusing one inbox across recreates meant a consumer created after a lost CONSUMER.CREATE reply (a transient the retry survives) could orphan a live server-side ephemeral, and its plain idle heartbeats could drive a recreate storm. Rotating to a fresh inbox on each recreate means an orphan's data and heartbeats never reach the new subscription, and dropping interest on the old inbox lets the server reap the orphan via its inactive_threshold - closing both the leak and the storm. On terminal recreate failure the subscription is torn down so "dead" is actually dead, and the watchdog timer is cancelled explicitly on rotation/teardown.

    Reconnect liveness

    • The reconnect flush loop is now bounded (#165). Under sustained publish pressure a fiber that re-filled the reconnect buffer during each flush write could defer the Open transition indefinitely, stranding the connection in Connecting. After a bounded number of drain passes the buffer is sealed and late publishers park on a flush gate (writing directly once Open, failing loudly once Closed), so recovery reaches Open in bounded time with per-publisher and buffered-before-direct wire order preserved.

    WebSocket data safety

    • A WebSocket close or fragmentation error no longer discards messages decoded from the same read chunk (#115). A close frame (or an RFC 6455 fragmentation violation) that shared a TCP read with data frames used to throw mid-batch, dropping the already-decoded messages. Terminal conditions now defer: the buffered data is returned from readLine() and the close/protocol error surfaces on the next call. Permessage-deflate inflation is also capped to guard against a decompression-bomb OOM.

    Silent-degradation roundup (#121)

    • Direct Get batch and KV history() use progress-based deadlines - a healthy-but-slow replay completes, a genuinely stalled server throws instead of silently returning a truncated prefix.
    • A PubAck missing stream is rejected as invalid rather than accepted as a bogus success.
    • Terminal (4xx/5xx) push status frames are no longer forwarded to the handler as data - an ordered consumer recreates, a caller-owned push consumer surfaces the status through the errorListener.
    • publish()'s at-least-once reconnect semantics are documented.

    Also included: #166 (lame-duck INFO during the reconnect replay poll) was verified already fixed by the v2.5.2 same-fiber recovery guard and is now pinned by a regression test.

    Open source →
    Release notes

    Fixed

    • [bugfix] The reconnect-buffer flush no longer defers the Open flip indefinitely under sustained publish pressure (#165, a #148 follow-up). Since #148 recovery stays Connecting until flushReconnectBuffer() fully drains, and the flush loops so publishes buffered mid-flush still go out in order before Open. A fiber publishing continuously re-filled the buffer during each flush write's suspension, so the loop kept iterating and the connection could stay Connecting for the whole outage window - subscribe()/request()/flush()/rtt()/processIncoming() all threw "Connection is not open" and Reconnected never fired (nats.go converges here by holding the connection mutex during the pending flush; PHP fibers have no equivalent implicit exclusion). The flush is now bounded: after RECONNECT_FLUSH_MAX_PASSES drain passes it SEALS the buffer, so late publishers park on a flush-done gate (then write directly once Open, or fail loudly once Closed) instead of appending, the remaining bytes drain in one final pass, and Open flips within a bounded number of passes. Wire ordering is unchanged: the already-buffered frames are written before the flip, so per-publisher order and buffered-before-direct (#148) still hold, and #123 retain-on-flush- failure / loud-exhaustion semantics are untouched.
    • [bugfix] A WebSocket server that delivered final data frames and then gracefully closed in the same read no longer loses those messages (#115). WebSocketTransport::processFrames() threw TransportClosedException the instant it hit the OP_CLOSE frame, discarding the payload of every data frame earlier in the same batch - already consumed out of the read buffer by the by-reference WebSocketFrameCodec::decode(), so those NATS messages were gone (silently lost on core NATS; recovered only via redelivery on JetStream). The transport now returns that accumulated data from readLine() first and defers the close to the next readLine() via a pendingClose flag, including on the large-frame spill path (#164) so a spilled frame's bytes are returned too. The RFC 6455 Close echo (#161) is still written when the close is first seen. Relatedly, two RFC 6455 5.4 protocol violations in the same handler now fail loudly with a ProtocolException instead of silently degrading: a new data frame arriving mid-fragmentation (previously overwrote the partial message) and an orphan continuation frame with no fragmented message in progress (previously dropped). Both violations DEFER the same way as the close: any valid data decoded earlier in the same batch is returned from readLine() first, and the ProtocolException is surfaced on the next readLine() call - the connection still fails loudly, just after the already-decoded messages are delivered, with no silent drop.
    • [bugfix] The WebSocket permessage-deflate inflate path now caps the decompressed size, closing a decompression-bomb OOM vector (#121). WebSocketFrameCodec::inflate() fed the whole compressed payload to a single inflate_add() with no output bound, so a tiny hostile frame could inflate to gigabytes and OOM-spike the client - inconsistent with the file's existing max-frame-size / #89 threat model. It now inflates the input in bounded slices (DEFLATE's ~1032:1 maximum ratio caps each call's output) and throws a ProtocolException the moment the accumulated output exceeds the cap, keeping peak allocation bounded. Legitimate payloads within the cap inflate byte-identically.
    • [bugfix] Ordered consumers no longer orphan a live server-side ephemeral on a recreate, and a non-current heartbeat can no longer drive a recreate storm (#122). Each recreate now ROTATES the deliver inbox: it generates a fresh deliver subject, subscribes it (a new sid), points the new CONSUMER.CREATE at that subject, and unsubscribes the old inbox. An orphan left by a lost CONSUMER.CREATE reply (a timeout / connection loss where the create may still have SUCCEEDED server-side) therefore delivers to the OLD inbox that the client no longer subscribes to, so neither its data frames NOR its plain idle heartbeats reach the tail-gap check - the recreate storm #122 targets cannot happen - and dropping the client's interest lets the orphan's server-side inactive_threshold reap it, so the leak self-heals. Because only the current consumer's frames arrive on the current inbox, the tail-gap check is inherently scoped without parsing control-frame subjects. Each recreate still chooses the consumer name client-side and best-effort-deletes any orphaned prior attempt as a faster-cleanup nice-to-have (mirroring the #151 tolerate-any-failure delete), and on a TERMINAL recreate failure the deliver subscription is torn down so "dead" is actually dead. The #113 watchdog (re-armed on the rotated sid) / recreateInFlight guard, #114/#116 recreate retry, and #155 ack-metadata tolerance are all preserved.
    • [bugfix] A non-100 status control frame (e.g. 409 Consumer Deleted, 404, 408, 503) on a push deliver subject is no longer forwarded to the user handler as if it were a message (#121). handlePushControlMessage() only intercepted status 100 (idle heartbeat / flow control); other status frames fell through and were delivered as empty "data". They are now intercepted for every push subscription: an ordered consumer treats such a terminal status as a gone consumer and recreates from the last in-order point, while a caller-owned push consumer surfaces a terminal (4xx/5xx) status through the error listener as a descriptive JetStreamException instead of silently dropping it. Status-0 data messages (including those carrying user headers) are unaffected.
    • [bugfix] A JetStream publish ack that carries neither an error nor a stream is now rejected with a JetStreamException instead of being accepted as a bogus PubAck('', 0) success (#121), matching nats.go, which rejects an empty-stream ack as invalid.
    • [bugfix] directGetBatch() and KeyValueBucket::history() no longer silently return a truncated prefix when their bounded wait elapses before completion (#121). A Direct Get batch that never receives its end-of-batch marker (204 / Nats-Num-Pending: 0), or a history replay that never catches up (num_pending never reaches 0), now throws a JetStreamException reporting the incomplete result rather than returning whatever partial data was collected as if it were complete. Both bounds are PROGRESS-BASED (reset on each inbound frame, mirroring #153's missed-heartbeat approach), so a healthy-but-slow large replay that keeps making progress no longer throws - only a genuinely stalled server (no progress for the interval) does. history() gained an optional per-call progress-timeout argument (defaulting to the previous bound).

    Documentation

    • [docs] NatsConnection::publish() now documents its at-least-once, nats.go-parity semantics while reconnecting (#121): a publish issued during a reconnect is buffered and reports success immediately
      • before the frame reaches any server - and if the reconnect ultimately exhausts every attempt the buffered frames are discarded, with the loss signalled only out-of-band via the connection-level Closed event and the "Reconnect exhausted: N bytes ... discarded" async error (#123), never through the returned Future. The related write-order note - a publish whose direct write fails is re-sent AFTER the frames concurrent publishers buffered during the same outage - is confirmed as the intended buffered-before-direct ordering (#148/#165), not a defect: the re-send is deliberately not seeded into the flush so recovery success stays independent of the failing frame (#145) and the post-recovery delivery drain runs before the retry (#144); per-publisher order is preserved. No behavior change.
    Open source →
  7. v2.5.2 12 Jul 2026
    Release notes

    A correctness-focused release resolving 23 issues from a full review of everything shipped since v2.4.0 (memory-loss risk, NATS/JetStream spec conformance, reconnect/drain semantics, and performance). Every fix landed one-by-one with a falsifiability-checked regression test and two independent adversarial review passes; the whole tree is green on PHPStan level 8, 1545 unit tests, integration + behat against NATS 2.12, 97.8% coverage, and the >= 90% MSI mutation gate.

    All changes are backward-compatible bug fixes (patch release). See CHANGELOG.md for the full per-issue detail.

    Message-loss and drain correctness

    • flush()/drain() now use FIFO ping/pong correlation instead of a shared flag, so a stale heartbeat PONG or a sibling flush's timeout can no longer end a drain early and drop in-flight messages on the lossless path (#117).
    • drain() delivers a suspended handler's remaining backlog before closing, lets a handler publish (a JetStream ack / reply) during draining, and reports any deadline-exceeded discard loudly instead of silently (#149, #150).
    • A mid-chunk parse failure no longer discards the valid frames already parsed from the same TCP segment, on every read path, and surfaces the error instead of vanishing (#147). Frames coalesced behind the handshake PONG are likewise retained (#157).

    Reconnect / connection lifecycle

    • Recovery stays Connecting until the subscription replay and buffered-publish flush complete, fixing publish-order inversion, writes to a dead socket, and replay/read collisions (#148).
    • connect() joins an in-flight recovery instead of racing it with a second dial chain that could drop new-epoch subscriptions (#145).
    • A handler exception during post-recovery delivery no longer closes a healthy connection (#144); the reconnect-disabled terminal path now releases state and closes the socket (#146); dropped-frame and discarded-inbound-backlog errors are now observable (#158); the first successful connect emits Connected (#161).

    JetStream

    • Ordered / push / KV-watch consumers now detect idle heartbeats stopping and recreate a reaped consumer (or surface a "not active" error) rather than dying silently forever (#113).
    • Ordered-consumer recreate tolerates any delete-leg failure (#151); atomic-batch publish is guarded by a server-version pre-flight so nothing is stored on a pre-2.12 server (#152); JetStream errors are discriminated by err_code, not message substrings (#154); $JS.ACK metadata parsing tolerates extra trailing tokens (#155); pull-consumer 409 handling, no_wait pacing, and idle-heartbeat validation/fail-fast are fixed (#153); KV/ObjectStore/Batch header requests surface no-responders uniformly as JetStreamException(503) (#161).

    Delivery accounting, WebSocket, and performance

    • Auto-unsubscribe no longer over-delivers one message past max (#156); SlowConsumerPolicy::Error drops are observable without corrupting auto-unsub accounting (#159); requestMany() respects maxResponses when replies coalesce (#160).
    • WebSocket: large frames spill to a join-once buffer (~3x faster on 10 MB frames, parity elsewhere) (#164); a server-initiated Close is echoed per RFC 6455 (#161).
    • The per-chunk pending-drain scan is O(subscriptions-with-backlog) instead of O(all subscriptions) — idle-chunk cost is now flat regardless of subscription count (#162); the inbound control-line split and per-chunk fiber overhead from #140 are restored (#163).

    Two narrow follow-ups found during review are tracked as #165 (flush-loop liveness under sustained publish pressure) and #166 (lame-duck INFO during reconnect replay).

    Open source →
    Release notes

    Fixed

    • [bugfix] KV, Object Store, and atomic-batch header requests now share the same no-responders exception taxonomy as every other JetStream path (#161). KeyValueBucket::publishWithHeadersAck (every KV put/update/delete with headers), ObjectStoreBucket::publishMeta (the meta-record rollup publish), and BatchPublisher's start and commit requests called requestWithHeaders() directly, so on a JetStream-disabled server or an unbound subject they surfaced a bare NatsException('No responders...') instead of the JetStreamException(503) that JetStreamContext::jsRequest()/publish() produce - a caller catching JetStreamException missed it. The normalization is extracted into a shared JetStreamRequest helper that all four sites (including jsRequest()) now funnel through. Only the no-responders case is normalized; the batch start/commit reply-shape detection and version pre-flight (#130/#138/#152) are unchanged.

    • [bugfix] A FAILED initial connect() that recovers via recoverConnection() (reconnect enabled) now emits Connected for the first-ever successful handshake instead of Disconnected then Reconnected, and no longer bumps the reconnect count for what is really an initial connect (#161). A process whose very first dial needed one retry previously observed Disconnected -> Reconnected for a connection that was never up and never saw Connected, so listener state machines keyed on Connected (metrics, readiness gates) never fired. The connection state machine (#144/#145/#148) is untouched: only which lifecycle event fires on the initial-connect recovery path changed, gated on a new "has ever been open" flag set by markConnectionOpen().

    • [bugfix] On a server-initiated WebSocket Close frame the transport now writes an echo Close frame (mirroring the received status code) before surfacing TransportClosedException, per RFC 6455 section 5.5.1 (#161). Previously the client threw without echoing, which strict intermediaries and servers treat as an abnormal closure (1006). The echo is best-effort (write failures are ignored - the socket may already be gone) and the TransportClosedException the connection layer relies on to reconnect is unchanged.

    • [bugfix] The per-inbound-chunk pending-message drain no longer scans every live subscription (#162), a performance regression introduced by #139. To avoid a per-message SplQueue alloc/free, #139 began keeping each subscription's queue allocated but EMPTY for the subscription's lifetime; drainAllPending() then iterated array_keys($this->pendingMessages) after every inbound chunk, so the drain scan became O(all live subscriptions) plus a fresh array_keys() copy of every sid - paid on every chunk INCLUDING each message-free heartbeat self-read (measured ~213 us/chunk at 10,000 idle subscriptions, ~0 in v2.4.0). The drain now iterates a dirty set of only the sids whose queue actually holds a message: a sid is recorded when a message is enqueued to it and removed once its queue drains empty, so the per-chunk cost is O(sids-with-backlog) and a message-free chunk returns in O(1) with no allocation. #139's no-per-message-realloc win is preserved (the empty queue objects are still retained; only the scan set changed), and delivery is byte-for-byte unchanged: per-sid FIFO holds, and cross-sid delivery keeps the ascending-sid (registration) order of the old full-map scan. The dispatchingSids re-entrancy guard (#112/#156), auto-unsubscribe completion (#112), and drain()'s bounded backlog round-up (#149/#150) are unaffected - hasUndeliveredDrainBacklog() now keys on the same dirty set, staying consistent with what the drain iterates.

    • [bugfix] Restore inbound hot-path throughput lost in #140 and drop the extra per-chunk read fiber (#163). First, ProtocolParser::splitControlLine() tokenized MSG/HMSG control lines with an explode(' ') + per-token canonical scan fast path that measured slower per line than the preg_split('/\s+/') it replaced on a PCRE-JIT build (roughly 5-7% on typical single-space lines and larger on non-canonical lines; JIT is on by default since PHP 7.3 and this package requires 8.2+): the canonical scan cost more than the JIT-compiled regex on typical single-space lines, and any non-canonical line then ran preg_split() anyway. The split reverts to preg_split('/\s+/'), which is byte-identical to the fast path for every input (the fast path already fell back to this exact call off the canonical path), so the whitespace tolerance is unchanged - multi-space, tab, and other whitespace runs still separate fields, pinned by the differential fuzz and the tokenization tests - and only the speed changes. Second, AmpSocketTransport::readLine() wrapped the socket read in its own async(), so each inbound chunk spawned a SECOND fiber on top of processIncoming()'s read fiber; the read now runs inline in the caller's fiber and returns an already-resolved future (mirroring the write path #136), removing one fiber and future allocation per chunk. Read cancellation (a bounded read's timeout still surfaces as CancelledException), EOF-to-TransportClosedException, and the empty-string no-socket poll all still surface through the returned future exactly as before.

    • [bugfix] Frames the server coalesces BEHIND the handshake PONG in one TCP segment are no longer dropped, and a frame left partly buffered at the handshake boundary no longer corrupts the stream (#157). awaitInitialPong() returned the moment it saw the PONG, discarding every frame the parser had already extracted after it in the same batch - an async INFO with connect_urls, a lame-duck notice, an -ERR - so discovered cluster peers vanished with no trace. Separately, connectOnce() then replaced the parser wholesale to couple the frame bound to the negotiated max_payload; if the handshake segment ended mid-frame, those buffered bytes were thrown away, the next read resumed at an arbitrary offset, parsed a bogus control line, and raised a spurious ProtocolException that forced an unnecessary reconnect. awaitInitialPong() now hands the frames parsed behind the PONG back to connectOnce(), which dispatches them through the normal enqueue/deliver path (an INFO updates the discovered-server pool; a MSG reaches its handler); and the post-handshake bound change is applied in place on the same parser (ProtocolParser::setMaxFrameSize()) instead of replacing it, so a partial trailing frame completes cleanly on the next read. The connect-start parser reset (#125) is preserved, so no framing state leaks across a reconnect. A lame-duck INFO coalesced behind a RECONNECT PONG, now dispatched during connectOnce(), would ask to reconnect while a recovery was already running on the same fiber; that re-entrant request is guarded to a no-op so the in-flight recovery still completes instead of deadlocking on its own future.

    • [bugfix] Frame-dispatch errors and a discarded inbound backlog can no longer vanish silently (#158), closing three gaps in tension with the "a drop must never be silent" principle (#134). First, the per-chunk finally { drainAllPending(); } in processIncoming() could REPLACE the in-flight dispatch exception: when a fatal frame (a server -ERR, a PONG-write failure) had thrown and a handler then threw while the enqueued backlog drained, PHP propagated the finally's exception and swallowed the fatal one, so the connection continued as if the server never errored. The drain is now contained so a handler failure during it is routed to the error listener and the primary dispatch exception still reaches the caller's escalation path. Second, dispatchFrames() kept only the FIRST failure (rethrown after the loop) and dropped the second and later frame failures from the same chunk without a trace; each suppressed failure is now emitted through the error listener (the first is still rethrown). Third, a terminal close reached from reconnect exhaustion, an auth abort, or reconnect being disabled cleared the parsed-but-undelivered INBOUND backlog silently, asymmetric with the loud OUTBOUND reconnect-buffer discard (#123); such a close now emits an error naming the count of inbound messages being discarded before releasing state (drain()'s own bounded-deadline discard (#149) and disconnect()'s documented nats.go Close() parity path are unchanged).

    • [bugfix] drain() no longer silently discards the backlog of a subscription whose handler is suspended mid-dispatch (#149). When a handler awaited inside the dispatch loop while messages were still queued for its sid, the dispatchingSids re-entrancy guard made drain()'s final delivery skip that sid, releaseRuntimeState() then cleared the subscription registry, and the resumed dispatch loop broke on the missing subscription - dropping the remainder on the documented lossless path. drain() now waits for every in-flight dispatch to finish and every sid's queue to empty before releasing state (nats.go Drain() waits for per-subscription delivery to complete before closing). The wait is bounded by a single overall drain budget computed once at entry - one deadline covers BOTH the flush-wait and the backlog-wait phases, so total drain time cannot exceed ~requestTimeoutMs (the earlier fix added a second sequential deadline that roughly doubled worst-case drain latency). When a handler stays suspended PAST that deadline so the remaining buffered messages cannot be delivered, drain() no longer drops them silently: it counts the still-buffered messages and emits an error naming that count ("drain deadline exceeded: N buffered message(s) were not delivered before close") through the error listener before closing, so the loss is always observable (mirrors the #123/#134 observable-drop principle).

    • [bugfix] drain() no longer breaks when a handler publishes during the drain (#150). A JetStream ack, a Service reply, or NatsMessage::respond() invoked from a handler while the connection is Draining previously threw ConnectionException ("Connection is not open"): the publish path only wrote to the socket when Open and only buffered while a reconnect was in flight. The unguarded final backlog delivery then propagated that exception out of drain(), so releaseRuntimeState()/transport->close() never ran - the connection stranded in Draining with the socket open and the delivered-but-unacked message was redelivered by the server. Now a publish during Draining writes straight to the still-live socket (nats.go drains by publishing then closing), and drain's final delivery is contained per pass so a handler exception is routed to the error listener while drain() always reaches Closed. A publish after the connection has Closed still throws (#146); pong-slot correlation (#117) and auto-unsubscribe counting (#112) are preserved.

    • [bugfix] JetStream push/ordered subscriptions now run an idle-heartbeat watchdog, so a consumer that stops delivering is no longer silently dead forever (#113). An ordered consumer, and any push consumer the caller created with idle_heartbeat, receives a status-100 heartbeat at least every interval while it is alive; previously nothing noticed when those heartbeats STOPPED, so a consumer reaped after an inactive_threshold lapse, a mem_storage R1 ordered-consumer restart, or an interest gap left the client holding a live core subscription to a deliver inbox no consumer would ever publish to again - no data, no heartbeat, no error, forever. The sequence-gap logic could never catch this because it only runs when a frame arrives. A monotonic watchdog now fires when no frame (data, heartbeat, or flow-control) has arrived for two heartbeat intervals: for an ordered consumer it triggers the same recreate path the gap logic uses (resuming from the last in-order point via opt_start_seq), matching nats.go's ErrConsumerNotActive monitor; for a caller-owned push consumer, which the library cannot recreate, it surfaces a descriptive "not active" error through the error listener. The watchdog rearms on every inbound frame (so a quiet-but-alive consumer is never falsely recreated), fires at most once per silence episode (no recreate/error storm; a failed recreate falls back to the existing bounded-retry + error-listener path), and holds the connection weakly and self-cancels the moment its subscription is torn down, so it never leaks a timer or roots an abandoned connection (mirroring the #126 ping timer). subscribeOrderedConsumer() gains an optional idleHeartbeatNs argument to tune the interval, and KV watchers now request a default idle heartbeat too (tunable via the new KeyWatchOptions::$idleHeartbeat), so a silent or reaped watch surfaces a "not active" error instead of hanging forever - previously the default KV watch requested no idle heartbeat, so total silence was indistinguishable from an idle stream and no watchdog armed. The ordered-consumer recreate is serialized by an in-flight guard so the dispatch-handler (sequence-gap / tail-gap) and watchdog-timer paths cannot both drive a recreate at once and orphan a transient ephemeral consumer, and a successful recreate clears the miss latch and rebases the silence clock so the watchdog re-arms for the new consumer - a replacement reaped again before its first heartbeat is recovered rather than leaving the watchdog wedged. The watchdog also rebases (and neither fires nor cancels) while the connection is mid-reconnect, so it survives a transient reconnect.

    • [bugfix] flush(), drain(), drainSubscription(), and rtt() now correlate PONGs to their PINGs through a FIFO slot queue (nats.go nc.pongs parity) instead of shared booleans that ANY PONG cleared (#117). Previously a stale PONG - one answering an earlier heartbeat PING whose bounded self-read timed out without consuming it, or a previously timed-out flush's PING - satisfied the wait immediately, so flush() returned before the server had processed the writes issued after that older PING, and drain()/drainSubscription() closed or dropped state with in-flight MSGs still unread: silent loss on the documented lossless path. A concurrent flush timing out also cleared the shared flag, releasing sibling flushes that had seen zero pongs. Now every outbound PING (heartbeats included, as placeholder slots) occupies one queue position, the PONG handler completes the oldest slot (TCP preserves PING/PONG order), a timed-out flush leaves its slot queued so its late PONG cannot release a later waiter, and every epoch end (reconnect handshake, terminal close) errors out all parked slots so a flush caught mid-reconnect fails fast with ConnectionException instead of idling out its deadline against the new socket. The maxPingsOut liveness watchdog still resets on any PONG.

    • [bugfix] Pull-consumer robustness (#153): the 409 pull statuses "Message Size Exceeds MaxBytes" and "Batch Completed" are now classified as pull-completion statuses instead of terminal errors, so an infinite consume() loop with setMaxBytes() survives an oversized pending head message and keeps pulling (nats.go excludes ErrMaxBytesExceeded/ ErrBatchCompleted from terminal handling); genuinely terminal 409s (Consumer Deleted, Consumer is push based) still stop the loop. Infinite mode also paces immediately answered empty pulls - a setNoWait(true) loop against an idle consumer used to busy-poll the server with an unthrottled 404/re-pull storm, and the non-terminal 409s re-pulled just as hot. Each consecutive empty window now backs off with an escalating delay (10ms doubling, capped at 500ms, reset on delivery), settling an idle consumer at about 2 pulls per second.

    • [bugfix] Pull idle heartbeats (#153): fetchBatch()/fetchNext() now validate the ADR-13 rule idle_heartbeat <= 50% of expires client-side and reject violations (and non-positive values) with a clear InvalidArgumentException instead of forwarding a value the server refuses. When idle heartbeats are requested, the fetch loop now tracks frame arrivals on the reply inbox (heartbeats included) and fails fast with a "missed idle heartbeats" JetStreamException once two heartbeat intervals pass in silence (nats.go ErrNoHeartbeat parity) - previously status-100 frames were discarded untracked and a dead server/route left the fetch waiting out the full expires+grace deadline. A partial batch collected before the silence is still returned.

    • [bugfix] A reconnect now stays in Connecting until the subscription replay and the reconnect-buffer flush have completed, and flips Open (arming the ping timer) only then - nats.go RECONNECTING parity. Previously connectOnce() flipped Open before the replay ran, with three consequences (#148): a publish from a concurrent fiber during the replay wrote straight to the wire ahead of the buffered earlier publishes, inverting per-publisher ordering and breaking JetStream Nats-Expected-Last-Subject-Sequence chains; a replay-leg failure left state = Open plus an armed ping timer on a dead socket for the whole backoff window, so user publishes surfaced spurious write errors instead of buffering; and the replay's own poll reads could collide with a user read admitted by the premature Open (Amp PendingReadError), aborting an otherwise-successful attempt. Publishes issued during the replay window keep buffering and the flush now drains in a loop, so frames appended mid-flush still go out - in publish order - before the connection opens. The heartbeat self-read additionally re-checks the state after its PING write, so a tick that raced into a recovery cannot read against the recovery's socket (#148).

    • [bugfix] A message handler throwing during the post-recovery delivery drain no longer closes a healthy, fully recovered connection: the exception used to escape recoverConnection() into its callers' failure handling, so the heartbeat paths (pingTimerTick() maxPingsOut escalation and consumeHeartbeatResponse() peer-closed recovery) flipped a SUCCESSFULLY recovered connection to Closed on a live socket - with no Closed event and no runtime-state release - and publish()'s write-failure retry surfaced an unrelated handler exception for a frame that was neither written nor buffered. Handler exceptions from that drain are now contained inside recoverConnection() and reported through the error listener (nats.go parity: handler errors during post-reconnect delivery are async errors, not connection failures); genuine recovery failures (exhaustion, auth, reconnect disabled) still throw unchanged (#144).

    • [bugfix] connect() no longer races an in-flight recovery (nats.go conn.mu parity). Calling connect() while a recovery was mid-flight (backoff, dial, or handshake) started a second concurrent connectOnce() chain against the same transport and parser; the recovery loop's next attempt then closed the healthy socket the user's connect() had just established and replayed only the pre-outage subscriptions, silently losing every subscription created on the new epoch (a runtime repro observed 4 dials for one outage). connect() now joins the in-flight recovery and shares its outcome, a concurrent connect() awaits the first dial instead of dialing in parallel, and connect() during drain() throws ConnectionException (Cannot connect: drain in progress) instead of dialing into the teardown. Re-entry semantics: a connect() called from a connection/error listener throws ConnectionException instead of joining - the listener runs inside the connecting/recovery fiber, so awaiting the join there could never complete (a permanent deadlock, including when the terminal Closed event is emitted by a failed initial connect); schedule supervision reconnects with Revolt\EventLoop::queue() and do not await the scheduled connect from inside the listener. The in-flight connect() deferred is now settled (and cleared) before every synchronous lifecycle emission (Connected/Closed), so it is never pending while a listener runs - closing the deadlock at its source in addition to the fiber guard. A dial that ends without the connection Open (a recovery or coalesced connect aborted by a concurrent disconnect()/drain()) throws ConnectionException ("aborted before the connection opened") - for the OWNER connect() (whose owned recovery was aborted mid-flight) exactly as for joiners, so an aborted owner no longer resolves as success on a Closed connection. The reverse direction is guarded too: recoverConnection() ignores recovery requests from stale failure continuations (a write/read that suspended before a terminal close and resumed failing later) while a user connect() is dialing - but that guard now also requires the state not be Open, so a genuine live-epoch failure while a Connected listener is still parked starts a recovery instead of being swallowed onto a dead socket. The closing = false reset also moved onto the fresh-dial path only, so a connect() racing a concurrent disconnect() can no longer disarm the user's close intent and let the recovery re-open a connection the user just closed - close intent wins and the connection stays Closed. A manual connect() after a terminal close (exhaustion, reconnect disabled, auth failure, user close) still starts a clean epoch exactly as before (#145).

    • [bugfix] Ordered consumer: a TimeoutException or ConnectionException from the best-effort deleteConsumer() leg of a recreate (sequence-gap or heartbeat tail-gap recovery) no longer bypasses the create-retry loop and permanently silences the consumer. Those exceptions extend NatsException, not JetStreamException, so the delete-leg catch missed them and the failure fell straight into the outer containment - the terminal "recreate failed" error was emitted with zero create attempts made, and no consumer, heartbeat, or message would ever arrive on the deliver inbox again. The delete leg now tolerates any failure (a timed-out delete may well have succeeded server-side), so control always proceeds to the create attempts and the terminal error is emitted only when the create leg itself is exhausted (#151).

    • [bugfix] The reconnect-disabled terminal path in performRecovery() now closes the transport best-effort and releases runtime state, matching every other terminal transition to Closed (the #127/#133 invariant). Previously the socket stayed pinned open and subscriptions/subscriptionMeta/pendingMessages (handler closures and payload bytes) survived the close, so a later manual connect() could deliver frames carrying the dead epoch's sids to stale handlers. The Reconnect is disabled exception and Closed-event semantics are unchanged. bufferFrame() additionally refuses publishes once the state is Closed, so a publish racing a terminal path's transport-close await fails loudly with Connection is not open instead of buffering bytes the state release would silently discard (#146).

    • [bugfix] A parse failure no longer silently discards valid sibling frames from the same chunk: ProtocolParser::push() retains frames parsed before a mid-chunk ProtocolException (drained via takeParsedFrames(), or prepended to the next push() result), and every read path that can hit one - processIncoming(), the heartbeat self-read, and the reconnect subscription-replay poll - now delivers them to their handlers instead of dropping them: their bytes are already consumed, and core NATS never resends, so they were permanently lost. The ProtocolException surfaces through the error listener on each of those paths instead of vanishing, and on processIncoming() the error emission and the recovery both run even when a handler throws while the recovered frames are delivered (the handler's exception still propagates afterwards, matching #128's containment semantics) (#147).

    • [bugfix] BatchPublisher::commit() now pre-flights the INFO-advertised server version and throws UnsupportedFeatureException BEFORE anything reaches the wire when the connected server is parseably older than 2.12 (#152). Previously the pre-2.12 detection (#130) fired only on the reply to the batch START request - by then the old server had already durably stored the start message as a plain publish, leaving one orphan message in the stream on the "nothing" path of an all-or-nothing API (and a silent plain publish for a single-message batch). Version parsing is numeric-prefix (nats.go-style): pre-releases such as 2.12.0-beta.1 count as 2.12 and proceed; unparseable versions (proxies, custom builds) and mixed-version clusters where the JS leader is older than the connected server still fall through to the reply-shape detection as defense in depth. Also documents that BatchPublisher::MAX_MESSAGES = 1000 is ADR-50's server DEFAULT batch limit, not a protocol constant - the server's error reply stays authoritative for the configured limit.

    • [bugfix] JetStream errors: the API error envelope's stable err_code (ADR-1) is now parsed at every envelope decode site and exposed via the new JetStreamException::getErrCode() accessor (null when the envelope carried none or the error is client-side). Error-kind discrimination now matches err_code first - createOrUpdateStream() detects "stream name already in use" by 10058 and KV createKey() detects "wrong last sequence" by 10071 - falling back to description substrings only when err_code is absent (old servers), so server rewording no longer breaks the create-or-update and exclusive-create semantics. The previous KV check compared getCode() to 10071 and could never match a real server rejection (those carry the HTTP-like 400). The synthetic "Key already exists" exception now carries 400 in getCode() and 10071 in getErrCode() instead of minting an API err_code into the transport-code slot (#154).

    • [bugfix] $JS.ACK reply-subject parsing now tolerates trailing tokens: JsMessageMetadata::fromMessage() and extractStreamSequence() accept the expanded v2 form with >= 11 tokens instead of exactly 11 or 12, anchoring field offsets from the front and ignoring everything after index 10 - nats.go parser parity, whose comment warns the parser must not be strict about trailing tokens because servers may append them (the 12th, a random suffix, was itself a later addition). A future server appending a 13th token no longer nulls out every delivery's metadata. The exact 9-token v1 form is unchanged. Additionally, the ordered consumer's null-metadata path is no longer a silent trapdoor: a reply subject that claims the $JS.ACK form but cannot be parsed used to route the message to the handler with BOTH the consumer-sequence gap check and the stale-consumer filter bypassed and zero errors emitted - the entire ordering guarantee evaporated quietly. Such a delivery now surfaces a descriptive JetStreamException through the error listener - once per consumer instance, re-armed on recreate, so an unparseable stream cannot become an error storm - and the message is still delivered best-effort (at-most-once, ordering unverified), matching the previous delivery behavior. Parse failures deliberately do not trigger a recreate: the replacement consumer would produce the same unparseable form, and the tolerant parser makes the branch nearly unreachable. Absent or plain (non-ack) reply subjects keep the silent best-effort path (#155).

    • [bugfix] WebSocket inbound performance for large frames (#164): a single WebSocket frame larger than 65 535 bytes (which must carry a 64-bit length) used to grow $readBuffer with a payload-sized .= copy per 8 KiB socket read - superlinear, the #140 issue the TCP path already fixed. Such a frame is now sized from its header (WebSocketFrameCodec::frameRequiredBytes()) and its remaining reads accumulate in a chunk list joined exactly once by a new spanning-consume path, so each payload byte is copied a bounded number of times regardless of how many reads it spans; fragmented-message continuation payloads likewise collect in a list joined once at the final fragment. Frames that fit a 7-bit or 16-bit length (<= 65 535 bytes, including every fragmented message's per-frame payloads) are inherently bounded and keep the pre-#164 .= + batch-decode path byte-for-byte - they are never even sized - so the small-frame and fragmented paths are unchanged. Measured through readLine() fed exact 8 KiB reads by an in-memory socket (medians of 21 interleaved before/after process rounds, min-of-5 each, one pinned core, WSL2/PHP 8.5): a single 10 MB frame drops ~22 ms to ~7 ms (~3x); a 10 MB message fragmented into 8 KiB continuation frames (~20 ms) and a flood of 100k 200-byte frames (~190 ms) stay at parity (within run-to-run noise). Outbound masking is unchanged - measured allocator-bound, with no stable win on PHP 8.5 (dropping the substr trim was within noise at 1 MB and slower at 8 KiB; PHP-level 8-byte-word masking was ~15x slower than the native whole-string XOR). Wire behavior is unchanged, pinned by the existing decode, reassembly and fragment-bound tests plus new multi-chunk, partial-next-frame, ping-between-fragments and read-boundary torture pins (a 64-bit-length frame and a large masked frame in one-byte reads, and continuation-frame headers split across reads).

    • [bugfix] Auto-unsubscribe armed with max <= already-delivered while a backlog was still queued no longer over-delivers exactly one message past the cap (#156). drainPendingForSid() enforced the delivery cap only AFTER delivering each message, so when unsubscribe(sid, max) was armed with max at or below the current delivered count while messages were still queued, completeAutoUnsubIfSatisfied() deferred (backlog not yet drained) and the next drain dequeued and delivered one more message before the post-delivery check fired - contradicting the #112 contract that the handler is never invoked more than max times. The drain loop now checks the cap at the TOP of the loop, before dequeuing/delivering, and drops the sid without invoking the handler once more when delivered >= max (nats.go AutoUnsubscribe gates delivery, not the aftermath). The existing post-delivery check is preserved for the #112 backlog-flush case where max > delivered on entry.

    • [bugfix] SlowConsumerPolicy::Error's overflow drop is now always observable, and its auto-unsubscribe accounting is documented and self-consistent (#159). The overflowing message is still lost - core NATS does not resend it - but the loss was surfaced inconsistently: the polling-queue (SubscriptionQueue) variant threw WITHOUT counting the drop through droppedCount()/the error listener, so it did not match the observable-drop contract the DropOldest/DropNewest paths honour (#134). The polling-queue Error variant now counts the drop via droppedCount() and reports it through the error listener before it throws. Accounting is deliberately unchanged: the dropped message still counts toward the auto-unsub max at intake (receivedCounts), exactly like DropOldest/DropNewest and exactly as the server counts a message the moment it writes it (#112). A client-side drop must NOT roll that count back - doing so would leave receivedCounts short of the max forever, so completeAutoUnsubIfSatisfied() would never fire and the subscription would leak (the #112 invariant). The documented, tested semantics: an Error-policy auto-unsub can therefore complete having delivered fewer than max messages, with each overflow surfaced loudly rather than lost silently. On the push (handler) path the overflow is surfaced exactly once - the thrown ConnectionException is rethrown to the caller (or, for a second frame in the same chunk, reported through the error listener) by dispatchFrames() (#158); the connection layer no longer also emits that same exception, which would have reported it twice. The per-subscription pending bound is message-COUNT based only; there is no byte-based bound (nats.go's pending limits are both count- and byte-based).

    • [bugfix] requestMany() no longer returns more than maxResponses when replies coalesce into a single TCP chunk (#160). The inbox collector appended every reply unconditionally while the wait loop enforced the cap only between reads, so one processIncoming() dispatching several replies could return more than requested. The collector now caps at maxResponses, dropping replies past the limit; stall/total-deadline semantics for under-cap collections are unchanged.

    Open source →
  8. v2.5.1 11 Jul 2026
    Release notes

    Patch release: fixes static analysis under PHPStan 2.2.5.

    • JsMessageMetadata::fromMessage() rewritten with literal token offsets per count() branch so PHPStan 2.2.5's stricter array-shape inference can prove every access. Byte-identical $JS.ACK parsing (pinned by the existing 9/11/12-token tests).

    v2.5.0's code failed CI's Unit + Static jobs because CI resolves dependencies fresh and picked up PHPStan 2.2.5, while the release gate had run against an older local vendor. This release is CI-verified green across the full matrix (PHP 8.2-8.5, E2E, Examples, Mutation): all functional changes are identical to v2.5.0 - see its notes for the full changelog of the 21-issue review cycle.

    Open source →
    Release notes

    Fixed

    • [bugfix] Static analysis: JsMessageMetadata::fromMessage() rewritten with literal token offsets per count() branch so PHPStan 2.2.5's stricter array-shape inference can prove every access (the shared base-offset arithmetic tripped offsetAccess.notFound and failed CI's Unit + Static jobs; CI resolves dependencies fresh and picked up 2.2.5 while the local gate ran an older vendor). Byte-identical parsing behavior, pinned by the existing 9/11/12-token ack-subject tests.
    Open source →
  9. v2.5.0 11 Jul 2026
    Release notes

    This release closes all 21 findings of the 2026-07-11 six-dimension review (#123-#143) plus the three in-flight fixes from the previous cycle (#112, #114, #116): every silent message-loss path found in the connection layer is fixed, the client survives abandonment and terminal closes without leaking, JetStream/KV behavior moved to spec/ADR parity, and the hot paths got measured performance work.

    Message-loss fixes

    • The reconnect buffer is no longer cleared before the flush write - a flush failure mid-recovery used to silently destroy every publish accepted during the reconnect window (#123).
    • Transport write() on a closed/never-connected socket throws instead of silently "succeeding" while sending nothing; recovery flips the connection off Open before its first await so concurrent publishes buffer instead of racing the dying socket (#124).
    • The reconnect handshake starts from a clean protocol parser - a drop mid-payload could previously poison every reconnect attempt against a healthy server until the budget exhausted (#125).
    • One failing frame no longer discards the other frames parsed from the same TCP chunk (slow-consumer overflow, failing PONG reply, fatal -ERR); the heartbeat read now reports fatal frames instead of swallowing them (#128).
    • subscribeQueue() buffers deliveries that arrive before the queue object exists (#129).
    • Atomic batch publish fails loudly with UnsupportedFeatureException on servers without batch support instead of silently storing the batch message-by-message (#130).

    Lifecycle & memory

    • An abandoned open connection is now garbage-collectable (the ping timer holds it weakly) and a destructor closes the socket - zombie clients no longer PING forever and steal queue-group deliveries (#126).
    • Every terminal transition to Closed releases runtime state; a manual connect() after reconnect exhaustion starts clean instead of resurrecting dead-epoch subscriptions as duplicate-delivering ghosts (#127).
    • Bucket wrapper caches, terminal-failure sockets, and post-commit batch payloads no longer accumulate (#133).

    Spec / interop

    • Stream and consumer names are validated client-side (nats.go parity) - dotted names no longer corrupt $JS.API subjects or silently read a sibling stream via direct get (#131).
    • ADR conformance roundup: idle_heartbeat honored and unknown pull-request keys rejected, ordered consumers pin num_replicas: 1, KV buckets are created with deny_delete/discard: new, KV keys enforce the ADR-8 charset, HPUB is guarded by the server's headers capability, service stats report real UTC, and the README documents the NATS 2.9+ consumer-management floor (#132).
    • Slow-consumer drops in SubscriptionQueue are observable via droppedCount() and the error listener; disconnect/unsubscribe backlog-discard semantics are documented (#134).

    Performance (measured against a live NATS 2.12)

    • Concurrent requests park instead of polling at 1 kHz: 200 in-flight requests idling 300 ms dropped from 425 ms CPU (a full core) to 92 ms (#135).
    • Outbound writes are single-hop and JetStream acks lost two of their three fiber hops: +18% serial publish throughput, ~2x ack throughput (#136).
    • The reconnect resubscribe replay is one write + one bounded drain instead of ~5 ms x subscriptions (#137).
    • Atomic batch commits coalesce intermediates into 512 KiB segments: 1000-message commit ~2.6x faster (#138).
    • Push/KV-watch deliveries dropped a per-message fiber hop and duplicate header parses: ~12% CPU end to end (#139); inbound parsing micro-overheads trimmed (#140).

    Testing & CI

    • First live-server reconnect coverage: a severing transport kills a real TCP session mid-idle/mid-traffic and asserts recovery and post-reconnect delivery (#141).
    • Unit gate ~12 s faster (fractional ping intervals - pingIntervalSeconds now accepts int|float, the release's one [feature]), ~250 rotted line-number pins fixed, behat tolerates exception subclasses (#142).
    • New nightly mutation-test workflow (03:17 UTC daily, 90% MSI gate); composer infection no longer dies at Composer's 300 s process timeout.
    • README corrections (#143) and the atomic-batch version-detection documentation rewrite (#130).

    Full details in CHANGELOG.md. All 21 fixes shipped with falsifiability-verified regression tests (each demonstrated to fail against the pre-fix code) and were gated on phpstan level 8, the unit suite, and the live integration suite against NATS 2.12.

    Open source →
    Release notes

    Added

    • [feature] NatsOptions::$pingIntervalSeconds now accepts int|float, so sub-second heartbeat intervals (e.g. 0.05) are expressible; integer values keep working unchanged (backward compatible for every existing caller) and 0 still disables the heartbeat. The underlying timer (EventLoop::repeat()) and the heartbeat read budget already operate on floats, so no runtime behavior changes for existing configurations. Motivation (#142): the integer floor made 1 s the minimum observable interval, forcing every ping-timer unit test to wall-clock-sleep past it (~10 s per unit-suite run); those tests now run 50 ms intervals with the same deterministic assertions.

    Changed

    • [bugfix] Concurrent requests no longer poll: a request waiting while another fiber owns the socket read used to wake every 1ms (allocating a Future per wakeup), so N concurrent requests burned O(N x 1000/s) wakeups - a KV getAll() on a large bucket became CPU-bound. Waiters now park on their reply or on a read-slot-release signal and one of them takes over the read pump when it frees; requestMany() waiters additionally wake per delivery so stall detection is unchanged. Measured with 200 concurrent requests idling 300ms against a live server: CPU time dropped from 425ms (a full core for the whole window) to 92ms (#135).
    • [bugfix] The outbound hot path no longer stacks 2-3 async() fiber hops per message (#136): transport write() runs inline in the caller's fiber and returns an already-resolved future (failures still surface through the future, never as a synchronous throw, so the #124 error contract is unchanged), and the JetStream ack/nak/term/inProgress helper returns the publish future directly instead of wrapping a third fiber around a replyTo null-check. Publish and request-target subjects are also memoized after first validation (bounded at 512 entries, full reset at the cap) so repeat publishes skip the regex + per-token scan; per-request reply inboxes are never cached. Measured against a live server: 50k serial small publishes went from ~4.5s wall / ~4.4s CPU (~11k msg/s) to ~3.9s / ~3.8s (~13k msg/s), and a 5k serial JetStream fetch-and-ack loop from ~1.0s wall / ~0.9s CPU (~4.9k acks/s) to ~0.5s / ~0.5s (~9.2k acks/s).
    • [bugfix] Reconnect subscription replay no longer has a ~5ms x N-subscriptions latency floor (#137): resubscribeAll() used to issue an awaited SUB write (plus the optional #112 UNSUB re-arm) and then a ~5ms drain poll per sid - with verbose off the server sends nothing after a successful SUB, so the poll always ate its full timeout serially inside the reconnect critical section (publishes buffering, no dispatch; 500 subscriptions added ~2.5s of blackout). The replay is now coalesced into O(1) transport writes - one buffer with every SUB (+UNSUB re-arm) frame, byte-identical on the wire and in order - followed by a single bounded drain poll, so prompt -ERR responses still abort the attempt exactly as before.
    • [bugfix] BatchPublisher::commit() no longer sends each intermediate batch message as its own awaited write (#138): per ADR-50 only the start and commit legs are request/reply, yet every intermediate paid ~1 syscall + 2 fiber hops, so a max-size 1000-message commit burned ~998 unnecessary syscalls of pure overhead in the library's designated atomic bulk API. The intermediates are now coalesced into bounded segments (at most 512 KiB per segment, so a 1000 x 1MB batch never concatenates into a ~1GB string) and each segment goes out as ONE transport write - byte-identical HPUB frames, same order, same batch headers. Per-message subject and max_payload validation now runs for the WHOLE block before any intermediate hits the wire, and the start/commit request legs (including the #130 pre-2.12 guards) are unchanged. Measured against a live 2.12 server: a 1000-message commit (128-byte payloads) dropped from ~55ms to ~21ms median wall time.
    • [bugfix] JetStream push deliveries (push consumers, ordered consumers, KV watches) no longer pay a per-message async() hop and duplicate parsing (#139): the push control-frame check runs synchronously in the dispatch fiber instead of spawning a fiber per message (a Future allocation, an event-loop hop, and at least one added tick of delivery latency each) and skips header parsing entirely for header-less data messages - the overwhelmingly common delivery; the rare flow-control/stalled acks are still sent, byte-identical. The ordered consumer reads Nats-Last-Consumer from the control frame's already-parsed headers instead of re-parsing the block, NATS header blocks are split with explode("\r\n") instead of preg_split (same pieces, cheaper), and a subscription's pending-message queue is now reused across drains instead of being freed when emptied and reallocated on the next delivery. Measured with an ordered consumer receiving 20k small messages from a live server (window includes the 20k publishes): median wall 2.20s -> 1.95s and CPU 2.06s -> 1.80s, ~9.1k -> ~10.3k msg/s end to end.
    • [bugfix] Inbound micro-overheads roundup (#140), behavior-identical: MSG/HMSG control lines are tokenized with a plain explode(' ') fast path instead of preg_split('/\s+/') per message (~5-10% of a core at 100k msg/s), falling back to the regex whenever the space-split result shows an empty token, a wrong token count, or embedded non-space whitespace - so tab/multi-space leniency is preserved bit-for-bit; the per-frame strtoupper() verb fold is skipped when the verb already matches a canonical upper-case form. While a large MSG/HMSG payload is incomplete, subsequent socket chunks accumulate in a list joined once on completion instead of growing the buffer with a copy per 8 KiB read (measured ~2-3x constant-factor overhead on multi-MB frames). Service endpoints no longer parse request headers (observer context / correlation id) on every request: the context is resolved lazily and memoized, so a service with no observers and a successful handler skips the parse entirely, while observer events and error-reply correlation ids are unchanged.

    Fixed

    • [bugfix] SubscriptionQueue slow-consumer drops are no longer silent (#134): an overflow under DropOldest/DropNewest now reports through the client's errorListener and logger with the same "Slow consumer on sid ..." debug-level signal the connection layer already emits for its own queue, and a new monotonic SubscriptionQueue::droppedCount() lets polling consumers detect delivery gaps. This matters because for subscribeQueue() consumers the connection queue drains into this second-level queue on every processIncoming() cycle - so this is where real overflow lands, and it previously produced no signal anywhere. The Error policy is unchanged (it already throws).
    • [bugfix] Resource-release roundup from the July review (#133): keyValue()/objectStore() no longer memoize bucket wrappers - the per-name cache had no eviction (not even deleteBucket()), so a long-lived client touching many bucket names (e.g. one per tenant) retained one wrapper per name forever; the wrappers are all-readonly value objects, so a fresh instance per call is behavior-equivalent. Every terminal connect/reconnect failure (failed initial connect, exhausted or auth-aborted recovery) now closes the transport socket best-effort, mirroring disconnect() - previously the last failed attempt's socket stayed pinned by the transport until the client object was GC'd. BatchPublisher::commit() releases the staged payloads once the batch is sent - a retained committed publisher (e.g. kept keyed by batchId() to correlate acks) previously pinned up to 1000 full payloads for its lifetime; as a consequence, count() now returns 0 after commit() (it previously kept reporting the staged total).
    • [bugfix] fetchBatch()/fetchNext() no longer silently drop unrecognized $pull fields: an unknown key now throws a JetStreamException naming the offending key and the supported set, so a typo (or a field this client does not implement) can no longer make the caller believe the option took effect - a bug-driven behavior change treated as a bugfix. The ADR-13 idle_heartbeat field (nanoseconds) is now an accepted pull-request field and reaches the wire (the fetch loop already absorbs the resulting status-100 heartbeat frames). Ordered consumers additionally pin num_replicas: 1 (ADR-17 / nats.go ordered.go parity), so an interest-retention stream's replica count is no longer inherited by the ephemeral ordered consumer (#132).
    • [bugfix] Spec-conformance corrections from the July review (#132): KV create() defaults now include deny_delete: true and discard: new (ADR-8 / nats.go CreateKeyValue parity; both stay user-overridable, discard: new expects NATS server 2.7.2+), so bucket revision history can no longer be deleted out from under other tooling and a full bucket rejects writes instead of silently evicting old keys. KV key validation is tightened to the ADR-8 rules (charset [-/_=.a-zA-Z0-9], reserved _kv prefix rejected), so entries written from PHP can no longer be unreadable via nats.go/nats.java/the nats CLI. Publishing with headers against a server whose INFO advertises "headers": false now fails client-side with a clear ConnectionException (nats.go ErrHeadersNotSupported parity) instead of the server killing the connection on an unknown HPUB operation. The services framework started timestamp is now generated in UTC, so its RFC3339 Z suffix is truthful on non-UTC hosts (ADR-32) and nats micro shows correct uptimes.
    • [bugfix] JetStream stream and consumer (durable) names are now validated client-side before being interpolated into a $JS.API.* subject, rejecting empty names and names containing spaces, tabs, CR/LF, ., *, >, / or \ (nats.go checkStreamName / checkConsumerName parity). Previously a dotted name silently changed which API endpoint the request hit: createConsumer('S', 'a.b') was routed by the server as the filtered-create form (consumer "a", filter "b"), getConsumer()/deleteConsumer() for that name hit no API route at all and surfaced a misleading 503 "subject is not bound to a stream", and directGetStreamMessage() on a dotted stream name could be routed as DIRECT.GET.<stream>.<last_by_subject> and silently return data from a SIBLING stream (#131).
    • [bugfix] Atomic batch publish no longer degrades silently to non-atomic storage on servers without batch support (pre-2.12): such a server treats Nats-Batch-* headers as opaque and acknowledges the batch start/commit as plain publishes, and commit() previously reported success while the "batch" had been stored message-by-message. The batch start now requires ADR-50's zero-byte ack (a normal PubAck aborts with UnsupportedFeatureException carrying the server version, before the remaining messages are published), and a multi-message commit ack must carry the batch id/count. The README "Server Version Requirements" section and the JetStreamContext::batch() docblock now describe when UnsupportedFeatureException can actually fire per feature class (#130).
    • [bugfix] NatsClient::subscribeQueue() no longer silently drops messages delivered between the SUB hitting the wire and the SubscriptionQueue object being constructed: the subscription handler is registered before the SUB write (so the sid is immediately routable, and a concurrent read or the heartbeat self-read can dispatch for it while subscribeQueue() is still suspended), but the handler discarded anything arriving before the queue existed. Early deliveries are now buffered and replayed into the queue (through the normal cap and slow-consumer policy) (#129).
    • [bugfix] An exception while handling one inbound frame no longer discards the frames already parsed from the same chunk. The parser has consumed the bytes, so an undispatched trailing frame was silently and permanently lost (core NATS does not resend) - e.g. a slow-consumer overflow on one subscription (Error policy) destroyed messages for healthy sibling subscriptions delivered in the same TCP chunk, and a failing PONG reply destroyed the messages behind the server PING. Dispatch is now contained per frame: every frame is handled, buffered deliveries are drained, and the first failure surfaces afterwards. The heartbeat self-read additionally reports a fatal frame (e.g. a server -ERR) through the errorListener instead of swallowing it whole (#128).
    • [bugfix] Every terminal transition to Closed now releases per-connection runtime state (subscription registry and handler closures, queued messages, counters, parser bytes, reconnect buffer) - previously only user disconnect()/drain() did. An exhausted reconnect or a terminal auth failure left everything referenced; worse, calling connect() again on the same instance silently believed it was subscribed (nothing was re-SUBbed) and a later automatic recovery would resurrect the dead epoch's sids as ghost subscriptions, duplicating deliveries into stale handler closures. Subscriptions now never survive a terminal close: re-connect() starts from a clean slate and the application re-creates its subscriptions (nats.go parity, documented on connect()) (#127).
    • [bugfix] A NatsConnection abandoned without disconnect()/drain() is now garbage-collectable: the ping timer's repeat closure previously bound $this strongly, so the event loop rooted the whole connection graph forever - the open socket, every subscription handler closure, and all buffers leaked per abandoned client, and the zombie timer kept PINGing and even delivering messages to abandoned handlers (stealing queue-group deliveries from live workers). The timer now holds the connection through a WeakReference and cancels itself once the application drops its last reference, and a new destructor cancels the heartbeat and closes the socket best-effort (#126).
    • [bugfix] The reconnect handshake now starts from a clean protocol parser. Previously the new connection's INFO/PONG bytes were fed into the parser state left by the dead connection; after a drop mid-message-payload the pending frame swallowed each attempt's INFO as phantom payload bytes, so every reconnect attempt failed with "Expected INFO during connect" and the client closed permanently against a healthy server once the attempt budget was exhausted (#125).
    • [bugfix] Transport write() on a closed/never-connected socket now throws TransportClosedException instead of silently succeeding (both TCP and WebSocket transports). Previously a publish, JetStream ACK, or flow-control reply racing a reconnect (or a concurrent disconnect()) could hit the nulled socket and report success while sending nothing - a silent message loss. The connection now also leaves the Open state before recovery's first await, so publishes issued while the dead socket is being torn down are routed into the reconnect buffer and replayed after the new handshake instead of racing the closing socket (#124).
    • [bugfix] A reconnect-flush failure no longer silently destroys publishes accepted during the reconnect window: flushReconnectBuffer() cleared the buffer before awaiting the write, so a socket failure during the flush left the next (successful) attempt with nothing to replay while every affected publish() had already reported success. The buffer is now cleared only after the flush write succeeds (a partially transmitted flush may duplicate frames on the retry - duplication beats loss, matching nats.go pending-buffer semantics), and exhausting reconnect attempts with a non-empty buffer reports the abandoned bytes through the errorListener and clears the buffer so a later manual connect() cannot replay frames from a dead epoch (#123).
    • [bugfix] unsubscribe($sid, $max) (auto-unsubscribe) sent UNSUB <sid> <max> but dropped the local handler immediately, so every message the server legitimately kept delivering up to the max was silently discarded. The handler now stays registered until $max total messages have been received (nats.go AutoUnsubscribe parity), a reconnect re-arms the server with the remaining allowance, and reaching the max removes the subscription locally (#112). The accounting is anchored to messages received (not delivered), so a message dropped by the slow-consumer policy still advances toward the max exactly as the server counts it - without this, under the default DropOldest policy the subscription could stall below its max forever (a permanent leak) and a reconnect would re-arm and over-deliver live messages past the intended max. Handler delivery is separately capped at the max so a batched-in or replayed extra frame is never over-delivered, and arming while a reconnect is in flight now defers to recovery instead of destroying the subscription (which would have silently lost the remaining armed deliveries).
    • [bugfix] Subscription state no longer leaks on failure paths: unsubscribe() releases local state even when the connection is not open or the UNSUB write fails (previously it threw first, leaking the entry and its handler closure, and resubscribeAll() would revive dead inboxes as ghost subscriptions), and subscribe() rolls its registry entry back when the SUB write fails. unsubscribe() on a connection that is not open now cleans up silently instead of throwing ConnectionException - a bug-driven behavior change treated as a bugfix (#116).
    • [bugfix] A failed ordered-consumer recreate (after a sequence gap) is no longer silently swallowed: the create is retried up to 3 times with backoff and a terminal failure is surfaced through the configured errorListener, so the application learns the consumer went permanently silent instead of waiting on dead air forever (#114). Adds NatsClient::options() exposing the client's runtime options.

    Testing & CI

    • [docs] New nightly mutation-testing workflow (mutation-nightly.yml, 03:17 UTC daily + manual dispatch): re-scores the whole unit-covered tree against the 90% MSI gate every day and uploads the Infection logs as artifacts, catching mutation-score drift between pushes. The composer infection script now disables Composer's 300 s process timeout, which killed any full mutation run mid-flight (the full sweep takes ~30 minutes; current score: 91% covered MSI over 5234 mutants at 100% mutation coverage).
    • [docs] Test-suite hygiene roundup from the July review (#142), dev-only: deleted testDirectGetBatchDelaysOnZeroFrames, whose assertions could not fail for its stated purpose (the pacing delay was never observed) while burning ~1 s per run - the sibling testDirectGetBatchReturnsEmptyArrayOnTimeout keeps the empty/timeout path covered; test comments no longer pin production line numbers (several were already stale) and reference the method or branch by name instead - deliberate per-mutant line pins in tests/Unit/Mutation are exempt; the ping-timer unit tests use fractional 50 ms intervals instead of ~10 s of wall-clock sleeps (see the pingIntervalSeconds entry above); the behat exception steps compare via is_a() (instanceof semantics) instead of strict class-string equality, so introducing a more precise exception subclass no longer breaks scenarios.
    • [docs] The reconnect path is now exercised against a live server (#141): a new SeveringTransport test decorator over the real AmpSocketTransport force-closes the live TCP socket mid-session, and two new integration tests (severing mid-idle and mid-traffic) assert the client observes the genuine EOF, reconnects, replays its subscriptions with a real SUB, and delivers post-reconnect traffic published from a second independent client. The four scripted "reconnect" tests that lived in the integration suite but never contacted the fixture (they ran only against injected fakes, so they were skipped by the local unit gate) were relocated to tests/Unit/NatsConnectionTest.php; one of them duplicated an existing unit test (testProcessIncomingReconnectsAndResubscribesAfterReadFailure covers the identical FlakyTransport script with stronger assertions) and was deleted instead. Dev-only - no runtime/library change.

    Documentation

    • [docs] README fixes: the feature table now shows the real named arguments for KV tombstones (delete/purge(..., tombstoneTtl:) - the documented ttl: threw "Unknown named parameter"), and the Schedule FQCN in the scheduling note renders with single backslashes (the doubled IDCT\\NATS\\... form inside a code span displayed literally and broke copy-paste) (#143).
    • [docs] disconnect() and plain unsubscribe() docblocks (connection and client facade) plus the README drain section now state that locally queued, undelivered messages are discarded (intentional nats.go Close()/Unsubscribe() parity) and name drain()/drainSubscription() as the lossless teardown paths (#134). No behavior change.
    • [docs] The README "NATS Server Version Requirements" section now states the real server floor: core NATS works against any server, but all JetStream consumer helpers use the 2.9+ named CONSUMER.CREATE API with no fallback to the legacy DURABLE.CREATE form, so consumer management requires NATS 2.9+ (documented in the feature table; pre-2.9 servers fail with a generic 503, not an UnsupportedFeatureException) (#132).
    Open source →
  10. v2.4.1 15 Jun 2026
    Release notes

    Patch release - test/CI and documentation only. No library or runtime change; safe drop-in for any 2.4.x user.

    Testing & CI

    • Added strict mutation testing with Infection: 517 new unit tests under tests/Unit/Mutation/ raised the suite's mutation score (Covered MSI) from 75% to 93%, killing ~870 previously-surviving mutants. A dedicated mutation CI job fails the build below 90% MSI - a quality bar on top of line coverage that catches assertions which pass but don't actually pin behavior.
    • Infection requires PHP 8.3+ and is intentionally not in require-dev, so composer install still works on PHP 8.2; the mutation job runs on PHP 8.3 and installs Infection itself.

    Documentation

    • Added a PHP Support Policy section: the library follows PHP's official release schedule, so PHP 8.2 support will be dropped by the end of 2026 (when PHP 8.2 reaches end-of-life), raising the minimum to PHP 8.3 at that point.
    • Repointed the Made in the EU badge to the renamed ideaconnect/made-in-the-eu repository.
    • De-AI typography cleanup: replaced em/en dashes, the ellipsis character, and arrow/multiplication signs with plain ASCII across the README, CHANGELOG, TESTS.md, and all PHP docblocks/comments (functional non-ASCII such as the µs duration unit is left intact).

    Full changelog: CHANGELOG.md - compare v2.4.0...v2.4.1.

    Open source →
    Release notes

    Testing & CI

    • [docs] Added mutation testing with Infection (composer infection, scripts/run-mutation.sh, infection.json5). 517 new unit tests under tests/Unit/Mutation/ raised the suite's mutation score (Covered MSI) from 75% to 93%, killing ~870 previously-surviving mutants. CI now enforces a strict mutation gate (a dedicated mutation job that fails the build below 90% MSI) - a quality bar on top of line coverage that catches assertions which pass but don't actually pin behavior. Mutation runs against the fast unit testsuite (no Docker); the remaining ~6% are mutants verified to be equivalent (no observable behavioral difference), documented in each test's reasoning rather than chased with meaningless assertions. Dev-only - no runtime/library change. PHP 8.2 support is unchanged: Infection requires PHP 8.3+ and is intentionally not in require-dev, so composer install still works on PHP 8.2; the mutation CI job runs on PHP 8.3 and installs Infection itself.

    Documentation

    • [docs] Added a "PHP Support Policy" section to the README: the library follows PHP's official release schedule, so PHP 8.2 support will be dropped by the end of 2026 (when PHP 8.2 reaches end-of-life), raising the minimum to PHP 8.3 at that point.
    • [docs] Pointed the "Made in the EU" badge at the renamed ideaconnect/made-in-the-eu repository.
    • [docs] Replaced AI-style typography in the README, CHANGELOG, TESTS.md, and PHP docblocks/comments with plain ASCII: em/en dashes become -, the ellipsis character becomes ..., and arrow / x for the arrow and multiplication signs. Functional non-ASCII (the µs duration unit, ©, §) is left intact.
    Open source →
  11. v2.4.0 14 Jun 2026
    Release notes

    Added

    • [feature] Protocol parser now recognizes operation verbs case-insensitively and accepts any whitespace (space or tab) between a verb and its arguments, aligning with the NATS wire spec. Real servers always send upper-case verbs, so this only adds leniency; argument/payload bytes are preserved verbatim. Resolves the long-standing README TODO (which has been removed).

    Fixed

    • [bugfix] JetStream: createStream() no longer rejects an empty subjects list when a non-empty sources configuration is provided. A pure aggregate/sourcing stream legitimately has no subjects of its own (the server allows it); the client previously only exempted mirror, so creating a sources-only aggregate stream failed with "Stream subjects must not be empty...".
    • [bugfix] Connection: a malformed async INFO frame is no longer allowed to throw out of the core processIncoming() read loop. Previously a non-JSON async INFO (corruption in flight, or a non-conformant server push) raised an uncaught JsonException that aborted the read cycle and skipped delivery of the MSG frames parsed from the same chunk. The runtime INFO decode is now contained (the bad update is skipped and surfaced via the error listener), mirroring the dispatch-containment principle from #97. Handshake INFO is still validated strictly and fails the connect on bad JSON.
    • [bugfix] WebSocket transport: the frame decoder no longer re-slices the entire remaining receive buffer once per frame. A single read carrying many coalesced frames is now decoded in O(total bytes) instead of O(frames x bytes) by advancing a cursor and trimming once, improving throughput under bursty high-fanout traffic. Behavior (including the "leave an incomplete trailing frame buffered" contract) is unchanged.

    Documentation

    • [docs] Added TESTS.md - a catalogue of every unit, integration, and Behat test with a one-line description of what it verifies - linked from the README's test baseline section.
    • [docs] Added an examples/ directory: one runnable, self-contained script per README example (42 files), plus scripts/run-examples.sh which runs them all against dockerized NATS and reports pass/skip/fail - a gate that keeps the README examples honest. Linked from the README Usage section.
    • [docs] Distributed Counter example now creates its backing stream with allow_direct: true (required because counterValue() reads via Direct Get); without it the documented example threw "no responders for $JS.API.DIRECT.GET". Prose updated accordingly.
    • [docs] Each examples/*.php script now opens with a file-level intro docblock describing exactly what it does and which README section it mirrors.
    • [docs] Every example heading in the README now carries a "Runnable example" pointer linking to the matching examples/*.php script, so each documented feature is one click from a runnable, verified file.
    • [docs] scripts/run-examples.sh now defaults NATS_NKEY_SEED to the dev seed trusted by build/nats/nkey.conf, so auth-standalone-nkey.php runs as a real functional test in the dev stack instead of self-skipping. With this, all 42 examples pass against the full dockerized stack.
    • [docs] CI now runs every example as a required gate (a dedicated examples job in .github/workflows/ci.yml that boots the full dockerized stack and runs scripts/run-examples.sh). The runner gained an EXAMPLES_STRICT mode (used by CI) that treats a skipped example as a failure, so the build fails unless every example actually executes and passes.
    Open source →
  12. v2.3.0 13 Jun 2026
    Release notes

    Security

    • Credential exposure via a configured tlsContext (#95). Versions before 2.3.0 could transmit the CONNECT frame - which carries the configured credentials (token / user-password / JWT signature / NKey signature) - in cleartext when a NatsOptions::$tlsContext was supplied but tlsRequired was off, the DSN used the nats:// scheme, and the server's INFO did not advertise tls_required. The TLS-required check ignored tlsContext, so the upgrade and the cleartext fail-safe were both skipped. Fixed: a configured tlsContext now forces the TLS upgrade (and fails fast if TLS cannot be established). Upgrading is recommended for anyone using the tlsContext escape hatch. See the Fixed entry below.

    Added

    • [feature] Object Store: ObjectStoreBucket::watch() now accepts an optional ObjectStoreWatchOptions to select the delivery policy, mirroring the KeyValue watch matrix and the reference ObjectStore.Watch. With no options (null) the watcher stays updates-only (deliver_policy=new, unchanged). Passing an ObjectStoreWatchOptions instance opts into "snapshot then follow" - replay the current metadata of every existing object first, then live updates (last_per_subject, the reference default) - or full history (includeHistory) / explicit updates-only (updatesOnly). (#98)
    • [feature] Services: a declared endpoint schema is now also surfaced in the standard $SRV.INFO response endpoint entries. ADR-32 stabilizes only PING/INFO/STATS, so spec-conformant tooling (nats CLI micro, nats.go) never queries the non-spec $SRV.SCHEMA verb; carrying the schema in INFO makes it discoverable. The $SRV.SCHEMA verb is retained for backward compatibility. (#101)

    Fixed

    • [bugfix] Object Store: an object stored with empty/default metadata is now readable by the official NATS clients. Empty metadata was serialized as a JSON array ("metadata":[]), which the Go client rejects with "object-store meta information invalid" because it expects a map; the field is now omitted when empty (matching omitempty), restoring interoperability with the nats CLI / nats.go for the common default-metadata case. Verified live against the nats CLI. (#109)
    • [bugfix] KeyValue: watch()'s onCaughtUp (end-of-initial-data) signal now fires on an empty or no-match bucket. Previously it could only fire from a delivered message reporting num_pending = 0, so with nothing to deliver it never fired and a caller blocking on it hung forever. The signal is now also derived from the created consumer's num_pending and fires immediately when the consumer starts with nothing pending. (#99)
    • [bugfix] Protocol: the inbound MSG/HMSG frame bound is now coupled to the server's negotiated max_payload instead of a fixed 8 MiB. On a server with a raised max_payload (e.g. 16/32/64 MiB), a legitimately large message larger than 8 MiB was rejected as an oversized frame - throwing a ProtocolException that the connection turned into a reconnect, so the message was effectively undeliverable. The parser bound is raised from INFO (max_payload + a header-block margin, never below the historical 8 MiB), with a generous 64 MiB fallback when max_payload is unknown. (#94)
    • [bugfix] Services: the endpoint success path no longer lets a json_encode failure escape the shared dispatch loop. A handler returning a value that cannot be JSON-encoded (binary / non-UTF-8 data, NAN/INF) previously threw a JsonException out of the subscription callback, aborting delivery for every subscription on the connection. The response publish is now guarded: an encode failure is recorded and answered with a controlled HANDLER_ERROR/500 reply (mirroring the handler-exception path), so one endpoint returning binary data can no longer take down the whole client's dispatch. (#97)
    • [bugfix] KeyValue: history() no longer uses the throwing messageMetadata() path. A delivery lacking a parseable $JS.ACK reply subject (a control / non-conformant frame) is now skipped instead of throwing out of the shared dispatch loop - which would tear down delivery for every subscription on the connection (the same class fixed for watch() in #90) - and is no longer recorded as a bogus history entry. (#96)
    • [bugfix] TLS: a configured NatsOptions::$tlsContext now correctly forces the TLS upgrade, matching its documented "treated as TLS-required" contract. Previously requiresTls() ignored tlsContext, so a tlsContext-only configuration over a nats:// DSN to a server that did not advertise tls_required connected in plaintext and wrote CONNECT (carrying credentials) in cleartext. The credentials fail-safe now also covers this path, so a tlsContext whose handshake cannot establish TLS fails fast instead of leaking credentials. (#95)
    • [bugfix] WebSocket: a corrupt permessage-deflate frame no longer emits an uncaught native E_WARNING from inflate_add()/deflate_add() before the typed ProtocolException. The warning is now suppressed (the return-value check already raises ProtocolException), so apps that promote warnings to exceptions get the intended ProtocolException instead of a generic ErrorException leaking from the codec. (#100)

    Documentation

    • [docs] README "Reconnect Behavior" no longer wrongly states that publishes during reconnect are lost. It now documents the outbound reconnect buffer (publishes are buffered up to reconnectBufferSize, default 8 MiB, and flushed on reconnect; rejected only when the buffer is full, buffering is disabled, or the connection is closed/not reconnecting), with test citations. (#102)
    • [docs] README "Configuration Option Mapping" table now lists the 11 previously-omitted NatsOptions fields - connectionListener, errorListener, jwtProvider, tokenProvider, reconnectBufferSize, tlsContext, randomizeServers, retryOnFailedInitialConnect, webSocketHeaders, webSocketCompression, logger - with types/defaults. NatsOptionsTest::testDefaultsMatchDocumentedValues now asserts these defaults too, keeping the table's "asserted by" claim accurate. (#103)
    • [docs] README: new "WebSocket Transport" section (with an Index entry) showing how to wire WebSocketTransport, the ws:// / wss:// expectations, and the webSocketHeaders / webSocketCompression options. (#104)
    • [docs] README: the Observability note now documents the typed connectionListener / errorListener closures, not just the PSR-3 logger. (#105)
    • [docs] README: added a standalone-NKey authentication example (nkey + nonceSigner, no JWT) to the Authentication Options block. (#106)
    • [docs] PHPDoc: KeyWatchOptions and KeyValueBucket::watch() now make clear that the last-per-subject "snapshot then follow" default applies only when a KeyWatchOptions instance is supplied; watch() called with $options = null is updates-only and replays nothing. (#107)
    • [docs] PHPDoc: ObjectInfo::$digest is no longer described as "Server-provided" - it is the content digest recorded by the writing client and verified on read. (#108)
    • [docs] Added a runnable performance baseline script (scripts/benchmark.php, request/reply + publish throughput) and a sample-results table in the README's Performance section.
    Open source →
  13. v2.2.0 10 Jun 2026
    Release notes

    Added

    • [feature] Server-version awareness for version-gated features. Each feature's minimum NATS version is documented (PHPDoc Requires NATS X.Y+ notes + a compatibility table in the README) and exposed programmatically via the new IDCT\NATS\JetStream\FeatureSupport registry (FeatureSupport::requiredVersion('allow_atomic') -> "2.12").
    • [feature] New IDCT\NATS\Exception\UnsupportedFeatureException (a subclass of JetStreamException). When a JetStream request fails because the connected server is too old for a feature (the server rejects the config field with unknown field "X"), the client now raises this typed exception carrying the feature, the required version, and the server's reported version - instead of an opaque error. The detection is reactive (derived from the server's own response on failure); there is no per-request version probe. JetStreamException is no longer final so it can be specialized (existing catch (JetStreamException) handlers are unaffected).
    Open source →
  14. v2.1.1 10 Jun 2026
    Release notes

    Verification pass for the 2.1.0 roadmap features against a live NATS 2.12.9 server.

    Fixed

    • [bugfix] Atomic batch publish (#8): the stream-config field is allow_atomic - the server rejects the previously-documented allow_atomic_publish with unknown field. Corrected the batch()/BatchPublisher docblocks (the BatchPublisher code itself was already correct and is now verified end-to-end: a 3-message batch commits 3/3 with the batch/count ack parsed).

    Added

    • Live integration tests for atomic batch publish (#8) and batched/multi Direct Get (#13), and a connection-level regression test for the fragmented-INFO handshake (#2, the trim($chunk) bug fixed in v1.0.1 previously had no test). Full integration suite (76 tests) passes against NATS 2.12.9.
    Open source →
  15. v2.1.0 10 Jun 2026
    Release notes

    NATS 2.11/2.12 client feature support (roadmap milestone, GitHub issues #4-#14). All changes are backward compatible (new optional parameters / new methods); the one behavior change (#5 delete markers) is bug-driven and flagged [bugfix], so this is a minor release.

    Changed

    • [bugfix] Honor JetStream subject delete-markers (Nats-Marker-Reason: MaxAge/Remove/Purge, ADR-43, issue #5). A server-written delete-marker is now treated as a tombstone rather than a live value: KeyValueBucket::get() returns a PURGE entry with a null value (was an empty-string PUT), getAll() omits the key, watch() emits a tombstone, and ObjectStoreBucket::watch()/info() skip the marker. Behavior change (flagged bc-break on the issue, but bug-driven so versioned as a bugfix): only reachable when a stream has subject_delete_marker_ttl set, which this client now also forwards as a create option.

    Added

    • [feature] Batched / multi Direct Get (ADR-31, issue #13). New directGetBatch() collects a multi-response Direct Get stream (terminated by a 204 EOB or Nats-Num-Pending: 0), and directGetLastForSubjects() fetches the latest message for many subjects in one request via multi_last. Additive - the existing per-subject bulk paths (getAll()/list()) are unchanged pending live verification on a 2.11+ server.
    • [feature] Pull-consumer priority groups and richer pull options (ADR-42, issue #7). fetchBatch()/fetchNext() accept a $pull array (group, id, min_pending, min_ack_pending, priority, max_bytes, no_wait); PullConsumerIterator gains setGroup()/setPriority()/setMinPending()/setMinAckPending()/setMaxBytes()/setNoWait() and transparently captures the Nats-Pin-Id and re-pins on a 423 stale-pin status. New unpinConsumer() (CONSUMER.UNPIN) and pinIdOf(); consumer-create validates priority_groups/ priority_policy.
    • [feature] Atomic (all-or-nothing) batch publish (ADR-50, issue #8). JetStreamContext::batch() returns a BatchPublisher: add() stages messages and commit() sends them with a shared Nats-Batch-Id, an incrementing Nats-Batch-Sequence, and Nats-Batch-Commit: 1 on the final message, returning a single PubAck exposing the committed batchCount/batchId. Capped at 1000 messages; an aborted batch surfaces as a JetStreamException. Requires allow_atomic_publish on the stream.
    • [feature] Multi-subject consumer filters (issue #10, NATS 2.10+). The consumer-create methods now accept a filter_subjects array (via options), validated client-side and mutually exclusive with the singular filter subject (combining the two is rejected with a clear error instead of an opaque server rejection).
    • [feature] Distributed counter CRDT (ADR-49, issue #9). JetStreamContext::incrementCounter() publishes a Nats-Incr delta (signed/unsigned integer string) and returns the new total; counterValue() reads the current value via Direct Get ("0" when absent). Values are handled as strings (decoded with JSON_BIGINT_AS_STRING) so arbitrary-precision counters are not truncated. The target stream must be created with allow_msg_counter enabled.
    • [feature] JetStreamContext::publish() now accepts optional message headers - a generic array $headers, a $msgId (Nats-Msg-Id) for server-side de-duplication within the stream's duplicate_window (issue #11), and a per-message $ttl (Nats-TTL; requires allow_msg_ttl on the stream - issue #4). KeyValueBucket::put() takes an optional per-key $ttl, and delete()/purge() take an optional tombstone TTL. TTL values (integer seconds, a Go duration string, or "never") are validated client-side via the new MessageTtl helper.
    • [feature] Recurring and cron scheduled publishing (ADR-51, issue #6). Schedule::every() builds an @every <interval> expression (from an integer number of seconds or a Go-style duration string) and Schedule::cron() validates/returns a 6-field (seconds-resolution) cron expression. Schedule::predefined() returns a predefined alias (@daily, @hourly, ...). JetStreamContext::publishScheduled() now accepts @at (with Z or a numeric RFC3339 offset), @every, cron, and the predefined aliases (previously only @at with Z) and emits the optional Nats-Schedule-Source, Nats-Schedule-Time-Zone (cron/alias only, rejected otherwise), and Nats-Schedule-Rollup: sub headers alongside the existing Nats-Schedule/-Target/-TTL. The target stream must be created with allow_msg_schedules enabled (e.g. createStream(..., ['allow_msg_schedules' => true])).
    Open source →
  16. v2.0.0 07 Jun 2026
    Release notes

    Findings from a deep review against a live NATS 2.12 server (README correctness, real-server behavior, bugs, and performance). Object Store interoperability with the nats CLI, idle-connection heartbeat survival, and request-timeout recovery were all verified working and are unchanged.

    Fixed

    • [feature] Added flush() (on NatsClient/NatsConnection): sends a PING and waits for the server's PONG, confirming the server has processed everything written so far (e.g. a SUBSCRIBE before publishing a dependent request). Bounded by the request timeout.
    • [feature] Service endpoints accept optional per-endpoint metadata (addEndpoint(..., metadata:)), advertised in the $SRV.INFO response per the NATS micro spec.
    • [bugfix] The protocol parser now rejects a size/sid token that would overflow a PHP int (which (int) silently saturates to PHP_INT_MAX) as a ProtocolException.
    • [bugfix] NkeySeedSigner now zeroes the raw seed and key-pair buffers (sodium_memzero) once the Ed25519 key is derived; ProtocolCodec fails fast if a configured nkey does not match the seed signer's public key. The service started timestamp now carries sub-second precision.
    • [bugfix] ObjectStoreBucket::putStream() no longer recopies the buffer tail per chunk (O(n^2) for a producer block much larger than chunkSize); it advances a read offset and compacts once per block. The constructor now rejects a non-positive chunkSize (which made put()/putStream() loop forever) with a JetStreamException.
    • [bugfix] Object Store info()/get()/list() now populate ObjectInfo::revision from the record's stream sequence (the Nats-Sequence Direct Get header, or the seq of the STREAM.MSG.GET fallback) instead of always leaving it null.
    • [bugfix] KeyValue/Object Store bucket names are now validated (^[A-Za-z0-9_-]+$); a name with dots or wildcards would otherwise mis-scope the backing stream subjects.
    • [bugfix] JetStream publish()/publishScheduled() now translate a no-responders reply into a JetStreamException (code 503) - e.g. publishing to a subject not bound to any stream - so a catch (JetStreamException) no longer misses it as a bare NatsException.
    • [bugfix] drain() now always closes the socket and clears state even if a fatal frame surfaces mid-flush, instead of escaping the flush loop and leaving the connection wedged in Draining.
    • [bugfix] Reconnect no longer deadlocks when a subscription handler publishes during recovery. Subscription-replay (drainImmediateServerFrames) previously delivered buffered messages to user callbacks while still inside the reconnect critical section; a callback that published and hit a write failure re-entered recoverConnection() and awaited the in-progress reconnect, hanging the recovery fiber. Buffered messages are now delivered after recovery completes (outside the critical section), so such a callback starts a fresh recovery instead of deadlocking.
    • [bugfix] Microservice endpoint error replies now carry the NATS micro-spec Nats-Service-Error and Nats-Service-Error-Code reply headers (400 for validation, 500 for handler errors), so a generic client (Go micro, nats CLI) detects the failure by header instead of treating the header-less JSON error body as success. The description is collapsed to a single line so a crafted message cannot break header framing; the JSON error body is unchanged.
    • [bugfix] KeyValue getAll() now paginates the STREAM.INFO subjects map (via offset) instead of reading a single page, so a bucket with more keys than the server's subjects-map cap is no longer silently truncated (mirroring Object Store list()), and it now throws on a STREAM.INFO API error instead of swallowing it into an empty result.
    • [bugfix] Single-record Direct Get reads now fall back to the leader STREAM.MSG.GET path when Direct Get is unavailable (a stream with allow_direct disabled, or an older server). The no-responders error is translated to a clear JetStreamException (code 503); KeyValue get() and Object Store info()/get() (single-chunk fast path) then retry on the leader, so reads keep working on interop buckets (e.g. created by the nats CLI without allow_direct) instead of surfacing an opaque error.
    • [bugfix] Ordered-consumer gap detection and streamSequenceOf() (used for KV/Object Store revision) now parse the 11-token domain-qualified ACK reply subject ($JS.ACK.<domain>.<account>.<stream>... without the trailing random token), not just the 9- and 12-token forms. Previously the sequence fell through to null on JetStream-domain/leaf deployments, silently disabling gap detection and revision tracking there.
    • [bugfix] The plaintext-credentials fail-safe now also covers the handshake-first TLS path. The guard that refuses to write CONNECT (which carries jwt/sig/nkey/user/pass/token) over a still-plain socket was gated on the non-handshake-first branch, so tlsHandshakeFirst=true combined with no TLS materials (and a nats:// DSN) while the server's INFO advertised tls_required could leak credentials in cleartext. The fail-fast now runs whenever TLS is required and the handshake did not establish it, regardless of tlsHandshakeFirst.
    • [bugfix] A JetStream flow-control STALL heartbeat is now answered. The server leaves the message reply empty for a stall and puts the flow-control reply subject in the Nats-Consumer-Stalled header value; the client previously detected the stall but published to the empty reply, so the ack never reached the server and a throttled ordered/flow-controlled consumer could stall indefinitely with no error surfaced. The normal $JS.FC. flow-control-request reply path is unchanged.
    • [bugfix] Subscription dispatch is now non-reentrant per SID. If a handler awaits on the connection (e.g. an ordered consumer recreating itself during gap recovery), a heartbeat tick or a nested request() self-pump could previously re-enter the per-SID drain and deliver that subscription's next message on top of the still-suspended handler - corrupting ordered-consumer recovery state (stale by-reference sequence/consumer-name -> duplicate deleteConsumer/recreate) and causing overlapping/duplicate delivery. Delivery for a SID in flight is now deferred until the suspended handler returns (FIFO preserved); other SIDs stay deliverable so nested requests still complete.
    • [bugfix] The CONNECT frame now advertises the resolved client library version (from the installed Composer package, with a constant fallback) instead of the stale hardcoded 0.1.0-dev, so server connz/monitoring attributes traffic to the correct version.
    • [bugfix] Header publishes (publishWithHeaders(), KV/Object Store metadata writes) now build and CR/LF-validate the header wire block once and reuse it for sizing and each write attempt, instead of re-running toWireBlock() two or three times per publish.
    • [bugfix] The per-chunk subscription drain no longer rescans every subscription that has ever received a message: a drained (or undeliverable) per-SID queue is now released, so the drain stays proportional to the subscriptions with pending messages. This also fixed a latent coupling where the request UNSUB cleanup was gated on the (now-released) pending queue.
    • [bugfix] Object Store get()/getToCallback() now fetch a single-chunk object with one Direct Get on its chunk subject, instead of creating, pulling from, and deleting a transient ephemeral consumer - turning the common small-object download from 4 round-trips into 1 (plus the metadata read). Multi-chunk objects still use the batched pull-consumer path.
    • [bugfix] Object Store put() and delete() now run the previous-revision lookup concurrently with the chunk upload / tombstone publish, awaiting it only just before the best-effort chunk purge it feeds. Previously the lookup was a serial round-trip on the critical path before the first byte was written, roughly doubling small-object write latency. The lookup is issued at the same coroutine depth as the upload, so request ordering stays deterministic.
    • [bugfix] Ephemeral push consumers (KeyValue/Object Store watch(), ordered consumer) now set an inactive_threshold, so the server reaps them once the subscription ends instead of leaking server-side consumers when a long-running app re-subscribes. An active subscription keeps the consumer alive; callers may override the threshold.
    • [bugfix] SubscriptionQueue now bounds its polling backlog with maxPendingMessagesPerSubscription and the configured slow-consumer policy. Previously the connection's per-chunk drain emptied its (capped) queue into the unbounded polling queue, so a queue consumed slower than it was fed could grow until OOM.
    • [bugfix] Object Store list() now paginates the meta-subject enumeration (via the STREAM.INFO offset) instead of reading a single page, so a bucket with more objects than the server's subjects-map cap is no longer silently truncated. The loop terminates on the first empty/duplicate page, so it is safe even against a server that ignores offset.
    • [bugfix] NatsHeaders::toWireBlock() now rejects an empty/blank header name or one containing whitespace or a colon (previously only CR/LF were rejected, so a bad name silently produced a malformed/mutated block), and trims surrounding whitespace from values so they round-trip symmetrically with decode (which already trims).
    • [bugfix] Object Store digest verification now compares the decoded digest bytes (tolerating missing base64url padding) with hash_equals(), instead of a string compare that spuriously rejected a byte-identical object whose metadata used unpadded base64url (some non-Go clients).
    • [bugfix] KeyValue get()/update()/delete()/purge()/getAll() now wrap a malformed (non-JSON) reply in a JetStreamException instead of leaking a raw JsonException, consistent with put() and the rest of the API.
    • [bugfix] The protocol parser now bounds an unterminated control line (no CRLF) to 1 MiB and raises a ProtocolException instead of buffering it without limit. maxFrameSize only bounded MSG/HMSG payloads (parsed after their control line completes), so a peer streaming bytes without a CRLF could drive the client to OOM.
    • [bugfix] Service::start() is now atomic: if a subscribe fails partway, it rolls back the subscriptions already made and rethrows, instead of leaving the service half-initialized with the idempotency guard then masking a retried start() as a no-op. A separate started flag tracks completion.
    • [bugfix] Microservice request observers now receive the terminal request_end event on the schema-validation rejection path too (previously only request_start -> request_error fired), so observer spans/timers/gauges are not leaked for rejected (often hostile) traffic.
    • [bugfix] NatsClient::service() now validates the service name (^[A-Za-z0-9_-]+$) and requires a semantic version, failing fast instead of crashing start() mid-loop or over-subscribing to discovery subjects when the name contains a dot/space/wildcard.
    • [bugfix] request() (and every JetStream/KV/Object Store call built on it) no longer throws a spurious TimeoutException when the reply is delivered in the same event-loop tick the deadline fires. The wait loop now checks for completion before the deadline, so a reply that lands as the timeout expires is returned instead of discarded.
    • [bugfix] Ordered-consumer gap recovery now contains a failed consumer recreate (pruned/deleted stream, leadership change, transient timeout) instead of throwing out of the shared subscription dispatch loop and aborting delivery for every other subscription on the connection.
    • [bugfix] PullConsumerIterator infinite mode (setIterations(null)) now survives a transient 409 (Exceeded MaxAckPending, Leadership Change, Server Shutdown, Exceeded MaxWaiting) and keeps polling, instead of treating every non-404/408 status as terminal and silently exiting forever. A terminal 409 Consumer Deleted still stops the loop, and finite mode is unchanged.
    • [bugfix] drain() no longer busy-spins (100% CPU) or hangs when the server never sends the flush PONG. The flush loop now yields between empty reads so its deadline can fire; previously a synchronous 0-frame read starved the event loop, so the TimeoutCancellation could never fire and drain() never returned.
    • [bugfix] drain() no longer resurrects the connection on a read failure mid-flush. A peer close during drain previously triggered recoverConnection() - reconnecting and re-SUBscribing the very subscriptions drain() had just removed (and possibly re-delivering messages). processIncoming() now skips recovery while the connection is Draining and treats the read failure as end-of-flush.
    • [bugfix] CredentialsParser now parses real nsc-generated .creds files. The marker regex required exactly five dashes on both the BEGIN and END lines, but the NATS toolchain emits five dashes on BEGIN and six on END, so CredentialsParser::fromFile() threw Credentials file does not contain a NATS USER JWT block on essentially every genuine credentials file - making the documented JWT-via-.creds auth path unusable. Both markers now accept five-or-more dashes.
    • [bugfix] Object Store now stores a 0-byte object with chunks=0 and publishes no chunk message, matching the official Object Store layout; previously it wrote one empty chunk and recorded chunks=1. get() of an empty object also returns immediately instead of blocking until the download batch expiry waiting for a chunk that never arrives.
    • [bugfix] Object Store get() and getToCallback() now return null for a deleted (tombstoned) object, consistent with a missing object and the official not-found semantics; the tombstone metadata remains observable via info(). Previously get() returned an ObjectData with null data and getToCallback() returned the ObjectInfo.
    • [bugfix] Microservice handler errors no longer leak the raw exception message to the requester: the reply carries a generic Internal server error under the HANDLER_ERROR code, while the full detail stays server-side (endpoint lastError, $SRV.STATS, and the request_error observer event).
    • [bugfix] Service $SRV.STATS no longer emits the non-spec requests/errors aliases; only the spec-compliant num_requests/num_errors remain.
    • [bugfix] Connections now disable Nagle's algorithm (TCP_NODELAY). NATS is a small-message request/reply protocol, and Nagle combined with delayed ACKs added roughly 40 ms of latency per round trip; local request/reply throughput improved about 16x in a single-process benchmark (~22 to ~365 req/s) after this change.
    • [bugfix] Ordered consumers (subscribeOrderedConsumer()) now deliver in order, gap-free, and without duplicates. Gap detection was based on the stream sequence, which is non-contiguous for a filtered consumer, so every filtered delivery looked like a gap; and on a gap the out-of-order message was forwarded and the expected sequence advanced past it, causing duplicate/out-of-order delivery and a cascading consumer delete+recreate storm. Detection now uses the JetStream consumer (delivery) sequence, the out-of-order message is discarded, and the consumer is recreated from the last in-order stream sequence (resuming from the next available message if the restart point was pruned).
    • [bugfix] NatsOptions now rejects genuinely-invalid configuration at construction (non-positive connectTimeoutMs/requestTimeoutMs, maxPendingMessagesPerSubscription below 1, and negative reconnect/maxPingsOut values) with an InvalidArgumentException, instead of misbehaving later. Legitimate edge values stay valid: pingIntervalSeconds <= 0 disables the heartbeat, maxPingsOut 0 is allowed, and an empty servers list falls back to the default - so this is input validation, not a breaking change.
    • [bugfix] KeyValue keys with a leading, trailing, or consecutive dot (which produce a malformed $KV.<bucket>.<key> subject) are now rejected up front; dots, colons and slashes elsewhere in a key remain valid.
    • [bugfix] Object Store put() now pipelines chunk publishes in bounded in-flight windows instead of awaiting one PubAck round-trip per chunk, so large-object uploads are no longer strictly round-trip-bound. PUB frames are written to the single connection in chunk order, so stream order (and download reassembly) is preserved.
    • [bugfix] Single-record reads - KeyValue get() and Object Store info() (and the metadata read behind get()/getToCallback()) - now use the Direct Get API (served by any replica) instead of leader-only STREAM.MSG.GET, consistent with getAll()/list(). On clustered/replicated streams this stops concentrating reads on the stream leader. (The internal put/delete cleanup lookup stays on STREAM.MSG.GET for deterministic ordering.)
    • [bugfix] KeyValue getAll() and Object Store list() now read the latest record per key/object via the Direct Get API issued concurrently, instead of N+1 sequential leader-only STREAM.MSG.GET reads. For large buckets this stops hammering the stream leader and collapses O(keys) serial round-trips into roughly one round-trip of wall-clock. (Concurrent request/reply on a single connection is covered by a new integration test.)
    • [bugfix] publish() and publishScheduled() now wrap a malformed (non-JSON) acknowledgment in a JetStreamException instead of leaking a raw JsonException, consistent with the other JetStream API calls.
    • [bugfix] Direct Get now rejects an unrecognized response (no status line and no Nats-Stream/Nats-Sequence headers) with a JetStreamException instead of returning a garbage body, guarding against a non-conformant server/proxy.
    • [bugfix] KeyValue watch() now delivers updates through a JetStream push consumer (deliver_policy=new, ack-free) so each entry carries its revision (the stream sequence). Previously it used a plain core subscription and always reported revision=null, so a watcher could never feed an entry back into update()/CAS. Live-updates-only semantics are unchanged.
    • [feature] JetStreamContext::streamSequenceOf() returns the stream sequence of a JetStream-delivered message (from its $JS.ACK reply).
    • [feature] ObjectStoreBucket::putStream() uploads an object from a producer callback without holding the whole payload in memory (the streaming counterpart to getToCallback()): blocks of any size are re-chunked to chunkSize, published in bounded in-flight windows, and the SHA-256 digest is computed incrementally.
    • [feature] Object Store watch() now delivers updates through a JetStream push consumer (consistent with KeyValue watch()) and exposes each update's stream sequence via the new ObjectInfo::$revision field. Previously it used a plain core subscription that carried no sequence/revision. Live-updates-only semantics (deliver_policy=new, ack-free) are unchanged; $revision is null on ObjectInfos from get()/info()/list().
    • [bugfix] Service::stop() now tolerates a closed/lost connection: it unsubscribes each endpoint best-effort and always clears its subscription state, instead of aborting on the first failure (which leaked the remaining subscriptions and broke a later start() restart).
    • [bugfix] Service::addEndpoint() now rejects a duplicate subject with an InvalidArgumentException instead of silently overwriting the earlier endpoint and handler (which also under-reported them in INFO/SCHEMA/STATS).
    • [bugfix] Service::run() now stops when the connection is unrecoverable (closed for good) and backs off interruptibly, instead of busy-spinning at ~50 Hz silently swallowing the error.
    • [bugfix] The microservice discovery handler now swallows an encode/publish failure (e.g. invalid-UTF-8 metadata) instead of throwing out of the shared dispatch loop, which would abort delivery of buffered frames for other subscriptions.
    • [feature] NatsClient::state() exposes the current connection state.
    • [feature] SubscriptionQueue::unsubscribe() / close() cancel the queue's own subscription (convenience for $client->unsubscribe($queue->sid)).
    • [feature] AmpSocketTransport now accepts nats:// DSNs directly (self-normalizing to tcp://), so the transport is usable standalone, not only via the connection layer.
    • [bugfix] Object Store downloads now use a no-ack (ack_policy=none) consumer. The read-only download previously used an explicit-ack consumer and acked each chunk; on a slow link an ack stalling past ack_wait triggered redelivery, which re-hashed a chunk and produced a spurious digest mismatch.
    • [bugfix] Object Store downloads now fail on a truncated transfer (fewer chunks than the metadata declares) via a digest-independent completeness check, instead of silently returning a partial object when the metadata carries no digest.
    • [bugfix] Object Store watch() now tolerates a malformed metadata payload (skips it) instead of throwing out of the dispatch loop, which would abort delivery of buffered frames for other subscriptions.
    • [bugfix] listStreams() and listConsumers() now paginate through the JetStream LIST API (offset/total). Previously they read only the first page, silently truncating accounts with more than the server page size (256) of streams, or a stream with more than 256 consumers.
    • [bugfix] PullConsumerIterator infinite mode (setIterations(null)) now keeps polling past routine empty windows (404/408) instead of terminating on the first idle gap, so a long-running worker is no longer killed by a quiet period. Terminal errors (e.g. 409 consumer deleted) still stop the loop, and finite mode is unchanged.
    • [bugfix] The heartbeat watchdog now resets the outstanding-ping counter only when an actual PONG is received, not on any inbound bytes. Previously a server that stopped answering PINGs but kept trickling data (or a proxy replaying buffered data) never tripped maxPingsOut, defeating dead-link detection on busy connections.
    • [bugfix] drain() now waits for the server's PONG (bounded by a deadline) before closing, instead of bailing on a transient partial/empty read. A larger message split across socket reads no longer cuts the flush short and drops in-flight deliveries.
    • [bugfix] SubscriptionQueue::fetchAll() no longer returns early on a transient empty read (e.g. the heartbeat self-read briefly owning the socket) while its configured timeout window still has time remaining.
    • [bugfix] recoverConnection() now coalesces concurrent reconnect attempts. A suspended ping-timer callback resuming while the read path already began recovering can no longer launch a second reconnect that races on the parser, state, and socket.
    • [bugfix] The protocol parser now rejects malformed frames instead of silently misframing the stream: non-numeric or negative MSG/HMSG sizes and sids, and HMSG header bytes exceeding total bytes, raise a ProtocolException. A parse failure now resyncs past the offending bytes instead of leaving them buffered to re-throw on every subsequent read, and processIncoming() treats an unparseable stream as a transport failure (reconnect) rather than letting the exception escape the read loop.
    • [bugfix] The client no longer transmits credentials in plaintext to a TLS-required server. When the server advertises tls_required (or the option/tls:// scheme requires TLS) but no TLS materials were configured at connect time, the previous code performed a no-op "upgrade" and then wrote CONNECT (token/user/pass/JWT/sig) over the still-plaintext socket, hanging until the handshake deadline. The client now fails fast with a clear error and never writes CONNECT before TLS is active.
    • [bugfix] A graceful peer close (socket EOF) now triggers reconnect from the read path. readLine() previously collapsed the EOF null into an empty string, which the connection treated as "no data this tick", so a server restart / idle-timeout / load-balancer reap left the client believing it was connected to a dead socket (never recovering when pings are disabled, recovering only after ~90s otherwise). Transports now signal EOF via a TransportClosedException, which processIncoming() and the heartbeat self-read escalate to recoverConnection().
    • [bugfix] SubscriptionQueue::fetch(), next() (with no/zero/negative timeout), and fetchAll() (with no timeout) no longer block the calling fiber forever on an idle subject against a real socket. Each now bounds its single poll with a small cancellation, honoring the documented non-blocking contract.
    • [bugfix] Service::run() now passes its cancellation into processIncoming(), not only the outer await(). Previously a timed/cancelled run loop left the idle socket read running detached, wedging the shared connection (every later read short-circuited and the heartbeat stalled). The read is now torn down on cancel.
    • [bugfix] getStreamMessage() no longer returns an empty payload when the stored body is the single character 0. The decoded body was passed through a falsy fallback, so a legitimate "0" payload was replaced with an empty string.
    • [bugfix] getStreamMessage() now preserves headers stored with the message. Previously the stored header block was dropped and the returned message had no headers.
    • [bugfix] The heartbeat keep-alive read now delivers any application message it happens to read while consuming the server reply, instead of leaving it buffered until the next manual read. This removes a rare case where a reply could be delayed until its own timeout.
    • [bugfix] Microservice endpoints now default to a shared queue group (q) so multiple instances of the same service load-balance requests, matching the NATS micro specification. Previously every instance handled every request, which duplicated side effects and work across instances. Behavior change: with more than one instance, each request is now handled by exactly one of them. Pass null or '' as the endpoint queue group to opt out and fan out to all instances. (Reclassified from a breaking change to a bugfix because the previous behavior defeated the framework's scaling model.)

    Changed

    • [feature] Faster Object Store downloads: object chunks are pulled in bounded batches rather than one request/reply round-trip per chunk, which significantly reduces latency for large, multi-chunk objects while keeping peak memory bounded. Digest verification, in-order delivery, the chunk-by-chunk getToCallback() contract, and nats CLI interoperability are unchanged.

    Documentation

    • [docs] Corrected the Performance Benchmark Recipe, which previously stalled after roughly 50 requests because the responder consumed only one transport chunk per request. The recipe now drives the responder with a single continuous read loop, and the processIncoming() single-chunk semantics are spelled out.
    • [docs] Corrected the Scheduled Publish example. It now creates the backing stream with allow_msg_schedules (and allow_msg_ttl when a schedule TTL is used) so the example runs as written; without those flags the server rejects the publish.
    • [docs] Renamed the "Stream Message Direct Get" section to "Stream Message Get" and clarified that getStreamMessage() uses the standard stream message get API (not the JetStream direct-get API) and preserves the stored body and headers.
    Open source →
  17. v1.0.1 09 Apr 2026

    Nothing published for this version

  18. v1.0.0 20 Mar 2026

    Nothing published for this version

Every package, every release, already written down.

The archive is open and free. Watching your own project is what we are building next.

Browse the archive