@kyneta/machine 1.6.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -4,7 +4,16 @@
4
4
  *
5
5
  * A Lease is a plain mutable record. Dispatchers mutate its fields
6
6
  * directly; no methods. When `depth` goes 0→1 a dispatcher becomes the
7
- * owner and resets `iterations`/`history` on its eventual 1→0 exit.
7
+ * owner and resets `iterations`/`history`/`counts`/`originStack` on its
8
+ * eventual 1→0 exit.
9
+ *
10
+ * Diagnostic instrumentation (history, counts, originStack) supports
11
+ * `BudgetExhaustedError`'s message:
12
+ * - `history` — bounded ring buffer of recent `{label, type}` events.
13
+ * - `counts` — cumulative `${label}:${type}` → count over the whole drain.
14
+ * - `originStack` — captured at the cascade's entry point (depth 0→1).
15
+ * Names the boundary where the dispatch system was re-entered from
16
+ * outside (userland for client-side flows, transport for server-side).
8
17
  */
9
18
  type Lease = {
10
19
  depth: number;
@@ -15,6 +24,8 @@ type Lease = {
15
24
  type: string;
16
25
  }[];
17
26
  readonly historyCapacity: number;
27
+ counts: Map<string, number>;
28
+ originStack: string | undefined;
18
29
  };
19
30
  type LeaseOptions = {
20
31
  budget?: number;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/dispatcher.ts","../src/machine.ts","../src/observable.ts"],"mappings":";;AAcA;;;;;;KAAY,KAAA;EACV,KAAA;EACA,UAAA;EAAA,SACS,MAAA;EACT,OAAA;IAAW,KAAA;IAAe,IAAA;EAAA;EAAA,SACjB,eAAA;AAAA;AAAA,KAGC,YAAA;EACV,MAAA;EACA,eAAe;AAAA;AAAA,iBAGD,WAAA,CAAY,OAAA,GAAU,YAAA,GAAe,KAAK;AAAA,cAU7C,oBAAA,SAA6B,KAAA;EAAA,SAC/B,KAAA,EAAO,KAAA;EAAA,SACP,KAAA;cACG,KAAA,UAAe,KAAA,EAAO,KAAA;AAAA;AAAA,KAaxB,iBAAA;EACV,KAAA,GAAQ,KAAK;EACb,KAAA;AAAA;AAAA,UAGe,gBAAA;EACf,QAAA,CAAS,GAAA,EAAK,GAAG;EAAA,SACR,UAAA;AAAA;;;;;;;;;;iBAYK,gBAAA,KAAA,CACd,OAAA,GAAU,GAAA,EAAK,GAAA,EAAK,QAAA,GAAW,GAAA,EAAK,GAAA,oBACpC,OAAA,GAAU,iBAAA,GACT,gBAAA,CAAiB,GAAA;;;;KC1ER,QAAA,SAAiB,GAAA,EAAK,GAAG;;KAGzB,MAAA,SAAe,QAAA,EAAU,QAAQ,CAAC,GAAA;;;;;;;;;;ADepB;AAG1B;;KCJY,OAAA,kBAAyB,MAAA,CAAO,GAAA;EAC1C,IAAA,GAAO,KAAA,KAAU,EAAA;EACjB,MAAA,CAAO,GAAA,EAAK,GAAA,EAAK,KAAA,EAAO,KAAA,IAAS,KAAA,KAAU,EAAA;EAC3C,IAAA,EAAM,KAAA,EAAO,KAAA;AAAA;;KAIH,QAAA;;;;;ADE8C;AAU1D;;;;;;;;;;iBCKgB,OAAA,YAAA,CACd,OAAA,EAAS,OAAA,CAAQ,GAAA,EAAK,KAAA,GACtB,IAAA,IAAQ,KAAA,EAAO,KAAA,EAAO,QAAA,EAAU,QAAA,CAAS,GAAA,aACxC,QAAA;;;;;;;;;KCbS,eAAA;EACV,IAAA,EAAM,CAAA;EACN,EAAA,EAAI,CAAC;EACL,SAAA;AAAA;AFhBwB;AAG1B;;AAH0B,KEsBd,kBAAA,OAAyB,UAAA,EAAY,eAAe,CAAC,CAAA;;AFjBhD;AAGjB;;;;;UE2BiB,gBAAA;EF3BoC;EE6BnD,QAAA,EAAU,QAAA,CAAS,GAAA;EF7BqC;EEgCxD,QAAA,IAAY,KAAA;EFtBoB;;;;;;EE8BhC,sBAAA,CAAuB,QAAA,EAAU,kBAAA,CAAmB,KAAA;EF9BZ;;;;;;EEsCxC,YAAA,CACE,SAAA,GAAY,KAAA,EAAO,KAAA,cACnB,OAAA;IAAY,SAAA;EAAA,IACX,OAAA,CAAQ,KAAA;EFtC4B;AAazC;;;;EEgCE,aAAA;IAA0B,MAAA;EAAA,GACxB,IAAA,EAAM,gBAAA,CAAiB,GAAA,EAAK,CAAA,GAC5B,MAAA,EAAQ,CAAA,YACR,OAAA;IAAY,SAAA;EAAA,IACX,OAAA,CAAQ,CAAA;EF/BI;;;EEoCf,OAAA;AAAA;;;;;;AFlCmB;AAYrB;;;;;;;;;;;;;;iBEiDgB,uBAAA,gBAAA,CACd,OAAA,EAAS,OAAA,CAAQ,GAAA,EAAK,KAAA,EAAO,EAAA,GAC7B,QAAA,GAAW,MAAA,EAAQ,EAAA,EAAI,QAAA,EAAU,QAAA,CAAS,GAAA,YAC1C,OAAA;EAAY,KAAA,GAAQ,KAAA;EAAO,KAAA;AAAA,IAC1B,gBAAA,CAAiB,GAAA,EAAK,KAAA"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/dispatcher.ts","../src/machine.ts","../src/observable.ts"],"mappings":";;AAuBA;;;;;;;;;;;;;;;KAAY,KAAA;EACV,KAAA;EACA,UAAA;EAAA,SACS,MAAA;EACT,OAAA;IAAW,KAAA;IAAe,IAAA;EAAA;EAAA,SACjB,eAAA;EACT,MAAA,EAAQ,GAAG;EACX,WAAA;AAAA;AAAA,KAGU,YAAA;EACV,MAAA;EACA,eAAe;AAAA;AAAA,iBAGD,WAAA,CAAY,OAAA,GAAU,YAAA,GAAe,KAAK;AAAA,cAiF7C,oBAAA,SAA6B,KAAA;EAAA,SAC/B,KAAA,EAAO,KAAA;EAAA,SACP,KAAA;cACG,KAAA,UAAe,KAAA,EAAO,KAAA;AAAA;AAAA,KAqBxB,iBAAA;EACV,KAAA,GAAQ,KAAK;EACb,KAAA;AAAA;AAAA,UAGe,gBAAA;EACf,QAAA,CAAS,GAAA,EAAK,GAAG;EAAA,SACR,UAAA;AAAA;;;;;AAAU;AAYrB;;;;iBAAgB,gBAAA,KAAA,CACd,OAAA,GAAU,GAAA,EAAK,GAAA,EAAK,QAAA,GAAW,GAAA,EAAK,GAAA,oBACpC,OAAA,GAAU,iBAAA,GACT,gBAAA,CAAiB,GAAA;;;;KCpKR,QAAA,SAAiB,GAAA,EAAK,GAAG;;KAGzB,MAAA,SAAe,QAAA,EAAU,QAAQ,CAAC,GAAA;;;;;;;;;;;;;KAclC,OAAA,kBAAyB,MAAA,CAAO,GAAA;EAC1C,IAAA,GAAO,KAAA,KAAU,EAAA;EACjB,MAAA,CAAO,GAAA,EAAK,GAAA,EAAK,KAAA,EAAO,KAAA,IAAS,KAAA,KAAU,EAAA;EAC3C,IAAA,EAAM,KAAA,EAAO,KAAA;AAAA;ADcE;AAAA,KCVL,QAAA;;;;;;;;ADa8C;AAiF1D;;;;;;;iBC7EgB,OAAA,YAAA,CACd,OAAA,EAAS,OAAA,CAAQ,GAAA,EAAK,KAAA,GACtB,IAAA,IAAQ,KAAA,EAAO,KAAA,EAAO,QAAA,EAAU,QAAA,CAAS,GAAA,aACxC,QAAA;;;;;;;;;KCbS,eAAA;EACV,IAAA,EAAM,CAAA;EACN,EAAA,EAAI,CAAC;EACL,SAAA;AAAA;;;;KAMU,kBAAA,OAAyB,UAAA,EAAY,eAAe,CAAC,CAAA;AFRjE;;;;AAEiB;AAGjB;;AALA,UEqBiB,gBAAA;EFhByC;EEkBxD,QAAA,EAAU,QAAA,CAAS,GAAA;EFlBO;EEqB1B,QAAA,IAAY,KAAA;EFrB4C;AAAA;AAiF1D;;;;EEpDE,sBAAA,CAAuB,QAAA,EAAU,kBAAA,CAAmB,KAAA;EFoDZ;;;;;;EE5CxC,YAAA,CACE,SAAA,GAAY,KAAA,EAAO,KAAA,cACnB,OAAA;IAAY,SAAA;EAAA,IACX,OAAA,CAAQ,KAAA;EF4CuB;;;AAAK;AAqBzC;EE1DE,aAAA;IAA0B,MAAA;EAAA,GACxB,IAAA,EAAM,gBAAA,CAAiB,GAAA,EAAK,CAAA,GAC5B,MAAA,EAAQ,CAAA,YACR,OAAA;IAAY,SAAA;EAAA,IACX,OAAA,CAAQ,CAAA;EFwDX;;AAAK;EEnDL,OAAA;AAAA;;;;;;;;;AFwDmB;AAYrB;;;;;;;;;;;iBEzCgB,uBAAA,gBAAA,CACd,OAAA,EAAS,OAAA,CAAQ,GAAA,EAAK,KAAA,EAAO,EAAA,GAC7B,QAAA,GAAW,MAAA,EAAQ,EAAA,EAAI,QAAA,EAAU,QAAA,CAAS,GAAA,YAC1C,OAAA;EAAY,KAAA,GAAQ,KAAA;EAAO,KAAA;AAAA,IAC1B,gBAAA,CAAiB,GAAA,EAAK,KAAA"}
