echoact 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- echoact/__init__.py +3 -0
- echoact/__main__.py +117 -0
- echoact/app.py +315 -0
- echoact/audio/__init__.py +0 -0
- echoact/audio/devices.py +192 -0
- echoact/audio/player.py +611 -0
- echoact/audio/wav.py +854 -0
- echoact/config/__init__.py +0 -0
- echoact/config/budget.py +370 -0
- echoact/config/settings.py +1244 -0
- echoact/db/__init__.py +0 -0
- echoact/db/backup.py +2429 -0
- echoact/db/migrations.py +434 -0
- echoact/db/schema.sql +214 -0
- echoact/db/store.py +2062 -0
- echoact/diagnostics.py +902 -0
- echoact/domain.py +487 -0
- echoact/engine/__init__.py +0 -0
- echoact/engine/container.py +843 -0
- echoact/engine/protocol.py +241 -0
- echoact/engine/runtime.py +324 -0
- echoact/engine/supervisor.py +961 -0
- echoact/engine/worker.py +659 -0
- echoact/errors.py +281 -0
- echoact/instance.py +172 -0
- echoact/jobs/__init__.py +0 -0
- echoact/jobs/engine.py +776 -0
- echoact/jobs/request.py +300 -0
- echoact/mcp/__init__.py +0 -0
- echoact/mcp/__main__.py +50 -0
- echoact/mcp/client.py +202 -0
- echoact/mcp/config.py +112 -0
- echoact/mcp/server.py +340 -0
- echoact/models/__init__.py +0 -0
- echoact/models/catalog.py +273 -0
- echoact/models/manifest.py +278 -0
- echoact/models/registry.py +1551 -0
- echoact/paths.py +93 -0
- echoact/policy.py +189 -0
- echoact/security/__init__.py +0 -0
- echoact/security/credentials.py +930 -0
- echoact/security/ratelimit.py +534 -0
- echoact/service/__init__.py +20 -0
- echoact/service/app.py +182 -0
- echoact/service/deps.py +563 -0
- echoact/service/errors.py +241 -0
- echoact/service/routes.py +1125 -0
- echoact/service/schemas.py +509 -0
- echoact/service/server.py +270 -0
- echoact/text/__init__.py +0 -0
- echoact/text/language.py +44 -0
- echoact/text/loader.py +577 -0
- echoact/text/normalize.py +924 -0
- echoact/text/segment.py +499 -0
- echoact/text/sniff.py +1202 -0
- echoact/ui/__init__.py +0 -0
- echoact/ui/bridge.py +50 -0
- echoact/ui/controls.py +360 -0
- echoact/ui/credential_dialog.py +131 -0
- echoact/ui/fonts.py +94 -0
- echoact/ui/i18n.py +260 -0
- echoact/ui/icons.py +440 -0
- echoact/ui/library.py +1642 -0
- echoact/ui/licence.py +162 -0
- echoact/ui/main_window.py +1202 -0
- echoact/ui/mcp_setup.py +494 -0
- echoact/ui/models_view.py +1142 -0
- echoact/ui/notifications.py +202 -0
- echoact/ui/reading.py +494 -0
- echoact/ui/settings_view.py +2258 -0
- echoact/ui/status_view.py +1193 -0
- echoact/ui/theme.py +579 -0
- echoact/util/__init__.py +0 -0
- echoact/util/ids.py +62 -0
- echoact/util/logging.py +127 -0
- echoact-0.1.0.dist-info/METADATA +162 -0
- echoact-0.1.0.dist-info/RECORD +80 -0
- echoact-0.1.0.dist-info/WHEEL +4 -0
- echoact-0.1.0.dist-info/entry_points.txt +3 -0
- echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
"""The parent side of the synthesis worker.
|
|
2
|
+
|
|
3
|
+
Nothing else in the application talks to the worker process. That is the
|
|
4
|
+
point of the module: A.2 puts synthesis in a child so N-03 can enforce
|
|
5
|
+
limits on it and N-21 can measure it apart from the GUI, the database, and
|
|
6
|
+
the service, and a single owner of the pipe is what keeps the seq discipline
|
|
7
|
+
in ``protocol`` meaningful.
|
|
8
|
+
|
|
9
|
+
Three rules shape the code, all of them from requirements rather than taste.
|
|
10
|
+
|
|
11
|
+
*Stale replies are dropped.* Every request carries an increasing ``seq``.
|
|
12
|
+
When a caller stops waiting -- a timeout, a cancelled job -- the pending
|
|
13
|
+
entry is removed, and a reply arriving afterwards matches nothing and is
|
|
14
|
+
discarded. It is never handed to whatever request ran next, because the
|
|
15
|
+
audio it describes belongs to text the caller has already abandoned.
|
|
16
|
+
|
|
17
|
+
*A worker that dies unexpectedly makes this object unusable.* The pending
|
|
18
|
+
request gets ``Code.WORKER_LOST`` and every later call raises it too, until
|
|
19
|
+
someone calls :meth:`kill` to tear the wreckage down deliberately. A silent
|
|
20
|
+
restart would look like a recovery while having lost the loaded model and
|
|
21
|
+
every remaining segment of the job -- and whether to restart is the job
|
|
22
|
+
engine's decision under F-19, not this object's.
|
|
23
|
+
|
|
24
|
+
*Release is bounded.* :meth:`kill` returns inside N-22's five seconds: ask
|
|
25
|
+
with ``Shutdown``, wait ``WORKER_TERMINATE_GRACE_S``, terminate, then close
|
|
26
|
+
the container, which on Windows kills anything still breathing because the
|
|
27
|
+
Job Object was created with ``KILL_ON_JOB_CLOSE``. Each step is measured
|
|
28
|
+
against one deadline rather than being given its own timeout, so the sum
|
|
29
|
+
cannot drift past the promise.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import os
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import threading
|
|
38
|
+
from collections import deque
|
|
39
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from enum import StrEnum
|
|
42
|
+
from io import TextIOWrapper
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any, Final
|
|
45
|
+
|
|
46
|
+
from ..domain import Budget
|
|
47
|
+
from ..errors import Code, EchoActError
|
|
48
|
+
from ..policy import (
|
|
49
|
+
BOUNDED_WAIT_CEILING_S,
|
|
50
|
+
BOUNDED_WAIT_DEFAULT_S,
|
|
51
|
+
ENGINE_TOTAL_STEPS,
|
|
52
|
+
WORKER_RELEASE_DEADLINE_S,
|
|
53
|
+
WORKER_TERMINATE_GRACE_S,
|
|
54
|
+
)
|
|
55
|
+
from ..util.ids import monotonic
|
|
56
|
+
from ..util.logging import get_logger
|
|
57
|
+
from . import protocol
|
|
58
|
+
from .container import ContainerLimits, ResourceContainer, make_container
|
|
59
|
+
|
|
60
|
+
log = get_logger("engine.supervisor")
|
|
61
|
+
|
|
62
|
+
#: The worker, as a module so that the child inherits nothing but the
|
|
63
|
+
#: interpreter -- no script path to get wrong when the app is packaged.
|
|
64
|
+
WORKER_MODULE: Final = "echoact.engine.worker"
|
|
65
|
+
|
|
66
|
+
#: The flag the frozen executable uses to become a worker instead of the
|
|
67
|
+
#: application. See packaging/entry.py.
|
|
68
|
+
WORKER_FLAG: Final = "--worker"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def default_worker_command() -> tuple[str, ...]:
|
|
72
|
+
"""How to start a worker, which differs between a checkout and a build.
|
|
73
|
+
|
|
74
|
+
From a checkout ``sys.executable`` is a Python interpreter and ``-m``
|
|
75
|
+
works. Inside a PyInstaller bundle it is the frozen application and
|
|
76
|
+
``-m`` means nothing to it, so the executable re-invokes itself with a
|
|
77
|
+
flag and dispatches. Deciding here rather than at each call site
|
|
78
|
+
means the supervisor's tests, which pass their own command, are
|
|
79
|
+
unaffected either way.
|
|
80
|
+
"""
|
|
81
|
+
if getattr(sys, "frozen", False):
|
|
82
|
+
return (sys.executable, WORKER_FLAG)
|
|
83
|
+
return (sys.executable, "-m", WORKER_MODULE)
|
|
84
|
+
|
|
85
|
+
# Section 4 fixes no timeout for an individual engine request, so these
|
|
86
|
+
# borrow the two waits it does fix rather than inventing numbers here. A
|
|
87
|
+
# request that blows its deadline is treated as a dead worker, which is the
|
|
88
|
+
# only honest reading: the protocol has one outstanding request at a time,
|
|
89
|
+
# so a worker that has not answered is a worker that cannot be asked
|
|
90
|
+
# anything else either.
|
|
91
|
+
START_TIMEOUT_S: Final = BOUNDED_WAIT_DEFAULT_S
|
|
92
|
+
LOAD_TIMEOUT_S: Final = BOUNDED_WAIT_CEILING_S
|
|
93
|
+
SYNTHESIZE_TIMEOUT_S: Final = BOUNDED_WAIT_CEILING_S
|
|
94
|
+
PING_TIMEOUT_S: Final = BOUNDED_WAIT_DEFAULT_S
|
|
95
|
+
|
|
96
|
+
#: F-87's allow-list, as the supervisor's default. It mirrors
|
|
97
|
+
#: ``echoact.engine.runtime.ALLOWED_PROVIDERS``, which is the authority,
|
|
98
|
+
#: rather than importing it: that module imports onnxruntime at load, and
|
|
99
|
+
#: pulling the inference runtime into the parent process would put tens of
|
|
100
|
+
#: megabytes into exactly the total N-21 asks us to keep apart from the
|
|
101
|
+
#: generation job. A test asserts the two agree. The check that matters is
|
|
102
|
+
#: in the worker anyway, against the session it actually constructed; this
|
|
103
|
+
#: is only what the parent asks for.
|
|
104
|
+
DEFAULT_ALLOWED_PROVIDERS: Final = ("CPUExecutionProvider",)
|
|
105
|
+
|
|
106
|
+
#: A worker error message is quoted back to the user but never logged and
|
|
107
|
+
#: never trusted to be short; N-20 keeps body text out of logs and this keeps
|
|
108
|
+
#: an engine traceback from smuggling any in.
|
|
109
|
+
_MAX_WORKER_MESSAGE = 200
|
|
110
|
+
|
|
111
|
+
_STDERR_KEEP = 20
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class WorkerState(StrEnum):
|
|
115
|
+
STOPPED = "stopped"
|
|
116
|
+
RUNNING = "running"
|
|
117
|
+
#: Died without being asked to. Unusable until :meth:`kill`.
|
|
118
|
+
LOST = "lost"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, slots=True)
|
|
122
|
+
class LoadedModel:
|
|
123
|
+
"""What the worker reported after F-09's preparation succeeded."""
|
|
124
|
+
|
|
125
|
+
model_id: str
|
|
126
|
+
model_dir: str
|
|
127
|
+
sample_rate: int
|
|
128
|
+
voices: tuple[str, ...]
|
|
129
|
+
providers: tuple[str, ...]
|
|
130
|
+
load_seconds: float
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True, slots=True)
|
|
134
|
+
class WorkerUsage:
|
|
135
|
+
"""F-22's figures for the generation job alone (N-21).
|
|
136
|
+
|
|
137
|
+
``source`` says where they came from: the kernel's own accounting for the
|
|
138
|
+
container, or the worker's self-report. They are not interchangeable --
|
|
139
|
+
the container counts the whole worker tree whether or not the worker is
|
|
140
|
+
answering -- so the distinction is reported rather than smoothed over.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
rss_bytes: int
|
|
144
|
+
cpu_percent: float
|
|
145
|
+
peak_rss_bytes: int
|
|
146
|
+
peak_commit_bytes: int
|
|
147
|
+
limits: ContainerLimits | None
|
|
148
|
+
source: str
|
|
149
|
+
age_s: float
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class _Waiter:
|
|
153
|
+
"""One in-flight request's mailbox."""
|
|
154
|
+
|
|
155
|
+
__slots__ = ("event", "reply", "error")
|
|
156
|
+
|
|
157
|
+
def __init__(self) -> None:
|
|
158
|
+
self.event = threading.Event()
|
|
159
|
+
self.reply: Any = None
|
|
160
|
+
self.error: EchoActError | None = None
|
|
161
|
+
|
|
162
|
+
def deliver(self, reply: Any) -> None:
|
|
163
|
+
self.reply = reply
|
|
164
|
+
self.event.set()
|
|
165
|
+
|
|
166
|
+
def fail(self, error: EchoActError) -> None:
|
|
167
|
+
self.error = error
|
|
168
|
+
self.event.set()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class WorkerSupervisor:
|
|
172
|
+
"""Owns one worker process and its resource container.
|
|
173
|
+
|
|
174
|
+
Warm reuse (F-17, F-18) lives here: the worker and its loaded model
|
|
175
|
+
survive between jobs, and :meth:`would_need_load` answers whether the
|
|
176
|
+
next job pays for a load. Note what that method does *not* take -- voice,
|
|
177
|
+
language, style, tempo. Those are arguments to :meth:`synthesize`, so
|
|
178
|
+
F-18's "changing only these keeps the model" is true by construction
|
|
179
|
+
rather than by a comparison someone has to remember to write. What does
|
|
180
|
+
release the model is a different model id or a different budget (F-19):
|
|
181
|
+
the budget is baked into the container at creation, so changing it means
|
|
182
|
+
a new container and therefore a new worker.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
*,
|
|
188
|
+
command: Sequence[str] | None = None,
|
|
189
|
+
container_factory: Callable[[Budget], ResourceContainer] = make_container,
|
|
190
|
+
env: Mapping[str, str] | None = None,
|
|
191
|
+
cwd: str | Path | None = None,
|
|
192
|
+
start_timeout_s: float = START_TIMEOUT_S,
|
|
193
|
+
) -> None:
|
|
194
|
+
self._command: tuple[str, ...] = tuple(
|
|
195
|
+
command if command is not None else default_worker_command()
|
|
196
|
+
)
|
|
197
|
+
self._container_factory = container_factory
|
|
198
|
+
self._env_overrides = dict(env or {})
|
|
199
|
+
self._cwd = str(cwd) if cwd is not None else None
|
|
200
|
+
self._start_timeout_s = start_timeout_s
|
|
201
|
+
|
|
202
|
+
# Held for the whole of one request/reply round trip: the protocol
|
|
203
|
+
# allows exactly one outstanding request. ``kill`` deliberately does
|
|
204
|
+
# not take it, so a stuck synthesis can still be released in time.
|
|
205
|
+
self._call_lock = threading.Lock()
|
|
206
|
+
# Short critical sections only: process handles, pending table,
|
|
207
|
+
# liveness. Never held while waiting on a reply.
|
|
208
|
+
self._state_lock = threading.RLock()
|
|
209
|
+
|
|
210
|
+
self._counter = protocol.Counter()
|
|
211
|
+
self._pending: dict[int, _Waiter] = {}
|
|
212
|
+
self._state = WorkerState.STOPPED
|
|
213
|
+
self._lost_error: EchoActError | None = None
|
|
214
|
+
self._stopping = False
|
|
215
|
+
|
|
216
|
+
self._proc: subprocess.Popen[bytes] | None = None
|
|
217
|
+
self._container: ResourceContainer | None = None
|
|
218
|
+
self._budget: Budget | None = None
|
|
219
|
+
self._loaded: LoadedModel | None = None
|
|
220
|
+
self._stdin: TextIOWrapper | None = None
|
|
221
|
+
self._stdout: TextIOWrapper | None = None
|
|
222
|
+
self._reader: threading.Thread | None = None
|
|
223
|
+
self._stderr_reader: threading.Thread | None = None
|
|
224
|
+
self._stderr_tail: deque[str] = deque(maxlen=_STDERR_KEEP)
|
|
225
|
+
self._ready = threading.Event()
|
|
226
|
+
self._worker_pid: int | None = None
|
|
227
|
+
self._last_report: tuple[int, float, float] | None = None
|
|
228
|
+
self._dropped_replies = 0
|
|
229
|
+
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
# State
|
|
232
|
+
# ------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def state(self) -> WorkerState:
|
|
236
|
+
with self._state_lock:
|
|
237
|
+
return self._state
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def is_running(self) -> bool:
|
|
241
|
+
with self._state_lock:
|
|
242
|
+
return self._state is WorkerState.RUNNING and self._proc is not None
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def is_usable(self) -> bool:
|
|
246
|
+
"""False after an unexpected death, until :meth:`kill` clears it."""
|
|
247
|
+
return self.state is not WorkerState.LOST
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def loaded_model(self) -> LoadedModel | None:
|
|
251
|
+
with self._state_lock:
|
|
252
|
+
return self._loaded
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def budget(self) -> Budget | None:
|
|
256
|
+
with self._state_lock:
|
|
257
|
+
return self._budget
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def worker_pid(self) -> int | None:
|
|
261
|
+
return self._worker_pid
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def limits(self) -> ContainerLimits | None:
|
|
265
|
+
"""What the container enforces, for F-22's display and N-03's
|
|
266
|
+
distinction between an enforced ceiling and a monitored one."""
|
|
267
|
+
c = self._container
|
|
268
|
+
return c.limits if c is not None else None
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def dropped_replies(self) -> int:
|
|
272
|
+
"""Replies discarded because their request had been abandoned."""
|
|
273
|
+
return self._dropped_replies
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def last_worker_report(self) -> tuple[int, float, float] | None:
|
|
277
|
+
"""The worker's own last ``(rss_bytes, cpu_percent, monotonic)``.
|
|
278
|
+
|
|
279
|
+
Kept separate from :meth:`usage` because it is a different
|
|
280
|
+
measurement: the worker reports what it can see of itself, while the
|
|
281
|
+
container reports what the kernel accounts to the whole job.
|
|
282
|
+
"""
|
|
283
|
+
return self._last_report
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def memory_exceeded(self) -> bool:
|
|
287
|
+
"""F-23, on a platform where the ceiling is polled rather than
|
|
288
|
+
enforced: the container saw the budget exceeded and halted the job."""
|
|
289
|
+
c = self._container
|
|
290
|
+
return c is not None and c.memory_exceeded
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def stderr_tail(self) -> tuple[str, ...]:
|
|
294
|
+
"""The worker's last diagnostic lines, for a failure report."""
|
|
295
|
+
return tuple(self._stderr_tail)
|
|
296
|
+
|
|
297
|
+
def would_need_load(self, model_id: str, budget: Budget) -> bool:
|
|
298
|
+
"""F-17/F-18/F-19, as one question the job engine can ask up front.
|
|
299
|
+
|
|
300
|
+
True means the next job pays a model load. It is false only when the
|
|
301
|
+
worker is alive, holds this exact model, and runs under this exact
|
|
302
|
+
budget.
|
|
303
|
+
"""
|
|
304
|
+
with self._state_lock:
|
|
305
|
+
return not (
|
|
306
|
+
self._state is WorkerState.RUNNING
|
|
307
|
+
and self._budget == budget
|
|
308
|
+
and self._loaded is not None
|
|
309
|
+
and self._loaded.model_id == model_id
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# ------------------------------------------------------------------
|
|
313
|
+
# Requests
|
|
314
|
+
# ------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
def load(
|
|
317
|
+
self,
|
|
318
|
+
model_id: str,
|
|
319
|
+
model_dir: str | Path,
|
|
320
|
+
budget: Budget,
|
|
321
|
+
*,
|
|
322
|
+
allowed_providers: Sequence[str] | None = None,
|
|
323
|
+
timeout: float = LOAD_TIMEOUT_S,
|
|
324
|
+
) -> LoadedModel:
|
|
325
|
+
"""Prepare a model, starting or replacing the worker as F-19 requires.
|
|
326
|
+
|
|
327
|
+
The parent resolves ``model_dir`` against the F-84 manifest before
|
|
328
|
+
calling; the worker never searches for weights, so a bad path is a
|
|
329
|
+
programming error here rather than a search there.
|
|
330
|
+
"""
|
|
331
|
+
self._require_usable()
|
|
332
|
+
directory = str(model_dir)
|
|
333
|
+
with self._call_lock:
|
|
334
|
+
with self._state_lock:
|
|
335
|
+
running = self._state is WorkerState.RUNNING
|
|
336
|
+
same_budget = self._budget == budget
|
|
337
|
+
loaded = self._loaded
|
|
338
|
+
if running and not same_budget:
|
|
339
|
+
# F-19: a new resource limit releases the model. The limit
|
|
340
|
+
# lives in the container, and a container's limits are fixed
|
|
341
|
+
# when it is created, so the worker goes with it.
|
|
342
|
+
log.info("budget changed; replacing the worker to apply it")
|
|
343
|
+
self.kill()
|
|
344
|
+
running = False
|
|
345
|
+
loaded = None
|
|
346
|
+
if not running:
|
|
347
|
+
self._spawn(budget)
|
|
348
|
+
loaded = None
|
|
349
|
+
elif loaded is not None and (loaded.model_id, loaded.model_dir) == (
|
|
350
|
+
model_id,
|
|
351
|
+
directory,
|
|
352
|
+
):
|
|
353
|
+
# F-17: warm reuse. The directory is compared as well as the
|
|
354
|
+
# id, so a repaired or relocated model (F-65) is reloaded
|
|
355
|
+
# rather than silently answered from the old weights.
|
|
356
|
+
return loaded
|
|
357
|
+
elif loaded is not None:
|
|
358
|
+
self._send_unload()
|
|
359
|
+
|
|
360
|
+
reply = self._call(
|
|
361
|
+
protocol.Load(
|
|
362
|
+
model_id=model_id,
|
|
363
|
+
model_dir=directory,
|
|
364
|
+
intra_op_threads=budget.intra_op_threads,
|
|
365
|
+
inter_op_threads=budget.inter_op_threads,
|
|
366
|
+
allowed_providers=list(allowed_providers or DEFAULT_ALLOWED_PROVIDERS),
|
|
367
|
+
),
|
|
368
|
+
timeout=timeout,
|
|
369
|
+
kill_on_timeout=True,
|
|
370
|
+
)
|
|
371
|
+
if not isinstance(reply, protocol.Loaded):
|
|
372
|
+
raise self._unexpected(reply)
|
|
373
|
+
model = LoadedModel(
|
|
374
|
+
model_id=reply.model_id,
|
|
375
|
+
model_dir=directory,
|
|
376
|
+
sample_rate=reply.sample_rate,
|
|
377
|
+
voices=tuple(reply.voices),
|
|
378
|
+
providers=tuple(reply.providers),
|
|
379
|
+
load_seconds=reply.load_seconds,
|
|
380
|
+
)
|
|
381
|
+
with self._state_lock:
|
|
382
|
+
self._loaded = model
|
|
383
|
+
log.info(
|
|
384
|
+
"model loaded model=%s rate=%d voices=%d in %.2fs",
|
|
385
|
+
model.model_id,
|
|
386
|
+
model.sample_rate,
|
|
387
|
+
len(model.voices),
|
|
388
|
+
model.load_seconds,
|
|
389
|
+
)
|
|
390
|
+
return model
|
|
391
|
+
|
|
392
|
+
def synthesize(
|
|
393
|
+
self,
|
|
394
|
+
*,
|
|
395
|
+
job_id: str,
|
|
396
|
+
segment_index: int,
|
|
397
|
+
text: str,
|
|
398
|
+
lang: str,
|
|
399
|
+
voice_id: str,
|
|
400
|
+
speed: float,
|
|
401
|
+
out_path: str | Path,
|
|
402
|
+
total_steps: int = ENGINE_TOTAL_STEPS,
|
|
403
|
+
timeout: float = SYNTHESIZE_TIMEOUT_S,
|
|
404
|
+
) -> protocol.Audio:
|
|
405
|
+
"""Render one segment and return the worker's ``Audio`` reply.
|
|
406
|
+
|
|
407
|
+
The reply is the parent's evidence that the file at ``out_path`` is
|
|
408
|
+
complete: the worker sends it only after the bytes are flushed and
|
|
409
|
+
closed, so a killed worker leaves a file the parent already knows to
|
|
410
|
+
discard rather than a truncated one it might use.
|
|
411
|
+
"""
|
|
412
|
+
self._require_usable()
|
|
413
|
+
with self._call_lock:
|
|
414
|
+
with self._state_lock:
|
|
415
|
+
if self._state is not WorkerState.RUNNING or self._loaded is None:
|
|
416
|
+
raise EchoActError(
|
|
417
|
+
Code.MODEL_NOT_READY,
|
|
418
|
+
"No model is loaded in the synthesis worker.",
|
|
419
|
+
)
|
|
420
|
+
reply = self._call(
|
|
421
|
+
protocol.Synthesize(
|
|
422
|
+
job_id=job_id,
|
|
423
|
+
segment_index=segment_index,
|
|
424
|
+
text=text,
|
|
425
|
+
lang=lang,
|
|
426
|
+
voice_id=voice_id,
|
|
427
|
+
speed=speed,
|
|
428
|
+
total_steps=total_steps,
|
|
429
|
+
out_path=str(out_path),
|
|
430
|
+
),
|
|
431
|
+
timeout=timeout,
|
|
432
|
+
kill_on_timeout=True,
|
|
433
|
+
)
|
|
434
|
+
if not isinstance(reply, protocol.Audio):
|
|
435
|
+
raise self._unexpected(reply)
|
|
436
|
+
return reply
|
|
437
|
+
|
|
438
|
+
def unload(self, *, timeout: float = PING_TIMEOUT_S) -> None:
|
|
439
|
+
"""F-19's explicit release, keeping the worker warm.
|
|
440
|
+
|
|
441
|
+
``Unload`` has no reply of its own in the protocol, so the round trip
|
|
442
|
+
is closed with a ``Ping``: stdin is ordered, and a ``Pong`` can only
|
|
443
|
+
arrive after the unload ahead of it was handled. Inventing an
|
|
444
|
+
acknowledgement the protocol does not define would be worse -- the
|
|
445
|
+
worker is written against the same file this is.
|
|
446
|
+
"""
|
|
447
|
+
with self._call_lock:
|
|
448
|
+
if not self.is_running:
|
|
449
|
+
with self._state_lock:
|
|
450
|
+
self._loaded = None
|
|
451
|
+
return
|
|
452
|
+
self._send_unload()
|
|
453
|
+
with self._state_lock:
|
|
454
|
+
self._loaded = None
|
|
455
|
+
self._call(protocol.Ping(), timeout=timeout, kill_on_timeout=False)
|
|
456
|
+
|
|
457
|
+
def ping(self, *, timeout: float = PING_TIMEOUT_S) -> protocol.Pong:
|
|
458
|
+
"""Liveness, and a resource sample on the way back.
|
|
459
|
+
|
|
460
|
+
It queues behind whatever request is in flight, because the protocol
|
|
461
|
+
allows one at a time. That is why F-22's display uses :meth:`usage`,
|
|
462
|
+
which reads the container and never waits on the worker at all.
|
|
463
|
+
"""
|
|
464
|
+
self._require_usable()
|
|
465
|
+
with self._call_lock:
|
|
466
|
+
reply = self._call(protocol.Ping(), timeout=timeout, kill_on_timeout=False)
|
|
467
|
+
if not isinstance(reply, protocol.Pong):
|
|
468
|
+
raise self._unexpected(reply)
|
|
469
|
+
return reply
|
|
470
|
+
|
|
471
|
+
# ------------------------------------------------------------------
|
|
472
|
+
# Resource reporting
|
|
473
|
+
# ------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
def usage(self) -> WorkerUsage | None:
|
|
476
|
+
"""The latest RSS and CPU for the generation job (F-22, N-21).
|
|
477
|
+
|
|
478
|
+
Prefers the container's sample: on Windows that is Job Object
|
|
479
|
+
accounting, which counts the worker and only the worker even while
|
|
480
|
+
it is too busy to answer a ``Ping``. The worker's own ``Stats`` is
|
|
481
|
+
the fallback, and the result says which was used.
|
|
482
|
+
"""
|
|
483
|
+
container = self._container
|
|
484
|
+
sample = container.usage() if container is not None else None
|
|
485
|
+
limits = container.limits if container is not None else None
|
|
486
|
+
now = monotonic()
|
|
487
|
+
if sample is not None:
|
|
488
|
+
return WorkerUsage(
|
|
489
|
+
rss_bytes=sample.rss_bytes,
|
|
490
|
+
cpu_percent=sample.cpu_percent,
|
|
491
|
+
peak_rss_bytes=sample.peak_rss_bytes,
|
|
492
|
+
peak_commit_bytes=sample.peak_commit_bytes,
|
|
493
|
+
limits=limits,
|
|
494
|
+
source=sample.source,
|
|
495
|
+
age_s=max(0.0, now - sample.sampled_at),
|
|
496
|
+
)
|
|
497
|
+
report = self._last_report
|
|
498
|
+
if report is None:
|
|
499
|
+
return None
|
|
500
|
+
rss, cpu, at = report
|
|
501
|
+
return WorkerUsage(
|
|
502
|
+
rss_bytes=rss,
|
|
503
|
+
cpu_percent=cpu,
|
|
504
|
+
peak_rss_bytes=rss,
|
|
505
|
+
peak_commit_bytes=0,
|
|
506
|
+
limits=limits,
|
|
507
|
+
source="worker report",
|
|
508
|
+
age_s=max(0.0, now - at),
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
def sample_usage(self) -> WorkerUsage | None:
|
|
512
|
+
"""Force a fresh container sample, then report. Used by the display
|
|
513
|
+
refresh; the periodic sampling happens in the container regardless."""
|
|
514
|
+
container = self._container
|
|
515
|
+
if container is not None:
|
|
516
|
+
container.sample()
|
|
517
|
+
return self.usage()
|
|
518
|
+
|
|
519
|
+
# ------------------------------------------------------------------
|
|
520
|
+
# Teardown
|
|
521
|
+
# ------------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
def kill(self) -> float:
|
|
524
|
+
"""Release everything, within N-22's five seconds. Returns elapsed.
|
|
525
|
+
|
|
526
|
+
Safe at any instant, including mid-segment: the worker holds no
|
|
527
|
+
durable state, and the parent named the output file before asking for
|
|
528
|
+
it, so an interrupted write leaves a file the caller already knows to
|
|
529
|
+
discard.
|
|
530
|
+
"""
|
|
531
|
+
started = monotonic()
|
|
532
|
+
deadline = started + WORKER_RELEASE_DEADLINE_S
|
|
533
|
+
|
|
534
|
+
def remaining(reserve: float = 0.0) -> float:
|
|
535
|
+
return max(0.0, deadline - monotonic() - reserve)
|
|
536
|
+
|
|
537
|
+
with self._state_lock:
|
|
538
|
+
proc = self._proc
|
|
539
|
+
container = self._container
|
|
540
|
+
stdin = self._stdin
|
|
541
|
+
self._stopping = True
|
|
542
|
+
self._loaded = None
|
|
543
|
+
self._budget = None
|
|
544
|
+
|
|
545
|
+
if proc is not None and proc.poll() is None and stdin is not None:
|
|
546
|
+
try:
|
|
547
|
+
protocol.write_message(stdin, protocol.Shutdown(seq=self._next_seq()))
|
|
548
|
+
except (OSError, ValueError):
|
|
549
|
+
pass
|
|
550
|
+
self._close_stream(stdin)
|
|
551
|
+
|
|
552
|
+
if proc is not None:
|
|
553
|
+
# Reserve time for the terminate-then-close steps below, so a
|
|
554
|
+
# worker that ignores Shutdown cannot eat the whole budget here.
|
|
555
|
+
self._wait(proc, min(WORKER_TERMINATE_GRACE_S, remaining(reserve=1.5)))
|
|
556
|
+
if proc.poll() is None:
|
|
557
|
+
log.info("worker did not exit on request; terminating")
|
|
558
|
+
try:
|
|
559
|
+
proc.terminate()
|
|
560
|
+
except OSError: # pragma: no cover - already gone
|
|
561
|
+
pass
|
|
562
|
+
self._wait(proc, remaining(reserve=1.0))
|
|
563
|
+
|
|
564
|
+
if container is not None:
|
|
565
|
+
# The last resort, and the one that cannot be refused: closing a
|
|
566
|
+
# Job Object created with KILL_ON_JOB_CLOSE kills every process
|
|
567
|
+
# still inside it.
|
|
568
|
+
container.close()
|
|
569
|
+
if proc is not None and proc.poll() is None:
|
|
570
|
+
self._wait(proc, remaining())
|
|
571
|
+
|
|
572
|
+
# The reader is joined before its stream is closed: the child is gone
|
|
573
|
+
# by now, so it is sitting on an EOF, and closing a pipe underneath a
|
|
574
|
+
# blocked read is how a teardown turns into a hang.
|
|
575
|
+
reader = self._reader
|
|
576
|
+
if reader is not None and reader is not threading.current_thread():
|
|
577
|
+
reader.join(timeout=remaining(reserve=0.25))
|
|
578
|
+
stderr_reader = self._stderr_reader
|
|
579
|
+
if stderr_reader is not None and stderr_reader is not threading.current_thread():
|
|
580
|
+
stderr_reader.join(timeout=min(0.5, remaining()))
|
|
581
|
+
self._teardown_streams()
|
|
582
|
+
|
|
583
|
+
with self._state_lock:
|
|
584
|
+
self._proc = None
|
|
585
|
+
self._container = None
|
|
586
|
+
self._reader = None
|
|
587
|
+
self._stderr_reader = None
|
|
588
|
+
self._worker_pid = None
|
|
589
|
+
self._state = WorkerState.STOPPED
|
|
590
|
+
self._lost_error = None
|
|
591
|
+
self._stopping = False
|
|
592
|
+
self._ready.clear()
|
|
593
|
+
|
|
594
|
+
self._fail_pending(
|
|
595
|
+
EchoActError(
|
|
596
|
+
Code.SHUTTING_DOWN,
|
|
597
|
+
"The synthesis worker was stopped before this request finished.",
|
|
598
|
+
)
|
|
599
|
+
)
|
|
600
|
+
elapsed = monotonic() - started
|
|
601
|
+
log.info("worker released in %.2fs", elapsed)
|
|
602
|
+
return elapsed
|
|
603
|
+
|
|
604
|
+
def __enter__(self) -> WorkerSupervisor:
|
|
605
|
+
return self
|
|
606
|
+
|
|
607
|
+
def __exit__(self, *exc: object) -> None:
|
|
608
|
+
self.kill()
|
|
609
|
+
|
|
610
|
+
# ------------------------------------------------------------------
|
|
611
|
+
# Internals
|
|
612
|
+
# ------------------------------------------------------------------
|
|
613
|
+
|
|
614
|
+
def _require_usable(self) -> None:
|
|
615
|
+
with self._state_lock:
|
|
616
|
+
if self._state is WorkerState.LOST:
|
|
617
|
+
raise self._lost_error or EchoActError(Code.WORKER_LOST)
|
|
618
|
+
|
|
619
|
+
def _spawn(self, budget: Budget) -> None:
|
|
620
|
+
container = self._container_factory(budget)
|
|
621
|
+
self._ready.clear()
|
|
622
|
+
self._stderr_tail.clear()
|
|
623
|
+
try:
|
|
624
|
+
proc = subprocess.Popen(
|
|
625
|
+
list(self._command),
|
|
626
|
+
stdin=subprocess.PIPE,
|
|
627
|
+
stdout=subprocess.PIPE,
|
|
628
|
+
stderr=subprocess.PIPE,
|
|
629
|
+
cwd=self._cwd,
|
|
630
|
+
env=self._child_env(),
|
|
631
|
+
close_fds=True,
|
|
632
|
+
**container.popen_kwargs(),
|
|
633
|
+
)
|
|
634
|
+
except OSError as exc:
|
|
635
|
+
container.close()
|
|
636
|
+
raise EchoActError(
|
|
637
|
+
Code.INTERNAL, "The synthesis worker could not be started.", cause=exc
|
|
638
|
+
) from exc
|
|
639
|
+
|
|
640
|
+
try:
|
|
641
|
+
container.adopt(proc.pid)
|
|
642
|
+
container.start_child(proc.pid)
|
|
643
|
+
except Exception as exc:
|
|
644
|
+
# N-03 is a guarantee, not a preference: a worker that could not
|
|
645
|
+
# be placed under its limits does not get to run.
|
|
646
|
+
_force_kill(proc)
|
|
647
|
+
container.close()
|
|
648
|
+
raise EchoActError(
|
|
649
|
+
Code.INTERNAL,
|
|
650
|
+
"The resource container could not be applied to the synthesis worker.",
|
|
651
|
+
cause=exc,
|
|
652
|
+
) from exc
|
|
653
|
+
|
|
654
|
+
assert proc.stdin is not None and proc.stdout is not None and proc.stderr is not None
|
|
655
|
+
# newline="\n" on the way out: the protocol frames on a single \n and
|
|
656
|
+
# the platform's line ending has no business in it.
|
|
657
|
+
stdin = TextIOWrapper(proc.stdin, encoding="utf-8", newline="\n", write_through=True)
|
|
658
|
+
stdout = TextIOWrapper(proc.stdout, encoding="utf-8", errors="replace")
|
|
659
|
+
stderr = TextIOWrapper(proc.stderr, encoding="utf-8", errors="replace")
|
|
660
|
+
|
|
661
|
+
with self._state_lock:
|
|
662
|
+
self._proc = proc
|
|
663
|
+
self._container = container
|
|
664
|
+
self._budget = budget
|
|
665
|
+
self._stdin = stdin
|
|
666
|
+
self._stdout = stdout
|
|
667
|
+
self._worker_pid = proc.pid
|
|
668
|
+
self._state = WorkerState.RUNNING
|
|
669
|
+
self._lost_error = None
|
|
670
|
+
self._stopping = False
|
|
671
|
+
self._loaded = None
|
|
672
|
+
|
|
673
|
+
self._reader = threading.Thread(
|
|
674
|
+
target=self._read_loop, args=(proc, stdout), name="echoact-worker-reader", daemon=True
|
|
675
|
+
)
|
|
676
|
+
self._reader.start()
|
|
677
|
+
self._stderr_reader = threading.Thread(
|
|
678
|
+
target=self._drain_stderr, args=(stderr,), name="echoact-worker-stderr", daemon=True
|
|
679
|
+
)
|
|
680
|
+
self._stderr_reader.start()
|
|
681
|
+
|
|
682
|
+
if not self._ready.wait(self._start_timeout_s):
|
|
683
|
+
self.kill()
|
|
684
|
+
raise EchoActError(
|
|
685
|
+
Code.WORKER_LOST,
|
|
686
|
+
"The synthesis worker did not start within its deadline.",
|
|
687
|
+
)
|
|
688
|
+
with self._state_lock:
|
|
689
|
+
if self._state is WorkerState.LOST:
|
|
690
|
+
err = self._lost_error or EchoActError(Code.WORKER_LOST)
|
|
691
|
+
else:
|
|
692
|
+
err = None
|
|
693
|
+
if err is not None:
|
|
694
|
+
self.kill()
|
|
695
|
+
raise err
|
|
696
|
+
log.info("worker started pid=%s limits=%s", proc.pid, container.limits.facility)
|
|
697
|
+
|
|
698
|
+
def _child_env(self) -> dict[str, str]:
|
|
699
|
+
env = dict(os.environ)
|
|
700
|
+
# The protocol is UTF-8 JSON and the text is Korean; the console
|
|
701
|
+
# encoding this process happens to have must not reach the pipe.
|
|
702
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
703
|
+
env["PYTHONUTF8"] = "1"
|
|
704
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
705
|
+
env.update(self._env_overrides)
|
|
706
|
+
return env
|
|
707
|
+
|
|
708
|
+
def _next_seq(self) -> int:
|
|
709
|
+
"""Under the state lock: a repeated seq would let one reply match two
|
|
710
|
+
requests, which is the one thing the drop rule cannot survive."""
|
|
711
|
+
with self._state_lock:
|
|
712
|
+
return self._counter.next()
|
|
713
|
+
|
|
714
|
+
def _send_unload(self) -> None:
|
|
715
|
+
stdin = self._stdin
|
|
716
|
+
if stdin is None:
|
|
717
|
+
return
|
|
718
|
+
try:
|
|
719
|
+
protocol.write_message(stdin, protocol.Unload(seq=self._next_seq()))
|
|
720
|
+
except (OSError, ValueError):
|
|
721
|
+
pass
|
|
722
|
+
|
|
723
|
+
def _call(self, msg: Any, *, timeout: float, kill_on_timeout: bool) -> Any:
|
|
724
|
+
waiter = _Waiter()
|
|
725
|
+
with self._state_lock:
|
|
726
|
+
if self._state is WorkerState.LOST:
|
|
727
|
+
raise self._lost_error or EchoActError(Code.WORKER_LOST)
|
|
728
|
+
if self._state is not WorkerState.RUNNING or self._stdin is None:
|
|
729
|
+
raise EchoActError(
|
|
730
|
+
Code.WORKER_LOST, "The synthesis worker is not running."
|
|
731
|
+
)
|
|
732
|
+
seq = self._counter.next()
|
|
733
|
+
msg.seq = seq
|
|
734
|
+
self._pending[seq] = waiter
|
|
735
|
+
stdin = self._stdin
|
|
736
|
+
|
|
737
|
+
try:
|
|
738
|
+
protocol.write_message(stdin, msg)
|
|
739
|
+
except (OSError, ValueError) as exc:
|
|
740
|
+
self._abandon(seq)
|
|
741
|
+
raise EchoActError(
|
|
742
|
+
Code.WORKER_LOST, "The synthesis worker's input closed.", cause=exc
|
|
743
|
+
) from exc
|
|
744
|
+
|
|
745
|
+
if not waiter.event.wait(timeout):
|
|
746
|
+
# The request is abandoned here, which is what makes a late reply
|
|
747
|
+
# unmatchable: the seq is gone from the table before the worker
|
|
748
|
+
# can answer it.
|
|
749
|
+
self._abandon(seq)
|
|
750
|
+
if kill_on_timeout:
|
|
751
|
+
self.kill()
|
|
752
|
+
raise EchoActError(
|
|
753
|
+
Code.WORKER_LOST,
|
|
754
|
+
f"The synthesis worker did not answer within {timeout:g} seconds.",
|
|
755
|
+
)
|
|
756
|
+
if waiter.error is not None:
|
|
757
|
+
raise waiter.error
|
|
758
|
+
return waiter.reply
|
|
759
|
+
|
|
760
|
+
def _abandon(self, seq: int) -> None:
|
|
761
|
+
with self._state_lock:
|
|
762
|
+
self._pending.pop(seq, None)
|
|
763
|
+
|
|
764
|
+
def _unexpected(self, reply: Any) -> EchoActError:
|
|
765
|
+
return EchoActError(
|
|
766
|
+
Code.INTERNAL,
|
|
767
|
+
"The synthesis worker answered with an unexpected message.",
|
|
768
|
+
detail={"type": getattr(reply, "type", "?")},
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
# -- reader ------------------------------------------------------------
|
|
772
|
+
|
|
773
|
+
def _read_loop(self, proc: subprocess.Popen[bytes], stdout: TextIOWrapper) -> None:
|
|
774
|
+
try:
|
|
775
|
+
for raw in stdout:
|
|
776
|
+
line = raw.strip()
|
|
777
|
+
if not line:
|
|
778
|
+
continue
|
|
779
|
+
try:
|
|
780
|
+
msg = protocol.decode(line)
|
|
781
|
+
except Exception:
|
|
782
|
+
# A line we cannot parse is a protocol error, never
|
|
783
|
+
# something to guess at. It is counted and skipped; the
|
|
784
|
+
# request it belonged to will time out on its own.
|
|
785
|
+
log.warning("unparseable line from the worker (%d chars)", len(line))
|
|
786
|
+
continue
|
|
787
|
+
try:
|
|
788
|
+
self._dispatch(msg)
|
|
789
|
+
except Exception: # pragma: no cover - dispatch must not die
|
|
790
|
+
log.exception("worker message dispatch failed")
|
|
791
|
+
except (OSError, ValueError):
|
|
792
|
+
pass
|
|
793
|
+
finally:
|
|
794
|
+
self._on_eof(proc)
|
|
795
|
+
|
|
796
|
+
def _dispatch(self, msg: Any) -> None:
|
|
797
|
+
if isinstance(msg, protocol.Ready):
|
|
798
|
+
self._worker_pid = msg.pid
|
|
799
|
+
self._ready.set()
|
|
800
|
+
return
|
|
801
|
+
if isinstance(msg, protocol.Stats):
|
|
802
|
+
self._last_report = (msg.rss_bytes, msg.cpu_percent, monotonic())
|
|
803
|
+
return
|
|
804
|
+
if isinstance(msg, protocol.Pong):
|
|
805
|
+
prev = self._last_report
|
|
806
|
+
cpu = prev[1] if prev else 0.0
|
|
807
|
+
self._last_report = (msg.rss_bytes, cpu, monotonic())
|
|
808
|
+
if isinstance(msg, protocol.Error) and msg.fatal:
|
|
809
|
+
# A fatal error is about the worker, not only about the request
|
|
810
|
+
# that provoked it, so everyone waiting hears about it.
|
|
811
|
+
error = _error_from(msg)
|
|
812
|
+
with self._state_lock:
|
|
813
|
+
self._state = WorkerState.LOST
|
|
814
|
+
self._lost_error = error
|
|
815
|
+
self._fail_pending(error)
|
|
816
|
+
return
|
|
817
|
+
|
|
818
|
+
with self._state_lock:
|
|
819
|
+
waiter = self._pending.pop(msg.seq, None)
|
|
820
|
+
if waiter is None:
|
|
821
|
+
# Stale: its request was cancelled or timed out. Dropping it is
|
|
822
|
+
# the protocol's rule -- matching it to the current request would
|
|
823
|
+
# attach audio to text nobody asked about any more.
|
|
824
|
+
self._dropped_replies += 1
|
|
825
|
+
log.debug("dropped a stale %s reply seq=%d", msg.type, msg.seq)
|
|
826
|
+
return
|
|
827
|
+
if isinstance(msg, protocol.Error):
|
|
828
|
+
waiter.fail(_error_from(msg))
|
|
829
|
+
else:
|
|
830
|
+
waiter.deliver(msg)
|
|
831
|
+
|
|
832
|
+
def _drain_stderr(self, stderr: TextIOWrapper) -> None:
|
|
833
|
+
"""Read the worker's diagnostics so a full pipe can never block it.
|
|
834
|
+
|
|
835
|
+
The lines are kept for a failure report but not logged at any normal
|
|
836
|
+
level: N-20 puts the burden on both sides, and the parent cannot
|
|
837
|
+
verify what the child wrote.
|
|
838
|
+
"""
|
|
839
|
+
try:
|
|
840
|
+
for raw in stderr:
|
|
841
|
+
line = raw.rstrip()
|
|
842
|
+
if line:
|
|
843
|
+
self._stderr_tail.append(line)
|
|
844
|
+
except (OSError, ValueError):
|
|
845
|
+
pass
|
|
846
|
+
|
|
847
|
+
def _on_eof(self, proc: subprocess.Popen[bytes]) -> None:
|
|
848
|
+
"""The worker's stdout closed, which means the worker is gone.
|
|
849
|
+
|
|
850
|
+
A death the container caused by halting an over-budget worker is
|
|
851
|
+
reported as OUT_OF_MEMORY rather than as a mystery: F-23 requires
|
|
852
|
+
the reason, and on a platform where the ceiling is polled the
|
|
853
|
+
container is the only party that knows it. Where the kernel
|
|
854
|
+
enforces the ceiling the worker survives its failed allocation and
|
|
855
|
+
sends the error itself, so this path does not apply.
|
|
856
|
+
"""
|
|
857
|
+
exit_code = proc.poll()
|
|
858
|
+
container = self._container
|
|
859
|
+
if container is not None and container.memory_exceeded:
|
|
860
|
+
error = EchoActError(
|
|
861
|
+
Code.OUT_OF_MEMORY,
|
|
862
|
+
detail={"exit_code": exit_code, "memory_bytes": container.budget.memory_bytes},
|
|
863
|
+
)
|
|
864
|
+
else:
|
|
865
|
+
error = EchoActError(
|
|
866
|
+
Code.WORKER_LOST,
|
|
867
|
+
"The synthesis worker stopped unexpectedly.",
|
|
868
|
+
detail={"exit_code": exit_code},
|
|
869
|
+
)
|
|
870
|
+
with self._state_lock:
|
|
871
|
+
if self._proc is not proc:
|
|
872
|
+
return
|
|
873
|
+
# ``_stopping`` distinguishes a death we asked for from one we
|
|
874
|
+
# did not. Only the second makes the supervisor unusable.
|
|
875
|
+
if self._stopping:
|
|
876
|
+
self._state = WorkerState.STOPPED
|
|
877
|
+
self._ready.set()
|
|
878
|
+
return
|
|
879
|
+
self._state = WorkerState.LOST
|
|
880
|
+
self._lost_error = error
|
|
881
|
+
self._loaded = None
|
|
882
|
+
self._ready.set()
|
|
883
|
+
log.error("worker lost exit_code=%s", exit_code)
|
|
884
|
+
self._fail_pending(error)
|
|
885
|
+
|
|
886
|
+
def _fail_pending(self, error: EchoActError) -> None:
|
|
887
|
+
with self._state_lock:
|
|
888
|
+
waiters = list(self._pending.values())
|
|
889
|
+
self._pending.clear()
|
|
890
|
+
for w in waiters:
|
|
891
|
+
w.fail(error)
|
|
892
|
+
|
|
893
|
+
# -- stream and process helpers ---------------------------------------
|
|
894
|
+
|
|
895
|
+
@staticmethod
|
|
896
|
+
def _wait(proc: subprocess.Popen[bytes], timeout: float) -> None:
|
|
897
|
+
if timeout <= 0:
|
|
898
|
+
return
|
|
899
|
+
try:
|
|
900
|
+
proc.wait(timeout=timeout)
|
|
901
|
+
except subprocess.TimeoutExpired:
|
|
902
|
+
pass
|
|
903
|
+
|
|
904
|
+
@staticmethod
|
|
905
|
+
def _close_stream(stream: Any) -> None:
|
|
906
|
+
if stream is None:
|
|
907
|
+
return
|
|
908
|
+
try:
|
|
909
|
+
stream.close()
|
|
910
|
+
except (OSError, ValueError):
|
|
911
|
+
pass
|
|
912
|
+
|
|
913
|
+
def _teardown_streams(self) -> None:
|
|
914
|
+
with self._state_lock:
|
|
915
|
+
stdin, stdout = self._stdin, self._stdout
|
|
916
|
+
self._stdin = self._stdout = None
|
|
917
|
+
self._close_stream(stdin)
|
|
918
|
+
self._close_stream(stdout)
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _force_kill(proc: subprocess.Popen[bytes]) -> None:
|
|
922
|
+
try:
|
|
923
|
+
proc.kill()
|
|
924
|
+
except OSError: # pragma: no cover - already gone
|
|
925
|
+
pass
|
|
926
|
+
try:
|
|
927
|
+
proc.wait(timeout=1.0)
|
|
928
|
+
except subprocess.TimeoutExpired: # pragma: no cover
|
|
929
|
+
pass
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _error_from(msg: protocol.Error) -> EchoActError:
|
|
933
|
+
"""Translate the worker's small vocabulary of codes.
|
|
934
|
+
|
|
935
|
+
An unrecognised code becomes GENERATION_FAILED rather than INTERNAL: the
|
|
936
|
+
worker only ever fails a synthesis request, and F-57 asks that the user
|
|
937
|
+
be told something true about what failed.
|
|
938
|
+
"""
|
|
939
|
+
try:
|
|
940
|
+
code = Code(msg.code)
|
|
941
|
+
except ValueError:
|
|
942
|
+
code = Code.GENERATION_FAILED
|
|
943
|
+
detail: dict[str, Any] = {}
|
|
944
|
+
if msg.job_id:
|
|
945
|
+
detail["job_id"] = msg.job_id
|
|
946
|
+
if msg.segment_index is not None:
|
|
947
|
+
detail["segment_index"] = msg.segment_index
|
|
948
|
+
return EchoActError(code, msg.message[:_MAX_WORKER_MESSAGE] or None, detail=detail)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
__all__ = [
|
|
952
|
+
"DEFAULT_ALLOWED_PROVIDERS",
|
|
953
|
+
"LOAD_TIMEOUT_S",
|
|
954
|
+
"PING_TIMEOUT_S",
|
|
955
|
+
"START_TIMEOUT_S",
|
|
956
|
+
"SYNTHESIZE_TIMEOUT_S",
|
|
957
|
+
"LoadedModel",
|
|
958
|
+
"WorkerState",
|
|
959
|
+
"WorkerSupervisor",
|
|
960
|
+
"WorkerUsage",
|
|
961
|
+
]
|