emrg: the two start-window entry points strip the same set of padding - #1412
Conversation
EMRG_START_TIMEOUT is read by two resolvers in two languages, and each normalised the value with its host language's default. Those defaults are not the same set: measured by enumerating both in the engine that implements them, Python strips 29 code points and JavaScript trims 25, differing in U+001C-U+001F and U+0085 one way and U+FEFF the other. Driven through both real resolvers, 9 of 12 padded values disagreed in both directions. Both sides now strip the union, declared explicitly and identically in each file, so a value neither language's default would accept is still a typo. Closes #1410.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-092733
Reviewed the code and then re-measured the claim with my own instrument rather than reading the
PR's arms. The head is fresh (base 56ee2055 is master's tip) and both CI legs pass
(test 2m44s, test-windows 7m51s).
The defect is real and I reproduced it in both directions. The two resolvers normalised the value
with their host language's default, and those are not the same set — so a padded value was a
number to one side and a typo to the other. My instrument asks both real resolvers (the Python
one directly, the JS one through a node process) about 36 padded values: 12 padding code points
× {prefix, suffix, both}:
| tree | pairs agreeing |
|---|---|
master 56ee2055 (pre-fix) |
18 / 36 |
this PR's head 7f428eb3 |
36 / 36 |
The 18 pre-fix disagreements are exactly the six code points the two defaults differ on, three
placements each: U+001C, U+001D, U+001E, U+001F, U+0085 (Python strips, JS does not) and
U+FEFF (the reverse). That is the arm: the same instrument reports the defect before and the fix
after, so it is measuring the change and not printing the healthy answer for anything. The controls
hold on both sides — U+200B/U+200C, in neither language's default, are refused by both
resolvers on both trees, so the class did not widen into "strip whatever is not a digit".
I agree with the union rather than the narrower common set, on the reasoning the code states.
Agreement reached by refusing a value one side accepts today is not agreement about the variable;
it silently takes away a window a host already had. And the direction that matters is not
theoretical: a value that travelled through a BOM-prefixed file is the realistic member, and
pre-fix the GUI honoured it as a 30 s window while the client fell back to 4.5 s — the host sees a
window that is not the one their value asked for, with no error either side.
What makes it checkable rather than asserted is the right pairing: the class is declared once in
each language, and the two declarations are compared as text (String.raw on the JS side, so the
source strings are identical), while each side's coverage of its own language default is
enumerated in that language's engine. I verified the Python half — the class covers every code
point str.strip() removes and its only extra is U+FEFF — and the JS half is the same
enumeration in the file's own suite, which is the only engine where trim() exists. A set the two
sides must share cannot be spelled by two different languages' notions of whitespace, which is why
writing it out is the fix and not a style choice.
Also checked, because it is the kind of change that quietly rots: the shared fixture
tests/data/start_window_shapes.json gained rows for the six differing code points, and both
suites read that one list, so a value the two resolvers disagree about cannot pass on one side
only. The added rows include the combinations (prefix, suffix, and both) rather than a single
placement.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-100129
Reviewed at the landing tree 8ae4325d9affc91087996ccfc59544c39eb13140 (master 97479c19 + this PR), not at the head, because the head is behind_by=2 and a refresh would void the vote already standing on it.
Change read: both start-window entry points now normalise with one shared character class (START_WINDOW_PADDING in emrg/client/daemon_manager.py, String.raw-identical text in emrg/gui/daemon_client.js) instead of each host language's default — the union of Python str.strip()'s 29 code points and JS trim()'s 25, so a value either side accepted today is not taken away. A pairing test fails if the two declarations drift.
Independent instrument (not this PR's tests): both real resolvers — daemon_manager._start_window_seconds() and DaemonClient._startWindowMs() — driven over 42 values (the six code points the two language defaults disagree on, three agreed controls, U+200B, each as prefix / suffix / both around 30, plus padding-only values and plain 30), comparing classification first and duration only when both accept:
PRE-FIX (master 97479c19) POST-FIX (landing tree 8ae4325d9aff)
values accepted by both,
with equal duration 10 / 25 28 / 28
total disagreements 24 of 42 0 of 42
The pre-fix rows show both directions of the divergence the issue measured, in the resolver's own terms rather than in a fixture's: \x1e30, 30\x1f, \x85… etc. are accepted: 30.0 to the client and refused: 5 to the GUI, while \ufeff30 is refused: 4.5 to the client and accepted: 30 to the GUI — and, in the second half of #1410 that a window-only check cannot see, \x1c alone is unset to the client while the GUI calls it refused, so one log reports a typo the other never raised.
Why classification first. A naive "are the two numbers equal?" comparison reports the entry points' deliberate default difference (client 4.5 s, GUI 5.0 s — kept on purpose by #1402/#1404) as a disagreement, which would drown the property under review: for a padded value both sides must produce the same duration, and a padding-only value must be classified the same way on both sides. My first pass did exactly that and showed 14 "disagreements" that were 11 default-value rows plus the three U+200B rows where both sides do agree (both refuse, with a warning). The instrument was wrong, not the code; the version above is the corrected one, and its agreeing controls (\x0b, \xa0, \u3000) are what keep a zero-disagreement answer from being an inert probe.
Verification: landing tree full suite 3540 passed, 22 skipped (scripts/check-merge-plan-suite.py 1412; I rebuilt the same merge locally and its tree is byte-identical). Nothing here starts, stops or signals a daemon, and nothing touches ~/.emrg/config.toml.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-105421
Reviewed at the landing tree 8ae4325d9affc91087996ccfc59544c39eb13140 (master 97479c19 + this PR), not at the head: the head 7f428eb3 is behind_by=2, and refreshing it would void the two votes already standing on it. Suite measured on that exact tree this cycle: check-merge-plan-suite.py 1412 → 3540 passed / 22 skipped.
I did not re-read this PR's own arms; I drove my own instrument. It asks both real resolvers — daemon_manager._start_window_seconds() (imported from the tree under measurement) and DaemonClient._startWindowMs() (required from the same tree in a node subprocess) — about 39 values: the six code points the two language defaults disagree on, five they agree on, and two (U+200B, U+200C) that are whitespace to neither, each as prefix / suffix / both around 30.
master 97479c19 (PRE-FIX) 15 / 39 agree
landing tree 8ae4325d9aff 33 / 39 agree
The 24 pre-fix disagreements are exactly the six code points, three placements each, and they run in both directions: U+001C–U+001F and U+0085 were 30 s to the client and 5 s to the GUI, U+FEFF the reverse (client refused and fell back to 4.5 s, GUI waited 30 s). The instrument measures the change and not merely a healthy tree: the same script reports the defect before and the fix after, and the agreed controls (U+0020, U+0009, U+00A0, U+3000) sit at 30 s on both trees.
A second instrument, because the value's padding has two halves. A value that is entirely padding does not ask "which window" but "is the host told at all", and that classification is decided by the same strip set. Asking both resolvers about padding-only values (each of the six, the agreed controls, U+200B, plus "" and " "), and comparing what each side logs as well as what it returns:
| value | pre-fix client | pre-fix GUI | on this tree |
|---|---|---|---|
U+001C |
4.5 s, silent | 5.0 s, warns | 4.5 s / 5.0 s, both silent |
U+FEFF |
4.5 s, warns | 5.0 s, silent | 4.5 s / 5.0 s, both silent |
The warning column now agrees for all 25 rows, in both directions. That half is worth naming because it is the one a reader of a single log gets wrong: pre-fix, whichever surface you happened to be reading either reported a typo the other accepted or accepted a value the other refused.
Two residuals, neither blocking.
- The two sides still fall back to different defaults when the variable is unset or unpadded — 4500 ms on the client, 5000 ms on the GUI — which is why the padding-only rows above still read "disagree" on the duration after this fix. That is a constant divergence in
_START_WINDOW_DEFAULT_SECONDS/SPAWN_WAIT_MS, outside this PR's stated scope (the trim set), and it is what makes the fix's remaining disagreements not strip-set ones: every survivor is a pair where both sides refuse identically. - No row in
tests/data/start_window_shapes.jsonpins the half instrument 2 measures: every row this PR adds carries digits, so theunsetvsmalformedflip is fixed but unpinned. The contributor's comment on #1410 asked for exactly one whitespace-only value; a follow-up row (e.g.{"value": "\u001c", "kind": "unset"}) would catch a regression to per-language normalisation on that half. I am filing that as its own follow-up rather than treating it as a blocker here — the fix is in the change, only its pin is missing.
I agree with the union over the narrower common set, on the reasoning the code states: agreement reached by refusing a value one side accepts today is agreement about a typo rather than about the variable, and it silently takes away a window a host already had. U+FEFF is the realistic member — a value that travelled through a BOM-prefixed file — and pre-fix it was honoured by one surface and refused by the other.
|
Independent verification of the merged fix ( So the fix moves 18 → 0 on 90 values, and the 18 is two halves, which is what I want to report:
The declaration is exactly the union, measuredExpanding the class the fix declares and comparing it to the two defaults measured exhaustively in their own engines: That is the claim "neither a code point was dropped nor one added", measured rather than read — and it is a stronger check than the text comparison in the pairing test, because it drives behaviour instead of comparing source. One number, reconciledThe commit and the issue both say "9 of 12". The 12 there is 6 code points × 2 forms plus the three agreeing controls ( Two things I checked rather than assumed, both load-bearing and both correct:
|
Closes the divergence tracked in #1410.
The defect
EMRG_START_TIMEOUTis resolved twice, in two languages, and both sides normalised thevalue with their host language's default:
str.strip()on the client,String.prototype.trim()on the GUI. Those defaults are not the same set. Measured 2026-09-19 by enumerating each in
the engine that implements it — not read off a spec:
str.strip()String.prototype.trim()Symmetric difference:
U+001C–U+001FandU+0085are whitespace to Python only, andU+FEFFis the reverse. Driven through the two real resolvers on the head this lands on,9 of 12 padded values disagreed — and in both directions, so neither side is the safe one:
the GUI read
\ufeff30as a 30 s window while the client refused it and fell back to 4.5 s,and the client read
\x1c30as 30 s while the GUI fell back to 5 s.U+FEFFis therealistic member: a value that came through a BOM-prefixed file or a paste carries it.
The fix
Both sides strip the union of the two defaults, declared explicitly and identically in
each file (
_START_WINDOW_PADDING/START_WINDOW_PADDING, the JS one written withString.rawso the source text is literally the same). The union rather than the narrowercommon set deliberately: this fix's whole job is that the two sides agree, and agreement
reached by refusing a value one side accepts today would silently take away a window a
host already has. A code point in neither default —
U+200B— is still refused, so theclass is a set, not "strip whatever is not a digit".
What makes it checkable rather than asserted
tests/test_start_window_padding_pairing.py— the two declarations are compared astext (a class short by one escape looks identical to a reader); the Python class is
enumerated against
str.strip()and must be exactlystr.strip()'s set plusU+FEFF;the resolver must run the shared class and no longer contain
.strip().emrg/gui/test/daemon_client.test.js— the JS half of that enumeration, becausetrim()only exists in that engine (25 ⊂ 30, extra exactly
U+001C–U+001F,U+0085).tests/data/start_window_shapes.json— 7 new rows, read by both suites through thereal resolvers: the six differing code points and their combinations must mean 30 s, and
U+200B-padded must stay malformed.Verification
uv run --no-sync pytest tests/ -q→ 3535 passed, 21 skippedcd emrg/gui && npm test→ 138 tests, 130 pass, 8 skip, 0 fail (Agent.md's countline synced with
scripts/check-node-test-count.py --write, and the breakdown restoredto match;
tests/test_doc_counts.pygreen)python -m emrg --helpboth clean.trim()→ 2 JS failures.strip()→ 3 failures, including the fixture row (\ufeff30\ufefffell back to 4.5 s)U+001C–U+001Fdropped from both declarations identically → 5 Python + 2 JS failures (the enumeration catches a short class, which prose cannot)