chankhavu commited on
Commit
d99a0e1
Β·
verified Β·
1 Parent(s): 1d99cf9

Harden: per-session workers, SIGINT-children (keep session), stdin /dev/null, exact marker, put size cap, shell reaper, telemetry off

Browse files
daemon/__pycache__/client.cpython-311.pyc CHANGED
Binary files a/daemon/__pycache__/client.cpython-311.pyc and b/daemon/__pycache__/client.cpython-311.pyc differ
 
daemon/client.py CHANGED
@@ -13,10 +13,23 @@ Run it (foreground, or under nohup / tmux / systemd):
13
 
14
  Each command carries a `session` name. Commands with the same session name share
15
  one long-lived bash process, so `cd`, exports, and activated venvs persist.
16
- A new session name spins up a fresh shell.
 
 
 
 
 
 
17
  """
18
 
19
  import os
 
 
 
 
 
 
 
20
  import sys
21
  import time
22
  import signal
@@ -27,6 +40,7 @@ import threading
27
  import subprocess
28
  import uuid
29
  import queue as queuelib
 
30
 
31
  from gradio_client import Client, handle_file
32
 
@@ -63,6 +77,8 @@ POLL_INTERVAL = float(os.environ.get("POLL_INTERVAL", "5"))
63
  DEFAULT_TIMEOUT = int(os.environ.get("CMD_TIMEOUT", "120"))
64
  MAX_OUTPUT_CHARS = 200_000
65
  MAX_FILE_BYTES = 100 * 1024 * 1024 # 100 MB cap on transfers
 
 
66
 
67
  _log_lock = threading.Lock()
68
 
@@ -72,118 +88,214 @@ def log(*a):
72
  print(f"[{time.strftime('%H:%M:%S')}]", *a, flush=True)
73
 
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  # --------------------------------------------------------------------------- #
76
  # Persistent bash session #
77
  # --------------------------------------------------------------------------- #
78
  class Shell:
79
- """A long-lived bash process. Commands are run serially; output is captured
80
- up to a sentinel line that also carries the exit code."""
81
 
82
  def __init__(self, name: str):
83
  self.name = name
84
- self._q: "queuelib.Queue[str]" = queuelib.Queue()
 
85
  self._start()
86
 
87
  def _start(self):
 
 
 
88
  self.proc = subprocess.Popen(
89
  ["bash", "--norc", "--noprofile"],
90
  stdin=subprocess.PIPE, stdout=subprocess.PIPE,
91
  stderr=subprocess.STDOUT, text=True, bufsize=1,
92
- start_new_session=True, # own process group, so we can SIGINT it
93
  env={**os.environ, "PS1": "", "TERM": "dumb"},
94
  )
95
- self._reader = threading.Thread(target=self._pump, daemon=True)
96
- self._reader.start()
97
 
98
- def _pump(self):
99
- for line in self.proc.stdout:
100
- self._q.put(line)
101
- self._q.put(None) # EOF sentinel
 
102
 
103
- def _drain_pending(self):
104
  while True:
105
  try:
106
  self._q.get_nowait()
107
  except queuelib.Empty:
108
  return
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  def run(self, command: str, timeout: int) -> tuple[str, int]:
111
  if self.proc.poll() is not None: # shell died (e.g. `exit`) β€” respawn
112
  log(f"session {self.name}: shell exited, restarting")
113
  self._start()
114
 
115
- self._drain_pending()
116
- marker = "__NII_END_" + uuid.uuid4().hex + "__"
117
- # Run the command, then print a sentinel with the exit status on its own line.
118
- script = f"{command}\nprintf '\\n%s %s\\n' '{marker}' \"$?\"\n"
119
  try:
120
- self.proc.stdin.write(script)
121
- self.proc.stdin.flush()
122
- except BrokenPipeError:
123
- return ("(shell pipe broken)", 127)
124
-
125
- out_lines: list[str] = []
126
- exit_code = 0
127
- deadline = time.monotonic() + max(1, timeout)
128
- interrupted = False
129
- while True:
130
- remaining = deadline - time.monotonic()
131
- if remaining <= 0 and not interrupted:
132
- # Interrupt the running command but keep the shell alive.
133
- try:
134
- os.killpg(self.proc.pid, signal.SIGINT)
135
- except ProcessLookupError:
136
- pass
137
- out_lines.append(f"\n[relay: timed out after {timeout}s, sent SIGINT]\n")
138
- interrupted = True
139
- deadline = time.monotonic() + 5 # grace to collect the sentinel
140
- continue
141
  try:
142
- line = self._q.get(timeout=max(0.1, remaining if not interrupted else 0.5))
143
- except queuelib.Empty:
144
- if interrupted:
145
- break
146
- continue
147
- if line is None: # shell EOF
148
- if interrupted:
149
- exit_code = 124 # conventional timeout exit code
150
- else:
151
- out_lines.append("\n[relay: shell closed]\n")
152
- exit_code = 137
153
- break
154
- stripped = line.rstrip("\n")
155
- if stripped.startswith(marker):
156
- parts = stripped.split()
 
 
157
  try:
158
- exit_code = int(parts[-1])
159
- except (ValueError, IndexError):
160
- exit_code = 0
161
- break
162
- out_lines.append(line)
163
-
164
- output = "".join(out_lines)
165
- # Trim the single leading newline our printf added before the sentinel.
166
- if output.endswith("\n"):
167
- output = output[:-1]
168
- if len(output) > MAX_OUTPUT_CHARS:
169
- output = output[:MAX_OUTPUT_CHARS] + "\n[relay: output truncated]"
170
- return (output, exit_code)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
 
172
 
173
  SHELLS: dict[str, Shell] = {}
 
174
 
175
 
176
  def get_shell(name: str) -> Shell:
177
- sh = SHELLS.get(name)
178
- if sh is None:
179
- sh = SHELLS[name] = Shell(name)
180
- log(f"opened bash session: {name}")
181
- return sh
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
 
184
  # --------------------------------------------------------------------------- #
185
- # Relay client #
186
  # --------------------------------------------------------------------------- #
 
 
 
187
  def _make_client() -> Client:
188
  # gradio_client renamed the auth kwarg from `hf_token` to `token` (~v2.x).
189
  try:
@@ -192,23 +304,43 @@ def _make_client() -> Client:
192
  return Client(SPACE, hf_token=HF_TOKEN, verbose=False)
193
 
194
 
 
 
 
 
195
  def connect() -> Client:
 
196
  while True:
197
  try:
198
  log(f"connecting to Space {SPACE} …")
199
- client = _make_client()
200
- client.predict(CLIENT_ID, SECRET, _meta(), api_name="/register")
 
201
  log(f"registered as '{CLIENT_ID}'")
202
- return client
203
  except Exception as e:
204
- log(f"connect failed: {e!r} β€” retrying in 10s")
 
205
  time.sleep(10)
206
 
207
 
208
- def _meta() -> str:
209
- return f"{os.uname().sysname} {os.uname().release} pid={os.getpid()}"
 
 
 
 
 
 
 
 
 
 
210
 
211
 
 
 
 
212
  def _expand(p: str) -> str:
213
  return os.path.expanduser(os.path.expandvars(p or ""))
214
 
@@ -219,31 +351,34 @@ def _result_path(ret) -> str:
219
  return ret.get("path") or ret.get("name") or ""
220
  if isinstance(ret, (list, tuple)) and ret:
221
  return _result_path(ret[0])
222
- return ret
223
 
224
 
225
- def do_put(client: Client, cmd: dict):
226
  """Server -> client: download the payload from the relay and write it to dest."""
227
  cid = cmd["id"]
228
  dest = _expand(cmd.get("dest") or cmd.get("filename") or "downloaded.bin")
229
  log(f"[file] put β†’ {dest}")
230
  try:
231
- ret = client.predict(cmd["file_id"], SECRET, api_name="/fetch_file")
232
  src = _result_path(ret)
 
 
 
 
 
233
  if os.path.isdir(dest) or dest.endswith(os.sep):
234
  dest = os.path.join(dest, cmd.get("filename") or os.path.basename(src))
235
- parent = os.path.dirname(os.path.abspath(dest))
236
- os.makedirs(parent, exist_ok=True)
237
  shutil.copy(src, dest)
238
- size = os.path.getsize(dest)
239
- out, code = (f"wrote {size} bytes to {dest}", 0)
240
  except Exception as e:
241
- out, code = (f"[relay: put failed: {e!r}]", 1)
242
- client.predict(cid, CLIENT_ID, SECRET, code, out, "file", api_name="/result")
243
  log(f"[file] put β†’ exit {code}")
244
 
245
 
246
- def do_get(client: Client, cmd: dict):
247
  """Client -> server: read a local file and upload it to the relay."""
248
  cid = cmd["id"]
249
  remote = _expand(cmd.get("remote_path"))
@@ -254,20 +389,26 @@ def do_get(client: Client, cmd: dict):
254
  size = os.path.getsize(remote)
255
  if size > MAX_FILE_BYTES:
256
  raise ValueError(f"{size} bytes exceeds the {MAX_FILE_BYTES}-byte limit")
257
- client.predict(cid, CLIENT_ID, SECRET, handle_file(remote), "",
258
- api_name="/upload_result")
259
- log(f"[file] get β†’ sent {size} bytes")
260
  except Exception as e:
261
- client.predict(cid, CLIENT_ID, SECRET, None, f"{e}", api_name="/upload_result")
 
 
 
262
  log(f"[file] get β†’ error: {e}")
 
 
 
 
 
 
263
 
264
 
265
- def handle(client: Client, cmd: dict):
266
  typ = cmd.get("type", "exec")
267
  if typ == "put":
268
- return do_put(client, cmd)
269
  if typ == "get":
270
- return do_get(client, cmd)
271
  cid = cmd["id"]
272
  session = cmd.get("session") or "main"
273
  command = cmd["command"]
@@ -277,29 +418,75 @@ def handle(client: Client, cmd: dict):
277
  output, code = get_shell(session).run(command, timeout)
278
  except Exception as e:
279
  output, code = (f"[relay: daemon error: {e!r}]", 1)
280
- client.predict(cid, CLIENT_ID, SECRET, code, output, session, api_name="/result")
281
  log(f"[{session}] β†’ exit {code} ({len(output)} bytes)")
282
 
283
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  def main():
285
  if not SPACE:
286
  sys.exit("set RELAY_SPACE to the Space repo id, e.g. your-username/nii-relay")
 
 
287
  log(f"daemon starting β€” client_id='{CLIENT_ID}', poll every {POLL_INTERVAL}s")
288
- client = connect()
 
289
  while True:
290
  try:
291
- res = client.predict(CLIENT_ID, SECRET, api_name="/poll")
292
- commands = (res or {}).get("commands", [])
293
- for cmd in commands:
294
- handle(client, cmd)
295
  except KeyboardInterrupt:
296
  log("bye")
 
297
  return
298
  except Exception as e:
299
- log(f"poll error: {e!r} β€” reconnecting")
300
- client = connect()
301
  time.sleep(POLL_INTERVAL)
302
 
303
 
304
  if __name__ == "__main__":
305
- main()
 
 
 
 
13
 
14
  Each command carries a `session` name. Commands with the same session name share
15
  one long-lived bash process, so `cd`, exports, and activated venvs persist.
16
+ A new session name spins up a fresh shell. Different sessions run concurrently
17
+ (one worker thread each), so a long command in one session never blocks another
18
+ session or the poll loop that keeps this client marked online.
19
+
20
+ Airgapped/restricted-network friendly: outbound HTTPS only (it reaches
21
+ huggingface.co AND <user>-<space>.hf.space), never binds a port, and disables
22
+ all telemetry by default.
23
  """
