Skip to content

Commit 1c59e84

Browse files
committed
ownts: runtime confirmation that the two OSS true positives really leak
Adds frontend/ownts/oss-verify — an independent, executable check that the two true positives in docs/notes/ownts-oss-benchmark.md are real DOM listener leaks, not just findings the analyzer agrees with itself on. - react-scroll-to-bottom@4.2.0 Composer.js:574 — addEventListener({capture:true}) torn down by removeEventListener with the default (false) capture; the flag is part of the removal key, so the listener is never removed. - @reactuses/core@6.4.0 index.mjs — onPressed = t => () => {…}, so onPressed('mouse') is a fresh function at add and again at remove; identities differ, so the drag/touch listeners are never removed. Each shape is run in a real DOM (jsdom): register -> run the effect's cleanup -> dispatch; the handler still firing proves the leak. Paired correct-cleanup controls must go silent, so the leak assertions are non-vacuous. Cross-checked in real Chromium (identical result, recorded in the README). Reduced cases are authored here with pkg@version:line + upstream sha256 provenance; no third-party source is vendored (repo policy). Wired into CI as ownts-oss-verify. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JsBjTfMzCm15fZ616nM1cz
1 parent a4df528 commit 1c59e84

6 files changed

Lines changed: 766 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,6 +1189,27 @@ jobs:
11891189
[ "$(echo "$clean" | grep -cE '\[(OWN|EFF|DI)[0-9]{3}\]')" -eq 0 ] \
11901190
|| { echo "FAIL: cleaned-up + memoised effects must not fire"; exit 1; }
11911191
1192+
# Runtime ground-truth for the two OwnTS OSS *true positives* (the
1193+
# react-scroll-to-bottom@4.2.0 capture-mismatch and the @reactuses/core@6.4.0
1194+
# fresh-fn-identity leaks from docs/notes/ownts-oss-benchmark.md). The analyzer
1195+
# flags these statically (ownts-react-effects, above); this job EXECUTES the
1196+
# reduced shapes in a real DOM (jsdom) and asserts the effect's own cleanup fails
1197+
# to remove the listener — with correct-cleanup controls that must go silent. So
1198+
# the "true positive" label rests on observed behaviour, not the analyzer alone.
1199+
ownts-oss-verify:
1200+
name: OwnTS OSS true positives -> runtime leak proof (jsdom)
1201+
runs-on: ubuntu-latest
1202+
steps:
1203+
- uses: actions/checkout@v4
1204+
- uses: actions/setup-node@v4
1205+
with:
1206+
node-version: "22"
1207+
- name: Confirm the two OSS leaks reproduce (and controls stay clean)
1208+
working-directory: frontend/ownts/oss-verify
1209+
run: |
1210+
npm ci
1211+
npm test
1212+
11921213
# The distribution surface (Уровень 1): the own-check.sh orchestrator walks a
11931214
# directory of real C# and prints findings in the host-parseable formats the
11941215
# GitHub Action (PR annotations) and a VS Error List (MSBuild) consume — and

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ __pycache__/
88
bin/
99
obj/
1010

