diff --git a/.changeset/attribution-serializable-reruns.md b/.changeset/attribution-serializable-reruns.md new file mode 100644 index 000000000..ac0a44996 --- /dev/null +++ b/.changeset/attribution-serializable-reruns.md @@ -0,0 +1,10 @@ +--- +"@solidjs/signals": patch +"@solidjs/diagnostics": patch +--- + +Attribution re-run records are serializable as emitted, and the observe tier's idle cost is a cap. + +- `RerunEvent` no longer carries the live `node`. It names its scope by `nodeId` — the engine's per-node id, stable across the scope's runs in the process and distinct between scopes (so runs of unnamed effects still fold to one scope after the record has left the process). In-process consumers that want the node ask `OBSERVE.subjectOf(event)`, which now answers for re-run records as it did for diagnostic events, for as long as the caller holds the record object. `attribution.why(target)` and `subscriptions(target)` are unchanged. +- `@solidjs/diagnostics` artifact format v7: re-runs are stored verbatim (`RerunRecord` is now an alias of `RerunEvent`), and the artifact gains `timeOrigin` — the capturing process's `performance.timeOrigin` — so every relative `at` in it (re-runs, holds, records, diagnostic `data`) is convertible to absolute time after the fact, and a server capture lines up with the browser session it served. The JSONL meta line carries it too; the browser bridge payload includes it. +- New tripwire in the signals suite: the built observe artifact runs a graph-heavy workload within 1.25× of the built prod artifact with no hooks installed (measured 1.03–1.09). The idle wiring cost was informational before; it is capped now. diff --git a/documentation/plans/observe-tier-plan.md b/documentation/plans/observe-tier-plan.md index 22b557963..d6d31ab83 100644 --- a/documentation/plans/observe-tier-plan.md +++ b/documentation/plans/observe-tier-plan.md @@ -180,7 +180,7 @@ skills and README text `DEV.attribution` → `OBSERVE.attribution`; ## PR B — serializable events, origin, engine diet -The engine entry landed in PR A (D6). What remains is the engine's public +The engine entry landed in PR A (D6). What remained was the engine's public record shape: `RerunEvent` drops the live `node` (`OBSERVE.subjectOf`-style lookup for in-process consumers), events gain `ts` and `origin`, and `origin` unifies client interaction and server request as the external cause of work @@ -190,6 +190,53 @@ ride along with `enable()` today). Details in the sketch §4–§5; specified alongside server-dev-build-plan P1, which supplies the request half of `origin`. +_Status (2026-09-16)._ Landed, in three pieces: + +- **`origin`** came through the Sentry workstream (`ChangeOrigin` / + `withOrigin`, stamped on `ChangeRecord`, `HoldEvent` and the client + `"call"` record; `OBSERVE.attribution.currentOrigin()` for wire layers; the + request half via server-dev-build-plan P1). The runtimes' records + (`OBSERVE.records`: boundary, invocation, call, frame) were designed + serializable from the start and already leave the process. +- **`RerunEvent` is serializable as emitted.** `node` is gone; `nodeId` (the + engine's per-node id — the same one `ChangeOrigin.run` joins and the + cycle/relay checks key on) names the scope, stable across its runs in the + process and distinct between scopes, so unnamed effects still fold to one + scope offline. `OBSERVE.subjectOf` — the lookup diagnostics already had — + now answers for re-run records too, keyed by the record object for as long + as any consumer holds it (the lifetime the node had when the record carried + it). `@solidjs/diagnostics` stores re-runs verbatim (`RerunRecord` is now + an alias of `RerunEvent`). +- **Clocks: no per-record `ts`.** Every `at` the engine and the runtimes emit + is on the `performance.now()` clock, consistently; a second clock per + record would cost bytes on every record and drift against the first. The + anchor travels once instead: `DiagnosticsArtifact.timeOrigin` (format v7, + the process's `performance.timeOrigin`) makes every `at` in an artifact + absolute after the fact and lines a server capture up with the browser + session it served. In-process exporters keep doing `timeOrigin + at` + themselves (RFC 08 documents the contract). + +**Engine diet — measured, not done.** Ranking the engine's functions by +minified weight (esbuild, per top-level declaration): the console face +(`formatRerun`/`formatCause`/`logRerun`) is ~1.7 KB minified, ~0.45 KB gz — +4% of the engine's 11.7 KB gz — and `formatOrigin` another ~0.15 KB gz, which +the spike's adapter calls for span names. The premise of the diet (formatters +ride along) is true but small. The weight is spread across the checks (~15 KB +min, a third), hold/interaction/navigation tracking, and the in-process query +and fold surfaces (`costs`, `feedback`, `why`, `history`, ~3.5 KB min / +~1.5 KB gz) — the last being what a production adapter that consumes records +never calls. Shedding those needs the object split into tree-shakable named +exports (`import { feedback } from "@solidjs/signals/attribution"`), an API +change worth ~2 KB gz (17%). Deferred to a decision: it is the same shape +freeze the record types just got, and better taken once than in pieces. + +**Idle wiring is now a cap.** `tests/observe-idle-cost.test.ts` runs one +graph-heavy workload against the built prod and observe artifacts in one +process, interleaved, best-of-k, and caps the observe/prod ratio at 1.25 +(measured 1.03–1.09 with no hooks installed; re-measured on a round over the +cap so worker-thread contention does not fail it, a real regression does). +`SIGNALS_TIER=observe pnpm bench` stays for the absolute number. + ## Open questions - **CJS.** _Resolved: removed, across the board._ Every entry used to ship a diff --git a/documentation/proposals/production-observability-sketch.md b/documentation/proposals/production-observability-sketch.md index bd8e478c6..f9182af2f 100644 --- a/documentation/proposals/production-observability-sketch.md +++ b/documentation/proposals/production-observability-sketch.md @@ -286,8 +286,9 @@ build. ### 4.4 Rerun record (from `RerunEvent`) -Serialized as `Omit` — `@solidjs/diagnostics` already -defines exactly this projection (`RerunRecord`). Attached to the interaction +Serialized as-is: since observe-tier-plan PR B the event carries `nodeId` +instead of the live `node` (`OBSERVE.subjectOf(event)` for in-process +consumers), so `@solidjs/diagnostics`'s `RerunRecord` is the same shape. Attached to the interaction span only above thresholds (4.1); otherwise folded into the span's aggregates. ### 4.5 Cause chain (from `ChangeRecord`) diff --git a/documentation/solid-2.0/08-dev-diagnostics.md b/documentation/solid-2.0/08-dev-diagnostics.md index 7d9c3398d..700f10af8 100644 --- a/documentation/solid-2.0/08-dev-diagnostics.md +++ b/documentation/solid-2.0/08-dev-diagnostics.md @@ -613,6 +613,8 @@ Each `DiagnosticEvent` has: | `nodeName` | `string?` | Debug name of the signal/node involved | | `data` | `object?` | Additional context | +An event is a serializable record and never carries the node it is about. `OBSERVE.subjectOf(record)` hands the live node back to a consumer that runs in-process — the console reporter uses it to print the DOM element a binding effect writes; devtools use it to go from a record to the scope. It answers for `DiagnosticEvent`s and the attribution engine's `RerunEvent`s, for as long as the caller holds the record object; a copy that left the process and came back has no subject. + ### `OBSERVE.records` — the runtimes' records channel Beside diagnostics (findings) and attribution (re-runs and holds), `OBSERVE` carries **records**: a record is a completed, serializable summary of one thing a runtime did — a boundary that waited, a server-function call, a frame stream — delivered synchronously the moment it is complete, with the live handles an in-process observer may want (the request, the response, the value as thrown) passed **beside** it rather than on it. One channel, `OBSERVE.records`, on both platforms; subscribe by record type, and the types available are whatever the loaded runtimes declared: @@ -683,7 +685,7 @@ One record per stream, delivered at `complete`. `id` is the frame's on the wire: The **server half** (`renderServerComponent`, `renderToFrameStream`, the handler path): `version` is what the producer stamped; `outcome` is `"complete"` when the render ran to the end, fragment failures included (those are counted in `errors`, each having revealed its fallback and ridden a keyed `error` chunk), and `"error"` when the render threw synchronously — the stream then carried the failure as its only content and completed anyway, and `live.error` is the value as thrown. `` boundaries inside the frame emit their own `"boundary"` records. The **client half** (`applyFrameResponse`): `version` is the consumer's restamp — the number the frame's stale-guard saw — and `address` is the local id the chunks were applied under when the consumer remapped the wire id onto its own boundary (the call's address, for the server-component transport), absent when applied under the wire id; `outcome` is `"complete"` when the `complete` chunk arrived, `"truncated"` when the body ended before it (the connection dropped, the producer abandoned the stream), `"error"` when the read failed (a malformed chunk, a body error) with the failure in `live.error`; `live.response` is the response the stream was read from. A single-flight response carries one stream per frame it refreshed; each is its own record, on both sides. Emitted by the observe and dev artifacts of the frames entry on either platform (`frames/dist/server.observe.js`, `client.observe.js`, and the dev pair). -`@solidjs/diagnostics` folds every record type into the artifact it captures — `artifact.records.{boundary, invocation, frame, call}`, format v6, one table per type — on either platform: `captureArtifact(() => renderToStream(…))` on the server, the browser bridge in the page; so a render's waits and calls and a page's requests are evidence a test or an agent can hold beside the findings. See the package README. +`@solidjs/diagnostics` folds every record type into the artifact it captures — `artifact.records.{boundary, invocation, frame, call}`, format v7, one table per type, with `artifact.timeOrigin` anchoring every record's `at` — on either platform: `captureArtifact(() => renderToStream(…))` on the server, the browser bridge in the page; so a render's waits and calls and a page's requests are evidence a test or an agent can hold beside the findings. See the package README. ### `OBSERVE.server` — the trace-provider slot @@ -847,7 +849,7 @@ createRoot(() => { }); ``` -**Records and clocks.** Everything the engine hands out — `RerunEvent`, `InteractionEvent`, `HoldEvent`, `NavigationEvent` — is a record with an absolute `at` on the `performance.now()` clock (`RerunEvent.at` the run's start, `HoldEvent.at` the start of the wait, `NavigationEvent.at`/`InteractionEvent.at` the request/dispatch) plus durations from it (`holdMs`, `settledMs`, `selfMs`). Epoch time for an exporter is `performance.timeOrigin + at` (milliseconds). Without cross-origin isolation the browser quantizes `performance.now()` to 100µs, so a single run's `selfMs` is often `0`; the per-interaction `settledMs` is the wall-clock number to report. Records carry live graph references (`RerunEvent.node`) and the frame objects that join them (`origin`, `interaction`) — the same object across records, so join by identity, not by name. `subscribe(type, listener)` delivers each record synchronously at the moment it is complete (a re-run at recompute end; an interaction, hold or navigation when it settles), bottom-up: a hold before the navigation it held, before the interaction that performed it. A listener runs inside the engine and must not write signals; hand work off to a microtask. +**Records and clocks.** Everything the engine hands out — `RerunEvent`, `InteractionEvent`, `HoldEvent`, `NavigationEvent` — is a record with an absolute `at` on the `performance.now()` clock (`RerunEvent.at` the run's start, `HoldEvent.at` the start of the wait, `NavigationEvent.at`/`InteractionEvent.at` the request/dispatch) plus durations from it (`holdMs`, `settledMs`, `selfMs`). Epoch time for an exporter is `performance.timeOrigin + at` (milliseconds). Without cross-origin isolation the browser quantizes `performance.now()` to 100µs, so a single run's `selfMs` is often `0`; the per-interaction `settledMs` is the wall-clock number to report. Records are serializable as emitted: none carries a live graph reference — a re-run names its scope by `nodeId` (the engine's per-node id, stable across the scope's runs in the process, distinct between scopes; in-process consumers get the node back through `OBSERVE.subjectOf(event)`) — while the frame objects that join records (`origin`, `interaction`) are the same object across records in-process, so join by identity there and by `ChangeOrigin.run`/`at`/`name` once they have left it. `subscribe(type, listener)` delivers each record synchronously at the moment it is complete (a re-run at recompute end; an interaction, hold or navigation when it settles), bottom-up: a hold before the navigation it held, before the interaction that performed it. A listener runs inside the engine and must not write signals; hand work off to a microtask. **Excluding the observer.** `OBSERVE.exclude(owner)` marks an owner subtree as the observer's own: diagnostics whose subject sits under it are built (a throwing site still throws) but never delivered or printed, and the attribution engine records no run for its computations, charges none of them to an interaction, counts no write to its signals or stores toward an interaction, and does not spend a once-per-key slot (`IMMUTABLE_UPDATE_IN_STORE`'s per-path memory) on them. An interaction whose writes all went to excluded subjects, with none of the app's work run — a click on the observer's own panel — is not recorded at all. Mark the root as it is created (a store's nodes take the owner the store was created under, recorded only once the engine is enabled — enable before creating the panel's stores), and make writes from outside the graph under it (`runWithOwner(owner, () => setPanel(…))`) so the writer's context is excluded too. `OBSERVE.isExcluded(subject)` answers the question for any owner or node. diff --git a/packages/diagnostics/README.md b/packages/diagnostics/README.md index 211fb1ba9..b8de56ccb 100644 --- a/packages/diagnostics/README.md +++ b/packages/diagnostics/README.md @@ -36,6 +36,8 @@ artifact.attribution; // { reruns, costs, holds, feedback } — who re-ran, why, Options: `scenario` labels the artifact, `attribution: false` captures diagnostics only, and an options object is passed through to the engine's `enable()` (`@solidjs/signals/attribution`). `artifactToJSONL(artifact)` emits line-oriented output for offline or agent-side analysis. +**Clocks.** Every `at` in the artifact — a re-run's start, a hold's, a record's — is on the capturing process's `performance.now()` clock; `artifact.timeOrigin` (epoch milliseconds, the process's `performance.timeOrigin`) anchors it, so `timeOrigin + at` is the absolute time of anything in the artifact and two captures from one process line up. Durations (`selfMs`, `holdMs`, `durationMs`) are already relative. Re-runs are stored as the engine emits them: `nodeId` names the scope (stable across its runs in the process, distinct between scopes), so runs of unnamed effects still fold to one scope offline; the live node never leaves the process (in-process, `OBSERVE.subjectOf(rerun)` hands it back). + ### Records: server renders and browser requests Beside findings and attribution the artifact carries **records** — `artifact.records`, the runtimes' `OBSERVE.records` channel folded into one table per record type, on either platform. Over a server render the evidence is **waits and calls**: run `renderToStream` (or `renderToString`) as the scenario: diff --git a/packages/diagnostics/src/artifact.ts b/packages/diagnostics/src/artifact.ts index 9248221ce..82889ede8 100644 --- a/packages/diagnostics/src/artifact.ts +++ b/packages/diagnostics/src/artifact.ts @@ -1,7 +1,7 @@ import { RECORD_TYPES } from "./records.js"; import type { DiagnosticsArtifact } from "./types.js"; -export const ARTIFACT_FORMAT_VERSION = 6 as const; +export const ARTIFACT_FORMAT_VERSION = 7 as const; /** Pretty JSON for humans and for checked-in golden files. */ export function serializeArtifact(artifact: DiagnosticsArtifact): string { @@ -21,6 +21,7 @@ export function artifactToJSONL(artifact: DiagnosticsArtifact): string { formatVersion: artifact.formatVersion, scenario: artifact.scenario, capturedAt: artifact.capturedAt, + timeOrigin: artifact.timeOrigin, durationMs: artifact.durationMs, diagnosticCount: artifact.diagnostics.length, rerunCount: artifact.attribution?.reruns.length ?? null, diff --git a/packages/diagnostics/src/browser.ts b/packages/diagnostics/src/browser.ts index faf82209a..f05df8eed 100644 --- a/packages/diagnostics/src/browser.ts +++ b/packages/diagnostics/src/browser.ts @@ -17,7 +17,6 @@ import type { AttributionOptions, DiagnosticsArtifact, HoldEvent, - RerunEvent, RerunRecord } from "./types.js"; @@ -30,6 +29,8 @@ export interface BridgeBeginOptions { /** The serializable half of an artifact — assembled into a full one Node-side. */ export interface BridgePayload { capturedAt: string; + /** The page's `performance.timeOrigin` — see `DiagnosticsArtifact.timeOrigin`. */ + timeOrigin: number; durationMs: number; diagnostics: DiagnosticsArtifact["diagnostics"]; attribution: DiagnosticsArtifact["attribution"]; @@ -123,7 +124,7 @@ export function installDiagnosticsBridge( let attribution: DiagnosticsArtifact["attribution"] = null; if (active.useAttribution) { attribution = { - reruns: engine.history().map(({ node: _node, ...record }: RerunEvent) => record), + reruns: [...engine.history()], costs: engine.costs(), holds: [...engine.holds()], feedback: engine.feedback() @@ -134,6 +135,7 @@ export function installDiagnosticsBridge( const records = active.records.stop(); return toSerializable({ capturedAt: active.startedAt.toISOString(), + timeOrigin: performance.timeOrigin, durationMs: performance.now() - active.start, diagnostics: events, attribution, @@ -145,12 +147,7 @@ export function installDiagnosticsBridge( }, whyDidRun(name) { requireAttributionSession("whyDidRun"); - return toSerializable( - engine - .history() - .filter(event => event.nodeName === name) - .map(({ node: _node, ...record }: RerunEvent) => record) - ); + return toSerializable(engine.history().filter(event => event.nodeName === name)); }, costs() { requireAttributionSession("costs"); diff --git a/packages/diagnostics/src/capture.ts b/packages/diagnostics/src/capture.ts index adf12461b..b193eac46 100644 --- a/packages/diagnostics/src/capture.ts +++ b/packages/diagnostics/src/capture.ts @@ -2,7 +2,7 @@ import { OBSERVE, flush } from "@solidjs/signals"; import { attribution as engine } from "@solidjs/signals/attribution"; import { ARTIFACT_FORMAT_VERSION } from "./artifact.js"; import { captureRecords } from "./records.js"; -import type { AttributionOptions, DiagnosticsArtifact, RerunEvent, RerunRecord } from "./types.js"; +import type { AttributionOptions, DiagnosticsArtifact } from "./types.js"; export interface CaptureOptions { /** Label stamped into the artifact meta. */ @@ -26,11 +26,6 @@ export interface CaptureResult { artifact: DiagnosticsArtifact; } -function toRerunRecord(event: RerunEvent): RerunRecord { - const { node: _node, ...record } = event; - return record; -} - /** * Run a scenario with the dev channels open — diagnostics, attribution, the * records — and fold what they saw into a single serializable artifact. This is the fixture everything else in this @@ -74,7 +69,7 @@ export async function captureArtifact( // Read every table before disable(): aggregates reset on disable. if (useAttribution) { attribution = { - reruns: engine.history().map(toRerunRecord), + reruns: [...engine.history()], costs: engine.costs(), holds: [...engine.holds()], feedback: engine.feedback() @@ -92,6 +87,7 @@ export async function captureArtifact( formatVersion: ARTIFACT_FORMAT_VERSION, scenario: options.scenario, capturedAt: startedAt.toISOString(), + timeOrigin: performance.timeOrigin, durationMs, diagnostics: events, attribution, diff --git a/packages/diagnostics/src/playwright.ts b/packages/diagnostics/src/playwright.ts index f8ae28646..72003761a 100644 --- a/packages/diagnostics/src/playwright.ts +++ b/packages/diagnostics/src/playwright.ts @@ -76,6 +76,7 @@ export async function captureBrowserArtifact( formatVersion: ARTIFACT_FORMAT_VERSION, scenario: options.scenario, capturedAt: payload.capturedAt, + timeOrigin: payload.timeOrigin, durationMs: payload.durationMs, diagnostics: payload.diagnostics, attribution: payload.attribution, diff --git a/packages/diagnostics/src/types.ts b/packages/diagnostics/src/types.ts index 42429577f..585175ef2 100644 --- a/packages/diagnostics/src/types.ts +++ b/packages/diagnostics/src/types.ts @@ -31,10 +31,13 @@ export type AttributionCosts = ReturnType; export type AttributionFeedback = ReturnType; /** - * A serializable projection of RerunEvent: everything except the live `node` - * reference, which is a cyclic graph object that cannot leave the process. + * A re-run as the artifact stores it. The engine's `RerunEvent` is + * serializable as emitted — it names its scope by `nodeId` and never carries + * the live node (in-process consumers ask `OBSERVE.subjectOf(event)`) — so + * the artifact copies records verbatim; the alias is the artifact's + * vocabulary for the same shape. */ -export type RerunRecord = Omit; +export type RerunRecord = RerunEvent; export interface ArtifactAttribution { reruns: RerunRecord[]; @@ -209,13 +212,24 @@ export interface ArtifactRecords { * `"kind:source"` strings) for the structured `acknowledgements`; v5 adds * `server` (the server runtime's boundary, invocation and frame records); * v6 replaces it with `records` — the same tables keyed by record type, on - * both platforms, plus the client's `call` and the frame's client half. + * both platforms, plus the client's `call` and the frame's client half. v7 + * adds `timeOrigin`, the anchor that turns every relative `at` into absolute + * time, and stores re-runs as the engine emits them (`nodeId`, no `node`). */ export interface DiagnosticsArtifact { - formatVersion: 6; + formatVersion: 7; /** Human/agent-readable label for the captured scenario. */ scenario?: string; capturedAt: string; + /** + * Epoch milliseconds of the capturing process's `performance.now()` zero + * (`performance.timeOrigin`). Every `at` in the artifact — on re-runs, + * holds, interactions, navigations, the runtimes' records, and the + * `data` of a diagnostic — is on that clock, so `timeOrigin + at` is the + * absolute time of any of them, and two artifacts from one process (a + * server render and the browser session it served) line up on it. + */ + timeOrigin: number; durationMs: number; diagnostics: DiagnosticEvent[]; /** Null when attribution was disabled for the capture. */ diff --git a/packages/diagnostics/tests/browser-bridge.test.ts b/packages/diagnostics/tests/browser-bridge.test.ts index 3141ff752..b8384272b 100644 --- a/packages/diagnostics/tests/browser-bridge.test.ts +++ b/packages/diagnostics/tests/browser-bridge.test.ts @@ -82,8 +82,9 @@ describe("browser bridge + playwright adapter", () => { ); app.dispose(); - expect(artifact.formatVersion).toBe(6); + expect(artifact.formatVersion).toBe(7); expect(artifact.scenario).toBe("browser-toggle"); + expect(artifact.timeOrigin).toBe(performance.timeOrigin); expectNoDiagnostics(artifact); // Same assertions work on browser-captured artifacts: mount happened // before the capture, so only the interaction's re-runs are counted. diff --git a/packages/diagnostics/tests/capture.test.ts b/packages/diagnostics/tests/capture.test.ts index 7b72b57c9..897ab6420 100644 --- a/packages/diagnostics/tests/capture.test.ts +++ b/packages/diagnostics/tests/capture.test.ts @@ -31,8 +31,10 @@ describe("captureArtifact — diagnostics channel", () => { { scenario: "orphan effect", attribution: deterministicAttribution } ); - expect(artifact.formatVersion).toBe(6); + expect(artifact.formatVersion).toBe(7); expect(artifact.scenario).toBe("orphan effect"); + // The anchor for every relative `at` in the artifact. + expect(artifact.timeOrigin).toBe(performance.timeOrigin); expectDiagnostic(artifact, "NO_OWNER_EFFECT"); expect(() => expectNoDiagnostics(artifact)).toThrow(DiagnosticsAssertionError); expectNoDiagnostics(artifact, { allow: ["NO_OWNER_EFFECT"] }); @@ -83,6 +85,16 @@ describe("captureArtifact — attribution channel", () => { const updates = reruns.filter(rerun => rerun.causes.length > 0); expect(updates.length).toBe(2); expect(updates.map(rerun => rerun.nodeName).sort()).toEqual(["double", "render"]); + // Stored as the engine emitted them: a scope id, no live node, and every + // `at` on the clock `artifact.timeOrigin` anchors. + for (const rerun of reruns) { + expect(rerun).not.toHaveProperty("node"); + expect(typeof rerun.nodeId).toBe("number"); + expect(rerun.at).toBeGreaterThan(0); + expect(rerun.at).toBeLessThan(performance.now()); + } + expect(new Set(reruns.map(r => r.nodeId)).size).toBe(new Set(reruns.map(r => r.nodeName)).size); + expect(JSON.parse(JSON.stringify(artifact))).toEqual(artifact); // Every update traces back to the "count" write. for (const rerun of updates) { const roots = new Set(); diff --git a/packages/diagnostics/tests/responsiveness.test.ts b/packages/diagnostics/tests/responsiveness.test.ts index d4383602f..1185b2d7d 100644 --- a/packages/diagnostics/tests/responsiveness.test.ts +++ b/packages/diagnostics/tests/responsiveness.test.ts @@ -107,7 +107,7 @@ async function capturePageTurn(withBusyIndicator: boolean, holdFor = 20) { describe("artifact — responsiveness evidence", () => { it("carries the holds and the feedback tables", async () => { const artifact = await capturePageTurn(false); - expect(artifact.formatVersion).toBe(6); + expect(artifact.formatVersion).toBe(7); const { holds, feedback } = artifact.attribution!; expect(holds).toHaveLength(1); expect(holds[0]).toMatchObject({ diff --git a/packages/signals/src/core/attribution.ts b/packages/signals/src/core/attribution.ts index 1c6d8fc1a..57c5c92ed 100644 --- a/packages/signals/src/core/attribution.ts +++ b/packages/signals/src/core/attribution.ts @@ -12,7 +12,9 @@ import { isExcluded, isSuppressed, ownerPath, - reportDiagnostic + recordSubject, + reportDiagnostic, + subjectOf } from "./dev.js"; import type { Transition } from "./scheduler.js"; import type { Computed, Signal } from "./types.js"; @@ -114,7 +116,16 @@ export interface RerunEvent { nodeRuns: number; nodeKind: "effect" | "memo"; nodeName: string; - node: Computed; + /** + * Identity of the scope that ran, stable for the node's lifetime within + * the process: every run of one memo/effect carries the same `nodeId`, so + * runs join to a scope after the record has left the process (where + * `nodeName` alone would merge every unnamed `effect`). The engine's own + * per-node id, also what `ChangeOrigin.run` and the cycle/relay checks + * key on; not meaningful across processes or sessions. In-process + * consumers that want the live node ask `OBSERVE.subjectOf(event)`. + */ + nodeId: number; /** * The deps that changed since this node's previous run. Empty means the * re-run was not triggered by a tracked value change (creation-adjacent @@ -405,8 +416,8 @@ function rootsOf(causes: ChangeRecord[], out: Set): void { } } -function recordCosts(event: RerunEvent): void { - let scope = scopeCosts.get(event.node); +function recordCosts(el: Computed, event: RerunEvent): void { + let scope = scopeCosts.get(el); if (scope === undefined) { scope = { name: event.nodeName, @@ -416,7 +427,7 @@ function recordCosts(event: RerunEvent): void { wastedMs: 0, overlayMs: 0 }; - scopeCosts.set(event.node, scope); + scopeCosts.set(el, scope); } scope.runs++; scope.selfMs += event.selfMs; @@ -1030,7 +1041,7 @@ function recordRerun( nodeRuns: (node._devRunCount = (node._devRunCount ?? 0) + 1), nodeKind: (el as { _type?: number })._type ? "effect" : "memo", nodeName: nodeName(el), - node: el, + nodeId: devId(el), causes, depCount: newDeps.length, depsAdded, @@ -1049,9 +1060,13 @@ function recordRerun( node._devRunInteraction = interaction; node._devRunSeq = event.run; node._devRunCauses = causes; + // The record is serializable and never carries the node; keep the node + // beside it for `OBSERVE.subjectOf` and the engine's own joins (effect + // cycles, relay tears, `why()`), for as long as anyone holds the record. + recordSubject(event, el); history.push(event); if (history.length > options.historyLimit) history.shift(); - recordCosts(event); + recordCosts(el, event); recordFeedbackRun(event); noteInteractionRun(interaction, timing.selfMs, false); if (event.nodeKind === "effect") checkEffectCycle(el, causes); @@ -3549,7 +3564,7 @@ export const attribution: Attribution = { }, why(target: unknown) { const node = ((target as Record)?.[$REFRESH] ?? target) as Computed; - return history.filter(event => event.node === node); + return history.filter(event => subjectOf(event) === node); }, subscriptions(target: unknown) { const node = ((target as Record)?.[$REFRESH] ?? target) as Computed; diff --git a/packages/signals/src/core/dev.ts b/packages/signals/src/core/dev.ts index 34f115dc1..8a96f0c77 100644 --- a/packages/signals/src/core/dev.ts +++ b/packages/signals/src/core/dev.ts @@ -8,7 +8,7 @@ import { type InteractionRef, type OriginRef } from "./attribution-hooks.js"; -import type { ChangeOrigin } from "./attribution.js"; +import type { ChangeOrigin, RerunEvent } from "./attribution.js"; // Cycle note: core.ts imports this module; we read its live `context` binding // only at call time (emitDiagnostic's default subject), never during module // evaluation, so the cycle is inert — same shape as the attribution.ts edge. @@ -302,11 +302,16 @@ export interface Observe { /** The server runtime's surface — see `ServerObserve`. */ server: ServerObserve; /** - * The live node an emitted event was about, when the emitter knew it. - * Events are serializable records and never carry the node; consumers that - * run in-process (devtools, the console reporter) look it up here. + * The live node an emitted record was about, when the emitter knew it. + * Records are serializable and never carry the node — a diagnostic event + * names its subject by `ownerPath`/`nodeName`, a re-run record by + * `nodeId` — so consumers that run in-process (devtools, the console + * reporter, `attribution.subscriptions(OBSERVE.subjectOf(run))`) look the + * node up here. Answers for `DiagnosticEvent`s and the attribution + * engine's `RerunEvent`s; `undefined` for anything else, and for a record + * that has left the process and come back. */ - subjectOf(event: DiagnosticEvent): DiagnosticSubject | undefined; + subjectOf(record: DiagnosticEvent | RerunEvent): DiagnosticSubject | undefined; /** * Marks `owner`'s subtree as the observer's own. A consumer that renders * inside the app it watches — an APM adapter's panel, devtools — would @@ -448,8 +453,8 @@ export const OBSERVE: Observe = __OBSERVE__ // client the slot stays this placeholder. The cast: the interface is // empty HERE and gains its members by augmentation downstream. server: {} as ServerObserve, - subjectOf(event) { - return eventSubjects.get(event); + subjectOf(record) { + return eventSubjects.get(record); }, exclude(owner) { excludedOwners.add(owner); @@ -601,13 +606,28 @@ function takeFooter(entry: DiagnosticEvent): string | undefined { } /** - * The subject each emitted event was about, for the console step: events are - * serializable records and cannot carry the node, but the console can show - * what the node knows — a rendering runtime may stamp a binding effect with - * the DOM element it writes (`_devElement`), and a live element reference - * beside the message is the most addressable pointer a console can print. + * The subject each emitted event was about: events are serializable records + * and cannot carry the node, so the node is kept beside the record for the + * in-process consumers that want it — the console step, which can show what + * the node knows (a rendering runtime may stamp a binding effect with the DOM + * element it writes, `_devElement`, and a live element reference beside the + * message is the most addressable pointer a console can print), and devtools + * that go from a re-run record back to the scope that ran. Keyed by the + * record object, so the subject lives exactly as long as some consumer holds + * the record (a ring buffer, a captured artifact) — the same lifetime the + * node had when records carried it directly. */ -const eventSubjects = new WeakMap(); +const eventSubjects = new WeakMap(); + +/** Register `subject` as what `record` was about — see `Observe.subjectOf`. */ +export function recordSubject(record: object, subject: DiagnosticSubject): void { + eventSubjects.set(record, subject); +} + +/** The live subject `record` was about, if its emitter registered one. */ +export function subjectOf(record: object): DiagnosticSubject | undefined { + return eventSubjects.get(record); +} /** * The console face of a diagnostic — ONE entry per finding: the message, the diff --git a/packages/signals/tests/attribution.test.ts b/packages/signals/tests/attribution.test.ts index c1c8ebebb..030a9611b 100644 --- a/packages/signals/tests/attribution.test.ts +++ b/packages/signals/tests/attribution.test.ts @@ -240,7 +240,50 @@ describe("why-did-this-run attribution", () => { flush(); const last = events.filter(e => e.nodeName === "branchy").at(-1)!; expect(last.depsAdded).toEqual(["b"]); - expect(attribution.subscriptions(run.node)).toEqual(["flag", "b"]); + expect(attribution.subscriptions(OBSERVE!.subjectOf(run)!)).toEqual(["flag", "b"]); + }); + + it("re-run records are serializable: nodeId names the scope, subjectOf hands back the node", () => { + const [a, setA] = createSignal(0, { name: "a" }); + let double!: () => number; + createRoot(() => { + double = createMemo(() => a() * 2, { name: "double" }); + createEffect( + () => double(), + () => {}, + { name: "reader" } + ); + }); + flush(); + const events = collect(); + setA(1); + flush(); + setA(2); + flush(); + + const doubles = events.filter(e => e.nodeName === "double"); + const readers = events.filter(e => e.nodeName === "reader"); + expect(doubles.length).toBe(2); + expect(readers.length).toBe(2); + // No live reference on the record: it survives the wire as-is. + for (const e of events) { + expect(e).not.toHaveProperty("node"); + expect(JSON.parse(JSON.stringify(e))).toEqual(e); + } + // One id per scope, stable across its runs, distinct between scopes. + expect(doubles[0].nodeId).toBe(doubles[1].nodeId); + expect(readers[0].nodeId).toBe(readers[1].nodeId); + expect(doubles[0].nodeId).not.toBe(readers[0].nodeId); + // In-process consumers get the node back through the observe surface; + // the engine's own queries still take the accessor. + const node = OBSERVE!.subjectOf(doubles[0]); + expect(node).toBeDefined(); + expect(OBSERVE!.subjectOf(doubles[1])).toBe(node); + expect(attribution.subscriptions(node!)).toEqual(["a"]); + expect(attribution.why(double)).toEqual(doubles); + expect(attribution.why(node)).toEqual(doubles); + // A copy that left the process has no subject. + expect(OBSERVE!.subjectOf(JSON.parse(JSON.stringify(doubles[0])))).toBeUndefined(); }); it("warns on hot scopes, once per window", () => { diff --git a/packages/signals/tests/observe-idle-cost.test.ts b/packages/signals/tests/observe-idle-cost.test.ts new file mode 100644 index 000000000..e561c7e71 --- /dev/null +++ b/packages/signals/tests/observe-idle-cost.test.ts @@ -0,0 +1,107 @@ +/** + * The observe tier's idle cost — what a production observability build pays + * with nothing subscribed and no engine installed — is a cap, not a number + * to read off a benchmark. The wiring is a null check per hook site, a label + * read, and an edge counter; every one of those runs on every write, read + * and recompute of every app on the observe build, so a regression here + * charges every consumer that opted into observability before any of them + * turned a hook on. + * + * Relative tripwire, same discipline as heap-mark-incremental: absolute + * wall-clock bounds do not survive CI, so the SAME workload runs against the + * built prod and observe artifacts in one process, interleaved, best-of-k, + * and the observe/prod ratio is what is capped. Both tiers see the same + * machine load, and best-of-k picks the quiet run for each. + */ +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; + +type Tier = typeof import("../src/index.js"); + +// The built artifacts — what apps actually resolve. Gitignored, so the test +// skips when they haven't been built (run `pnpm build`); resolved paths rather +// than literal specifiers so the build's type pass doesn't try to find them. +const here = dirname(fileURLToPath(import.meta.url)); +const PROD = resolve(here, "../dist/prod/index.js"); +const OBSERVE = resolve(here, "../dist/observe/index.js"); + +/** + * A graph-heavy workload with no hooks installed: N chains of + * signal → memo → memo → effect, then K write passes that touch every chain, + * then teardown. Reads, writes, recomputes, effect runs and creation all + * cross the observe wiring; nothing observes. + */ +function workload(tier: Tier, N: number, K: number): number { + const { createEffect, createMemo, createRoot, createSignal, flush } = tier; + let ms = 0; + createRoot(dispose => { + const setters: ((v: number) => void)[] = []; + let sink = 0; + for (let i = 0; i < N; i++) { + const [a, setA] = createSignal(i); + const b = createMemo(() => a() * 2); + const c = createMemo(() => b() + 1); + createEffect( + () => c(), + v => { + sink += v; + } + ); + setters.push(setA); + } + flush(); + const start = performance.now(); + for (let k = 1; k <= K; k++) { + for (let i = 0; i < N; i++) setters[i](i + k); + flush(); + } + ms = performance.now() - start; + dispose(); + if (sink === Infinity) throw new Error("unreachable"); + }); + return ms; +} + +describe.skipIf(!existsSync(PROD) || !existsSync(OBSERVE))("observe tier idle cost", () => { + test("no hooks installed: the observe artifact runs the same graph within the cap of prod", async () => { + const prod = (await import(PROD)) as Tier; + const observe = (await import(OBSERVE)) as Tier; + expect((prod as any).OBSERVE).toBeUndefined(); + expect((observe as any).OBSERVE).toBeDefined(); + + // ~15ms a sample locally; under a loaded CI worker a full three rounds + // stays well inside the explicit timeout below. + const N = 1000; + const K = 10; + // Warm both (JIT, allocator) before anything is timed. + workload(prod, N, 2); + workload(observe, N, 2); + // Measured 2026-09-16 (M-series, five samples): 1.03–1.09 — the wiring + // is 3–9% on a graph that does nothing but cross it. The cap trips when + // the wiring costs ~3x what it does today (25%), which is the regression + // this exists to catch — a hook site that stopped being a null check. + // Noise: the suite runs this beside other files on worker + // threads, so one tier can draw the busy slots; a round is best-of-k + // interleaved, and a round over the cap is re-measured (a regression is + // over the cap every round, contention is not). + const CAP = 1.25; + let best = Infinity; + let detail = ""; + for (let round = 0; round < 3 && best >= CAP; round++) { + let prodMs = Infinity; + let observeMs = Infinity; + for (let i = 0; i < 5; i++) { + prodMs = Math.min(prodMs, workload(prod, N, K)); + observeMs = Math.min(observeMs, workload(observe, N, K)); + } + const ratio = observeMs / prodMs; + if (ratio < best) { + best = ratio; + detail = `observe ${observeMs.toFixed(1)}ms / prod ${prodMs.toFixed(1)}ms`; + } + } + expect(best, detail).toBeLessThan(CAP); + }, 60_000); +}); diff --git a/packages/web/test/diagnostic-element-ref.spec.tsx b/packages/web/test/diagnostic-element-ref.spec.tsx index 222536927..941918393 100644 --- a/packages/web/test/diagnostic-element-ref.spec.tsx +++ b/packages/web/test/diagnostic-element-ref.spec.tsx @@ -4,7 +4,7 @@ */ import { afterEach, describe, expect, test, vi } from "vitest"; import { render } from "@solidjs/web"; -import { createSignal, flush } from "solid-js"; +import { createSignal, flush, OBSERVE } from "solid-js"; import { attribution } from "solid-js/attribution"; /** @@ -33,8 +33,9 @@ describe("diagnostic element references", () => { document.body.appendChild(container); const dispose = render(() =>
, container); flush(); + // Records never carry the node; the observe surface hands it back in-process. const nodes: any[] = []; - attribution.subscribe(e => nodes.push(e.node)); + attribution.subscribe(e => nodes.push(OBSERVE!.subjectOf(e))); for (let i = 0; i < 6; i++) { setCls(`c${i}`); diff --git a/packages/web/test/server/diagnostics-server-scenario.spec.tsx b/packages/web/test/server/diagnostics-server-scenario.spec.tsx index 6f6bedb5f..887e10034 100644 --- a/packages/web/test/server/diagnostics-server-scenario.spec.tsx +++ b/packages/web/test/server/diagnostics-server-scenario.spec.tsx @@ -151,7 +151,8 @@ describe("captureArtifact over a server render", () => { expect(html).toContain("Ada"); // The findings, located by component. - expect(artifact.formatVersion).toBe(6); + expect(artifact.formatVersion).toBe(7); + expect(artifact.timeOrigin).toBe(performance.timeOrigin); expect(artifact.scenario).toBe("profile page"); expectDiagnostic(artifact, "HEAD_TAG_INVALID", { count: 1 }); expectDiagnostic(artifact, "SERVER_WRITE", { count: 1 }); @@ -196,7 +197,8 @@ describe("captureArtifact over a server render", () => { .map(line => JSON.parse(line)); expect(lines[0]).toMatchObject({ type: "meta", - formatVersion: 6, + formatVersion: 7, + timeOrigin: performance.timeOrigin, diagnosticCount: 2, recordCounts: { boundary: 1, invocation: 1, frame: 0, call: 0 } });