hugpy-platform 0.2.0a0__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.
@@ -0,0 +1,105 @@
1
+ """hugpy-platform: the stdlib-first foundation every Hugpy package stands on.
2
+
3
+ What lives here (and nothing above it — no engine, storage, fleet or server):
4
+
5
+ central canonical central base URL (``HUGPY_BASE_URL`` + aliases)
6
+ platform_facade OS facts (``IS_WINDOWS``/``IS_MACOS``/``IS_LINUX``) and the
7
+ sanitising ``env_value`` seam over ``abstract_essentials``
8
+ app_dirs per-OS data/config/cache/engine dirs and the ``~/.hugpy``
9
+ runtime-file accessors
10
+ constants storage roots, HF cache layout, fleet-wide defaults
11
+ (import has side effects: creates dirs — import it explicitly)
12
+ utils small filesystem / message / naming helpers
13
+ module_imports ``get_<package>()`` lazy accessors for heavy ML libraries
14
+ async_runtime one process-wide asyncio loop + sync bridges
15
+ client_liveness "is the HTTP client still there" probe (Flask optional)
16
+ binaries / procutil / hardware executables, process trees, RAM/GPU probes
17
+ compat_pydantic pure-Python pydantic stand-in for platforms without pydantic_core
18
+ except_utils ``caught`` / ``attempt`` / ``catching`` logging helpers
19
+ trust Hugging Face publisher trust tiers
20
+ buildinfo which build is this process: workspace version, sha/dirty,
21
+ editable source, lockstep check (``/health``, ``/build``, heartbeat)
22
+
23
+ This ``__init__`` re-exports only the light, side-effect-free names below; the
24
+ heavier modules are imported by dotted path. ``buildinfo`` is exported lazily
25
+ (``hugpy_platform.buildinfo`` resolves on first attribute access, PEP 562) so
26
+ importing the package never pays for it.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ from hugpy_platform.app_dirs import (
31
+ cache_dir,
32
+ config_dir,
33
+ data_dir,
34
+ engine_dir,
35
+ ensure_hugpy_home,
36
+ hugpy_config_dir,
37
+ hugpy_home,
38
+ hugpy_logs_dir,
39
+ hugpy_run_dir,
40
+ hugpy_state_dir,
41
+ models_root,
42
+ )
43
+ from hugpy_platform.central import CENTRAL_ENV_VARS, DEFAULT_CENTRAL, central_base_url
44
+ from hugpy_platform.compat_pydantic import ensure_pydantic
45
+ from hugpy_platform.except_utils import FAILED, attempt, catching, caught, caught_block
46
+ from hugpy_platform.platform_facade import EXE_SUFFIX, IS_LINUX, IS_MACOS, IS_WINDOWS, env_value
47
+ from hugpy_platform.trust import trust_label, trust_tier
48
+
49
+ try: # the installed distribution's version: the workspace tag/commit, never a literal
50
+ from importlib.metadata import version as _dist_version
51
+ __version__ = _dist_version("hugpy-platform")
52
+ except Exception: # noqa: BLE001 — source tree without metadata
53
+ __version__ = "0.0.0+unknown"
54
+
55
+ _LAZY_SUBMODULES = ("buildinfo",)
56
+
57
+
58
+ def __getattr__(name: str):
59
+ """Lazy submodule export: ``hugpy_platform.buildinfo`` without an import-time cost."""
60
+ if name in _LAZY_SUBMODULES:
61
+ import importlib
62
+ module = importlib.import_module(f"{__name__}.{name}")
63
+ globals()[name] = module
64
+ return module
65
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
66
+
67
+
68
+ __all__ = [
69
+ "__version__",
70
+ # central
71
+ "CENTRAL_ENV_VARS",
72
+ "DEFAULT_CENTRAL",
73
+ "central_base_url",
74
+ # platform facts + env seam
75
+ "EXE_SUFFIX",
76
+ "IS_LINUX",
77
+ "IS_MACOS",
78
+ "IS_WINDOWS",
79
+ "env_value",
80
+ # app dirs
81
+ "cache_dir",
82
+ "config_dir",
83
+ "data_dir",
84
+ "engine_dir",
85
+ "ensure_hugpy_home",
86
+ "hugpy_config_dir",
87
+ "hugpy_home",
88
+ "hugpy_logs_dir",
89
+ "hugpy_run_dir",
90
+ "hugpy_state_dir",
91
+ "models_root",
92
+ # pydantic shim
93
+ "ensure_pydantic",
94
+ # exception helpers
95
+ "FAILED",
96
+ "attempt",
97
+ "catching",
98
+ "caught",
99
+ "caught_block",
100
+ # trust tiers
101
+ "trust_label",
102
+ "trust_tier",
103
+ # build identity (lazy submodule)
104
+ "buildinfo",
105
+ ]
@@ -0,0 +1,305 @@
1
+ """Per-OS application directories — one source of truth.
2
+
3
+ Replaces the scattered hardcoded ``/srv/abstractendeavors/...``,
4
+ ``~/.local/share/hugpy``, ``/etc/llama-swap``, and ``/mnt/llm_storage`` literals.
5
+ Every path is overridable by the same env vars the code already honoured, so
6
+ existing Linux deployments are unaffected; only the *defaults* become per-OS:
7
+
8
+ data_dir() Linux ~/.local/share/hugpy macOS ~/Library/Application Support/hugpy Windows %LOCALAPPDATA%\\hugpy
9
+ config_dir() Linux ~/.config/hugpy macOS ~/Library/Application Support/hugpy Windows %LOCALAPPDATA%\\hugpy
10
+ cache_dir() Linux ~/.cache/hugpy macOS ~/Library/Caches/hugpy Windows %LOCALAPPDATA%\\hugpy\\Cache
11
+ engine_dir() data_dir()/engine — where the fetched llama.cpp binary lands
12
+ models_root() DEFAULT_ROOT or data_dir()/llm_storage
13
+
14
+ We use ``platformdirs`` when available (added to base deps) and fall back to a
15
+ hand-rolled per-OS layout so this module never hard-fails on import.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+
21
+ from hugpy_platform.platform_facade import IS_MACOS, IS_WINDOWS, env_value
22
+
23
+ _APP = "hugpy"
24
+
25
+
26
+ def _home(*parts: str) -> str:
27
+ return os.path.join(os.path.expanduser("~"), *parts)
28
+
29
+
30
+ def _fallback_data() -> str:
31
+ if IS_WINDOWS:
32
+ base = os.environ.get("LOCALAPPDATA") or _home("AppData", "Local")
33
+ return os.path.join(base, _APP)
34
+ if IS_MACOS:
35
+ return _home("Library", "Application Support", _APP)
36
+ return os.path.join(os.environ.get("XDG_DATA_HOME") or _home(".local", "share"), _APP)
37
+
38
+
39
+ def _fallback_config() -> str:
40
+ if IS_WINDOWS:
41
+ base = os.environ.get("LOCALAPPDATA") or _home("AppData", "Local")
42
+ return os.path.join(base, _APP)
43
+ if IS_MACOS:
44
+ return _home("Library", "Application Support", _APP)
45
+ return os.path.join(os.environ.get("XDG_CONFIG_HOME") or _home(".config"), _APP)
46
+
47
+
48
+ def _fallback_cache() -> str:
49
+ if IS_WINDOWS:
50
+ base = os.environ.get("LOCALAPPDATA") or _home("AppData", "Local")
51
+ return os.path.join(base, _APP, "Cache")
52
+ if IS_MACOS:
53
+ return _home("Library", "Caches", _APP)
54
+ return os.path.join(os.environ.get("XDG_CACHE_HOME") or _home(".cache"), _APP)
55
+
56
+
57
+ def _dirs():
58
+ try:
59
+ import platformdirs
60
+
61
+ return platformdirs.PlatformDirs(_APP, appauthor=False)
62
+ except Exception:
63
+ return None
64
+
65
+
66
+ def data_dir() -> str:
67
+ override = env_value("HUGPY_DATA_DIR")
68
+ if override:
69
+ return _ensure(override)
70
+ d = _dirs()
71
+ return _ensure(d.user_data_dir if d else _fallback_data())
72
+
73
+
74
+ def config_dir() -> str:
75
+ override = env_value("HUGPY_CONFIG_DIR")
76
+ if override:
77
+ return _ensure(override)
78
+ d = _dirs()
79
+ return _ensure(d.user_config_dir if d else _fallback_config())
80
+
81
+
82
+ def cache_dir() -> str:
83
+ override = env_value("HUGPY_CACHE_DIR")
84
+ if override:
85
+ return _ensure(override)
86
+ d = _dirs()
87
+ return _ensure(d.user_cache_dir if d else _fallback_cache())
88
+
89
+
90
+ def engine_dir() -> str:
91
+ """Where ``hugpy install-engine`` unpacks the native llama.cpp binaries."""
92
+ override = env_value("HUGPY_ENGINE_DIR") or env_value("LLAMA_CPP_DIR")
93
+ if override:
94
+ return _ensure(override)
95
+ return _ensure(os.path.join(data_dir(), "engine"))
96
+
97
+
98
+ def models_root() -> str:
99
+ """Model/upload/dataset storage root.
100
+
101
+ Honours the legacy ``DEFAULT_ROOT``/``MODELS_HOME`` env vars first — but only
102
+ if that path can actually be created and written. A stale/un-writable override
103
+ (e.g. ``DEFAULT_ROOT=/mnt/llm_storage`` carried in a server ``.env`` onto a
104
+ worker or a phone where ``/mnt`` is read-only) is ignored in favour of a
105
+ per-user dir under ``data_dir()``, so storage never lands on a dead path.
106
+ """
107
+ override = env_value("DEFAULT_ROOT")
108
+ if override and _usable(override):
109
+ return override
110
+ # Preserve the historical Linux mount when it exists and is writable.
111
+ legacy = "/mnt/llm_storage"
112
+ try:
113
+ if os.path.isdir(legacy) and os.access(legacy, os.W_OK):
114
+ return legacy
115
+ except OSError:
116
+ pass
117
+ return _ensure(os.path.join(data_dir(), "llm_storage"))
118
+
119
+
120
+ def demo_media_base() -> str:
121
+ """Base URL the video arm's canned demo loads its sample media from."""
122
+ return env_value("HUGPY_DEMO_MEDIA_BASE") or "https://hugpy.ai/demo-media"
123
+
124
+
125
+ def demo_media_dir() -> str:
126
+ """Local demo-media tree to serve at ``/demo-media/`` (self-hosters).
127
+
128
+ Empty string means "not configured" — deliberately NO default and NO
129
+ directory creation; the ``/demo-media/`` route only exists when this is set.
130
+ """
131
+ return env_value("HUGPY_DEMO_MEDIA_DIR") or ""
132
+
133
+
134
+ def _usable(path: str) -> bool:
135
+ """True only if *path* exists (or can be created) AND is writable."""
136
+ try:
137
+ os.makedirs(path, exist_ok=True)
138
+ return os.path.isdir(path) and os.access(path, os.W_OK)
139
+ except OSError:
140
+ return False
141
+
142
+
143
+ def _ensure(path: str) -> str:
144
+ try:
145
+ os.makedirs(path, exist_ok=True)
146
+ except OSError:
147
+ pass
148
+ return path
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # hugpy runtime "mechanic" files (HUGPY_HOME, default ~/.hugpy)
153
+ # ---------------------------------------------------------------------------
154
+ # Historically these state/config/log files were written straight into $HOME
155
+ # (``~/todo.json``, ``~/steward-state.json``, ``~/.abstract_hugpy_worker.json``,
156
+ # ``~/model_metadata.db`` …), strewing the home directory. They now live under a
157
+ # single base dir — ``HUGPY_HOME`` (default ``~/.hugpy``) — split into::
158
+ #
159
+ # state/ todo.json, steward-state.json, model_metadata.db, flow.json, …
160
+ # config/ abstract_hugpy_worker.json (+ .settings.json/.update.json)
161
+ # logs/ bridge-mail.jsonl, bugreport.json
162
+ # run/ *.lock
163
+ #
164
+ # Each named accessor MIGRATES a legacy ``~/<name>`` file into its new home the
165
+ # first time it is resolved (atomic same-fs rename, cross-fs copy fallback), so
166
+ # upgrades are seamless and idempotent — no flag-day, no data loss. Every path
167
+ # stays overridable so existing deployments and tests can pin locations.
168
+ #
169
+ # NOTE: standalone station-stack scripts (keeper_relay.py, bugreport/scanner.py)
170
+ # run under the system interpreter and cannot import this package; they carry a
171
+ # byte-identical inline resolver. Keep the two in sync — this module is the spec.
172
+
173
+ def hugpy_home() -> str:
174
+ """Single base directory for hugpy's runtime files. Default ``~/.hugpy``."""
175
+ return _ensure(env_value("HUGPY_HOME") or _home(".hugpy"))
176
+
177
+
178
+ def hugpy_state_dir() -> str:
179
+ return _ensure(os.path.join(hugpy_home(), "state"))
180
+
181
+
182
+ def hugpy_config_dir() -> str:
183
+ return _ensure(os.path.join(hugpy_home(), "config"))
184
+
185
+
186
+ def hugpy_logs_dir() -> str:
187
+ return _ensure(os.path.join(hugpy_home(), "logs"))
188
+
189
+
190
+ def hugpy_run_dir() -> str:
191
+ return _ensure(os.path.join(hugpy_home(), "run"))
192
+
193
+
194
+ def _relocate(new_path: str, *legacy_names: str) -> str:
195
+ """Return *new_path*, first migrating a legacy ``~/<name>`` into it once.
196
+
197
+ Idempotent and safe: if *new_path* already exists nothing moves. Uses an
198
+ atomic rename on the same filesystem and falls back to a copy+unlink across
199
+ filesystems. Any failure leaves the legacy file untouched and returns the
200
+ new path anyway (a fresh file is then created there).
201
+ """
202
+ try:
203
+ if os.path.exists(new_path):
204
+ return new_path
205
+ for name in legacy_names:
206
+ old = _home(name)
207
+ if not os.path.exists(old):
208
+ continue
209
+ if os.path.abspath(old) == os.path.abspath(new_path):
210
+ continue
211
+ try:
212
+ os.replace(old, new_path) # atomic, same filesystem
213
+ except OSError:
214
+ import shutil
215
+ shutil.move(old, new_path) # cross-filesystem fallback
216
+ break
217
+ except OSError:
218
+ pass
219
+ return new_path
220
+
221
+
222
+ # --- named accessors --------------------------------------------------------
223
+
224
+ def worker_id_file() -> str:
225
+ """``config/abstract_hugpy_worker.json`` — the worker identity file.
226
+
227
+ Honors an explicit ``WORKER_ID_FILE`` override (used by tests / multi-worker
228
+ hosts); otherwise migrates the legacy ``~/.abstract_hugpy_worker.json`` plus
229
+ its ``.settings.json`` / ``.update.json`` sidecars into ``config/``.
230
+ """
231
+ override = env_value("WORKER_ID_FILE")
232
+ if override:
233
+ return override
234
+ new = os.path.join(hugpy_config_dir(), "abstract_hugpy_worker.json")
235
+ _relocate(new, ".abstract_hugpy_worker.json")
236
+ for suffix in (".settings.json", ".update.json"):
237
+ _relocate(new + suffix, ".abstract_hugpy_worker.json" + suffix)
238
+ return new
239
+
240
+
241
+ def gguf_worker_id_file() -> str:
242
+ """``config/gguf_worker.json`` — sibling worker identity (same pattern)."""
243
+ override = env_value("WORKER_ID_FILE")
244
+ if override:
245
+ return override
246
+ new = os.path.join(hugpy_config_dir(), "gguf_worker.json")
247
+ return _relocate(new, ".gguf_worker.json")
248
+
249
+
250
+ def todo_file() -> str:
251
+ return _relocate(os.path.join(hugpy_state_dir(), "todo.json"), "todo.json")
252
+
253
+
254
+ def todo_history_file() -> str:
255
+ return _relocate(
256
+ os.path.join(hugpy_state_dir(), "todo-history.jsonl"), "todo-history.jsonl"
257
+ )
258
+
259
+
260
+ def todo_lock() -> str:
261
+ return _relocate(os.path.join(hugpy_run_dir(), "todo.lock"), ".todo.lock")
262
+
263
+
264
+ def steward_config() -> str:
265
+ return _relocate(os.path.join(hugpy_config_dir(), "steward.json"), "steward.json")
266
+
267
+
268
+ def steward_state() -> str:
269
+ return _relocate(
270
+ os.path.join(hugpy_state_dir(), "steward-state.json"), "steward-state.json"
271
+ )
272
+
273
+
274
+ def model_metadata_db() -> str:
275
+ return _relocate(
276
+ os.path.join(hugpy_state_dir(), "model_metadata.db"), "model_metadata.db"
277
+ )
278
+
279
+
280
+ def model_physical_json() -> str:
281
+ # The ``.lock`` sidecar is derived by callers as ``path + ".lock"`` and rides
282
+ # along in state/ next to the file — intentionally not split into run/.
283
+ return _relocate(
284
+ os.path.join(hugpy_state_dir(), "model_physical.json"), "model_physical.json"
285
+ )
286
+
287
+
288
+ def flow_json() -> str:
289
+ return _relocate(os.path.join(hugpy_state_dir(), "flow.json"), "flow.json")
290
+
291
+
292
+ def bridge_mail() -> str:
293
+ return _relocate(
294
+ os.path.join(hugpy_logs_dir(), "bridge-mail.jsonl"), ".bridge-mail.jsonl"
295
+ )
296
+
297
+
298
+ def bugreport_json() -> str:
299
+ return _relocate(os.path.join(hugpy_logs_dir(), "bugreport.json"), "bugreport.json")
300
+
301
+
302
+ def ensure_hugpy_home() -> str:
303
+ """Create the full ``HUGPY_HOME`` skeleton. Called by installers/postinst."""
304
+ hugpy_state_dir(); hugpy_config_dir(); hugpy_logs_dir(); hugpy_run_dir()
305
+ return hugpy_home()
@@ -0,0 +1,226 @@
1
+ """Process-wide async runtime — ONE long-lived event loop in a daemon thread.
2
+
3
+ Replaces the per-request ``asyncio.new_event_loop()`` pattern every SSE / one-shot
4
+ endpoint used. That pattern caused two problems:
5
+
6
+ * Loop-binding crashes — an asyncio sync primitive (Semaphore/Lock/Event)
7
+ cached on a process singleton binds to the FIRST request's loop and then
8
+ raises "bound to a different event loop" on the next request. With one
9
+ persistent loop, cached primitives stay valid for the life of the process.
10
+ * Per-request loop churn — creating/closing a loop per request and pinning a
11
+ thread in run_until_complete for the whole stream. The shared loop interleaves
12
+ many streams cooperatively; blocking model work runs in the default executor
13
+ (asyncio.to_thread), so the loop stays responsive.
14
+
15
+ Sync callers submit coroutines via ``run()`` / ``iter_sync()``; the loop runs them
16
+ and the caller blocks on a ``concurrent.futures.Future``. All entry points are
17
+ thread-safe (``run_coroutine_threadsafe``). Usable from both central (gunicorn
18
+ threads) and the worker agent (its request threads).
19
+
20
+ ABANDON-ON-DISCONNECT (2026-07-27). These two functions are the ONE place a WSGI
21
+ thread parks while the loop works, so they are also the only place that can hand
22
+ the thread back when the caller gives up. Instead of blocking forever on
23
+ ``fut.result()`` they wake every ``client_liveness.poll_s()`` and ask the
24
+ request's socket probe whether the client is still there; on a definite
25
+ disconnect they cancel the in-flight coroutine (CancelledError unwinds its
26
+ ``finally`` blocks, releasing relay slots / cold-hold permits / httpx streams)
27
+ and give the request slot back. With no probe bound — a background thread, an
28
+ internal drain, a WSGI server that publishes no socket — ``alive`` is None and
29
+ the behaviour is byte-identical to before.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import asyncio
34
+ import inspect
35
+ import threading
36
+ import logging
37
+ import concurrent.futures as _cf
38
+
39
+ from hugpy_platform import client_liveness
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ _loop: "asyncio.AbstractEventLoop | None" = None
44
+ _thread: "threading.Thread | None" = None
45
+ _start_lock = threading.Lock()
46
+
47
+
48
+ def loop() -> "asyncio.AbstractEventLoop":
49
+ """The shared event loop, starting its daemon thread on first use."""
50
+ global _loop, _thread
51
+ lp = _loop
52
+ if lp is not None and lp.is_running():
53
+ return lp
54
+ with _start_lock:
55
+ if _loop is not None and _loop.is_running():
56
+ return _loop
57
+ lp = asyncio.new_event_loop()
58
+ ready = threading.Event()
59
+
60
+ def _run():
61
+ asyncio.set_event_loop(lp)
62
+ ready.set()
63
+ lp.run_forever()
64
+
65
+ t = threading.Thread(target=_run, name="hugpy-async-runtime", daemon=True)
66
+ t.start()
67
+ ready.wait(5)
68
+ _loop, _thread = lp, t
69
+ logger.info("async runtime started (thread=%s)", t.name)
70
+ return _loop
71
+
72
+
73
+ def submit(coro) -> "_cf.Future":
74
+ """Schedule a coroutine on the shared loop; return its concurrent Future."""
75
+ return asyncio.run_coroutine_threadsafe(coro, loop())
76
+
77
+
78
+ def _client_gone(alive) -> bool:
79
+ """Ask a liveness checker, treating ANY failure as "still connected"."""
80
+ if alive is None:
81
+ return False
82
+ try:
83
+ return bool(alive())
84
+ except Exception: # noqa: BLE001 — never abandon a caller on doubt
85
+ return False
86
+
87
+
88
+ def _abandon(fut) -> None:
89
+ """Cancel an in-flight step and let its ``finally`` blocks unwind."""
90
+ if fut is None or fut.done():
91
+ return
92
+ fut.cancel()
93
+ try:
94
+ fut.result(5)
95
+ except BaseException: # noqa: BLE001 — cancellation is the expected outcome
96
+ pass
97
+
98
+
99
+ def run(coro, *, alive=None):
100
+ """Run a coroutine on the shared loop from a sync thread; block for its result.
101
+
102
+ ``alive`` is a zero-arg "is my caller still connected?"; it defaults to the
103
+ probe this thread's Flask request bound (see _platform.client_liveness). When
104
+ one is available and reports a disconnect, the coroutine is CANCELLED and
105
+ ``ClientGone`` is raised — the WSGI thread is returned to the pool instead of
106
+ sitting out (up to) a 25-minute cold hold for a caller who left. No probe ⇒
107
+ plain blocking ``.result()``, exactly as before.
108
+ """
109
+ if not asyncio.iscoroutine(coro):
110
+ # Tolerate already-resolved values (callers that may pass a plain result).
111
+ return coro
112
+ if alive is None:
113
+ alive = client_liveness.current_checker()
114
+ fut = submit(coro)
115
+ if alive is None:
116
+ return fut.result()
117
+ poll = client_liveness.poll_s()
118
+ while True:
119
+ try:
120
+ return fut.result(poll)
121
+ except _cf.TimeoutError:
122
+ if not _client_gone(alive):
123
+ continue
124
+ _abandon(fut)
125
+ logger.info("client disconnected — abandoned in-flight work "
126
+ "and released the request slot")
127
+ raise client_liveness.ClientGone(
128
+ "the client disconnected before the reply was ready; the "
129
+ "in-flight work was cancelled and its slot released")
130
+
131
+
132
+ def await_sync(value):
133
+ """Resolve an awaitable on the process loop or return a plain value."""
134
+ if not inspect.isawaitable(value):
135
+ return value
136
+ return run(value)
137
+
138
+
139
+ def call_soon_threadsafe(callback, *args) -> None:
140
+ """Schedule a plain callback on the shared loop (e.g. ``Event.set`` from
141
+ another thread, which is otherwise unsafe to call cross-loop)."""
142
+ loop().call_soon_threadsafe(callback, *args)
143
+
144
+
145
+ def _step_wait(heartbeat, heartbeat_secs: float, poll, waited: float):
146
+ """How long to block on the current step: whichever of the heartbeat tick and
147
+ the disconnect poll comes first. None (block forever) only when neither
148
+ applies — the pre-existing internal-drain behaviour."""
149
+ hb = (heartbeat_secs - waited) if heartbeat is not None else None
150
+ if hb is not None and hb <= 0:
151
+ hb = heartbeat_secs
152
+ if poll is None:
153
+ return hb
154
+ if hb is None:
155
+ return poll
156
+ return max(0.01, min(hb, poll))
157
+
158
+
159
+ def iter_sync(agen, heartbeat: "bytes | None" = None, heartbeat_secs: float = 15.0,
160
+ alive=None):
161
+ """Drive an async generator from a sync (WSGI) thread on the SHARED loop.
162
+
163
+ Mirrors the old per-request driver semantics:
164
+ * With ``heartbeat`` bytes, each step waits at most ``heartbeat_secs`` and
165
+ yields the keepalive on timeout while the SAME step keeps running — so a
166
+ slow first token can't trip an upstream proxy, and every keepalive write
167
+ lets the WSGI server notice a dead client and trigger teardown.
168
+ * ``heartbeat=None`` blocks for each real event (internal/worker drains).
169
+ * On teardown the in-flight step is cancelled, then ``aclose()`` cascades
170
+ GeneratorExit through every ``async for`` / ``async with`` so a relayed
171
+ worker's httpx stream is released rather than leaked.
172
+
173
+ ABANDON-ON-DISCONNECT: when a client probe is available (``alive``, defaulting
174
+ to this request thread's), the step wait is additionally bounded by the poll
175
+ interval and a definite disconnect ends the drain — teardown below then
176
+ cancels and acloses exactly as it does for any other early exit. This is what
177
+ covers ``heartbeat=None`` drains such as the /v1 non-streaming completion,
178
+ which never writes to the client and so can never learn of a disconnect from
179
+ a failed write.
180
+ """
181
+ lp = loop()
182
+ if alive is None:
183
+ alive = client_liveness.current_checker()
184
+ poll = client_liveness.poll_s() if alive is not None else None
185
+ fut = None
186
+ waited = 0.0
187
+ try:
188
+ while True:
189
+ if fut is None:
190
+ fut = asyncio.run_coroutine_threadsafe(agen.__anext__(), lp)
191
+ waited = 0.0
192
+ step = _step_wait(heartbeat, heartbeat_secs, poll, waited)
193
+ try:
194
+ item = fut.result(step)
195
+ fut = None
196
+ except _cf.TimeoutError:
197
+ waited += step or 0.0
198
+ if _client_gone(alive):
199
+ logger.info("client disconnected mid-drain — abandoning the "
200
+ "stream and releasing the request slot")
201
+ break
202
+ # Next event still cooking — keep the connection warm, keep
203
+ # awaiting the SAME step (it's still running on the loop).
204
+ if heartbeat is not None and waited >= heartbeat_secs - 1e-9:
205
+ waited = 0.0
206
+ yield heartbeat
207
+ continue
208
+ except StopAsyncIteration:
209
+ fut = None
210
+ break
211
+ if isinstance(item, str):
212
+ item = item.encode("utf-8")
213
+ yield item
214
+ finally:
215
+ try:
216
+ # Cancel the in-flight step first: CancelledError unwinds the
217
+ # chain's `async with` blocks (closing the worker httpx stream),
218
+ # after which aclose() can finalize without "already running".
219
+ _abandon(fut)
220
+ closer = asyncio.run_coroutine_threadsafe(agen.aclose(), lp)
221
+ try:
222
+ closer.result(10)
223
+ except BaseException:
224
+ pass
225
+ except Exception:
226
+ pass
@@ -0,0 +1,28 @@
1
+ """Atomic JSON writes for the small shared state files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import secrets
8
+
9
+
10
+ def read_json_dict(path: str) -> dict | None:
11
+ """Read a JSON object, returning None for absent or malformed files."""
12
+ try:
13
+ with open(path, "r", encoding="utf-8") as fh:
14
+ data = json.load(fh)
15
+ except Exception: # noqa: BLE001 — unreadable and malformed are both absent
16
+ return None
17
+ return data if isinstance(data, dict) else None
18
+
19
+
20
+ def save_json(path: str, data: dict) -> None:
21
+ """Write JSON with a unique temporary file and an atomic replacement."""
22
+ parent = os.path.dirname(path)
23
+ if parent:
24
+ os.makedirs(parent, exist_ok=True)
25
+ tmp = f"{path}.{os.getpid()}.{secrets.token_hex(4)}.tmp"
26
+ with open(tmp, "w", encoding="utf-8") as f:
27
+ json.dump(data, f, indent=2, sort_keys=True)
28
+ os.replace(tmp, path)