Skip to content

Commit 0cab63b

Browse files
committed
fix(core): avoid lowering RLIMIT_CPU hard limit in parser guard to prevent SIGXCPU in CI
1 parent f7cd74d commit 0cab63b

3 files changed

Lines changed: 106 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ codeclone . --update-baseline
142142
- Expanded security tests (HTML escaping and safety checks).
143143
- Added regression tests for deterministic report ordering across HTML/TXT/JSON,
144144
baseline/cache integrity edge cases, and symlink traversal/loop safety.
145+
- Fixed POSIX parser CPU guard to avoid lowering `RLIMIT_CPU` hard limit, preventing
146+
potential process termination in long CI test sessions.
145147

146148
---
147149

codeclone/extractor.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,14 @@ def _timeout_handler(_signum: int, _frame: object) -> None:
7070

7171
old_limits = resource.getrlimit(resource.RLIMIT_CPU)
7272
soft, hard = old_limits
73-
new_soft = (
74-
min(timeout_s, soft) if soft != resource.RLIM_INFINITY else timeout_s
75-
)
76-
new_hard = (
77-
min(timeout_s + 1, hard)
78-
if hard != resource.RLIM_INFINITY
79-
else timeout_s + 1
80-
)
81-
resource.setrlimit(resource.RLIMIT_CPU, (new_soft, new_hard))
73+
hard_ceiling = timeout_s if hard == resource.RLIM_INFINITY else max(1, hard)
74+
if soft == resource.RLIM_INFINITY:
75+
new_soft = min(timeout_s, hard_ceiling)
76+
else:
77+
new_soft = min(timeout_s, soft, hard_ceiling)
78+
# Never lower hard limit: raising it back may be disallowed for
79+
# unprivileged processes and can lead to process termination later.
80+
resource.setrlimit(resource.RLIMIT_CPU, (new_soft, hard))
8281
except Exception:
8382
# If resource is unavailable or cannot be set, rely on alarm only.
8483
pass

tests/test_extractor.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,102 @@ def setrlimit(_key: int, _val: tuple[int, int]) -> None:
106106
assert tree is not None
107107

108108

109+
def test_parse_limits_never_lowers_hard_limit(monkeypatch: pytest.MonkeyPatch) -> None:
110+
calls: list[tuple[int, int]] = []
111+
112+
class _DummyResource:
113+
RLIMIT_CPU = 0
114+
RLIM_INFINITY = 10**9
115+
116+
@staticmethod
117+
def getrlimit(_key: int) -> tuple[int, int]:
118+
return (_DummyResource.RLIM_INFINITY, _DummyResource.RLIM_INFINITY)
119+
120+
@staticmethod
121+
def setrlimit(_key: int, val: tuple[int, int]) -> None:
122+
calls.append(val)
123+
# Simulate a system where changing hard limit would fail.
124+
assert val[1] == _DummyResource.RLIM_INFINITY
125+
126+
monkeypatch.setattr(os, "name", "posix")
127+
monkeypatch.setattr(signal, "getsignal", lambda *_args, **_kwargs: None)
128+
monkeypatch.setattr(signal, "signal", lambda *_args, **_kwargs: None)
129+
monkeypatch.setattr(signal, "setitimer", lambda *_args, **_kwargs: None)
130+
monkeypatch.setitem(sys.modules, "resource", _DummyResource)
131+
132+
with extractor._parse_limits(5):
133+
pass
134+
135+
assert calls
136+
# First set lowers only soft limit, hard stays unchanged.
137+
assert calls[0] == (5, _DummyResource.RLIM_INFINITY)
138+
# Final restore returns to original limits.
139+
assert calls[-1] == (
140+
_DummyResource.RLIM_INFINITY,
141+
_DummyResource.RLIM_INFINITY,
142+
)
143+
144+
145+
def test_parse_limits_uses_finite_soft_limit_branch(
146+
monkeypatch: pytest.MonkeyPatch,
147+
) -> None:
148+
calls: list[tuple[int, int]] = []
149+
150+
class _DummyResource:
151+
RLIMIT_CPU = 0
152+
RLIM_INFINITY = 10**9
153+
154+
@staticmethod
155+
def getrlimit(_key: int) -> tuple[int, int]:
156+
return (20, 20)
157+
158+
@staticmethod
159+
def setrlimit(_key: int, val: tuple[int, int]) -> None:
160+
calls.append(val)
161+
162+
monkeypatch.setattr(os, "name", "posix")
163+
monkeypatch.setattr(signal, "getsignal", lambda *_args, **_kwargs: None)
164+
monkeypatch.setattr(signal, "signal", lambda *_args, **_kwargs: None)
165+
monkeypatch.setattr(signal, "setitimer", lambda *_args, **_kwargs: None)
166+
monkeypatch.setitem(sys.modules, "resource", _DummyResource)
167+
168+
with extractor._parse_limits(5):
169+
pass
170+
171+
# New soft is min(timeout, old_soft, hard_ceiling), hard is preserved.
172+
assert calls[0] == (5, 20)
173+
assert calls[-1] == (20, 20)
174+
175+
176+
def test_parse_limits_restore_failure_is_ignored(
177+
monkeypatch: pytest.MonkeyPatch,
178+
) -> None:
179+
class _DummyResource:
180+
RLIMIT_CPU = 0
181+
RLIM_INFINITY = 10**9
182+
_calls = 0
183+
184+
@staticmethod
185+
def getrlimit(_key: int) -> tuple[int, int]:
186+
return (_DummyResource.RLIM_INFINITY, _DummyResource.RLIM_INFINITY)
187+
188+
@staticmethod
189+
def setrlimit(_key: int, _val: tuple[int, int]) -> None:
190+
_DummyResource._calls += 1
191+
if _DummyResource._calls >= 2:
192+
raise RuntimeError("restore denied")
193+
194+
monkeypatch.setattr(os, "name", "posix")
195+
monkeypatch.setattr(signal, "getsignal", lambda *_args, **_kwargs: None)
196+
monkeypatch.setattr(signal, "signal", lambda *_args, **_kwargs: None)
197+
monkeypatch.setattr(signal, "setitimer", lambda *_args, **_kwargs: None)
198+
monkeypatch.setitem(sys.modules, "resource", _DummyResource)
199+
200+
# Should not raise even if restoring old limits fails.
201+
with extractor._parse_limits(5):
202+
pass
203+
204+
109205
def test_extract_syntax_error() -> None:
110206
with pytest.raises(ParseError):
111207
extract_units_from_source(

0 commit comments

Comments
 (0)