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.
Files changed (80) hide show
  1. echoact/__init__.py +3 -0
  2. echoact/__main__.py +117 -0
  3. echoact/app.py +315 -0
  4. echoact/audio/__init__.py +0 -0
  5. echoact/audio/devices.py +192 -0
  6. echoact/audio/player.py +611 -0
  7. echoact/audio/wav.py +854 -0
  8. echoact/config/__init__.py +0 -0
  9. echoact/config/budget.py +370 -0
  10. echoact/config/settings.py +1244 -0
  11. echoact/db/__init__.py +0 -0
  12. echoact/db/backup.py +2429 -0
  13. echoact/db/migrations.py +434 -0
  14. echoact/db/schema.sql +214 -0
  15. echoact/db/store.py +2062 -0
  16. echoact/diagnostics.py +902 -0
  17. echoact/domain.py +487 -0
  18. echoact/engine/__init__.py +0 -0
  19. echoact/engine/container.py +843 -0
  20. echoact/engine/protocol.py +241 -0
  21. echoact/engine/runtime.py +324 -0
  22. echoact/engine/supervisor.py +961 -0
  23. echoact/engine/worker.py +659 -0
  24. echoact/errors.py +281 -0
  25. echoact/instance.py +172 -0
  26. echoact/jobs/__init__.py +0 -0
  27. echoact/jobs/engine.py +776 -0
  28. echoact/jobs/request.py +300 -0
  29. echoact/mcp/__init__.py +0 -0
  30. echoact/mcp/__main__.py +50 -0
  31. echoact/mcp/client.py +202 -0
  32. echoact/mcp/config.py +112 -0
  33. echoact/mcp/server.py +340 -0
  34. echoact/models/__init__.py +0 -0
  35. echoact/models/catalog.py +273 -0
  36. echoact/models/manifest.py +278 -0
  37. echoact/models/registry.py +1551 -0
  38. echoact/paths.py +93 -0
  39. echoact/policy.py +189 -0
  40. echoact/security/__init__.py +0 -0
  41. echoact/security/credentials.py +930 -0
  42. echoact/security/ratelimit.py +534 -0
  43. echoact/service/__init__.py +20 -0
  44. echoact/service/app.py +182 -0
  45. echoact/service/deps.py +563 -0
  46. echoact/service/errors.py +241 -0
  47. echoact/service/routes.py +1125 -0
  48. echoact/service/schemas.py +509 -0
  49. echoact/service/server.py +270 -0
  50. echoact/text/__init__.py +0 -0
  51. echoact/text/language.py +44 -0
  52. echoact/text/loader.py +577 -0
  53. echoact/text/normalize.py +924 -0
  54. echoact/text/segment.py +499 -0
  55. echoact/text/sniff.py +1202 -0
  56. echoact/ui/__init__.py +0 -0
  57. echoact/ui/bridge.py +50 -0
  58. echoact/ui/controls.py +360 -0
  59. echoact/ui/credential_dialog.py +131 -0
  60. echoact/ui/fonts.py +94 -0
  61. echoact/ui/i18n.py +260 -0
  62. echoact/ui/icons.py +440 -0
  63. echoact/ui/library.py +1642 -0
  64. echoact/ui/licence.py +162 -0
  65. echoact/ui/main_window.py +1202 -0
  66. echoact/ui/mcp_setup.py +494 -0
  67. echoact/ui/models_view.py +1142 -0
  68. echoact/ui/notifications.py +202 -0
  69. echoact/ui/reading.py +494 -0
  70. echoact/ui/settings_view.py +2258 -0
  71. echoact/ui/status_view.py +1193 -0
  72. echoact/ui/theme.py +579 -0
  73. echoact/util/__init__.py +0 -0
  74. echoact/util/ids.py +62 -0
  75. echoact/util/logging.py +127 -0
  76. echoact-0.1.0.dist-info/METADATA +162 -0
  77. echoact-0.1.0.dist-info/RECORD +80 -0
  78. echoact-0.1.0.dist-info/WHEEL +4 -0
  79. echoact-0.1.0.dist-info/entry_points.txt +3 -0
  80. 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
+ ]