Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ metadata and the backend fallback mirror it.

### Fixed

- 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)

## [0.5.7] — 2026-10-05
Expand Down
10 changes: 9 additions & 1 deletion backend/services/proc_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions docs/electron-dubbing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
67 changes: 67 additions & 0 deletions tests/test_proc_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Cancellation snapshots retain subprocesses registered during a kill."""
import importlib
import subprocess
import sys
import threading


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"
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():
registry = importlib.import_module("services.proc_registry")
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)
Loading