24
 
25
  import os
26
+
27
+ # Disable all phone-home before importing gradio_client / huggingface_hub, so a
28
+ # restricted-egress container never tries to reach api.gradio.app etc.
29
+ os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
30
+ os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
31
+ os.environ.setdefault("DO_NOT_TRACK", "1")
32
+
33
  import sys
34
  import time
35
  import signal
 
40
  import subprocess
41
  import uuid
42
  import queue as queuelib
43
+ from collections import defaultdict
44
 
45
  from gradio_client import Client, handle_file
46
 
 
77
  DEFAULT_TIMEOUT = int(os.environ.get("CMD_TIMEOUT", "120"))
78
  MAX_OUTPUT_CHARS = 200_000
79
  MAX_FILE_BYTES = 100 * 1024 * 1024 # 100 MB cap on transfers
80
+ MAX_SHELLS = int(os.environ.get("MAX_SHELLS", "48"))
81
+ SHELL_IDLE_TTL = int(os.environ.get("SHELL_IDLE_TTL", "1800")) # reap idle shells after 30 min
82
 
83
  _log_lock = threading.Lock()
84
 
 
88
  print(f"[{time.strftime('%H:%M:%S')}]", *a, flush=True)
89
 
90
 
91
+ # --------------------------------------------------------------------------- #
92
+ # Process-tree helpers (used to interrupt a command without killing bash) #
93
+ # --------------------------------------------------------------------------- #
94
+ def _descendants(pid: int) -> list[int]:
95
+ """All descendant pids of `pid`, via /proc. Bash itself is NOT included."""
96
+ children: dict[int, list[int]] = defaultdict(list)
97
+ try:
98
+ entries = os.listdir("/proc")
99
+ except OSError:
100
+ return []
101
+ for d in entries:
102
+ if not d.isdigit():
103
+ continue
104
+ try:
105
+ with open(f"/proc/{d}/stat") as f:
106
+ data = f.read()
107
+ # format: "pid (comm) state ppid ..."; comm may contain spaces/parens
108
+ rest = data[data.rfind(")") + 2:].split()
109
+ ppid = int(rest[1])
110
+ except (OSError, IndexError, ValueError):
111
+ continue
112
+ children[ppid].append(int(d))
113
+ out, stack = [], [pid]
114
+ while stack:
115
+ for c in children.get(stack.pop(), []):
116
+ out.append(c)
117
+ stack.append(c)
118
+ return out
119
+
120
+
121
  # --------------------------------------------------------------------------- #
