fix(docker): recover from out-of-band container removal in persistent mode (salvage #36631) (#39415)
Salvage of #36631 (@annguyenNous), rebased onto current main with regression tests added. Fixes #36266. When a persistent Docker sandbox container is removed out-of-band (idle reaper, `docker prune`, OOM kill, daemon restart), the gateway kept issuing `docker exec` against the dead container ID, returning "No such container" on every subsequent tool call — the agent was permanently blocked until the gateway process restarted. DockerEnvironment.execute() now detects the "No such container" / "is not running" error after a non-zero exit (gated on persist_across_processes) and calls _recreate_container(): it tries label-based reuse first, falls back to a fresh container replaying the same image + full all_run_args set, re-runs init_session(), and retries the command once. A genuine non-zero exit is NOT misclassified as container-gone. Differs from #36631 as submitted: adds the tests the original lacked. tests/tools/test_docker_environment.py covers _is_container_gone pattern matching (incl. the negative/control case), the recover-and-retry path, the persist_across_processes=False opt-out (no recovery), and the ordinary-failure passthrough (no spurious recreation). _make_dummy_env now forwards persist_across_processes. Verified: - Unit: 67/67 in test_docker_environment.py (4 new + existing). - Live E2E against the real docker daemon: started a persistent container, `docker rm -f`'d it out-of-band, and the next execute() transparently recreated a fresh container and succeeded; a follow-up command worked in the recovered container; a real `exit N` passed through without triggering recovery. Co-authored-by: annguyenNous <annguyenNous@users.noreply.github.com>
This commit is contained in:
co-authored by
annguyenNous
parent
c54b935873
commit
8a888441d7
@@ -44,6 +44,7 @@ def _make_dummy_env(**kwargs):
|
||||
auto_mount_cwd=kwargs.get("auto_mount_cwd", False),
|
||||
env=kwargs.get("env"),
|
||||
run_as_host_user=kwargs.get("run_as_host_user", False),
|
||||
persist_across_processes=kwargs.get("persist_across_processes", True),
|
||||
)
|
||||
|
||||
|
||||
@@ -1707,3 +1708,128 @@ def test_plain_image_keeps_docker_init_and_run_noexec(monkeypatch):
|
||||
assert "noexec" in run_mounts[0], (
|
||||
f"/run must stay noexec for non-s6 images, got: {run_mounts[0]}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Out-of-band container removal recovery (issue #36266, PR #36631)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_container_gone_matches_removal_errors(monkeypatch):
|
||||
"""``_is_container_gone`` recognizes the docker errors that mean the
|
||||
container no longer exists, and does NOT match ordinary command failures.
|
||||
"""
|
||||
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
|
||||
_mock_subprocess_run(monkeypatch)
|
||||
env = _make_dummy_env()
|
||||
|
||||
# Positive: the daemon's "container gone" phrasings.
|
||||
assert env._is_container_gone(
|
||||
"Error response from daemon: No such container: hermes-abc123"
|
||||
)
|
||||
assert env._is_container_gone("Error: No such container: deadbeef")
|
||||
assert env._is_container_gone(
|
||||
"Error response from daemon: Container abc is not running"
|
||||
)
|
||||
|
||||
# Control / negative: a real command failure must NOT be misclassified as
|
||||
# the container being gone — otherwise every non-zero exit would trigger a
|
||||
# spurious container recreation.
|
||||
assert not env._is_container_gone("bash: nonsuch: command not found")
|
||||
assert not env._is_container_gone("Traceback (most recent call last): ...")
|
||||
assert not env._is_container_gone("")
|
||||
assert not env._is_container_gone("permission denied")
|
||||
|
||||
|
||||
def test_execute_recovers_from_out_of_band_removal(monkeypatch):
|
||||
"""When a persistent container is removed out-of-band, ``execute`` detects
|
||||
the "No such container" error, recreates the container, and retries once —
|
||||
returning success transparently.
|
||||
"""
|
||||
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
|
||||
_mock_subprocess_run(monkeypatch)
|
||||
env = _make_dummy_env(
|
||||
persistent_filesystem=True,
|
||||
persist_across_processes=True,
|
||||
)
|
||||
|
||||
# First execute() sees a dead container; second (post-recovery) succeeds.
|
||||
outputs = iter([
|
||||
{"output": "Error response from daemon: No such container: hermes-x", "returncode": 1},
|
||||
{"output": "ok", "returncode": 0},
|
||||
])
|
||||
|
||||
def _fake_super_execute(self, command, cwd="", **kwargs):
|
||||
return next(outputs)
|
||||
|
||||
recreate_calls = []
|
||||
|
||||
def _fake_recreate(self):
|
||||
recreate_calls.append(True)
|
||||
self._container_id = "recovered-container-id"
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(docker_env.BaseEnvironment, "execute", _fake_super_execute)
|
||||
monkeypatch.setattr(
|
||||
docker_env.DockerEnvironment, "_recreate_container", _fake_recreate
|
||||
)
|
||||
|
||||
result = env.execute("echo hi")
|
||||
|
||||
assert recreate_calls == [True], "recovery should have been attempted exactly once"
|
||||
assert result.get("returncode") == 0, f"expected success after recovery, got {result!r}"
|
||||
assert result.get("output") == "ok"
|
||||
|
||||
|
||||
def test_execute_does_not_recover_when_not_persistent(monkeypatch):
|
||||
"""A non-persistent session must NOT trigger container recreation on a
|
||||
"No such container" error — recovery is only meaningful for the persistent,
|
||||
cross-process container that can be removed out-of-band.
|
||||
"""
|
||||
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
|
||||
_mock_subprocess_run(monkeypatch)
|
||||
env = _make_dummy_env(
|
||||
persistent_filesystem=True,
|
||||
persist_across_processes=False,
|
||||
)
|
||||
|
||||
def _fake_super_execute(self, command, cwd="", **kwargs):
|
||||
return {"output": "No such container: x", "returncode": 1}
|
||||
|
||||
def _fail_recreate(self):
|
||||
pytest.fail("recreation must not run when persist_across_processes is False")
|
||||
|
||||
monkeypatch.setattr(docker_env.BaseEnvironment, "execute", _fake_super_execute)
|
||||
monkeypatch.setattr(
|
||||
docker_env.DockerEnvironment, "_recreate_container", _fail_recreate
|
||||
)
|
||||
|
||||
result = env.execute("echo hi")
|
||||
assert result.get("returncode") == 1, "the original error must pass through unchanged"
|
||||
|
||||
|
||||
def test_execute_does_not_recover_on_ordinary_failure(monkeypatch):
|
||||
"""A genuine non-zero exit that is NOT a container-gone error must pass
|
||||
through without triggering recovery (guards against over-eager recreation).
|
||||
"""
|
||||
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
|
||||
_mock_subprocess_run(monkeypatch)
|
||||
env = _make_dummy_env(
|
||||
persistent_filesystem=True,
|
||||
persist_across_processes=True,
|
||||
)
|
||||
|
||||
def _fake_super_execute(self, command, cwd="", **kwargs):
|
||||
return {"output": "bash: badcmd: command not found", "returncode": 127}
|
||||
|
||||
def _fail_recreate(self):
|
||||
pytest.fail("recreation must not run for an ordinary command failure")
|
||||
|
||||
monkeypatch.setattr(docker_env.BaseEnvironment, "execute", _fake_super_execute)
|
||||
monkeypatch.setattr(
|
||||
docker_env.DockerEnvironment, "_recreate_container", _fail_recreate
|
||||
)
|
||||
|
||||
result = env.execute("badcmd")
|
||||
assert result.get("returncode") == 127
|
||||
assert "command not found" in result.get("output", "")
|
||||
|
||||
Reference in New Issue
Block a user