"""Synthetic GIL-heavy turn driver for the AC-4 isolation certify harness. Mechanism B (the class ``docs/desktop/2026-07-04-dashboard-process-isolation-PRD.md`` targets) is interpreter-wide GIL starvation: concurrent heavy agent turns run compute in threads of the SERVING process, and CPython's single GIL lets those threads starve the event loop that flushes WebSocket frames for MINUTES. A 2026-07-04 ``sample`` showed the loop thread parked in ``take_gil`` while worker threads burned the interpreter — NOT blocked on I/O. To certify the fix (AC-4) without spending real tokens on 6 concurrent 100K+ context model calls, the harness needs a turn driver that reproduces THAT regime: sustained pure-Python CPU that holds the GIL for the turn's duration. A network/sleep stub is WRONG here — it would release the GIL during I/O and never reproduce ``take_gil`` contention, so a dry-run green off it is a fake green (the spec says so explicitly). This module is a **test seam**: it is dead unless ``HERMES_ISO_CERTIFY_SYNTH_TURN`` is set. When armed, ``tui_gateway.server._make_agent`` returns a :class:`SyntheticHeavyAgent` instead of a real ``AIAgent``. Because both the in-process ``_pool`` path (isolation OFF) and the compute-host child path (isolation ON) build their agent through ``_make_agent``, the SAME synthetic turn exercises whichever dispatch path is under test — the isolation boundary is the only variable between an OFF run and an ON run. The per-turn intensity (wall duration, CPU chunk size, streamed-delta cadence, token accounting) is carried in the prompt text as a small JSON spec so the harness has full control and the server seam stays dumb. Any prompt that is not a JSON object falls back to env / built-in defaults. """ from __future__ import annotations import json import os import threading import time from typing import Any, Callable, Optional def synth_turn_armed() -> bool: """True when the synthetic-turn test seam is armed via env.""" return os.environ.get("HERMES_ISO_CERTIFY_SYNTH_TURN") == "1" def _env_float(name: str, default: float) -> float: try: return float(os.environ.get(name, "") or default) except (TypeError, ValueError): return default def _env_int(name: str, default: int) -> int: try: return int(os.environ.get(name, "") or default) except (TypeError, ValueError): return default class SyntheticHeavyAgent: """An AIAgent-shaped object whose turn is a GIL-holding CPU burn. Presents only the surface ``tui_gateway.server``'s turn path and status helpers read: ``run_conversation``/``interrupt``/``clear_interrupt`` plus a handful of ``model``/``provider``/``session_*`` attributes consumed by ``_get_usage`` and ``_session_info``. It never opens a socket or spawns a subprocess, so the only work it does is the deterministic Python loop below — exactly the ``take_gil`` regime under test. """ def __init__(self, session_id: str, *, model: str = "synthetic-heavy") -> None: self.session_id = session_id self.model = model self.provider = "synthetic" self.api_mode = "chat_completions" self.base_url = "" self.api_key = "" self.platform = "" self.tools: list[Any] = [] self.reasoning_config: dict | None = None self.service_tier: str | None = None self.context_compressor = None self._config_context_length = 200_000 self._cached_system_prompt = "" # Cumulative session counters (read by _get_usage → status bar). self.session_input_tokens = 0 self.session_output_tokens = 0 self.session_prompt_tokens = 0 self.session_completion_tokens = 0 self.session_reasoning_tokens = 0 self.session_total_tokens = 0 self.session_api_calls = 0 self.history: list[dict[str, str]] = [] self._interrupt = threading.Event() # ── interrupt contract (mirrors AIAgent) ─────────────────────────── def clear_interrupt(self) -> None: self._interrupt.clear() def interrupt(self) -> None: self._interrupt.set() def _has_stream_consumers(self) -> bool: # defensive; not used by our loop return True def close(self) -> None: """No-op teardown (session lifecycle calls agent.close() on some paths).""" self._interrupt.set() # ── spec parsing ─────────────────────────────────────────────────── @staticmethod def _parse_spec(message: Any) -> dict[str, Any]: spec: dict[str, Any] = {} if isinstance(message, str): text = message.strip() if text.startswith("{"): try: parsed = json.loads(text) if isinstance(parsed, dict): spec = parsed except (ValueError, TypeError): spec = {} return { # Primary control: wall-clock seconds of GIL-holding compute. "duration_s": float(spec.get("duration_s", _env_float("HERMES_ISO_CERTIFY_DURATION_S", 8.0))), # Pure-Python integer ops per interrupt-check chunk. Small enough # that an interrupt is honored within a few ms; large enough that # the loop stays hot on the GIL between checks. "chunk": int(spec.get("chunk", _env_int("HERMES_ISO_CERTIFY_CHUNK", 20_000))), # Streamed-delta cadence (seconds). Each delta is a loop wakeup that # marshals a frame across the transport — the serving-path pressure. "delta_interval_s": float(spec.get("delta_interval_s", _env_float("HERMES_ISO_CERTIFY_DELTA_S", 0.05))), # Notional output tokens attributed per streamed delta (drives the # 100K+-token "heavy turn" proxy in usage/metadata). "tokens_per_delta": int(spec.get("tokens_per_delta", _env_int("HERMES_ISO_CERTIFY_TPD", 512))), # Optional per-chunk sleep to model a lighter/mixed regime (0 = pure # burn). --dry-run uses a short duration, NOT a sleep, so the smoke # path still exercises the real dispatch seam. "sleep_s": float(spec.get("sleep_s", 0.0)), } # ── the turn ─────────────────────────────────────────────────────── def run_conversation( self, message: Any, *, conversation_history: Optional[list[dict[str, str]]] = None, stream_callback: Optional[Callable[[str], None]] = None, task_id: Optional[str] = None, **_kwargs: Any, ) -> dict[str, Any]: spec = self._parse_spec(message) duration = max(0.0, spec["duration_s"]) chunk = max(1, spec["chunk"]) interval = max(0.001, spec["delta_interval_s"]) tokens_per_delta = max(0, spec["tokens_per_delta"]) sleep_s = max(0.0, spec["sleep_s"]) base_history = list(conversation_history if conversation_history is not None else self.history) start = time.monotonic() last_delta = start acc = 0 deltas = 0 interrupted = False while True: if self._interrupt.is_set(): interrupted = True break now = time.monotonic() if now - start >= duration: break # GIL-holding pure-Python work. A tight integer loop runs one # bytecode step per iteration and NEVER releases the GIL — this is # the exact interpreter contention that starves the serving loop. for _ in range(chunk): acc = (acc * 1_000_003 + 12_345) & 0xFFFFFFFFFFFFFFFF if sleep_s: time.sleep(sleep_s) if now - last_delta >= interval: deltas += 1 self.session_output_tokens += tokens_per_delta self.session_completion_tokens += tokens_per_delta self.session_total_tokens += tokens_per_delta if stream_callback is not None: stream_callback(f"synthtok-{deltas:05d} ") last_delta = now self.session_api_calls += 1 # Fold the checksum into the reply so the loop is not dead-code-eliminated # and the turn produces a deterministic, inspectable result. final = ( f"[synthetic heavy turn] deltas={deltas} " f"out_tokens={self.session_output_tokens} " f"interrupted={interrupted} checksum={acc & 0xFFFF:04x}" ) messages = [ *base_history, {"role": "user", "content": str(message)[:200]}, {"role": "assistant", "content": final}, ] self.history = messages return { "final_response": final, "messages": messages, "interrupted": interrupted, "error": None, "last_reasoning": None, } def maybe_build_synthetic_agent(session_id: str, model_override: Any = None) -> SyntheticHeavyAgent | None: """Return a :class:`SyntheticHeavyAgent` when the seam is armed, else ``None``. ``model_override`` (dict or str) only influences the reported ``model`` label so status frames look plausible; it never changes the compute. """ if not synth_turn_armed(): return None model = "synthetic-heavy" if isinstance(model_override, dict) and model_override.get("model"): model = str(model_override["model"]) elif isinstance(model_override, str) and model_override: model = model_override return SyntheticHeavyAgent(session_id, model=model) __all__ = [ "SyntheticHeavyAgent", "maybe_build_synthetic_agent", "synth_turn_armed", ]