Skip to content

perf(shadow): build TypeScript mismatch warnings in event-loop steps - #250

Draft
lan17 wants to merge 2 commits into
mainfrom
claude/ts-mismatch-walk-perf
Draft

lan17 wants to merge 2 commits into
mainfrom
claude/ts-mismatch-walk-perf

Conversation

@lan17

@lan17 lan17 commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

Problem

The TypeScript built-in mismatch walk blocks the event loop for a whole confirmed-mismatch warning: two full
JSON.stringify calls, two full JSON.parse calls and the walk, one after another. On a 7.4 MB value that was one
block of about 70 ms. #248 asks for a faster walk with identical output, TypeScript first; in session the maintainer
asked to "leverage setImmediete and some other optimizations to make sure we are not taking as long and also we are
not blocking event loop and also that its still correct". Refs #248, which stays open for the other ports.

Change

Change type: Refactor (identical output) with a TypeScript-only capacity change, recorded as a Deviations entry in
design/2026-10-24-shadow-mismatch-paths.md. That record is Implemented, so per design/README.md it gains only the
Deviations entry and this pull request in its list.

  • Event-loop steps. shadowMismatchLogSteps is a generator that yields after each native rendering or parse of a
    side whose rendering is at least 65,536 UTF-16 code units long. warnShadowMismatch awaits an unreferenced
    setImmediate between steps; renderShadowMismatchLog drives the same generator synchronously for tests and
    vectors. Each native call, the walk and the hook still run without a break; native calls cannot be sliced. Short
    sides never yield, which avoids hops for typical values and keeps the TypeScript replays working: their harness
    drains one zero-time fake tick per input, and an immediate scheduled inside that tick lands 1 ms later.
  • Slot held through the warning. A confirmed mismatch with logging on keeps its shadow slot until the warning
    reaches the logger, as Go and Rust already do, so warnings in flight stay within shadowMaxInFlight. While one is
    built, further jobs for the same key and jobs beyond capacity report dropped. The slot is released whether the
    verdict settles as mismatch or as a late timeout.
  • Less work for the same output.
    • The walk skips members and items whose values are === before sorting and visiting; sorting a subset keeps its
      order.
    • A built-in diffJson side is rendered only as far as its 1 KiB clamp reads (jsonPrefix), and a side that fits
      is kept as the value with no parse-back. Hook sides keep the old path.
    • With the default JsonSerializer (RedisCache.loadsParsedJson: the key's serializer has
      JsonSerializer.prototype.load), the walk reads the decoded cached value instead of parsing its rendering
      again. jsonLeaf compares a number beyond double range, which another port can write as 1e400, as the null
      the rendering writes.

On one Apple-silicon Mac (same process, interleaved, medians of 9; outputs asserted equal), with the default
serializer:

Value main at diff: "paths" This PR Longest step
150,000 string members, 7.3 MB 141.6 ms 111.2 ms 32.6 ms
20,000 records, one field differs, 7.4 MB 76.8 ms 50.6 ms 18.8 ms
20,000 records plus one appended, 7.4 MB 50.6 ms 33.3 ms 17.0 ms
50,000 records in reversed member order, 17.9 MB 230.8 ms 160.7 ms 66.0 ms

The value fields alone took 54 ms on the first row, unchanged. The longest step is now the walk or one native parse.
Narrowing the parse to the differing region, rewriting JSON.stringify in JavaScript and capturing the tree with a
replacer were also prototyped and verified; they are left out (see Follow-ups).