11+
# Node deps for the OwnTS OSS runtime-confirmation project (frontend/ownts/oss-verify)
12+
node_modules/
13+
1114
# Corpus mining: cloned third-party source + raw reports (scripts/mine.sh).
1215
# Never commit other projects' code; promote interesting findings into
1316
# corpus/real-world/ as minimal reduced cases instead.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# OwnTS OSS true-positive — runtime confirmation
2+
3+
Independent, runtime confirmation that the two **true positives** from the OwnTS OSS
4+
benchmark ([`docs/notes/ownts-oss-benchmark.md`](../../../docs/notes/ownts-oss-benchmark.md),
5+
"First confirmed TRUE positive") are *real* listener leaks — not the analyzer merely
6+
agreeing with itself.
7+
8+
The analyzer flags these shapes **statically** already
9+
([`examples/EffectFunctionCallback.tsx`](../examples/EffectFunctionCallback.tsx) pins
10+
the capture-mismatch). This project is the other half: it **executes** the reduced
11+
shapes in a real DOM and observes that the effect's own cleanup fails to remove the
12+
listener. Each leak case is paired with a **control** that cleans up correctly and
13+
must go silent, so a passing leak assertion can't be a harness artefact.
14+
15+
## The two findings
16+
17+
| # | Package | Site | Upstream file sha256 | Bug class |
18+
|---|---------|------|----------------------|-----------|
19+
| 1 | `react-scroll-to-bottom@4.2.0` | `lib/esm/ScrollToBottom/Composer.js:574` (add) / `:579` (remove) | `114886d7…efe16` | capture-flag mismatch: added `{ capture: true }`, removed with default `false` |
20+
| 2 | `@reactuses/core@6.4.0` | `dist/index.mjs:2830/2835` (add) / `:2846/2851` (remove) | `4e47dc7a…6b9c` | fresh function identity: `onPressed = t => () => {…}`, so `onPressed('mouse')` is a new fn at add and again at remove |
21+
22+
Both `removeEventListener` calls therefore never match what `addEventListener`
23+
registered, and a listener piles up on every effect re-run / `target` change.
24+
25+
## Run
26+
27+
```bash
28+
cd frontend/ownts/oss-verify
29+
npm ci # or: npm install
30+
npm test # node --test, exits non-zero if any leak/control assertion fails
31+
```
32+
33+
Expected: `# pass 5 # fail 0` — two leak proofs, two controls, one root-cause identity check.
34+
35+
## What "confirmed" rests on
36+
37+
- **jsdom** is the committed DOM engine (hermetic, no browser binary — runs in CI).
38+
- The same five assertions were **cross-checked in real Chromium** (Playwright,
39+
pre-installed headless build). Identical result:
40+
41+
| check | jsdom | Chromium |
42+
|-------|:-----:|:--------:|
43+
| case 1 buggy cleanup — listener still fires | 1 | 1 |
44+
| case 1 correct cleanup — silent | 0 | 0 |
45+
| case 2 buggy cleanup — listener still fires | 1 | 1 |
46+
| case 2 correct cleanup — silent | 0 | 0 |
47+
48+
So the leak is a property of the DOM spec (capture flag and function identity are
49+
both part of a listener's removal key), reproduced identically by both engines —
50+
not a jsdom quirk.
51+
52+
## Provenance / no vendored source
53+
54+
Per the repo policy (`.gitignore`: *"Never commit other projects' code"*), the
55+
register/cleanup helpers in the test are **minimal reduced cases authored here**, not
56+
copies of the upstream source. The table above pins each to `pkg@version : file :
57+
line` plus the upstream file sha256, so the shapes can be re-derived from a fresh
58+
`npm pack react-scroll-to-bottom@4.2.0 @reactuses/core@6.4.0` and diffed if either
59+
package is republished.
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Runtime ground-truth confirmation for the two OwnTS OSS *true positives*
2+
// recorded in docs/notes/ownts-oss-benchmark.md ("First confirmed TRUE positive").
3+
//
4+
// The analyzer already flags these shapes statically (see
5+
// frontend/ownts/examples/EffectFunctionCallback.tsx for the capture-mismatch pin).
6+
// This test is the INDEPENDENT half: it proves the flagged shapes genuinely leak a
7+
// listener when executed in a real DOM — so the "true positive" label rests on
8+
// observed behaviour, not on the analyzer's own say-so.
9+
//
10+
// How it proves a leak: register the listener, run the effect's *cleanup*, then
11+
// dispatch the event. If the handler still fires, cleanup failed to remove it — a
12+
// leak. Each case is paired with a CONTROL that cleans up correctly and must go
13+
// silent, so a passing leak assertion cannot be an artefact of the harness.
14+
//
15+
// Repo policy (.gitignore: "Never commit other projects' code") is respected: the
16+
// register/cleanup helpers below are MINIMAL REDUCED CASES authored here, not copies
17+
// of the upstream source. Provenance for each is the pkg@version : file : line and
18+
// the upstream file sha256 recorded in README.md, so a maintainer can re-derive them
19+
// from a fresh `npm pack`.
20+
//
21+
// DOM engine: jsdom (hermetic, no browser binary — CI-friendly). The exact same
22+
// assertions were cross-checked in real Chromium; the numbers are in README.md.
23+
24+
import { test } from "node:test";
25+
import assert from "node:assert/strict";
26+
import { JSDOM } from "jsdom";
27+
28+
/** A fresh, isolated document + Event constructor per case. */
29+
function dom() {
30+
const { window } = new JSDOM("<!doctype html><body></body>");
31+
return { document: window.document, Event: window.Event };
32+
}
33+
34+
// ---------------------------------------------------------------------------
35+
// Case 1 — react-scroll-to-bottom@4.2.0
36+
// lib/esm/ScrollToBottom/Composer.js:574 (add) / :579 (remove)
37+
// Bug class: capture-flag mismatch. Added with { capture: true }, removed with
38+
// the default (capture: false). removeEventListener matches on the capture flag,
39+
// so the listener is never removed — a new one piles up every time `target`
40+
// changes. Reduced shape mirrors examples/EffectFunctionCallback.tsx.
41+
// ---------------------------------------------------------------------------
42+
test("RSTB@4.2.0: capture:true add + default-capture remove leaks the listener", () => {
43+
const { document, Event } = dom();
44+
const target = document.createElement("div");
45+
let fired = 0;
46+
const handleFocus = () => { fired++; };
47+
48+
// effect body
49+
target.addEventListener("focus", handleFocus, { capture: true, passive: true });
50+
// effect cleanup, as published: no options → capture defaults to false
51+
target.removeEventListener("focus", handleFocus);
52+
53+
target.dispatchEvent(new Event("focus")); // capture listeners also fire at target
54+
assert.equal(fired, 1, "listener survived cleanup → leak (expected 1 fire)");
55+
});
56+
57+
test("RSTB control: removing with the matching capture flag is clean", () => {
58+
const { document, Event } = dom();
59+
const target = document.createElement("div");
60+
let fired = 0;
61+
const handleFocus = () => { fired++; };
62+
63+
target.addEventListener("focus", handleFocus, { capture: true, passive: true });
64+
target.removeEventListener("focus", handleFocus, { capture: true }); // the fix
65+
66+
target.dispatchEvent(new Event("focus"));
67+
assert.equal(fired, 0, "correct cleanup must remove the listener");
68+
});
69+
70+
// ---------------------------------------------------------------------------
71+
// Case 2 — @reactuses/core@6.4.0
72+
// dist/index.mjs: add dragstart/touchstart :2830/:2835, remove :2846/:2851
73+
// Bug class: fresh function identity. onPressed = useCallback(t => () => {…}),
74+
// so onPressed('mouse') returns a NEW function every call. add registers one
75+
// instance; cleanup calls onPressed('mouse') AGAIN, producing a different
76+
// instance that removeEventListener cannot match. The drag/touch listeners are
77+
// never removed (the sibling onReleased listeners use a stable ref and are fine).
78+
// ---------------------------------------------------------------------------
79+
test("@reactuses/core@6.4.0: onPressed('mouse') returns a fresh fn each call", () => {
80+
const onPressed = (srcType) => () => { void srcType; };
81+
assert.notEqual(onPressed("mouse"), onPressed("mouse"),
82+
"curried onPressed must yield a new identity per call (the root cause)");
83+
});
84+
85+
test("@reactuses/core@6.4.0: add(onPressed('mouse')) + remove(onPressed('mouse')) leaks", () => {
86+
const { document, Event } = dom();
87+
const element = document.createElement("div");
88+
let fired = 0;
89+
// curried, as published: a new inner fn per invocation
90+
const onPressed = (srcType) => () => { void srcType; fired++; };
91+
92+
// effect body — the freshly-returned fn is what gets registered
93+
element.addEventListener("dragstart", onPressed("mouse"));
94+
// effect cleanup, as published — a *different* freshly-returned fn
95+
element.removeEventListener("dragstart", onPressed("mouse"));
96+
97+
element.dispatchEvent(new Event("dragstart"));
98+
assert.equal(fired, 1, "listener survived cleanup → leak (expected 1 fire)");
99+
});
100+
101+
test("@reactuses/core control: add & remove the SAME captured fn is clean", () => {
102+
const { document, Event } = dom();
103+
const element = document.createElement("div");
104+
let fired = 0;
105+
const onPressed = (srcType) => () => { void srcType; fired++; };
106+
107+
const handler = onPressed("mouse"); // capture once (the fix)
108+
element.addEventListener("dragstart", handler);
109+
element.removeEventListener("dragstart", handler);
110+
111+
element.dispatchEvent(new Event("dragstart"));
112+
assert.equal(fired, 0, "correct cleanup must remove the listener");
113+
});

0 commit comments

Comments
 (0)