refactor(docker): use ExitStack for launch teardown

Replace the manual state-dict + per-resource branching teardown in
DockerBottleBackend.launch with an ExitStack: each resource registers
its own cleanup callback at the moment it's created, and stack.close()
unwinds in LIFO order. The previous form had to hand-coordinate four
nullable slots and re-check existence for the container; ExitStack
encodes the same semantics declaratively.
This commit is contained in:
2026-05-11 19:58:57 -04:00
parent 3424888c02
commit 4fc0707760
+25 -32
View File
@@ -14,7 +14,7 @@ import dataclasses
import os import os
import subprocess import subprocess
import sys import sys
from contextlib import contextmanager from contextlib import ExitStack, contextmanager
from pathlib import Path from pathlib import Path
from typing import Iterator, Sequence from typing import Iterator, Sequence
@@ -44,6 +44,15 @@ from .provision import ssh as _ssh
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
def _force_remove_container(name: str) -> None:
if docker_mod.container_exists(name):
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
class DockerBottleBackend(BottleBackend): class DockerBottleBackend(BottleBackend):
"""Docker backend implementation. Selected by CLAUDE_BOTTLE_BACKEND """Docker backend implementation. Selected by CLAUDE_BOTTLE_BACKEND
(default).""" (default)."""
@@ -162,31 +171,11 @@ class DockerBottleBackend(BottleBackend):
f"got {type(plan).__name__}" f"got {type(plan).__name__}"
) )
state: dict[str, str] = { stack = ExitStack()
"container": "",
"pipelock": "",
"internal_network": "",
"egress_network": "",
}
def teardown() -> None: def teardown() -> None:
try: try:
if state["container"] and docker_mod.container_exists(state["container"]): stack.close()
subprocess.run(
["docker", "rm", "-f", state["container"]],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
state["container"] = ""
if state["pipelock"]:
self._proxy.stop(state["pipelock"])
state["pipelock"] = ""
if state["internal_network"]:
network_mod.network_remove(state["internal_network"])
state["internal_network"] = ""
if state["egress_network"]:
network_mod.network_remove(state["egress_network"])
state["egress_network"] = ""
except BaseException: except BaseException:
# Teardown must not raise; swallow so the caller's # Teardown must not raise; swallow so the caller's
# __exit__ path can still propagate the original error. # __exit__ path can still propagate the original error.
@@ -199,22 +188,26 @@ class DockerBottleBackend(BottleBackend):
plan.derived_image, plan.image, plan.spec.user_cwd plan.derived_image, plan.image, plan.spec.user_cwd
) )
state["internal_network"] = network_mod.network_create_internal(plan.slug) internal_network = network_mod.network_create_internal(plan.slug)
state["egress_network"] = network_mod.network_create_egress(plan.slug) stack.callback(network_mod.network_remove, internal_network)
egress_network = network_mod.network_create_egress(plan.slug)
stack.callback(network_mod.network_remove, egress_network)
proxy_plan = dataclasses.replace( proxy_plan = dataclasses.replace(
plan.proxy_plan, plan.proxy_plan,
internal_network=state["internal_network"], internal_network=internal_network,
egress_network=state["egress_network"], egress_network=egress_network,
) )
state["pipelock"] = self._proxy.start(proxy_plan) pipelock_name = self._proxy.start(proxy_plan)
stack.callback(self._proxy.stop, pipelock_name)
container = self._run_agent_container(plan, state["internal_network"]) container = self._run_agent_container(plan, internal_network)
state["container"] = container stack.callback(_force_remove_container, container)
prompt_path = self.provision(plan, container) prompt_path = self.provision(plan, container)
bottle = DockerBottle(container, teardown, prompt_path) yield DockerBottle(container, teardown, prompt_path)
yield bottle
finally: finally:
teardown() teardown()