Skip to content

emrg: the two start-window entry points strip the same set of padding - #1412

Merged
argszero merged 1 commit into
masterfrom
fix/both-entry-points-strip-the-same-padding
Sep 19, 2026
Merged

argszero merged 1 commit into
masterfrom
fix/both-entry-points-strip-the-same-padding

Conversation

@argszero

Copy link
Copy Markdown
Owner

Closes the divergence tracked in #1410.

The defect

EMRG_START_TIMEOUT is resolved twice, in two languages, and both sides normalised the
value 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:

code points stripped
Python str.strip() 29
JavaScript String.prototype.trim() 25

Symmetric difference: U+001C–U+001F and U+0085 are whitespace to Python only, and
U+FEFF is 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 \ufeff30 as a 30 s window while the client refused it and fell back to 4.5 s,
and the client read \x1c30 as 30 s while the GUI fell back to 5 s. U+FEFF is the
realistic 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 with
String.raw so the source text is literally the same). The union rather than the narrower
common 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 the
class 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 as
    text (a class short by one escape looks identical to a reader); the Python class is
    enumerated against str.strip() and must be exactly str.strip()'s set plus U+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, because trim()
    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 the
    real 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 skipped
  • cd emrg/gui && npm test → 138 tests, 130 pass, 8 skip, 0 fail (Agent.md's count
    line synced with scripts/check-node-test-count.py --write, and the breakdown restored
    to match; tests/test_doc_counts.py green)
  • import check + python -m emrg --help both clean
  • Four mutation arms, each restored byte-exactly afterwards — all discriminate:
    • GUI back to .trim() → 2 JS failures
    • client back to .strip() → 3 failures, including the fixture row (\ufeff30\ufeff fell back to 4.5 s)
    • U+001C–U+001F dropped from both declarations identically → 5 Python + 2 JS failures (the enumeration catches a short class, which prose cannot)
    • the two declarations made to drift by one escape → the identity test fails

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 argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

✅ 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 argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

✅ 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 argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

✅ 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.

  1. 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.
  2. No row in tests/data/start_window_shapes.json pins the half instrument 2 measures: every row this PR adds carries digits, so the unset vs malformed flip 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.

@argszero
argszero merged commit 1716a63 into master Sep 19, 2026
2 checks passed
@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Independent verification of the merged fix (1716a63, master dd2a0e6), run before and after on one instrument so the number has a control: both real resolvers (daemon_manager._start_window_seconds() and DaemonClient._startWindowMs) driven over the same 90 values, on the pre-fix head 631bb977 and on the fix. No daemon was started, stopped or signalled — both sides are pure resolvers.

reference set: 30 padding code points, driven through every tree

=== 631bb977 (pre-fix)     90 values: 30 prefix · 30 suffix · 30 alone
    DIVERGED   18 / 90
      U+001C prefix  client=duration(30.0)  gui=malformed
      U+001C alone   client=unset           gui=malformed      … (4 code points × 3 forms, plus 6)
    alone rows: client classes=['malformed','unset']  gui classes=['malformed','unset']

=== dd2a0e6 (fix)
    DIVERGED    0 / 90
    alone rows: client classes=['unset']  gui classes=['unset']

So the fix moves 18 → 0 on 90 values, and the 18 is two halves, which is what I want to report:

  • 12 are the padded pairs (<cp>30 and 30<cp> for each of the six differential code points) — the half the issue text covers.
  • 6 are the alone rows — a value that is entirely padding ("\x1c", "\ufeff", …). These used to flip not between two windows but between the list's own unset and malformed kinds: the client returned its default in silence while the GUI warned, or the reverse. That half was not in the issue text, and it is fixed here for a reason worth stating: stripping the same union makes a wholly-padded value empty on both sides, so both take the if not raw branch. All 30 alone rows are now unset on both — and the shared list still does not carry that shape (its padding rows all have digits; its unset entries are "" and " "). It is resolved by construction, not pinned; if a cheap pin is wanted, {"value": "\u001c", "kind": "unset"} is the row.

The declaration is exactly the union, measured

Expanding the class the fix declares and comparing it to the two defaults measured exhaustively in their own engines:

python default :  29   js default :  25
union          :  30   declared class :  30
declared == union : True
in a default but NOT declared: none
declared but in neither      : none

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, reconciled

The commit and the issue both say "9 of 12". The 12 there is 6 code points × 2 forms plus the three agreeing controls (U+000B, U+00A0, U+3000), i.e. 9 diverging + 3 agreeing. On the 12 padded pairs alone it is 12 / 12, with the controls removed. Worth recording because "9 of 12" reads as "three of them were fine", and the three that were fine are only fine because neither runtime treats them as padding — they are the instrument's control, not survivors of the defect.

Two things I checked rather than assumed, both load-bearing and both correct:

  • the JS regex carries the g flag — without it String.replace strips only the leading run, so "\u008530\u0085" would fall back while "\u001c30" would pass; the list's two prefix-and-suffix rows catch that variant ("\u008530\u0085", "\ufeff30\ufeff"), and my grid catches it per code point;
  • EMRG_START_TIMEOUT is still read exactly twice in the tree (os.environ.get in daemon_manager.py, env[...] in daemon_client.js), so there is no third reader left with a language default of its own — no sibling defect of this shape.

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