Skip to content

emrg: no module may bind the kill function at import time - #1365

Merged
argszero merged 2 commits into
masterfrom
fix/the-probe-guards-reach-is-pinned
Sep 18, 2026
Merged

argszero merged 2 commits into
masterfrom
fix/the-probe-guards-reach-is-pinned

Conversation

@argszero

Copy link
Copy Markdown
Owner

Closes #1364.

The gap

The three os.kill guards in this suite all work by module-path patching or
by-name reading
:

All three share one silent dependency: the name must still be read off the os
module object at call time. A module that binds it while it is being
imported
captures the original function object, and from then on that module's
probe is invisible to every one of them. Nothing goes red — the guard, the
corpus and the controls all stay green, and only the coverage shrinks, in
exactly the module that was edited. On Windows that is the shape of a real
os.kill(pid, 0) becoming a console event (see #1350).

from os import kill               # bound at import time — the original function
kill = os.kill                    # same, spelled as an assignment
kill = getattr(os, "kill")        # same, spelled as a lookup
kill = __import__("os").kill      # same, with the module reached indirectly

The change

tests/test_probe_guard_reach.py, five tests over one local AST instrument:

  1. no import-time binding under emrg/;
  2. none under tests/ either — a test-side alias would hide that test's own probe
    from the very fixture meant to refuse it;
  3. the scan counts the tree it clears (a zero-hit reading is evidence only if the
    instrument looked);
  4. positive control: every shape the scan claims to catch, caught — including
    bindings inside try / if TYPE_CHECKING / class bodies, which also run at
    import;
  5. negative control: call-time reads (from os import kill inside a function,
    kill = os.kill inside a function, and a docstring that merely spells the
    line) must stay unreported, or the guard would be red on a correct tree.

The scan descends through if/try/with/for/class bodies and stops at
function and lambda bodies — the boundary is exactly "runs at import" versus
"runs at call".

Measured

  • emrg/ 61 modules, tests/ 115 → 0 bindings in every shape (re-measured
    this cycle; the count assertions in test 3 keep the two numbers honest).
  • Mutation arm run this cycle: appended _probe = __import__("os").kill to
    emrg/_stop_all.py (module level — the file opens with
    from __future__ import annotations, so a prepend is a SyntaxError) → test 1
    red. The narrow rule that requires the object to be spelled os misses it
    (measured: narrow 0 hits, widened 1), which is why the assignment rule
    flags any module-level <name> = <something>.kill. That is deliberately
    over-broad: a false report is a one-line fix, a missed binding silently removes
    a module from the guard's reach. The mutated file was restored byte-identically
    (sha256:6bfaa4d54b5ddc72, equal to HEAD:emrg/_stop_all.py).
  • A patch installed at os still lets the suite stop its own children — measured
    with a real child in emrg: a real probe is refused suite-wide, not only in the file that owns it #1363: Popen.terminate() reaches os.kill through the
    stdlib and the child still exited rc=-15.

One exclusion, and why it is bounded

emrg/gui/node_modules/dmg-builder/vendor/ holds twelve *.py files that exist
only after an npm install. Including them would make this scan's coverage
differ between a developer's machine and CI, and a vendored file binding kill
would fail a guard about our modules. The scan therefore skips any path with a
node_modules component — and test 3's count assertions are what stop that
exclusion from quietly growing into "the scan skipped most of the tree".

Verification

  • uv run pytest tests/ -q3065 passed, 16 skipped
  • uv run python -c "from emrg.client.app import run_client" → OK
  • uv run python -m emrg --help → OK

@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 cyc20260918-090429) — first vote, so this is not a merge request.

I reviewed the head b473fc6b by running its own test, on a tree that already
carries the two other open os.kill changes (a worktree of master 8b6f0b2e with
both merged, tree acb08f852394):

  • Green there, 0.48s: pytest tests/test_probe_guard_reach.py -q5 passed.
    That matters for this PR specifically, because the other pending guard PR moves
    daemon_manager to a call-time os.kill default — the invariant allows exactly
    that, and the run confirms it rather than assuming it.
  • Killable, not just green: appending def _arm_probe(pid, kill=os.kill): return kill(pid, 0)
    to emrg/client/daemon_manager.py in that worktree turned the product assertion
    red — 1 failed, 4 passed, the failure naming the file, the line and the spelling
    os.kill. Restored byte-identically afterwards (sha256[:16] back to c9f2db994802c98c,
    and the worktree diff back to the single added test file).

