2020"""
2121
2222import json
23+ import math
2324import os
2425import sys
2526import threading
27+ import time
28+ import urllib .error
29+ import urllib .request
2630from http .server import BaseHTTPRequestHandler , ThreadingHTTPServer
2731
2832from runpod_sdk_runtime .executor import execute_request
33+ from runpod_sdk_runtime .mounts import configure_mounts
2934
3035PORT = int (os .environ .get ("RUNPOD_TASK_PORT" , "8080" ))
3136TOKEN = os .environ .get ("RUNPOD_TASK_TOKEN" , "" )
3237
33- # watchdog: self-terminate when the client is clearly gone. RUNNING
34- # jobs are never killed (terminateAfter is the runaway backstop);
35- # state NONE means the client died before submitting, while DONE means
36- # the result sat uncollected. normal flows poll every ~2s, so these never fire
37- # for a live client.
38+ # active work is exempt from idle cleanup.
3839IDLE_TIMEOUT = float (os .environ .get ("RUNPOD_TASK_IDLE_TIMEOUT" , "600" ))
3940WATCHDOG_INTERVAL = 15.0
41+ TERMINATE_ATTEMPTS = 3
42+ TERMINATE_TIMEOUT = 5.0
4043
4144# single background job slot for /submit + /result
4245_job_lock = threading .Lock ()
4346_job_state = {"status" : "NONE" , "response" : None }
4447_last_contact = {"ts" : None } # set at server start
45- # inline /execute requests in flight; the watchdog must not terminate
46- # the pod while one runs (long executes outlive the idle timeout)
48+ # inline requests are protected from idle cleanup while running.
4749_inline_executions = {"count" : 0 }
50+ _execution_deadline = None
51+ _watchdog_wakeup = threading .Event ()
4852
4953
5054def _touch_contact ():
5155 import time
5256
53- _last_contact ["ts" ] = time .time ()
57+ _last_contact ["ts" ] = time .monotonic ()
58+
59+
60+ def _set_timeout (timeout ):
61+ global _execution_deadline
62+ if timeout is not None :
63+ deadline = time .monotonic () + timeout
64+ if _execution_deadline is None or deadline < _execution_deadline :
65+ _execution_deadline = deadline
66+ _watchdog_wakeup .set ()
5467
5568
5669def _should_self_terminate (status , last_contact , now , idle_timeout , inline = 0 ):
57- """the watchdog decision: kill only provably-abandoned pods."""
5870 if status == "RUNNING" or inline > 0 :
5971 return False
6072 if last_contact is None :
@@ -63,57 +75,101 @@ def _should_self_terminate(status, last_contact, now, idle_timeout, inline=0):
6375
6476
6577def _terminate_self ():
66- """terminate this pod via the injected pod-scoped api key.
67-
68- every pod carries a RUNPOD_API_KEY scoped to itself; podTerminate
69- with it removes the pod entirely. exiting the process is the
70- fallback (stops the workload; terminateAfter finishes the job).
71- """
72- import json as _json
73- import urllib .request
74-
78+ """exit only after the control plane acknowledges pod deletion."""
7579 pod_id = os .environ .get ("RUNPOD_POD_ID" )
7680 api_key = os .environ .get ("RUNPOD_API_KEY" )
77- if pod_id and api_key :
81+ if not pod_id or not api_key :
82+ sys .stderr .write (
83+ "[task-runner] cannot delete pod: missing RUNPOD_POD_ID or RUNPOD_API_KEY\n "
84+ )
85+ return False
86+ api_base = os .environ .get ("RUNPOD_API_BASE_URL" , "https://api.runpod.io" )
87+ payload = json .dumps (
88+ {
89+ "query" : (
90+ "mutation podTerminate($input: PodTerminateInput!) "
91+ "{ podTerminate(input: $input) }"
92+ ),
93+ "variables" : {"input" : {"podId" : pod_id }},
94+ }
95+ ).encode ()
96+ request = urllib .request .Request (
97+ f"{ api_base .rstrip ('/' )} /graphql" ,
98+ data = payload ,
99+ headers = {
100+ "Content-Type" : "application/json" ,
101+ "Authorization" : f"Bearer { api_key } " ,
102+ },
103+ )
104+ for attempt in range (TERMINATE_ATTEMPTS ):
105+ retryable = False
78106 try :
79- api_base = os .environ .get ("RUNPOD_API_BASE_URL" , "https://api.runpod.io" )
80- payload = _json .dumps (
81- {
82- "query" : (
83- "mutation podTerminate($input: PodTerminateInput!) "
84- "{ podTerminate(input: $input) }"
85- ),
86- "variables" : {"input" : {"podId" : pod_id }},
87- }
88- ).encode ()
89- request = urllib .request .Request (
90- f"{ api_base } /graphql" ,
91- data = payload ,
92- headers = {
93- "Content-Type" : "application/json" ,
94- "Authorization" : f"Bearer { api_key } " ,
95- },
107+ with urllib .request .urlopen (request , timeout = TERMINATE_TIMEOUT ) as response : # noqa: S310
108+ body = json .load (response )
109+ errors = body .get ("errors" ) if isinstance (body , dict ) else None
110+ data = body .get ("data" ) if isinstance (body , dict ) else None
111+ if (
112+ not errors
113+ and isinstance (data , dict )
114+ and "podTerminate" in data
115+ and data ["podTerminate" ] is None
116+ ):
117+ sys .stderr .write (f"[task-runner] pod { pod_id } deletion acknowledged\n " )
118+ os ._exit (0 )
119+ return True
120+ if isinstance (errors , list ) and errors :
121+ retryable = all (
122+ isinstance (error , dict )
123+ and isinstance (error .get ("extensions" ), dict )
124+ and error ["extensions" ].get ("code" )
125+ in {
126+ "INTERNAL_SERVER_ERROR" ,
127+ "SERVICE_UNAVAILABLE" ,
128+ "TOO_MANY_REQUESTS" ,
129+ "TIMEOUT" ,
130+ }
131+ for error in errors
132+ )
133+ failure = "graphql errors" if errors else "missing deletion acknowledgment"
134+ except urllib .error .HTTPError as exc :
135+ retryable = exc .code == 429 or 500 <= exc .code < 600
136+ failure = f"http { exc .code } "
137+ exc .close ()
138+ except (urllib .error .URLError , OSError ) as exc :
139+ retryable = True
140+ failure = str (exc )
141+ except (ValueError , TypeError ) as exc :
142+ failure = f"invalid deletion response: { exc } "
143+ if not retryable or attempt == TERMINATE_ATTEMPTS - 1 :
144+ sys .stderr .write (
145+ f"[task-runner] pod { pod_id } deletion failed: { failure } ; "
146+ "keeping runtime alive for watchdog retry\n "
96147 )
97- urllib .request .urlopen (request , timeout = 30 ) # noqa: S310
98- sys .stderr .write ("[task-runner] self-terminated (abandoned)\n " )
99- except Exception : # noqa: BLE001 - fall through to process exit
100- pass
101- os ._exit (0 )
148+ return False
149+ time .sleep (0.5 * (2 ** attempt ))
150+ return False
102151
103152
104153def _watchdog ():
105154 import time
106155
107156 while True :
108- time .sleep (WATCHDOG_INTERVAL )
109- if _should_self_terminate (
110- _job_state ["status" ],
111- _last_contact ["ts" ],
112- time .time (),
113- IDLE_TIMEOUT ,
114- inline = _inline_executions ["count" ],
115- ):
116- _terminate_self ()
157+ with _job_lock :
158+ now = time .monotonic ()
159+ expired = _execution_deadline is not None and now >= _execution_deadline
160+ if expired or _should_self_terminate (
161+ _job_state ["status" ],
162+ _last_contact ["ts" ],
163+ now ,
164+ IDLE_TIMEOUT ,
165+ inline = _inline_executions ["count" ],
166+ ):
167+ _terminate_self ()
168+ delay = WATCHDOG_INTERVAL
169+ if _execution_deadline is not None and _execution_deadline > now :
170+ delay = min (delay , _execution_deadline - now )
171+ _watchdog_wakeup .clear ()
172+ _watchdog_wakeup .wait (delay )
117173
118174
119175class Handler (BaseHTTPRequestHandler ):
@@ -160,21 +216,47 @@ def do_POST(self): # noqa: N802 - BaseHTTPRequestHandler api
160216 if not self ._authed ():
161217 self ._send (401 , {"error" : "unauthorized" })
162218 return
219+ if self .path not in ("/execute" , "/submit" , "/timeout" ):
220+ self ._send (404 , {"error" : "not found" })
221+ return
222+ try :
223+ request = self ._read_request ()
224+ except (ValueError , TypeError ):
225+ self ._send (400 , {"error" : "invalid request" })
226+ return
227+ if not isinstance (request , dict ):
228+ self ._send (400 , {"error" : "invalid request" })
229+ return
230+ timeout = request .get ("timeout" )
231+ if timeout is not None and (
232+ isinstance (timeout , bool )
233+ or not isinstance (timeout , (int , float ))
234+ or not math .isfinite (timeout )
235+ or timeout < 0
236+ ):
237+ self ._send (400 , {"error" : "timeout must be a finite non-negative number" })
238+ return
239+ if self .path == "/timeout" :
240+ with _job_lock :
241+ _set_timeout (timeout )
242+ self ._send (200 , {"timeout" : timeout })
243+ return
163244 if self .path == "/execute" :
164245 with _job_lock :
246+ _set_timeout (timeout )
165247 _inline_executions ["count" ] += 1
166248 try :
167- self ._send (200 , execute_request (self . _read_request () ))
249+ self ._send (200 , execute_request (request ))
168250 finally :
169251 with _job_lock :
170252 _inline_executions ["count" ] -= 1
171253 return
172254 if self .path == "/submit" :
173- request = self ._read_request ()
174255 with _job_lock :
175256 if _job_state ["status" ] == "RUNNING" :
176257 self ._send (409 , {"error" : "a job is already running" })
177258 return
259+ _set_timeout (timeout )
178260 _job_state ["status" ] = "RUNNING"
179261 _job_state ["response" ] = None
180262
@@ -185,9 +267,11 @@ def run():
185267 _job_state ["response" ] = response
186268
187269 threading .Thread (target = run , daemon = True ).start ()
188- self ._send (200 , {"status" : "RUNNING" })
270+ response = {"status" : "RUNNING" }
271+ if timeout is not None :
272+ response ["timeout" ] = timeout
273+ self ._send (200 , response )
189274 return
190- self ._send (404 , {"error" : "not found" })
191275
192276 def log_message (self , format , * args ): # noqa: A002 - stdlib signature
193277 # request logging is noise in container logs (dev sessions
@@ -199,6 +283,7 @@ def main() -> None:
199283 if not TOKEN :
200284 sys .stderr .write ("[task-runner] RUNPOD_TASK_TOKEN not set, exiting\n " )
201285 sys .exit (1 )
286+ configure_mounts ()
202287 _touch_contact ()
203288 threading .Thread (target = _watchdog , daemon = True ).start ()
204289 server = ThreadingHTTPServer (("0.0.0.0" , PORT ), Handler )
0 commit comments