mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 05:02:50 +02:00
Bundled KDE Connect: no copy left behind by a stop or a local read error
- A start turned off while copying removes the copy instead of starting an agent that would be killed before it could tidy up. - A local read error while copying removes the partial copy too. - The retry race test holds the new start until the retry has decided, so it fails every time without the fix (checked 3/3), not by luck. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d5e8193ce5
commit
efffd72c52
2 files changed
+73
-15
No files matched your search
+58
-10
@@ -172,6 +172,24 @@ class Bundled(unittest.TestCase):
|
||||
with self.assertRaises(self.server.Failure):
|
||||
self.deliver(frame_has=False, damaged=True)
|
||||
|
||||
def test_a_failed_copy_is_removed(self):
|
||||
calls = []
|
||||
|
||||
def ssh(remote, stdin=None, timeout=30, text=True):
|
||||
calls.append(remote)
|
||||
if "cat >" in remote and len([c for c in calls if "cat >" in c]) == 2:
|
||||
raise self.server.Failure("Broken pipe")
|
||||
return b"" if not text else ""
|
||||
folder, packages = self.fake_bundle()
|
||||
old = self.server.ssh, self.server.KDECONNECT, self.server.LOCAL
|
||||
self.server.ssh, self.server.KDECONNECT, self.server.LOCAL = ssh, folder, False
|
||||
try:
|
||||
with self.assertRaises(self.server.Failure):
|
||||
self.server.InputAgent(packages=packages).deliver(lambda m: None)
|
||||
finally:
|
||||
self.server.ssh, self.server.KDECONNECT, self.server.LOCAL = old
|
||||
self.assertTrue(calls[-1].startswith("rm -rf .local/share/frame-control/kdeconnect/incoming/"), calls[-1])
|
||||
|
||||
def lifecycle(self, agent_lines, deliver=None):
|
||||
"""An InputAgent whose ssh and agent are fakes. Returns it, the folders each launch
|
||||
used, and the fake agents. A fake agent reports its lines, then keeps running
|
||||
@@ -263,33 +281,63 @@ class Bundled(unittest.TestCase):
|
||||
agent.start() # while the first launch is still copying
|
||||
self.assertTrue(entered.wait(5), "the second start didn't launch")
|
||||
gate.set()
|
||||
self.wait_for(lambda: len(procs) == 2 and agent.status == {"state": "ready"})
|
||||
# The stopped launch's agent was ended; the new one is the one in use.
|
||||
self.wait_for(lambda: sum(p.ended.is_set() for p in procs) == 1)
|
||||
self.assertFalse(agent.proc.ended.is_set())
|
||||
self.wait_for(lambda: agent.status == {"state": "ready"})
|
||||
time.sleep(0.1)
|
||||
# The stopped launch started no agent; the new one is the one in use.
|
||||
self.assertEqual(len(procs), 1)
|
||||
self.assertIs(agent.proc, procs[0])
|
||||
agent.stop()
|
||||
self.wait_for(lambda: all(p.ended.is_set() for p in procs))
|
||||
|
||||
def test_retry_and_a_new_start_never_run_two_agents(self):
|
||||
# A start() landing just as an agent that asked for the packages exits: exactly one
|
||||
# of it and the retry launches, and stop() ends everything.
|
||||
# of it and the retry launches, and stop() ends everything. The new start is held
|
||||
# in its copy until the retry has decided, so neither can see the other's agent.
|
||||
first, decided = [], threading.Event()
|
||||
agent, launches, procs = self.lifecycle([[b'{"state": "need-packages"}\n'], [b'{"state": "ready"}\n']])
|
||||
watch, raced = agent._watch, []
|
||||
watch, launch, deliver = agent._watch, agent._launch, agent.deliver
|
||||
|
||||
def racing_watch(proc, errors, retry=False):
|
||||
wanted = watch(proc, errors, retry)
|
||||
if wanted and not raced:
|
||||
raced.append(True)
|
||||
if wanted and not first:
|
||||
first.append(threading.current_thread())
|
||||
agent.start() # lands between the agent exiting and the retry
|
||||
return wanted
|
||||
agent._watch = racing_watch
|
||||
|
||||
def held_deliver(report, force=False):
|
||||
if force:
|
||||
decided.set() # the retry went ahead
|
||||
elif first and threading.current_thread() is not first[0]:
|
||||
decided.wait(5) # the new start waits until the retry has decided
|
||||
return deliver(report, force)
|
||||
|
||||
def first_launch(generation, force=False):
|
||||
try:
|
||||
launch(generation, force)
|
||||
finally:
|
||||
if first and threading.current_thread() is first[0]:
|
||||
decided.set() # the retry declined
|
||||
agent._watch, agent._launch, agent.deliver = racing_watch, first_launch, held_deliver
|
||||
agent.start()
|
||||
self.wait_for(lambda: agent.status == {"state": "ready"})
|
||||
time.sleep(0.2)
|
||||
time.sleep(0.1)
|
||||
self.assertEqual(sum(not p.ended.is_set() for p in procs), 1, launches)
|
||||
agent.stop()
|
||||
self.wait_for(lambda: all(p.ended.is_set() for p in procs))
|
||||
|
||||
def test_stopped_during_the_copy_removes_it(self):
|
||||
gate, entered = threading.Event(), threading.Event()
|
||||
agent, launches, procs = self.lifecycle([], deliver=lambda: (entered.set(), gate.wait(5)))
|
||||
removed = []
|
||||
agent.discard = removed.append
|
||||
agent.deliver = lambda report, force=False: (entered.set(), gate.wait(5), "~/incoming/x")[2]
|
||||
agent.start()
|
||||
self.assertTrue(entered.wait(5))
|
||||
agent.stop()
|
||||
gate.set()
|
||||
self.wait_for(lambda: removed == ["incoming/x"])
|
||||
self.assertEqual(procs, []) # no agent started for it
|
||||
|
||||
def test_copies_go_to_a_folder_of_their_own(self):
|
||||
first, _, _ = self.deliver(frame_has=False)
|
||||
second, _, _ = self.deliver(frame_has=False)
|
||||
|
||||
+15
-5
@@ -648,14 +648,19 @@ class InputAgent:
|
||||
quoted = shlex.quote(name)
|
||||
ssh(f"cd {folder} && cat > {quoted}.part && mv {quoted}.part {quoted}",
|
||||
stdin=path.read_bytes(), text=False, timeout=600)
|
||||
except Failure:
|
||||
try:
|
||||
ssh(f"rm -rf {folder}", timeout=20) # a partial copy is no use to anyone
|
||||
except Failure:
|
||||
pass # the agent tidies it later
|
||||
except (Failure, OSError):
|
||||
self.discard(folder) # a partial copy is no use to anyone
|
||||
raise
|
||||
return f"~/{folder}"
|
||||
|
||||
def discard(self, folder):
|
||||
"""Remove a copy no agent will take over (best effort; agents tidy up old ones too)."""
|
||||
if folder and not LOCAL:
|
||||
try:
|
||||
ssh(f"rm -rf {folder}", timeout=20)
|
||||
except Failure:
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
with self.lock:
|
||||
if self.launching == self.generation or (self.proc and self.proc.poll() is None):
|
||||
@@ -674,6 +679,11 @@ class InputAgent:
|
||||
try:
|
||||
ensure_master()
|
||||
folder = self.deliver(report, force)
|
||||
with self.lock:
|
||||
stopped = self.generation != generation
|
||||
if stopped: # turned off while copying: no agent will take the copy over
|
||||
self.discard(folder.removeprefix("~/"))
|
||||
raise Failure("stopped")
|
||||
proc = subprocess.Popen([*SSH, FRAME, self.command(folder)], stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=errors)
|
||||
except (Failure, OSError) as e:
|
||||
|
||||
Reference in new issue
Block a user