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:
saphidandClaude Opus 5.5 committed 2026-09-28 21:44:02 +10:00
1 parent d5e8193ce5
commit efffd72c52
2 files changed
+73 -15

No files matched your search

+58 -10
View File
@@ -172,6 +172,24 @@ class Bundled(unittest.TestCase):
with self.assertRaises(self.server.Failure): with self.assertRaises(self.server.Failure):
self.deliver(frame_has=False, damaged=True) 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): def lifecycle(self, agent_lines, deliver=None):
"""An InputAgent whose ssh and agent are fakes. Returns it, the folders each launch """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 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 agent.start() # while the first launch is still copying
self.assertTrue(entered.wait(5), "the second start didn't launch") self.assertTrue(entered.wait(5), "the second start didn't launch")
gate.set() gate.set()
self.wait_for(lambda: len(procs) == 2 and agent.status == {"state": "ready"}) self.wait_for(lambda: agent.status == {"state": "ready"})
# The stopped launch's agent was ended; the new one is the one in use. time.sleep(0.1)
self.wait_for(lambda: sum(p.ended.is_set() for p in procs) == 1) # The stopped launch started no agent; the new one is the one in use.
self.assertFalse(agent.proc.ended.is_set()) self.assertEqual(len(procs), 1)
self.assertIs(agent.proc, procs[0])
agent.stop() agent.stop()
self.wait_for(lambda: all(p.ended.is_set() for p in procs)) 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): 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 # 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']]) 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): def racing_watch(proc, errors, retry=False):
wanted = watch(proc, errors, retry) wanted = watch(proc, errors, retry)
if wanted and not raced: if wanted and not first:
raced.append(True) first.append(threading.current_thread())
agent.start() # lands between the agent exiting and the retry agent.start() # lands between the agent exiting and the retry
return wanted 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() agent.start()
self.wait_for(lambda: agent.status == {"state": "ready"}) 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) self.assertEqual(sum(not p.ended.is_set() for p in procs), 1, launches)
agent.stop() agent.stop()
self.wait_for(lambda: all(p.ended.is_set() for p in procs)) 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): def test_copies_go_to_a_folder_of_their_own(self):
first, _, _ = self.deliver(frame_has=False) first, _, _ = self.deliver(frame_has=False)
second, _, _ = self.deliver(frame_has=False) second, _, _ = self.deliver(frame_has=False)
+15 -5
View File
@@ -648,14 +648,19 @@ class InputAgent:
quoted = shlex.quote(name) quoted = shlex.quote(name)
ssh(f"cd {folder} && cat > {quoted}.part && mv {quoted}.part {quoted}", ssh(f"cd {folder} && cat > {quoted}.part && mv {quoted}.part {quoted}",
stdin=path.read_bytes(), text=False, timeout=600) stdin=path.read_bytes(), text=False, timeout=600)
except Failure: except (Failure, OSError):
try: self.discard(folder) # a partial copy is no use to anyone
ssh(f"rm -rf {folder}", timeout=20) # a partial copy is no use to anyone
except Failure:
pass # the agent tidies it later
raise raise
return f"~/{folder}" 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): def start(self):
with self.lock: with self.lock:
if self.launching == self.generation or (self.proc and self.proc.poll() is None): if self.launching == self.generation or (self.proc and self.proc.poll() is None):
@@ -674,6 +679,11 @@ class InputAgent:
try: try:
ensure_master() ensure_master()
folder = self.deliver(report, force) 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, proc = subprocess.Popen([*SSH, FRAME, self.command(folder)], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=errors) stdout=subprocess.PIPE, stderr=errors)
except (Failure, OSError) as e: except (Failure, OSError) as e: