Skip to content

Realm-safe verdicts: hook-aware scans, brand checks, union passes-gate - #317

Merged
kungfooman merged 8 commits into
mainfrom
fix-diagnosis-realm-316
Oct 31, 2026
Merged

kungfooman merged 8 commits into
mainfrom
fix-diagnosis-realm-316

Conversation

@kungfooman

Copy link
Copy Markdown
Owner

Fixes #316

Primer

Runtime Type Inspector validates values where the engine runs and reports where the panel reads, often across an iframe boundary that shares values live without cloning. Two prototype systems then disagree: the checking realm's Array (or Map, Set, Promise, class) constructor is not the panel realm's, so every instanceof check against a realm-local constructor fails for a genuine foreign value, and every prototype-identity gate (Object.getPrototypeOf(value) === Object.prototype) rejects foreign plain objects. The validators and the on-click differ both contained such gates at exactly the places where a verdict turns: container acceptance (validateArray, validateMap, validateSet, validateTuple, validatePromise, validateIArguments, validateGlobalConstructor), template inference collection (collectCandidates), snapshot tagging and traversal (tagSnapshot, snapshotTag), and log serialization (stringifyValue). The fast-path scans added a second, independent verdict hazard: firstBadIndex and firstBadEntry skip the full validation loop when their cheap predicates report clean, which is only sound when the predicates mirror the full loop exactly — and neither consulted element-level userland hooks (customTypes, customValidations), so a rejecting hook was silently bypassed. The union differ had the mirror-image flaw: it picked the closest structural match with no validator consultation, printing "matches no union member" for values the validator accepted (nominal trust, option leniency), which is exactly the phantom-row class from production (data.anchor, data.color, data.margin each flagged closest is number[] with 1 problem while the checker passed them).

What changed

  1. src-runtime/isPromiseValue.js (new, barrel-exported, spec-pinned in realmBrand.spec.js): brand check (Object.prototype.toString is [object Promise]) replacing instanceof Promise in validatePromise. Thenables still fail (no duck-typing); foreign promises pass.
  2. src-runtime/validateGlobalConstructor.js keeps instanceof for same-realm subclasses, then falls back to constructor-name equality for live-shared platform instances (foreign Float32Array validates; plain impostor shapes fail). validateGlobalConstructor.spec.js (new) pins local, foreign, impostor, and unknown-name cases.
  3. src-runtime/validateIArguments.js checks typeof value[Symbol.iterator] === 'function' instead of instanceof Function, so foreign iterables pass the protocol check.
  4. src-runtime/collectCandidates.js uses isArrayValue in its object, tuple, and array branches, so template inference collects positionally from foreign arrays and stops treating foreign arrays as objects with named properties.
  5. src-runtime/tagSnapshot.js traverses and excludes by brand (isMapValue/isSetValue) instead of instanceof Map/Set, so instances nested in foreign maps keep their $type tags and foreign maps serialize by entries.
  6. src-runtime/stringifyValue.js gates its Map/Set branches on the same brands, so foreign maps log as {Map, entries} instead of collapsing to keyless {}.
  7. src-runtime/firstBadIndex.js bails to a full scan (returns 0) when customValidations is non-empty or customTypes holds the scanned primitive (customTypes.number for number, and so on). Reproduced before the fix: customTypes.number = () => false plus [1, 2] vs number[] validated true.
  8. src-runtime/firstBadEntry.js bails to -2 when a customTypes hook shadows a registered class name, in all three class branches (keys, bare values, single-arg references). Reproduced before the fix: customTypes.LiveBox = () => false plus Map<string, LiveBox> of genuine instances validated clean.
  9. src-runtime/explainMismatch.js gains the last-resort union passes-gate: when every member fails structurally, each member is probed with the validator itself, and a passing member diffs clean. The gate only ever converts would-be rows to clean, never the reverse, so structurally clean members (with their informational children) keep flowing through the existing loop untouched. Reproduced before the fix: {a: 1, typo: 2} vs {a: number} | {b: string} with Exact objects off printed "matches no union member" for a passing value.
  10. src-runtime/plugin-windowed/windowedOptions.js corrects the spend-budget doc to what the code does: the accumulated scan total restarts on size changes; same-size value replacements keep riding until the next scheduled full scan (at most one window away, bounded by mapCheckWindowCap), because observing content without scanning is exactly what riding skips.
  11. src-runtime/liveSharing.spec.js (new, 21 tests) is the hostile local-vs-foreign battery: every verdict runs the same shape twice (local build plus vm.runInNewContext live-shared build) and asserts agreement — arrays, tuples, nested members, Maps/Sets with entry checks, promises vs thenables, foreign iterables, IArguments, positional template candidates, tag traversal through foreign maps, Map serialization, mistyped-foreign still fails, wrong-tag fails, foreign same-spelled instances under Partial, deep foreign nesting pinpointing, frozen/sparse/proxied arrays, array-likes, constructor-name platform checks, and display survival. firstBadIndex.spec.js gains the hook-bypass pins; firstBadEntry.spec.js gains the class-hook shadowing pin.

