fix: ensure container removal on teardown even if bash hangs or dies (#2887)

`OCIContainer.__enter__` was hardened in #2879 to always clean up on
failure, but `__exit__` was left unguarded. If the container/bash had
already died, `bash_stdin.write("exit 0")` raises `BrokenPipeError`; if
bash refuses to exit, `process.wait(timeout=30)` raises `TimeoutExpired`.
Either propagated out of `__exit__` before `_remove_container()` ran,
leaking the container (and, on timeout, the `start` process too).

Wrap the teardown so a broken pipe or timeout instead forces the process
down (kill + wait) and always falls through to container removal, while
still respecting CIBW_DEBUG_KEEP_CONTAINER. Pipe closes are now also
guarded so a flush-on-close against a dead pipe can't mask cleanup.

Add non-docker unit tests for the clean-exit, already-dead-bash, and
shutdown-timeout paths.

Assisted-by: ClaudeCode:claude-opus-4.8
This commit is contained in:
Henry Schreiner
2026-06-04 22:35:59 -04:00
committed by GitHub
parent 2f49f9a050
commit 29888149e1
2 changed files with 144 additions and 16 deletions
+31 -16
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import contextlib
import dataclasses
import io
import json
@@ -374,24 +375,38 @@ class OCIContainer:
exc_tb: TracebackType | None,
) -> None:
assert self.process is not None
self.bash_stdin.write(b"exit 0\n")
self.bash_stdin.flush()
self.process.wait(timeout=30)
self.bash_stdin.close()
self.bash_stdout.close()
try:
# Ask bash to exit cleanly and wait for the `start` process to finish.
# If the container/bash has already died, the write raises
# BrokenPipeError; if bash refuses to exit, `wait` raises
# TimeoutExpired. Both are handled below so we always reach the
# cleanup in `finally` rather than leaking the process or container.
self.bash_stdin.write(b"exit 0\n")
self.bash_stdin.flush()
self.process.wait(timeout=30)
if self.engine.name == "podman":
# This works around what seems to be a race condition in the podman
# backend. The full reason is not understood. See PR #966 for a
# discussion on possible causes and attempts to remove this line.
# For now, this seems to work "well enough".
self.process.wait()
if self.engine.name == "podman":
# This works around what seems to be a race condition in the
# podman backend. The full reason is not understood. See PR #966
# for a discussion on possible causes and attempts to remove this
# line. For now, this seems to work "well enough".
self.process.wait()
except (OSError, subprocess.TimeoutExpired):
# bash didn't shut down cleanly; force the process down so it isn't
# leaked, then continue to cleanup.
if self.process.poll() is None:
self.process.kill()
self.process.wait()
finally:
with contextlib.suppress(OSError):
self.bash_stdin.close()
with contextlib.suppress(OSError):
self.bash_stdout.close()
self.process = None
self.process = None
keep_container = strtobool(os.environ.get("CIBW_DEBUG_KEEP_CONTAINER", ""))
if not keep_container:
self._remove_container()
keep_container = strtobool(os.environ.get("CIBW_DEBUG_KEEP_CONTAINER", ""))
if not keep_container:
self._remove_container()
def _remove_container(self) -> None:
assert self.name is not None
+113
View File
@@ -632,6 +632,119 @@ def test_enter_error_cleanup_failure(
assert "Failed to remove 'bar' container" in err
class _FakeStream:
def __init__(self, *, write_error: Exception | None = None) -> None:
self._write_error = write_error
self.closed = False
def write(self, data: bytes) -> int:
if self._write_error is not None:
raise self._write_error
return len(data)
def flush(self) -> None:
pass
def close(self) -> None:
self.closed = True
class _FakeProcess:
def __init__(self, *, wait_error: Exception | None = None, alive: bool = True) -> None:
self._wait_error = wait_error
self._alive = alive
self.killed = False
def wait(self, timeout: float | None = None) -> int:
if self._wait_error is not None and timeout is not None:
raise self._wait_error
return 0
def poll(self) -> int | None:
return None if self._alive else 0
def kill(self) -> None:
self.killed = True
self._alive = False
def _container_ready_for_exit(
monkeypatch: pytest.MonkeyPatch,
*,
process: _FakeProcess,
bash_stdin: _FakeStream,
bash_stdout: _FakeStream,
) -> tuple[OCIContainer, list[bool]]:
"""Build a container with the post-__enter__ state faked, ready for __exit__."""
monkeypatch.setattr(cibuildwheel.oci_container, "detect_ci_provider", lambda: None)
container = OCIContainer(
engine=OCIContainerEngineConfig("docker"), image="foo", oci_platform=OCIPlatform.AMD64
)
container.name = "bar"
removed: list[bool] = []
monkeypatch.setattr(container, "_remove_container", lambda: removed.append(True))
monkeypatch.setattr(container, "process", process)
# bash_stdin/bash_stdout are only set during __enter__, so raising=False
monkeypatch.setattr(container, "bash_stdin", bash_stdin, raising=False)
monkeypatch.setattr(container, "bash_stdout", bash_stdout, raising=False)
return container, removed
def test_exit_clean_shutdown_removes_container(monkeypatch: pytest.MonkeyPatch) -> None:
process = _FakeProcess(alive=True)
bash_stdin = _FakeStream()
bash_stdout = _FakeStream()
container, removed = _container_ready_for_exit(
monkeypatch, process=process, bash_stdin=bash_stdin, bash_stdout=bash_stdout
)
container.__exit__(None, None, None)
assert removed == [True]
assert not process.killed
assert container.process is None
assert bash_stdin.closed
assert bash_stdout.closed
def test_exit_removes_container_when_bash_already_dead(monkeypatch: pytest.MonkeyPatch) -> None:
# bash has already exited, so writing "exit 0" raises BrokenPipeError. The
# container must still be removed rather than leaked.
process = _FakeProcess(alive=False)
bash_stdin = _FakeStream(write_error=BrokenPipeError())
bash_stdout = _FakeStream()
container, removed = _container_ready_for_exit(
monkeypatch, process=process, bash_stdin=bash_stdin, bash_stdout=bash_stdout
)
container.__exit__(None, None, None)
assert removed == [True]
assert container.process is None
assert bash_stdin.closed
assert bash_stdout.closed
assert not process.killed # already dead, nothing to kill
def test_exit_kills_process_on_shutdown_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
# bash refuses to exit, so process.wait(timeout=30) raises TimeoutExpired. The
# process must be killed and the container removed rather than leaked.
process = _FakeProcess(wait_error=subprocess.TimeoutExpired(cmd="bash", timeout=30))
bash_stdin = _FakeStream()
bash_stdout = _FakeStream()
container, removed = _container_ready_for_exit(
monkeypatch, process=process, bash_stdin=bash_stdin, bash_stdout=bash_stdout
)
container.__exit__(None, None, None)
assert process.killed
assert removed == [True]
assert container.process is None
assert bash_stdin.closed
assert bash_stdout.closed
@pytest.mark.parametrize("platform", list(OCIPlatform))
def test_multiarch_image(container_engine: OCIContainerEngineConfig, platform: OCIPlatform) -> None:
if detect_ci_provider() == CIProvider.travis_ci and DEFAULT_OCI_PLATFORM not in {