"""Shared subprocess lifecycle helpers for every process this adapter spawns.

Every spawn that may need to be killed on a timeout goes through these three
helpers so the #487 process-tree guarantees hold uniformly across the bridge,
guard, gates, observe, and context callers.

owner: RStack developed by Richardson Gunde; Tau adapter contributed by Jeomon
"""
from __future__ import annotations

import asyncio
import os
import signal
from typing import Optional


def _own_process_group_kwargs() -> dict:
    """#487: every subprocess this adapter spawns and may need to kill on a
    timeout must own its own process group (POSIX only — `start_new_session`
    is a no-op/unsupported concept on Windows, where a plain `proc.kill()`
    remains the pre-existing, unchanged behavior — not a regression, since
    nothing in this adapter handled Windows process trees before this fix
    either). See `_communicate_or_kill`'s docstring for why this matters."""
    return {} if os.name == "nt" else {"start_new_session": True}


def _kill_process_tree(proc: "asyncio.subprocess.Process") -> None:
    """#487: kill the WHOLE group on POSIX (see `_communicate_or_kill`'s
    docstring); Windows falls back to the single-pid `proc.kill()` — the
    same coverage this adapter had before this fix, not a regression.

    Qodo review caught a real race in an earlier version of this fix: looking
    up the group id via `os.getpgid(proc.pid)` at kill time can raise
    `ProcessLookupError` if the tracked pid has ALREADY exited while its
    still-running forked descendants keep the group alive — that lookup
    failure would then skip killing the still-live group entirely,
    reintroducing the exact orphan this fix exists to prevent. Not needed:
    `start_new_session=True` makes this process its OWN group leader at
    spawn time, so its pgid is always simply `proc.pid` — no lookup, and
    nothing to race."""
    try:
        if os.name == "nt":
            proc.kill()
        else:
            os.killpg(proc.pid, signal.SIGKILL)
    except ProcessLookupError:
        pass  # already exited between the timeout firing and this kill


async def _communicate_or_kill(proc: "asyncio.subprocess.Process", data: Optional[bytes], timeout: float) -> tuple[bytes, bytes]:
    """#487: `asyncio.wait_for` cancels the *await*, not the child — a timed-out
    process is left running as an orphan (visible to CI's zombie-process
    checks) unless killed and reaped explicitly. Every timeout branch in this
    adapter must go through this helper, not a bare `proc.kill()`, including the
    pre-existing `_run_guard` path this audit found only half-implemented the
    pattern (kill without a follow-up `await proc.wait()`).

    Kills the WHOLE PROCESS GROUP, not just the tracked pid — live-verified
    (not just reasoned about) during this fix: `npx` (or any wrapper script)
    commonly forks its own child (the real work happens one level down); that
    grandchild inherits the same stdout/stderr pipes. A bare `proc.kill()`
    only signals the immediate child — the grandchild survives as an orphan
    still holding the pipe open, so `proc.communicate()`'s read (and even the
    subsequent `await proc.wait()`) can block for the FULL original hang
    duration regardless of the timeout, because asyncio's subprocess
    transport waits for the pipes to reach EOF, which requires EVERY holder
    of the write end to exit. Reproduced directly with a real forking wrapper
    script before this fix (100s instead of the configured 1s), fixed by
    killing the process GROUP `os.killpg(proc.pid, SIGKILL)` instead of the
    single pid — verified this cuts it back to ~1s with zero orphaned
    processes remaining. Requires every spawn feeding this helper to pass
    `start_new_session=True` so it owns its own process group."""
    try:
        return await asyncio.wait_for(proc.communicate(data), timeout=timeout)
    except asyncio.TimeoutError:
        _kill_process_tree(proc)
        await proc.wait()
        raise
