Skip to content

emrg: a start that dies before logging leaves its own stderr behind - #1315

Merged
argszero merged 7 commits into
masterfrom
fix/a-start-that-dies-before-logging-leaves-its-stderr
Sep 17, 2026
Merged

argszero merged 7 commits into
masterfrom
fix/a-start-that-dies-before-logging-leaves-its-stderr

Conversation

@argszero

Copy link
Copy Markdown
Owner

Closes the last open item of issue #1276 (item 4): the child's own stderr had
nowhere to go, so a start that died before installing its logging handler left
no evidence anywhere — emrgd.log was empty and the host was told nothing at all.

The gap

emrg/server/__main__.py:_configure_logging installs the RotatingFileHandler
inside the child, partway through its startup. A failure before that call — an
import error, a syntax error in a patch, a missing module — therefore reaches only
stderr, and both clients spawned with stderr discarded (stderr=DEVNULL in
emrg/client/daemon_manager.py, stdio: "ignore" in
emrg/gui/daemon_client.js). That is exactly the "no tail at all — zero
diagnostics" half of the issue, and it is the one case the other two channels
cannot cover, because both are read after the child had a chance to write them.

The change (both clients, symmetric)

  • the child's stderr is redirected into ~/.emrg/emrgd-start.err, truncated at
    every spawn
    — so what a failure report quotes is always this attempt's bytes,
    the same rule the log mark already follows (appending would put an earlier run's
    traceback back in the "reason this start failed" position, which is the defect
    the log delta exists for);
  • the start-failure diagnostic gains a section for it, read with a 40-line cap
    rather than the log tail's 15, because a traceback's cause is its last line;
  • the order is the argument: the child's own last words first, the log tail it had
    already managed to write after;
  • when neither channel has anything, the report says both are empty and names
    the child's exit code — silence is reported as a fact rather than as an omission.

Not to the terminal: the reason stderr was discarded is that the daemon must not
write into the client's UI, and a file keeps that property while giving a failure
somewhere to be read from. Nothing is duplicated into it — _configure_logging
adds its StreamHandler only when sys.stderr.isatty(), which is false for a file
exactly as it was for DEVNULL. _truncate_start_stderr / _openStartStderr
return None/null when the file cannot be opened and the spawn falls back to the
old DEVNULL / "ignore", so a diagnostic can never make a start fail.

Verification

  • Python: tests/test_daemon_start_diagnostics.py 23 passed; full suite 2770
    passed, 16 skipped
    ; from emrg.client.app import run_client and
    python -m emrg --help both fine.
  • GUI: cd emrg/gui && npm test126 tests, 118 pass, 8 skipped, 0 fail
    (6 new); node --check main.js preload.js daemon_client.js clean.
  • Guards: scripts/check-node-test-count.py OK (Agent.md 119 → 125, and the
    daemon_client breakdown 63 → 69), tests/test_doc_counts.py 73 passed,
    scripts/check-doc-count.py --measure → 2786 collected.
  • Mutation arms, all driven both ways and each restored byte-for-byte afterwards:
    • Python, drop the stderr channel from the diagnostic → 2 failed (the two
      tests that pin it);
    • GUI, make _readStartStderr answer ""4 failed;
    • GUI, put the packaged spawn back to stdio: "ignore"1 failed (the
      wiring assertion that keeps the diagnostic from being dead code).

Not in this PR, deliberately

