From aafe8e0b28fe30fcc45a5a61856894f911fc22f6 Mon Sep 17 00:00:00 2001 From: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:25:44 -0400 Subject: [PATCH 1/3] fix(dub): retain process registrations made during cancellation Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --- CHANGELOG.md | 2 + backend/services/proc_registry.py | 10 ++++- docs/electron-dubbing.md | 2 + tests/test_proc_registry.py | 66 +++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 tests/test_proc_registry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 534142f5a..539e09468 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ metadata and the backend fallback mirror it. ### Fixed +- Dubbing keeps newly started media processes cancellable during an overlapping stop (#0) — thanks @rudycelekli! + - MCP speech tools wait through model loading and progress-extended CPU renders instead of timing out before the backend (#2609) ## [0.5.7] — 2026-10-05 diff --git a/backend/services/proc_registry.py b/backend/services/proc_registry.py index 1df1f1c7c..9eb105c5e 100644 --- a/backend/services/proc_registry.py +++ b/backend/services/proc_registry.py @@ -53,7 +53,15 @@ def kill_job_procs(job_id: str) -> None: job_id.replace("\n", " ").replace("\r", " "), e, ) with _active_procs_lock: - _active_procs.pop(job_id, None) + tracked = _active_procs.get(job_id) + if tracked is not None: + # A new subprocess may register while kill() is in progress. + # Retire only the snapshot this cancellation actually owned. + for proc in procs: + if proc in tracked: + tracked.remove(proc) + if not tracked: + _active_procs.pop(job_id, None) def has_active_procs(job_id: str) -> bool: diff --git a/docs/electron-dubbing.md b/docs/electron-dubbing.md index 24fdcf153..9f2ad5b0c 100644 --- a/docs/electron-dubbing.md +++ b/docs/electron-dubbing.md @@ -379,3 +379,5 @@ audio if a later import fails validation or runs out of space. Cancelling an upload waits for its copy worker to stop before closing the input and clearing the reserved job, so the same upload can be retried safely. + +Cancellation keeps any subprocess started while another process is being stopped in the job registry, so a subsequent stop can still cancel it. diff --git a/tests/test_proc_registry.py b/tests/test_proc_registry.py new file mode 100644 index 000000000..b9ff8c288 --- /dev/null +++ b/tests/test_proc_registry.py @@ -0,0 +1,66 @@ +"""Cancellation snapshots retain subprocesses registered during a kill.""" +import subprocess +import sys +import threading + +from services import proc_registry as registry + + +def test_registration_during_native_kill_remains_cancellable(): + entered = threading.Event() + release = threading.Event() + job = "registry-overlapping-kill" + children = [] + + class BlockingKill: + def __init__(self, process): + self.process = process + + @property + def returncode(self): + return self.process.returncode + + def kill(self): + entered.set() + assert release.wait(5) + self.process.kill() + + thread = None + try: + first = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) + children.append(first) + registry.register_proc(job, BlockingKill(first)) + thread = threading.Thread(target=registry.kill_job_procs, args=(job,)) + thread.start() + assert entered.wait(5) + second = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) + children.append(second) + registry.register_proc(job, second) + release.set() + thread.join(5) + assert not thread.is_alive() + first.wait(timeout=5) + assert registry.has_active_procs(job) + registry.kill_job_procs(job) + second.wait(timeout=5) + assert second.returncode != 0 + assert not registry.has_active_procs(job) + finally: + release.set() + if thread: + thread.join(5) + for process in children: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + registry.kill_job_procs(job) + + +def test_completed_and_absent_processes_are_safe_to_cancel(): + job = "registry-completed-kill" + completed = subprocess.Popen([sys.executable, "-c", "pass"]) + completed.wait(timeout=5) + registry.register_proc(job, completed) + registry.kill_job_procs(job) + registry.kill_job_procs(job) + assert not registry.has_active_procs(job) From 793f104d0eeaf0717d2431661d96d6bcd13221eb Mon Sep 17 00:00:00 2001 From: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:49:29 -0400 Subject: [PATCH 2/3] docs: link process-custody regression report (#2632) Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 539e09468..396d31e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ metadata and the backend fallback mirror it. ### Fixed -- Dubbing keeps newly started media processes cancellable during an overlapping stop (#0) — thanks @rudycelekli! +- Dubbing keeps newly started media processes cancellable during an overlapping stop (#2632) — thanks @rudycelekli! - MCP speech tools wait through model loading and progress-extended CPU renders instead of timing out before the backend (#2609) From ae425e70962621b709a576c8ba0ea9732528fd23 Mon Sep 17 00:00:00 2001 From: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:58:29 -0400 Subject: [PATCH 3/3] test(dub): resolve process registry after module cleanup Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --- tests/test_proc_registry.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_proc_registry.py b/tests/test_proc_registry.py index b9ff8c288..9135a1cd9 100644 --- a/tests/test_proc_registry.py +++ b/tests/test_proc_registry.py @@ -1,12 +1,12 @@ """Cancellation snapshots retain subprocesses registered during a kill.""" +import importlib import subprocess import sys import threading -from services import proc_registry as registry - def test_registration_during_native_kill_remains_cancellable(): + registry = importlib.import_module("services.proc_registry") entered = threading.Event() release = threading.Event() job = "registry-overlapping-kill" @@ -57,6 +57,7 @@ def kill(self): def test_completed_and_absent_processes_are_safe_to_cancel(): + registry = importlib.import_module("services.proc_registry") job = "registry-completed-kill" completed = subprocess.Popen([sys.executable, "-c", "pass"]) completed.wait(timeout=5)