Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Perf: AbortSignal-cancellable chunked scans for text-heavy rules (#150 Tier 3)#162

Merged
twschiller merged 3 commits into
mainfrom
perf/cancellable-text-scans
Jun 5, 2026
Merged

Perf: AbortSignal-cancellable chunked scans for text-heavy rules (#150 Tier 3)#162
twschiller merged 3 commits into
mainfrom
perf/cancellable-text-scans

Conversation

@twschiller

@twschiller twschiller commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Implements item 21 from issue #150.

Summary

The four text-heavy rules — pii-redact, secrets-redact, encoded-payload-redact, prompt-injection-redact — used to scan large innerText synchronously, blocking the event loop for hundreds of ms on dense pages and producing a cross-tree mutation hazard when an SPA route swap landed mid-scan. This PR:

  1. Introduces lib/yielding-text-walk.ts — a chunked TreeWalker over text nodes that yields between chunks (default 100 nodes) and accepts an optional AbortSignal. Yield mechanism is requestIdleCallback (yields to layout/paint) with a setTimeout(0) fallback.
  2. Wires each of the four rules to a ReusableAbortController from abort-utils. Route-change events (via the existing subscribeRouteChange) call abortAndReset on each rule independently, so an in-flight scan against the old tree stops cleanly when the SPA swaps to the new one.

Incremental subtree-watcher batches do NOT abort — they target their own scoped root and don't conflict with whatever else is running. The structural-reset signal is the only cancellation channel, as discussed in #159's risk audit.

Design choices

  • Sync fast path: when the whole tree fits in one chunk (the common case for small subtrees + the default chunkSize=100), process and onComplete fire before the helper returns. Existing rule tests keep working without explicit await machinery — only the multi-chunk path (large pages, the abort tests) needs to flush microtasks.
  • onComplete callback instead of returning Promise<void>: prompt-injection-redact collects containers in pass 1 then dedupes + hides in pass 2. The second pass must see the full container set, so it has to run after the last chunk — onComplete handles that uniformly whether the walk was sync or chunked.
  • Resume node pre-fetch: process(chunk) detaches the chunk's text nodes (the consumer rules call replaceMatchesInTextNode), so the walker's currentNode becomes a detached node and nextNode() returns null. The fix pre-fetches the next node before process and re-anchors walker.currentNode to it before yielding. Caught by review feedback on the initial commit; regression tests added.
  • abort-utils CJS stub in src/__test-mocks__/abort-utils.ts, wired via moduleNameMapper. The package is ESM-only and ts-jest's CJS pipeline can't transform it; the stub mirrors the runtime semantics each rule depends on (signal flips on abortAndReset, onAbort dispatch). Three pre-existing test files still carry inline jest.mock("abort-utils", …) blocks that take precedence over the mapper — left in place to keep this PR focused.

Rebase notes

Rebased onto 4a10459 after #160 and #161 landed.

Test plan

  • bun run test — 1285 / 1285 pass (4 new pass over pre-rebase baseline come from Fix: prompt-injection-redact would re-hide a revealed container #161's auto-merged tests)
  • bun run typecheck
  • bun run check (biome + eslint)
  • bun run knip
  • Helper unit tests (yielding-text-walk.test.ts): sync fast path, chunked path with microtask yield, signal handling (pre-aborted / mid-walk abort / mid-process abort), filter parity with walkTextNodes (NON_CONTENT_TAGS, placeholder skip, minLength, shouldSkipParent), and the resume-anchor regression where process detaches each text node.
  • Helper property tests (yielding-text-walk.property.test.ts): for any tree + any chunkSize, the flat-mapped concatenation of all chunks equals walkTextNodes's output in document order — covers sync large-chunk and async small-chunk paths plus the chunk-size invariant.
  • Per-rule abort tests: each of the 4 rules has a "teardown aborts the in-flight chunked walk" case. For prompt-injection-redact the assertion is stronger — onComplete is the hide pass, so an abort before it runs means no placeholders ever installed.
  • Per-rule cross-chunk test: pii-redact builds 200 SSNs, drains timers, asserts all 200 get masked (catches the resume-anchor regression end-to-end).
  • Reveal-flow tests from Fix: prompt-injection-redact would re-hide a revealed container #161: re-applying after a manual reveal must not re-hide the container; sibling containers with their own injection text must still get hidden.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Jun 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agent-browser-shield-demo-site Ready Ready Preview, Comment Jun 5, 2026 1:56pm

Request Review

@unblocked unblocked Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found.

About Unblocked

Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.

📖 Documentation — Learn more in our docs.

💬 Ask questions — Mention @unblocked to request a review or summary, or ask follow-up questions.

👍 Give feedback — React to comments with 👍 or 👎 to help us improve.

⚙️ Customize — Adjust settings in your preferences.

Comment thread extension/src/lib/yielding-text-walk.ts
twschiller and others added 2 commits June 5, 2026 09:49
…Tier 3)

Adds lib/yielding-text-walk.ts: a chunked TreeWalker over text nodes
that yields between chunks (default 100 nodes) via requestIdleCallback
with setTimeout(0) fallback. Mirrors walkTextNodes' filter semantics
(NON_CONTENT_TAGS, isInsidePlaceholder, minLength, shouldSkipParent).
Sync fast path when the tree fits in one chunk — process and
onComplete fire before the helper returns, so existing rule tests
keep working without an explicit await.

Each of the four text-heavy rules (pii-redact, secrets-redact,
encoded-payload-redact, prompt-injection-redact) now owns a
ReusableAbortController. Route-change events fire abortAndReset on
each rule independently, so an in-flight chunked walk against the old
tree stops cleanly when the SPA swaps to the new one — no more
cross-tree mutations from a stale scan. Incremental subtree-watcher
batches intentionally do NOT abort: they target their own scoped
root and can complete concurrently with whatever else is running.

Adds an abort-utils CJS stub via moduleNameMapper so the ESM-only
package transparently loads in ts-jest's CJS pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…boundaries

unblocked[bot] flagged a real bug. The consumer rules' `process`
callbacks call `replaceMatchesInTextNode`, which detaches each text
node in the chunk from the DOM — including the one walker.currentNode
points at. On resume, walker.nextNode() off a detached node returns
null and the walk silently ends after the first chunk.

Fix: pre-fetch the resume node before calling `process`, then anchor
walker.currentNode to it before yielding. Resume is held in a
`pendingResume` slot rather than seeded directly into the chunk —
seeding would skew the chunk-size invariant (property test caught
that variant on the first try).

Two regression tests:
  - yielding-text-walk.test.ts: chunked walk where `process` detaches
    each text node; the helper must still visit every node exactly
    once.
  - pii-redact.test.ts: 200-node tree, no abort, drain timers,
    assert all 200 SSNs masked (not just chunk 1's 100).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
CI's coverage threshold for ./src/rules/ requires 96% functions; the
post-rebase build sat at 95.66% because the route-change callback
each rule registers with subscribeRouteChange was never invoked by a
test. Adds one "route change aborts the in-flight chunked walk"
case per rule — mirror of the teardown abort test but the
cancellation signal comes from a popstate event. Brings src/rules/
function coverage to 97%.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@twschiller twschiller merged commit 944bb5e into main Jun 5, 2026
7 checks passed
@twschiller twschiller deleted the perf/cancellable-text-scans branch June 5, 2026 13:58
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.

1 participant