Two phases of the remote-GPU plan, landing together because neither is
useful alone: on a 4090 any render long enough to exercise the progress
lease also outgrows the 8 MiB message cap, so a gateway that routes work
remotely without an artifact transport just moves where the failure
happens.
**The gateway** (`services/gpu_gateway.py`) is the single owner of GPU
calling, model status, downloads and model load, for both targets —
`prewarm`, `run`, `status`, `download`. prewarm and run stay separate
because collapsing them loses the two-phase load/generate budget split
(#1033/#1037) that the worker protocol already mirrors. Admission moves
in here too: the old `check_gpu_admission` call read *local* pool stats,
so under Remote it would 429 on local saturation while the remote GPU
sat idle.
**Artifacts** now move out of band above a negotiated threshold. Bytes
land in an attempt-scoped `.part` file, are verified against a declared
sha256, and are renamed into place only on an explicit last chunk — a
transfer that arrives short, reordered, or simply stops commits nothing.
A resume rehashes what is already on disk, or the digest would attest
only to the tail, which is the exact case a resume exists to protect.
Two failure modes found while verifying this, both fixed with
mutation-checked regressions:
* an oversized payload with no session (mid-reconnect, or a control
plane too old to serve UploadResult) has nowhere to go. It must not
enter `_pending` — an over-cap frame is re-sent on every reconnect,
killing the session each time and stranding every other task — but
it must stay retryable, unlike the size gate's TERMINAL verdict:
nothing about the render is wrong, only the route to it.
* the upload resume loop was bounded by "did the offset change", which
a receiver alternating between two byte counts satisfies forever.
The worker is single-slot by default, so that is not one lost upload
but the machine, doing nothing else, until someone restarts it.
Bounded by a round count instead.
The control stream is split into control and bulk queues so the
heartbeat this whole liveness model rests on cannot queue behind a
payload — `result_json` has no size cliff to catch it, and the next bulk
message added to the protocol would have reintroduced the stall
silently.
Live streaming stays on the control plane and now says so once per
socket: that route exists to put audio in the user's ear before the
sentence finishes, and paying queue admission plus a round trip per
utterance would spend the one thing it is for. Silence would have been
worse than the limit — the header badge would read "gpu2" while this
machine did all the work.
Backend 5236 passed, frontend 1807 passed. End-to-end verification on
real hardware has NOT been re-run since these changes; the CHANGELOG
claim for the Synthesize button waits on that.
494 lines
18 KiB
Python
494 lines
18 KiB
Python
"""Getting the user's own files onto the machine that renders them (B5).
|
|
|
|
Remote cloning could not work, and did not fail either. ``assignment_to_pb``
|
|
never populated ``inputs``, so the assignment carried ``ref_audio`` as a path
|
|
on the *control plane* — ``~/…/omnivoice_data/voices/x.wav``, which names
|
|
nothing on the worker. ``DownloadArtifact`` had no caller, and would have
|
|
404'd if it had one: it serves only the artifact directory, while reference
|
|
audio lives in ``VOICES_DIR`` or a tempfile. Meanwhile ``clone`` was
|
|
advertised as supported.
|
|
|
|
The failure mode is the quiet one. An engine handed a dead reference path does
|
|
not raise — it renders in its default voice, and the user gets audio that is
|
|
simply not their clone.
|
|
|
|
So the path has three halves, and this file covers all three:
|
|
|
|
* the control plane **stages** every file-valued parameter into the artifact
|
|
store under its content hash (one copy per voice, however many clones),
|
|
* the assignment **declares** them and carries ids instead of paths,
|
|
* the worker **fetches** them and points the parameters at its own copies.
|
|
|
|
Plus the disk: ``purge_finished`` deleted rows and left every rendered result
|
|
and every staged reference clip behind, forever.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sqlite3
|
|
|
|
import pytest
|
|
|
|
from core.path_security import resolve_within
|
|
from services import tts_backend
|
|
from worker import deadlines, executor as executor_module, task_store
|
|
from worker.errors import ErrorClass
|
|
from worker.executor import TaskExecutor, TaskFailure
|
|
from worker.lifecycle import Attempt, Task, TaskState
|
|
from worker.protocol.gen import worker_v1_pb2 as pb
|
|
from worker.transport import codec
|
|
|
|
|
|
# ── Fixtures ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def db(tmp_path, monkeypatch):
|
|
"""See test_worker_registry.py: patch the globals the store actually reads,
|
|
because tests/backend/conftest.py purges core.* between tests."""
|
|
from worker import task_store as ts
|
|
|
|
db_globals = ts.db_conn.__wrapped__.__globals__
|
|
path = str(tmp_path / "userdata.db")
|
|
with sqlite3.connect(path) as conn:
|
|
conn.executescript(db_globals["_BASE_SCHEMA"])
|
|
monkeypatch.setitem(db_globals, "DB_PATH", path)
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
def artifacts(tmp_path, monkeypatch):
|
|
"""The control plane's artifact directory, isolated per test."""
|
|
root = tmp_path / "artifacts"
|
|
(root / task_store.INPUTS_DIRNAME).mkdir(parents=True)
|
|
monkeypatch.setattr(task_store, "artifact_root", lambda **_kw: str(root))
|
|
return str(root)
|
|
|
|
|
|
@pytest.fixture
|
|
def voice(tmp_path):
|
|
"""A reference clip that exists ONLY on the control plane."""
|
|
path = tmp_path / "voices" / "my-voice.wav"
|
|
path.parent.mkdir(parents=True)
|
|
path.write_bytes(b"RIFF" + b"reference audio" * 64)
|
|
return str(path)
|
|
|
|
|
|
def _task(task_id="t1", **params) -> Task:
|
|
return Task(
|
|
task_id=task_id,
|
|
operation="clone",
|
|
engine="fake-engine",
|
|
model_id="fake-model",
|
|
params={"text": "hello", **params},
|
|
)
|
|
|
|
|
|
def _attempt(task_id="t1", attempt_id="a1") -> Attempt:
|
|
return Attempt(
|
|
attempt_id=attempt_id, task_id=task_id, worker_id="w1", session_epoch=1, attempt_number=1
|
|
)
|
|
|
|
|
|
def _budget() -> deadlines.Deadlines:
|
|
return deadlines.Deadlines(
|
|
accept_seconds=30,
|
|
model_load_seconds=600,
|
|
execution_seconds=300,
|
|
progress_lease_seconds=120,
|
|
result_delivery_seconds=900,
|
|
grace_seconds=60,
|
|
)
|
|
|
|
|
|
def _assignment(task: Task, *, artifact_root: str) -> pb.TaskAssignment:
|
|
return codec.assignment_to_pb(task, _attempt(task.task_id), _budget(), artifact_root=artifact_root)
|
|
|
|
|
|
def _download_from(artifact_root: str, *, corrupt: bool = False):
|
|
"""A stand-in for ``DownloadArtifact``, resolved exactly as the server does.
|
|
|
|
``server._resolve_input`` is ``resolve_within(artifact_dir, artifact_id)``
|
|
plus an ``isfile`` check, so a ref this cannot resolve is one the real RPC
|
|
would answer with NOT_FOUND.
|
|
"""
|
|
calls: list[str] = []
|
|
|
|
async def fetch(ref, destination):
|
|
calls.append(ref.artifact_id)
|
|
source = resolve_within(artifact_root, ref.artifact_id)
|
|
if not os.path.isfile(source):
|
|
raise FileNotFoundError(ref.artifact_id)
|
|
if corrupt:
|
|
with open(destination, "wb") as handle:
|
|
handle.write(b"truncated")
|
|
return
|
|
shutil.copyfile(source, destination)
|
|
|
|
fetch.calls = calls
|
|
return fetch
|
|
|
|
|
|
# ── The worker's engine stack ──────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _live_tts_backend():
|
|
"""Re-resolve the module alias post-purge — see test_worker_executor_residency."""
|
|
global tts_backend
|
|
|
|
import services.tts_backend # noqa: PLC0415
|
|
|
|
tts_backend = services.tts_backend
|
|
|
|
|
|
class _FakeBackend:
|
|
"""Records the kwargs the engine was actually called with."""
|
|
|
|
id = "fake-engine"
|
|
display_name = "Fake Engine (test)"
|
|
sample_rate = 24_000
|
|
|
|
last_kwargs: dict = {}
|
|
|
|
def ensure_ready(self) -> None:
|
|
pass
|
|
|
|
def generate(self, text, **kwargs):
|
|
import torch
|
|
|
|
type(self).last_kwargs = dict(kwargs)
|
|
return torch.zeros(240)
|
|
|
|
|
|
@pytest.fixture
|
|
def engine(monkeypatch, _live_tts_backend):
|
|
_FakeBackend.last_kwargs = {}
|
|
monkeypatch.setitem(tts_backend._REGISTRY, "fake-engine", _FakeBackend)
|
|
monkeypatch.setattr(tts_backend, "_ENGINE_INSTANCES", {})
|
|
monkeypatch.setattr(tts_backend, "_ENGINE_LAST_USED", {})
|
|
monkeypatch.setattr(tts_backend, "_ENGINE_IN_USE", {})
|
|
from services import watermark
|
|
|
|
monkeypatch.setattr(watermark, "mark_synthetic", lambda audio, sr, **kw: audio)
|
|
return _FakeBackend
|
|
|
|
|
|
# ── Staging, on the control plane ──────────────────────────────────────────
|
|
|
|
|
|
def test_reference_audio_is_copied_into_the_artifact_store(artifacts, voice):
|
|
task = _task(ref_audio=voice)
|
|
|
|
entries = task_store.ensure_staged(task, now=1000.0)
|
|
|
|
assert len(entries) == 1
|
|
entry = entries[0]
|
|
assert entry["key"] == "ref_audio"
|
|
staged = resolve_within(artifacts, entry["artifact_id"])
|
|
assert staged.read_bytes() == open(voice, "rb").read()
|
|
assert entry["size_bytes"] == os.path.getsize(voice)
|
|
assert entry["sha256"] in entry["artifact_id"]
|
|
# The original stays where it is: a local fallback still needs it.
|
|
assert task.params["ref_audio"] == voice
|
|
assert os.path.isfile(voice)
|
|
|
|
|
|
def test_repeated_clones_of_one_voice_share_a_single_copy(artifacts, voice, tmp_path):
|
|
copy = tmp_path / "a-different-name.wav"
|
|
shutil.copyfile(voice, copy)
|
|
|
|
first = task_store.ensure_staged(_task("t1", ref_audio=voice), now=1000.0)
|
|
second = task_store.ensure_staged(_task("t2", ref_audio=str(copy)), now=1001.0)
|
|
|
|
assert first[0]["artifact_id"] == second[0]["artifact_id"], "content hash, not filename"
|
|
inputs = os.listdir(os.path.join(artifacts, task_store.INPUTS_DIRNAME))
|
|
assert len(inputs) == 1
|
|
|
|
|
|
def test_staging_twice_stages_once(artifacts, voice, monkeypatch):
|
|
task = _task(ref_audio=voice)
|
|
task_store.ensure_staged(task, now=1000.0)
|
|
|
|
def _explode(*_a, **_kw):
|
|
raise AssertionError("re-staged an input that was already staged")
|
|
|
|
monkeypatch.setattr(task_store, "stage_input", _explode)
|
|
assert len(task_store.ensure_staged(task, now=1001.0)) == 1
|
|
|
|
|
|
def test_a_parameter_that_is_not_a_file_is_left_alone(artifacts):
|
|
task = _task(ref_audio="voice-profile-id")
|
|
|
|
assert task_store.ensure_staged(task, now=1000.0) == []
|
|
assert task_store.INPUTS_PARAM_KEY not in task.params
|
|
|
|
|
|
def test_an_unreadable_reference_is_reported_not_swallowed(artifacts, voice, monkeypatch):
|
|
def _denied(path, *_a, **_kw):
|
|
raise PermissionError("nope")
|
|
|
|
monkeypatch.setattr(task_store, "_digest", _denied)
|
|
with pytest.raises(task_store.InputStagingError):
|
|
task_store.ensure_staged(_task(ref_audio=voice), now=1000.0)
|
|
|
|
|
|
def test_submitting_a_task_stages_and_records_its_inputs(db, artifacts, voice):
|
|
task_store.create(_task(ref_audio=voice), now=1000.0)
|
|
|
|
stored = task_store.get("t1")
|
|
entries = stored.params[task_store.INPUTS_PARAM_KEY]
|
|
assert len(entries) == 1, "the durable row must name what the task owns"
|
|
assert resolve_within(artifacts, entries[0]["artifact_id"]).is_file()
|
|
|
|
|
|
# ── The assignment ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_the_assignment_declares_its_inputs(artifacts, voice):
|
|
"""The regression: ``inputs`` was never populated, by anyone, ever."""
|
|
task = _task(ref_audio=voice)
|
|
|
|
assignment = _assignment(task, artifact_root=artifacts)
|
|
|
|
assert len(assignment.inputs) == 1
|
|
ref = assignment.inputs[0]
|
|
assert ref.artifact_id.startswith(task_store.INPUTS_DIRNAME)
|
|
assert ref.size_bytes == os.path.getsize(voice)
|
|
assert len(ref.sha256) == 64
|
|
assert ref.task_id == "t1" and ref.attempt_id == "a1"
|
|
|
|
|
|
def test_no_control_plane_path_reaches_the_worker(artifacts, voice):
|
|
task = _task(ref_audio=voice)
|
|
|
|
assignment = _assignment(task, artifact_root=artifacts)
|
|
|
|
assert voice not in assignment.params_json, "sent a path that means nothing remotely"
|
|
params = json.loads(assignment.params_json)
|
|
assert params["ref_audio"] == assignment.inputs[0].artifact_id
|
|
assert params["text"] == "hello"
|
|
# Staging bookkeeping holds control-plane paths; it stays home.
|
|
assert task_store.INPUTS_PARAM_KEY not in params
|
|
|
|
|
|
def test_an_unstageable_input_fails_the_task_instead_of_shipping_a_path(
|
|
artifacts, voice, monkeypatch
|
|
):
|
|
def _boom(*_a, **_kw):
|
|
raise task_store.InputStagingError("the reference clip vanished")
|
|
|
|
monkeypatch.setattr(task_store, "stage_input", _boom)
|
|
assignment = _assignment(_task(ref_audio=voice), artifact_root=artifacts)
|
|
params = json.loads(assignment.params_json)
|
|
|
|
assert list(assignment.inputs) == []
|
|
assert "ref_audio" not in params, "a dead path renders the wrong voice, silently"
|
|
assert params[executor_module.INPUT_ERRORS_PARAM]
|
|
|
|
|
|
def test_the_error_key_is_the_one_the_worker_reads():
|
|
"""Two modules, one contract, no import between them."""
|
|
assert codec._INPUT_ERRORS_KEY == executor_module.INPUT_ERRORS_PARAM
|
|
|
|
|
|
# ── The worker side ────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_clone_whose_reference_only_exists_on_the_control_plane_succeeds(
|
|
db, artifacts, voice, engine, tmp_path
|
|
):
|
|
"""The whole point of the phase, end to end.
|
|
|
|
The reference clip exists on the control plane and nowhere else. Submit,
|
|
build the assignment, run it on a worker whose only route to the file is
|
|
``DownloadArtifact`` — and the engine must be called with a readable local
|
|
copy of the user's actual voice.
|
|
"""
|
|
task = _task(ref_audio=voice)
|
|
task_store.create(task, now=1000.0)
|
|
assignment = _assignment(task, artifact_root=artifacts)
|
|
fetch = _download_from(artifacts)
|
|
worker = TaskExecutor(fetch_input=fetch, input_dir=str(tmp_path / "worker-inputs"))
|
|
|
|
await worker.execute(assignment, fetch_input=fetch)
|
|
|
|
used = engine.last_kwargs["ref_audio"]
|
|
assert used != voice, "the worker cannot open a control-plane path"
|
|
assert os.path.isfile(used)
|
|
assert open(used, "rb").read() == open(voice, "rb").read()
|
|
assert fetch.calls == [assignment.inputs[0].artifact_id]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_second_clone_of_a_voice_transfers_nothing(
|
|
artifacts, voice, engine, tmp_path
|
|
):
|
|
assignment = _assignment(_task(ref_audio=voice), artifact_root=artifacts)
|
|
fetch = _download_from(artifacts)
|
|
worker = TaskExecutor(fetch_input=fetch, input_dir=str(tmp_path / "worker-inputs"))
|
|
|
|
await worker.execute(assignment)
|
|
await worker.execute(assignment)
|
|
|
|
assert len(fetch.calls) == 1, "content-addressed cache re-downloaded a clip it held"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_damaged_transfer_is_refused(artifacts, voice, engine, tmp_path):
|
|
"""A truncated clip does not fail — it clones silence."""
|
|
cache = tmp_path / "worker-inputs"
|
|
assignment = _assignment(_task(ref_audio=voice), artifact_root=artifacts)
|
|
worker = TaskExecutor(fetch_input=_download_from(artifacts, corrupt=True), input_dir=str(cache))
|
|
|
|
with pytest.raises(TaskFailure) as raised:
|
|
await worker.execute(assignment)
|
|
|
|
assert raised.value.error.code == "INPUT_CORRUPT"
|
|
assert list(cache.iterdir()) == [], "a damaged transfer must not be committed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_unreachable_input_is_retryable_not_terminal(artifacts, voice, engine, tmp_path):
|
|
assignment = _assignment(_task(ref_audio=voice), artifact_root=artifacts)
|
|
shutil.rmtree(os.path.join(artifacts, task_store.INPUTS_DIRNAME))
|
|
worker = TaskExecutor(fetch_input=_download_from(artifacts), input_dir=str(tmp_path / "in"))
|
|
|
|
with pytest.raises(TaskFailure) as raised:
|
|
await worker.execute(assignment)
|
|
|
|
assert raised.value.error.error_class is ErrorClass.TRANSIENT
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_worker_that_cannot_fetch_says_so(artifacts, voice, engine):
|
|
assignment = _assignment(_task(ref_audio=voice), artifact_root=artifacts)
|
|
|
|
with pytest.raises(TaskFailure) as raised:
|
|
await TaskExecutor().execute(assignment)
|
|
|
|
assert raised.value.error.error_class is ErrorClass.CAPABILITY
|
|
assert raised.value.error.code == "INPUT_TRANSFER_UNSUPPORTED"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_staging_error_is_terminal_on_the_worker(engine, tmp_path):
|
|
assignment = pb.TaskAssignment(
|
|
operation="clone",
|
|
engine="fake-engine",
|
|
params_json='{"text": "hi", "input_errors": ["the reference clip vanished"]}',
|
|
)
|
|
|
|
with pytest.raises(TaskFailure) as raised:
|
|
await TaskExecutor(input_dir=str(tmp_path)).execute(assignment)
|
|
|
|
assert raised.value.error.error_class is ErrorClass.TERMINAL
|
|
assert raised.value.error.code == "INPUT_UNAVAILABLE"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_hostile_filename_cannot_escape_the_cache(artifacts, voice, engine, tmp_path):
|
|
"""``filename`` is remote input; only the hash names the local copy."""
|
|
cache = tmp_path / "worker-inputs"
|
|
assignment = _assignment(_task(ref_audio=voice), artifact_root=artifacts)
|
|
assignment.inputs[0].filename = "../../../../pwned.wav"
|
|
worker = TaskExecutor(fetch_input=_download_from(artifacts), input_dir=str(cache))
|
|
|
|
await worker.execute(assignment)
|
|
|
|
used = engine.last_kwargs["ref_audio"]
|
|
assert os.path.dirname(os.path.realpath(used)) == os.path.realpath(str(cache))
|
|
assert not (tmp_path.parent / "pwned.wav").exists()
|
|
|
|
|
|
def test_the_worker_input_cache_has_a_ceiling(tmp_path):
|
|
directory = tmp_path / "cache"
|
|
directory.mkdir()
|
|
for index in range(5):
|
|
path = directory / f"{index}.bin"
|
|
path.write_bytes(b"x" * 100)
|
|
os.utime(path, (1000 + index, 1000 + index))
|
|
|
|
executor_module._prune_input_cache(str(directory), limit_bytes=250)
|
|
|
|
survivors = sorted(p.name for p in directory.iterdir())
|
|
assert survivors == ["3.bin", "4.bin"], "the cache must evict oldest-first"
|
|
|
|
|
|
# ── The disk ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _finish(task: Task, *, at: float) -> None:
|
|
task.state = TaskState.COMPLETED
|
|
task.finished_at = at
|
|
|
|
|
|
def _result_artifact(artifacts: str, task_id: str) -> str:
|
|
path = os.path.join(artifacts, task_id, "a1.bin")
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
with open(path, "wb") as handle:
|
|
handle.write(b"rendered audio")
|
|
return path
|
|
|
|
|
|
WEEK = 7 * 24 * 3600
|
|
|
|
|
|
def test_purging_a_task_takes_its_artifacts_with_it(db, artifacts, voice):
|
|
"""The regression: rows were purged, bytes were kept — forever."""
|
|
task = _task(ref_audio=voice)
|
|
task_store.create(task, now=1000.0)
|
|
entry = task.params[task_store.INPUTS_PARAM_KEY][0]
|
|
staged = str(resolve_within(artifacts, entry["artifact_id"]))
|
|
result = _result_artifact(artifacts, "t1")
|
|
_finish(task, at=1000.0)
|
|
task_store.save(task, now=1000.0)
|
|
|
|
removed = task_store.purge_finished(now=1000.0 + WEEK + 1)
|
|
|
|
assert removed == 1
|
|
assert task_store.get("t1") is None
|
|
assert not os.path.exists(result), "every remote render leaked its output"
|
|
assert not os.path.exists(staged), "every remote clone leaked a copy of the voice"
|
|
|
|
|
|
def test_a_voice_another_task_still_uses_survives_the_purge(db, artifacts, voice):
|
|
old = _task("t1", ref_audio=voice)
|
|
task_store.create(old, now=1000.0)
|
|
_finish(old, at=1000.0)
|
|
task_store.save(old, now=1000.0)
|
|
live = _task("t2", ref_audio=voice)
|
|
task_store.create(live, now=1000.0)
|
|
staged = str(resolve_within(artifacts, live.params[task_store.INPUTS_PARAM_KEY][0]["artifact_id"]))
|
|
|
|
task_store.purge_finished(now=1000.0 + WEEK + 1)
|
|
|
|
assert task_store.get("t1") is None
|
|
assert task_store.get("t2") is not None
|
|
assert os.path.isfile(staged), "one copy is shared by every clone of that voice"
|
|
|
|
|
|
def test_a_recently_staged_input_is_never_swept(db, artifacts, voice):
|
|
task = _task(ref_audio=voice)
|
|
task_store.create(task, now=1000.0 + WEEK)
|
|
staged = str(resolve_within(artifacts, task.params[task_store.INPUTS_PARAM_KEY][0]["artifact_id"]))
|
|
_finish(task, at=1000.0)
|
|
task_store.save(task, now=1000.0)
|
|
|
|
task_store.purge_finished(now=1000.0 + WEEK + 1)
|
|
|
|
assert os.path.isfile(staged), "swept an input younger than the cutoff"
|
|
|
|
|
|
def test_purge_survives_a_missing_artifact_directory(db, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(task_store, "artifact_root", lambda **_kw: str(tmp_path / "gone"))
|
|
task = _task()
|
|
task_store.create(task, now=1000.0)
|
|
_finish(task, at=1000.0)
|
|
task_store.save(task, now=1000.0)
|
|
|
|
assert task_store.purge_finished(now=1000.0 + WEEK + 1) == 1
|