@@ -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 ()
0 commit comments