diff --git a/tests/tools/test_base_environment.py b/tests/tools/test_base_environment.py index bfa20fc5ac1..8d6e6ab7328 100644 --- a/tests/tools/test_base_environment.py +++ b/tests/tools/test_base_environment.py @@ -323,6 +323,22 @@ class TestInitSessionFailure: assert env._snapshot_ready is False + def test_snapshot_ready_false_on_nonzero_bootstrap_exit(self): + """A non-zero bootstrap result should trigger fallback mode.""" + env = _TestableEnv() + + def mock_run_bash(*args, **kwargs): + mock = MagicMock() + mock.poll.return_value = 0 + mock.returncode = 127 + mock.stdout = iter([]) + return mock + + env._run_bash = mock_run_bash + env.init_session() + + assert env._snapshot_ready is False + def test_login_flag_when_snapshot_not_ready(self): """When _snapshot_ready=False, execute() should pass login=True to _run_bash.""" env = _TestableEnv() diff --git a/tools/environments/base.py b/tools/environments/base.py index 409fec71d8b..67107690f1b 100644 --- a/tools/environments/base.py +++ b/tools/environments/base.py @@ -357,7 +357,7 @@ class BaseEnvironment(ABC): ``_snapshot_ready = True`` so subsequent commands source the snapshot instead of running with ``bash -l``. """ - # Full capture: env vars, functions (filtered), aliases, shell options. + # Full capture: env vars, functions, aliases, shell options. # Restore configured cwd after login shell profile scripts, which may # change the working directory (e.g. bashrc `cd ~`). Without this, # pwd -P captures the profile's directory, not terminal.cwd. @@ -391,7 +391,21 @@ class BaseEnvironment(ABC): _snap_tmp = shlex.quote(self._snapshot_path + ".tmp.") + "$BASHPID" bootstrap = ( f"export -p > {_snap_tmp}\n" - f"declare -f | grep -vE '^_[^_]' >> {_snap_tmp}\n" + # Dump function definitions, filtering out private (``_``-prefixed) + # helpers — mainly bash-completion internals (``_git``, ``_make``…) + # — by NAME, not by line. A naive ``declare -f | grep -vE '^_[^_]'`` + # is line-based: it strips the function *header* line but leaves the + # orphaned ``{ … }`` body behind, which corrupts the snapshot and + # makes every sourced command fail (e.g. exit 127). Selecting the + # wanted names with ``declare -F`` first, then dumping only those + # whole definitions, preserves the filter's intent without ever + # tearing a function body. The non-empty guard matters: bare + # ``declare -f`` with no name args dumps ALL functions, so an empty + # name list (only private funcs present) would otherwise leak the + # very functions we meant to drop. + f"__hermes_fns=$(declare -F | awk '{{print $3}}' | grep -vE '^_[^_]') || true\n" + f"[ -n \"$__hermes_fns\" ] && declare -f $__hermes_fns " + f">> {_snap_tmp} 2>/dev/null || true\n" f"alias -p >> {_snap_tmp}\n" f"echo 'shopt -s expand_aliases' >> {_snap_tmp}\n" f"echo 'set +e' >> {_snap_tmp}\n" @@ -406,6 +420,10 @@ class BaseEnvironment(ABC): try: proc = self._run_bash(bootstrap, login=True, timeout=self._snapshot_timeout) result = self._wait_for_process(proc, timeout=self._snapshot_timeout) + if int(result.get("returncode") or 0) != 0: + raise RuntimeError( + f"snapshot bootstrap failed with exit code {result.get('returncode')}" + ) self._snapshot_ready = True self._update_cwd(result) logger.info(