Skip to content

Commit 72b4093

Browse files
authored
Merge pull request #6714 from loopx-project/codex/checkpoint-archive-deadline-20261017
fix(benchmark): enforce shared deadlines within Docker archives
2 parents 937a011 + 641b90c commit 72b4093

4 files changed

Lines changed: 269 additions & 19 deletions

File tree

‎benchmark/README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,17 @@ staging directory instead of ambient temporary storage. If that filesystem fills
632632
capture still fails and runs its existing reader/barrier cleanup without publishing an
633633
outer artifact; it does not retry on another volume or substitute a later point.
634634

635+
The controller checks its original shared deadline during download progress, tar
636+
header and file reads, safety filtering and permission restoration, including
637+
standalone SQLite companions, the held request and the phase file. It retains
638+
HTTP read timeouts instead of using Docker's process-stream reader, which disables
639+
them. These checks are cooperative between IO operations, not preemption of a
640+
blocked filesystem call or a new allowance per member. On expiry the daemon reader
641+
closes before this operation's writer cleanup; no native point or outer artifact
642+
is published from the expired export. Safe extraction still rejects the complete
643+
invalid inventory before writing member payloads, uses Python's data filter and
644+
restores ordinary source modes without owners or special permission bits.
645+
635646
The container must initially be running and unpaused. Cancellation, failed file
636647
reads, rejected native capture and a lost pause response attempt to release only
637648
this operation's barrier. Failure to resume the worker is reported and publishes

‎benchmark/edgebench/checkpoint_worker.py‎

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -64,21 +64,43 @@ def _guest_path(value: str) -> PurePosixPath:
6464
return path
6565

6666

67-
def _archive(container, remote: str, destination: Path) -> Path:
67+
class _ArchiveReader:
68+
"""Check the shared clock during tar headers and large-file copy reads."""
69+
def __init__(self, raw, deadline):
70+
self.raw, self.deadline = raw, deadline
71+
72+
def __getattr__(self, name):
73+
return getattr(self.raw, name)
74+
75+
def read(self, *args):
76+
_remaining(self.deadline)
77+
block = self.raw.read(*args)
78+
_remaining(self.deadline)
79+
return block
80+
81+
82+
def _archive(container, remote: str, destination: Path, *, deadline: float | None = None) -> Path:
6883
"""Read through the daemon, rejecting links/devices and archive traversal.
6984
7085
Docker's archive reader works while paused, unlike exec-based file readers.
7186
Keep only the declared object, not parent filesystem or process credentials.
7287
"""
7388
from docker.utils import decode_json_header
7489

90+
def check():
91+
if deadline is not None:
92+
_remaining(deadline)
93+
94+
check()
7595
# The SDK's get_archive hides its Response behind a lazy generator. Closing
7696
# that generator alone need not close an unread HTTP body. Own the response
7797
# so every early exit releases the daemon reader before writer cleanup.
7898
api = container.client.api
7999
response = api._get(api._url("/containers/{0}/archive", container.id),
80-
params={"path": remote}, stream=True, headers={"Accept-Encoding": "identity"})
100+
params={"path": remote}, stream=True, headers={"Accept-Encoding": "identity"},
101+
**({"timeout": _remaining(deadline)} if deadline is not None else {}))
81102
with closing(response):
103+
check()
82104
api._raise_for_status(response)
83105
stat = decode_json_header(response.headers["x-docker-container-path-stat"])
84106
if stat.get("linkTarget"):
@@ -87,32 +109,48 @@ def _archive(container, remote: str, destination: Path) -> Path:
87109
# The explicitly provisioned export volume owns both the raw transfer
88110
# and extracted files; a full ambient /tmp must not defeat that choice.
89111
with tempfile.TemporaryFile(dir=destination) as raw:
90-
for chunk in api._stream_raw_result(response, 2 * 1024 * 1024, False):
112+
# _stream_raw_result is Docker's long-running-process reader: it
113+
# disables socket timeouts. Archive IO must keep the request's read
114+
# timeout as well as check the shared absolute clock on progress.
115+
for chunk in response.iter_content(64 * 1024, decode_unicode=False):
116+
check()
91117
raw.write(chunk)
118+
check()
92119
raw.seek(0)
93-
with tarfile.open(fileobj=raw) as archive:
94-
members = archive.getmembers()
120+
with tarfile.open(fileobj=_ArchiveReader(raw, deadline) if deadline is not None else raw) as archive:
121+
members = []
95122
root = PurePosixPath(remote).name
96123
names = set()
97-
for member in members:
124+
for member in archive:
125+
check()
98126
path = PurePosixPath(member.name)
99127
if (path.is_absolute() or ".." in path.parts or not path.parts or
100128
path.parts[0] != root or str(path) in names or
101129
not (member.isfile() or member.isdir())):
102130
raise ValueError("Worker archive contains undeclared, linked or special members")
103131
names.add(str(path))
104-
archive.extractall(destination, members=members, filter="data")
132+
members.append(member)
133+
134+
def filtered(member, path):
135+
check()
136+
result = tarfile.data_filter(member, path)
137+
check()
138+
return result
139+
140+
archive.extractall(destination, members=members, filter=filtered)
105141
# The data filter intentionally drops directory modes. This is a
106142
# private raw transport witness, so restore only ordinary permission
107143
# bits after safe extraction; never restore owners or special bits.
108144
# Otherwise a private 0700 authority directory becomes 0755 and a
109145
# matching frozen source is falsely rejected as having changed.
110146
for member in sorted(members, key=lambda entry: len(PurePosixPath(entry.name).parts), reverse=True):
147+
check()
111148
(destination / member.name).chmod(member.mode & 0o777)
149+
check()
112150
return _physical(destination / root)
113151

