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
echoact/errors.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Error catalogue shared by every access path.
|
|
2
|
+
|
|
3
|
+
N-24 requires the GUI, REST, and MCP to share one set of error semantics, and
|
|
4
|
+
F-57 requires each error to carry a stable code, a user-facing message, whether
|
|
5
|
+
a retry can succeed, and a request identifier. This module is the single place
|
|
6
|
+
those facts are recorded; no surface invents a code of its own.
|
|
7
|
+
|
|
8
|
+
Messages here are the English text. The GUI localises through
|
|
9
|
+
``echoact.ui.i18n`` keyed on ``code``; REST and MCP always answer in English so
|
|
10
|
+
that F-86's display-language setting never changes an API response.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Code(StrEnum):
|
|
21
|
+
"""Stable error codes. Values are part of the external contract."""
|
|
22
|
+
|
|
23
|
+
# -- input validation (F-03, F-32 to F-37) ---------------------------
|
|
24
|
+
INPUT_EMPTY = "INPUT_EMPTY"
|
|
25
|
+
INPUT_TOO_LONG = "INPUT_TOO_LONG"
|
|
26
|
+
FILE_TOO_LARGE = "FILE_TOO_LARGE"
|
|
27
|
+
FILE_UNSUPPORTED = "FILE_UNSUPPORTED"
|
|
28
|
+
FILE_NOT_TEXT = "FILE_NOT_TEXT"
|
|
29
|
+
FILE_ENCODING = "FILE_ENCODING"
|
|
30
|
+
FILE_CORRUPT = "FILE_CORRUPT"
|
|
31
|
+
FILE_ENCRYPTED = "FILE_ENCRYPTED"
|
|
32
|
+
FILE_PERMISSION = "FILE_PERMISSION"
|
|
33
|
+
FILE_NOT_FOUND = "FILE_NOT_FOUND"
|
|
34
|
+
|
|
35
|
+
# -- request shape (F-54, F-49, F-57) ---------------------------------
|
|
36
|
+
#: The body could not be read at all -- malformed JSON, a multipart
|
|
37
|
+
#: body whose parts do not parse. Distinct from a body that parsed
|
|
38
|
+
#: and then failed validation, because a client can act on the
|
|
39
|
+
#: difference: one is a bug in how it encoded the request, the other
|
|
40
|
+
#: is a wrong value in it.
|
|
41
|
+
MALFORMED_REQUEST = "MALFORMED_REQUEST"
|
|
42
|
+
#: A field this version does not define. F-54 forbids substituting an
|
|
43
|
+
#: unknown option, and silently dropping one is a substitution the
|
|
44
|
+
#: caller cannot see.
|
|
45
|
+
UNKNOWN_OPTION = "UNKNOWN_OPTION"
|
|
46
|
+
#: The same fault inside the voice settings, which Section 2.10 gives a
|
|
47
|
+
#: different status. A separate code rather than an override on the
|
|
48
|
+
#: one above: the status is part of what a code means here, and a
|
|
49
|
+
#: per-call escape hatch would make that stop being true.
|
|
50
|
+
VOICE_SETTINGS_INVALID = "VOICE_SETTINGS_INVALID"
|
|
51
|
+
JOB_KIND_MISSING = "JOB_KIND_MISSING"
|
|
52
|
+
JOB_KIND_UNKNOWN = "JOB_KIND_UNKNOWN"
|
|
53
|
+
IDEMPOTENCY_KEY_MISSING = "IDEMPOTENCY_KEY_MISSING"
|
|
54
|
+
IDEMPOTENCY_KEY_CONFLICT = "IDEMPOTENCY_KEY_CONFLICT"
|
|
55
|
+
INPUT_AMBIGUOUS = "INPUT_AMBIGUOUS"
|
|
56
|
+
|
|
57
|
+
# -- voice settings (F-04 to F-08) -----------------------------------
|
|
58
|
+
MODEL_UNKNOWN = "MODEL_UNKNOWN"
|
|
59
|
+
VOICE_UNKNOWN = "VOICE_UNKNOWN"
|
|
60
|
+
LANGUAGE_UNKNOWN = "LANGUAGE_UNKNOWN"
|
|
61
|
+
STYLE_UNKNOWN = "STYLE_UNKNOWN"
|
|
62
|
+
TEMPO_OUT_OF_RANGE = "TEMPO_OUT_OF_RANGE"
|
|
63
|
+
VOICE_GENDER_MISMATCH = "VOICE_GENDER_MISMATCH"
|
|
64
|
+
|
|
65
|
+
# -- model preparation (F-09, F-63 to F-65, F-84) --------------------
|
|
66
|
+
MODEL_NOT_READY = "MODEL_NOT_READY"
|
|
67
|
+
MODEL_CORRUPT = "MODEL_CORRUPT"
|
|
68
|
+
MODEL_DOWNLOAD_FORBIDDEN = "MODEL_DOWNLOAD_FORBIDDEN"
|
|
69
|
+
MODEL_DOWNLOAD_FAILED = "MODEL_DOWNLOAD_FAILED"
|
|
70
|
+
MODEL_LICENSE_NOT_ACCEPTED = "MODEL_LICENSE_NOT_ACCEPTED"
|
|
71
|
+
MODEL_OVER_BUDGET = "MODEL_OVER_BUDGET"
|
|
72
|
+
|
|
73
|
+
# -- execution (F-23, F-47, N-23) ------------------------------------
|
|
74
|
+
BUSY = "BUSY"
|
|
75
|
+
RATE_LIMITED = "RATE_LIMITED"
|
|
76
|
+
INSUFFICIENT_RESOURCES = "INSUFFICIENT_RESOURCES"
|
|
77
|
+
OUT_OF_MEMORY = "OUT_OF_MEMORY"
|
|
78
|
+
GENERATION_FAILED = "GENERATION_FAILED"
|
|
79
|
+
WORKER_LOST = "WORKER_LOST"
|
|
80
|
+
RUNTIME_PROVIDER_REFUSED = "RUNTIME_PROVIDER_REFUSED"
|
|
81
|
+
|
|
82
|
+
# -- results (F-55, F-42, 4.2) ---------------------------------------
|
|
83
|
+
RESULT_NOT_READY = "RESULT_NOT_READY"
|
|
84
|
+
RESULT_EXPIRED = "RESULT_EXPIRED"
|
|
85
|
+
RESULT_MISSING = "RESULT_MISSING"
|
|
86
|
+
SEGMENT_NOT_READY = "SEGMENT_NOT_READY"
|
|
87
|
+
|
|
88
|
+
# -- access control (N-17, N-19, F-50, F-61) -------------------------
|
|
89
|
+
UNAUTHENTICATED = "UNAUTHENTICATED"
|
|
90
|
+
FORBIDDEN = "FORBIDDEN"
|
|
91
|
+
NOT_FOUND = "NOT_FOUND"
|
|
92
|
+
HOST_NOT_ALLOWED = "HOST_NOT_ALLOWED"
|
|
93
|
+
ORIGIN_NOT_ALLOWED = "ORIGIN_NOT_ALLOWED"
|
|
94
|
+
CREDENTIAL_EXPIRED = "CREDENTIAL_EXPIRED"
|
|
95
|
+
CREDENTIAL_REVOKED = "CREDENTIAL_REVOKED"
|
|
96
|
+
AUTH_LOCKED_OUT = "AUTH_LOCKED_OUT"
|
|
97
|
+
PAYLOAD_TOO_LARGE = "PAYLOAD_TOO_LARGE"
|
|
98
|
+
|
|
99
|
+
# -- storage (N-14 to N-16, F-43, F-44) ------------------------------
|
|
100
|
+
STORAGE_FULL = "STORAGE_FULL"
|
|
101
|
+
RETENTION_LIMIT_REACHED = "RETENTION_LIMIT_REACHED"
|
|
102
|
+
DB_LOCKED = "DB_LOCKED"
|
|
103
|
+
DB_UNAVAILABLE = "DB_UNAVAILABLE"
|
|
104
|
+
BACKUP_INVALID = "BACKUP_INVALID"
|
|
105
|
+
BACKUP_INCOMPATIBLE = "BACKUP_INCOMPATIBLE"
|
|
106
|
+
BACKUP_TOO_LARGE = "BACKUP_TOO_LARGE"
|
|
107
|
+
DELETE_BLOCKED_IN_USE = "DELETE_BLOCKED_IN_USE"
|
|
108
|
+
|
|
109
|
+
# -- service lifecycle (F-52, F-79, N-31) ----------------------------
|
|
110
|
+
SERVICE_OFF = "SERVICE_OFF"
|
|
111
|
+
SERVICE_PORT_UNAVAILABLE = "SERVICE_PORT_UNAVAILABLE"
|
|
112
|
+
APP_NOT_RUNNING = "APP_NOT_RUNNING"
|
|
113
|
+
MCP_DISABLED = "MCP_DISABLED"
|
|
114
|
+
SHUTTING_DOWN = "SHUTTING_DOWN"
|
|
115
|
+
|
|
116
|
+
# -- audio (F-67, F-68) ----------------------------------------------
|
|
117
|
+
OUTPUT_DEVICE_LOST = "OUTPUT_DEVICE_LOST"
|
|
118
|
+
OUTPUT_DEVICE_UNAVAILABLE = "OUTPUT_DEVICE_UNAVAILABLE"
|
|
119
|
+
|
|
120
|
+
INTERNAL = "INTERNAL"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# code -> (HTTP status, retryable in principle, English message)
|
|
124
|
+
#
|
|
125
|
+
# "Retryable" is F-57's flag: the identical request may succeed later without
|
|
126
|
+
# the caller changing anything. Every retryable rejection carries a
|
|
127
|
+
# retry-after hint at the surface.
|
|
128
|
+
_CATALOGUE: dict[Code, tuple[int, bool, str]] = {
|
|
129
|
+
Code.INPUT_EMPTY: (422, False, "The text is empty or contains only whitespace."),
|
|
130
|
+
Code.INPUT_TOO_LONG: (413, False, "The text exceeds the 50,000-character limit."),
|
|
131
|
+
Code.FILE_TOO_LARGE: (413, False, "The file exceeds the 2,000,000-byte limit."),
|
|
132
|
+
Code.FILE_UNSUPPORTED: (415, False, "This file format cannot be read as text."),
|
|
133
|
+
Code.FILE_NOT_TEXT: (415, False, "The file does not appear to contain text."),
|
|
134
|
+
Code.FILE_ENCODING: (415, False, "The file's text encoding could not be determined."),
|
|
135
|
+
Code.FILE_CORRUPT: (415, False, "The file appears to be damaged."),
|
|
136
|
+
Code.FILE_ENCRYPTED: (415, False, "The file is encrypted."),
|
|
137
|
+
Code.FILE_PERMISSION: (403, False, "The file cannot be read with the current permissions."),
|
|
138
|
+
Code.FILE_NOT_FOUND: (404, False, "The file no longer exists."),
|
|
139
|
+
Code.MALFORMED_REQUEST: (400, False, "The request body could not be read."),
|
|
140
|
+
Code.UNKNOWN_OPTION: (400, False, "The request names a field this version does not define."),
|
|
141
|
+
Code.VOICE_SETTINGS_INVALID: (422, False, "The voice settings are not valid."),
|
|
142
|
+
Code.JOB_KIND_MISSING: (400, False, "The request must state a job kind."),
|
|
143
|
+
Code.JOB_KIND_UNKNOWN: (400, False, "That job kind is not supported by this version."),
|
|
144
|
+
Code.IDEMPOTENCY_KEY_MISSING: (400, False, "A duplicate-prevention key is required."),
|
|
145
|
+
Code.IDEMPOTENCY_KEY_CONFLICT: (
|
|
146
|
+
409,
|
|
147
|
+
False,
|
|
148
|
+
"That key was already used with different content.",
|
|
149
|
+
),
|
|
150
|
+
Code.INPUT_AMBIGUOUS: (400, False, "Provide either inline text or an upload, not both."),
|
|
151
|
+
Code.MODEL_UNKNOWN: (422, False, "That model is not one this version supports."),
|
|
152
|
+
Code.VOICE_UNKNOWN: (422, False, "That voice is not available for the selected model."),
|
|
153
|
+
Code.LANGUAGE_UNKNOWN: (422, False, "That language is not supported."),
|
|
154
|
+
Code.STYLE_UNKNOWN: (422, False, "That speaking style is not supported."),
|
|
155
|
+
Code.TEMPO_OUT_OF_RANGE: (422, False, "Tempo must be between 0.70x and 1.50x."),
|
|
156
|
+
Code.VOICE_GENDER_MISMATCH: (422, False, "That voice does not belong to the selected gender."),
|
|
157
|
+
Code.MODEL_NOT_READY: (409, False, "The model has not been prepared on this machine."),
|
|
158
|
+
Code.MODEL_CORRUPT: (409, False, "The model's files do not match the manifest."),
|
|
159
|
+
Code.MODEL_DOWNLOAD_FORBIDDEN: (
|
|
160
|
+
403,
|
|
161
|
+
False,
|
|
162
|
+
"The owner has not authorised downloading this model.",
|
|
163
|
+
),
|
|
164
|
+
Code.MODEL_DOWNLOAD_FAILED: (503, True, "The model download did not complete."),
|
|
165
|
+
Code.MODEL_LICENSE_NOT_ACCEPTED: (
|
|
166
|
+
403,
|
|
167
|
+
False,
|
|
168
|
+
"The model's licence terms have not been accepted.",
|
|
169
|
+
),
|
|
170
|
+
Code.MODEL_OVER_BUDGET: (422, False, "The model cannot run within the current resource budget."),
|
|
171
|
+
Code.BUSY: (409, True, "A generation job is already running."),
|
|
172
|
+
Code.RATE_LIMITED: (429, True, "Too many requests."),
|
|
173
|
+
Code.INSUFFICIENT_RESOURCES: (503, True, "There is not enough free memory to start."),
|
|
174
|
+
Code.OUT_OF_MEMORY: (503, True, "The job was halted because it exceeded its memory budget."),
|
|
175
|
+
Code.GENERATION_FAILED: (500, False, "Speech generation failed."),
|
|
176
|
+
Code.WORKER_LOST: (500, True, "The synthesis worker stopped unexpectedly."),
|
|
177
|
+
Code.RUNTIME_PROVIDER_REFUSED: (
|
|
178
|
+
500,
|
|
179
|
+
False,
|
|
180
|
+
"The inference runtime offered a non-local execution provider.",
|
|
181
|
+
),
|
|
182
|
+
Code.RESULT_NOT_READY: (409, True, "The result is not finished yet."),
|
|
183
|
+
Code.RESULT_EXPIRED: (410, False, "The result's lifetime has passed."),
|
|
184
|
+
Code.RESULT_MISSING: (410, False, "The result file is missing."),
|
|
185
|
+
Code.SEGMENT_NOT_READY: (409, True, "That segment has not been generated yet."),
|
|
186
|
+
Code.UNAUTHENTICATED: (401, False, "Authentication is required."),
|
|
187
|
+
Code.FORBIDDEN: (403, False, "This credential does not carry that permission."),
|
|
188
|
+
Code.NOT_FOUND: (404, False, "No such item."),
|
|
189
|
+
Code.HOST_NOT_ALLOWED: (400, False, "Unexpected Host header."),
|
|
190
|
+
Code.ORIGIN_NOT_ALLOWED: (400, False, "Unexpected Origin header."),
|
|
191
|
+
Code.CREDENTIAL_EXPIRED: (401, False, "The credential has expired."),
|
|
192
|
+
Code.CREDENTIAL_REVOKED: (401, False, "The credential was revoked."),
|
|
193
|
+
Code.AUTH_LOCKED_OUT: (429, True, "Too many authentication failures."),
|
|
194
|
+
Code.PAYLOAD_TOO_LARGE: (413, False, "The request body exceeds 2,000,000 bytes."),
|
|
195
|
+
Code.STORAGE_FULL: (507, False, "There is not enough free disk space."),
|
|
196
|
+
Code.RETENTION_LIMIT_REACHED: (507, False, "The retention space limit has been reached."),
|
|
197
|
+
Code.DB_LOCKED: (503, True, "The local database is busy."),
|
|
198
|
+
Code.DB_UNAVAILABLE: (503, True, "The local database cannot be opened."),
|
|
199
|
+
Code.BACKUP_INVALID: (422, False, "The backup is damaged."),
|
|
200
|
+
Code.BACKUP_INCOMPATIBLE: (422, False, "The backup was made by an incompatible version."),
|
|
201
|
+
Code.BACKUP_TOO_LARGE: (413, False, "The backup exceeds the restore limits."),
|
|
202
|
+
Code.DELETE_BLOCKED_IN_USE: (409, True, "That item is in use by a running job."),
|
|
203
|
+
Code.SERVICE_OFF: (503, False, "The local service is turned off."),
|
|
204
|
+
Code.SERVICE_PORT_UNAVAILABLE: (503, False, "The local service could not bind its port."),
|
|
205
|
+
Code.APP_NOT_RUNNING: (503, False, "EchoAct is not running."),
|
|
206
|
+
Code.MCP_DISABLED: (503, False, "MCP is not enabled in EchoAct."),
|
|
207
|
+
Code.SHUTTING_DOWN: (503, False, "EchoAct is shutting down."),
|
|
208
|
+
Code.OUTPUT_DEVICE_LOST: (503, True, "The audio output device disappeared."),
|
|
209
|
+
Code.OUTPUT_DEVICE_UNAVAILABLE: (503, True, "No audio output device is available."),
|
|
210
|
+
Code.INTERNAL: (500, False, "An internal error occurred."),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def http_status(code: Code) -> int:
|
|
215
|
+
return _CATALOGUE[code][0]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def is_retryable(code: Code) -> bool:
|
|
219
|
+
return _CATALOGUE[code][1]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def default_message(code: Code) -> str:
|
|
223
|
+
return _CATALOGUE[code][2]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class EchoActError(Exception):
|
|
227
|
+
"""The one exception type that crosses a module boundary.
|
|
228
|
+
|
|
229
|
+
Anything raised out of a package module is either this or a programming
|
|
230
|
+
error. Surfaces translate it; they never guess a status from its text.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
def __init__(
|
|
234
|
+
self,
|
|
235
|
+
code: Code,
|
|
236
|
+
message: str | None = None,
|
|
237
|
+
*,
|
|
238
|
+
detail: dict[str, Any] | None = None,
|
|
239
|
+
retry_after_s: float | None = None,
|
|
240
|
+
cause: BaseException | None = None,
|
|
241
|
+
) -> None:
|
|
242
|
+
self.code = code
|
|
243
|
+
self.message = message or default_message(code)
|
|
244
|
+
self.detail = detail or {}
|
|
245
|
+
# Only a retryable code may carry a hint; N-23 forbids inviting a
|
|
246
|
+
# retry that cannot succeed.
|
|
247
|
+
self.retry_after_s = retry_after_s if is_retryable(code) else None
|
|
248
|
+
super().__init__(f"{code.value}: {self.message}")
|
|
249
|
+
if cause is not None:
|
|
250
|
+
self.__cause__ = cause
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def http_status(self) -> int:
|
|
254
|
+
return http_status(self.code)
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def retryable(self) -> bool:
|
|
258
|
+
return is_retryable(self.code)
|
|
259
|
+
|
|
260
|
+
def to_payload(self, request_id: str) -> dict[str, Any]:
|
|
261
|
+
"""F-57's wire shape. Never carries a path, a credential, or body text."""
|
|
262
|
+
body: dict[str, Any] = {
|
|
263
|
+
"code": self.code.value,
|
|
264
|
+
"message": self.message,
|
|
265
|
+
"retryable": self.retryable,
|
|
266
|
+
"request_id": request_id,
|
|
267
|
+
}
|
|
268
|
+
if self.detail:
|
|
269
|
+
body["detail"] = self.detail
|
|
270
|
+
if self.retry_after_s is not None:
|
|
271
|
+
body["retry_after_s"] = round(self.retry_after_s, 3)
|
|
272
|
+
return body
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@dataclass(frozen=True, slots=True)
|
|
276
|
+
class Problem:
|
|
277
|
+
"""A non-fatal report shown to the user, e.g. a file that would not open."""
|
|
278
|
+
|
|
279
|
+
code: Code
|
|
280
|
+
message: str
|
|
281
|
+
remedies: tuple[str, ...] = field(default_factory=tuple)
|
echoact/instance.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""One instance per user account, per F-85.
|
|
2
|
+
|
|
3
|
+
A second launch must surface the window that already exists rather than
|
|
4
|
+
start a second engine, a second database session, or a second REST
|
|
5
|
+
listener -- and above all it must never create a second generation slot,
|
|
6
|
+
which F-47 makes an app-wide singleton.
|
|
7
|
+
|
|
8
|
+
The mechanism is a lock file holding the first instance's activation port
|
|
9
|
+
and a random token, plus a loopback listener that accepts exactly one
|
|
10
|
+
message. Two details are deliberate:
|
|
11
|
+
|
|
12
|
+
* The listener is not the REST service and shares nothing with it. It
|
|
13
|
+
binds an ephemeral port, speaks one word, and does nothing else, so a
|
|
14
|
+
bind failure here cannot affect F-79's separate story about the REST
|
|
15
|
+
port -- and the REST service being turned off does not stop a second
|
|
16
|
+
launch from finding the first window.
|
|
17
|
+
* A stale lock file is taken over rather than treated as a running app.
|
|
18
|
+
A forced termination leaves one behind, and refusing to start after a
|
|
19
|
+
crash would be worse than the problem the lock solves.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import secrets
|
|
27
|
+
import socket
|
|
28
|
+
import threading
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
|
|
32
|
+
from .paths import ensure_tree, lock_path
|
|
33
|
+
from .util.logging import get_logger
|
|
34
|
+
|
|
35
|
+
log = get_logger("instance")
|
|
36
|
+
|
|
37
|
+
_ACTIVATE = "echoact-activate"
|
|
38
|
+
_CONNECT_TIMEOUT_S = 1.5
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class LockInfo:
|
|
43
|
+
pid: int
|
|
44
|
+
port: int
|
|
45
|
+
token: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AlreadyRunning(Exception):
|
|
49
|
+
"""Raised by :func:`acquire` when another instance answered."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class InstanceLock:
|
|
53
|
+
"""Held for the life of the application process."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, on_activate: Callable[[], None] | None = None) -> None:
|
|
56
|
+
self._on_activate = on_activate
|
|
57
|
+
self._server: socket.socket | None = None
|
|
58
|
+
self._thread: threading.Thread | None = None
|
|
59
|
+
self._token = secrets.token_urlsafe(24)
|
|
60
|
+
self._stop = threading.Event()
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def port(self) -> int:
|
|
64
|
+
return self._server.getsockname()[1] if self._server else 0
|
|
65
|
+
|
|
66
|
+
def start(self) -> None:
|
|
67
|
+
self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
68
|
+
self._server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
|
|
69
|
+
self._server.bind(("127.0.0.1", 0))
|
|
70
|
+
self._server.listen(4)
|
|
71
|
+
self._server.settimeout(0.5)
|
|
72
|
+
self._thread = threading.Thread(target=self._serve, name="echoact-instance", daemon=True)
|
|
73
|
+
self._thread.start()
|
|
74
|
+
_write_lock(LockInfo(pid=os.getpid(), port=self.port, token=self._token))
|
|
75
|
+
|
|
76
|
+
def release(self) -> None:
|
|
77
|
+
self._stop.set()
|
|
78
|
+
if self._server is not None:
|
|
79
|
+
try:
|
|
80
|
+
self._server.close()
|
|
81
|
+
except OSError:
|
|
82
|
+
pass
|
|
83
|
+
self._server = None
|
|
84
|
+
if self._thread is not None:
|
|
85
|
+
self._thread.join(timeout=1.5)
|
|
86
|
+
self._thread = None
|
|
87
|
+
try:
|
|
88
|
+
path = lock_path()
|
|
89
|
+
info = _read_lock()
|
|
90
|
+
# Only remove our own lock: a lock written by a later instance
|
|
91
|
+
# after we were killed is not ours to delete.
|
|
92
|
+
if info is None or info.pid == os.getpid():
|
|
93
|
+
path.unlink(missing_ok=True)
|
|
94
|
+
except OSError:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
def _serve(self) -> None:
|
|
98
|
+
while not self._stop.is_set() and self._server is not None:
|
|
99
|
+
try:
|
|
100
|
+
conn, _ = self._server.accept()
|
|
101
|
+
except TimeoutError:
|
|
102
|
+
continue
|
|
103
|
+
except OSError:
|
|
104
|
+
return
|
|
105
|
+
with conn:
|
|
106
|
+
try:
|
|
107
|
+
conn.settimeout(1.0)
|
|
108
|
+
payload = conn.recv(256).decode("utf-8", "replace").strip()
|
|
109
|
+
except OSError:
|
|
110
|
+
continue
|
|
111
|
+
want = f"{_ACTIVATE} {self._token}"
|
|
112
|
+
if payload != want:
|
|
113
|
+
# Not our second launch. Say nothing: an unauthenticated
|
|
114
|
+
# caller learns neither that this is EchoAct nor why it
|
|
115
|
+
# was refused.
|
|
116
|
+
continue
|
|
117
|
+
try:
|
|
118
|
+
conn.sendall(b"ok\n")
|
|
119
|
+
except OSError:
|
|
120
|
+
pass
|
|
121
|
+
if self._on_activate:
|
|
122
|
+
try:
|
|
123
|
+
self._on_activate()
|
|
124
|
+
except Exception as exc: # noqa: BLE001
|
|
125
|
+
log.warning("activation handler failed: %s", type(exc).__name__)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _write_lock(info: LockInfo) -> None:
|
|
129
|
+
ensure_tree()
|
|
130
|
+
path = lock_path()
|
|
131
|
+
tmp = path.with_suffix(".tmp")
|
|
132
|
+
tmp.write_text(
|
|
133
|
+
json.dumps({"pid": info.pid, "port": info.port, "token": info.token}),
|
|
134
|
+
encoding="utf-8",
|
|
135
|
+
)
|
|
136
|
+
os.replace(tmp, path)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _read_lock() -> LockInfo | None:
|
|
140
|
+
try:
|
|
141
|
+
data = json.loads(lock_path().read_text(encoding="utf-8"))
|
|
142
|
+
return LockInfo(pid=int(data["pid"]), port=int(data["port"]), token=str(data["token"]))
|
|
143
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def notify_existing() -> bool:
|
|
148
|
+
"""Ask a running instance to show itself. True if one answered.
|
|
149
|
+
|
|
150
|
+
A lock file whose listener does not answer is stale -- the usual cause
|
|
151
|
+
is the forced termination F-45 already has to recover from -- so the
|
|
152
|
+
caller starts normally and overwrites it.
|
|
153
|
+
"""
|
|
154
|
+
info = _read_lock()
|
|
155
|
+
if info is None or info.port <= 0:
|
|
156
|
+
return False
|
|
157
|
+
try:
|
|
158
|
+
with socket.create_connection(("127.0.0.1", info.port), _CONNECT_TIMEOUT_S) as s:
|
|
159
|
+
s.settimeout(_CONNECT_TIMEOUT_S)
|
|
160
|
+
s.sendall(f"{_ACTIVATE} {info.token}".encode())
|
|
161
|
+
return s.recv(16).strip() == b"ok"
|
|
162
|
+
except OSError:
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def acquire(on_activate: Callable[[], None] | None = None) -> InstanceLock:
|
|
167
|
+
"""Become the single instance, or raise :class:`AlreadyRunning`."""
|
|
168
|
+
if notify_existing():
|
|
169
|
+
raise AlreadyRunning
|
|
170
|
+
lock = InstanceLock(on_activate)
|
|
171
|
+
lock.start()
|
|
172
|
+
return lock
|
echoact/jobs/__init__.py
ADDED
|
File without changes
|