package/dist/index.js CHANGED
@@ -5,18 +5,69 @@ function createLease(options) {
5
5
  iterations: 0,
6
6
  budget: options?.budget ?? 1e5,
7
7
  history: [],
8
- historyCapacity: options?.historyCapacity ?? 32
8
+ historyCapacity: options?.historyCapacity ?? 32,
9
+ counts: /* @__PURE__ */ new Map(),
10
+ originStack: void 0
9
11
  };
10
12
  }
13
+ /**
14
+ * Single mutation site for the lease's diagnostic projections. Future
15
+ * additions (e.g. subscriber-call site) land here so `history` and
16
+ * `counts` can't drift out of sync with each other.
17
+ */
18
+ function recordDispatch(lease, label, type) {
19
+ if (lease.history.length >= lease.historyCapacity) lease.history.shift();
20
+ lease.history.push({
21
+ label,
22
+ type
23
+ });
24
+ const key = `${label}:${type}`;
25
+ lease.counts.set(key, (lease.counts.get(key) ?? 0) + 1);
26
+ }
27
+ /**
28
+ * Pure formatter for the cascade-origin section of `BudgetExhaustedError`'s
29
+ * message. Strips the synthetic `Error: cascade origin` header from the
30
+ * captured stack — it's the label we used to *construct* the Error solely
31
+ * to grab a stack, not a meaningful frame.
32
+ */
33
+ function formatOrigin(originStack) {
34
+ if (!originStack) return "";
35
+ const lines = originStack.split("\n");
36
+ const start = lines[0]?.startsWith("Error") ? 1 : 0;
37
+ return ` cascade entered from:\n${lines.slice(start).map((l) => ` ${l.trim()}`).join("\n")}\n`;
38
+ }
39
+ /**
40
+ * Pure formatter for the histogram section. Width is computed per
41
+ * render because cooperating dispatchers produce labels as long as
42
+ * `synchronizer:sync:sync/synthetic-doc-removed-all` (46 chars) — a
43
+ * fixed `padEnd` width would mis-align the count column.
44
+ */
45
+ function formatHistogram(counts, total, topN) {
46
+ if (counts.size === 0 || total <= 0) return "";
47
+ const entries = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, topN);
48
+ const maxKeyLen = Math.max(...entries.map(([k]) => k.length));
49
+ return ` top message types:\n${entries.map(([key, n]) => {
50
+ const pct = (n / total * 100).toFixed(1);
51
+ return ` ${key.padEnd(maxKeyLen)} ${String(n).padStart(7)} (${pct.padStart(4)}%)`;
52
+ }).join("\n")}\n`;
53
+ }
54
+ function formatRecent(history) {
55
+ if (history.length === 0) return "";
56
+ const tail = history.map((h) => `${h.label}:${h.type}`).join(", ");
57
+ return ` recent (${history.length}): ${tail}\n`;
58
+ }
11
59
  var BudgetExhaustedError = class extends Error {
12
60
  lease;
13
61
  label;
14
62
  constructor(label, lease) {
15
- super(`[dispatcher:${label}] iteration budget exhausted (${lease.iterations} > ${lease.budget}); recent: ${lease.history.map((h) => `${h.label}:${h.type}`).join(", ")}`);
63
+ const header = `[dispatcher:${label}] iteration budget exhausted (${lease.iterations} > ${lease.budget})`;
64
+ const body = formatOrigin(lease.originStack) + formatHistogram(lease.counts, lease.iterations, 5) + formatRecent(lease.history);
65
+ super(body.length > 0 ? `${header}\n${body}` : header);
16
66
  this.name = "BudgetExhaustedError";
17
67
  this.lease = {
18
68
  ...lease,
19
- history: [...lease.history]
69
+ history: [...lease.history],
70
+ counts: new Map(lease.counts)
20
71
  };
21
72
  this.label = label;
22
73
  }
@@ -40,17 +91,13 @@ function createDispatcher(handler, options) {
40
91
  if (isDispatching) return;
41
92
  isDispatching = true;
42
93
  const owns = lease.depth === 0;
94
+ if (owns) lease.originStack = (/* @__PURE__ */ new Error("cascade origin")).stack;
43
95
  lease.depth += 1;
44
96
  try {
45
97
  while (pending.length > 0) {
46
98
  const next = pending.shift();
47
99
  lease.iterations += 1;
48
- const type = typeof next === "object" && next !== null && "type" in next ? String(next.type) : "<untyped>";
49
- if (lease.history.length >= lease.historyCapacity) lease.history.shift();
50
- lease.history.push({
51
- label,
52
- type
53
- });
100
+ recordDispatch(lease, label, typeof next === "object" && next !== null && "type" in next ? String(next.type) : "<untyped>");
54
101
  if (lease.iterations > lease.budget) throw new BudgetExhaustedError(label, lease);
55
102
  handler(next, dispatch);
56
103
  }
@@ -59,6 +106,8 @@ function createDispatcher(handler, options) {
59
106
  if (owns) {
60
107
  lease.iterations = 0;
61
108
  lease.history.length = 0;
109
+ lease.counts.clear();
110
+ lease.originStack = void 0;
62
111
  }
63
112
  isDispatching = false;
64
113
  }
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/dispatcher.ts","../src/machine.ts","../src/observable.ts"],"sourcesContent":["// dispatcher — drain-to-quiescence primitive shared by reactive frontiers.\n//\n// createDispatcher() is the underlying primitive that the input-processing\n// loop in createObservableProgram is built on. Factoring it out as a named\n// export lets cooperating dispatchers share a Lease — a single iteration\n// budget and re-entry depth tracker that bounds runaway cascades.\n\n/**\n * Shared iteration budget for cooperating dispatchers.\n *\n * A Lease is a plain mutable record. Dispatchers mutate its fields\n * directly; no methods. When `depth` goes 0→1 a dispatcher becomes the\n * owner and resets `iterations`/`history` on its eventual 1→0 exit.\n */\nexport type Lease = {\n depth: number\n iterations: number\n readonly budget: number\n history: { label: string; type: string }[]\n readonly historyCapacity: number\n}\n\nexport type LeaseOptions = {\n budget?: number\n historyCapacity?: number\n}\n\nexport function createLease(options?: LeaseOptions): Lease {\n return {\n depth: 0,\n iterations: 0,\n budget: options?.budget ?? 100_000,\n history: [],\n historyCapacity: options?.historyCapacity ?? 32,\n }\n}\n\nexport class BudgetExhaustedError extends Error {\n readonly lease: Lease\n readonly label: string\n constructor(label: string, lease: Lease) {\n super(\n `[dispatcher:${label}] iteration budget exhausted (${lease.iterations} > ${lease.budget}); ` +\n `recent: ${lease.history.map(h => `${h.label}:${h.type}`).join(\", \")}`,\n )\n this.name = \"BudgetExhaustedError\"\n // Snapshot the lease so the history survives the owning dispatcher's\n // finally-block reset that runs as the exception unwinds.\n this.lease = { ...lease, history: [...lease.history] }\n this.label = label\n }\n}\n\nexport type DispatcherOptions = {\n lease?: Lease\n label?: string\n}\n\nexport interface DispatcherHandle<Msg> {\n dispatch(msg: Msg): void\n readonly queueDepth: number\n}\n\n/**\n * Drain-to-quiescence dispatcher with optional shared budget.\n *\n * Re-entrant `dispatch(msg)` from inside the handler — including from\n * another `DispatcherHandle.dispatch(...)` sharing the same Lease —\n * joins the current drain rather than recursing. This is the property\n * that lets cooperating dispatchers compose: an A→B→A oscillation is\n * one cascade in one lease, not a stack overflow.\n */\nexport function createDispatcher<Msg>(\n handler: (msg: Msg, dispatch: (msg: Msg) => void) => void,\n options?: DispatcherOptions,\n): DispatcherHandle<Msg> {\n const lease = options?.lease ?? createLease()\n const label = options?.label ?? \"dispatcher\"\n const pending: Msg[] = []\n let isDispatching = false\n\n function dispatch(msg: Msg): void {\n pending.push(msg)\n if (isDispatching) return\n\n isDispatching = true\n const owns = lease.depth === 0\n lease.depth += 1\n try {\n while (pending.length > 0) {\n const next = pending.shift()!\n lease.iterations += 1\n const type =\n typeof next === \"object\" && next !== null && \"type\" in next\n ? String((next as { type: unknown }).type)\n : \"<untyped>\"\n if (lease.history.length >= lease.historyCapacity) {\n lease.history.shift()\n }\n lease.history.push({ label, type })\n if (lease.iterations > lease.budget) {\n throw new BudgetExhaustedError(label, lease)\n }\n handler(next, dispatch)\n }\n } finally {\n lease.depth -= 1\n if (owns) {\n lease.iterations = 0\n lease.history.length = 0\n }\n isDispatching = false\n }\n }\n\n return {\n dispatch,\n get queueDepth(): number {\n return pending.length\n },\n }\n}\n","/** Dispatch a message into a running program. */\nexport type Dispatch<Msg> = (msg: Msg) => void\n\n/** An effect is a continuation that may dispatch messages. */\nexport type Effect<Msg> = (dispatch: Dispatch<Msg>) => void\n\n/**\n * A Mealy machine — pure state transitions with effect outputs.\n *\n * `Fx` defaults to `Effect<Msg>` (closure effects) but can be any\n * data type for programs with custom effect executors.\n *\n * - `init`: initial state and zero or more effects to execute at startup.\n * - `update`: pure transition — given a message and the current state,\n * return the new state and zero or more effects.\n * - `done`: optional teardown hook, called with the final state when\n * the runtime is disposed.\n */\nexport type Program<Msg, Model, Fx = Effect<Msg>> = {\n init: [Model, ...Fx[]]\n update(msg: Msg, model: Model): [Model, ...Fx[]]\n done?(model: Model): void\n}\n\n/** Dispose a running program — stops message processing and calls `done`. */\nexport type Disposer = () => void\n\n/**\n * Run a program whose effects are `Effect<Msg>` closures.\n *\n * The runtime:\n * 1. Extracts `[model, ...effects]` from `program.init`.\n * 2. Executes each initial effect with `dispatch`.\n * 3. Calls `view(model, dispatch)` if provided.\n * 4. On `dispatch(msg)`: calls `update(msg, state)`, updates state,\n * executes effects, calls `view`.\n * 5. Returns a `Disposer` that stops dispatch and calls `program.done`.\n *\n * Effects are executed synchronously in order. An effect may call\n * `dispatch` re-entrantly — the runtime processes re-entrant messages\n * after the current dispatch cycle completes (queue-based).\n */\nexport function runtime<Msg, Model>(\n program: Program<Msg, Model>,\n view?: (model: Model, dispatch: Dispatch<Msg>) => void,\n): Disposer {\n let state: Model\n let isRunning = true\n const pending: Msg[] = []\n let isDispatching = false\n\n function dispatch(msg: Msg): void {\n if (!isRunning) return\n\n pending.push(msg)\n if (isDispatching) return\n\n isDispatching = true\n try {\n while (pending.length > 0) {\n const next = pending.shift()!\n const [newModel, ...effects] = program.update(next, state)\n state = newModel\n for (const effect of effects) {\n effect(dispatch)\n }\n if (view) view(state, dispatch)\n }\n } finally {\n isDispatching = false\n }\n }\n\n // Initialize\n const [initialModel, ...initialEffects] = program.init\n state = initialModel\n for (const effect of initialEffects) {\n effect(dispatch)\n }\n if (view) view(state, dispatch)\n\n // Return disposer\n return () => {\n if (!isRunning) return\n isRunning = false\n program.done?.(state)\n }\n}\n","// observable — data-effect runtime with state observation.\n//\n// createObservableProgram() is the data-effect counterpart to runtime().\n// Where runtime() executes closure effects (Effect<Msg>), this function\n// accepts a custom executor for data effects (Fx). It also provides\n// state observation: subscribeToTransitions, waitForState, waitForStatus.\n//\n// This subsumes ClientStateMachine's observation API and the peer program's\n// hand-rolled dispatch loop. Transition delivery is synchronous — the\n// listener fires after each update. The microtask-batched delivery from\n// ClientStateMachine is unnecessary complexity that no consumer depends on.\n\nimport { createDispatcher, type Lease } from \"./dispatcher.js\"\nimport type { Dispatch, Program } from \"./machine.js\"\n\n// ---------------------------------------------------------------------------\n// Ambient declarations for timer APIs (not in lib: [\"ESNext\"])\n// ---------------------------------------------------------------------------\n\ndeclare function setTimeout(callback: () => void, ms: number): unknown\ndeclare function clearTimeout(id: unknown): void\n\n// ---------------------------------------------------------------------------\n// Observation types\n// ---------------------------------------------------------------------------\n\n/**\n * A state transition event — from one model to another.\n *\n * Generic over the model type. This is the machine-level primitive;\n * transport packages re-export or alias it for their specific state types.\n */\nexport type StateTransition<S> = {\n from: S\n to: S\n timestamp: number\n}\n\n/**\n * Listener for state transitions.\n */\nexport type TransitionListener<S> = (transition: StateTransition<S>) => void\n\n// ---------------------------------------------------------------------------\n// ObservableHandle\n// ---------------------------------------------------------------------------\n\n/**\n * Handle for a running observable program.\n *\n * Provides dispatch, state access, transition observation, and disposal.\n * The observation API (`subscribeToTransitions`, `waitForState`, `waitForStatus`)\n * matches the surface of the former `ClientStateMachine<S>`.\n */\nexport interface ObservableHandle<Msg, Model> {\n /** Dispatch a message into the program. */\n dispatch: Dispatch<Msg>\n\n /** Get the current model synchronously. */\n getState(): Model\n\n /**\n * Subscribe to state transitions.\n *\n * Transitions are delivered synchronously after each update.\n * Returns an unsubscribe function.\n */\n subscribeToTransitions(listener: TransitionListener<Model>): () => void\n\n /**\n * Wait for a specific state.\n *\n * Resolves immediately if the current state matches the predicate.\n * Otherwise waits for a transition that matches.\n */\n waitForState(\n predicate: (state: Model) => boolean,\n options?: { timeoutMs?: number },\n ): Promise<Model>\n\n /**\n * Wait for a specific status string on a model with a `status` discriminant.\n *\n * Convenience wrapper around `waitForState()`.\n */\n waitForStatus<S extends { status: string }>(\n this: ObservableHandle<Msg, S>,\n status: S[\"status\"],\n options?: { timeoutMs?: number },\n ): Promise<S>\n\n /**\n * Dispose the program — stops dispatch and calls `program.done`.\n */\n dispose(): void\n}\n\n// ---------------------------------------------------------------------------\n// createObservableProgram\n// ---------------------------------------------------------------------------\n\n/**\n * Run a program with data effects and state observation.\n *\n * Like `runtime()`, but instead of executing closure effects directly,\n * it delegates to a custom `executor` for each data effect. This enables\n * programs whose effects are inspectable data types (not opaque closures).\n *\n * The runtime:\n * 1. Extracts `[model, ...effects]` from `program.init`.\n * 2. Executes each initial effect via `executor(effect, dispatch)`.\n * 3. On `dispatch(msg)`: calls `update(msg, state)`, updates state,\n * notifies transition listeners, executes effects.\n * 4. Re-entrant dispatch (effect calls dispatch) is queued and processed\n * after the current dispatch cycle completes.\n * 5. `dispose()` stops dispatch and calls `program.done`.\n *\n * @param program - The program algebra: init, update, done.\n * @param executor - Interprets data effects as I/O.\n * @returns An observable handle for the running program.\n */\nexport function createObservableProgram<Msg, Model, Fx>(\n program: Program<Msg, Model, Fx>,\n executor: (effect: Fx, dispatch: Dispatch<Msg>) => void,\n options?: { lease?: Lease; label?: string },\n): ObservableHandle<Msg, Model> {\n let state: Model\n let isRunning = true\n const listeners = new Set<TransitionListener<Model>>()\n\n // --------------------------------------------------------------------------\n // Transition notification\n // --------------------------------------------------------------------------\n\n function notifyTransition(from: Model, to: Model): void {\n if (from === to) return\n\n const transition: StateTransition<Model> = {\n from,\n to,\n timestamp: Date.now(),\n }\n\n for (const listener of listeners) {\n try {\n listener(transition)\n } catch {\n // Swallow listener errors — observers must not break dispatch.\n }\n }\n }\n\n // --------------------------------------------------------------------------\n // Dispatch\n // --------------------------------------------------------------------------\n\n const handle = createDispatcher<Msg>(\n (msg, redispatch) => {\n if (!isRunning) return\n const prev = state\n const [newModel, ...effects] = program.update(msg, state)\n state = newModel\n notifyTransition(prev, state)\n for (const effect of effects) {\n executor(effect, redispatch)\n }\n },\n { lease: options?.lease, label: options?.label ?? \"observable\" },\n )\n\n function dispatch(msg: Msg): void {\n if (!isRunning) return\n handle.dispatch(msg)\n }\n\n // --------------------------------------------------------------------------\n // Observation\n // --------------------------------------------------------------------------\n\n function getState(): Model {\n return state\n }\n\n function subscribeToTransitions(\n listener: TransitionListener<Model>,\n ): () => void {\n listeners.add(listener)\n return () => {\n listeners.delete(listener)\n }\n }\n\n function waitForState(\n predicate: (state: Model) => boolean,\n options?: { timeoutMs?: number },\n ): Promise<Model> {\n // Resolve immediately if already matching\n if (predicate(state)) {\n return Promise.resolve(state)\n }\n\n return new Promise((resolve, reject) => {\n let timeoutId: unknown\n\n const unsubscribe = subscribeToTransitions(transition => {\n if (predicate(transition.to)) {\n cleanup()\n resolve(transition.to)\n }\n })\n\n const cleanup = () => {\n unsubscribe()\n if (timeoutId !== undefined) {\n clearTimeout(timeoutId)\n }\n }\n\n if (options?.timeoutMs !== undefined) {\n timeoutId = setTimeout(() => {\n cleanup()\n reject(\n new Error(`Timeout waiting for state after ${options.timeoutMs}ms`),\n )\n }, options.timeoutMs)\n }\n })\n }\n\n function waitForStatus<S extends { status: string }>(\n this: ObservableHandle<Msg, S>,\n status: S[\"status\"],\n options?: { timeoutMs?: number },\n ): Promise<S> {\n return this.waitForState((s: S) => s.status === status, options)\n }\n\n function dispose(): void {\n if (!isRunning) return\n isRunning = false\n program.done?.(state)\n }\n\n // --------------------------------------------------------------------------\n // Initialize\n // --------------------------------------------------------------------------\n\n const [initialModel, ...initialEffects] = program.init\n state = initialModel\n for (const effect of initialEffects) {\n executor(effect, dispatch)\n }\n\n // --------------------------------------------------------------------------\n // Return handle\n // --------------------------------------------------------------------------\n\n return {\n dispatch,\n getState,\n subscribeToTransitions,\n waitForState,\n waitForStatus,\n dispose,\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/dispatcher.ts","../src/machine.ts","../src/observable.ts"],"sourcesContent":["// dispatcher — drain-to-quiescence primitive shared by reactive frontiers.\n//\n// createDispatcher() is the underlying primitive that the input-processing\n// loop in createObservableProgram is built on. Factoring it out as a named\n// export lets cooperating dispatchers share a Lease — a single iteration\n// budget and re-entry depth tracker that bounds runaway cascades.\n\n/**\n * Shared iteration budget for cooperating dispatchers.\n *\n * A Lease is a plain mutable record. Dispatchers mutate its fields\n * directly; no methods. When `depth` goes 0→1 a dispatcher becomes the\n * owner and resets `iterations`/`history`/`counts`/`originStack` on its\n * eventual 1→0 exit.\n *\n * Diagnostic instrumentation (history, counts, originStack) supports\n * `BudgetExhaustedError`'s message:\n * - `history` — bounded ring buffer of recent `{label, type}` events.\n * - `counts` — cumulative `${label}:${type}` → count over the whole drain.\n * - `originStack` — captured at the cascade's entry point (depth 0→1).\n * Names the boundary where the dispatch system was re-entered from\n * outside (userland for client-side flows, transport for server-side).\n */\nexport type Lease = {\n depth: number\n iterations: number\n readonly budget: number\n history: { label: string; type: string }[]\n readonly historyCapacity: number\n counts: Map<string, number>\n originStack: string | undefined\n}\n\nexport type LeaseOptions = {\n budget?: number\n historyCapacity?: number\n}\n\nexport function createLease(options?: LeaseOptions): Lease {\n return {\n depth: 0,\n iterations: 0,\n budget: options?.budget ?? 100_000,\n history: [],\n historyCapacity: options?.historyCapacity ?? 32,\n counts: new Map(),\n originStack: undefined,\n }\n}\n\n// ---------------------------------------------------------------------------\n// Diagnostic recording — single mutation site for history + counts\n// ---------------------------------------------------------------------------\n\n/**\n * Single mutation site for the lease's diagnostic projections. Future\n * additions (e.g. subscriber-call site) land here so `history` and\n * `counts` can't drift out of sync with each other.\n */\nfunction recordDispatch(lease: Lease, label: string, type: string): void {\n if (lease.history.length >= lease.historyCapacity) lease.history.shift()\n lease.history.push({ label, type })\n const key = `${label}:${type}`\n lease.counts.set(key, (lease.counts.get(key) ?? 0) + 1)\n}\n\n// ---------------------------------------------------------------------------\n// Pure formatters for BudgetExhaustedError's message sections\n// ---------------------------------------------------------------------------\n\n/**\n * Pure formatter for the cascade-origin section of `BudgetExhaustedError`'s\n * message. Strips the synthetic `Error: cascade origin` header from the\n * captured stack — it's the label we used to *construct* the Error solely\n * to grab a stack, not a meaningful frame.\n */\nexport function formatOrigin(originStack: string | undefined): string {\n if (!originStack) return \"\"\n const lines = originStack.split(\"\\n\")\n const start = lines[0]?.startsWith(\"Error\") ? 1 : 0\n const frames = lines.slice(start).map(l => ` ${l.trim()}`)\n return ` cascade entered from:\\n${frames.join(\"\\n\")}\\n`\n}\n\n/**\n * Pure formatter for the histogram section. Width is computed per\n * render because cooperating dispatchers produce labels as long as\n * `synchronizer:sync:sync/synthetic-doc-removed-all` (46 chars) — a\n * fixed `padEnd` width would mis-align the count column.\n */\nexport function formatHistogram(\n counts: ReadonlyMap<string, number>,\n total: number,\n topN: number,\n): string {\n if (counts.size === 0 || total <= 0) return \"\"\n const entries = [...counts.entries()]\n .sort((a, b) => b[1] - a[1])\n .slice(0, topN)\n const maxKeyLen = Math.max(...entries.map(([k]) => k.length))\n const rows = entries.map(([key, n]) => {\n const pct = ((n / total) * 100).toFixed(1)\n return ` ${key.padEnd(maxKeyLen)} ${String(n).padStart(7)} (${pct.padStart(4)}%)`\n })\n return ` top message types:\\n${rows.join(\"\\n\")}\\n`\n}\n\nexport function formatRecent(\n history: readonly { label: string; type: string }[],\n): string {\n if (history.length === 0) return \"\"\n const tail = history.map(h => `${h.label}:${h.type}`).join(\", \")\n return ` recent (${history.length}): ${tail}\\n`\n}\n\n// ---------------------------------------------------------------------------\n// BudgetExhaustedError\n// ---------------------------------------------------------------------------\n\nexport class BudgetExhaustedError extends Error {\n readonly lease: Lease\n readonly label: string\n constructor(label: string, lease: Lease) {\n const header = `[dispatcher:${label}] iteration budget exhausted (${lease.iterations} > ${lease.budget})`\n const body =\n formatOrigin(lease.originStack) +\n formatHistogram(lease.counts, lease.iterations, 5) +\n formatRecent(lease.history)\n super(body.length > 0 ? `${header}\\n${body}` : header)\n this.name = \"BudgetExhaustedError\"\n // Snapshot the lease so the diagnostic state survives the owning\n // dispatcher's finally-block reset that runs as the exception unwinds.\n // `counts` is a Map and must be cloned explicitly — spread does not\n // copy Map contents.\n this.lease = {\n ...lease,\n history: [...lease.history],\n counts: new Map(lease.counts),\n }\n this.label = label\n }\n}\n\nexport type DispatcherOptions = {\n lease?: Lease\n label?: string\n}\n\nexport interface DispatcherHandle<Msg> {\n dispatch(msg: Msg): void\n readonly queueDepth: number\n}\n\n/**\n * Drain-to-quiescence dispatcher with optional shared budget.\n *\n * Re-entrant `dispatch(msg)` from inside the handler — including from\n * another `DispatcherHandle.dispatch(...)` sharing the same Lease —\n * joins the current drain rather than recursing. This is the property\n * that lets cooperating dispatchers compose: an A→B→A oscillation is\n * one cascade in one lease, not a stack overflow.\n */\nexport function createDispatcher<Msg>(\n handler: (msg: Msg, dispatch: (msg: Msg) => void) => void,\n options?: DispatcherOptions,\n): DispatcherHandle<Msg> {\n const lease = options?.lease ?? createLease()\n const label = options?.label ?? \"dispatcher\"\n const pending: Msg[] = []\n let isDispatching = false\n\n function dispatch(msg: Msg): void {\n pending.push(msg)\n if (isDispatching) return\n\n isDispatching = true\n const owns = lease.depth === 0\n if (owns) {\n // Capture the frame that opened this drain. Subscribers re-entering\n // mid-cascade don't overwrite it — the owning drain resets it on\n // exit. The frame names the *entry point* into the dispatch system\n // (userland or transport), not necessarily user code.\n lease.originStack = new Error(\"cascade origin\").stack\n }\n lease.depth += 1\n try {\n while (pending.length > 0) {\n const next = pending.shift()!\n lease.iterations += 1\n const type =\n typeof next === \"object\" && next !== null && \"type\" in next\n ? String((next as { type: unknown }).type)\n : \"<untyped>\"\n recordDispatch(lease, label, type)\n if (lease.iterations > lease.budget) {\n throw new BudgetExhaustedError(label, lease)\n }\n handler(next, dispatch)\n }\n } finally {\n lease.depth -= 1\n if (owns) {\n lease.iterations = 0\n lease.history.length = 0\n lease.counts.clear()\n lease.originStack = undefined\n }\n isDispatching = false\n }\n }\n\n return {\n dispatch,\n get queueDepth(): number {\n return pending.length\n },\n }\n}\n","/** Dispatch a message into a running program. */\nexport type Dispatch<Msg> = (msg: Msg) => void\n\n/** An effect is a continuation that may dispatch messages. */\nexport type Effect<Msg> = (dispatch: Dispatch<Msg>) => void\n\n/**\n * A Mealy machine — pure state transitions with effect outputs.\n *\n * `Fx` defaults to `Effect<Msg>` (closure effects) but can be any\n * data type for programs with custom effect executors.\n *\n * - `init`: initial state and zero or more effects to execute at startup.\n * - `update`: pure transition — given a message and the current state,\n * return the new state and zero or more effects.\n * - `done`: optional teardown hook, called with the final state when\n * the runtime is disposed.\n */\nexport type Program<Msg, Model, Fx = Effect<Msg>> = {\n init: [Model, ...Fx[]]\n update(msg: Msg, model: Model): [Model, ...Fx[]]\n done?(model: Model): void\n}\n\n/** Dispose a running program — stops message processing and calls `done`. */\nexport type Disposer = () => void\n\n/**\n * Run a program whose effects are `Effect<Msg>` closures.\n *\n * The runtime:\n * 1. Extracts `[model, ...effects]` from `program.init`.\n * 2. Executes each initial effect with `dispatch`.\n * 3. Calls `view(model, dispatch)` if provided.\n * 4. On `dispatch(msg)`: calls `update(msg, state)`, updates state,\n * executes effects, calls `view`.\n * 5. Returns a `Disposer` that stops dispatch and calls `program.done`.\n *\n * Effects are executed synchronously in order. An effect may call\n * `dispatch` re-entrantly — the runtime processes re-entrant messages\n * after the current dispatch cycle completes (queue-based).\n */\nexport function runtime<Msg, Model>(\n program: Program<Msg, Model>,\n view?: (model: Model, dispatch: Dispatch<Msg>) => void,\n): Disposer {\n let state: Model\n let isRunning = true\n const pending: Msg[] = []\n let isDispatching = false\n\n function dispatch(msg: Msg): void {\n if (!isRunning) return\n\n pending.push(msg)\n if (isDispatching) return\n\n isDispatching = true\n try {\n while (pending.length > 0) {\n const next = pending.shift()!\n const [newModel, ...effects] = program.update(next, state)\n state = newModel\n for (const effect of effects) {\n effect(dispatch)\n }\n if (view) view(state, dispatch)\n }\n } finally {\n isDispatching = false\n }\n }\n\n // Initialize\n const [initialModel, ...initialEffects] = program.init\n state = initialModel\n for (const effect of initialEffects) {\n effect(dispatch)\n }\n if (view) view(state, dispatch)\n\n // Return disposer\n return () => {\n if (!isRunning) return\n isRunning = false\n program.done?.(state)\n }\n}\n","// observable — data-effect runtime with state observation.\n//\n// createObservableProgram() is the data-effect counterpart to runtime().\n// Where runtime() executes closure effects (Effect<Msg>), this function\n// accepts a custom executor for data effects (Fx). It also provides\n// state observation: subscribeToTransitions, waitForState, waitForStatus.\n//\n// This subsumes ClientStateMachine's observation API and the peer program's\n// hand-rolled dispatch loop. Transition delivery is synchronous — the\n// listener fires after each update. The microtask-batched delivery from\n// ClientStateMachine is unnecessary complexity that no consumer depends on.\n\nimport { createDispatcher, type Lease } from \"./dispatcher.js\"\nimport type { Dispatch, Program } from \"./machine.js\"\n\n// ---------------------------------------------------------------------------\n// Ambient declarations for timer APIs (not in lib: [\"ESNext\"])\n// ---------------------------------------------------------------------------\n\ndeclare function setTimeout(callback: () => void, ms: number): unknown\ndeclare function clearTimeout(id: unknown): void\n\n// ---------------------------------------------------------------------------\n// Observation types\n// ---------------------------------------------------------------------------\n\n/**\n * A state transition event — from one model to another.\n *\n * Generic over the model type. This is the machine-level primitive;\n * transport packages re-export or alias it for their specific state types.\n */\nexport type StateTransition<S> = {\n from: S\n to: S\n timestamp: number\n}\n\n/**\n * Listener for state transitions.\n */\nexport type TransitionListener<S> = (transition: StateTransition<S>) => void\n\n// ---------------------------------------------------------------------------\n// ObservableHandle\n// ---------------------------------------------------------------------------\n\n/**\n * Handle for a running observable program.\n *\n * Provides dispatch, state access, transition observation, and disposal.\n * The observation API (`subscribeToTransitions`, `waitForState`, `waitForStatus`)\n * matches the surface of the former `ClientStateMachine<S>`.\n */\nexport interface ObservableHandle<Msg, Model> {\n /** Dispatch a message into the program. */\n dispatch: Dispatch<Msg>\n\n /** Get the current model synchronously. */\n getState(): Model\n\n /**\n * Subscribe to state transitions.\n *\n * Transitions are delivered synchronously after each update.\n * Returns an unsubscribe function.\n */\n subscribeToTransitions(listener: TransitionListener<Model>): () => void\n\n /**\n * Wait for a specific state.\n *\n * Resolves immediately if the current state matches the predicate.\n * Otherwise waits for a transition that matches.\n */\n waitForState(\n predicate: (state: Model) => boolean,\n options?: { timeoutMs?: number },\n ): Promise<Model>\n\n /**\n * Wait for a specific status string on a model with a `status` discriminant.\n *\n * Convenience wrapper around `waitForState()`.\n */\n waitForStatus<S extends { status: string }>(\n this: ObservableHandle<Msg, S>,\n status: S[\"status\"],\n options?: { timeoutMs?: number },\n ): Promise<S>\n\n /**\n * Dispose the program — stops dispatch and calls `program.done`.\n */\n dispose(): void\n}\n\n// ---------------------------------------------------------------------------\n// createObservableProgram\n// ---------------------------------------------------------------------------\n\n/**\n * Run a program with data effects and state observation.\n *\n * Like `runtime()`, but instead of executing closure effects directly,\n * it delegates to a custom `executor` for each data effect. This enables\n * programs whose effects are inspectable data types (not opaque closures).\n *\n * The runtime:\n * 1. Extracts `[model, ...effects]` from `program.init`.\n * 2. Executes each initial effect via `executor(effect, dispatch)`.\n * 3. On `dispatch(msg)`: calls `update(msg, state)`, updates state,\n * notifies transition listeners, executes effects.\n * 4. Re-entrant dispatch (effect calls dispatch) is queued and processed\n * after the current dispatch cycle completes.\n * 5. `dispose()` stops dispatch and calls `program.done`.\n *\n * @param program - The program algebra: init, update, done.\n * @param executor - Interprets data effects as I/O.\n * @returns An observable handle for the running program.\n */\nexport function createObservableProgram<Msg, Model, Fx>(\n program: Program<Msg, Model, Fx>,\n executor: (effect: Fx, dispatch: Dispatch<Msg>) => void,\n options?: { lease?: Lease; label?: string },\n): ObservableHandle<Msg, Model> {\n let state: Model\n let isRunning = true\n const listeners = new Set<TransitionListener<Model>>()\n\n // --------------------------------------------------------------------------\n // Transition notification\n // --------------------------------------------------------------------------\n\n function notifyTransition(from: Model, to: Model): void {\n if (from === to) return\n\n const transition: StateTransition<Model> = {\n from,\n to,\n timestamp: Date.now(),\n }\n\n for (const listener of listeners) {\n try {\n listener(transition)\n } catch {\n // Swallow listener errors — observers must not break dispatch.\n }\n }\n }\n\n // --------------------------------------------------------------------------\n // Dispatch\n // --------------------------------------------------------------------------\n\n const handle = createDispatcher<Msg>(\n (msg, redispatch) => {\n if (!isRunning) return\n const prev = state\n const [newModel, ...effects] = program.update(msg, state)\n state = newModel\n notifyTransition(prev, state)\n for (const effect of effects) {\n executor(effect, redispatch)\n }\n },\n { lease: options?.lease, label: options?.label ?? \"observable\" },\n )\n\n function dispatch(msg: Msg): void {\n if (!isRunning) return\n handle.dispatch(msg)\n }\n\n // --------------------------------------------------------------------------\n // Observation\n // --------------------------------------------------------------------------\n\n function getState(): Model {\n return state\n }\n\n function subscribeToTransitions(\n listener: TransitionListener<Model>,\n ): () => void {\n listeners.add(listener)\n return () => {\n listeners.delete(listener)\n }\n }\n\n function waitForState(\n predicate: (state: Model) => boolean,\n options?: { timeoutMs?: number },\n ): Promise<Model> {\n // Resolve immediately if already matching\n if (predicate(state)) {\n return Promise.resolve(state)\n }\n\n return new Promise((resolve, reject) => {\n let timeoutId: unknown\n\n const unsubscribe = subscribeToTransitions(transition => {\n if (predicate(transition.to)) {\n cleanup()\n resolve(transition.to)\n }\n })\n\n const cleanup = () => {\n unsubscribe()\n if (timeoutId !== undefined) {\n clearTimeout(timeoutId)\n }\n }\n\n if (options?.timeoutMs !== undefined) {\n timeoutId = setTimeout(() => {\n cleanup()\n reject(\n new Error(`Timeout waiting for state after ${options.timeoutMs}ms`),\n )\n }, options.timeoutMs)\n }\n })\n }\n\n function waitForStatus<S extends { status: string }>(\n this: ObservableHandle<Msg, S>,\n status: S[\"status\"],\n options?: { timeoutMs?: number },\n ): Promise<S> {\n return this.waitForState((s: S) => s.status === status, options)\n }\n\n function dispose(): void {\n if (!isRunning) return\n isRunning = false\n program.done?.(state)\n }\n\n // --------------------------------------------------------------------------\n // Initialize\n // --------------------------------------------------------------------------\n\n const [initialModel, ...initialEffects] = program.init\n state = initialModel\n for (const effect of initialEffects) {\n executor(effect, dispatch)\n }\n\n // --------------------------------------------------------------------------\n // Return handle\n // --------------------------------------------------------------------------\n\n return {\n dispatch,\n getState,\n subscribeToTransitions,\n waitForState,\n waitForStatus,\n dispose,\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyneta/machine",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "Universal Mealy machine algebra — Program, Effect, Dispatch, runtime",
5
5
  "author": "Duane Johnson",
6
6
  "license": "MIT",
@@ -5,6 +5,9 @@ import {
5
5
  BudgetExhaustedError,
6
6
  createDispatcher,
7
7
  createLease,
8
+ formatHistogram,
9
+ formatOrigin,
10
+ formatRecent,
8
11
  type Lease,
9
12
  } from "../dispatcher.js"
10
13
 
@@ -141,3 +144,154 @@ describe("createDispatcher", () => {
141
144
  expect(handle.queueDepth).toBe(0)
142
145
  })
143
146
  })
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Lease diagnostic state — origin frame and message-type histogram
150
+ // ---------------------------------------------------------------------------
151
+
152
+ describe("Lease diagnostic state", () => {
153
+ it("originStack is cleared when the owning drain exits cleanly", () => {
154
+ // Guards against a refactor of the cleanup block forgetting to
155
+ // clear originStack — stale stacks would bleed between cascades.
156
+ const lease = createLease()
157
+ const handle = createDispatcher<{ type: "n" }>(() => {}, { lease })
158
+ handle.dispatch({ type: "n" })
159
+ expect(lease.originStack).toBeUndefined()
160
+ })
161
+
162
+ it("originStack is captured once per cascade — re-entrant dispatches see the same frame", () => {
163
+ const lease = createLease()
164
+ const seen: (string | undefined)[] = []
165
+ const handle = createDispatcher<{ type: "n"; depth: number }>(
166
+ (msg, dispatch) => {
167
+ seen.push(lease.originStack)
168
+ if (msg.depth < 3) dispatch({ type: "n", depth: msg.depth + 1 })
169
+ },
170
+ { lease },
171
+ )
172
+ handle.dispatch({ type: "n", depth: 0 })
173
+ expect(new Set(seen).size).toBe(1)
174
+ expect(seen[0]).toBeDefined()
175
+ })
176
+
177
+ it("counts reset on owning drain exit so they don't accumulate across cascades", () => {
178
+ const lease = createLease()
179
+ const handle = createDispatcher<{ type: "n" }>(() => {}, {
180
+ lease,
181
+ label: "x",
182
+ })
183
+
184
+ handle.dispatch({ type: "n" })
185
+ expect(lease.counts.size).toBe(0)
186
+
187
+ handle.dispatch({ type: "n" })
188
+ expect(lease.counts.size).toBe(0)
189
+ })
190
+ })
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // BudgetExhaustedError — diagnostic payload survives the cleanup unwind
194
+ // ---------------------------------------------------------------------------
195
+
196
+ describe("BudgetExhaustedError diagnostic payload", () => {
197
+ it("snapshots origin, counts, and history into the message and into err.lease", () => {
198
+ // One trip exercises the whole diagnostic pipeline: the entry-point
199
+ // stack is captured, the histogram accrues, the snapshot survives
200
+ // the owning drain's finally-block reset, and the message renders
201
+ // all three sections. Merging these assertions into one test keeps
202
+ // the cascade-trip cost paid once.
203
+ const lease = createLease({ budget: 5, historyCapacity: 4 })
204
+ const handle = createDispatcher<{ type: "tick" }>(
205
+ (msg, dispatch) => dispatch(msg),
206
+ { lease, label: "osc" },
207
+ )
208
+
209
+ let caught: unknown
210
+ try {
211
+ handle.dispatch({ type: "tick" })
212
+ } catch (err) {
213
+ caught = err
214
+ }
215
+ const err = caught as BudgetExhaustedError
216
+ expect(err).toBeInstanceOf(BudgetExhaustedError)
217
+
218
+ // Origin: snapshot present and names the test's call site.
219
+ expect(err.lease.originStack).toBeDefined()
220
+ expect(err.lease.originStack).toContain("dispatcher.test")
221
+
222
+ // Counts: Map snapshot is independent of the live lease (Map doesn't
223
+ // spread, so the snapshot must explicitly clone).
224
+ expect(err.lease.counts.get("osc:tick")).toBeGreaterThan(0)
225
+
226
+ // Message: contains the three diagnostic section headers and the
227
+ // dominant message type.
228
+ expect(err.message).toContain("cascade entered from:")
229
+ expect(err.message).toContain("top message types:")
230
+ expect(err.message).toContain("recent (")
231
+ expect(err.message).toContain("osc:tick")
232
+ })
233
+ })
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Error-message formatters — pure, table-testable projections
237
+ // ---------------------------------------------------------------------------
238
+
239
+ describe("formatHistogram", () => {
240
+ it("returns the empty string when there is nothing to render", () => {
241
+ expect(formatHistogram(new Map(), 100, 5)).toBe("")
242
+ expect(formatHistogram(new Map([["a", 1]]), 0, 5)).toBe("")
243
+ })
244
+
245
+ it("sorts entries descending and truncates to top-N", () => {
246
+ const counts = new Map([
247
+ ["a", 50],
248
+ ["b", 30],
249
+ ["c", 20],
250
+ ])
251
+ const out = formatHistogram(counts, 100, 2)
252
+ const lines = out.trim().split("\n")
253
+ expect(lines[0]).toBe("top message types:")
254
+ expect(lines[1]).toMatch(/a\s+50\s+\(50\.0%\)/)
255
+ expect(lines[2]).toMatch(/b\s+30\s+\(30\.0%\)/)
256
+ expect(out).not.toContain("c ")
257
+ })
258
+
259
+ it("pads keys to the widest entry so the count column aligns", () => {
260
+ // Existing labels can reach 46+ chars (e.g.
261
+ // `synchronizer:sync:sync/synthetic-doc-removed-all`); a fixed pad
262
+ // width would mis-align the count column.
263
+ const counts = new Map([
264
+ ["short", 5],
265
+ ["a-much-longer-label-here", 3],
266
+ ])
267
+ const out = formatHistogram(counts, 10, 5)
268
+ const lines = out.trim().split("\n").slice(1)
269
+ const colOfFive = lines[0]!.indexOf("5 (")
270
+ const colOfThree = lines[1]!.indexOf("3 (")
271
+ expect(colOfFive).toBe(colOfThree)
272
+ })
273
+ })
274
+
275
+ describe("formatOrigin", () => {
276
+ it("drops the synthetic 'Error: cascade origin' header and indents the frames", () => {
277
+ // The header is the label of the Error we constructed solely to
278
+ // capture a stack; it's not a useful frame and would be misleading
279
+ // at the top of the rendered block.
280
+ const stack = "Error: cascade origin\n at testFn (file.ts:42:3)"
281
+ const out = formatOrigin(stack)
282
+ expect(out).toContain("cascade entered from:")
283
+ expect(out).toContain("at testFn (file.ts:42:3)")
284
+ expect(out).not.toContain("Error: cascade origin")
285
+ })
286
+ })
287
+
288
+ describe("formatRecent", () => {
289
+ it("joins history entries as 'label:type' with the count in the header", () => {
290
+ const out = formatRecent([
291
+ { label: "a", type: "x" },
292
+ { label: "b", type: "y" },
293
+ ])
294
+ expect(out).toContain("recent (2):")
295
+ expect(out).toContain("a:x, b:y")
296
+ })
297
+ })
package/src/dispatcher.ts CHANGED
@@ -10,7 +10,16 @@
10
10
  *
11
11
  * A Lease is a plain mutable record. Dispatchers mutate its fields
12
12
  * directly; no methods. When `depth` goes 0→1 a dispatcher becomes the
13
- * owner and resets `iterations`/`history` on its eventual 1→0 exit.
13
+ * owner and resets `iterations`/`history`/`counts`/`originStack` on its
14
+ * eventual 1→0 exit.
15
+ *
16
+ * Diagnostic instrumentation (history, counts, originStack) supports
17
+ * `BudgetExhaustedError`'s message:
18
+ * - `history` — bounded ring buffer of recent `{label, type}` events.
19
+ * - `counts` — cumulative `${label}:${type}` → count over the whole drain.
20
+ * - `originStack` — captured at the cascade's entry point (depth 0→1).
21
+ * Names the boundary where the dispatch system was re-entered from
22
+ * outside (userland for client-side flows, transport for server-side).
14
23
  */
15
24
  export type Lease = {
16
25
  depth: number
@@ -18,6 +27,8 @@ export type Lease = {
18
27
  readonly budget: number
19
28
  history: { label: string; type: string }[]
20
29
  readonly historyCapacity: number
30
+ counts: Map<string, number>
31
+ originStack: string | undefined
21
32
  }
22
33
 
23
34
  export type LeaseOptions = {
@@ -32,21 +43,100 @@ export function createLease(options?: LeaseOptions): Lease {
32
43
  budget: options?.budget ?? 100_000,
33
44
  history: [],
34
45
  historyCapacity: options?.historyCapacity ?? 32,
46
+ counts: new Map(),
47
+ originStack: undefined,
35
48
  }
36
49
  }
37
50
 
51
+ // ---------------------------------------------------------------------------
52
+ // Diagnostic recording — single mutation site for history + counts
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /**
56
+ * Single mutation site for the lease's diagnostic projections. Future
57
+ * additions (e.g. subscriber-call site) land here so `history` and
58
+ * `counts` can't drift out of sync with each other.
59
+ */
60
+ function recordDispatch(lease: Lease, label: string, type: string): void {
61
+ if (lease.history.length >= lease.historyCapacity) lease.history.shift()
62
+ lease.history.push({ label, type })
63
+ const key = `${label}:${type}`
64
+ lease.counts.set(key, (lease.counts.get(key) ?? 0) + 1)
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Pure formatters for BudgetExhaustedError's message sections
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * Pure formatter for the cascade-origin section of `BudgetExhaustedError`'s
73
+ * message. Strips the synthetic `Error: cascade origin` header from the
74
+ * captured stack — it's the label we used to *construct* the Error solely
75
+ * to grab a stack, not a meaningful frame.
76
+ */
77
+ export function formatOrigin(originStack: string | undefined): string {
78
+ if (!originStack) return ""
79
+ const lines = originStack.split("\n")
80
+ const start = lines[0]?.startsWith("Error") ? 1 : 0
81
+ const frames = lines.slice(start).map(l => ` ${l.trim()}`)
82
+ return ` cascade entered from:\n${frames.join("\n")}\n`
83
+ }
84
+
85
+ /**
86
+ * Pure formatter for the histogram section. Width is computed per
87
+ * render because cooperating dispatchers produce labels as long as
88
+ * `synchronizer:sync:sync/synthetic-doc-removed-all` (46 chars) — a
89
+ * fixed `padEnd` width would mis-align the count column.
90
+ */
91
+ export function formatHistogram(
92
+ counts: ReadonlyMap<string, number>,
93
+ total: number,
94
+ topN: number,
95
+ ): string {
96
+ if (counts.size === 0 || total <= 0) return ""
97
+ const entries = [...counts.entries()]
98
+ .sort((a, b) => b[1] - a[1])
99
+ .slice(0, topN)
100
+ const maxKeyLen = Math.max(...entries.map(([k]) => k.length))
101
+ const rows = entries.map(([key, n]) => {
102
+ const pct = ((n / total) * 100).toFixed(1)
103
+ return ` ${key.padEnd(maxKeyLen)} ${String(n).padStart(7)} (${pct.padStart(4)}%)`
104
+ })
105
+ return ` top message types:\n${rows.join("\n")}\n`
106
+ }
107
+
108
+ export function formatRecent(
109
+ history: readonly { label: string; type: string }[],
110
+ ): string {
111
+ if (history.length === 0) return ""
112
+ const tail = history.map(h => `${h.label}:${h.type}`).join(", ")
113
+ return ` recent (${history.length}): ${tail}\n`
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // BudgetExhaustedError
118
+ // ---------------------------------------------------------------------------
119
+
38
120
  export class BudgetExhaustedError extends Error {
39
121
  readonly lease: Lease
40
122
  readonly label: string
41
123
  constructor(label: string, lease: Lease) {
42
- super(
43
- `[dispatcher:${label}] iteration budget exhausted (${lease.iterations} > ${lease.budget}); ` +
44
- `recent: ${lease.history.map(h => `${h.label}:${h.type}`).join(", ")}`,
45
- )
124
+ const header = `[dispatcher:${label}] iteration budget exhausted (${lease.iterations} > ${lease.budget})`
125
+ const body =
126
+ formatOrigin(lease.originStack) +
127
+ formatHistogram(lease.counts, lease.iterations, 5) +
128
+ formatRecent(lease.history)
129
+ super(body.length > 0 ? `${header}\n${body}` : header)
46
130
  this.name = "BudgetExhaustedError"
47
- // Snapshot the lease so the history survives the owning dispatcher's
48
- // finally-block reset that runs as the exception unwinds.
49
- this.lease = { ...lease, history: [...lease.history] }
131
+ // Snapshot the lease so the diagnostic state survives the owning
132
+ // dispatcher's finally-block reset that runs as the exception unwinds.
133
+ // `counts` is a Map and must be cloned explicitly — spread does not
134
+ // copy Map contents.
135
+ this.lease = {
136
+ ...lease,
137
+ history: [...lease.history],
138
+ counts: new Map(lease.counts),
139
+ }
50
140
  this.label = label
51
141
  }
52
142
  }
@@ -85,6 +175,13 @@ export function createDispatcher<Msg>(
85
175
 
86
176
  isDispatching = true
87
177
  const owns = lease.depth === 0
178
+ if (owns) {
179
+ // Capture the frame that opened this drain. Subscribers re-entering
180
+ // mid-cascade don't overwrite it — the owning drain resets it on
181
+ // exit. The frame names the *entry point* into the dispatch system
182
+ // (userland or transport), not necessarily user code.
183
+ lease.originStack = new Error("cascade origin").stack
184
+ }
88
185
  lease.depth += 1
89
186
  try {
90
187
  while (pending.length > 0) {
@@ -94,10 +191,7 @@ export function createDispatcher<Msg>(
94
191
  typeof next === "object" && next !== null && "type" in next
95
192
  ? String((next as { type: unknown }).type)
96
193
  : "<untyped>"
97
- if (lease.history.length >= lease.historyCapacity) {
98
- lease.history.shift()
99
- }
100
- lease.history.push({ label, type })
194
+ recordDispatch(lease, label, type)
101
195
  if (lease.iterations > lease.budget) {
102
196
  throw new BudgetExhaustedError(label, lease)
103
197
  }
@@ -108,6 +202,8 @@ export function createDispatcher<Msg>(
108
202
  if (owns) {
109
203
  lease.iterations = 0
110
204
  lease.history.length = 0
205
+ lease.counts.clear()
206
+ lease.originStack = undefined
111
207
  }
112
208
  isDispatching = false
113
209
  }