122
  # Persistent bash session #
123
  # --------------------------------------------------------------------------- #
124
  class Shell:
125
+ """A long-lived bash process. Commands run serially within the session;
126
+ output is captured up to a unique sentinel line that carries the exit code."""
127
 
128
  def __init__(self, name: str):
129
  self.name = name
130
+ self.busy = False
131
+ self.last_used = time.time()
132
  self._start()
133
 
134
  def _start(self):
135
+ # Fresh queue + reader per incarnation so a previous reader's leftover
136
+ # output (or its EOF marker) can never leak into the new shell.
137
+ self._q: "queuelib.Queue[str | None]" = queuelib.Queue()
138
  self.proc = subprocess.Popen(
139
  ["bash", "--norc", "--noprofile"],
140
  stdin=subprocess.PIPE, stdout=subprocess.PIPE,
141
  stderr=subprocess.STDOUT, text=True, bufsize=1,
142
+ start_new_session=True,
143
  env={**os.environ, "PS1": "", "TERM": "dumb"},
144
  )
145
+ t = threading.Thread(target=self._pump, args=(self.proc, self._q), daemon=True)
146
+ t.start()
147
 
148
+ @staticmethod
149
+ def _pump(proc, q):
150
+ for line in proc.stdout:
151
+ q.put(line)
152
+ q.put(None) # EOF sentinel
153
 