Proof

  1. The tmp/repro-windowing.mjs harness (scratch, not committed) showed got true, want false on the hook-rejection cases before the fix and got false after, with the production cross-realm matrix (tmp/repro-matrix.mjs, foreign arrays plus tagged snapshot) diffing to exactly the one genuine alignment row in all four environments (same-realm, cloned, foreign live-shared, missing classes).
  2. Every fix is mutation-pinned: stashing firstBadIndex.js fails exactly the two hook tests; stashing firstBadEntry.js fails exactly the shadowing test; reverting any brand swap fails exactly its live-sharing tests; dropping the union gate fails exactly testExcessUnionStaysClean; reverting snapshotTag.js fails 13 tag specs; narrowing unwrapTransparent fails 8 nominal tests. Restores verified green after each.
  3. tmp/repro-real-color.mjs (scratch) diffs the real 12-method engine Color harvest (parsed out of playcanvas-engine's own .rti.mjs) against the tagged snapshot with both registries present and absent: clean in both, matching the worker's nominal accept.

Latent holes

  1. The windowed map ride still skips up to one window of checks after a same-size value mutation: the spend/size proxy cannot observe content without scanning, which is the documented tradeoff (windowedOptions states the latency bound; rides stay off by default with a 100ms earn budget). Verdict: tolerated deliberately — detection is bounded, never lost, and false positives stay impossible.
  2. The differ still reports genuine data defects on live instances the validator passes nominally (a mistyped prop on a live LiveBox diffs data.rows[0].get("a").w while validation trusts the identity, same as tsc trusting the declared type). The union gate suppresses only the misleading "no member matches" verdict; a direct member row names a real defect. Verdict: tolerated deliberately and pinned (testDeepForeignNestingPinpoints asserts both halves).
  3. describeValue, Warning, typeTree, and DisplayAnything keep realm-local instanceof branches: they render, never decide, and foreign values degrade to generic-but-correct displays instead of throwing (pinned by testForeignDisplaysSurvive). Verdict: tolerated deliberately — display-only, no verdict path reads them.
  4. validateTypeof keeps nominal identity for class targets: a foreign same-named class correctly fails, since nominal identity cannot cross realms. Verdict: tolerated deliberately — typeof X is about the registered constructor, not the brand.

…g trust (#316)

Panel diffs values shared live across iframe boundaries, where
instanceof Array/Map/Set fails because each realm owns its
constructors. Dense clean arrays then scored exactly one fallback
problem and lost every Vec|number[] union, printing confident
'closest is number[]' rows for anchor/margin the checker had passed.
isArrayValue/isMapValue/isSetValue (brand checks, one function per
file, barrel-exported, realmBrand.spec.js) replace instanceof in all
verdict paths: validators, windowed map scan, and the differ.
Tagged snapshots get the validator's nominal trust in the differ too:
a union member matching the $type tag accepts outright, and the
object branch exempts transport-stripped methods by tag equality even
when materialization erased the class name or the panel registry
lacks the classes. crossRealmDiagnosis.spec.js pins the production
payload (foreign arrays plus tagged snapshot diff to exactly the
alignment row), mutation-verified per fix line.
snapshotTag rejected any object whose prototype is not the local
Object.prototype, so snapshots shared live across iframe boundaries
kept their home realm's prototype and lost their tag. Every nominal
path in the differ went blind and counted transport-stripped methods
as defects against the 12-method engine shapes. The gate now accepts
any root prototype (proto null, or proto-of-proto null): live
instances still fail it in every realm.
unwrapTransparent folds Readonly/NonNullable/NoInfer members to the
nominal name the harvest spells (getter Readonly<Color> vs bare tag),
in the union shortcut and the object-branch method exemption.
crossRealmDiagnosis pins the wrapped member; snapshotTag.spec pins
foreign tags, foreign instances, and impostors.
…n passes-gate (#316)

firstBadIndex skipped element-level userland hooks: a clean primitive
scan returned -1 and validateArray trusted it, so customTypes.number
rejecting [1,2] still passed. The scan now bails to 0 when
customValidations is non-empty or customTypes holds the scanned
primitive. firstBadEntry had the same hole one level up: it trusted
instanceof past a customTypes hook shadowing the class name, so
Map<string, LiveBox> with a rejecting hook passed. All three class
branches bail to -2 on a shadowing hook.
 Realm-safe container checks everywhere a verdict depends on them:
isPromiseValue (brand, not constructor; thenables still fail),
validateGlobalConstructor (instanceof, then constructor-name fallback
for live-shared platform instances), validateIArguments (typeof the
iterator function, not instanceof Function), collectCandidates tuple
and array branches (isArrayValue), tagSnapshot traversal and
tagName exclusions plus stringifyValue Map/Set branches (brand, so
foreign entries survive tagging and logging).
 explainMismatch gains the last-resort union passes-gate: when every
member fails structurally but the validator accepts one, the row
would contradict the verdict, so it diffs clean (informational
extras on clean members keep flowing, only would-be rows convert).
 Corrects the windowing spend-budget doc to what the code does: the
total restarts on size changes; same-size replacements ride until the
next scheduled full scan, at most one window away.
 Covered by liveSharing.spec.js (21 hostile local-vs-foreign pairs),
validateGlobalConstructor.spec.js, and hook-bypass pins in
firstBadIndex.spec.js/firstBadEntry.spec.js; every fix verified by
stash-revert failing exactly its tests.
@kungfooman kungfooman self-assigned this Oct 30, 2026
@kungfooman kungfooman added type assertions Everything related to type assertions. type error reporting Everything related to type error reporting in UI runtime labels Oct 30, 2026
Hand-written object/union/reference literals rot when the parser
shape changes and teach a construction path real code never uses.
The tower shapes and the Readonly-wrapped member now come from the
same parser the transpiler feeds, with the empty-args reference kept
literal (no valid spelling parses to it).
Measured the own-tag guard at ~60ns per promise check against zero
threat: spoofing Symbol.toStringTag requires deliberate craft in a
realm that already runs code, while the slot-probing alternative
manufactures unhandled rejections on ordinary rejected promises. The
JSDoc now states the tradeoff instead of over-claiming; the spec pins
cross-realm, subclass and thenable behavior.
diffValue carried three inline realm/nominal branches with their own
essays: the union tag shortcut, the union validator last-resort, and
the object-branch method exemption, plus a local classMatches twin.
Each is now one function per file with its own JSDoc @example and its
own spec (unionNominalTag, unionValidatorPass, snapshotNominalMatch,
classMatches), wired into test_runtime.js and the barrel. diffValue
keeps three one-line calls; behavior is unchanged (every helper spec
fails when its helper is stubbed, full suite green before and after).
validateIArguments accepted anything iterable; tsc requires indexed
elements plus integer length plus callee, so arrays and generators
passed RTI while failing tsc-strict. The check now requires all three,
with guarded reads: strict-mode arguments throw on .callee access, so
an iterator plus length with a throwing callee still accepts (genuine
strict arguments), while a readable non-function callee fails.
Fixture gains the array case with @ts-expect-error (consumed, tsc
run stays green); output regenerated from the real emit; errors.json
extended. Stash-reverting the validator fails exactly the array and
generator specs.
Trusted application code: accidental cross-realm incompatibility
and accidental misclassification are in scope, deliberate spoofing
(Symbol.toStringTag, fabricated constructor names) is explicitly out.
Brand and constructor checks stay pragmatic compatibility heuristics,
never security boundaries.
@kungfooman
kungfooman merged commit 38585c7 into main Oct 31, 2026
4 checks passed
@kungfooman
kungfooman deleted the fix-diagnosis-realm-316 branch October 31, 2026 09:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

runtime type assertions Everything related to type assertions. type error reporting Everything related to type error reporting in UI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Diagnosis Mismatch in Runtime - Realm issue

1 participant