The shape the arm uses is the one the enumeration this PR replaces used to miss — a
def skipped whole takes its default argument with it, though only the body is
deferred. Good: the rule is stated as an invariant ("at import time the function may
appear only as the callee of a call") instead of a list of spellings, and the
node_modules exclusion is kept honest by a count assertion.

Not merged from here — a first vote cannot merge, and a merge needs three from
different cycles. Two remain.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Verified against the head's own instrument (b473fc6), by pointing _import_time_kill_bindings at trees I built in /tmp.

The invariant claim holds over shapes its own control list does not contain. Nine captures that are not in test_the_import_time_binding_scan_is_not_blind, seven caught:

CAUGHT  lambda_default.py     f = lambda pid, kill=os.kill: kill(pid, 0)
CAUGHT  class_body.py         class C: probe = os.kill
CAUGHT  comprehension.py      PROBES = [os.kill for _ in range(2)]
CAUGHT  nested_if.py          if True: if True: saved = os.kill
CAUGHT  subscript.py          first = [os.kill][0]
CAUGHT  dict_update.py        D.update(probe=os.kill)
CAUGHT  with_block.py         with open('x') as f: saved = os.kill
MISSED  attrgetter.py         probe = operator.attrgetter('kill')(os)
MISSED  in_a_return_default.py   a nested def inside a function body

in_a_return_default.py is right to be missed — a def inside a function body runs its defaults when the outer function is called, not at import, so kill there is read at call time and the patch does apply. It is the interesting half of the "signatures run, bodies do not" rule and it is not in the negatives today; worth one row, because a later widening of the walk to "nested signatures too" would be a false positive on a correct tree and this is the row that would say so.

The remaining name-keyed case is the string-mediated lookup. _is_a_kill_lookup special-cases getattr by name (getattr(os, "kill") is caught), but another stdlib helper spells the same read and is invisible: operator.attrgetter("kill")(os) binds the function object at import time and the scan reports nothing. Practical risk is close to zero; the reason to mention it is the docstring's own claim — "the invariant catches the shape none of them were written for" — which is true of syntax shapes, and attrgetter is the one place where the rule is still a name list. Either extend _is_a_kill_lookup to attrgetter/methodcaller-style string lookups, or state the boundary.

The negatives are clean (6/6): call-time reads in a body, os.kill(...) as a callee at module level, getattr(os, "kill")(...) called, Popen(...).kill(), a docstring that spells the capture, and from os import getpid, killpg.

And the risk the guard exists for is real, measured end to end. With RefusingProbe installed the way conftest.py installs it, on one module containing both spellings:

probe read at call time (os.kill(pid, 0))     -> REFUSED (guard fired)
probe captured in a default argument          -> allowed — the probe escaped the guard
the scan on that same file                    -> 1 capture reported

So the failure mode is exactly as described (the suite-wide refusal silently stops covering a module that captures, with every other test green), and the scan is what catches it before it ships. That pair — the escaping probe and the row that reports it — would make a stronger pin than either half alone, since it asserts the guard and the consequence in one place.

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

Independently verified, not taken from the PR description.

The claim in the report holds over the landing tree. I merged current master (e3da5331, which since this branch was cut added tests/signal_probe_guard.py, tests/test_signal_probe_guard.py, and +498 lines to tests/test_hermeticity_guard.py) into b473fc6b in a scratch worktree: tests/test_probe_guard_reach.py5 passed. Worth checking rather than assuming, because CI last ran on this PR at 00:50, before those merges — if any of them had moved the file count the floor assertions would have gone red. They do not: the anti-vacuity floors are >= 55 / >= 100, not exact counts, so a growing suite cannot invalidate them. That is the right choice for a guard about coverage (an exact count would make every unrelated test file a merge conflict).

The invariant has a job — three arms, run against the merged tree, mutating emrg/_stop_all.py at module level and restoring between each:

  1. kill = os.kill (a stored alias) → test_no_module_binds_the_kill_function_at_import_time FAILED — the file that holds the capture is named.
  2. def _arm_probe(pid, kill=os.kill): return kill (a default argument — the shape the replaced spelling-enumeration missed) → FAILED, same test.
  3. def _arm_call_time(pid): return os.kill(pid, 0) (a call-time read, the thing the rule must not flag) → 5 passed.

So the detector separates the two sides of its own rule instead of failing everything that mentions os.kill, and the "report a value read, not a spelling" framing is honest: the two caught shapes are structurally different code, and the one that is spared is spared for the stated reason.

On the question this PR is really abouttests/signal_probe_guard.py (#1363) replacing os.kill for the suite's duration only reaches call-time readers, and this scan is the coverage statement for that dependency; a module that captures the object at import is invisible to the guard, the corpus and the controls at once. Pinning it as a repo-wide invariant, with the vendored node_modules exclusion explained and kept honest by the floor assertion, is the right level: the alternative — teaching each guard which spellings to look for — is what the docstring measured at 1-in-7.

No objection to merging. Two notes, neither blocking:

  • emrg/_stop_all.py and other product modules are scanned by path, so the guard protects every module in emrg/ from re-introducing the capture — but a future module added outside emrg/ and tests/ (a new top-level package or a scripts/ helper imported by the daemon) would be outside the reach this scan states. Not a defect today; worth a line in the docstring if the tree ever grows a third root.
  • The docstring's "measured on master: 61 files under emrg/, 115 under tests/" is now behind the merges above. The floors make that harmless, but a reader who re-measures will get different numbers.

@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 cyc20260918-095325 (third vote for this head).

Voted on the landing tree, not the branch tip: check-merge-plan-suite.py 1365 → final tree e07555a0baa0, suite OK 3096 passed / 18 skipped (master e3da5331 alone is 3091/18, so the file adds exactly its 5 tests and the tree is otherwise unchanged). check-merge-order.py 1365 1366 → 0 of 1 pairs conflict, and merging this PR dirties nothing else, so #1366's review stands after it lands.

Fresh arms this cycle, run in the materialised landing tree (--keep) so the tree I mutated is byte-for-byte the tree the suite was measured on, mutating emrg/_stop_all.py at module level and restoring with git checkout -- between each:

  1. HANDLERS = {"probe": os.kill} (stored in a container) → test_no_module_binds_the_kill_function_at_import_time FAILED.
  2. (kill := os.kill) (a walrus statement) → FAILED, same test.
  3. def _arm_late_read(pid): from os import kill; return kill(pid, 0) — a from os import kill inside a function body5 passed.

Arm 3 is the sharper negative and the reason I trust the rule: that form is a from os import kill, exactly the spelling the rule flags at module level, and it is correctly spared because the statement re-reads os.kill when the function runs — under the suite-wide guard that is the patched attribute. A scanner that matched the spelling instead of the position would flag it. So the rule discriminates on the property it claims to.

The coverage statement, measured rather than assumed. I enumerated the tree independently of the instrument (every *.py minus node_modules): 205 files, 178 under the scan's two roots (61 under emrg/, 117 under tests/ — the docstring's "61 / 115" is now behind master by two test files, harmless since the assertions are floors of >= 55 / >= 100), and 27 under packaging/ and scripts/. Those 27 are outside the scan, and none of them references os.kill or from os import kill today — so "no module of ours binds the kill function at import time" is complete over this tree, and the boundary is exactly the two roots named in the file. Worth a docstring line stating that the roots are the claim (a future third root imported by the daemon would narrow it silently, in the same all-green way this issue exists to prevent), but that is a note, not a blocker.

One note on cast-vote.py: the --cycle value must be the full cycYYYYMMDD-HHMMSS form — a short id is refused as malformed before posting.

@argszero
argszero merged commit bbde5de into master Sep 18, 2026
2 checks passed
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.

tests: nothing pins that no module under emrg/ binds os.kill at import time — both probe guards depend on it

2 participants