mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 05:02:50 +02:00
Pad: drain the KDE Connect queue without losing, stranding or reordering input
Second review found the queue could be trimmed under an in-flight send (deleting unsent events), the worker could retire as a request enqueued, and ASCII could overtake an accent still being typed (or the reverse). The worker now takes its batch out while sending and puts it back on failure, retires under the lock, and each way waits out the other's estimated typing time. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9f3fb89e30
commit
3539215517
2 files changed
+91
-9
No files matched your search
+54
-1
@@ -690,11 +690,17 @@ class PadDelivery(unittest.TestCase):
|
||||
self.s = server
|
||||
self.saved = (server._touch, server._input, server.KDE_WAIT)
|
||||
server._touch, server._input = self.Agent(), self.Agent()
|
||||
server._kde_queue.clear()
|
||||
self.reset()
|
||||
|
||||
def tearDown(self):
|
||||
self.s._touch, self.s._input, self.s.KDE_WAIT = self.saved
|
||||
self.settle()
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.s._kde_queue.clear()
|
||||
self.s._kde_until = self.s._typed_until = 0.0
|
||||
self.s._kde_worker[0] = None
|
||||
|
||||
def settle(self):
|
||||
worker = self.s._kde_worker[0]
|
||||
@@ -755,3 +761,50 @@ class PadDelivery(unittest.TestCase):
|
||||
self.assertLessEqual(len(self.s._kde_queue), self.s.KDE_QUEUE_LIMIT)
|
||||
self.settle()
|
||||
self.assertEqual(self.s._kde_queue, [])
|
||||
|
||||
def test_ascii_just_after_an_accent_stays_behind_it(self):
|
||||
self.s.remote_input({"events": [{"key": "é"}]})
|
||||
self.settle()
|
||||
self.s.remote_input({"events": [{"key": "x"}]}) # KDE Connect may still be typing the é
|
||||
self.settle()
|
||||
self.assertEqual(self.s._touch.got, [])
|
||||
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "x"}])
|
||||
|
||||
def test_accent_just_after_gamescope_text_waits_for_it(self):
|
||||
self.s.remote_input({"events": [{"key": "x" * 50}]})
|
||||
self.assertEqual(self.s._touch.got, [[{"text": "x" * 50}]])
|
||||
start = time.time()
|
||||
self.s.remote_input({"events": [{"key": "é"}]})
|
||||
self.settle()
|
||||
self.assertGreater(time.time() - start, 0.5) # the 50 keys' time, plus a margin
|
||||
self.assertEqual(self.typed(), [{"key": "é"}])
|
||||
|
||||
def test_trimming_while_sending_doesnt_drop_unsent_events(self):
|
||||
slow = self.Agent()
|
||||
gate = threading.Event()
|
||||
send = slow.send
|
||||
|
||||
def held(events):
|
||||
gate.wait(5)
|
||||
return send(events)
|
||||
slow.send = held
|
||||
self.s._input = slow
|
||||
self.s.remote_input({"events": [{"key": "é"}] * 150})
|
||||
time.sleep(0.2) # the worker has taken its 150 and is mid-send
|
||||
self.s.remote_input({"events": [{"key": "ü"}] * 150})
|
||||
gate.set()
|
||||
self.settle()
|
||||
sent = self.typed()
|
||||
self.assertEqual(sent[:150], [{"key": "é"}] * 150)
|
||||
self.assertEqual(sent[150:], [{"key": "ü"}] * 150) # none of the new ones went missing
|
||||
|
||||
def test_input_queued_as_the_worker_finishes_is_still_sent(self):
|
||||
for _ in range(40):
|
||||
self.reset()
|
||||
self.s._input.got.clear()
|
||||
self.s.remote_input({"events": [{"key": "é"}]})
|
||||
time.sleep(0.002)
|
||||
self.s.remote_input({"events": [{"key": "ü"}]})
|
||||
self.settle()
|
||||
self.assertEqual(self.s._kde_queue, [])
|
||||
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "ü"}])
|
||||
+37
-8
@@ -858,9 +858,10 @@ PAD_NOTCH = 15 # scroll units for one wheel notch
|
||||
|
||||
def typed_in_kde(events):
|
||||
"""Whether these events must go the KDE Connect way: they hold an accent or emoji, or
|
||||
earlier ones are still waiting there (typing must stay in order, so it can't be split)."""
|
||||
earlier ones are queued, being sent or still being typed there (typing must stay in
|
||||
order, so it can't be split between the two ways)."""
|
||||
with _kde_lock:
|
||||
waiting = bool(_kde_queue)
|
||||
waiting = bool(_kde_queue) or time.time() < _kde_until
|
||||
return waiting or any(ch not in frame_touch.ASCII for e in events for ch in e.get("key", ""))
|
||||
|
||||
|
||||
@@ -928,46 +929,74 @@ def remote_input(body):
|
||||
status = _touch.send(touch)
|
||||
if touch and not status.get("sent"):
|
||||
return status
|
||||
if touch:
|
||||
_note_typed(touch)
|
||||
if slow:
|
||||
_queue_for_kde(slow)
|
||||
return {**status, "sent": True}
|
||||
|
||||
|
||||
# Typing KDE Connect is to do: it starts the first time it's needed, so this waits for it.
|
||||
# Nothing on the Frame says when typed text has landed, so each way holds the other back
|
||||
# for as long as what it was given should take (a short margin on a time estimate).
|
||||
_kde_queue = []
|
||||
_kde_lock = threading.Lock()
|
||||
_kde_worker = [None]
|
||||
_kde_until = 0.0 # keys typed through KDE Connect should have landed by then
|
||||
_typed_until = 0.0 # likewise for gamescope's
|
||||
KDE_QUEUE_LIMIT = 200
|
||||
KDE_WAIT = 90 # seconds to wait for KDE Connect before giving up on what's queued
|
||||
TYPING_SETTLE = 0.6 # seconds past the estimate
|
||||
|
||||
|
||||
def _note_typed(touch):
|
||||
"""Gamescope types a key every ~8 ms; remember when what was just sent should be done."""
|
||||
global _typed_until
|
||||
keys = sum(len(e["text"]) if "text" in e else 1 for e in touch if "text" in e or "key" in e)
|
||||
if keys:
|
||||
with _kde_lock:
|
||||
_typed_until = max(_typed_until, time.time()) + keys * 0.012 + TYPING_SETTLE
|
||||
|
||||
|
||||
def _queue_for_kde(events):
|
||||
with _kde_lock:
|
||||
_kde_queue.extend(events)
|
||||
del _kde_queue[:-KDE_QUEUE_LIMIT]
|
||||
if not (_kde_worker[0] and _kde_worker[0].is_alive()):
|
||||
if _kde_worker[0] is None:
|
||||
_kde_worker[0] = threading.Thread(target=_drain_kde, daemon=True)
|
||||
_kde_worker[0].start()
|
||||
|
||||
|
||||
def _drain_kde():
|
||||
"""The only sender: in order, dropping from the queue just what went, until it's empty."""
|
||||
"""The only sender, in order. A batch leaves the queue while it's being sent (the queue
|
||||
can be trimmed meanwhile) and goes back to the front if it didn't go."""
|
||||
global _kde_until
|
||||
deadline = time.time() + KDE_WAIT
|
||||
while True:
|
||||
with _kde_lock:
|
||||
batch = _kde_queue[:INPUT_BATCH_LIMIT]
|
||||
del _kde_queue[:len(batch)]
|
||||
if not batch:
|
||||
_kde_worker[0] = None # retired under the lock, so a new request starts another
|
||||
return
|
||||
wait = _typed_until - time.time()
|
||||
_kde_until = time.time() + 3600 # held while in flight
|
||||
if wait > 0:
|
||||
time.sleep(wait) # gamescope's typing, from just before, goes first
|
||||
status = _input.send(batch)
|
||||
with _kde_lock:
|
||||
if status.get("sent"):
|
||||
del _kde_queue[:len(batch)]
|
||||
_kde_until = time.time() + len(batch) * 0.03 + TYPING_SETTLE
|
||||
deadline = time.time() + KDE_WAIT
|
||||
elif status.get("state") == "error" or time.time() > deadline:
|
||||
continue
|
||||
_kde_until = 0.0
|
||||
if status.get("state") == "error" or time.time() > deadline:
|
||||
_kde_queue.clear() # not coming; don't hold it forever
|
||||
_kde_worker[0] = None
|
||||
return
|
||||
if not status.get("sent"):
|
||||
time.sleep(0.4)
|
||||
_kde_queue[:0] = batch
|
||||
del _kde_queue[:-KDE_QUEUE_LIMIT] # as when queuing: the oldest go first
|
||||
time.sleep(0.4)
|
||||
|
||||
|
||||
# ---- touch: the headset's panels, through gamescope's own input (frame_touch.py) ----
|
||||
|
||||
Reference in new issue
Block a user