22
33from __future__ import annotations
44
5+ import ctypes
6+ import errno
57import hashlib
68import json
79import os
810import re
911import secrets
1012import shutil
13+ import socket
14+ import stat
1115import subprocess
16+ import sys
1217import tempfile
1318import urllib .error
1419import urllib .request
1520from collections .abc import Callable , Mapping , Sequence
21+ from contextlib import suppress
1622from dataclasses import dataclass
23+ from datetime import datetime , timezone
1724from importlib .resources import files
1825from pathlib import Path
1926
@@ -42,6 +49,19 @@ class InitializationResult:
4249 run_command : str
4350
4451
52+ @dataclass (frozen = True )
53+ class OutputReservation :
54+ """Identity and recovery data for an output-path reservation."""
55+
56+ path : Path
57+ token : str
58+ device : int
59+ inode : int
60+ destination : Path
61+ version : str
62+ started_at : str
63+
64+
4565@dataclass (frozen = True )
4666class TemplateContext :
4767 """Normalized names used while rendering a project."""
@@ -88,7 +108,7 @@ def initialize_project(
88108 destination .parent .mkdir (parents = True , exist_ok = True )
89109 lock_path = destination .parent / f".{ destination .name } .openshell-middleware-init.lock"
90110 lock_token = secrets .token_hex (16 )
91- _acquire_lock (lock_path , lock_token , destination , version )
111+ reservation = _acquire_lock (lock_path , lock_token , destination , version )
92112 staging_path : Path | None = None
93113 try :
94114 _validate_destination (destination )
@@ -98,6 +118,7 @@ def initialize_project(
98118 dir = destination .parent ,
99119 )
100120 )
121+ _write_reservation_metadata (reservation , staging_path )
101122 proto , proto_url = downloader (version )
102123 _validate_proto (proto , version )
103124 _render_project (staging_path , language , context )
@@ -113,9 +134,8 @@ def initialize_project(
113134 python_package = context .package_name if language == "python" else None ,
114135 )
115136 runner (language , staging_path , context .package_name )
116- _verify_lock (lock_path , lock_token )
117- _validate_destination (destination )
118- staging_path .replace (destination )
137+ _verify_lock (reservation )
138+ _publish_no_replace (staging_path , destination )
119139 staging_path = None
120140 except InitializationError :
121141 raise
@@ -124,7 +144,7 @@ def initialize_project(
124144 finally :
125145 if staging_path is not None :
126146 shutil .rmtree (staging_path , ignore_errors = True )
127- _release_lock (lock_path , lock_token )
147+ _release_lock (reservation )
128148
129149 return InitializationResult (
130150 destination = destination ,
@@ -133,7 +153,7 @@ def initialize_project(
133153 run_command = (
134154 f"uv run { context .distribution_name } "
135155 if language == "python"
136- else "cargo run -- 127 .0.0.1 :50051"
156+ else "cargo run -- 0 .0.0.0 :50051"
137157 ),
138158 )
139159
@@ -235,48 +255,156 @@ def _validate_destination(destination: Path) -> None:
235255 raise InitializationError (f"invalid output path: { destination } " )
236256
237257
238- def _acquire_lock (lock_path : Path , token : str , destination : Path , version : str ) -> None :
258+ def _acquire_lock (
259+ lock_path : Path , token : str , destination : Path , version : str
260+ ) -> OutputReservation :
239261 try :
240- lock_path .mkdir ()
262+ lock_path .mkdir (mode = 0o700 )
241263 except FileExistsError as error :
242264 raise InitializationError (
243265 f"output path is reserved by another initializer: { destination } ; "
244- f"inspect { lock_path } before removing a stale reservation"
266+ f"inspect { lock_path / 'metadata.json' } and follow the stale-reservation "
267+ "recovery steps in the openshell-middleware-init README"
245268 ) from error
269+ lock_stat = lock_path .stat (follow_symlinks = False )
270+ reservation = OutputReservation (
271+ path = lock_path ,
272+ token = token ,
273+ device = lock_stat .st_dev ,
274+ inode = lock_stat .st_ino ,
275+ destination = destination ,
276+ version = version ,
277+ started_at = datetime .now (timezone .utc ).isoformat (),
278+ )
246279 try :
247280 (lock_path / "owner" ).write_text (token )
248- (lock_path / "metadata.json" ).write_text (
249- json .dumps (
250- {
251- "pid" : os .getpid (),
252- "target_version" : version ,
253- "final_output" : str (destination ),
254- },
255- indent = 2 ,
256- )
257- + "\n "
258- )
281+ _write_reservation_metadata (reservation , None )
259282 except OSError :
260- shutil . rmtree (lock_path , ignore_errors = True )
283+ _remove_reservation_files (lock_path )
261284 raise
285+ return reservation
286+
287+
288+ def _write_reservation_metadata (reservation : OutputReservation , staging_path : Path | None ) -> None :
289+ (reservation .path / "metadata.json" ).write_text (
290+ json .dumps (
291+ {
292+ "pid" : os .getpid (),
293+ "host" : socket .gethostname (),
294+ "started_at" : reservation .started_at ,
295+ "target_version" : reservation .version ,
296+ "final_output" : str (reservation .destination ),
297+ "staging_output" : str (staging_path ) if staging_path is not None else None ,
298+ },
299+ indent = 2 ,
300+ )
301+ + "\n "
302+ )
262303
263304
264- def _verify_lock (lock_path : Path , token : str ) -> None :
305+ def _verify_lock (reservation : OutputReservation ) -> None :
265306 try :
266- recorded = (lock_path / "owner" ).read_text ()
307+ lock_stat = reservation .path .stat (follow_symlinks = False )
308+ if (
309+ not stat .S_ISDIR (lock_stat .st_mode )
310+ or lock_stat .st_dev != reservation .device
311+ or lock_stat .st_ino != reservation .inode
312+ ):
313+ raise OSError ("reservation identity changed" )
314+ flags = os .O_RDONLY | getattr (os , "O_NOFOLLOW" , 0 )
315+ descriptor = os .open (reservation .path / "owner" , flags )
316+ with os .fdopen (descriptor ) as owner :
317+ owner_stat = os .fstat (owner .fileno ())
318+ if not stat .S_ISREG (owner_stat .st_mode ):
319+ raise OSError ("reservation owner is not a regular file" )
320+ recorded = owner .read ()
267321 except OSError as error :
268322 raise InitializationError ("output reservation was lost; refusing to publish" ) from error
269- if recorded != token :
323+ if recorded != reservation . token :
270324 raise InitializationError ("output reservation ownership changed; refusing to publish" )
271325
272326
273- def _release_lock (lock_path : Path , token : str ) -> None :
327+ def _remove_reservation_files (lock_path : Path ) -> None :
328+ known_names = {"owner" , "metadata.json" }
274329 try :
275- if (lock_path / "owner" ).read_text () != token :
330+ lock_stat = lock_path .stat (follow_symlinks = False )
331+ if not stat .S_ISDIR (lock_stat .st_mode ):
332+ return
333+ if {entry .name for entry in lock_path .iterdir ()} - known_names :
276334 return
277335 except OSError :
278336 return
279- shutil .rmtree (lock_path , ignore_errors = True )
337+ for name in known_names :
338+ try :
339+ (lock_path / name ).unlink ()
340+ except FileNotFoundError :
341+ pass
342+ except OSError :
343+ return
344+ with suppress (OSError ):
345+ lock_path .rmdir ()
346+
347+
348+ def _release_lock (reservation : OutputReservation ) -> None :
349+ try :
350+ _verify_lock (reservation )
351+ except InitializationError :
352+ return
353+ _remove_reservation_files (reservation .path )
354+
355+
356+ def _publish_no_replace (source : Path , destination : Path ) -> None :
357+ """Atomically publish ``source`` without replacing any destination entry."""
358+ source_bytes = os .fsencode (source )
359+ destination_bytes = os .fsencode (destination )
360+ if sys .platform .startswith ("linux" ):
361+ library = ctypes .CDLL (None , use_errno = True )
362+ try :
363+ rename = library .renameat2
364+ except AttributeError as error : # pragma: no cover - old Linux libc
365+ raise InitializationError (
366+ "this Linux runtime cannot publish atomically without replacing an output"
367+ ) from error
368+ rename .argtypes = (
369+ ctypes .c_int ,
370+ ctypes .c_char_p ,
371+ ctypes .c_int ,
372+ ctypes .c_char_p ,
373+ ctypes .c_uint ,
374+ )
375+ rename .restype = ctypes .c_int
376+ result = rename (- 100 , source_bytes , - 100 , destination_bytes , 1 )
377+ elif sys .platform == "darwin" : # pragma: no cover - platform-specific
378+ library = ctypes .CDLL (None , use_errno = True )
379+ rename = library .renamex_np
380+ rename .argtypes = (ctypes .c_char_p , ctypes .c_char_p , ctypes .c_uint )
381+ rename .restype = ctypes .c_int
382+ result = rename (source_bytes , destination_bytes , 0x00000004 )
383+ elif os .name == "nt" : # pragma: no cover - platform-specific
384+ try :
385+ source .rename (destination )
386+ except FileExistsError as error :
387+ raise InitializationError (
388+ f"output path appeared during setup; refusing to overwrite it: { destination } "
389+ ) from error
390+ return
391+ else : # pragma: no cover - unsupported platform
392+ raise InitializationError (
393+ "this platform cannot publish atomically without replacing an output"
394+ )
395+
396+ if result == 0 :
397+ return
398+ error_number = ctypes .get_errno ()
399+ if error_number in {errno .EEXIST , errno .ENOTEMPTY }:
400+ raise InitializationError (
401+ f"output path appeared during setup; refusing to overwrite it: { destination } "
402+ )
403+ if error_number in {errno .EINVAL , errno .ENOSYS , errno .EOPNOTSUPP }:
404+ raise InitializationError (
405+ "the output filesystem does not support atomic no-replace publication"
406+ )
407+ raise OSError (error_number , os .strerror (error_number ), destination )
280408
281409
282410def _render_project (destination : Path , language : str , context : TemplateContext ) -> None :
0 commit comments