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 2026Releases
latest 18-
v2.8.008 Aug 2026Release notes
Open source →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 plainunsubscribe($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 fromSTREAM.INFO. It is required on any handle that did not itself runcreate(), including a freshkeyValue()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 anexactNameparameter for watching an object whose own name contains*or>, andObjectStoreWatchOptionsgained anidleHeartbeatargument.[feature]subscribeOrderedConsumer()gainedconsumerOverridesandonConsumerCreated.
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
onErrorhandler the worker used to stop for good whilehandle()resolved normally, so it looked like a clean drain while messages piled up. fetchBatch()anddirectGetBatch()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 amax_deliver: 1consumer.- KV buckets created with
sourcesnow 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()andflush()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 theClosedstate.
Protocol and specification correctness:
- The WebSocket transport enforces RFC 6455 and RFC 7692 strictly: masked server frames, fragmented or oversized control frames,
RSV1without negotiated compression, and handshakes missing theUpgradeheaders 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.
$SRVdiscovery responses serialize emptymetadataas{}rather than[], which Go-based tooling rejected outright, making a metadata-less service invisible tonats micro ls.- Object Store
addLink()matches nats.go's guard,watch()encodes exact names, andlist()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.STATScould 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$terminalparameter, so already-decoded data is never discarded. It also rejects masked frames unless you passallowMasked: true.WebSocketFrameCodec::unmask()is deprecated in favour of that parameter.- KV source and mirror names: the
bucketalias is alwaysKV_-prefixed, so a bucket literally namedKV_xnow resolves to its own stream rather than to bucketx. An explicitname, or a bare string entry, keeps the nats.go convention of being used as-is when it already starts withKV_. - A mirrored KV bucket handle that did not run
create()must callbind()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.Release notes
Open source →Added
[feature]JetStreamContext::stopOrderedConsumer(int $sid): Futurestops 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 plainunsubscribe($sid)only ever worked until the first recreate, so this is now the documented way to stop any of them.[feature]KeyValueBucket::bind(): Futureresolves a mirrored bucket's read and write prefixes fromSTREAM.INFO. It is required on any handle that did not itself runcreate()(including a freshkeyValue()handle in the same process) before reads and write-through work correctly.[feature]NatsHeaders::get(array $headers, string $name): ?stringlooks 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 anexactNameparameter for watching an object whose own name contains*or>, andObjectStoreWatchOptionsgained anidleHeartbeatargument to tune the watch's heartbeat interval.[feature]JetStreamContext::subscribeOrderedConsumer()gainedconsumerOverrides(extra consumer configuration merged into the created instance) andonConsumerCreated(invoked once with the initial instance'sConsumerInfo, for example to readnum_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 usedecode(..., 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 requiresUpgrade: websocket/ aConnectiontoken list containingUpgrade, rejects extension responses that were never offered (an unsolicitedpermessage-deflateused 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-volunteeredserver_max_window_bitsof 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 echoserver_no_context_takeoverwhen 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 publicWebSocketFrameCodec::decode(): it no longer THROWS on a strictness violation - it reports it via a new by-ref$terminalout-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); passallowMasked: trueto 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 failshandle()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'sNats-Last-Consumeris 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 reportingNats-Last-ConsumerBELOW 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-missmulti_lastchunk'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 KVgetAll()/ ObjectStorelist()per-subject lookups fall back to the leader STREAM.MSG.GET path on a Direct Get 503 (allow_direct-disabled interop buckets) exactly likeget()/info(). Thelist()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.goErrObjectAlreadyExistsguard shape; overwriting a live object silently stranded its chunks forever, and allowing the tombstone diverged cross-client on shared buckets) - andaddLink()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 newexactName: trueparameter 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-insensitiveNatsHeaders::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);BasicJsonSchemaValidatorrejects 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 viaObjectStoreWatchOptions::$idleHeartbeat). A recreate before the first delivery re-applies the watch's initial deliver policy, so anew/last_per_subjectwatch never replays from sequence 1.subscribeOrderedConsumer()gainedconsumerOverridesandonConsumerCreatedparameters, and the newJetStreamContext::stopOrderedConsumer(int $sid)stops an ordered consumer / watch even after recreates rotated its internal sid (a plainunsubscribe()only ever worked until the first rotation). A watch stopped via the legacy plainunsubscribe($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 likestopOrderedConsumer()- 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 astopOrderedConsumer()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 withsourcesnow 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 notKV_-prefixed). Mirror buckets now setmirror_direct: trueand write THROUGH to the origin's prefix (nats.goputPreparity) instead of publishing to their own subject that no stream ingests (503); cross-domain mirrors (domainshorthand →external: {api: "$JS.<domain>.API"}) route writes via the external API prefix and reads via the origin prefix. The newKeyValueBucket::bind()resolves the same prefixes from STREAM.INFO for handles attached to mirror buckets created elsewhere - note this includes a FRESHkeyValue()handle in the same process (each handle is independent; only the instance that rancreate()is auto-resolved). Mirror read/write prefixes are applied only AFTER the stream create is confirmed server-side - and, symmetrically, the stale-prefix reset increate()/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: thebucketalias isKV_-prefixed UNCONDITIONALLY (it explicitly declares a KV bucket, so a bucket legitimately namedKV_xmaps to its own backing streamKV_KV_x), while a transform-less explicit sourcename, bare-string entries, and mirrornames areKV_-prefixed only when not already (nats.go stream-name idempotence) - the previous used-as-is behavior produced invisible data; supplysubject_transformsto 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 insidepublish()could re-order a retried chunk BEHIND later accepted chunks, andput()/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.gopurgePartialparity) - 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 noonErrorconfiguredhandle()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.goConsume()parity; finite/fetch semantics unchanged); the first 503 of a streak firesonErroronce 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. oneonErrorper no-responders episode, so a later outage on an idle, never-delivering stream is still reported. -
[bugfix]ThefetchBatch()anddirectGetBatch()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 amax_deliver: 1consumer - 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()andflush()can no longer hang forever on a backpressure-stalled peer: transport writes suspend indefinitely when the send buffer is full (they cannot be cancelled), anddrain()cancels the heartbeat FIRST - removing the only escalation that could break such a wedge - so its documented ~requestTimeoutMsbound 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, sodrain()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 whoseclose()throws on an already-broken socket cannot re-strand Draining either. Handler publishes issued whiledrain()delivers backlog are bounded by the drain's REMAINING budget (not a fresh fullrequestTimeoutMseach), 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 extenddrain()to ~K xrequestTimeoutMs.flush()'s PING write is bounded by the request timeout and surfaces aTimeoutExceptionon a write-side wedge. Implemented at the connection layer -TransportInterfaceis 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 theWebSocketTransport::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$SRVdiscovery response (PING/INFO/STATS/SCHEMA) now serializes emptymetadatamaps as JSON objects ({}) instead of arrays ([]), service-level and per-endpoint. ADR-32 types metadata asmap[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 aServiceErrorcode 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.STATSreportnum_errors > num_requests) and no longer emits a late duplicaterequest_errorobserver 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'slast_errorrecords 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 socketwrite()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 thetry/catchnever 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 awaitingtransport->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 anddrain()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-TCPAmpSocketTransportwas never affected (itsclose()does not write). -
[bugfix]The pipelined pull engine no longer treats a single immediately-answered empty pull as proof of idleness whensetDepth()> 1 (#169). Retires run in issue order, so underno_waitwith steady traffic belowdepth*batcha 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 finitesetIterations()mode) keep the original latch-on-first-empty behavior unchanged.
-
v2.7.116 Jul 2026Release notes
Open source →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, catchableConnectionExceptionwhen 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 theoverflow/prioritizedpolicy now honorsetDepth()instead of pulling strictly serially — those policies never emit aNats-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, apinned_clientgroup that captured a pin and then LOST it (a423cleared 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.
Release notes
Open source →Fixed
[bugfix]request()/requestMany()now surface a clear, catchableConnectionExceptionwhen 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 theoverfloworprioritizedpolicy now honorsetDepth()instead of pulling strictly serially. Those policies never emit aNats-Pin-Id, so the "grouped-and-unpinned" serialization guard (meant to let apinned_clientgroup 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, apinned_clientgroup that captured a pin and then LOST it (a423stale-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.
-
v2.7.015 Jul 2026Release notes
Open source →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 yourMaxAckPending/ack_wait, or callsetBatching(1)to restore one-message pulls.
All existing pull-consumer semantics are preserved:
stop()/drain(), the escalating idle backoff, finitesetIterations(), pinned priority groups (setGroup()) + 423 stale-pin re-pull, terminal-statusonError, 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%).
Release notes
Open source →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 (seesetDepth(), 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, finitesetIterations()(still serial and stop-on-any-error), pinned priority groups (setGroup()), 423 stale-pin re-pull, terminal-statusonError, and reconnect survival. The single-shotfetchBatch()/fetchNext()primitives are unchanged.[feature]The default pull batch size is now 100 (was 1) and a newsetDepth()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 yourMaxAckPendingandack_wait; callsetBatching(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.
- Pull consumer pipelining (#120). Pull consumers now keep several pull requests in flight over one long-lived pull inbox (
-
v2.6.015 Jul 2026Release notes
Open source →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 size —
NatsOptions::$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()onNatsClient/NatsConnectionexposes one read-and-dispatch cycle as anIncomingChunkResult(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 throughrequest(). Reply delivery, timeouts, no-responders handling, cancellation, andrequestManysemantics 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-onlylast_per_subjectconsumer instead of downloading every value;getAll()and Object Storelist()fetch every record with one batchedmulti_lastDirect 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 bymax_payloadso 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.
Release notes
Open source →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 anIncomingChunkResult(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]KVkeys()/listKeys()no longer download every value just to list names (#110). They now enumerate via alast_per_subject+headers_onlyephemeral consumer (the metaOnly watch path nats.go'sKeys()uses), so only theKV-Operation/Nats-Sequenceheaders return and DEL/PURGE tombstones are filtered by header alone - no value body crosses the wire. Previouslykeys()wasarray_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]KVgetAll()and Object Storelist()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 newJetStreamContext::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 negotiatedmax_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(),JetStreamContextpull-fetch and direct-get-batch,KeyValueBucket::history()andkeys(),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 throughrequest(). Reply delivery, timeouts, no-responders (503) handling, cancellation, andrequestManysemantics 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 everyrequest()/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.
- Configurable transport read chunk size —
-
v2.5.412 Jul 2026Release notes
Open source →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().Release notes
Open source →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).
-
v2.5.312 Jul 2026Release notes
Open source →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
Opentransition indefinitely, stranding the connection inConnecting. 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
streamis 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.
Release notes
Open source →Fixed
[bugfix]The reconnect-buffer flush no longer defers theOpenflip indefinitely under sustained publish pressure (#165, a #148 follow-up). Since #148 recovery staysConnectinguntilflushReconnectBuffer()fully drains, and the flush loops so publishes buffered mid-flush still go out in order beforeOpen. A fiber publishing continuously re-filled the buffer during each flush write's suspension, so the loop kept iterating and the connection could stayConnectingfor the whole outage window -subscribe()/request()/flush()/rtt()/processIncoming()all threw "Connection is not open" andReconnectednever 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: afterRECONNECT_FLUSH_MAX_PASSESdrain passes it SEALS the buffer, so late publishers park on a flush-done gate (then write directly onceOpen, or fail loudly onceClosed) instead of appending, the remaining bytes drain in one final pass, andOpenflips 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()threwTransportClosedExceptionthe instant it hit theOP_CLOSEframe, discarding the payload of every data frame earlier in the same batch - already consumed out of the read buffer by the by-referenceWebSocketFrameCodec::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 fromreadLine()first and defers the close to the nextreadLine()via apendingCloseflag, 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 aProtocolExceptioninstead 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 fromreadLine()first, and theProtocolExceptionis surfaced on the nextreadLine()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 singleinflate_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 aProtocolExceptionthe 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 newCONSUMER.CREATEat that subject, and unsubscribes the old inbox. An orphan left by a lostCONSUMER.CREATEreply (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-sideinactive_thresholdreap 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) /recreateInFlightguard, #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 descriptiveJetStreamExceptioninstead of silently dropping it. Status-0 data messages (including those carrying user headers) are unaffected.[bugfix]A JetStream publish ack that carries neither anerrornor astreamis now rejected with aJetStreamExceptioninstead of being accepted as a bogusPubAck('', 0)success (#121), matching nats.go, which rejects an empty-stream ack as invalid.[bugfix]directGetBatch()andKeyValueBucket::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_pendingnever reaches 0), now throws aJetStreamExceptionreporting 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
Closedevent and the "Reconnect exhausted: N bytes ... discarded" async error (#123), never through the returnedFuture. 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.
- 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
- 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
-
v2.5.212 Jul 2026Release notes
Open source →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
Connectinguntil 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.ACKmetadata parsing tolerates extra trailing tokens (#155); pull-consumer 409 handling,no_waitpacing, and idle-heartbeat validation/fail-fast are fixed (#153); KV/ObjectStore/Batch header requests surface no-responders uniformly asJetStreamException(503)(#161).
Delivery accounting, WebSocket, and performance
- Auto-unsubscribe no longer over-delivers one message past
max(#156);SlowConsumerPolicy::Errordrops are observable without corrupting auto-unsub accounting (#159);requestMany()respectsmaxResponseswhen 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).
Release notes
Open source →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), andBatchPublisher's start and commit requests calledrequestWithHeaders()directly, so on a JetStream-disabled server or an unbound subject they surfaced a bareNatsException('No responders...')instead of theJetStreamException(503)thatJetStreamContext::jsRequest()/publish()produce - a caller catchingJetStreamExceptionmissed it. The normalization is extracted into a sharedJetStreamRequesthelper that all four sites (includingjsRequest()) 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 initialconnect()that recovers viarecoverConnection()(reconnect enabled) now emitsConnectedfor the first-ever successful handshake instead ofDisconnectedthenReconnected, 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 observedDisconnected -> Reconnectedfor a connection that was never up and never sawConnected, so listener state machines keyed onConnected(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 bymarkConnectionOpen(). -
[bugfix]On a server-initiated WebSocket Close frame the transport now writes an echo Close frame (mirroring the received status code) before surfacingTransportClosedException, 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 theTransportClosedExceptionthe 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-messageSplQueuealloc/free, #139 began keeping each subscription's queue allocated but EMPTY for the subscription's lifetime;drainAllPending()then iteratedarray_keys($this->pendingMessages)after every inbound chunk, so the drain scan became O(all live subscriptions) plus a fresharray_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. ThedispatchingSidsre-entrancy guard (#112/#156), auto-unsubscribe completion (#112), anddrain()'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 anexplode(' ')+ per-token canonical scan fast path that measured slower per line than thepreg_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 ranpreg_split()anyway. The split reverts topreg_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 ownasync(), so each inbound chunk spawned a SECOND fiber on top ofprocessIncoming()'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 asCancelledException), 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 withconnect_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 negotiatedmax_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 spuriousProtocolExceptionthat forced an unnecessary reconnect.awaitInitialPong()now hands the frames parsed behind the PONG back toconnectOnce(), 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 duringconnectOnce(), 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-chunkfinally { drainAllPending(); }inprocessIncoming()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) anddisconnect()'s documented nats.goClose()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, thedispatchingSidsre-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.goDrain()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, aServicereply, orNatsMessage::respond()invoked from a handler while the connection isDrainingpreviously threwConnectionException("Connection is not open"): the publish path only wrote to the socket whenOpenand only buffered while a reconnect was in flight. The unguarded final backlog delivery then propagated that exception out of drain(), soreleaseRuntimeState()/transport->close()never ran - the connection stranded inDrainingwith the socket open and the delivered-but-unacked message was redelivered by the server. Now a publish duringDrainingwrites 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 reachesClosed. A publish after the connection hasClosedstill 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 withidle_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 aninactive_thresholdlapse, amem_storageR1 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 viaopt_start_seq), matching nats.go'sErrConsumerNotActivemonitor; 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 optionalidleHeartbeatNsargument to tune the interval, and KV watchers now request a default idle heartbeat too (tunable via the newKeyWatchOptions::$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(), andrtt()now correlate PONGs to their PINGs through a FIFO slot queue (nats.gonc.pongsparity) 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, soflush()returned before the server had processed the writes issued after that older PING, anddrain()/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 withConnectionExceptioninstead of idling out its deadline against the new socket. ThemaxPingsOutliveness 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 infiniteconsume()loop withsetMaxBytes()survives an oversized pending head message and keeps pulling (nats.go excludesErrMaxBytesExceeded/ErrBatchCompletedfrom terminal handling); genuinely terminal 409s (Consumer Deleted, Consumer is push based) still stop the loop. Infinite mode also paces immediately answered empty pulls - asetNoWait(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 ruleidle_heartbeat <= 50% of expiresclient-side and reject violations (and non-positive values) with a clearInvalidArgumentExceptioninstead 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"JetStreamExceptiononce two heartbeat intervals pass in silence (nats.goErrNoHeartbeatparity) - previously status-100 frames were discarded untracked and a dead server/route left the fetch waiting out the fullexpires+grace deadline. A partial batch collected before the silence is still returned. -
[bugfix]A reconnect now stays inConnectinguntil the subscription replay and the reconnect-buffer flush have completed, and flipsOpen(arming the ping timer) only then - nats.go RECONNECTING parity. PreviouslyconnectOnce()flippedOpenbefore 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 JetStreamNats-Expected-Last-Subject-Sequencechains; a replay-leg failure leftstate = Openplus 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 prematureOpen(AmpPendingReadError), 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 escaperecoverConnection()into its callers' failure handling, so the heartbeat paths (pingTimerTick()maxPingsOut escalation andconsumeHeartbeatResponse()peer-closed recovery) flipped a SUCCESSFULLY recovered connection to Closed on a live socket - with no Closed event and no runtime-state release - andpublish()'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 insiderecoverConnection()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.goconn.muparity). Callingconnect()while a recovery was mid-flight (backoff, dial, or handshake) started a second concurrentconnectOnce()chain against the same transport and parser; the recovery loop's next attempt then closed the healthy socket the user'sconnect()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 concurrentconnect()awaits the first dial instead of dialing in parallel, andconnect()duringdrain()throwsConnectionException(Cannot connect: drain in progress) instead of dialing into the teardown. Re-entry semantics: aconnect()called from a connection/error listener throwsConnectionExceptioninstead of joining - the listener runs inside the connecting/recovery fiber, so awaiting the join there could never complete (a permanent deadlock, including when the terminalClosedevent is emitted by a failed initial connect); schedule supervision reconnects withRevolt\EventLoop::queue()and do not await the scheduled connect from inside the listener. The in-flightconnect()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 concurrentdisconnect()/drain()) throwsConnectionException("aborted before the connection opened") - for the OWNERconnect()(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 userconnect()is dialing - but that guard now also requires the state not be Open, so a genuine live-epoch failure while aConnectedlistener is still parked starts a recovery instead of being swallowed onto a dead socket. Theclosing = falsereset also moved onto the fresh-dial path only, so aconnect()racing a concurrentdisconnect()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 manualconnect()after a terminal close (exhaustion, reconnect disabled, auth failure, user close) still starts a clean epoch exactly as before (#145). -
[bugfix]Ordered consumer: aTimeoutExceptionorConnectionExceptionfrom the best-effortdeleteConsumer()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 extendNatsException, notJetStreamException, 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 inperformRecovery()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 andsubscriptions/subscriptionMeta/pendingMessages(handler closures and payload bytes) survived the close, so a later manualconnect()could deliver frames carrying the dead epoch's sids to stale handlers. TheReconnect is disabledexception 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 withConnection is not openinstead 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-chunkProtocolException(drained viatakeParsedFrames(), or prepended to the nextpush()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. TheProtocolExceptionsurfaces through the error listener on each of those paths instead of vanishing, and onprocessIncoming()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 throwsUnsupportedFeatureExceptionBEFORE 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 as2.12.0-beta.1count 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 thatBatchPublisher::MAX_MESSAGES = 1000is 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 stableerr_code(ADR-1) is now parsed at every envelope decode site and exposed via the newJetStreamException::getErrCode()accessor (null when the envelope carried none or the error is client-side). Error-kind discrimination now matcheserr_codefirst -createOrUpdateStream()detects "stream name already in use" by 10058 and KVcreateKey()detects "wrong last sequence" by 10071 - falling back to description substrings only whenerr_codeis absent (old servers), so server rewording no longer breaks the create-or-update and exclusive-create semantics. The previous KV check comparedgetCode()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 ingetCode()and 10071 ingetErrCode()instead of minting an API err_code into the transport-code slot (#154). -
[bugfix]$JS.ACKreply-subject parsing now tolerates trailing tokens:JsMessageMetadata::fromMessage()andextractStreamSequence()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.ACKform 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 descriptiveJetStreamExceptionthrough 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$readBufferwith 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 throughreadLine()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 thesubstrtrim 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 withmax <= already-deliveredwhile 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 whenunsubscribe(sid, max)was armed withmaxat 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 thanmaxtimes. 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 whendelivered >= max(nats.goAutoUnsubscribegates delivery, not the aftermath). The existing post-delivery check is preserved for the #112 backlog-flush case wheremax > deliveredon 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 throughdroppedCount()/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 viadroppedCount()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 leavereceivedCountsshort of the max forever, socompleteAutoUnsubIfSatisfied()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 thanmaxmessages, with each overflow surfaced loudly rather than lost silently. On the push (handler) path the overflow is surfaced exactly once - the thrownConnectionExceptionis rethrown to the caller (or, for a second frame in the same chunk, reported through the error listener) bydispatchFrames()(#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 thanmaxResponseswhen 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 oneprocessIncoming()dispatching several replies could return more than requested. The collector now caps atmaxResponses, dropping replies past the limit; stall/total-deadline semantics for under-cap collections are unchanged.
-
v2.5.111 Jul 2026Release notes
Open source →Patch release: fixes static analysis under PHPStan 2.2.5.
JsMessageMetadata::fromMessage()rewritten with literal token offsets percount()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 + Staticjobs 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.Release notes
Open source →Fixed
[bugfix]Static analysis:JsMessageMetadata::fromMessage()rewritten with literal token offsets percount()branch so PHPStan 2.2.5's stricter array-shape inference can prove every access (the shared base-offset arithmetic trippedoffsetAccess.notFoundand failed CI'sUnit + Staticjobs; 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.
-
v2.5.011 Jul 2026Release notes
Open source →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 offOpenbefore 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
UnsupportedFeatureExceptionon 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
Closedreleases runtime state; a manualconnect()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.APIsubjects or silently read a sibling stream via direct get (#131). - ADR conformance roundup:
idle_heartbeathonored and unknown pull-request keys rejected, ordered consumers pinnum_replicas: 1, KV buckets are created withdeny_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
SubscriptionQueueare observable viadroppedCount()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 -
pingIntervalSecondsnow acceptsint|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 infectionno 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.
Release notes
Open source →Added
[feature]NatsOptions::$pingIntervalSecondsnow acceptsint|float, so sub-second heartbeat intervals (e.g.0.05) are expressible; integer values keep working unchanged (backward compatible for every existing caller) and0still 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 KVgetAll()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-3async()fiber hops per message (#136): transportwrite()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-ERRresponses 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 andmax_payloadvalidation 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-messageasync()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 readsNats-Last-Consumerfrom the control frame's already-parsed headers instead of re-parsing the block, NATS header blocks are split withexplode("\r\n")instead ofpreg_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 plainexplode(' ')fast path instead ofpreg_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-framestrtoupper()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]SubscriptionQueueslow-consumer drops are no longer silent (#134): an overflow underDropOldest/DropNewestnow reports through the client'serrorListenerand logger with the same "Slow consumer on sid ..." debug-level signal the connection layer already emits for its own queue, and a new monotonicSubscriptionQueue::droppedCount()lets polling consumers detect delivery gaps. This matters because forsubscribeQueue()consumers the connection queue drains into this second-level queue on everyprocessIncoming()cycle - so this is where real overflow lands, and it previously produced no signal anywhere. TheErrorpolicy 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 evendeleteBucket()), 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, mirroringdisconnect()- 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 bybatchId()to correlate acks) previously pinned up to 1000 full payloads for its lifetime; as a consequence,count()now returns 0 aftercommit()(it previously kept reporting the staged total).[bugfix]fetchBatch()/fetchNext()no longer silently drop unrecognized$pullfields: an unknown key now throws aJetStreamExceptionnaming 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-13idle_heartbeatfield (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 pinnum_replicas: 1(ADR-17 / nats.goordered.goparity), 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): KVcreate()defaults now includedeny_delete: trueanddiscard: new(ADR-8 / nats.goCreateKeyValueparity; both stay user-overridable,discard: newexpects 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_kvprefix rejected), so entries written from PHP can no longer be unreadable via nats.go/nats.java/thenatsCLI. Publishing with headers against a server whose INFO advertises"headers": falsenow fails client-side with a clearConnectionException(nats.goErrHeadersNotSupportedparity) instead of the server killing the connection on an unknown HPUB operation. The services frameworkstartedtimestamp is now generated in UTC, so its RFC3339Zsuffix is truthful on non-UTC hosts (ADR-32) andnats microshows 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.gocheckStreamName/checkConsumerNameparity). 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", anddirectGetStreamMessage()on a dotted stream name could be routed asDIRECT.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 treatsNats-Batch-*headers as opaque and acknowledges the batch start/commit as plain publishes, andcommit()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 withUnsupportedFeatureExceptioncarrying 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 theJetStreamContext::batch()docblock now describe whenUnsupportedFeatureExceptioncan actually fire per feature class (#130).[bugfix]NatsClient::subscribeQueue()no longer silently drops messages delivered between the SUB hitting the wire and theSubscriptionQueueobject 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 whilesubscribeQueue()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 theerrorListenerinstead of swallowing it whole (#128).[bugfix]Every terminal transition toClosednow releases per-connection runtime state (subscription registry and handler closures, queued messages, counters, parser bytes, reconnect buffer) - previously only userdisconnect()/drain()did. An exhausted reconnect or a terminal auth failure left everything referenced; worse, callingconnect()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 onconnect()) (#127).[bugfix]ANatsConnectionabandoned withoutdisconnect()/drain()is now garbage-collectable: the ping timer's repeat closure previously bound$thisstrongly, 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 aWeakReferenceand 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]Transportwrite()on a closed/never-connected socket now throwsTransportClosedExceptioninstead of silently succeeding (both TCP and WebSocket transports). Previously a publish, JetStream ACK, or flow-control reply racing a reconnect (or a concurrentdisconnect()) could hit the nulled socket and report success while sending nothing - a silent message loss. The connection now also leaves theOpenstate 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 affectedpublish()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 theerrorListenerand clears the buffer so a later manualconnect()cannot replay frames from a dead epoch (#123).[bugfix]unsubscribe($sid, $max)(auto-unsubscribe) sentUNSUB <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$maxtotal messages have been received (nats.goAutoUnsubscribeparity), 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 defaultDropOldestpolicy 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, andresubscribeAll()would revive dead inboxes as ghost subscriptions), andsubscribe()rolls its registry entry back when the SUB write fails.unsubscribe()on a connection that is not open now cleans up silently instead of throwingConnectionException- 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 configurederrorListener, so the application learns the consumer went permanently silent instead of waiting on dead air forever (#114). AddsNatsClient::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. Thecomposer infectionscript 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: deletedtestDirectGetBatchDelaysOnZeroFrames, whose assertions could not fail for its stated purpose (the pacing delay was never observed) while burning ~1 s per run - the siblingtestDirectGetBatchReturnsEmptyArrayOnTimeoutkeeps 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 intests/Unit/Mutationare exempt; the ping-timer unit tests use fractional 50 ms intervals instead of ~10 s of wall-clock sleeps (see thepingIntervalSecondsentry above); the behat exception steps compare viais_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 newSeveringTransporttest decorator over the realAmpSocketTransportforce-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 totests/Unit/NatsConnectionTest.php; one of them duplicated an existing unit test (testProcessIncomingReconnectsAndResubscribesAfterReadFailurecovers 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 documentedttl:threw "Unknown named parameter"), and the Schedule FQCN in the scheduling note renders with single backslashes (the doubledIDCT\\NATS\\...form inside a code span displayed literally and broke copy-paste) (#143).[docs]disconnect()and plainunsubscribe()docblocks (connection and client facade) plus the README drain section now state that locally queued, undelivered messages are discarded (intentional nats.goClose()/Unsubscribe()parity) and namedrain()/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+ namedCONSUMER.CREATEAPI with no fallback to the legacyDURABLE.CREATEform, so consumer management requires NATS 2.9+ (documented in the feature table; pre-2.9 servers fail with a generic 503, not anUnsupportedFeatureException) (#132).
-
v2.4.115 Jun 2026Release notes
Open source →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 dedicatedmutationCI 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, socomposer installstill works on PHP 8.2; themutationjob 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-eurepository. - 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
µsduration unit is left intact).
Full changelog:
CHANGELOG.md- comparev2.4.0...v2.4.1.Release notes
Open source →Testing & CI
[docs]Added mutation testing with Infection (composer infection,scripts/run-mutation.sh,infection.json5). 517 new unit tests undertests/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 dedicatedmutationjob 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 fastunittestsuite (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 inrequire-dev, socomposer installstill works on PHP 8.2; themutationCI 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 renamedideaconnect/made-in-the-eurepository.[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 /xfor the arrow and multiplication signs. Functional non-ASCII (theµsduration unit,©,§) is left intact.
- Added strict mutation testing with Infection: 517 new unit tests under
-
v2.4.014 Jun 2026Release notes
Open source →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 emptysubjectslist when a non-emptysourcesconfiguration is provided. A pure aggregate/sourcing stream legitimately has no subjects of its own (the server allows it); the client previously only exemptedmirror, so creating a sources-only aggregate stream failed with "Stream subjects must not be empty...".[bugfix]Connection: a malformed asyncINFOframe is no longer allowed to throw out of the coreprocessIncoming()read loop. Previously a non-JSON async INFO (corruption in flight, or a non-conformant server push) raised an uncaughtJsonExceptionthat aborted the read cycle and skipped delivery of theMSGframes 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]AddedTESTS.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 anexamples/directory: one runnable, self-contained script per README example (42 files), plusscripts/run-examples.shwhich 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 withallow_direct: true(required becausecounterValue()reads via Direct Get); without it the documented example threw "no responders for $JS.API.DIRECT.GET". Prose updated accordingly.[docs]Eachexamples/*.phpscript 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 matchingexamples/*.phpscript, so each documented feature is one click from a runnable, verified file.[docs]scripts/run-examples.shnow defaultsNATS_NKEY_SEEDto the dev seed trusted bybuild/nats/nkey.conf, soauth-standalone-nkey.phpruns 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 dedicatedexamplesjob in.github/workflows/ci.ymlthat boots the full dockerized stack and runsscripts/run-examples.sh). The runner gained anEXAMPLES_STRICTmode (used by CI) that treats a skipped example as a failure, so the build fails unless every example actually executes and passes.
-
v2.3.013 Jun 2026Release notes
Open source →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 aNatsOptions::$tlsContextwas supplied buttlsRequiredwas off, the DSN used thenats://scheme, and the server's INFO did not advertisetls_required. The TLS-required check ignoredtlsContext, so the upgrade and the cleartext fail-safe were both skipped. Fixed: a configuredtlsContextnow forces the TLS upgrade (and fails fast if TLS cannot be established). Upgrading is recommended for anyone using thetlsContextescape hatch. See the Fixed entry below.
Added
[feature]Object Store:ObjectStoreBucket::watch()now accepts an optionalObjectStoreWatchOptionsto 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 anObjectStoreWatchOptionsinstance 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 endpointschemais now also surfaced in the standard$SRV.INFOresponse endpoint entries. ADR-32 stabilizes only PING/INFO/STATS, so spec-conformant tooling (nats CLI micro, nats.go) never queries the non-spec$SRV.SCHEMAverb; carrying the schema in INFO makes it discoverable. The$SRV.SCHEMAverb 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. Emptymetadatawas serialized as a JSON array ("metadata":[]), which the Go client rejects with "object-store meta information invalid" because it expects amap; the field is now omitted when empty (matchingomitempty), restoring interoperability with thenatsCLI / nats.go for the common default-metadata case. Verified live against thenatsCLI. (#109)[bugfix]KeyValue:watch()'sonCaughtUp(end-of-initial-data) signal now fires on an empty or no-match bucket. Previously it could only fire from a delivered message reportingnum_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'snum_pendingand 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 negotiatedmax_payloadinstead of a fixed 8 MiB. On a server with a raisedmax_payload(e.g. 16/32/64 MiB), a legitimately large message larger than 8 MiB was rejected as an oversized frame - throwing aProtocolExceptionthat 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 whenmax_payloadis unknown. (#94)[bugfix]Services: the endpoint success path no longer lets ajson_encodefailure escape the shared dispatch loop. A handler returning a value that cannot be JSON-encoded (binary / non-UTF-8 data, NAN/INF) previously threw aJsonExceptionout 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 controlledHANDLER_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 throwingmessageMetadata()path. A delivery lacking a parseable$JS.ACKreply 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 forwatch()in #90) - and is no longer recorded as a bogus history entry. (#96)[bugfix]TLS: a configuredNatsOptions::$tlsContextnow correctly forces the TLS upgrade, matching its documented "treated as TLS-required" contract. PreviouslyrequiresTls()ignoredtlsContext, so atlsContext-only configuration over anats://DSN to a server that did not advertisetls_requiredconnected in plaintext and wrote CONNECT (carrying credentials) in cleartext. The credentials fail-safe now also covers this path, so atlsContextwhose handshake cannot establish TLS fails fast instead of leaking credentials. (#95)[bugfix]WebSocket: a corrupt permessage-deflate frame no longer emits an uncaught nativeE_WARNINGfrominflate_add()/deflate_add()before the typedProtocolException. The warning is now suppressed (the return-value check already raisesProtocolException), so apps that promote warnings to exceptions get the intendedProtocolExceptioninstead of a genericErrorExceptionleaking 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 toreconnectBufferSize, 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-omittedNatsOptionsfields -connectionListener,errorListener,jwtProvider,tokenProvider,reconnectBufferSize,tlsContext,randomizeServers,retryOnFailedInitialConnect,webSocketHeaders,webSocketCompression,logger- with types/defaults.NatsOptionsTest::testDefaultsMatchDocumentedValuesnow 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 wireWebSocketTransport, thews:///wss://expectations, and thewebSocketHeaders/webSocketCompressionoptions. (#104)[docs]README: the Observability note now documents the typedconnectionListener/errorListenerclosures, 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:KeyWatchOptionsandKeyValueBucket::watch()now make clear that the last-per-subject "snapshot then follow" default applies only when aKeyWatchOptionsinstance is supplied;watch()called with$options = nullis updates-only and replays nothing. (#107)[docs]PHPDoc:ObjectInfo::$digestis 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.
- Credential exposure via a configured
-
v2.2.010 Jun 2026Release notes
Open source →Added
[feature]Server-version awareness for version-gated features. Each feature's minimum NATS version is documented (PHPDocRequires NATS X.Y+notes + a compatibility table in the README) and exposed programmatically via the newIDCT\NATS\JetStream\FeatureSupportregistry (FeatureSupport::requiredVersion('allow_atomic')->"2.12").[feature]NewIDCT\NATS\Exception\UnsupportedFeatureException(a subclass ofJetStreamException). When a JetStream request fails because the connected server is too old for a feature (the server rejects the config field withunknown 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.JetStreamExceptionis no longerfinalso it can be specialized (existingcatch (JetStreamException)handlers are unaffected).
-
v2.1.110 Jun 2026Release notes
Open source →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 isallow_atomic- the server rejects the previously-documentedallow_atomic_publishwithunknown field. Corrected thebatch()/BatchPublisherdocblocks (theBatchPublishercode itself was already correct and is now verified end-to-end: a 3-message batch commits 3/3 with thebatch/countack 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.
-
v2.1.010 Jun 2026Release notes
Open source →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 aPURGEentry with a null value (was an empty-stringPUT),getAll()omits the key,watch()emits a tombstone, andObjectStoreBucket::watch()/info()skip the marker. Behavior change (flaggedbc-breakon the issue, but bug-driven so versioned as a bugfix): only reachable when a stream hassubject_delete_marker_ttlset, which this client now also forwards as a create option.
Added
[feature]Batched / multi Direct Get (ADR-31, issue #13). NewdirectGetBatch()collects a multi-response Direct Get stream (terminated by a 204 EOB orNats-Num-Pending: 0), anddirectGetLastForSubjects()fetches the latest message for many subjects in one request viamulti_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$pullarray (group,id,min_pending,min_ack_pending,priority,max_bytes,no_wait);PullConsumerIteratorgainssetGroup()/setPriority()/setMinPending()/setMinAckPending()/setMaxBytes()/setNoWait()and transparently captures theNats-Pin-Idand re-pins on a 423 stale-pin status. NewunpinConsumer()(CONSUMER.UNPIN) andpinIdOf(); consumer-create validatespriority_groups/priority_policy.[feature]Atomic (all-or-nothing) batch publish (ADR-50, issue #8).JetStreamContext::batch()returns aBatchPublisher:add()stages messages andcommit()sends them with a sharedNats-Batch-Id, an incrementingNats-Batch-Sequence, andNats-Batch-Commit: 1on the final message, returning a single PubAck exposing the committedbatchCount/batchId. Capped at 1000 messages; an aborted batch surfaces as aJetStreamException. Requiresallow_atomic_publishon the stream.[feature]Multi-subject consumer filters (issue #10, NATS 2.10+). The consumer-create methods now accept afilter_subjectsarray (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 aNats-Incrdelta (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 withJSON_BIGINT_AS_STRING) so arbitrary-precision counters are not truncated. The target stream must be created withallow_msg_counterenabled.[feature]JetStreamContext::publish()now accepts optional message headers - a genericarray $headers, a$msgId(Nats-Msg-Id) for server-side de-duplication within the stream'sduplicate_window(issue #11), and a per-message$ttl(Nats-TTL; requiresallow_msg_ttlon the stream - issue #4).KeyValueBucket::put()takes an optional per-key$ttl, anddelete()/purge()take an optional tombstone TTL. TTL values (integer seconds, a Go duration string, or "never") are validated client-side via the newMessageTtlhelper.[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) andSchedule::cron()validates/returns a 6-field (seconds-resolution) cron expression.Schedule::predefined()returns a predefined alias (@daily,@hourly, ...).JetStreamContext::publishScheduled()now accepts@at(withZor a numeric RFC3339 offset),@every, cron, and the predefined aliases (previously only@atwithZ) and emits the optionalNats-Schedule-Source,Nats-Schedule-Time-Zone(cron/alias only, rejected otherwise), andNats-Schedule-Rollup: subheaders alongside the existingNats-Schedule/-Target/-TTL. The target stream must be created withallow_msg_schedulesenabled (e.g.createStream(..., ['allow_msg_schedules' => true])).
-
v2.0.007 Jun 2026Release notes
Open source →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
natsCLI, idle-connection heartbeat survival, and request-timeout recovery were all verified working and are unchanged.Fixed
[feature]Addedflush()(onNatsClient/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-endpointmetadata(addEndpoint(..., metadata:)), advertised in the$SRV.INFOresponse 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 toPHP_INT_MAX) as aProtocolException.[bugfix]NkeySeedSignernow zeroes the raw seed and key-pair buffers (sodium_memzero) once the Ed25519 key is derived;ProtocolCodecfails fast if a configured nkey does not match the seed signer's public key. The servicestartedtimestamp 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 thanchunkSize); it advances a read offset and compacts once per block. The constructor now rejects a non-positivechunkSize(which madeput()/putStream()loop forever) with aJetStreamException.[bugfix]Object Storeinfo()/get()/list()now populateObjectInfo::revisionfrom the record's stream sequence (theNats-SequenceDirect Get header, or theseqof 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]JetStreampublish()/publishScheduled()now translate a no-responders reply into aJetStreamException(code 503) - e.g. publishing to a subject not bound to any stream - so acatch (JetStreamException)no longer misses it as a bareNatsException.[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 inDraining.[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-enteredrecoverConnection()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-specNats-Service-ErrorandNats-Service-Error-Codereply headers (400 for validation, 500 for handler errors), so a generic client (Gomicro,natsCLI) 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]KeyValuegetAll()now paginates the STREAM.INFO subjects map (viaoffset) 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 Storelist()), 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 leaderSTREAM.MSG.GETpath when Direct Get is unavailable (a stream withallow_directdisabled, or an older server). The no-responders error is translated to a clearJetStreamException(code 503); KeyValueget()and Object Storeinfo()/get()(single-chunk fast path) then retry on the leader, so reads keep working on interop buckets (e.g. created by thenatsCLI withoutallow_direct) instead of surfacing an opaque error.[bugfix]Ordered-consumer gap detection andstreamSequenceOf()(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, sotlsHandshakeFirst=truecombined with no TLS materials (and anats://DSN) while the server's INFO advertisedtls_requiredcould leak credentials in cleartext. The fail-fast now runs whenever TLS is required and the handshake did not establish it, regardless oftlsHandshakeFirst.[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 theNats-Consumer-Stalledheader 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 nestedrequest()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 -> duplicatedeleteConsumer/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 hardcoded0.1.0-dev, so serverconnz/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-runningtoWireBlock()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 Storeget()/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 Storeput()anddelete()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 Storewatch(), ordered consumer) now set aninactive_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]SubscriptionQueuenow bounds its polling backlog withmaxPendingMessagesPerSubscriptionand 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 Storelist()now paginates the meta-subject enumeration (via the STREAM.INFOoffset) 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 ignoresoffset.[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) withhash_equals(), instead of a string compare that spuriously rejected a byte-identical object whose metadata used unpadded base64url (some non-Go clients).[bugfix]KeyValueget()/update()/delete()/purge()/getAll()now wrap a malformed (non-JSON) reply in aJetStreamExceptioninstead of leaking a rawJsonException, consistent withput()and the rest of the API.[bugfix]The protocol parser now bounds an unterminated control line (no CRLF) to 1 MiB and raises aProtocolExceptioninstead of buffering it without limit.maxFrameSizeonly 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 retriedstart()as a no-op. A separatestartedflag tracks completion.[bugfix]Microservice request observers now receive the terminalrequest_endevent on the schema-validation rejection path too (previously onlyrequest_start->request_errorfired), 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 crashingstart()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 spuriousTimeoutExceptionwhen 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]PullConsumerIteratorinfinite mode (setIterations(null)) now survives a transient409(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 terminal409 Consumer Deletedstill 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 theTimeoutCancellationcould never fire anddrain()never returned.[bugfix]drain()no longer resurrects the connection on a read failure mid-flush. A peer close during drain previously triggeredrecoverConnection()- reconnecting and re-SUBscribing the very subscriptionsdrain()had just removed (and possibly re-delivering messages).processIncoming()now skips recovery while the connection isDrainingand treats the read failure as end-of-flush.[bugfix]CredentialsParsernow parses realnsc-generated.credsfiles. 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, soCredentialsParser::fromFile()threwCredentials file does not contain a NATS USER JWT blockon essentially every genuine credentials file - making the documented JWT-via-.credsauth path unusable. Both markers now accept five-or-more dashes.[bugfix]Object Store now stores a 0-byte object withchunks=0and publishes no chunk message, matching the official Object Store layout; previously it wrote one empty chunk and recordedchunks=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 Storeget()andgetToCallback()now returnnullfor a deleted (tombstoned) object, consistent with a missing object and the official not-found semantics; the tombstone metadata remains observable viainfo(). Previouslyget()returned anObjectDatawithnulldata andgetToCallback()returned theObjectInfo.[bugfix]Microservice handler errors no longer leak the raw exception message to the requester: the reply carries a genericInternal server errorunder theHANDLER_ERRORcode, while the full detail stays server-side (endpointlastError,$SRV.STATS, and therequest_errorobserver event).[bugfix]Service$SRV.STATSno longer emits the non-specrequests/errorsaliases; only the spec-compliantnum_requests/num_errorsremain.[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]NatsOptionsnow rejects genuinely-invalid configuration at construction (non-positiveconnectTimeoutMs/requestTimeoutMs,maxPendingMessagesPerSubscriptionbelow 1, and negative reconnect/maxPingsOutvalues) with anInvalidArgumentException, instead of misbehaving later. Legitimate edge values stay valid:pingIntervalSeconds<= 0disables the heartbeat,maxPingsOut0 is allowed, and an emptyserverslist 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 Storeput()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 - KeyValueget()and Object Storeinfo()(and the metadata read behindget()/getToCallback()) - now use the Direct Get API (served by any replica) instead of leader-onlySTREAM.MSG.GET, consistent withgetAll()/list(). On clustered/replicated streams this stops concentrating reads on the stream leader. (The internal put/delete cleanup lookup stays onSTREAM.MSG.GETfor deterministic ordering.)[bugfix]KeyValuegetAll()and Object Storelist()now read the latest record per key/object via the Direct Get API issued concurrently, instead of N+1 sequential leader-onlySTREAM.MSG.GETreads. 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()andpublishScheduled()now wrap a malformed (non-JSON) acknowledgment in aJetStreamExceptioninstead of leaking a rawJsonException, consistent with the other JetStream API calls.[bugfix]Direct Get now rejects an unrecognized response (no status line and noNats-Stream/Nats-Sequenceheaders) with aJetStreamExceptioninstead of returning a garbage body, guarding against a non-conformant server/proxy.[bugfix]KeyValuewatch()now delivers updates through a JetStream push consumer (deliver_policy=new, ack-free) so each entry carries itsrevision(the stream sequence). Previously it used a plain core subscription and always reportedrevision=null, so a watcher could never feed an entry back intoupdate()/CAS. Live-updates-only semantics are unchanged.[feature]JetStreamContext::streamSequenceOf()returns the stream sequence of a JetStream-delivered message (from its$JS.ACKreply).[feature]ObjectStoreBucket::putStream()uploads an object from a producer callback without holding the whole payload in memory (the streaming counterpart togetToCallback()): blocks of any size are re-chunked tochunkSize, published in bounded in-flight windows, and the SHA-256 digest is computed incrementally.[feature]Object Storewatch()now delivers updates through a JetStream push consumer (consistent with KeyValuewatch()) and exposes each update's stream sequence via the newObjectInfo::$revisionfield. Previously it used a plain core subscription that carried no sequence/revision. Live-updates-only semantics (deliver_policy=new, ack-free) are unchanged;$revisionisnullonObjectInfos fromget()/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 laterstart()restart).[bugfix]Service::addEndpoint()now rejects a duplicate subject with anInvalidArgumentExceptioninstead 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]AmpSocketTransportnow acceptsnats://DSNs directly (self-normalizing totcp://), 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 pastack_waittriggered 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 Storewatch()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()andlistConsumers()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]PullConsumerIteratorinfinite 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 trippedmaxPingsOut, 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 aProtocolException. A parse failure now resyncs past the offending bytes instead of leaving them buffered to re-throw on every subsequent read, andprocessIncoming()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 advertisestls_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 EOFnullinto 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 aTransportClosedException, whichprocessIncoming()and the heartbeat self-read escalate torecoverConnection().[bugfix]SubscriptionQueue::fetch(),next()(with no/zero/negative timeout), andfetchAll()(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 intoprocessIncoming(), not only the outerawait(). 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 character0. 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. Passnullor''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-chunkgetToCallback()contract, andnatsCLI 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 theprocessIncoming()single-chunk semantics are spelled out.[docs]Corrected the Scheduled Publish example. It now creates the backing stream withallow_msg_schedules(andallow_msg_ttlwhen 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 thatgetStreamMessage()uses the standard stream message get API (not the JetStream direct-get API) and preserves the stored body and headers.
-
v1.0.109 Apr 2026Nothing published for this version
-
v1.0.020 Mar 2026Nothing published for this version