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
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
File without changes