Streamix Diagnostics Guide
Normative rules for everything that observes the runtime: execution accounting, the runtime event bus, runtime introspection, the inspector panel, and devtools tracing. The architectural overview lives in RUNTIME.md; this document states the rules that any change to diagnostics must keep.
How the layer is shaped
One bus, one ledger, one control surface — and one optional entry that carries them:
- One seam (
runtime/introspection-hooks.tsin the core, plus the instrumentation flag inruntime/instrumentation.ts): the reactive core calls the__streamix*hooks andtrackOperatorExecutionthrough no-op defaults that preserve exact value and iterator semantics. The@epikodelabs/streamix/introspectionsecondary entry installs the real implementations and the real diagnostics port at import time; applications that never import it never download any diagnostics code. TheflowNodesidentity set stays in the core (StreamixRuntime.markFlowNode) because thechanged()dedupe must be correct before the entry loads. - One bus (
runtime/events.ts): every event the runtime moves — graph lifecycle (register/dependencies/change/flow/dispose), execution lifecycle (next/complete/cancel/error), and devtools traces — flows through a singleRuntimeEventBuswith one sequence and oneobservedguard. Diagnostics is a subscriber, never a second channel. Coordinator source transport is deliberately not on this bus: each execution delivers values straight into its own buffer — no bus, no per-event allocation — so production source delivery never marks the shared bus observed (rule 1). Flow emissions publish onenextevent, not achangeplus anext;changed()skips the graph event for flow-backed nodes in every configuration, not just while instrumentation is enabled. - One ledger (
introspection/src/lib/diagnostics.tsin the introspection entry):RuntimeDiagnosticsowns all diagnostics-only state — execution records, origins, atom/flow state readers — plus the frozeninspect()snapshot. The reactive core (StreamixRuntime) never reads it; it sees only theRuntimeDiagnosticsPortinterface with a built-in no-op. Record-backed nodes (flows, operator iterators) are counted once, in their record — emissions, completions, cancels, errors. The browser ledger counts only record-less nodes (atoms, deriveds) and mirrors aggregates onto operator definition entries; every node is counted by exactly one of the two. - One span per execution (
beginExecutionon the runtime): accounting token and bus events in one object, or a shared no-op when instrumentation is off and the bus is unobserved. Emission call sites never hand-build events; the entry'soperator-execution.tsand the flow run loop both go through it. - One control surface (
introspection/src/lib/controls.ts):streamixDiagnosticswithsetEnabled/enabledand the fine-grainedsetAccounting/setOriginCapture. Internal per rule 7 — never in the root package API.enableStreamixIntrospectionarms accounting too: the dev introspection flag is the single activation switch, and discovery depends on it — flows register intoflowStates(the discovery source) only while accounting is on, so an inspector without accounting sees no flows at all.
The rules at a glance
| # | Rule | Enforced by |
|---|---|---|
| 1 | No cost when unused | instrumentation flags, observed guards, bench-runtime.mjs |
| 2 | Observing never interferes | cached reads, getter-free previews, observer isolation |
| 3 | Accounting never lies | generation tokens, displaced-token auto-finish, specs |
| 4 | Printed data never changes | frozen snapshots, bounded previews |
| 5 | Retention is always bounded | trace cap, WeakRef sweeps, record lifetimes |
| 6 | Every node describes itself | origin capture, operator labels, compile-time enrichment |
| 7 | Diagnostics never change semantics | production-parity e2e, toggle specs |
| 8 | Every behavior has a regression spec | runtime-*.spec.ts, introspection.spec.ts |
1. No cost when unused
Diagnostics is the first thing production strips and the last thing that may spend a cycle anyway.
- Event objects are never constructed before the bus's
observedcheck — not insidepublish(), and not at the call site. A flow emission in an unobserved production build allocates nothing for diagnostics; thebeginExecutionspan returns the shared no-op. - Operator iterators are instrumented only when instrumentation is on or a listener is attached; otherwise
trackOperatorExecutionreturns the original iterator untouched. - Per-node state readers (atom state, flow state) are registered only while instrumentation is enabled. No closure per atom in production.
- The disabled path must stay within measurement noise of a build without diagnostics at all.
scripts/bench-runtime.mjscompares disabled / accounting / observed medians; a regression in the disabled column blocks release.
New diagnostic hooks follow the same shape: check the flag or observedbefore building any data. Never construct "just in case".
2. Observing never interferes
Inspection is read-only over live objects, and it must not change their behavior:
- Reading a value through diagnostics serves the cached state. It never evaluates a derived formula, never starts an idle flow, never subscribes, never marks anything dirty.
- Previews are getter-free:
streamixPreviewreads own property value descriptors only, prints[getter]instead of invoking accessors, and is bounded (240 characters, 12 keys, depth 2, circular-safe). Inspecting an object must not execute user code. - Every dispatch and observer boundary isolates failures: a throwing listener, observer, or preview can never break propagation or strand a producer.
3. Accounting never lies
RuntimeExecutions numbers must always reconcile:
- Exactly one terminal exit per execution —
started == completed + cancelled + failed. A displaced token (a secondstart()for the same node) is auto-finished ascancel, including when instrumentation was toggled mid-flight. - Generation tokens make late results from a previous execution harmless: stale
next()and stalefinish()are ignored, never counted. erroris the current run's error and is cleared on restart;lastError/lastExitare historical. Consumers pick deliberately.- Records live exactly as long as their node: released on dispose, and for one-shot operator iterators on finish. Accounting memory is bounded by the live graph, not by history.
4. Printed data never changes
Everything a diagnostic surface has shown is a snapshot, frozen at creation.
- Trace events are
Object.freezed when appended. A line already shown to a developer is never rewritten, re-counted, or retroactively re-labeled — the trace grows, old lines fall off the bounded end, and nothing else. inspect()anddetails()return frozen snapshots. Live state (current value, state, emitted counters) belongs to the object listings, which are understood to change; the trace is understood not to.- When an object dies, its listing row disappears and its terminal event stays in the trace. Disposal is recorded, never edited.
5. Retention is always bounded
Diagnostics must not become the leak it exists to find.
- Trace: the last 1000 events, oldest discarded. The inspector panel mirrors this bound with its DOM rows, so the two never drift apart.
- Framework devtools trace: the last 250 events (
runtime/devtools.ts), also oldest-first discarded. - Inspector entries:
WeakRefplus aFinalizationRegistry, with an incremental sweep (16 entries per appended event) so dead metadata releases even where finalization is delayed. - Scheduler heap: stale entries are skipped by identity and dropped at drain.
- There is no unbounded map keyed by something the user creates in a loop.
6. Every node describes itself
Self-description is the base layer; build-tooling metadata is enrichment. The mechanical wiring — which hooks an operator or factory must call, and which are already handled by the factories — is in INTROSPECTION.md.
- In dev builds, every atom, derived, flow, and operator captures its creation site (
origin: file, line, column, function) automatically at the factory choke points. No naming, no build step. Operators additionally carry their operator name as a label. - Origin capture is best-effort and honest about it: exact file positions in per-module dev serving; bundle-relative positions in bundled builds. Capture is disabled in production and can be toggled (
streamixDiagnostics.setOriginCapture). - The compile-time transform (variable names, owner classes, scope fields) refines and overrides — it is never the sole source of metadata. An app built without it still gets file-and-line-grade introspection.
- Identity is not name: instances created at the same location get distinct ids, and ids stay stable for the object's lifetime.
7. Diagnostics never change semantics
Turning diagnostics on or off may change cost, never behavior.
- Same ordering, same values, same cancellation, same errors with the flags in either position. The scheduler's ordering invariants are identical under instrumentation.
- Production parity is verified, not assumed: a production bundle exposes no
window.__streamix, mounts no inspector panel, and keeps no event history. - The runtime remains internal. The only public diagnostic surfaces are the dev-only browser API and panel; anything else stays behind internal imports.
8. Every behavior has a regression spec
A rule in this guide is only real if a test breaks when it is violated.
runtime-diagnostics.spec.ts— records, inspection shape, self-description.runtime-improvements.spec.ts,runtime-instrumentation.spec.ts— accounting invariants and the no-cost-when-unused toggles.introspection.spec.ts— pruning, lazy evaluation, scope relations, previews, trace behavior.- Demo e2e (
scripts/verify-demo-introspection.mjs) — discovery in all eight built development apps, app5 request output, app6 interaction and scaling, app8 Vue board interactions, and production parity (--production, usingdist/apps-production). - Tests that toggle flags restore them in
finally. Fixture flows that must stay observable mid-flight stay pending on a release function; a completed fixture releases its record with its atom by design.
Adding a new diagnostic
Checklist before merging anything that observes the runtime:
- Does it cost nothing (no allocation, no call) when disabled? Rule 1.
- Does it avoid executing user code and avoid changing what it observes? Rule 2.
- Does it keep the accounting invariants? Rule 3.
- Does it emit frozen snapshots? Rule 4.
- Is its retention bounded? Rule 5.
- Does it self-describe without requiring the compiler transform? Rule 6.
- Is behavior identical with it off? Rule 7.
- Which spec fails if it regresses? Rule 8.