Skip to content

Commit 1bb9faa

Browse files
authored
Merge branch 'main' into dependabot/pip/boto3-gte-1.43.46
2 parents 07a0149 + 3fa1948 commit 1bb9faa

3 files changed

Lines changed: 137 additions & 4 deletions

File tree

‎requirements.txt‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ aiohttp-retry >= 2.9.1
33

44
backoff >= 2.2.1
55
boto3 >= 1.43.46
6-
click >= 8.1.7
6+
click >= 8.4.2
77
colorama >= 0.4.6, < 0.4.7
88
cryptography >= 48.0.1
9-
fastapi[all] >= 0.138.1
9+
fastapi[all] >= 0.139.0
1010
paramiko >= 5.0.0
1111
prettytable >= 3.18.0
12-
psutil >= 5.9.0
12+
psutil >= 7.2.2
1313
py-cpuinfo >= 9.0.0
1414
inquirerpy == 0.3.4
1515
requests >= 2.34.2

‎runpod/serverless/modules/rp_fitness.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,51 @@ def _reset_registration_state() -> None:
110110
_registration_state["system_checks"] = False
111111

112112

113+
# Bound how long the best-effort unhealthy report may delay the exit.
114+
_REPORT_TIMEOUT_SECONDS = 2
115+
116+
117+
def _report_unhealthy(check: str, reason: str) -> None:
118+
"""
119+
Best-effort report of a fitness-check failure to the host before exit.
120+
121+
Sends a single GET to the ping URL (same URL/credentials the heartbeat
122+
uses) with status=unhealthy plus the failing check name and reason, so the
123+
host can emit a queryable worker.fitness_failed event. Any failure — no
124+
ping URL, no API key, HTTP error, timeout — is swallowed, so this can never
125+
prevent the os._exit that follows. It is synchronous, so it may delay that
126+
exit by up to _REPORT_TIMEOUT_SECONDS (network phases only; it adds no
127+
delay when there is no ping URL/API key to report to).
128+
"""
129+
ping_url = os.environ.get("RUNPOD_WEBHOOK_PING")
130+
api_key = os.environ.get("RUNPOD_AI_API_KEY")
131+
if not ping_url or ping_url == "PING_NOT_SET" or not api_key:
132+
return
133+
134+
try:
135+
# Deferred imports: keep module import light and avoid import cycles.
136+
from runpod.http_client import SyncClientSession
137+
from runpod.serverless.modules.worker_state import WORKER_ID
138+
from runpod.version import __version__ as runpod_version
139+
140+
ping_url = ping_url.replace("$RUNPOD_POD_ID", WORKER_ID)
141+
params = {
142+
"status": "unhealthy",
143+
"check": check,
144+
"reason": reason[:256],
145+
"runpod_version": runpod_version,
146+
}
147+
session = SyncClientSession()
148+
try:
149+
session.headers.update({"Authorization": api_key})
150+
session.get(ping_url, params=params, timeout=_REPORT_TIMEOUT_SECONDS)
151+
finally:
152+
session.close()
153+
except Exception:
154+
# Best-effort only; the exit is the guarantee, not this report.
155+
pass
156+
157+
113158
def _ensure_gpu_check_registered() -> None:
114159
"""
115160
Ensure GPU fitness check is registered.
@@ -234,6 +279,14 @@ async def run_fitness_checks() -> None:
234279
)
235280
log.debug(f"Traceback:\n{full_traceback}")
236281

282+
# Best-effort report to the host so the failure is queryable. It is
283+
# bounded (see _REPORT_TIMEOUT_SECONDS) and fully swallowed, so it
284+
# can delay the force-exit below but can never prevent it.
285+
try:
286+
_report_unhealthy(check_name, f"{error_type}: {error_message}")
287+
except Exception: # a report failure must never prevent the exit
288+
pass
289+
237290
# Force-kill immediately; see _terminate_unhealthy for why this is
238291
# os._exit rather than sys.exit.
239292
log.error("Worker is unhealthy, exiting.")

‎tests/test_serverless/test_modules/test_fitness/test_force_kill.py‎

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,16 @@
77
raises ``SystemExit`` and then blocks in interpreter shutdown joining those
88
threads, so the worker logs "unhealthy, exiting." but never terminates.
99
The exit must go through ``os._exit`` to terminate unconditionally.
10+
11+
Also covers SLS-380: the best-effort unhealthy report the worker sends to the
12+
host before force-exiting.
1013
"""
1114