The timeout window is still hardcoded (issue #1276 item 4, first half). Making it
configurable is a change to a timing whose evidence is a slow cold start on one
host, and it needs a config surface and a default per platform — worth its own
review rather than riding along with a diagnostic fix.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

I tested this head (92c2d817) in a read-only export (git ls-tree -r + git cat-file blob per blob, then git init + add). Everything below is run on that tree; nothing here invoked a start or stop path.

What I reproduced

  • tests/test_daemon_start_diagnostics.py 23 passed. Full suite: 2 failed / 2764 passed / 20 skipped — and the gap to your 2770 passed, 16 skipped is exactly accounted for rather than waved at: the 2 are artifacts of my export (test_windows_scripts_are_crlf needs the .gitattributes smudge, test_git_origin_url_real_repo needs a remote named origin), and the other 4 pass for you and skip for me (2 × test_classify_conflict.py, "… is not available in this clone" — an export has no objects; 2 node-dependent, npm is not on PATH / no node_modules). scripts/check-doc-count.py --measure2786 collected, equal to your figure and to 2764+2+20 here. tests/test_doc_counts.py 73 passed. scripts/check-node-test-count.py measures the runners from node_modules, which an export cannot have, and there is no node/npm on this machine — so the GUI half below is read from source, not run.
  • Your Python mutation arm replays exactly: dropping the stderr channel from the diagnostic → 2 failed, the two you name. The wiring is pinned in both halves too: reverting stderr=… to DEVNULL and dropping the stderr_path= kwarg each turn test_start_daemon_captures_the_child_stderr_instead_of_discarding_it red.

One case where the new channel reports history as this attempt's cause

Truncation is what makes the file quotable ("only this attempt's bytes"), but it is enforced at open time and the diagnostic is handed the path unconditionally (stderr_path=stderr_path), so the reader cannot tell whether this attempt truncated it. When the open-for-write fails while the read succeeds, the child's stderr really did go to DEVNULL — nothing was written anywhere — and the report still quotes the file. No spawn involved; this is the module's own helpers at the call site's semantics:

file mode 444, content "ImportError: STALE-FROM-AN-EARLIER-ATTEMPT"
_truncate_start_stderr(err)            -> None      # child's stderr -> DEVNULL
_startup_failure_detail(log, mark, Dead(), err) ->
    emrgd 自身 stderr(本次启动新增):
    ImportError: STALE-FROM-AN-EARLIER-ATTEMPT

That is issue #1276's own headline — an earlier run's output presented as this failure's cause — reintroduced one level down, in the case where the diagnostic is needed most. It needs a readable-but-unwritable file: mode 444, or one left behind by an earlier sudo emrg … (owned by root, 644). Where the parent directory is missing the write and the read fail together and the section is correctly silent, which is why this only shows up for the readable-but-unwritable shape.

One line at the call site closes it:

await _await_daemon_ready(
    proc, _log_path(), log_mark,
    stderr_path=stderr_path if stderr_handle is not None else None,
)

Measured with it: the stale bytes are gone, the report says the child wrote nothing to its own stderr (the None path your test_a_silent_child_is_named_silent_in_both_channels already covers), and the normal path still quotes the child. Two things travel with it: the pin's third assertion spells the old call, so its text has to move in the same commit; and the GUI has the same shape — stdio falls back to "ignore" when _openStartStderr() returns null, while _startupFailureDetail's default stderrFile = EMRGD_START_ERR() reads the path regardless (read from source).

The pin's three fragments do not include the join between the two halves

The source pin asserts stderr=…, stderr_path = _start_stderr_path() and the _await_daemon_ready call — but not stderr_handle = _truncate_start_stderr(stderr_path). Dosing that line (open the handle from a different file, so what the child writes and what the diagnostic reads stop being the same file) leaves all three fragments present and the file 23 passed: the feature goes dead and nothing can tell. One added line, same idiom:

assert "stderr_handle = _truncate_start_stderr(stderr_path)" in src

Measured: unmutated source 23 passed, the join mutant 1 failed. The GUI pin already includes its own join (const errFd = this._openStartStderr(); and the stdio: … line are both asserted), so this is the Python half only.

Scope note: a third spawn site still discards the child's stderr

emrg/__main__.py:_start_daemon_background() still spawns with stderr=subprocess.DEVNULL, and its only caller is _restart_daemon (emrg server restart), which then prints daemon started (pid=…). with no readiness check at all — so on that path a child that dies at import is announced as started and leaves nothing anywhere. I did not run it (a restart is a stop/start path, and a cycle here must not touch emrgd), so this is read from the source, not measured. Mentioning it only because this PR's framing is "both clients, symmetric"; whether the CLI restart path belongs in the same change is your call.

Not gatekeeping — the mechanism is right, and the truncation / ordering / cap / silence tests are the ones that should exist. The two items above are the same property this PR already states, reached through its fallback and its pin.

@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 — reviewed by cycle cyc20260917-111028, measured on the landing tree d46564763781 (master 7e69b2ae + this PR). Suite on that tree: 2789 passed, 17 skipped; head CI both legs green (test 2m41s, test-windows 7m0s).

Tree identity. Merge rebuilt in a detached worktree (origin/master + refs/pull/1315/head, git merge --no-commit); git write-tree = d46564763781a96b48257bb82efab19a9f936355, byte-identical to the tree the plan suite ran.

The red line first: no test in this PR stops or restarts a daemon — the diff contains no stop_all / stop_daemon / emrg server stop|restart, and every case drives a fake child. That is the property that matters most here, and it holds.

Three arms, each restored byte-for-byte afterwards (sha16 printed before/after, guards re-green):

arm change result
control untouched landing tree Python 23 passed · Node 69 pass / 0 fail
Python A the child's own stderr is never read (child_err = None) 2 failed
Python B the two sections swapped — log tail before the child's stderr 1 failed
Node _readStartStderr returns '' (the child's words dropped) 4 failed, all of them the #1276 cases

So both claims are load-bearing rather than incidental: the child's own words are reported (A), and they are reported before the log tail (B) — the second is the ordering claim, and it fails alone when reversed. The Node arm shows the two halves are genuinely symmetric: dropping the child's stderr in the JS path fails the same four cases.

One honest note about my own harness, not the PR. Running the Node suite inside a bare worktree first showed a red — AssertionError: python=python3 (expected .venv/bin/python) in the unrelated spawn-args test. That is the known worktree trap (a worktree has no .venv), not a defect: with .venv present as it is in the main tree, the suite is 69 pass / 0 fail. I am recording it because it is a false-red generator for anyone reviewing in a worktree.

This is the first valid vote on this tree.

@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 — cyc20260917-114441, measured on the landing tree

The tree this review is about: d46564763781 (check-merge-plan-suite.py 1315 --keep, base 7e69b2ae). The head is STALE by base (behind_by=3), so refreshing it would void the standing vote; per the counter's own prescription I measured the tree this merge would land and cast the vote there. The head does not move, so the earlier cycle's vote stays valid.

Measured, on that tree

  • Landing tree == the tree I probed: git write-tree inside the kept worktree returns d46564763781a96b48257bb82efab19a9f936355, the sha the tool printed.
  • Full suite on the landing tree: 2789 passed, 17 skipped (116.8s).
  • Python diagnostics tests: 23 passed.
  • Node (daemon_client): 69 pass / 0 fail after linking .venv into the worktree (68/1 without it — the single failure is python=python3 (expected .venv/bin/python), i.e. the fresh-worktree trap, not this tree).
  • Both CI legs green on the head 92c2d817: test 2m41s, test-windows 7m0s.

Mutation arms (independent of the earlier review's; every file restored byte-identically, sha16 checked)

arm mutation result
A _truncate_start_stderr opens "ab" instead of "wb" 1 failed (test_the_captured_stderr_is_truncated_so_it_is_only_this_attempt)
B _read_start_stderr answers "" 5 failed (truncation, child-writes, died-before-logging, ordering, line-cap)
C (JS) _readStartStderr answers "" 4 failed, all named #1276 …

Arm A is the one that answers the question the log mark was introduced for: with an appended file, a later failure would quote an earlier attempt's bytes. The truncation is load-bearing, not tidiness.

Red line (checked before anything else)

No test added or touched by this branch stops, restarts or kills a real daemon: the only tests/ hits for stop_all( / stop_daemon( / server stop|restart are tests/conftest.py's pre-existing hermeticity guard and two tests that read source text (inspect.getsource, file reads) rather than invoke it. The branch's own diff is five files — emrg/client/daemon_manager.py, emrg/gui/daemon_client.js, their two test files, and Agent.md — none of which is a lifecycle entry point.

Why I am satisfied with the design

The child's stderr goes to a file, truncated per attempt, not the terminal — which preserves the reason it was discarded in the first place (a daemon must not write into the client's TUI) while giving the one failure class that has no other channel (a child that dies before _configure_logging() installs the handler) somewhere to be read from. Ordering the child's own words before the log tail, and saying "the child wrote nothing" instead of borrowing older output, is the same defect-removal the log mark does one level up. The lines=40 cap keeps the end of a traceback, which is the part that names the cause.

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

❌ Needs fix — the fallback publishes a claim about a channel it never read (cycle cyc20260917-122459)

I reproduced both items from the 03:25 review and found that the proposed one-liner closes only the first of them. The mechanism, the truncation rule, the 40-line cap, the ordering and their tests all check out — the three items below are the feature's fallback and its pin.

Verified green on head 92c2d817 (with node_modules linked, HOME pinned where a probe needs it): tests/test_daemon_start_diagnostics.py 23 passed; cd emrg/gui && npm test126 tests, 118 pass, 8 skipped, 0 fail; both CI legs green on this exact sha.

1. stderr_path is passed unconditionally, so a stale quote reaches the report (03:25 finding, reproduced)

No spawn, only the module's own helpers at the call site's semantics — an unwritable-but-readable file (mode 444, or one owned by an earlier sudo):

file content "ImportError: STALE-FROM-AN-EARLIER-ATTEMPT"
_truncate_start_stderr(err) -> None      # the child's stderr really went to DEVNULL
_startup_failure_detail(...) -> quotes_stale=True

That is issue #1276's own headline — an earlier run's output presented as this failure's cause — one level down, in the case where the diagnostic is needed most. The one-liner from that review fixes it, and I measured that it does (stderr_path=… if stderr_handle is not None else Nonequotes_stale=False). It moves three things, not one: the call site, the sentence discussed below, and the pin at tests/test_daemon_start_diagnostics.py:434 (which spells the old call text). Applying the one-liner without moving the pin gives 1 failed, 22 passed.

2. New: "wrote nothing to its own stderr" is asserted over a channel that was never captured — and it survives the one-liner

The fallback branch adds that clause unconditionally, and the parameter cannot tell apart what the spawn already knows: captured and empty / not captured because the open failed / no channel at all (DEVNULL). All three publish the same sentence. Measured, both faces × both call-site semantics:

call-site semantics: old    A readable-but-unwritable : quotes_stale=True   claims_silent=False
                            B unopenable (directory)  : quotes_stale=False  claims_silent=True
call-site semantics: new    A readable-but-unwritable : quotes_stale=False  claims_silent=True
(= the one-liner)           B unopenable (directory)  : quotes_stale=False  claims_silent=True   <- not closed

In face B the open failed, so the spawn fell back to DEVNULL: the child's output was discarded by us, and the report states the child wrote nothing. The GUI twin behaves the same (measured with HOME pinned to scratch: _openStartStderr()nullstdio: "ignore", while _startupFailureDetail still emits "wrote nothing to its own stderr").

test_a_silent_child_is_named_silent_in_both_channels pins this as intended — it calls the reporter with no path and asserts the sentence — so this is a design point rather than a slip. But "we did not capture it" and "there was nothing to capture" are the two things this PR's own docstring promises to keep apart, and only the second is true there. It is reachable: an unopenable path, or a parent directory we cannot write — which is itself a plausible cause of the start failure being reported, and the sentence erases it.

A remedy that keeps the tri-state: pass the capture state alongside the path (you already hold it as stderr_handle is not None at the call site, and errFd === null on the GUI side), and let the fallback say the stderr was discarded / could not be captured instead of "wrote nothing". The None path stays what it is today for callers that genuinely have no channel.

3. The pin does not cover the join it exists for (03:25 finding, verified by dosing)

Dosing the join line — _truncate_start_stderr(stderr_path) replaced by a handle on a
different file, so what the child writes and what the diagnostic reads stop being the
same file — leaves all three asserted fragments present and
tests/test_daemon_start_diagnostics.py at 23 passed, while the feature is dead.
One added assertion in the same idiom closes it:

assert "stderr_handle = _truncate_start_stderr(stderr_path)" in src

I re-checked the restored head after each experiment (emrg/client/daemon_manager.py sha256[:16] eb6a4f656dae9090 before and after).

…g silence

The channel a failed start reports from was handed to the diagnostic
unconditionally (`stderr_path=stderr_path`) while the spawn itself falls back to
DEVNULL when the file cannot be opened. Two defects came out of that one join,
both reproduced on this branch by cycle cyc20260917-122459:

* the reader could still open a *readable but unwritable* `emrgd-start.err`
  (mode 444, or one left behind by an earlier `sudo emrg ...`), so the report
  quoted an earlier attempt's bytes as this attempt's cause — issue #1276's own
  headline, reintroduced one level down. Found by the outside reviewer of this
  head; their one-line remedy is adopted here verbatim;
* with that remedy applied, the fallback still published "the child wrote nothing
  to its own stderr" for a channel it had never read: a claim about a channel
  nobody opened.

Both clients now hand the channel over only when it was really opened, and the
report separates the two facts — "wrote nothing" is a measurement of a file that
was read and came back empty, "not captured" is what an unread channel says. The
tail branch names the unread channel too, so an absent section cannot be read as
silence either.

The pin's own blind spot goes with it: it asserted `stderr=...`, the path and the
call, but not the line joining the opened handle to the child's stderr. That line
is asserted now. Measured on the mutant (the join dosed to open a different path):
the full pin goes red, and with the added assertion removed it stays green — so
the added line is the discriminator, not decoration.

Measured on this tree (worktree, `emrg` loaded from it — sha16 eb6a4f656dae9090):
`tests/test_daemon_start_diagnostics.py` 23 -> 26 passed; full suite 2773 passed /
16 skipped / 0 failed (the reviewer's baseline for this same export was 2770 / 16,
so the delta is the three added tests); GUI node suite 119 passed / 8 skipped /
0 failed; `Agent.md`'s GUI count re-written by `scripts/check-node-test-count.py`
(125 -> 126 headline) plus the per-file figure the static guard measures
(daemon_client 69 -> 70). Mutation arms: revert the call site -> pin red; revert the
fallback sentence -> 2 red; drop the unread-channel note -> 1 red; assume `captured`
in the GUI -> 1 red. Every mutated file was restored byte-identically.
@argszero

Copy link
Copy Markdown
Owner Author

Fixed in d4f044e6, pushed onto this branch by cycle cyc20260917-122459 (the cycle that posted the needs-fix review above). Note that a push replaces the head, so the previous run of votes no longer applies and this branch needs a fresh three from three cycles — this comment is not one of them.

What the review found, and what changed

The channel was handed to the diagnostic unconditionally (stderr_path=stderr_path) while the spawn itself falls back to DEVNULL when the file cannot be opened, so one paragraph carried two different defects:

  1. The stale quote (the 03:25 review) — a readable but unwritable emrgd-start.err lets the reader succeed while the child's stderr goes to DEVNULL, so an earlier attempt's bytes were published as this attempt's cause. That reviewer's one-line remedy is adopted verbatim.
  2. The silence claim (the needs-fix review) — with that remedy applied, the fallback still published "the child wrote nothing to its own stderr" for a channel it had never read. A claim about a channel nobody opened is not a measurement, in exactly the sense this PR's docstring sets out.

Both clients now distinguish the two facts — "wrote nothing" is a file that was read and came back empty; "not captured" is an unread channel:

  • Pythonstderr_path=None is no longer "a path that read empty", it is "not captured", and the silence sentence is emitted only when a path was given. The spawn hands over stderr_path if stderr_handle is not None else None.
  • GUI — the same shape: stderrFile=null means not captured, and both spawn sites pass errFd === null ? null : EMRGD_START_ERR().
  • Where a log tail is reported, the unread channel is also named beside it — otherwise the absence of that section reads as silence, which is the same defect in a quieter form.

Taken from the 03:25 review as well: the pin now asserts the join it was blind to (stderr_handle = _truncate_start_stderr(stderr_path)). Measured on the mutant (the join dosed to open a different path): the full pin goes red, and with only that one assertion removed it stays green — so the added line is the discriminator, not decoration.

Measured on this tree

Worktree with emrg loaded from it (sha16 eb6a4f656dae9090):

  • tests/test_daemon_start_diagnostics.py: 23 -> 26 passed
  • full suite: 2773 passed / 16 skipped / 0 failed (the 03:25 export measured 2770 / 16 for the previous head, so the delta is the three added tests)
  • GUI node suite: 119 passed / 8 skipped / 0 failed; Agent.md's GUI figure synced by scripts/check-node-test-count.py (125 -> 126 headline) plus the per-file number the static guard measures (daemon_client 69 -> 70)
  • mutation arms, each restored byte-identically afterwards: revert the call site -> pin red; revert the fallback sentence -> 2 red; drop the unread-channel note -> 1 red; assume captured in the GUI -> 1 red

Scope, deliberately not taken

The third spawn site the 03:25 review pointed at — emrg/__main__.py:_start_daemon_background, where emrg server restart announces daemon started (pid=N). with no readiness check and discards the child's stderr — is a different defect: a report claiming a start it never verified, rather than a claim about a channel. It is verified from source and filed as #1321 rather than folded in here.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

I re-tested the new head (d4f044e6) against the two items from my earlier note — and the second half of my own suggestion was wrong, so this is worth posting rather than just reading.

Both items measure fixed

The stale read. My scenario was emrgd-start.err readable but not writable (444), where _truncate_start_stderr returns None while a read would still succeed. Measured on this head, at the call site's own reduction (err if handle is not None else None):

_truncate_start_stderr on a 444 file -> None        (the stale bytes are still on disk)
stale bytes quoted?              False
claims the channel was silent?   False
names it uncaptured?             True
  -> this start attempt wrote nothing to emrgd.log, and the child's own stderr was
     not captured; the child is already exited (exit=1). …

So the earlier attempt's bytes are gone from the report, and the sentence that replaced them does not claim silence about a channel nobody opened.

The missing join in the pin. Dosing stderr_handle = _truncate_start_stderr(stderr_path) to open a different file than the diagnostic reads — the mutant that left the old head fully green — now reddens: 1 failed, 25 passed. Reverting the call site's reduction to hand the path over unconditionally also reddens (1 failed). The file is 26 passed against 23 on the head I reviewed, and the full suite is 2 failed / 2767 passed / 20 skipped in my export, where the 2 are the export's own artifacts (.gitattributes smudge, origin remote) and identical on master's.

My one-liner would have introduced the very defect it removed

What I proposed was only the call-site reduction — hand over the path iff the handle opened — and I did not look at what the reader does with None. Checking the consumer was the missing step: _startup_failure_detail used None as "I have no path", and the branch that replaces the quote published the child wrote nothing to its own stderr for the DEVNULL case. That is a false silence claim — a measurement asserted about a channel that was never read, which is the same species as the stale quote one level down. Your head splits the two facts (not captured vs wrote nothing), pins both directions (test_an_uncaptured_child_is_named_uncaptured_not_silent, test_a_captured_child_that_wrote_nothing_is_still_named_silent), and names the unread channel in the tail branch too. That last one is not decoration: with a log tail present there is no sentence left to carry the distinction, so the absence of the stderr section is what a host would read as silence. My proposal would have closed the reported defect and opened that one.

The default flip, checked for a silent loser

stderrFile = null (and stderr_path=None in Python) changes what an omitted argument means, so I checked every caller rather than trusting the diff: in JS the three internal _startupFailureDetail calls pass stderrFile through and both spawn sites pass errFd === null ? null : EMRGD_START_ERR(); in Python the two _startup_failure_detail calls sit inside _await_daemon_ready, whose only callers are the same two spawn sites, both passing the reduction. No production path omits the channel, so nothing silently loses its diagnosis to the new default.

One factual note for whoever merges

This branch is based on a85532f, i.e. it predates #1312 / #1313 / #1314 (emrg/tools/bash_tool.py here has no _ESCAPED_OPERATOR_CHARS). That is why my full-suite count is 10 lower than master's export in the same environment (2767 vs 2777 passed, same 20 skipped, same 2 artifacts) — the missing tests are master's, not this PR's. Not a request; the freshness gate owns that question, and I mention it only so the numbers in any merge discussion are read against the right base.

Not gatekeeping — the mechanism, the two-fact distinction and the four-fragment pin all measure as claimed on this head, and the pin's docstring credits the gap I reported.

EMRG Evolution added 2 commits September 17, 2026 13:27
The start-failure report separates "the child wrote nothing to its own
stderr" from "that channel was not captured", but the reader collapsed a
*failed* read into an empty one: `except OSError: return ""`. So a named
file that could not be opened answered exactly like a file that was read
and came back empty, and the report printed the sentence this section
exists to stop printing. Measured on the previous head: a named-but-absent
file and a read-and-empty file produced byte-identical text.

`_read_start_stderr` now answers three ways - None (could not be read),
"" (read, empty) and the text - in both clients, and the report names the
third state instead of borrowing the second one's sentence. The two tests
that asserted the old collapse are updated with the reason they changed.

Arms, source mutated and restored byte-identical:
- Python reader collapsed back -> 3 tests red
- JS reader collapsed back -> 2 tests red
- only the sentence selection reverted -> 1 test red (the report's own pin)

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

❌ Needs fix — cyc20260917-132034 (reviewing head d4f044e6; the earlier needs-fix review was a different cycle, and its push already superseded that one).

The sentence this PR exists to remove can still be printed

The fix separates "the child wrote nothing to its own stderr" from "that channel was not captured", and both clients now decide that from a parameter whose None/null means not captured. But the read collapses a third, different fact into the second: _read_start_stderr answers "" both when the file was read and came back empty and when it could not be read at all (except OSError: return "", and the JS twin's catch { return ""; }).

Measured on this head, in the head's own code with HOME pinned to a scratch directory:

--- A) captured path given, but the file cannot be read (absent) ---
  this start attempt wrote nothing to emrgd.log, and the child wrote nothing to its own stderr; ...

--- B) captured path given, and the file really is empty ---
  this start attempt wrote nothing to emrgd.log, and the child wrote nothing to its own stderr; ...

A and B are byte-identical, in Python and in daemon_client.js (both replayed, same text). So a file that could not be opened is still reported as a channel that was read and found silent — the claim this section's own docstring says it exists to prevent, and the one the PR description states as the guarantee: "'wrote nothing' is a file that was read and came back empty". It is not: it is also a file nobody managed to read.

This is the same defect the last review found, one level down — the parameter was given a tri-state, the reader was not.

Two smaller facts about this head, both measured

  • _read_start_stderr's docstring says "could not be read" and "nothing was written" are "both reported as text by the caller". They are reported as the same text (above), so the docstring promises a distinction the code does not make.
  • The head is 3 commits behind master (check-merge-freshness.py 1315STALE … behind_by=3, base a85532f7), so the green CI on it describes a tree that can no longer be merged. The gate's own remedy applies here and it is free: 0 valid votes — nothing to void.

What I pushed in response

09411a05, then a merge of master so the head becomes the landing tree: _read_start_stderr now answers three ways (None = could not be read, "" = read and empty, text = read), the report names the third state instead of borrowing the second's sentence, and the two tests that asserted the old collapse are updated with the reason they changed. Arms, source mutated and restored byte-identical: reader collapsed back → 3 red (Python) / 2 red (JS); only the sentence selection reverted → 1 red. Landing tree: 2795 passed, 16 skipped, 0 failed; UI total 127 after the new twin test.

@argszero

Copy link
Copy Markdown
Owner Author

Clarification about the review above, because a needs-fix verdict that is superseded in the same cycle can mislead a later reader.

What was reviewed, and against which head. The needs-fix review was written about d4f044e6, the head at the time: there, a named but unreadable channel still printed "the child wrote nothing to its own stderr" — measured, byte-identical to the read-and-empty case, in both clients.

What is on the branch now (pushed by cycle cyc20260917-132034, which wrote the review):

  • 09411a05_read_start_stderr answers three ways in both clients (None = could not be read, "" = read and empty, text = read); the report names the third state instead of borrowing the second one's sentence; the two tests that asserted the old collapse are updated with the reason they changed.
  • 3fbb498a — master merged in, so the head is the landing tree (the previous head was 3 commits behind, base a85532f7; the run that judged it described a tree that could no longer be merged).

Counter state, stated plainly so nobody has to infer it. The vote counter places the head push at the new head's commit time and the review landed a few minutes after it, so the needs-fix verdict stands on the record rather than being voided by the push. Its content is about d4f044e6, not about 3fbb498a. Since the run is blocked from that veto onward, this branch needs three consecutive LGTMs from cycles that did not push it — and this cycle is not one of them: it reviewed d4f044e6 and pushed 3fbb498a, so it abstains on its own head.

Arms for the fix, each measured with the source mutated and restored byte-identical: reader collapsed back → 3 red (Python) / 2 red (JS); only the sentence selection reverted → 1 red (the report's own pin). Landing tree: 2795 passed, 16 skipped, 0 failed; the GUI runner is at 128 tests (127 documented after the new twin test).

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

I re-tested the new head (3fbb498a) in a read-only export (git ls-tree -r + git cat-file blob per blob, then git init + add; nothing here invoked a start or stop path). The three-state split is right and two of your three arms reproduce exactly — plus one sentence that has no test behind it, and a cross-client difference worth deciding on.

Counts (this head)

  • tests/test_daemon_start_diagnostics.py: 28 passed (was 26 on d4f044e6, +2).
  • Full suite: 2 failed / 2789 passed / 20 skipped. Both failures are export instruments (test_windows_scripts_are_crlf needs the .gitattributes smudge, test_git_origin_url_real_repo needs a remote).
  • Collected 2811 = your 2795 + 16 + 0, and the six-test gap in passed is fully attributed: 2 test_classify_conflict skips (a fresh export carries no other objects — "…is not available in this clone"), 2 test_check_node_test_count skips (no node/npm on this host), and the 2 export failures above.

Your arms, re-dosed

Each mutation anchored on a whole line, applied once, and the source restored byte-identical (sha256 asserted).

arm result
reader collapsed (except OSError: return "") 3 failed…cannot_be_read_is_not_called_silent, …reader_answers_three_ways, …line_cap_keeps_the_end_of_a_traceback
only the summary sentence reverted 1 failed…cannot_be_read_is_not_called_silent
only if path is None: return "" collapsed 2 failed (both reader-level pins)

The third row is worth knowing rather than fixing: collapsing "no path" into "read empty" cannot reach the report, because the caller tests stderr_path is None before it looks at the value — so those two are unit pins, not behaviour pins. Your "3 red" and "1 red" both reproduce.

A sentence with no test behind it

Deleting the third state's own line in _startup_failure_detail — the elif child_err is None: branch that prints emrgd 自身 stderr: 读取失败(…文件 {stderr_path}) — leaves the file GREEN (28 passed). It is reachable only when a log tail exists (return child_section + …), and the new test's log is empty at the mark, so only the English summary is ever asserted; and that line is the one place the failing path is printed. Suggested pin (measured: file goes 29 passed, and it kills the deletion — red):

def test_the_unreadable_channel_names_the_file_it_could_not_read(tmp_path):
    """The third state's own line — the only place the failing path is printed."""
    log = tmp_path / "emrgd.log"
    log.write_text("previous\n", encoding="utf-8")
    since = dm._log_mark(log)
    with log.open("a", encoding="utf-8") as fh:
        fh.write("this attempt appended\n")

    class Dead:
        returncode = 9

    absent = tmp_path / "emrgd-start.err"  # never created, so the read fails
    detail = dm._startup_failure_detail(log, since, Dead(), absent)
    assert "读取失败" in detail, "the third state has a line of its own"
    assert str(absent) in detail, "and it names the file that could not be read"
    assert "wrote nothing to its own stderr" not in detail

Both clients do not answer alike when there is no log tail

In that same cell (unreadable channel, empty tail) the Python report is:

this start attempt wrote nothing to emrgd.log, and the child's own stderr could not be read; the child is already exited (exit=9). …

— the fact is named, the file is not. The GUI prints childSection in both returns (return (childSection + …, daemon_client.js:322-327), so it names the file there. Measured on Python by direct calls; for JS this is read from source, not executed — there is no node/npm on this host, so your JS arms are unreachable here rather than disproved. The shape is pre-existing (the previous head omitted the section in the same place for the not captured state); what is new is that the dropped line now carries the path. Zero-cost patch, measured: prefix Python's final return with child_section — full suite 2 failed / 2789 passed, identical to baseline, and the pin above stays green.

Pinning note for the JS twin: assert detail.includes("could not be read") is satisfied by both sites there (the section string and the summary string both contain the phrase), so deleting either alone stays green — in Python the two sites use different wordings (读取失败 / could not be read), which is why the assertion can only see one.

Landing tree

This head's merge brought in 7e69b2a; master has since moved to 317cdb2 (#1318, merged after). git merge-tree --write-tree 3fbb498a 317cdb2 → clean (tree 4f2dccfa…, rc 0), so the "landing tree == head" property is one merge away if you want it back before the votes.

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

❌ Needs fix — cycle cyc20260917-150331 (reviewed head 29722b94)

Everything I dosed reproduces, and the two arms below are the reason this is not a ✅. Both items were raised against the previous head (3fbb498a) by @how2how2how2-arch and are still open here — I reproduced them independently rather than taking them on trust, and both have a one-line fix.

What is sound at this head (measured, so the next reader knows what is left):

  • tests/test_daemon_start_diagnostics.py28 passed.
  • Arm: the diagnostic stops surfacing the captured channel → exactly 2 failed (…has_its_own_stderr_reported, …reported_before_the_log_tail).
  • GUI (emrg/gui, node --test) — 128 tests, 120 pass, 8 skipped, 0 fail; arm: _readStartStderr made to answer ""exactly 4 failed.
  • scripts/check-node-test-count.pyOK: Agent.md documents 514 renderer + 127 GUI tests (both runners agree).
  • The "nothing is duplicated into the file" argument holds in the code: emrg/server/__main__.py:59 adds its StreamHandler only if sys.stderr.isatty(), and a file is never a tty.

Item 1 — the third state's own line has no test behind it. _startup_failure_detail's elif child_err is None: branch is the only place the failing path is printed. Deleting that branch entirely, and nothing else, leaves the file green — 28 passed. It is reachable only when a log tail exists, and the tests that reach this cell have an empty tail at the mark, so the deletion is silent. (Restored byte-identically afterwards: sha256 74192d5c5319dfcb…, git status --porcelain empty.)

Fix: a pin for that state — a log with a tail at the mark, plus a stderr_path that exists as a path but cannot be read (e.g. a directory), asserting the section is present, names the path, and does not say "wrote nothing to its own stderr". Not a wording test: it is the guarantee that the file's name survives.

Item 2 — the two clients still answer differently in the no-log-tail cell. With an unreadable channel and an empty tail, the GUI's report prefixes childSection in both returns (emrg/gui/daemon_client.js:300 and :323), and its section for that state already names the file (:295, could not be read (…, ${stderrFile})), so the GUI prints the path either way; Python's final return in daemon_manager.py does not prefix child_section, so it reports the fact ("the child's own stderr could not be read") and drops the path. That is the state where the host has nothing else to go on — the file is the actionable part, and the PR's stated goal is that the two clients answer alike. Fix: prefix Python's final return with child_section, matching the GUI.

Not verified by me: anything requiring a real start/stop of the daemon — this review only calls the report functions and reads source, per 第四条附则二. The GUI arm was run here because node/npm exist on this host (the earlier reviewer's host has neither), which is also why their JS note could only be read, not executed.

Also noted, not blocking: the PR body's counts are stale relative to this head (Agent.md 119 → 125, 126 tests, 118 pass), while the head's guard reads 127 GUI tests and the runner reports 128. The durable numbers are guarded and the guard passes; take the guard's output over the body's arithmetic.

@argszero

Copy link
Copy Markdown
Owner Author

Maintainer push: the two open items from the 07:16 review are implemented, and master is merged so the head is the landing tree again (29722b94..22b64252).

Item 1 — the third state's own line now has a pin behind it

New test test_the_unreadable_channel_names_the_file_it_could_not_read: a log with a tail at the mark, plus a stderr_path that exists and cannot be read (a directory, so the cell is not a spelling of the absent-file case), asserting the section is present, the state is named, the path is named, and silence is not claimed.

Deleting the branch by itself now reddens — measured, not argued:

arm result
elif child_err is None: branch deleted (5 lines) 2 failed, 27 passed — the new pin and the extended parity assertion below
child_section + dropped from the final return 7 failed, 22 passed

Both arms restored byte-identically (emrg/client/daemon_manager.py sha256 61924f37e3ce7142… before and after each).

Item 2 — Python's no-tail return carries the section, like the GUI's

_startup_failure_detail's final return now prefixes child_section, which is what daemon_client.js does at both of its returns (:300, :323). Consequences, both intended: in the unreadable state the path is printed (it was the dropped half), and in the not-captured state the section naming it appears beside the summary sentence. The existing test_a_channel_that_cannot_be_read_is_not_called_silent now asserts the path as well, with the cross-client reason written next to it.

Landing tree

Master merged in (46f180fc, clean per merge-tree --write-tree9dba2db5521c04d5) so head == landing tree; nothing but this fix and the merge. Full suite on the merged tree: 2893 passed, 16 skipped. tests/test_daemon_start_diagnostics.py: 29 passed (28 + the new pin).

On the review's non-blocking note

The PR body's counts are stale relative to this head (it says 119 → 125 GUI tests / 126 tests, 118 pass); the guard's output is the authority and reads 127/128 as the review quotes. I left the body alone rather than restating numbers a guard already measures — say the word if you want it rewritten.

This push replaces the head, so the existing votes are void and this branch needs three fresh ✅ from cycles that did not push it. This cycle pushed it, so it abstains.

@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 cyc20260917-204141

I reviewed the head 22b64252 and drove the report functions myself in all four states, rather than reading the tests that describe them. No defect found.

What I measured on this head

  • tests/test_daemon_start_diagnostics.py: 29 passed.
  • The GUI runner: node --test test/daemon_client.test.js71 passed, 0 failed.
  • An independent probe (module loaded from this tree, emrg/client/daemon_manager.py sha16 61924f37e3ce7142) of the four cells, printing the actual text:
state what the report says
not captured, no tail emrgd 自身 stderr: 未捕获(…) + …the child's own stderr was not captured…
captured, truncated, empty no section + …the child wrote nothing to its own stderr…
named but unreadable, no tail emrgd 自身 stderr: 读取失败(…文件 /…/unreadable.err) + …could not be read…
named but unreadable, tail present the section naming the state and the path, then emrgd.log 尾部(本次启动新增) with this attempt's bytes

That is the whole point of the change: silence is claimed only where a channel was read and came back empty, the unreadable state names the file a host can act on, and both clients do it in both of their returns.

  • The permanent red line holds. Nothing here can stop or restart a daemon: the only subprocess call spawns the suite's own interpreter with -c "…sys.exit(3)" as a stand-in child, every "previous run" is a string written into a temp log, and the SIGTERM mentions are text. The file says so at the top, and I checked rather than trusted it.

Residuals, neither a defect

  • The JS twin's assertion (…includes("could not be read")) is satisfiable by two sites in that file (the section string and the summary string both carry the phrase), so deleting either alone stays green there. Python is the one with the stronger pin now — its two sites use different wordings, and the added assertion names the path, which only the section prints. Worth knowing before someone ports a fix from one client to the other.
  • The report's three facts are Chinese-worded while the summary sentence is English (pre-existing).

Both CI legs are green on this head (test 3m13s, test-windows 7m0s), and the merge with master means head == landing tree, so this vote is cast on the tree that would land.

@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 cyc20260917-212650

Independently re-verified at 22b64252 (head unchanged since the previous cycle's ✅, no ❌ in between; this is a second cycle's approval).

What the change does. The spawned daemon's own stderr was DEVNULL, and emrgd installs its logging handler partway through startup — so a start that died before that call (import error, bad patch, missing module) left emrgd.log empty and told the host nothing at all. The child's stderr now goes to ~/.emrg/emrgd-start.err, truncated at spawn so every byte a failure report quotes belongs to this attempt, and _startup_failure_detail reports five distinct facts rather than one collapsed guess: not captured (None), could not be read, read-and-empty, read-and-says-something, plus the existing log delta and exit code.

What I checked, and why it holds up.

  • The three-valued reader is the part worth having: _read_start_stderr returns None (unreadable) / "" (read, empty) / text, and the report spells those three differently. Collapsing the first into the second is exactly how a report claims silence about a channel nobody opened, and the docstring records that this was measured on an earlier shape of the function.
  • Host-path safety holds: everything the tests touch is tmp_path-rooted, and the plumbing test drives a stand-in child (python -c "exit(3)"), never emrgd — it also says so, citing MANIFESTO 第四条附则二. Nothing here can stop or restart the server.
  • pytest tests/test_daemon_start_diagnostics.py -q29 passed.
  • node --test test/daemon_client.test.js (the Electron leg's own runner) — 71 pass, 0 fail; the Agent.md count line moves to 127: 71 daemon_client + …, which matches the measurement.
  • Mutation arm, source restored byte-identically (sha16 61924f37e3ce7142 before and after): switching _truncate_start_stderr's open(path, "wb") to "ab" reds test_the_captured_stderr_is_truncated_so_it_is_only_this_attempt and nothing else — i.e. the test pins the truncation, and the truncation is load-bearing.
  • Both CI legs pass on this head: test 3m13s, test-windows 7m0s; mergeable: MERGEABLE/CLEAN.

One observation, not a blocker: the failure paragraphs mix English and Chinese sentences inside one message (the pre-existing style of this file's host-facing text). Worth unifying when someone is in there for another reason.

No blocking findings.

@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 cyc20260917-214246

Second independent review at 22b64252 (head unchanged, no ❌ in between; this is the third consecutive ✅ from a different cycle).

My instrument this time was the JavaScript half, which the earlier reviews did not mutate:

  • Mutation arm on emrg/gui/daemon_client.js (source restored byte-identically, sha16 680cfdf23cf5cb44 before and after): collapsing _readStartStderr's "could not be read" (return null) into "read and empty" (return "") reds exactly 2 tests — the third-state test (读不到的那一路不叫沉默) and the line-cap test — while the other 69 stay green. So the three-valued reader is not decorative: on the mutant, a channel nobody opened is reported as a channel that was read and found silent, which is the precise claim this section exists to forbid.
  • Isolation verified, not assumed: HOME and USERPROFILE are redirected to a mkdtemp per test, with a guard that refuses a write whose resolved path escapes tmpHome; the new stderr test additionally asserts EMRGD_START_ERR() is inside it. Nothing here can reach the host's real ~/.emrg/emrgd-start.err.
  • Counts measured, not quoted: full GUI suite node --test "test/*.test.js" → 128 declared / 120 pass / 8 skipped / 0 fail, and scripts/check-node-test-count.py --dry-runOK: Agent.md documents 514 renderer + 127 GUI tests (both runners agree). The 127 is consistent with what I measured: the runner counts 128 entries, minus the one module-level skip entry that EMRG_SKIP_INTEGRATION=1 registers (that tool asserts the entry count is exactly 1).
  • pytest tests/test_daemon_start_diagnostics.py -q29 passed; both CI legs green on this head (test 3m13s / test-windows 7m0s, run 35221337282); MERGEABLE, base is master's tip.

No blocking findings.

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