Skip to content

Generation and lifecycle ​

Edit a command file and there is no process restart, and messages being handled aren't cut off. That's because startup, hot reload and config changes all take the same internal path: build the new version completely in the background, then switch over in one step. Each version is called a generation.

What you will observe ​

You…What happens
Save a command, middleware or tool fileOnly the affected part is rebuilt; it's live within a fraction of a second, no restart
Change one plugin's config in zhin.config.ymlThat plugin is rebuilt; an invalid new config keeps the old one
Ship code that throws, a missing dependency, a failed external connectionThe switch is cancelled, the old version keeps serving, and the terminal prints the error
Have messages in flight during the switchThey finish on the old version; new messages use the new one
Add or remove child plugins, or change the structure in package.json#zhinBeyond what hot reload can safely handle — the CLI restarts the process

Plugin authors have one obligation: resources acquired in setup (timers, connections, registrations) must be released through context.lifecycle or the function setup returns. See Why resources must be attached to lifecycle.

The rest of this page explains how the mechanism works, for readers who want the details or plan to contribute.

Snapshot: The World State of a Generation ​

Each generation is an immutable RuntimeSnapshot (import types from zhin.js):

ts
interface RuntimeSnapshot {
  readonly generation: number;                       // Generation number, monotonically increasing from 0
  readonly root: PluginId;
  readonly tree: ReadonlyMap<PluginId, PluginNodeSnapshot>;   // Plugin tree
  readonly config: ReadonlyMap<PluginId, unknown>;            // Each plugin's ConfigView
  readonly resources: ReadonlyMap<PluginId, ReadonlyMap<TokenId, unknown>>;
  readonly capabilities: ReadonlyMap<CapabilityId, CapabilitySlot>;
  readonly projections: ReadonlyMap<FeatureId, unknown>;      // AdapterIndex, CommandIndex...
}

Snapshot collections use read-only views. Serializable descriptors must be immutable, while stateful Resources are exposed only through controlled interfaces. Plugin code always sees one fixed generation world.

RuntimeSnapshot and Runtime sidecars such as ownership and the generation model form one CommittedGeneration. Root swaps this complete record, so commit observers cannot see a new snapshot paired with previous-generation sidecars.

One Atomic Publish Point ​

RootController serializes all control operations (promise queue), executing the same pipeline for each transaction:

The corresponding code skeleton (RootController.transact):

ts
const previous = this.snapshots.acquire();
try {
  prepared = await prepare(previous.value, signal);
  if (!prepared) return previous.value;      // No changes, return directly
  if (prepared.handoff) {
    await prepared.handoff.activateNext(signal);
  }
  return this.#commitGeneration(previous.value.generation, prepared);
} catch (error) {
  return this.#rollback(prepared, { activated }, error);
} finally {
  previous.release();
}

GenerationHandoffStack composes candidate readiness only: activateNext runs in forward order and a failure deactivates every successfully activated participant in reverse order. The previous generation is never touched during the transaction. If compensation also fails, Root fails closed and accepts no new operations.

Adapter Admission Switches Atomically with the Snapshot ​

@zhin.js/adapter does not close the old Endpoint before commit. The candidate AdapterIndex completes start() and open() during activateNext, but its CapabilityContext injects a OutboundMessageService bound to the candidate generation's GenerationAdmissionGate; every inbound event fails closed before commit. When SnapshotStore publishes the new snapshot it synchronously closes removed gates and opens new gates. A projection retained by both generations is never closed briefly.

The old generation therefore remains available throughout preparation. Failed activation only stops the candidate. After commit, the old Endpoint may drain with old snapshot leases; late events are rejected by its retired gate, and its disposer eventually runs close() / stop(). Adapters have no dedicated quiescePrevious, resumePrevious, or post-commit openNext phase. Every configured Endpoint is a required prerequisite: create, start, or open failure rejects the candidate generation. No inert/unconfigured record or background late-open is published. The shared HTTP listener belongs to the Process Host. Candidate generations receive only an HttpHost routing port; gated HTTP/WS registrations cannot shadow an old route before commit, and generation code has no listener listen/close authority. Console RPC, SSE, and login-assistance subscriptions are also owned by the Process Host and remain available before first-generation Adapter activation. Operations that need generation capabilities still acquire the current snapshot lease, so candidate capabilities are never exposed early.

Snapshot Leases: In-Flight Messages Are Not Interrupted ​

Committing only switches the pointer -- the old generation is not destroyed immediately since it may still be serving in-flight messages. SnapshotStore manages this with leases:

  • acquire() obtains the current generation snapshot and increments the reference count; call release() when done;
  • A replaced old generation is marked as retired and only truly dispose()d when the lease count reaches zero;
  • RootController.stop() waits for all historical generations to be released before returning, so shutdown does not cut off resources mid-message-processing.

HMR Semantics ​

HmrCoordinator (@zhin.js/runtime) watches for file changes, merges a batch of filesystem events from the same operation into a single transaction, and then uses InvalidationPlanner to plan the invalidation scope:

  • Generation-level: Changes only affect certain plugin subtrees or capability slots -- only the affected parts are rebuilt (subtree / slot / topology, three kinds of preparers), going through the handoff described above;
  • Process-level: Changes that the module loader cannot safely invalidate, or manifest topology changes that cross the restart boundary (determined by RestartBoundaryPlanner, e.g., adding/removing child plugin dependencies) -- handed to onRestartRequired, with the outer layer (CLI) restarting the process.

A failed HMR transaction cancels the remaining changes in the batch and invokes onError; it will not silently replay. HMR stop closes admission and awaits the in-flight reload. Once a process restart is required, the coordinator rejects further generation work. Post-commit Console or logging observer failures are diagnostic only and cannot rewrite a committed reload as failed.

Why Resources Must Be Attached to Lifecycle ​

Hot reload = old generation destroyed, new generation rebuilt. If a plugin stores resources in module-level variables (let _db = ...), when the module is reloaded the old variable dies with the old generation, but some callbacks still hold references to it -- this is the classic cause of "ghost singleton" incidents (timers firing repeatedly, using an already-closed database connection).

The rule is simple: all resources with a lifecycle must be registered in context.lifecycle (a DisposeStack), and they are automatically deregistered when the generation ends.

Cross-module generation state must be provided as a snapshot Resource and resolved through the GenerationView held by the current operation. Module-level latest-value stacks, createGenerationStore, and any “current live generation” lookup are forbidden: they cannot isolate multiple Roots and can expose a shadow candidate before commit.