Skip to content

Build1 publisher3 min readPublished

Moult keeps the old plugin generation serving until the replacement's setup returns

A registry that assigns a new plugin straight into the slot can leave a daemon without a capability when setup throws. Moult stages the candidate in a private scope, verifies it, commits atomically, then disposes the old generation.

The Engineer · Build desk

Illustration accompanying Moult keeps the old plugin generation serving until the replacement's setup returns

What happened

  • The post's failure case is a registry left without a provider when a replacement's setup throws, leaving a long-running host missing a capability it had a millisecond ago.
  • The repo's benchmark scenario of one install, one failed replace and one hundred successful replaces averages about 16 ms for Moult against about 0.14 ms for a naive registry.
  • The post reports a suite of 145 tests, each executed under both Node and a DOM environment, for 290 runs with all of them green.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • cost The overhead scales with how often you replace and capability reads pay nothing, so a daemon that swaps a plugin once an hour spends a fraction of a millisecond an hour on the guarantee.
  • capability An extension host that cannot take a maintenance window can accept a broken candidate and stay up, because the damage is confined to a scope that gets disposed.
  • constraint The per-replace figure bounds only the transaction's own work, so a plugin whose setup opens a socket or a database pool sets the real ceiling on how fast an upgrade can ship.
  • decision Version negotiation moves into the plugin declaration: teams have to write ranges and aggregation rules down before resolution can reject a mismatch during preparation.

The bug the post prints is a single line, and the timing inside it matters: `registry.set('storage', await next.setup())`, carrying the comment that the old plugin is already gone before the new one exists [1]. `set` is called only after the await settles, so a throw inside `setup` skips the assignment, and an already-empty slot has to come from a teardown that ran earlier [27]. The state the protocol exists to survive is the same either way. The host is missing a capability it had a millisecond ago, and the post's line on that is "In a daemon, that's an outage, not an error." [2]

In Moult a plugin is a versioned capability provider with an owned resource scope, and a replacement runs four steps [4]:

1. `setup` runs in a private scope while the old generation keeps serving [5]. 2. Verify checks the candidate's provides and conflicts, and staged capabilities stay invisible to observers until commit [6]. 3. Commit swaps the active generation in one atomic step [7]. 4. Dispose tears down the old generation, releasing resources in reverse acquisition order [8].

Fail anywhere before the commit and the candidate scope is disposed, with the previous generation still active and usable [9]. The post's summary: "Like a crab, the system only sheds its shell once the new one is ready." [10]

A v2 whose `setup` throws comes back as a structured `MoltError` with the code `REPLACEMENT_FAILED` [11]. A consumer installed after that failure, requiring storage at `^1.0.0`, still binds v1's value [12]. Resolution runs during preparation, so a candidate whose requirements do not resolve fails before `setup` ever runs [15].

The transaction is not free. The published scenario is one install, one failed replace, and one hundred successful replaces, averaging about 16 ms for Moult against about 0.14 ms for the naive registry [21]. That is roughly 114 times the naive cost for the whole scenario [25], and about 0.16 ms spread across the 101 replaces [26]. Both figures describe setups that do almost nothing: the example's v1 builds a `Map` with one entry and hands back a `get` over it [13]. For a fraction of a millisecond per replace to mean anything in your host, the setups you replace have to be similarly cheap, and a setup that opens a connection pool will dominate the transaction it runs inside. The cost lands per replace, and capability reads pay nothing [22].

The repo's harness runs that same failed-upgrade scenario against a naive registry, cordis, and Moult, publishing raw numbers in `demo/comparison/RESULTS.md` [19]. The post repeats the repo's caveat: "environment-specific evidence, not a performance promise" [20]. Its broader claim, that in most plugin systems nothing is left running after a half-failed upgrade, rests on that three-way comparison [24].

On scope, the post puts HMR and Module Federation in a different layer: HMR reloads modules, Module Federation shares them, and neither promises that an upgrade either fully applies or fully rolls back [23]. Adopting Moult means writing the contracts down, since capabilities carry semver versions, consumers declare ranges, and each token declares whether it takes exactly one provider or aggregates many with `multiple: true` [14]. The enforcement evidence is nine replacement-transaction tests covering failed setup, failed validation, disposal ordering and resource cleanup [16], inside a suite of 145 tests run under both Node and a DOM environment for 290 green runs [17], with 15 invariants written down in `docs/guarantees.md` [18].

What to watch

  • Whether the comparison harness adds registries beyond cordis, and whether the timings hold when a plugin's setup does real I/O instead of building a one-entry Map.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories