Terminal env snapshot concurrency and atomicity fixes

NousResearch/hermes-agent · cluster 38249 · environments

Data as of

Nothing open on GitHub. Fix merged in #54098. Check it covers your case.

The short answer

0open issues
0open PRs
7items grouped
Known?
Yes. 7 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #54098. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

Multiple bug reports and fix attempts address concurrent environment snapshot writes corrupting PATH, with specific attention to bash 3.2 on macOS where $BASHPID is unavailable and writers share a single temp file.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #54098 fix(terminal): atomic env-snapshot writes keyed on $BASHPID to prevent PATH corruption (#38249)

    merged to main

    files: infographic/atomic-env-snapshot-38249/infographic.png, tests/tools/test_base_environment.py, tools/environments/base.py, tools/environments/local.py

    Show the diff
    • tests/tools/test_base_environment.py
      @@ -90,6 +90,166 @@ def test_cd_failure_exit_126(self):
               assert "exit 126" in wrapped
       
       
      +class TestAtomicSnapshotWrite:
      +    """Regression for #38249: concurrent terminal calls in one session both
      +    source AND rewrite the shared env snapshot. A non-atomic ``export -p >
      +    snap`` truncates-then-writes in place, so a concurrent ``source snap`` can
      +    read a half-written file and embed ``declare -x``/``export`` fragments into
      +    PATH, breaking ``ls``/``git``/``tr`` with command-not-found. The write must
      +    assemble in a temp file and ``mv -f`` it into place (mv is atomic on POSIX
      +    same-fs), so a reader sees the old-or-new complete file, never a torn one.
      +    """
      +
      +    def test_wrap_command_uses_atomic_temp_then_mv(self):
      +        env = _TestableEnv()
      +        env._snapshot_ready = True
      +        wrapped = env._wrap_command("echo hi", "/tmp")
      +        # Env dump goes to a temp file, not directly over the live snapshot.
      +        assert "export -p > " in wrapped
      +        assert ".tmp." in wrapped
      +        # Then an atomic rename onto the real snapshot path.
      +        assert "mv -f " in wrapped
      +        # The env-dump must NOT write the live snapshot in place (the bug).
      +        snap = env._snapshot_path
      +        assert f"export -p > {snap} " not in wrapped
      +        assert f"export -p > '{snap}'" not in wrapped
      +
      +    def test_temp_path_uses_bashpid_not_dollardollar(self):
      +        """The temp name MUST use ``$BASHPID`` (the real subshell PID), not
      +        ``$$``.  In ``&``-launched concurrent subshells ``$$`` stays the parent
      +        shell's PID, so two writers would pick the same temp name, clobber each
      +        other mid-write, and mv would publish a torn file — the corruption is
      +        only narrowed, not closed.  This is the bug shared by every prior PR in
      +        the #38249 cluster."""
      +        env = _TestableEnv()
      +        env._snapshot_ready = True
      +        wrapped = env._wrap_command("echo hi", "/tmp")
      +        assert "$BASHPID" in wrapped
      +        # The bare $$ temp form must be gone.
      +        assert ".tmp.$$" not in wrapped
      +
      +    def test_temp_path_static_part_is_quoted_bashpid_outside(self):
      +        """The static path portion must be shlex-quoted (Windows/Git-Bash
      +        ``C:/Users/...`` or spaces) while ``$BASHPID`` stays OUTSIDE the quotes
      +        so it still expands."""
      +        env = _TestableEnv()
      +        env._snapshot_ready = True
      +        env._snapshot_path = "/tmp/has space/hermes-snap-x.sh"
      +        wrapped = env._wrap_command("echo hi", "/tmp")
      +        # The static path (with its space) is shlex-quoted as a single word, with
      +        # $BASHPID appended OUTSIDE the quotes so it still expands at runtime.
      +        assert "'/tmp/has space/hermes-snap-x.sh.tmp.'$BASHPID" in wrapped
      +        # The space must never appear bare/unquoted in the temp token (that would
      +        # word-split into two args and break the redirect/mv).
      +        assert " space/hermes-snap-x.sh.tmp.$BASHPID" not in wrapped
      +
      +    def test_wrap_command_mv_chained_on_export_success(self):
      +        """A failed/partial ``export -p`` must NOT mv a torn temp over a good
      +        snapshot.  The mv is chained with ``&&`` on the export, and the temp is
      +        removed on failure."""
      +        env = _TestableEnv()
      +        env._snapshot_ready = True
      +        wrapped = env._wrap_command("echo hi", "/tmp")
      +        assert "export -p > " in wrapped and "&& mv -f " in wrapped
      +        assert "rm -f " in wrapped  # temp cleanup on failure
      +
      +    def test_init_session_bootstrap_also_atomic_and_bashpid(self):
      +        """The init_session bootstrap (first snapshot write) is the same shared
      +        file a concurrent command could source — it must be atomic and use
      +        ``$BASHPID`` too."""
      +        env = _TestableEnv()
      +        captured = {}
      +
      +        def fake_run_bash(cmd_string, *, login=False, timeout=120, stdin_data=None):
      +            captured["cmd"] = cmd_string
      +            raise RuntimeError("stop after capture")  # we only need the script
      +
      +        env._run_bash = fake_run_bash  # type: ignore[assignment]
      +        try:
      +            env.init_session()
      +        except Exception:
      +            pass
      +        boot = captured.get("cmd", "")
      +        assert ".tmp." in boot and "mv -f " in boot, boot
      +        assert "$BASHPID" in boot
      +        assert ".tmp.$$" not in boot
      +
      +
      +class TestAtomicSnapshotConcurrencyBehavioral:
      +    """Behavioral regression for #38249 — actually EXECUTES the generated
      +    snapshot write/read concurrently and asserts the file never tears.
      +
      +    The string-inspection tests prove the right script is emitted; this proves
      +    the emitted script's guarantee holds under real concurrency: N concurrent
      +    writers + readers, and the snapshot is ALWAYS a complete, parseable env
      +    dump — never truncated mid-line with a ``declare -x`` / ``export`` fragment
      +    that would corrupt PATH.  Crucially it uses ``$BASHPID`` (per-subshell
      +    unique), which is what closes the race; ``$$`` would still tear here.
      +    """
      +
      +    def _run(self, script):
      +        import subprocess
      +        return subprocess.run(["/bin/bash", "-c", script], capture_output=True, text=True)
      +
      +    def test_concurrent_writes_never_tear_the_snapshot(self, tmp_path):
      +        import shutil
      +        if not shutil.which("bash"):
      +            import pytest
      +            pytest.skip("bash required")
      +        import shlex
      +        snap = str(tmp_path / "hermes-snap-x.sh")
      +        _q = shlex.quote
      +        _snap_tmp = _q(snap + ".tmp.") + "$BASHPID"
      +        # One writer iteration = the exact atomic sequence _wrap_command emits.
      +        writer = (
      +            "for i in $(seq 1 80); do "
      +            "export BIG_$i=$(head -c 600 /dev/zero | tr '\\0' x); "
      +            f"{{ export -p > {_snap_tmp} && mv -f {_snap_tmp} {_q(snap)}; }} "
      +            f"2>/dev/null || rm -f {_snap_tmp} 2>/dev/null || true; "
      +            "done"
      +        )
      +        # Reader: repeatedly source the snapshot and check PATH never absorbs
      +        # an `export `/`declare -x` fragment (the corruption signature).
      +        reader = (
      +            "export PATH=/usr/bin:/bin; "
      +            "for i in $(seq 1 160); do "
      +            f"( source {_q(snap)} >/dev/null 2>&1 || true; "
      +            "case \"$PATH\" in *'declare -x'*|*'export '*) echo CORRUPT;; esac ); "
      +            "done"
      +        )
      +        self._run(f"export -p > {_q(snap)}")  # seed a valid snapshot
      +        # 4 concurrent writers + 4 readers, repeated.
      +        w = " & ".join([writer] * 4)
      +        r = " & ".join([reader] * 4)
      +        procs = [self._run(f"{w} & {r} & wait") for _ in range(3)]
      +        corrupt = any("CORRUPT" in p.stdout for p in procs)
      +        assert not corrupt, "snapshot tore — PATH absorbed a declare-x/export fragment"
      +        final = self._run(f"source {_q(snap)} >/dev/null 2>&1 && echo OK || echo BROKEN")
      +        assert "OK" in final.stdout, f"final snapshot not sourceable: {final.stdout} {final.stderr}"
      +
      +    def test_failed_export_does_not_destroy_good_snapshot(self, tmp_path):
      +        """If ``export -p`` fails, the ``&&``-chained mv must NOT clobber the
      +        existing good snapshot."""
      +        import shutil
      +        if not shutil.which("bash"):
      +            import pytest
      +            pytest.skip("bash required")
      +        import shlex
      +        snap = str(tmp_path / "snap.sh")
      +        _q = shlex.quote
      +        self._run(f"echo 'export GOOD=1' > {_q(snap)}")  # seed good snapshot
      +        # Redirect export into an unwritable dir so the export side fails; mv
      +        # must then NOT run (&&) and not clobber snap.
      +        bad_tmp = _q("/nonexistent-dir/snap.tmp.") + "$BASHPID"
      +        script = (
      +            f"{{ export -p > {bad_tmp} && mv -f {bad_tmp} {_q(snap)}; }} "
      +            f"2>/dev/null || rm -f {bad_tmp} 2>/dev/null || true"
      +        )
      +        self._run(script)
      +        out = self._run(f"cat {_q(snap)}")
      +        assert "export GOOD=1" in out.stdout, "good snapshot was destroyed by a failed export"
      +
      +
       class TestExtractCwdFromOutput:
           def test_happy_path(self):
               env = _TestableEnv()
    • tools/environments/base.py
      @@ -371,13 +371,34 @@ def init_session(self):
               # backends) into every terminal-tool response.
               _quoted_snap = shlex.quote(self._snapshot_path)
               _quoted_cwd_file = shlex.quote(self._cwd_file)
      +        # Use atomic file replacement: assemble the snapshot in a temp file,
      +        # then mv it over the final path.  This prevents concurrent source()
      +        # calls from reading a half-written snapshot when another terminal
      +        # command finishes and rewrites the env vars (issue #38249).  `mv` is
      +        # atomic on POSIX when src and dest are on the same filesystem, so
      +        # source() either sees the old complete snapshot or the new complete
      +        # one — never a partial/truncated file.
      +        #
      +        # The temp name MUST be unique per concurrent writer.  ``$$`` is the
      +        # bash PID, but in ``&``-launched subshells (how concurrent terminal
      +        # calls run) ``$$`` stays the *parent* shell's PID — so two concurrent
      +        # writers would pick the SAME temp name, clobber each other's temp
      +        # mid-write, and mv would then publish a torn file (the corruption is
      +        # only narrowed, not closed).  ``$BASHPID`` is the actual subshell PID
      +        # and is genuinely unique per writer, which closes the race.  The
      +        # static path is shlex-quoted (Windows/Git-Bash drive letters, spaces)
      +        # with ``$BASHPID`` left outside the quotes so it still expands.
      +        _snap_tmp = shlex.quote(self._snapshot_path + ".tmp.") + "$BASHPID"
               bootstrap = (
      -            f"export -p > {_quoted_snap}\n"
      -            f"declare -f | grep -vE '^_[^_]' >> {_quoted_snap}\n"
      -            f"alias -p >> {_quoted_snap}\n"
      -            f"echo 'shopt -s expand_aliases' >> {_quoted_snap}\n"
      -            f"echo 'set +e' >> {_quoted_snap}\n"
      -            f"echo 'set +u' >> {_quoted_snap}\n"
      +            f"export -p > {_snap_tmp}\n"
      +            f"declare -f | grep -vE '^_[^_]' >> {_snap_tmp}\n"
      +            f"alias -p >> {_snap_tmp}\n"
      +            f"echo 'shopt -s expand_aliases' >> {_snap_tmp}\n"
      +            f"echo 'set +e' >> {_snap_tmp}\n"
      +            f"echo 'set +u' >> {_snap_tmp}\n"
      +            # Publish atomically only if assembly succeeded; otherwise drop the
      +            # partial temp rather than leave it to be sourced or orphaned.
      +            f"mv -f {_snap_tmp} {_quoted_snap} || rm -f {_snap_tmp}\n"
                   f"builtin cd {_quoted_cwd} 2>/dev/null || true\n"
                   f"pwd -P > {_quoted_cwd_file} 2>/dev/null || true\n"
                   f"printf '\\n{self._cwd_marker}%s{self._cwd_marker}\\n' \"$(pwd -P)\"\n"
      @@ -427,6 +448,14 @@ def _wrap_command(self, command: str, cwd: str) -> str:
               # :meth:`init_session` for the same fix on the bootstrap block.
               _quoted_snap = shlex.quote(self._snapshot_path)
               _quoted_cwd_file = shlex.quote(self._cwd_file)
      +        # Use atomic file replacement for env snapshot updates (issue #38249).
      +        # Assemble into a per-writer-unique temp file, then mv to atomically
      +        # replace the snapshot so concurrent source() calls never read a
      +        # truncated/half-written file.  ``$BASHPID`` (not ``$$``) is the actual
      +        # subshell PID — unique per concurrent ``&``-launched writer — so two
      +        # writers never share a temp name and clobber each other before the mv.
      +        # Static path shlex-quoted (Windows/spaces); ``$BASHPID`` left to expand.
      +        _snap_tmp = shlex.quote(self._snapshot_path + ".tmp.") + "$BASHPID"
       
               parts = []
       
      @@ -451,9 +480,15 @@ def _wrap_command(self, command: str, cwd: str) -> str:
               parts.append(f"eval '{escaped}'")
               parts.append("__hermes_ec=$?")
       
      -        # Re-dump env vars to snapshot (last-writer-wins for concurrent calls)
      +        # Re-dump env vars to snapshot (atomic replacement to avoid races).
      +        # Chain mv on the export succeeding so a failed/partial dump never
      +        # replaces a good snapshot; drop the temp on failure so it isn't
      +        # orphaned (cleaned up wholesale in LocalEnvironment.cleanup too).
               if self._snapshot_ready:
      -            parts.append(f"export -p > {_quoted_snap} 2>/dev/null || true")
      +            parts.append(
      +                f"{{ export -p > {_snap_tmp} && mv -f {_snap_tmp} {_quoted_snap}; }} "
      +                f"2>/dev/null || rm -f {_snap_tmp} 2>/dev/null || true"
      +            )
       
               # Write CWD to file (local reads this) and stdout marker (remote parses this)
               parts.append(f"pwd -P > {_quoted_cwd_file} 2>/dev/null || true")
    • tools/environments/local.py
      @@ -987,3 +987,14 @@ def cleanup(self):
                       os.unlink(f)
                   except OSError:
                       pass
      +        # Remove any orphaned atomic-write temp snapshots (snap.tmp.<bashpid>)
      +        # a failed/interrupted mv could have left behind (#38249).
      +        try:
      +            import glob
      +            for tmp in glob.glob(f"{self._snapshot_path}.tmp.*"):
      +                try:
      +                    os.unlink(tmp)
      +                except OSError:
      +                    pass
      +        except Exception:
      +            pass

4 PRs closed without merging

From the close comments: superseded by another PR 2 · merged via another change 1 · other 1

  • #38267 fix(terminal): serialize environment snapshot execution · other → #54098
  • #38279 fix(environments): use atomic file replacement for snapshot writes #38249 · merged via another change → 6a2958a, #54098
  • #53159 fix(terminal): atomic env-snapshot replacement to prevent PATH corruption (#38249) · superseded by another PR → #54098
  • #57867 fix(environments): portable per-writer PID for atomic snapshot writes on bash 3.2 · superseded by another PR → 911d380, #54314
Closed issues · 2
  • Closed#38249[Bug]: Concurrent terminal environment snapshots can corrupt PATH with declare -x entries
  • Closed#57866Env snapshot tears on macOS: $BASHPID is empty under /bin/bash 3.2, all concurrent writers share one temp file
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.26: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
environments
Merged PRs
#54098
Closed issues
2
Attempts
4 PRs closed without merging
Fix link checked
28 Jun 2026

For your agent

The same facts, typed.