154
+ def _drain(self):
155
  while True:
156
  try:
157
  self._q.get_nowait()
158
  except queuelib.Empty:
159
  return
160
 
161
+ def _interrupt(self):
162
+ """Stop the running command but keep bash alive: signal only bash's
163
+ descendants (the command + its children), never bash itself."""
164
+ for sig in (signal.SIGINT, signal.SIGTERM):
165
+ kids = _descendants(self.proc.pid)
166
+ if not kids:
167
+ return
168
+ for k in kids:
169
+ try:
170
+ os.kill(k, sig)
171
+ except OSError:
172
+ pass
173
+ time.sleep(0.4)
174
+ for k in _descendants(self.proc.pid):
175
+ try:
176
+ os.kill(k, signal.SIGKILL)
177
+ except OSError:
178
+ pass
179
+
180
+ def close(self):
181
+ try:
182
+ self.proc.terminate()
183
+ except Exception:
184
+ pass
185
+
186
  def run(self, command: str, timeout: int) -> tuple[str, int]:
187
  if self.proc.poll() is not None: # shell died (e.g. `exit`) β€” respawn
188
  log(f"session {self.name}: shell exited, restarting")
189
  self._start()
190
 
191
+ self.busy = True
 
 
 
192
  try:
193
+ self._drain()
194
+ marker = "__NII_END_" + uuid.uuid4().hex + "__"
195
+ # Run the command as a group with stdin from /dev/null so commands
196
+ # that read stdin (cat, a REPL, ssh, read) can't swallow the sentinel
197
+ # and hang. The group is NOT a subshell, so cd/exports still persist.
198
+ # Then print the sentinel + exit code on its own line.
199
+ script = (
200
+ "{\n" + command + "\n} </dev/null\n"
201
+ + f"printf '\\n%s %s\\n' '{marker}' \"$?\"\n"
202
+ )
 
 
 
 
 
 
 
 
 
 
 
203
  try:
204
+ self.proc.stdin.write(script)
205
+ self.proc.stdin.flush()
206
+ except (BrokenPipeError, ValueError):
207
+ return ("[relay: shell pipe broken]", 127)
208
+
209
+ out_lines: list[str] = []
210
+ exit_code = 0
211
+ deadline = time.monotonic() + max(1, timeout)
212
+ killed = False
213
+ while True:
214
+ remaining = deadline - time.monotonic()
215
+ if remaining <= 0 and not killed:
216
+ self._interrupt()
217
+ out_lines.append(f"\n[relay: timed out after {timeout}s, interrupted]\n")
218
+ killed = True
219
+ deadline = time.monotonic() + 8 # grace to collect the sentinel
220
+ continue
221
  try:
222
+ wait = max(0.1, (deadline - time.monotonic()) if not killed else 0.5)
223
+ line = self._q.get(timeout=wait)
224
+ except queuelib.Empty:
225
+ if killed and time.monotonic() > deadline:
226
+ # bash never produced the sentinel β€” give up, respawn.
227
+ self.close()
228
+ self._start()
229
+ exit_code = 124
230
+ break
231
+ continue
232
+ if line is None: # shell EOF
233
+ if not killed:
234
+ out_lines.append("\n[relay: shell closed]\n")
235
+ exit_code = 137 if not killed else 130
236
+ break
237
+ stripped = line.rstrip("\n")
238
+ # Exact sentinel match: "<marker> <int>" only.
239
+ if stripped.startswith(marker + " "):
240
+ tail = stripped[len(marker) + 1:].strip()
241
+ if tail.isdigit():
242
+ exit_code = int(tail)
243
+ break
244
+ out_lines.append(line)
245
+
246
+ output = "".join(out_lines)
247
+ if output.endswith("\n"): # drop the single newline our printf injected
248
+ output = output[:-1]
249
+ if len(output) > MAX_OUTPUT_CHARS:
250
+ output = output[:MAX_OUTPUT_CHARS] + "\n[relay: output truncated]"
251
+ return (output, exit_code)
252
+ finally:
253
+ self.busy = False
254
+ self.last_used = time.time()
255
 
256
 
257
  SHELLS: dict[str, Shell] = {}
258
+ SHELLS_LOCK = threading.Lock()
259
 
260
 
261
  def get_shell(name: str) -> Shell:
262
+ with SHELLS_LOCK:
263
+ sh = SHELLS.get(name)
264
+ if sh is not None and sh.proc.poll() is not None:
265
+ sh.close()
266
+ sh = None
267
+ if sh is None:
268
+ # cap total shells: evict the least-recently-used idle one
269
+ if len(SHELLS) >= MAX_SHELLS:
270
+ idle = [(s.last_used, n) for n, s in SHELLS.items() if not s.busy]
271
+ if idle:
272
+ _, victim = min(idle)
273
+ SHELLS.pop(victim).close()
274
+ log(f"evicted idle session (cap): {victim}")
275
+ sh = SHELLS[name] = Shell(name)
276
+ log(f"opened bash session: {name}")
277
+ return sh
278
+
279
+
280
+ def _shell_reaper():
281
+ while True:
282
+ time.sleep(120)
283
+ now = time.time()
284
+ with SHELLS_LOCK:
285
+ for name in list(SHELLS):
286
+ sh = SHELLS[name]
287
+ if not sh.busy and (now - sh.last_used) > SHELL_IDLE_TTL:
288
+ sh.close()
289
+ SHELLS.pop(name, None)
290
+ log(f"reaped idle session: {name}")
291
 
292
 
293
  # --------------------------------------------------------------------------- #
294
+ # Relay client (shared, swapped atomically on reconnect) #
295
  # --------------------------------------------------------------------------- #
296
+ CLIENT: Client | None = None
297
+
298
+
299
  def _make_client() -> Client:
300
  # gradio_client renamed the auth kwarg from `hf_token` to `token` (~v2.x).
301
  try:
 
304
  return Client(SPACE, hf_token=HF_TOKEN, verbose=False)
305
 
306
 
307
+ def _meta() -> str:
308
+ return f"{os.uname().sysname} {os.uname().release} pid={os.getpid()}"
309
+
310
+
311
  def connect() -> Client:
312
+ global CLIENT
313
  while True:
314
  try:
315
  log(f"connecting to Space {SPACE} …")
316
+ c = _make_client()
317
+ c.predict(CLIENT_ID, SECRET, _meta(), api_name="/register")
318
+ CLIENT = c
319
  log(f"registered as '{CLIENT_ID}'")
320
+ return c
321
  except Exception as e:
322
+ # Don't log raw exception β€” it can include request payload (the secret).
323
+ log(f"connect failed: {type(e).__name__} β€” retrying in 10s")
324
  time.sleep(10)
325
 
326
 
327
+ def _post(*args, **kwargs):
328
+ c = CLIENT
329
+ if c is None:
330
+ raise RuntimeError("no relay connection")
331
+ return c.predict(*args, **kwargs)
332
+
333
+
334
+ def _safe_result(cid: str, code: int, output: str, session: str):
335
+ try:
336
+ _post(cid, CLIENT_ID, SECRET, code, output, session, api_name="/result")
337
+ except Exception as e:
338
+ log(f"result post failed for {session}: {type(e).__name__}: {e}")
339
 
340
 
341
+ # --------------------------------------------------------------------------- #
342
+ # Command handlers (run on per-session worker threads) #
343
+ # --------------------------------------------------------------------------- #
344
  def _expand(p: str) -> str:
345
  return os.path.expanduser(os.path.expandvars(p or ""))
346
 
 
351
  return ret.get("path") or ret.get("name") or ""
352
  if isinstance(ret, (list, tuple)) and ret:
353
  return _result_path(ret[0])
354
+ return ret if isinstance(ret, str) else ""
355
 
356
 
357
+ def do_put(cmd: dict):
358
  """Server -> client: download the payload from the relay and write it to dest."""
359
  cid = cmd["id"]
360
  dest = _expand(cmd.get("dest") or cmd.get("filename") or "downloaded.bin")
361
  log(f"[file] put β†’ {dest}")
362
  try:
363
+ ret = _post(cmd["file_id"], SECRET, api_name="/fetch_file")
364
  src = _result_path(ret)
365
+ if not src or not os.path.isfile(src):
366
+ raise FileNotFoundError("relay returned no file")
367
+ size = os.path.getsize(src)
368
+ if size > MAX_FILE_BYTES:
369
+ raise ValueError(f"{size} bytes exceeds the {MAX_FILE_BYTES}-byte limit")
370
  if os.path.isdir(dest) or dest.endswith(os.sep):
371
  dest = os.path.join(dest, cmd.get("filename") or os.path.basename(src))
372
+ os.makedirs(os.path.dirname(os.path.abspath(dest)) or ".", exist_ok=True)
 
373
  shutil.copy(src, dest)
374
+ out, code = (f"wrote {os.path.getsize(dest)} bytes to {dest}", 0)
 
375
  except Exception as e:
376
+ out, code = (f"[relay: put failed: {e}]", 1)
377
+ _safe_result(cid, code, out, "file")
378
  log(f"[file] put β†’ exit {code}")
379
 
380
 
381
+ def do_get(cmd: dict):
382
  """Client -> server: read a local file and upload it to the relay."""
383
  cid = cmd["id"]
384
  remote = _expand(cmd.get("remote_path"))
 
389
  size = os.path.getsize(remote)
390
  if size > MAX_FILE_BYTES:
391
  raise ValueError(f"{size} bytes exceeds the {MAX_FILE_BYTES}-byte limit")
 
 
 
392
  except Exception as e:
393
+ try:
394
+ _post(cid, CLIENT_ID, SECRET, None, f"{e}", api_name="/upload_result")
395
+ except Exception as e2:
396
+ log(f"error report failed: {type(e2).__name__}: {e2}")
397
  log(f"[file] get β†’ error: {e}")
398
+ return
399
+ try:
400
+ _post(cid, CLIENT_ID, SECRET, handle_file(remote), "", api_name="/upload_result")
401
+ log(f"[file] get β†’ sent {size} bytes")
402
+ except Exception as e:
403
+ log(f"[file] get upload failed: {type(e).__name__}: {e}")
404
 
405
 
406
+ def handle(cmd: dict):
407
  typ = cmd.get("type", "exec")
408
  if typ == "put":
409
+ return do_put(cmd)
410
  if typ == "get":
411
+ return do_get(cmd)
412
  cid = cmd["id"]
413
  session = cmd.get("session") or "main"
414
  command = cmd["command"]
 
418
  output, code = get_shell(session).run(command, timeout)
419
  except Exception as e:
420
  output, code = (f"[relay: daemon error: {e!r}]", 1)
421
+ _safe_result(cid, code, output, session)
422
  log(f"[{session}] β†’ exit {code} ({len(output)} bytes)")
423
 
424
 
425
+ # --------------------------------------------------------------------------- #
426
+ # Per-session workers: commands for a session run on that session's thread, #
427
+ # so the poll loop never blocks and sessions run concurrently. #
428
+ # --------------------------------------------------------------------------- #
429
+ class Worker:
430
+ def __init__(self, name: str):
431
+ self.name = name
432
+ self.q: "queuelib.Queue[dict]" = queuelib.Queue()
433
+ threading.Thread(target=self._loop, daemon=True).start()
434
+
435
+ def submit(self, cmd: dict):
436
+ self.q.put(cmd)
437
+
438
+ def _loop(self):
439
+ while True:
440
+ cmd = self.q.get()
441
+ try:
442
+ handle(cmd)
443
+ except Exception as e:
444
+ log(f"worker[{self.name}] error: {type(e).__name__}: {e}")
445
+
446
+
447
+ WORKERS: dict[str, Worker] = {}
448
+ WORKERS_LOCK = threading.Lock()
449
+
450
+
451
+ def get_worker(name: str) -> Worker:
452
+ with WORKERS_LOCK:
453
+ w = WORKERS.get(name)
454
+ if w is None:
455
+ w = WORKERS[name] = Worker(name)
456
+ return w
457
+
458
+
459
+ def _shutdown():
460
+ with SHELLS_LOCK:
461
+ for sh in list(SHELLS.values()):
462
+ sh.close()
463
+
464
+
465
  def main():
466
  if not SPACE:
467
  sys.exit("set RELAY_SPACE to the Space repo id, e.g. your-username/nii-relay")
468
+ if not SECRET:
469
+ log("WARNING: RELAY_SECRET is empty β€” the relay will reject this daemon")
470
  log(f"daemon starting β€” client_id='{CLIENT_ID}', poll every {POLL_INTERVAL}s")
471
+ threading.Thread(target=_shell_reaper, daemon=True).start()
472
+ connect()
473
  while True:
474
  try:
475
+ res = CLIENT.predict(CLIENT_ID, SECRET, api_name="/poll")
476
+ for cmd in (res or {}).get("commands", []):
477
+ get_worker(cmd.get("session") or "main").submit(cmd)
 
478
  except KeyboardInterrupt:
479
  log("bye")
480
+ _shutdown()
481
  return
482
  except Exception as e:
483
+ log(f"poll error: {type(e).__name__} β€” reconnecting")
484
+ connect()
485
  time.sleep(POLL_INTERVAL)
486
 
487
 
488
  if __name__ == "__main__":
489
+ try:
490
+ main()
491
+ except KeyboardInterrupt:
492
+ _shutdown()