Repository navigation
Expand file tree
/
Copy pathcontrol_channel.py
More file actions
275 lines (242 loc) · 9.94 KB
/
Copy pathcontrol_channel.py
File metadata and controls
275 lines (242 loc) · 9.94 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
"""Hub -> daemon control socket (app-hub spec §3).
Newline-delimited JSON over ~/.openflow/control.sock. Unlike the widget
channel this is request/reply and serves many clients at once: every
request {"id": n, "cmd": "...", ...args} gets exactly one reply,
{"id": n, "ok": true, ...fields} or {"id": n, "ok": false, "error": "..."}.
The daemon passes a handler table {cmd: fn(**args) -> dict | None}; each
connection is served on its own thread, so a slow `rerun` (a Sarvam call)
never holds up a `status` poll.
"""
from __future__ import annotations
import errno
import fcntl
import inspect
import itertools
import json
import os
import socket
import threading
import time
from pathlib import Path
from typing import Any, Callable, Mapping, Optional
from openflow_logger import log_exception
SOCKET_PATH = str(Path(os.path.expanduser("~/.openflow")) / "control.sock")
DEFAULT_TIMEOUT = 5.0
# One request line may carry a whole dictation (paste_text / rerun); cap it
# so a runaway client can't grow the buffer without bound.
MAX_LINE = 1 << 20
Handler = Callable[..., Optional[dict]]
class ControlError(Exception):
"""The daemon answered with an error, or the call failed."""
class DaemonNotRunning(ControlError):
"""Nothing is listening on the control socket."""
def __init__(self, msg: str = "OpenFlow isn't running") -> None:
super().__init__(msg)
class ControlServer:
def __init__(self, handlers: Mapping[str, Handler], path: str = SOCKET_PATH) -> None:
self.path = path
self._handlers = dict(handlers)
self._sock: Optional[socket.socket] = None
self._stopped = threading.Event()
self._conns: set[socket.socket] = set()
self._lock = threading.Lock()
self._ino: Optional[int] = None
self._lock_fd: Optional[int] = None
# -- lifecycle (same stale-file / 0600 / single-owner rules as widget_channel)
def start(self) -> None:
Path(self.path).parent.mkdir(mode=0o700, parents=True, exist_ok=True)
self._acquire_lock()
try:
# Holding the lock means any existing socket file is stale.
try:
os.unlink(self.path)
except FileNotFoundError:
pass
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
# umask is process-wide: it is held only around bind().
old_umask = os.umask(0o177) # socket is created 0600, never wider
try:
s.bind(self.path)
finally:
os.umask(old_umask)
except BaseException:
self._release_lock()
raise
self._ino = os.stat(self.path).st_ino
s.listen(64) # the hub can fire several calls at once
self._sock = s
threading.Thread(target=self._accept_loop, name="control-accept",
daemon=True).start()
def _acquire_lock(self) -> None:
fd = os.open(self.path + ".lock", os.O_CREAT | os.O_RDWR, 0o600)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc:
os.close(fd)
if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES):
raise OSError(errno.EADDRINUSE,
f"control server already running at {self.path}") from exc
raise
self._lock_fd = fd
def _release_lock(self) -> None:
if self._lock_fd is not None:
os.close(self._lock_fd) # closing drops the flock
self._lock_fd = None
def stop(self) -> None:
self._stopped.set()
with self._lock:
conns, self._conns = self._conns, set()
for c in conns:
_close(c)
if self._sock is not None:
self._sock.close()
try:
# Only remove the path if it is still the socket we bound.
if self._ino is not None and os.stat(self.path).st_ino == self._ino:
os.unlink(self.path)
except OSError:
pass
self._release_lock()
# -- serving
def _accept_loop(self) -> None:
while not self._stopped.is_set():
try:
client, _ = self._sock.accept()
except OSError as exc:
if self._stopped.is_set():
break
log_exception("control_channel", "accept failed; retrying", exc)
time.sleep(0.1)
continue
with self._lock:
if self._stopped.is_set():
_close(client)
break
self._conns.add(client)
threading.Thread(target=self._serve, args=(client,),
name="control-conn", daemon=True).start()
def _serve(self, conn: socket.socket) -> None:
buf = b""
try:
while not self._stopped.is_set():
chunk = conn.recv(65536)
if not chunk:
break
buf += chunk
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
if line.strip():
conn.sendall(_encode(self.handle_line(line)))
if len(buf) > MAX_LINE:
conn.sendall(_encode({"id": None, "ok": False,
"error": "request too large"}))
break
except OSError:
pass # client went away, or stop() closed us
except Exception as exc: # never let a bug kill the thread noisily
log_exception("control_channel", "connection handler crashed", exc)
finally:
with self._lock:
self._conns.discard(conn)
_close(conn)
def handle_line(self, line: bytes) -> dict:
"""One request line -> one reply dict. Never raises."""
try:
req = json.loads(line)
except ValueError:
req = None
if not isinstance(req, dict):
return {"id": None, "ok": False, "error": "malformed request"}
rid = req.get("id")
cmd = req.get("cmd")
fn = self._handlers.get(cmd) if isinstance(cmd, str) else None
if fn is None:
return {"id": rid, "ok": False, "error": f"unknown command: {cmd!r}"}
args = {k: v for k, v in req.items() if k not in ("id", "cmd")}
try:
inspect.signature(fn).bind(**args)
except TypeError as exc:
return {"id": rid, "ok": False, "error": f"bad arguments for {cmd}: {exc}"}
except ValueError:
pass # no signature to check (builtin); let the call decide
try:
result = fn(**args)
except Exception as exc:
log_exception("control_channel", f"{cmd} failed", exc)
return {"id": rid, "ok": False, "error": str(exc) or type(exc).__name__}
reply: dict[str, Any] = dict(result or {})
reply.update(id=rid, ok=True)
try:
json.dumps(reply, ensure_ascii=False)
except (TypeError, ValueError) as exc:
log_exception("control_channel", f"{cmd}: unserializable reply", exc)
return {"id": rid, "ok": False, "error": f"{cmd}: reply not serializable"}
return reply
class ControlClient:
"""Blocking client: one short-lived connection per call, so it is safe to
use from any thread (the hub calls it from Qt worker threads)."""
_ids = itertools.count(1)
def __init__(self, path: str = SOCKET_PATH) -> None:
self.path = path
# A full listen queue also refuses a connection; retry briefly before
# calling the daemon gone. A missing socket file means gone at once.
CONNECT_RETRIES = (0.02, 0.05, 0.1)
def _connect(self, timeout: float) -> socket.socket:
delays = iter(self.CONNECT_RETRIES)
while True:
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(timeout)
try:
s.connect(self.path)
return s
except FileNotFoundError as exc:
s.close()
raise DaemonNotRunning() from exc
except ConnectionRefusedError as exc:
s.close()
delay = next(delays, None)
if delay is None:
raise DaemonNotRunning() from exc
time.sleep(delay)
except OSError as exc:
s.close()
raise DaemonNotRunning() from exc
def call(self, cmd: str, timeout: float = DEFAULT_TIMEOUT, **args: Any) -> dict:
"""Send one command; return the reply's fields (without id/ok).
Raises DaemonNotRunning if nothing listens, ControlError otherwise."""
rid = next(self._ids)
data = _encode({**args, "id": rid, "cmd": cmd})
s = self._connect(timeout)
try:
s.sendall(data)
buf = b""
while b"\n" not in buf:
chunk = s.recv(65536)
if not chunk:
raise DaemonNotRunning() # daemon quit before replying
buf += chunk
except socket.timeout as exc:
raise ControlError(f"{cmd} timed out after {timeout:g}s") from exc
except ControlError:
raise
except OSError as exc:
raise DaemonNotRunning() from exc
finally:
s.close()
try:
reply = json.loads(buf.split(b"\n", 1)[0])
except ValueError as exc:
raise ControlError("malformed reply") from exc
if not isinstance(reply, dict):
raise ControlError("malformed reply")
if not reply.get("ok"):
raise ControlError(str(reply.get("error") or "command failed"))
return {k: v for k, v in reply.items() if k not in ("id", "ok")}
def _encode(msg: dict) -> bytes:
return (json.dumps(msg, ensure_ascii=False) + "\n").encode("utf-8")
def _close(sock: socket.socket) -> None:
try:
sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
sock.close()