docs / history / GATE_PROBE_EVIDENCE_fixhang5c.md
docs / history / GATE_PROBE_EVIDENCE_fixhang5c.md
Branch spike/ssh2-transport @ 43d0934 + uncommitted work. Method: verbatim slice of
patternNeedsWorkerIsolation extracted from src/tools/fileSystemTools.ts by script
(only 4 TS-only constructs stripped β signature x2, boolean[], string[]; zero logic changes),
written to _redos_test/_tmp_pwis.js, executed via require() inside the Node sandbox
(child_process and new Function are banned there). Probe file is REGENERATED from disk after every edit.
((a+){3}){4}x β false = mis-triaged SAFE β inline .test() on the 15k-a line
(main-thread freeze class; T1b double-fail root cause of 29.08).
Decisive control pair (pre-fix): ((a+){3}){4} β true vs ((a+){3}){4}x β false.
Only difference: the trailing literal x. β L123's $-anchored test is the sole cause.
L123 on disk (pre-fix): /^{[0-9]+(,[0-9]*)?\}$/ β char codes [47,94,123,...,63,92,125,36,47]:
bare { (implicit-literal per ES spec), escaped \}, $ anchor. At the ) of (a+){3} in
T1b pattern, j points at the second brace: slice = {4}x; anchored test requires ENTIRE
remainder to be exactly {n[,m]} β fails on trailing x β no return true β falls through.
Full byte map of all brace-test sites (disk truth, pre-fix) β only L123 was both anchored AND live:
| Line | Literal (byte-verified) | Anchored? | Role | Pre-fix verdict |
|---|---|---|---|---|
| 104 | /^\{[0-9]+(,[0-9]*)?\}/ | no | char-class quantifier β mark enclosing body | OK (prefix form) |
| 123 | /{[0-9]+(,[0-9]*)?\}$/ | yes ($) | )+brace on quantified body β return true | BUG β T1b |
| 135 | /^\{[0-9]+(,[0-9]*)?\}/ | no | alternation-analysis quantifier detection | OK (prefix form) |
| 166 | /{[0-9]+(,[0-9*)?}\}$/-ish, tested vs qAfter (single char) | yes | enclosing-scope mark after )+quant | DEAD test β a 1-char string can never match this β₯3-char pattern; no behavior either way β left untouched (minimal scope) |
| 170 | /^\{[0-9]+(,[0-9]*)?\}/ | no | fall-through mark for enclosing body | OK (prefix form β NOTE: last night's note listed it as a second anchored site; disk truth shows it was already fixed/prefix since 29.08 19:59, file mtime) |
| 187 | /^\{[0-9]+(,[0-9]*)?\}/ (.match, advance via ) |
Pre-fix expectations: the "expected" column is what a CORRECT gate should answer; rows that disagree pre-fix are defects.
| Pattern | Pre-fix got | Post-fix got | Expected | Note |
|---|---|---|---|---|
((a+){3}){4}x | false β | true β | true | T1b pattern β THE defect |
((a+){3,9})z | false β | true β | true | bonus defect found today: var-brace + trailing literal, no outer quantifier needed for explosion (C(n-1,k) ambiguity with k in 3..9 of inner unbounded body) |
(a+){5,7}(b+)? | false β | true β | true | bonus defect found today: same class |
^(a+)+$ | true β | true β | true | canonical ReDoS control |
ab\1a+(\d+) | true β | true β | true | backreference control (T2c) |
(a*){50} | true β | true β | true | deep fixed repetition (comment-cited case β regression guard) |
((a+)b)+ | true β | true β | true | yesterday's forward-mark fix (regression guard) |
a{5} | false β | false β |
Post-fix: 11/11 match. Documented accepted over-triage: (a+){2} now β true (worker).
Rationale recorded in gate header comment: mis-triaged-safe can HANG THE HOST; mis-triaged-risky
costs ~one 10β30ms worker spawn per file β the design posture explicitly pays that cost.
Post-fix byte readback of L123 literal: [47,94,92,123,...,63,92,125,47] = /^\{[0-9]+(,[0-9]*)?\}/
β escaped braces, NO $, proper close. Function span unchanged (L78..L206); file mtime advanced
only by this edit. Backup: src/tools/fileSystemTools.ts.bak.
((a+){3}){4}x, path _redos_test/ (include-only
t1b_worker_redos.txt), max_file_size 60000. PASS = ~2β4s return + skipped_files
"terminated in isolated worker after 2000ms" + FIX-HANG-5 warn lines in main.log.T1b live re-fire after user rebuild+restart returned FAST with the skip message β a superficial
PASS β but main.log showed the worker never ran any regex at all:
REGEX_TEST_WORKER_SOURCE was written for the browser Web Worker API (self.onmessage,
e.data envelope), but it is launched via node:worker_threads new WorkerCtor(src, { eval: true }).
In Node worker threads (verified on host runtime Node v24.15.0):
self global β line 1 of the inline source threw ReferenceError: self is not defined at boot;parentPort.on('message', handler) receives the payload value directly β no MessageEvent (probe: received arg typeof=object, keys=[lines,patterns], hasDataProp=false);worker.postMessage(v) and workerβmain via parentPort.postMessage(v) are both direct values.Net effect: every risky-pattern file was "skipped" through the 'error' event path with ZERO regex work done, while the user-facing reason string (single shared null-resolution in grep_files wiring) claimed a 2000ms watchdog kill. Containment held β but only via the error fallback; the actual terminate()-of-a-spinning-.test() mechanism had never been exercised live (T1's earlier "pass" was plausibly the same silent crash).
| Probe | Result | Meaning |
|---|---|---|
| diagnostic parentPort handler on trivial payload | received {lines,patterns} directly; e.data undefined | confirms direct-value contract β the fix's shape |
fixed source, safe pattern a{5}, lines [nope / aaabbb / aaaaaXtail / aaaax] | returned exactly [2] | boot + matching semantics correct (first-match-per-line preserved) |
fixed source, T1b-exact: 15k-a line (literal construction), pattern ((a+){3}){4}x, prod-style 2000ms watchdog | WATCHDOG_KILL at 2010 ms β terminate() resolved the promise while the worker was still spinning | empirically proves worker.terminate() preempts an unpreemptible .test() on this runtime (the load-bearing FIX-HANG-5 claim, previously docs-only) |
Caveat: a sandbox probe of the OLD source string returned a parse artifact ("Unexpected token '{'") instead of the live "self is not defined"; main.log remains the authoritative repro. No bearing on fix validity.
REGEX_TEST_WORKER_SOURCE rewritten: parentPort (require-guarded), direct-value handler arg named
data, internal try/catch now posts {error} and RETURNS instead of falling through to post [indices],
final post guarded. Loop body / first-match-per-line semantics byte-identical to before; ES5, no imports
beyond the parentPort require (present in every node worker thread).
Byte readback: 18 string elements on disk === the runtime-proven source (matchesTestedV2ByteForByte=true);
no self. remaining in code (only inside the explanatory comment at L54). ESLint clean. New .bak holds the
pre-5c state (= includes FIX-HANG-5 + 5b; restoring it rolls back only 5c).
parentPort inside the worker source literal AND that
self.onmessage is GONE from dist/index.js (indexOf checks, NOT regex β hard rule).((a+){3}){4}x, _redos_test/ include-only
t1b_worker_redos.txt, max_file_size 60000).Expected signature on PASS β NOTE it CHANGES vs the 5b-era expectation:
[grep_files] FIX-HANG-5: worker exceeded 2000ms budget β terminated (possible ReDoS);self is not defined must be ABSENT from any new log lines.Known residual limitation (documented, intentionally NOT changed β minimal scope): all null resolutions of
testLinesInWorker (watchdog kill / worker 'error' / internal {error} message) still share one user-facing
reason string. With 5c in place the dominant path is a genuine watchdog kill; an exotic crash would be
mislabeled as "terminated after 2000ms". Also: the spawn-failure comment at L~231 says "falling back to inline"
but null resolution actually records a SKIP (no inline run) β safe direction, comment only misleading.
Branch spike/ssh2-transport @ 43d0934 + uncommitted work. Method: verbatim slice of
patternNeedsWorkerIsolation extracted from src/tools/fileSystemTools.ts by script
(only 4 TS-only constructs stripped β signature x2, boolean[], string[]; zero logic changes),
written to _redos_test/_tmp_pwis.js, executed via require() inside the Node sandbox
(child_process and new Function are banned there). Probe file is REGENERATED from disk after every edit.
((a+){3}){4}x β false = mis-triaged SAFE β inline .test() on the 15k-a line
(main-thread freeze class; T1b double-fail root cause of 29.08).
Decisive control pair (pre-fix): ((a+){3}){4} β true vs ((a+){3}){4}x β false.
Only difference: the trailing literal x. β L123's $-anchored test is the sole cause.
L123 on disk (pre-fix): /^{[0-9]+(,[0-9]*)?\}$/ β char codes [47,94,123,...,63,92,125,36,47]:
bare { (implicit-literal per ES spec), escaped \}, $ anchor. At the ) of (a+){3} in
T1b pattern, j points at the second brace: slice = {4}x; anchored test requires ENTIRE
remainder to be exactly {n[,m]} β fails on trailing x β no return true β falls through.
Full byte map of all brace-test sites (disk truth, pre-fix) β only L123 was both anchored AND live:
| Line | Literal (byte-verified) | Anchored? | Role | Pre-fix verdict |
|---|---|---|---|---|
| 104 | /^\{[0-9]+(,[0-9]*)?\}/ | no | char-class quantifier β mark enclosing body | OK (prefix form) |
| 123 | /{[0-9]+(,[0-9]*)?\}$/ | yes ($) | )+brace on quantified body β return true | BUG β T1b |
| 135 | /^\{[0-9]+(,[0-9]*)?\}/ | no | alternation-analysis quantifier detection | OK (prefix form) |
| 166 | /{[0-9]+(,[0-9*)?}\}$/-ish, tested vs qAfter (single char) | yes | enclosing-scope mark after )+quant | DEAD test β a 1-char string can never match this β₯3-char pattern; no behavior either way β left untouched (minimal scope) |
| 170 | /^\{[0-9]+(,[0-9]*)?\}/ | no | fall-through mark for enclosing body | OK (prefix form β NOTE: last night's note listed it as a second anchored site; disk truth shows it was already fixed/prefix since 29.08 19:59, file mtime) |
| 187 | /^\{[0-9]+(,[0-9]*)?\}/ (.match, advance via ) |
Pre-fix expectations: the "expected" column is what a CORRECT gate should answer; rows that disagree pre-fix are defects.
| Pattern | Pre-fix got | Post-fix got | Expected | Note |
|---|---|---|---|---|
((a+){3}){4}x | false β | true β | true | T1b pattern β THE defect |
((a+){3,9})z | false β | true β | true | bonus defect found today: var-brace + trailing literal, no outer quantifier needed for explosion (C(n-1,k) ambiguity with k in 3..9 of inner unbounded body) |
(a+){5,7}(b+)? | false β | true β | true | bonus defect found today: same class |
^(a+)+$ | true β | true β | true | canonical ReDoS control |
ab\1a+(\d+) | true β | true β | true | backreference control (T2c) |
(a*){50} | true β | true β | true | deep fixed repetition (comment-cited case β regression guard) |
((a+)b)+ | true β | true β | true | yesterday's forward-mark fix (regression guard) |
a{5} | false β | false β |
Post-fix: 11/11 match. Documented accepted over-triage: (a+){2} now β true (worker).
Rationale recorded in gate header comment: mis-triaged-safe can HANG THE HOST; mis-triaged-risky
costs ~one 10β30ms worker spawn per file β the design posture explicitly pays that cost.
Post-fix byte readback of L123 literal: [47,94,92,123,...,63,92,125,47] = /^\{[0-9]+(,[0-9]*)?\}/
β escaped braces, NO $, proper close. Function span unchanged (L78..L206); file mtime advanced
only by this edit. Backup: src/tools/fileSystemTools.ts.bak.
((a+){3}){4}x, path _redos_test/ (include-only
t1b_worker_redos.txt), max_file_size 60000. PASS = ~2β4s return + skipped_files
"terminated in isolated worker after 2000ms" + FIX-HANG-5 warn lines in main.log.T1b live re-fire after user rebuild+restart returned FAST with the skip message β a superficial
PASS β but main.log showed the worker never ran any regex at all:
REGEX_TEST_WORKER_SOURCE was written for the browser Web Worker API (self.onmessage,
e.data envelope), but it is launched via node:worker_threads new WorkerCtor(src, { eval: true }).
In Node worker threads (verified on host runtime Node v24.15.0):
self global β line 1 of the inline source threw ReferenceError: self is not defined at boot;parentPort.on('message', handler) receives the payload value directly β no MessageEvent (probe: received arg typeof=object, keys=[lines,patterns], hasDataProp=false);worker.postMessage(v) and workerβmain via parentPort.postMessage(v) are both direct values.Net effect: every risky-pattern file was "skipped" through the 'error' event path with ZERO regex work done, while the user-facing reason string (single shared null-resolution in grep_files wiring) claimed a 2000ms watchdog kill. Containment held β but only via the error fallback; the actual terminate()-of-a-spinning-.test() mechanism had never been exercised live (T1's earlier "pass" was plausibly the same silent crash).
| Probe | Result | Meaning |
|---|---|---|
| diagnostic parentPort handler on trivial payload | received {lines,patterns} directly; e.data undefined | confirms direct-value contract β the fix's shape |
fixed source, safe pattern a{5}, lines [nope / aaabbb / aaaaaXtail / aaaax] | returned exactly [2] | boot + matching semantics correct (first-match-per-line preserved) |
fixed source, T1b-exact: 15k-a line (literal construction), pattern ((a+){3}){4}x, prod-style 2000ms watchdog | WATCHDOG_KILL at 2010 ms β terminate() resolved the promise while the worker was still spinning | empirically proves worker.terminate() preempts an unpreemptible .test() on this runtime (the load-bearing FIX-HANG-5 claim, previously docs-only) |
Caveat: a sandbox probe of the OLD source string returned a parse artifact ("Unexpected token '{'") instead of the live "self is not defined"; main.log remains the authoritative repro. No bearing on fix validity.
REGEX_TEST_WORKER_SOURCE rewritten: parentPort (require-guarded), direct-value handler arg named
data, internal try/catch now posts {error} and RETURNS instead of falling through to post [indices],
final post guarded. Loop body / first-match-per-line semantics byte-identical to before; ES5, no imports
beyond the parentPort require (present in every node worker thread).
Byte readback: 18 string elements on disk === the runtime-proven source (matchesTestedV2ByteForByte=true);
no self. remaining in code (only inside the explanatory comment at L54). ESLint clean. New .bak holds the
pre-5c state (= includes FIX-HANG-5 + 5b; restoring it rolls back only 5c).
parentPort inside the worker source literal AND that
self.onmessage is GONE from dist/index.js (indexOf checks, NOT regex β hard rule).((a+){3}){4}x, _redos_test/ include-only
t1b_worker_redos.txt, max_file_size 60000).Expected signature on PASS β NOTE it CHANGES vs the 5b-era expectation:
[grep_files] FIX-HANG-5: worker exceeded 2000ms budget β terminated (possible ReDoS);self is not defined must be ABSENT from any new log lines.Known residual limitation (documented, intentionally NOT changed β minimal scope): all null resolutions of
testLinesInWorker (watchdog kill / worker 'error' / internal {error} message) still share one user-facing
reason string. With 5c in place the dominant path is a genuine watchdog kill; an exotic crash would be
mislabeled as "terminated after 2000ms". Also: the spawn-failure comment at L~231 says "falling back to inline"
but null resolution actually records a SKIP (no inline run) β safe direction, comment only misleading.
braceM[0]| no |
consume brace quantifier in body; null-safe (if (braceM)) |
OK β also fixes yesterday's latent null![0] crash class by construction |
| 189 | /^\{[0-9]+,[0-9]*\}/ | no | mark VARIABLE-length {n,m}/{n,} body quantifiers only (fixed {n} intentionally NOT marked β unique partition) | OK (prefix form; comma required by design) |
| false |
| SAFE control: must stay INLINE |
(ab){2} | false β | false β | false | SAFE control: unquantified body, unique partition β stays INLINE |
(a+)b | false β | false β | false | T2 latent-crash case; single split β no explosion β inline is fine |
foo(bar)?baz | (added) | false β | false | plain pattern control β stays INLINE |
- if (q === '{' && /^{[0-9]+(,[0-9]*)?\}$/.test(pattern.slice(j))) return true; // brace-bounded still catastrophic when deeply composed: ((a+){3}){4}β¦ / (a*){50}
+ if (q === '{') return /^\{[0-9]+(,[0-9]*)?\}/.test(pattern.slice(j)); // FIX-HANG-5b (30.08): UNANCHORED prefix test β ...
stderr: [grep_files] FIX-HANG-5: worker error (self is not defined) β treating as skipped
stdout: [grep_files] FIX-HANG-5: skipped t1b_worker_redos.txt (worker kill β possible ReDoS)
braceM[0]| no |
consume brace quantifier in body; null-safe (if (braceM)) |
OK β also fixes yesterday's latent null![0] crash class by construction |
| 189 | /^\{[0-9]+,[0-9]*\}/ | no | mark VARIABLE-length {n,m}/{n,} body quantifiers only (fixed {n} intentionally NOT marked β unique partition) | OK (prefix form; comma required by design) |
| false |
| SAFE control: must stay INLINE |
(ab){2} | false β | false β | false | SAFE control: unquantified body, unique partition β stays INLINE |
(a+)b | false β | false β | false | T2 latent-crash case; single split β no explosion β inline is fine |
foo(bar)?baz | (added) | false β | false | plain pattern control β stays INLINE |
- if (q === '{' && /^{[0-9]+(,[0-9]*)?\}$/.test(pattern.slice(j))) return true; // brace-bounded still catastrophic when deeply composed: ((a+){3}){4}β¦ / (a*){50}
+ if (q === '{') return /^\{[0-9]+(,[0-9]*)?\}/.test(pattern.slice(j)); // FIX-HANG-5b (30.08): UNANCHORED prefix test β ...
stderr: [grep_files] FIX-HANG-5: worker error (self is not defined) β treating as skipped
stdout: [grep_files] FIX-HANG-5: skipped t1b_worker_redos.txt (worker kill β possible ReDoS)