chankhavu commited on
Commit
a0e459e
·
verified ·
1 Parent(s): 63e165b

Add poller daemon (client.py)

Browse files
Files changed (2) hide show
  1. daemon/client.py +210 -0
  2. daemon/requirements.txt +1 -0
daemon/client.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ NII Relay daemon — runs inside a no-SSH container, polls the HF Space relay, and
4
+ executes shell commands in persistent named bash sessions.
5
+
6
+ Run it (foreground, or under nohup / tmux / systemd):
7
+
8
+ export RELAY_SPACE="your-username/nii-relay" # the Space repo id
9
+ export RELAY_SECRET="<same secret as the Space>"
10
+ export HF_TOKEN="hf_..." # read token; needed if Space is private
11
+ export CLIENT_ID="$(hostname)" # how it shows up in the UI (optional)
12
+ python client.py
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
23
+ import threading
24
+ import subprocess
25
+ import uuid
26
+ import queue as queuelib
27
+
28
+ from gradio_client import Client
29
+
30
+ SPACE = os.environ.get("RELAY_SPACE", "")
31
+ SECRET = os.environ.get("RELAY_SECRET", "")
32
+ HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_TOKEN")
33
+ CLIENT_ID = os.environ.get("CLIENT_ID") or os.uname().nodename
34
+ POLL_INTERVAL = float(os.environ.get("POLL_INTERVAL", "5"))
35
+ DEFAULT_TIMEOUT = int(os.environ.get("CMD_TIMEOUT", "120"))
36
+ MAX_OUTPUT_CHARS = 200_000
37
+
38
+ _log_lock = threading.Lock()
39
+
40
+
41
+ def log(*a):
42
+ with _log_lock:
43
+ print(f"[{time.strftime('%H:%M:%S')}]", *a, flush=True)
44
+
45
+
46
+ # --------------------------------------------------------------------------- #
47
+ # Persistent bash session #
48
+ # --------------------------------------------------------------------------- #
49
+ class Shell:
50
+ """A long-lived bash process. Commands are run serially; output is captured
51
+ up to a sentinel line that also carries the exit code."""
52
+
53
+ def __init__(self, name: str):
54
+ self.name = name
55
+ self._q: "queuelib.Queue[str]" = queuelib.Queue()
56
+ self._start()
57
+
58
+ def _start(self):
59
+ self.proc = subprocess.Popen(
60
+ ["bash", "--norc", "--noprofile"],
61
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE,
62
+ stderr=subprocess.STDOUT, text=True, bufsize=1,
63
+ start_new_session=True, # own process group, so we can SIGINT it
64
+ env={**os.environ, "PS1": "", "TERM": "dumb"},
65
+ )
66
+ self._reader = threading.Thread(target=self._pump, daemon=True)
67
+ self._reader.start()
68
+
69
+ def _pump(self):
70
+ for line in self.proc.stdout:
71
+ self._q.put(line)
72
+ self._q.put(None) # EOF sentinel
73
+
74
+ def _drain_pending(self):
75
+ while True:
76
+ try:
77
+ self._q.get_nowait()
78
+ except queuelib.Empty:
79
+ return
80
+
81
+ def run(self, command: str, timeout: int) -> tuple[str, int]:
82
+ if self.proc.poll() is not None: # shell died (e.g. `exit`) — respawn
83
+ log(f"session {self.name}: shell exited, restarting")
84
+ self._start()
85
+
86
+ self._drain_pending()
87
+ marker = "__NII_END_" + uuid.uuid4().hex + "__"
88
+ # Run the command, then print a sentinel with the exit status on its own line.
89
+ script = f"{command}\nprintf '\\n%s %s\\n' '{marker}' \"$?\"\n"
90
+ try:
91
+ self.proc.stdin.write(script)
92
+ self.proc.stdin.flush()
93
+ except BrokenPipeError:
94
+ return ("(shell pipe broken)", 127)
95
+
96
+ out_lines: list[str] = []
97
+ exit_code = 0
98
+ deadline = time.monotonic() + max(1, timeout)
99
+ interrupted = False
100
+ while True:
101
+ remaining = deadline - time.monotonic()
102
+ if remaining <= 0 and not interrupted:
103
+ # Interrupt the running command but keep the shell alive.
104
+ try:
105
+ os.killpg(self.proc.pid, signal.SIGINT)
106
+ except ProcessLookupError:
107
+ pass
108
+ out_lines.append(f"\n[relay: timed out after {timeout}s, sent SIGINT]\n")
109
+ interrupted = True
110
+ deadline = time.monotonic() + 5 # grace to collect the sentinel
111
+ continue
112
+ try:
113
+ line = self._q.get(timeout=max(0.1, remaining if not interrupted else 0.5))
114
+ except queuelib.Empty:
115
+ if interrupted:
116
+ break
117
+ continue
118
+ if line is None: # shell EOF
119
+ if interrupted:
120
+ exit_code = 124 # conventional timeout exit code
121
+ else:
122
+ out_lines.append("\n[relay: shell closed]\n")
123
+ exit_code = 137
124
+ break
125
+ stripped = line.rstrip("\n")
126
+ if stripped.startswith(marker):
127
+ parts = stripped.split()
128
+ try:
129
+ exit_code = int(parts[-1])
130
+ except (ValueError, IndexError):
131
+ exit_code = 0
132
+ break
133
+ out_lines.append(line)
134
+
135
+ output = "".join(out_lines)
136
+ # Trim the single leading newline our printf added before the sentinel.
137
+ if output.endswith("\n"):
138
+ output = output[:-1]
139
+ if len(output) > MAX_OUTPUT_CHARS:
140
+ output = output[:MAX_OUTPUT_CHARS] + "\n[relay: output truncated]"
141
+ return (output, exit_code)
142
+
143
+
144
+ SHELLS: dict[str, Shell] = {}
145
+
146
+
147
+ def get_shell(name: str) -> Shell:
148
+ sh = SHELLS.get(name)
149
+ if sh is None:
150
+ sh = SHELLS[name] = Shell(name)
151
+ log(f"opened bash session: {name}")
152
+ return sh
153
+
154
+
155
+ # --------------------------------------------------------------------------- #
156
+ # Relay client #
157
+ # --------------------------------------------------------------------------- #
158
+ def connect() -> Client:
159
+ while True:
160
+ try:
161
+ log(f"connecting to Space {SPACE} …")
162
+ client = Client(SPACE, hf_token=HF_TOKEN, verbose=False)
163
+ client.predict(CLIENT_ID, SECRET, _meta(), api_name="/register")
164
+ log(f"registered as '{CLIENT_ID}'")
165
+ return client
166
+ except Exception as e:
167
+ log(f"connect failed: {e!r} — retrying in 10s")
168
+ time.sleep(10)
169
+
170
+
171
+ def _meta() -> str:
172
+ return f"{os.uname().sysname} {os.uname().release} pid={os.getpid()}"
173
+
174
+
175
+ def handle(client: Client, cmd: dict):
176
+ cid = cmd["id"]
177
+ session = cmd.get("session") or "main"
178
+ command = cmd["command"]
179
+ timeout = int(cmd.get("timeout") or DEFAULT_TIMEOUT)
180
+ log(f"[{session}] $ {command}")
181
+ try:
182
+ output, code = get_shell(session).run(command, timeout)
183
+ except Exception as e:
184
+ output, code = (f"[relay: daemon error: {e!r}]", 1)
185
+ client.predict(cid, CLIENT_ID, SECRET, code, output, session, api_name="/result")
186
+ log(f"[{session}] → exit {code} ({len(output)} bytes)")
187
+
188
+
189
+ def main():
190
+ if not SPACE:
191
+ sys.exit("set RELAY_SPACE to the Space repo id, e.g. your-username/nii-relay")
192
+ log(f"daemon starting — client_id='{CLIENT_ID}', poll every {POLL_INTERVAL}s")
193
+ client = connect()
194
+ while True:
195
+ try:
196
+ res = client.predict(CLIENT_ID, SECRET, api_name="/poll")
197
+ commands = (res or {}).get("commands", [])
198
+ for cmd in commands:
199
+ handle(client, cmd)
200
+ except KeyboardInterrupt:
201
+ log("bye")
202
+ return
203
+ except Exception as e:
204
+ log(f"poll error: {e!r} — reconnecting")
205
+ client = connect()
206
+ time.sleep(POLL_INTERVAL)
207
+
208
+
209
+ if __name__ == "__main__":
210
+ main()
daemon/requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ gradio_client>=1.3