1215
import subprocess
1316
import sys
14-
from unittest.mock import patch
17+
from unittest.mock import MagicMock, patch
18+
19+
import pytest
1520

1621
from runpod.serverless.modules import rp_fitness
1722

@@ -92,3 +97,78 @@ def test_terminate_unhealthy_exits_even_if_flush_raises():
9297
rp_fitness._terminate_unhealthy(1)
9398

9499
mock_exit.assert_called_once_with(1)
100+
101+
102+
def test_report_unhealthy_posts_check_and_reason(monkeypatch):
103+
monkeypatch.setenv("RUNPOD_WEBHOOK_PING", "https://api.test/ping/$RUNPOD_POD_ID")
104+
monkeypatch.setenv("RUNPOD_AI_API_KEY", "key-123")
105+
106+
fake_session = MagicMock()
107+
with patch("runpod.http_client.SyncClientSession", return_value=fake_session), \
108+
patch("runpod.serverless.modules.worker_state.WORKER_ID", "podABC"):
109+
rp_fitness._report_unhealthy("_cuda_init_check", "RuntimeError: boom")
110+
111+
assert fake_session.get.call_count == 1
112+
call = fake_session.get.call_args
113+
url = call.args[0] if call.args else call.kwargs["url"]
114+
params = call.kwargs["params"]
115+
assert "podABC" in url # $RUNPOD_POD_ID substituted
116+
assert params["status"] == "unhealthy"
117+
assert params["check"] == "_cuda_init_check"
118+
assert params["reason"] == "RuntimeError: boom"
119+
fake_session.headers.update.assert_called_once_with({"Authorization": "key-123"})
120+
121+
122+
def test_report_unhealthy_skipped_without_ping_url(monkeypatch):
123+
monkeypatch.delenv("RUNPOD_WEBHOOK_PING", raising=False)
124+
monkeypatch.setenv("RUNPOD_AI_API_KEY", "key-123")
125+
with patch("runpod.http_client.SyncClientSession") as session_cls:
126+
rp_fitness._report_unhealthy("_memory_check", "RuntimeError: low")
127+
session_cls.assert_not_called()
128+
129+
130+
def test_report_unhealthy_truncates_long_reason(monkeypatch):
131+
monkeypatch.setenv("RUNPOD_WEBHOOK_PING", "https://api.test/ping")
132+
monkeypatch.setenv("RUNPOD_AI_API_KEY", "key-123")
133+
134+
fake_session = MagicMock()
135+
with patch("runpod.http_client.SyncClientSession", return_value=fake_session):
136+
rp_fitness._report_unhealthy("_disk_check", "x" * 300)
137+
138+
params = fake_session.get.call_args.kwargs["params"]
139+
assert len(params["reason"]) == 256
140+
141+
142+
def test_report_unhealthy_skipped_without_api_key(monkeypatch):
143+
monkeypatch.setenv("RUNPOD_WEBHOOK_PING", "https://api.test/ping")
144+
monkeypatch.delenv("RUNPOD_AI_API_KEY", raising=False)
145+
with patch("runpod.http_client.SyncClientSession") as session_cls:
146+
rp_fitness._report_unhealthy("_memory_check", "RuntimeError: low")
147+
session_cls.assert_not_called()
148+
149+
150+
def test_report_unhealthy_swallows_errors(monkeypatch):
151+
monkeypatch.setenv("RUNPOD_WEBHOOK_PING", "https://api.test/ping")
152+
monkeypatch.setenv("RUNPOD_AI_API_KEY", "key-123")
153+
fake_session = MagicMock()
154+
fake_session.get.side_effect = RuntimeError("network down")
155+
with patch("runpod.http_client.SyncClientSession", return_value=fake_session):
156+
rp_fitness._report_unhealthy("_disk_check", "RuntimeError: full") # must not raise
157+
158+
159+
@pytest.mark.asyncio
160+
async def test_failure_reports_before_exit():
161+
from runpod.serverless.modules.rp_fitness import register_fitness_check, run_fitness_checks
162+
163+
@register_fitness_check
164+
def _cuda_init_check():
165+
raise RuntimeError("device busy")
166+
167+
with patch("runpod.serverless.modules.rp_fitness._report_unhealthy") as mock_report:
168+
with pytest.raises(SystemExit):
169+
await run_fitness_checks()
170+
171+
mock_report.assert_called_once()
172+
args = mock_report.call_args.args
173+
assert args[0] == "_cuda_init_check"
174+
assert "RuntimeError" in args[1] and "device busy" in args[1]

0 commit comments

Comments
 (0)