aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--assistant.py140
1 files changed, 107 insertions, 33 deletions
diff --git a/assistant.py b/assistant.py
index 37e8ad8..a36d43e 100644
--- a/assistant.py
+++ b/assistant.py
@@ -228,6 +228,9 @@ def emit(event):
now_state.clear()
now_state.update({k: v for k, v in event.items() if k != "type"})
line = (json.dumps(event) + "\n").encode()
+ # ponytail: sends run under the caller's lock, so one stalled client holds
+ # everyone up for its 2 s timeout, once, before it is dropped; per-client
+ # send queues if that is ever felt.
with clients_lock:
for c in clients[:]:
try:
@@ -263,6 +266,11 @@ def reader(c):
if not data:
break
buf += data
+ if len(buf) > 1 << 20: # a megabyte with no newline is not a command
+ log("client line over 1 MB, dropped")
+ emit({"type": "error", "message": "line too long"})
+ buf = b""
+ continue
*lines, buf = buf.split(b"\n")
for line in lines:
if line.strip():
@@ -271,8 +279,18 @@ def reader(c):
def serve(path=SOCK):
+ probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ try:
+ probe.connect(path)
+ except OSError:
+ pass # missing, or left over from a crash
+ else:
+ # Unlinking a live socket would orphan the running daemon's clients.
+ probe.close()
+ raise SystemExit(f"another assistant is serving {path}")
+ probe.close()
try:
- os.unlink(path) # left over from a crash
+ os.unlink(path)
except FileNotFoundError:
pass
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
@@ -285,7 +303,12 @@ def serve(path=SOCK):
def accept():
while True:
- c, _ = srv.accept()
+ try:
+ c, _ = srv.accept()
+ except OSError as e: # EMFILE and friends: log, back off, keep serving
+ log(f"accept failed: {e}")
+ time.sleep(1)
+ continue
c.settimeout(2)
# Under the lock: no event can fall between hello and the client
# joining the list.
@@ -325,38 +348,59 @@ def handle(raw):
log(f"bad command: {raw[:200]!r}")
emit({"type": "error", "message": "bad command"})
return
- with lock:
- if kind == "ask":
- text = str(cmd.get("text", "")).strip()
- if text:
- interrupt()
- threading.Thread(target=reply, args=(turn, text), daemon=True).start()
- elif kind == "stop":
+ # Ids are integers: SQLite would match "1" to row 1 while Python's
+ # comparison with the current id would not, and a missing id would
+ # equal an unsaved conversation's None.
+ bad = ("bad id" if kind in ("open", "delete") and type(cmd.get("id")) is not int
+ else "bad text" if kind == "ask" and not isinstance(cmd.get("text"), str)
+ else "bad query" if kind == "search" and not isinstance(cmd.get("q"), str)
+ else None)
+ if bad:
+ emit({"type": "error", "message": bad})
+ return
+ try:
+ with lock:
+ command(kind, cmd)
+ except Exception:
+ # A failed command must not kill the reader: the client would stay
+ # connected, receiving events, with nothing reading what it sends.
+ log(f"{kind} failed:\n" + traceback.format_exc().rstrip())
+ emit({"type": "error", "message": f"{kind} failed"})
+
+
+def command(kind, cmd):
+ # Caller holds lock; arguments are already checked.
+ if kind == "ask":
+ text = cmd["text"].strip()
+ if text:
interrupt()
- elif kind == "new":
+ threading.Thread(target=reply, args=(turn, text), daemon=True).start()
+ elif kind == "stop":
+ interrupt()
+ elif kind == "new":
+ start_new()
+ emit({"type": "conversation", "id": None, "turns": []})
+ elif kind == "list":
+ emit({"type": "conversations", "items": list_convs(DB)})
+ elif kind == "open":
+ got = load_conv(DB, cmd["id"])
+ if got is None:
+ emit({"type": "error", "message": "no such conversation"})
+ return
+ interrupt()
+ cur.update(id=cmd["id"], history=got[0], turns=got[1])
+ emit({"type": "conversation", "id": cur["id"], "turns": cur["turns"]})
+ elif kind == "search":
+ emit({"type": "results", "items": search(DB, cmd["q"])})
+ elif kind == "delete":
+ delete_conv(DB, cmd["id"])
+ if cmd["id"] == cur["id"]:
start_new()
emit({"type": "conversation", "id": None, "turns": []})
- elif kind == "list":
- emit({"type": "conversations", "items": list_convs(DB)})
- elif kind == "open":
- got = load_conv(DB, cmd.get("id"))
- if got is None:
- emit({"type": "error", "message": "no such conversation"})
- return
- interrupt()
- cur.update(id=cmd["id"], history=got[0], turns=got[1])
- emit({"type": "conversation", "id": cur["id"], "turns": cur["turns"]})
- elif kind == "search":
- emit({"type": "results", "items": search(DB, str(cmd.get("q", "")))})
- elif kind == "delete":
- delete_conv(DB, cmd.get("id"))
- if cmd.get("id") == cur["id"]:
- start_new()
- emit({"type": "conversation", "id": None, "turns": []})
- emit({"type": "conversations", "items": list_convs(DB)})
- else:
- log(f"unknown command: {kind!r}")
- emit({"type": "error", "message": f"unknown command {kind}"})
+ emit({"type": "conversations", "items": list_convs(DB)})
+ else:
+ log(f"unknown command: {kind!r}")
+ emit({"type": "error", "message": f"unknown command {kind}"})
def context_size(model):
@@ -520,6 +564,8 @@ def answer(mine, text):
+ [{"role": "user", "content": f"[{now}] {text}"}])
first = len(msgs) # this turn's tool messages start here
said, used = "", []
+ # ponytail: a stopped reply notices at its next chunk, so llama-server's
+ # slot stays busy until then and the next question queues behind it.
for rnd in range(CFG["tool_rounds"] + 1):
calls, content = {}, ""
# The last round offers no tools, which forces an answer.
@@ -574,6 +620,7 @@ def main():
global DB
# SIGTERM runs the finally below, so the socket is not left behind.
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
+ served = False
try:
DB = db_open(DB_PATH)
try:
@@ -581,16 +628,17 @@ def main():
except OSError:
pass # llama-server down: asked again per request
serve()
+ served = True
log(f"ready on {SOCK}")
while True:
signal.pause()
except (KeyboardInterrupt, SystemExit) as e:
- log(f"exit: {type(e).__name__}")
+ log(f"exit: {type(e).__name__} {e}".rstrip())
except BaseException:
log("crashed:\n" + traceback.format_exc().rstrip())
raise
finally:
- if os.path.exists(SOCK):
+ if served and os.path.exists(SOCK): # never another daemon's socket
os.remove(SOCK)
@@ -763,6 +811,11 @@ def test():
script["delay"] = 0.02
send(type="ask", text="long one")
until("token")
+ c3 = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ c3.connect(path) # mid-reply: hello carries the partial
+ h3 = json.loads(c3.makefile("rb").readline())
+ assert h3["pending"]["user"] == "long one" and h3["pending"]["reply"].startswith("w0")
+ c3.close()
send(type="stop")
seen = until("done")
assert seen[-1].get("stopped") is True
@@ -785,6 +838,27 @@ def test():
assert until("error")[-1]["message"] == "no such conversation"
cl.sendall(b"not json\n")
assert until("error")[-1]["message"] == "bad command"
+ for cmd, why in (({"type": "open", "id": [1]}, "bad id"),
+ ({"type": "open", "id": str(conv)}, "bad id"),
+ ({"type": "delete"}, "bad id"),
+ ({"type": "ask", "text": None}, "bad text"),
+ ({"type": "search", "q": 3}, "bad query")):
+ send(**cmd)
+ assert until("error")[-1]["message"] == why, cmd
+ assert cur["id"] == conv # none of them touched the conversation
+ real, DB = DB, None # every query now raises
+ send(type="list")
+ assert until("error")[-1]["message"] == "list failed"
+ DB = real
+ send(type="list") # the reader survived it
+ assert [x["id"] for x in until("conversations")[-1]["items"]] == [conv]
+ try:
+ serve(path) # a second daemon on a live socket
+ assert False
+ except SystemExit as e:
+ assert "another assistant" in str(e)
+ send(type="list") # and the first one still serves
+ until("conversations")
send(type="delete", id=conv)
seen = until("conversations")
assert {"type": "conversation", "id": None, "turns": []} in seen # was current