Skip to content

release lane robustness: untimed tree-state probe subprocess, releasehash.py's overloaded exit code 2 #627

Description

@SUaDtL

Finding 1: the tree-state probe subprocess call has no timeout

core/pysrc/_releaselib.py, the git status --porcelain probe used by run-pre-tag's before/after tree-state assertion (around the probe = subprocess.run([git_executable(), "status", "--porcelain", ...], capture_output=True, text=True, cwd=project_root) call), passes no timeout=. If git status hangs — an index lock held by another process, a network-mounted repo, a credential-helper prompt some git config triggers unexpectedly — run-pre-tag (and by extension the whole /ca:release lane) hangs indefinitely with no diagnostic, rather than failing loudly with an actionable message.

This is a repo-wide pattern, not an isolated oversight — none of _releaselib.py's subprocess.run calls (the git --exec-path probe, the declared pre-tag command dispatch, this tree-state probe) pass a timeout. That consistency is presumably deliberate for the pre-tag command dispatch itself (an operator's declared build/test command may legitimately run long, and an arbitrary timeout would break real releases). The tree-state probe is different: it is this module's own git status call, not operator-declared shell, and has no legitimate reason to run long.

Suggested approach

Add a timeout (with a documented rationale for the chosen value) specifically to the internal git status probe, leaving the operator-declared pre-tag command dispatch untimed. Needs a test proving a hung git process degrades to a clean, distinct error rather than a hang — which needs a controllable slow-git fixture, not just a timeout= addition.

Finding 2: releasehash.py's exit code 2 means three different things

core/pysrc/releasehash.py's main() returns exit code 2 for: (a) malformed CLI usage (wrong subcommand/arg count), (b) an unknown release target, and (c) the check subcommand's NEVER_CONFIRMED state (pre-tag commands declared but never confirmed via releasehash.py record <target> — a normal, expected first-run condition, not an error). The module's own CLI docstring documents check's exit codes as "0 confirmed, 1 changed, 2 never confirmed, 3/4 declared-file states" without mentioning the same 2 is reused for the two other, unrelated failure classes.

In practice this is low-impact today: core/surface/skills/release/SKILL.md step 6c, the only production caller, treats any nonzero exit from check the same way (read the commands, then record), and always invokes check with a target the row itself already resolved — so the "unknown target" collision is not currently reachable from the one real caller. It is still a latent trap for any future caller (or a manual/scripted invocation) that tries to distinguish these cases by exit code alone.

Suggested approach

Give "bad CLI usage" and "unknown target" distinct exit codes from NEVER_CONFIRMED, and update the module docstring's exit-code table accordingly. Low risk to fix, but touches a documented CLI contract — needs a test per distinguished code.

Evidence

core/pysrc/_releaselib.py (run-pre-tag's tree-state probe): subprocess.run([git_executable(), "status", "--porcelain", ...], capture_output=True, text=True, cwd=project_root)  -- no timeout=

core/pysrc/releasehash.py:175  return 2   # usage error
core/pysrc/releasehash.py:186  return 2   # unknown target
core/pysrc/releasehash.py:212  return 2   # NEVER_CONFIRMED
core/pysrc/releasehash.py:39   # docstring: "releasehash.py check <target>  exit 0 confirmed, 1 changed, 2 never confirmed, 3/4 declared-file states"

Found during the #578/#579 findings-triage cluster; cross-ref #578.

Activity

  1. SUaDtL commented on Sep 17, 2026

    @SUaDtL
    CollaboratorAuthor

    Investigated Finding 1 (the untimed tree-state probe) via /ca:debug; scoping this comment to that finding only, leaving Finding 2 (releasehash.py's overloaded exit code 2) untouched.

    Confirmed still reproducible on current main: core/pysrc/_releaselib.py's release_tree_status() (the git status --porcelain probe used by run-pre-tag's before/after tree-state assertion) still passes no timeout=, unlike every other internal git probe in this module (which all use timeout=30 -- see lines 871/886/897/907/926/2442/2455/2481/2493/3093/3110/3144).

    A minimal fix is ready but NOT landed here — it requires a cross-host version advance (core/pysrc/_releaselib.py is vendored byte-identically into all three plugins' hooks/_releaselib.py, confirmed via python tools/sync-core.py --check) plus a decision about how the fix's changelog entry interacts with the root CHANGELOG.md's existing ## [Unreleased] section, which already carries unshipped entries from separate, unrelated work. That decision isn't mine to make from a bug-fix PR, so I'm leaving the fix out of my current PR and recording the details here instead:

    Fix: wrap the subprocess.run(...) call in release_tree_status() with timeout=30 (matching this module's own established convention), and catch subprocess.TimeoutExpired to return a synthetic subprocess.CompletedProcess(args, 1, "", <actionable message>) instead of letting it raise. This needs NO caller-side change: git status --porcelain exits 0 regardless of dirty/clean state (verified), so any nonzero probe.returncode already routes through the existing "the probe itself failed" handling in both the clean-tree-status CLI subcommand and run-pre-tag's _tree_state() (which maps it to exit 8 -- 'the tree-state PROBE itself failed', never exit 6's 'a command mutated the tree').

    Regression test idiom (worked and verified, exercised against core/pysrc/_releaselib.py loaded as core_releaselib in .github/scripts/test_release_lib.py):

    hang = subprocess.TimeoutExpired(cmd=["git", "status", "--porcelain"], timeout=30)
    with mock.patch.object(core_releaselib.subprocess, "run", side_effect=hang):
        probe = core_releaselib.release_tree_status({}, ".")
    self.assertNotEqual(probe.returncode, 0)
    self.assertIn("timed out", probe.stderr.lower())

    confirmed red (uncaught TimeoutExpired) before the fix, green after. Plus a companion test_a_normal_git_status_is_unaffected sanity check against the real repo.

    Whoever picks this up next: land it via its own /ca:release-aware PR that also resolves the Unreleased-section placement question, bumping ca/ca-codex/ca-pi together.

  2. SUaDtL commented on Oct 5, 2026

    @SUaDtL
    CollaboratorAuthor

    Closing as fixed by #845, with clean-tree guard follow-up in #852. Internal tree-state probes now have a 30-second timeout and return a clean probe failure on TimeoutExpired; operator-declared commands remain untimed. releasehash exit codes distinguish never-confirmed (2), malformed usage (64), and unknown target (65).

    Timeout regression coverage injects TimeoutExpired; CLI regressions exercise the exit-code contract. The published 0.15.1 helper carries the fix. This verification was source/history review, not a fresh real hung-process test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    sev:lowTribunal/triage: low severity

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions