v0.13.0
The names and lifecycle release. Two threads that turned out to be the same thread: what an actor is built from, and when it goes away. Props is gone — spawning takes the actor class, and per-actor configuration became ActorOptions, an ordinary options family like every other one in the framework. Sharded entities now passivate by default, empty shards stop with them, and the generated names the framework hands out are no longer guessable. Underneath, a cluster-transport bug that could partition two healthy nodes permanently, and the discovery that the documented mTLS recipe was never actually authenticating anyone. Pre-1.0 — this minor carries breaking changes; see below.
🚀 New features
ActorOptions(#547) —withSupervisorStrategy,withDispatcher,withMailboxCapacity,withMailbox,withInternal,withEntity,withDisplayName, plus anActorOptionsValidatorthat rejects a non-positivemailboxCapacityat thespawncall rather than from inside the mailbox constructor. Accepted as a builder or as a plain object, like every other options family.Actor.displayName()— a readable name for an actor in logs and DevTools (#891). A path is an address, not a name: under sharding the log source grows to ~120 characters of machine identifier, and the business identity it stands for had to be repeated by hand in every message the entity logged. OverridedisplayName()and the actor says it once — the name joins the line as its own segment (... - User(test-user-590) - recovery complete) and labels the row in the DevTools actor tree, which is worth the most forBehaviorsactors whose class column readsTypedActoron every row. Also settable at the spawn site withActorOptions.withDisplayName(...)and at runtime withcontext.setDisplayName(...). Defaults to the path, so existing log output is unchanged, and it stays a label: metrics, tracing, dead letters and every cluster-wire identifier keep using the path.- Empty shards passivate (#892). A shard actor used to outlive its entities indefinitely — since entity ids spread over the hash space, a long-running node accumulated one idle, empty shard actor per
numShards. A shard that has stood empty forshardPassivationIdleMsis now stopped too. The region keeps ownership, so the shard stays routable and the next message re-creates it with no coordinator round trip. shardPassivationIdleMs/withShardPassivationIdleMs()/actor-ts.sharding.shard-passivation-idle(#892). Unset, it followspassivationIdleMs;0keeps empty shards resident while entities still passivate.ShardInfo.resident(#901) —ClusterSharding.shards()now reports whether each shard actor was materialised when its region answered.entityCount: 0cannot say that on its own: a running-but-empty shard and one that passivated report the same count.
⚠️ Breaking changes (pre-1.0)
-
Propsis gone from the public API (#547). Spawning takes the actor class or a factory directly:// before system.spawn(Props.create(() => new Greeter()), 'greeter'); system.spawn(Props.create(() => new Worker(db)).withMailboxCapacity(500), 'w'); // after system.spawn(Greeter, 'greeter'); // zero-arg class const workerOptions = ActorOptions.create<WorkerMessage>().withMailboxCapacity(500); system.spawn(() => new Worker(db), 'w', workerOptions);
Propsbundled two unrelated things — what to construct and how to run it — and 75 % of its ~970 call sites used only the first. Migration: dropProps.create(and its closing); move each.withX(…)into a thirdActorOptionsargument;asInternal()becomeswithInternal(). Renamed carriers:entityProps→entityActor, singletonprops→actor,singletonProps→singletonActor,childProps→child,routeeProps→routee,behaviorFor→actorFor;BackoffSupervisor.props→.factory,ClusterRouter.props→.factory,typedProps→typedActor. Two behavioural notes:ActorOptionsmutates in place wherePropswas copy-on-write (settings are snapshotted at spawn), and a class whose constructor takes arguments is now rejected at the spawn call instead of being constructed withundefineddependencies. -
Idle entities passivate by default, after 5 minutes (#892).
passivation-idleshipped as0ms, so nothing ever passivated until an operator went looking for the key, and entity sets only grew. Migration: an entity that keeps state in memory and does not rebuild it inpreStartnow loses that state after five minutes idle — persistent entities recover, plain ones do not. Setpassivation-idle = 0msto restore the old behaviour.ShardedDaemonProcessopts out on its own. -
The cluster TLS listener requests a client certificate (#565). See Security — a cluster already passing
castarts demanding peer certificates, and mutual TLS on Deno is now refused rather than silently skipped. -
Anonymous actors are named
$anonymous-<n>-<random>, not$1/$2(#895). The old per-parent counter was both opaque and guessable —/user/$1is the first anonymous actor of every run, and a path is an address. Migration: code that hard-codes an anonymous path or parses$<n>out of a name must spawn with a name of its own. -
Unnamed reliable-delivery controllers are
consumer-<n>-<random>/producer-<n>-<random>(#897). The fallback came from a module-global counter, so/system/delivery/consumer-1was the first one of every run, and twoActorSystems in one process drew from the same sequence. -
Actor names starting with
$are reserved for the framework (#900).spawnandspawnTypednow reject them — until now anyone could claim the prefixspawnAnonymousgenerates. A$anywhere other than the first character is unaffected.
🔒 Security
- The cluster TLS listener never requested a client certificate (#565, severity: critical).
requestCerthard-defaulted tofalsein both listener adapters, andrequestClientCertwas never set totrueanywhere in the repo. On a serverrejectUnauthorizeddoes nothing unlessrequestCertis on — so the mTLS recipe the Cluster security page documents produced server-authenticated TLS only. Since thehellohandshake carries no credential of its own, that left the peer certificate — the cluster's only admission control — unrequested: anything that could reach the remoting port completed the handshake presenting nothing and then claimed whatever node identity it liked. It is also the mitigation several other findings lean on, so until now those notes promised more than the transport delivered.requestClientCertnow defaults toca !== undefined, and two incoherent configurations fail closed at bind time:requestClientCert: truewith noca, and mutual TLS on Deno, whereDeno.listenTlscannot request a client certificate and the dialer sends none. - Quorum correlation ids in
DistributedDataare no longer guessable (#896).nextPendingId()returnedp<Date.now()>-<counter>. That value travels on the wire and the peer echoes it back on its acknowledgment, so a guessable id is one whose acknowledgment can be forged — satisfying a quorum write or read no peer actually confirmed. Now sixteen random hex characters. - A
ClusterClient's own wire identity no longer comes fromMath.random()(#910). The synthetic port a client names itself by goes into theNodeAddressit announces and keys the cluster'sbyPeermap, so it is an address — andMath.random()is not a CSPRNG. The comment above it claimed hrtime-derived randomness, which the code never did. Now drawn withcrypto.getRandomValuesacross the whole ephemeral range; the old 15 000-slot window also made accidental collisions likely at a few dozen clients per process, which was a correctness problem on its own. - Filesystem object-storage temp paths no longer come from
Math.random()(#898). The atomic-write temp file was named with the clock andMath.random(), so a local process sharing the directory could predict the path and pre-create it or plant a symlink there.
🐛 Fixed
- Two nodes that dial each other at the same moment no longer stay split forever (#697).
openOutboundregisters a connection inbyPeerbefore the handshake, and the hello-hijack guard compared identity alone — so in a crossing dial each node held an un-acked outbound under the other's key and rejected the other's perfectly legitimatehello. Neither dial then received itshello-ack, andonClosereleased the slot only if the handshake had completed: no re-dial, and every frame for that peer accumulating silently in the handshake buffer. The pair was partitioned for the lifetime of the process. Cleanup is now keyed on the dialled address, a 5 s handshake deadline reclaims a dial that connects but never acks, the buffer is capped, and a crossing dial is settled by address order so exactly one survives. An established peer connection is still never displaced. rememberEntitiesno longer forgets every entity when a shard rebalances (#632). The departing region announced anEntityStoppedfor every entity of the shard, which emptied the coordinator's registry — so when the shard was reallocated there was nothing left to ship to the new owner. A rebalance is the ordinary path, so this wasrememberEntitiesfailing at the one thing it exists for; it survived because the only coverage was a cold restart, which reloads from the journal and never exercises a live handoff.- Filesystem object storage stopped recognising its own temp files (#909). The
Math.random()removal above changed the temp-file name without updating the patternlist()uses to skip them, so a crashed writer's partial body was reported as an ordinary object. The test had staged the old shape as a literal, which still matched the stale pattern — so it passed while the behaviour it guards was gone. - Messages buffered during a handoff are no longer stranded (#893).
completeHandOffcleared the region's cached shard home without ever replaying the buffer, and the coordinator announces a new placement only to the new owner — so on a shard that went quiet after the rebalance, the trigger never came. - A shard ref for a remote shard no longer drops messages while that shard is passivated (#901). Remote shard traffic now goes to the owning region, which materialises the shard before forwarding.
- Remembered entities return after an unexpected shard death (#894). Ownership stayed put, which is what lets the next message re-create the shard — but that also meant nothing ever re-shipped the remembered registry.
preRestartnever stopped children, whatever its documentation said (#899). No behaviour changed; the documentation now matches, and the consequence it was hiding is spelled out — an actor that spawns a named child inpreStartfails its first restart.- The sharding-failover churn test no longer measures the scheduler (#902). It bounded a sample count by a wall clock, and instrumentation showed the sample count was ~100 % Windows timer granularity.
🛠 Tooling & CI
- The six example frontends are built in CI (#903). No workflow path filter covered
examples/**, so a PR touching only those directories produced zero checks — andts-patternwas missing from all six lockfiles without anything noticing. - Six advisories cleared in the Angular example frontends (#904), bundles rebuilt for Next 16.3.0 (#905), and Dependabot no longer re-proposes
@types/nodemajors against the deliberateenginesfloor (#906). - The docs API-drift guard covers the
Propsremoval (#907). It runs in CI and its own header says to add a pattern whenever an API is renamed or removed — the largest removal the project has made had added none. publish.ymlhas a concurrency group, closing the TOCTOU window between therelease: publishedtrigger and theworkflow_dispatchfallback, andmulti-runtime.ymlnow runs onmainso the tagged release merge gets a Node/Deno signal.