114152

115-
def _archive_component(container, remote: str, destination: Path) -> Path:
153+
def _archive_component(container, remote: str, destination: Path, *, deadline: float | None = None) -> Path:
116154
"""Transport a standalone SQLite participant's committed storage too.
117155
118156
A paused process can still have committed data only in its WAL. Reading
@@ -123,7 +161,7 @@ def _archive_component(container, remote: str, destination: Path) -> Path:
123161
"""
124162
from docker.errors import NotFound
125163

126-
copied = _archive(container, remote, destination)
164+
copied = _archive(container, remote, destination, deadline=deadline)
127165
if not copied.is_file():
128166
return copied
129167
with copied.open("rb") as source:
@@ -135,7 +173,7 @@ def _archive_component(container, remote: str, destination: Path) -> Path:
135173
for suffix in ("-wal", "-journal"):
136174
with tempfile.TemporaryDirectory(prefix=".sqlite-transport-", dir=destination) as stage:
137175
try:
138-
companion = _archive(container, remote + suffix, Path(stage) / "member")
176+
companion = _archive(container, remote + suffix, Path(stage) / "member", deadline=deadline)
139177
except NotFound:
140178
continue
141179
if not companion.is_file():
@@ -220,7 +258,7 @@ def capture_worker(controller_file: Path, *, token: str, capture_id: str,
220258
if not container.attrs["State"]["Paused"]:
221259
raise RuntimeError("Worker writer barrier was not established")
222260
if "handoff" in request:
223-
held = _archive(container, request["handoff"]["source_path"], stage / "held")
261+
held = _archive(container, request["handoff"]["source_path"], stage / "held", deadline=deadline)
224262
raw = held.read_bytes()
225263
if hashlib.sha256(raw).hexdigest() != request["handoff"]["sha256"]:
226264
raise ValueError("Worker handoff changed before its writer barrier")
@@ -235,7 +273,7 @@ def capture_worker(controller_file: Path, *, token: str, capture_id: str,
235273
deadline = min(deadline, time.monotonic() + prepared["deadline_epoch"] - time.time())
236274
for entry in request["components"]:
237275
client.api.timeout = _remaining(deadline)
238-
copied = _archive_component(container, entry["source_path"], stage / "workers" / entry["id"])
276+
copied = _archive_component(container, entry["source_path"], stage / "workers" / entry["id"], deadline=deadline)
239277
components.append({"id": entry["id"], "source_path": str(copied)})
240278
if prepared is not None:
241279
observed = effect_runtime_result("runtime.checkpoint_artifacts", {
@@ -251,7 +289,7 @@ def capture_worker(controller_file: Path, *, token: str, capture_id: str,
251289
raise ValueError("Worker source changed after native/research preparation: " + ", ".join(changed))
252290
if time.time() >= prepared["deadline_epoch"]:
253291
raise TimeoutError("Worker handoff expired before evaluator capture")
254-
phase = _archive(container, request["phase_deadline_path"], stage / "phase")
292+
phase = _archive(container, request["phase_deadline_path"], stage / "phase", deadline=deadline)
255293
native = capture(controller_file, token=token, capture_id=capture_id,
256294
destination=stage / "native", timeout=_remaining(deadline), phase_deadline_file=phase)
257295
container.reload()

‎benchmark/tests/test_checkpoint_archive_transport.py‎

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ def __iter__(self):
2424
raise self.error
2525
yield b"incomplete archive"
2626

27+
def iter_content(self, *args, **kwargs):
28+
return iter(self)
29+
2730
def close(self):
2831
self.closed = True
2932

@@ -38,7 +41,7 @@ def test_failed_archive_always_closes_daemon_reader(tmp_path, monkeypatch, failu
3841
base64.b64encode(json.dumps(stat).encode()).decode()}
3942
api = SimpleNamespace(_url=lambda *args: "archive", _get=lambda *args, **kwargs: stream,
4043
_raise_for_status=lambda response: None,
41-
_stream_raw_result=lambda response, *args: iter(response))
44+
_stream_raw_result=lambda *args: pytest.fail("archive must retain its socket timeout"))
4245

4346
class Container:
4447
id = "synthetic"
@@ -74,9 +77,10 @@ def test_export_storage_does_not_require_ambient_temporary_storage(tmp_path, mon
7477
response = SimpleNamespace(headers={"x-docker-container-path-stat":
7578
base64.b64encode(json.dumps({"linkTarget": ""}).encode()).decode()}, closed=False)
7679
response.close = lambda: setattr(response, "closed", True)
80+
response.iter_content = lambda *args, **kwargs: iter([raw.getvalue()])
7781
api = SimpleNamespace(_url=lambda *args: "archive", _get=lambda *args, **kwargs: response,
7882
_raise_for_status=lambda value: None,
79-
_stream_raw_result=lambda *args: iter([raw.getvalue()]))
83+
_stream_raw_result=lambda *args: pytest.fail("archive must retain its socket timeout"))
8084
container = SimpleNamespace(id="synthetic", client=SimpleNamespace(api=api))
8185

8286
destination = tmp_path / "provisioned-export"
@@ -86,3 +90,111 @@ def test_export_storage_does_not_require_ambient_temporary_storage(tmp_path, mon
8690
assert list(destination.iterdir()) == [copied], "no raw transfer file may escape its lifetime"
8791
assert response.closed
8892
assert unavailable.read_bytes() == b"not a directory"
93+
94+
95+
def _tar_transport(payload):
96+
response = SimpleNamespace(headers={"x-docker-container-path-stat":
97+
base64.b64encode(json.dumps({"linkTarget": ""}).encode()).decode()}, closed=False)
98+
response.close = lambda: setattr(response, "closed", True)
99+
response.iter_content = lambda *args, **kwargs: iter([payload])
100+
api = SimpleNamespace(_url=lambda *args: "archive", _get=lambda *args, **kwargs: response,
101+
_raise_for_status=lambda value: None,
102+
_stream_raw_result=lambda *args: pytest.fail("archive must retain its socket timeout"))
103+
return SimpleNamespace(id="synthetic", client=SimpleNamespace(api=api)), response
104+
105+
106+
def _research_tar():
107+
raw = io.BytesIO()
108+
with tarfile.open(fileobj=raw, mode="w") as archive:
109+
directory = tarfile.TarInfo("data")
110+
directory.type, directory.mode = tarfile.DIRTYPE, 0o700
111+
archive.addfile(directory)
112+
for name in ("first", "second"):
113+
member = tarfile.TarInfo("data/" + name)
114+
member.size, member.mode = 256 * 1024, 0o600
115+
archive.addfile(member, io.BytesIO(b"x" * member.size))
116+
return raw.getvalue()
117+
118+
119+
@pytest.mark.parametrize("stage", ["download", "headers", "file_read", "filter", "modes"])
120+
def test_single_archive_stops_at_shared_deadline_and_closes_reader(tmp_path, monkeypatch, stage):
121+
"""Progress in one component cannot renew the original capture clock."""
122+
container, response = _tar_transport(_research_tar())
123+
now = [100.0]
124+
monkeypatch.setattr(worker.time, "monotonic", lambda: now[0])
125+
triggered = []
126+
if stage == "download":
127+
def download(*args, **kwargs):
128+
yield b"first block"
129+
triggered.append(True)
130+
now[0] = 102
131+
yield b"must not be written"
132+
pytest.fail("expired stream must not be consumed further")
133+
response.iter_content = download
134+
elif stage in {"headers", "file_read"}:
135+
original = worker.tempfile.TemporaryFile
136+
@contextmanager
137+
def delayed_read(**kwargs):
138+
with original(**kwargs) as raw:
139+
class SlowRead:
140+
def __getattr__(self, name):
141+
return getattr(raw, name)
142+
def read(self, size=-1):
143+
# Header scan reads 512-byte records; file extraction
144+
# reads blocks after validating the complete inventory.
145+
data = raw.read(size)
146+
if (stage == "headers" and size == 512 or
147+
stage == "file_read" and size > 512):
148+
triggered.append(True)
149+
now[0] = 102
150+
return data
151+
yield SlowRead()
152+
monkeypatch.setattr(worker.tempfile, "TemporaryFile", delayed_read)
153+
elif stage == "filter":
154+
original = worker.tarfile.data_filter
155+
def slow_filter(*args):
156+
member = original(*args)
157+
triggered.append(True)
158+
now[0] = 102
159+
return member
160+
monkeypatch.setattr(worker.tarfile, "data_filter", slow_filter)
161+
else:
162+
original = worker.tarfile.TarFile.extractall
163+
def finished_extraction(*args, **kwargs):
164+
original(*args, **kwargs)
165+
triggered.append(True)
166+
now[0] = 102
167+
monkeypatch.setattr(worker.tarfile.TarFile, "extractall", finished_extraction)
168+
with pytest.raises(TimeoutError, match="capture window closed"):
169+
worker._archive(container, "/data", tmp_path / "expired", deadline=101)
170+
assert triggered and response.closed
171+
172+
173+
def test_expired_archive_does_not_open_a_daemon_reader(tmp_path, monkeypatch):
174+
container, response = _tar_transport(_research_tar())
175+
container.client.api._get = lambda *args, **kwargs: pytest.fail("expired operation must not start IO")
176+
monkeypatch.setattr(worker.time, "monotonic", lambda: 2)
177+
with pytest.raises(TimeoutError, match="capture window closed"):
178+
worker._archive(container, "/data", tmp_path / "expired", deadline=1)
179+
assert not (tmp_path / "expired").exists()
180+
181+
182+
@pytest.mark.parametrize("unsafe", ["absolute", "parent", "foreign", "duplicate", "symlink", "hardlink", "fifo"])
183+
def test_archive_rejects_unsafe_inventory_before_extracting_any_file(tmp_path, unsafe):
184+
raw = io.BytesIO()
185+
with tarfile.open(fileobj=raw, mode="w") as archive:
186+
first = tarfile.TarInfo("data/retained")
187+
first.size = 4
188+
archive.addfile(first, io.BytesIO(b"safe"))
189+
second = tarfile.TarInfo({"absolute": "/escape", "parent": "data/../escape",
190+
"foreign": "elsewhere/file", "duplicate": "data/retained"}.get(unsafe, "data/unsafe"))
191+
if unsafe in {"symlink", "hardlink", "fifo"}:
192+
second.type = {"symlink": tarfile.SYMTYPE, "hardlink": tarfile.LNKTYPE,
193+
"fifo": tarfile.FIFOTYPE}[unsafe]
194+
second.linkname = "data/retained"
195+
archive.addfile(second)
196+
container, response = _tar_transport(raw.getvalue())
197+
destination = tmp_path / "rejected"
198+
with pytest.raises(ValueError, match="undeclared, linked or special"):
199+
worker._archive(container, "/data", destination)
200+
assert response.closed and not (destination / "data").exists()

0 commit comments

Comments
 (0)