Open questions

  1. D3. D3 chose "the parsed native-JSON renderings of both sides". With the default serializer the walk now reads
    the serializer's JSON.parse of the payload, which holds the same JSON as a parse of its rendering, so output is
    unchanged; only a comparator that mutates the cached value, which its contract forbids, can make the diff name
    members cachedValueJson does not show, or fail the warning with render_error. Recommended: treat it as an
    implementation detail, recorded under Deviations. If you count it as a departure from a confirmed decision,
    AGENTS.md needs a new record, and I would drop the reuse from this pull request.
  2. Slot hold. Recommended: keep it, since it bounds the CPU and memory of warnings in flight as Go and Rust do.
    It shows as more dropped jobs for a key that keeps mismatching with large values.
  3. Formal tier. The mutant rule fires (mutations.json anchors M62, M67 and M69 moved). Recommended, per your
    scoping for port-local work: accept the TypeScript lanes below instead of a formal-full.yaml Replay or Mutations
    dispatch. Say if you want the dispatch.

Ports

Port Status
TypeScript done
Go N/A: TypeScript first, per #248; Go already holds the slot through the warning
Rust N/A: TypeScript first, per #248; Rust already holds the slot through the warning
Python N/A: TypeScript first, per #248

Validation

Head: fdff86d. It differs from bfcc118 only in the design record's pull request number, a Markdown-only change,
so the runs at bfcc118 cover it.

  • tsc --noEmit, vitest run --coverage at bfcc118: 3,898 passed, 333 skipped; coverage floors hold.
  • pnpm build, test:package on Node 24 at bfcc118: passed.
  • make docs (docs:build) and make audit at bfcc118: passed.
  • make formal-generate then make formal-ts on that fresh corpus at bfcc118: 7,920 replay tests passed.
  • make mutations-ts MUTATION_ONLY=M13,M29,M48,M49,M58,M62,M65,M66,M67,M68,M69,M70,M72,M73,M75 at bfcc118: every
    required detection holds. M69's planted code was updated to the generator's signature, since it no longer compiled.
  • Seeded equivalence fuzz of shadow-log-json.ts against main at bae1e5e: 80,000 iterations on the first
    build and 30,000 on the file at bfcc118, over all plans,
    hooks, exotic values and payloads written the way other ports write them (overflow literals, duplicate and own
    __proto__ members): 0 mismatches. An independent review fuzzed about 1.2 million more checks, including every
    special code unit at offsets 990 to 1040 around the side clamp: 0 mismatches.
  • New tests fail on their negative controls: removing the slot hold, releasing it only on the success path,
    treating every serializer as JSON, and removing jsonLeaf each fail a test.

Not run: formal-full.yaml in replay or mutations mode (open question 3); the Go, Rust and Python lanes, since
no file they read changed.

Follow-ups

lan17 added 2 commits October 28, 2026 21:50
The built-in mismatch walk blocked the event loop for the whole warning:
two full renders, two full parses and the walk, about 70 ms on a 7.4 MB
value. The warning now returns to the event loop after each native
rendering or parse of a side rendered at 65,536 code units or more, and
holds the job's shadow slot until it reaches the logger, as Go and Rust
do, so warnings in flight stay within shadowMaxInFlight.

The walk also does less work for the same output. It skips members and
items whose values are identical before sorting, renders a diffJson side
only as far as its 1 KiB clamp reads, and, with the default
JsonSerializer, reads the decoded cached value instead of parsing its
rendering again, comparing a number beyond double range as the null the
rendering writes.

Refs #248
@codecov-commenter

codecov-commenter commented Oct 29, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.74468% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.68%. Comparing base (bae1e5e) to head (fdff86d).

Files with missing lines Patch % Lines
typescript/src/internal/shadow-log-json.ts 94.87% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main     #250   +/-   ##
=======================================
  Coverage   94.67%   94.68%           
=======================================
  Files          84       84           
  Lines       13026    13100   +74     
  Branches      805      829   +24     
=======================================
+ Hits        12333    12404   +71     
- Misses        627      629    +2     
- Partials       66       67    +1     
Flag Coverage Δ
go 93.25% <ø> (-0.05%) ⬇️
python 93.60% <ø> (ø)
rust 94.78% <ø> (ø)
typescript 97.22% <95.74%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants