Skip to content

Commit 46d1ad2

Browse files
authored
Merge pull request #2 from QJHWC/fix/ci-cross-platform
fix: stabilize cross-platform CI and SSH timeout cleanup
2 parents 44a46c5 + 8a3d4cf commit 46d1ad2

16 files changed

Lines changed: 765 additions & 110 deletions

.gitattributes

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
* text=auto eol=lf
2+
3+
*.eps binary
4+
*.jpeg binary
5+
*.jpg binary
6+
*.pdf binary
7+
*.png binary
8+
*.zip binary

.github/workflows/ci.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ jobs:
1616
python: ["3.10", "3.11", "3.12"]
1717
runs-on: ${{ matrix.os }}
1818
steps:
19-
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
20-
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
19+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
20+
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405
2121
with:
2222
python-version: ${{ matrix.python }}
2323
cache: pip
@@ -29,7 +29,7 @@ jobs:
2929
- run: python -m pip install --upgrade pip
3030
- run: python -m pip install -r requirements-dev.txt
3131
- run: python -m pip check
32-
- run: python -m pytest -q
32+
- run: python -m pytest -q --tb=short
3333
- run: python -m ruff check .
3434
- run: python -m mypy paperforge
3535
- if: matrix.python == '3.11'
@@ -42,8 +42,8 @@ jobs:
4242
real-compute-integration:
4343
runs-on: ubuntu-24.04
4444
steps:
45-
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
46-
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
45+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
46+
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405
4747
with:
4848
python-version: "3.11"
4949
cache: pip
@@ -58,7 +58,7 @@ jobs:
5858
PAPERFORGE_KUBECTL: /usr/local/bin/kubectl
5959
PAPERFORGE_KUBERNETES_CONTEXT: kind-paperforge-v3
6060
PAPERFORGE_KUBERNETES_NAMESPACE: paperforge-v3
61-
run: python -m pytest -q tests/integration/test_real_compute_backends.py
61+
run: python -m pytest -q --tb=short tests/integration/test_real_compute_backends.py
6262
- if: always()
6363
run: |
6464
kind delete cluster --name paperforge-v3 || true

engine/perform_review.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ def load_paper(pdf_path, num_pages=None, min_size=100):
301301

302302

303303
def load_review(path):
304-
with open(path) as json_file:
304+
with open(path, encoding="utf-8") as json_file:
305305
loaded = json.load(json_file)
306306
return loaded["review"]
307307

@@ -332,7 +332,7 @@ def get_review_fewshot_examples(num_fs_examples=1):
332332
):
333333
txt_path = paper.replace(".pdf", ".txt")
334334
if os.path.exists(txt_path):
335-
with open(txt_path) as f:
335+
with open(txt_path, encoding="utf-8") as f:
336336
paper_text = f.read()
337337
else:
338338
paper_text = load_paper(paper)

engine/run_lock.py

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
33
Prevents concurrent processes from writing to the same workspace
44
(e.g. two training sweeps or a sync + writeup running in parallel).
5-
Uses POSIX fcntl advisory locking; on platforms without fcntl support,
6-
locking is unavailable and callers will receive a RuntimeError.
5+
Uses POSIX ``flock`` or the Windows CRT byte-range lock.
76
"""
87

98
from __future__ import annotations
@@ -13,25 +12,59 @@
1312
from collections.abc import Iterator
1413
from contextlib import contextmanager
1514
from pathlib import Path
16-
from typing import TextIO
15+
from typing import TextIO, TypedDict
1716

1817
try:
1918
import fcntl
20-
except Exception as exc:
19+
except ImportError:
2120
fcntl = None # type: ignore[assignment]
22-
_FCNTL_IMPORT_ERROR = exc
23-
else:
24-
_FCNTL_IMPORT_ERROR = None
21+
22+
try:
23+
import msvcrt
24+
except ImportError:
25+
msvcrt = None # type: ignore[assignment]
2526

2627

2728
_REENTRANT_GUARD = threading.RLock()
28-
_REENTRANT_LOCKS: dict[str, dict[str, object]] = {}
29+
30+
31+
class _HeldLock(TypedDict):
32+
fp: TextIO
33+
count: int
34+
35+
36+
_REENTRANT_LOCKS: dict[str, _HeldLock] = {}
2937

3038

3139
class RunLockTimeoutError(TimeoutError):
3240
"""Raised when the workspace lock cannot be acquired within timeout."""
3341

3442

43+
def _lock_nonblocking(lock_fp: TextIO) -> None:
44+
lock_fp.seek(0)
45+
if fcntl is not None:
46+
fcntl.flock(lock_fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
47+
return
48+
if msvcrt is not None:
49+
msvcrt.locking(lock_fp.fileno(), msvcrt.LK_NBLCK, 1)
50+
return
51+
raise RuntimeError("no supported process lock implementation is available")
52+
53+
54+
def _unlock(lock_fp: TextIO) -> None:
55+
lock_fp.seek(0)
56+
if fcntl is not None:
57+
fcntl.flock(lock_fp.fileno(), fcntl.LOCK_UN)
58+
elif msvcrt is not None:
59+
msvcrt.locking(lock_fp.fileno(), msvcrt.LK_UNLCK, 1)
60+
61+
62+
def _is_lock_contention(exc: OSError) -> bool:
63+
if isinstance(exc, BlockingIOError):
64+
return True
65+
return msvcrt is not None and exc.errno in {13, 33, 36}
66+
67+
3568
def acquire_run_lock(
3669
run_dir: Path,
3770
timeout: int = 30,
@@ -43,8 +76,8 @@ def acquire_run_lock(
4376
The caller must keep the returned file handle alive until the
4477
protected operation completes; closing the handle releases the lock.
4578
"""
46-
if fcntl is None:
47-
raise RuntimeError(f"fcntl unavailable on this platform: {_FCNTL_IMPORT_ERROR}")
79+
if fcntl is None and msvcrt is None:
80+
raise RuntimeError("no supported process lock implementation is available")
4881

4982
run_dir = run_dir.resolve()
5083
run_dir.mkdir(parents=True, exist_ok=True)
@@ -54,23 +87,29 @@ def acquire_run_lock(
5487
existing = _REENTRANT_LOCKS.get(str(lock_path))
5588
if existing is not None:
5689
existing["count"] = int(existing["count"]) + 1
57-
return existing["fp"] # type: ignore[return-value]
90+
return existing["fp"]
5891

5992
lock_path.touch(exist_ok=True)
60-
fp = lock_path.open("w", encoding="utf-8")
93+
fp = lock_path.open("a+", encoding="utf-8")
94+
if lock_path.stat().st_size == 0:
95+
fp.write("\0")
96+
fp.flush()
6197

6298
start = time.monotonic()
6399
waiting_printed = False
64100
while True:
65101
try:
66-
fcntl.flock(fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
102+
_lock_nonblocking(fp)
67103
with _REENTRANT_GUARD:
68104
_REENTRANT_LOCKS[str(lock_path)] = {
69105
"fp": fp,
70106
"count": 1,
71107
}
72108
return fp
73-
except BlockingIOError:
109+
except OSError as exc:
110+
if not _is_lock_contention(exc):
111+
fp.close()
112+
raise
74113
elapsed = time.monotonic() - start
75114
if timeout >= 0 and elapsed >= float(timeout):
76115
fp.close()
@@ -84,8 +123,6 @@ def acquire_run_lock(
84123

85124

86125
def release_run_lock(lock_fp: TextIO) -> None:
87-
if fcntl is None:
88-
return
89126
lock_path = Path(getattr(lock_fp, "name", "")).resolve()
90127
with _REENTRANT_GUARD:
91128
existing = _REENTRANT_LOCKS.get(str(lock_path))
@@ -96,7 +133,7 @@ def release_run_lock(lock_fp: TextIO) -> None:
96133
return
97134
_REENTRANT_LOCKS.pop(str(lock_path), None)
98135
try:
99-
fcntl.flock(lock_fp.fileno(), fcntl.LOCK_UN)
136+
_unlock(lock_fp)
100137
finally:
101138
lock_fp.close()
102139

paperforge/compute/_local_supervisor.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ def _atomic_json(path: Path, payload: dict[str, Any]) -> None:
4242
Path(temporary).unlink(missing_ok=True)
4343

4444

45+
def _kill_process_group(pid: int, sig: int) -> None:
46+
killpg = getattr(os, "killpg", None)
47+
if killpg is None:
48+
raise AttributeError("process-group signaling is unavailable")
49+
killpg(pid, sig)
50+
51+
4552
def _terminate(process: subprocess.Popen[bytes]) -> None:
4653
if process.poll() is not None:
4754
return
@@ -55,7 +62,7 @@ def _terminate(process: subprocess.Popen[bytes]) -> None:
5562
process.terminate()
5663
else:
5764
try:
58-
os.killpg(process.pid, signal.SIGTERM)
65+
_kill_process_group(process.pid, signal.SIGTERM)
5966
except (AttributeError, ProcessLookupError):
6067
process.terminate()
6168
try:
@@ -65,7 +72,10 @@ def _terminate(process: subprocess.Popen[bytes]) -> None:
6572
process.kill()
6673
else:
6774
try:
68-
os.killpg(process.pid, signal.SIGKILL)
75+
_kill_process_group(
76+
process.pid,
77+
getattr(signal, "SIGKILL", signal.SIGTERM),
78+
)
6979
except (AttributeError, ProcessLookupError):
7080
process.kill()
7181
process.wait(timeout=5)

paperforge/compute/docker.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import time
1111
from collections.abc import Sequence
1212
from dataclasses import dataclass
13-
from pathlib import Path, PurePosixPath
13+
from pathlib import Path, PurePosixPath, PureWindowsPath
1414
from typing import Any
1515

1616
from paperforge.path_safety import (
@@ -48,6 +48,14 @@
4848
)
4949

5050

51+
def _host_container_user() -> str:
52+
getuid = vars(os).get("getuid")
53+
getgid = vars(os).get("getgid")
54+
if not callable(getuid) or not callable(getgid):
55+
return "65532:65532"
56+
return f"{getuid()}:{getgid()}"
57+
58+
5159
@dataclass(frozen=True)
5260
class DockerConfig:
5361
image: str
@@ -61,7 +69,15 @@ class DockerConfig:
6169
def __post_init__(self) -> None:
6270
if not self.image or "\x00" in self.image:
6371
raise ValueError("docker image must be non-empty")
64-
if not _RUNTIME_PATTERN.fullmatch(self.runtime):
72+
windows_runtime = PureWindowsPath(self.runtime)
73+
safe_windows_runtime = (
74+
os.name == "nt"
75+
and windows_runtime.is_absolute()
76+
and ".." not in windows_runtime.parts
77+
and not self.runtime.startswith(("\\\\", "//"))
78+
and not any(ord(character) < 32 for character in self.runtime)
79+
)
80+
if not _RUNTIME_PATTERN.fullmatch(self.runtime) and not safe_windows_runtime:
6581
raise ValueError("docker runtime contains unsafe characters")
6682
workdir = PurePosixPath(self.container_workdir)
6783
if not workdir.is_absolute() or ".." in workdir.parts:
@@ -219,12 +235,7 @@ def _submit_argv(
219235
"--security-opt",
220236
"no-new-privileges",
221237
"--user",
222-
self.config.container_user
223-
or (
224-
f"{os.getuid()}:{os.getgid()}"
225-
if hasattr(os, "getuid")
226-
else "65532:65532"
227-
),
238+
self.config.container_user or _host_container_user(),
228239
"--pids-limit",
229240
"512",
230241
"--tmpfs",

paperforge/compute/local.py

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -745,29 +745,34 @@ def _supervisor_alive(self, job_id: str, metadata: dict[str, Any]) -> bool:
745745
powershell = shutil.which("powershell.exe") or shutil.which("pwsh.exe")
746746
if powershell is None:
747747
return False
748-
inspected = subprocess.run(
749-
[
750-
powershell,
751-
"-NoLogo",
752-
"-NoProfile",
753-
"-NonInteractive",
754-
"-Command",
755-
(
756-
"$p = Get-CimInstance Win32_Process -Filter "
757-
f"\"ProcessId = {raw_pid}\"; "
758-
"if ($null -ne $p) { [Console]::Out.Write($p.CommandLine) }"
759-
),
760-
],
761-
check=False,
762-
capture_output=True,
763-
text=True,
764-
)
765-
command = inspected.stdout.strip()
766-
return (
767-
inspected.returncode == 0
768-
and "paperforge.compute._local_supervisor" in command
769-
and str(metadata["launch_path"]) in command
770-
)
748+
for attempt in range(3):
749+
inspected = subprocess.run(
750+
[
751+
powershell,
752+
"-NoLogo",
753+
"-NoProfile",
754+
"-NonInteractive",
755+
"-Command",
756+
(
757+
"$p = Get-CimInstance Win32_Process -Filter "
758+
f"\"ProcessId = {raw_pid}\"; "
759+
"if ($null -ne $p) { "
760+
"[Console]::Out.Write($p.CommandLine) }"
761+
),
762+
],
763+
check=False,
764+
capture_output=True,
765+
text=True,
766+
)
767+
command = inspected.stdout.strip()
768+
if inspected.returncode == 0 and command:
769+
return (
770+
"paperforge.compute._local_supervisor" in command
771+
and str(metadata["launch_path"]) in command
772+
)
773+
if attempt < 2:
774+
time.sleep(0.05)
775+
return False
771776
try:
772777
os.kill(raw_pid, 0)
773778
except (OSError, ProcessLookupError):
@@ -788,7 +793,8 @@ def _supervisor_alive(self, job_id: str, metadata: dict[str, Any]) -> bool:
788793
@staticmethod
789794
def _signal_supervisor(pid: int, *, force: bool) -> None:
790795
if os.name != "nt":
791-
os.kill(pid, signal.SIGKILL if force else signal.SIGTERM)
796+
force_signal = getattr(signal, "SIGKILL", signal.SIGTERM)
797+
os.kill(pid, force_signal if force else signal.SIGTERM)
792798
return
793799
if force:
794800
subprocess.run(
@@ -930,6 +936,11 @@ def status(self, job_id: str, *, execute: bool = False) -> JobResult:
930936
return completion
931937
if self._supervisor_alive(job_id, dict(previous.metadata)):
932938
return previous
939+
time.sleep(0.05)
940+
completion = self._completion_result(job_id, previous)
941+
if completion is not None:
942+
self._remember(job_id, result=completion)
943+
return completion
933944
process = self._processes.get(job_id)
934945
if process is not None and process.poll() is not None:
935946
time.sleep(0.05)

0 commit comments

Comments
 (0)