Cai
2026-08-05 986c2ab4381d5ccc8ed7474705303c71ab43183b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
from __future__ import annotations
 
from dataclasses import dataclass
from datetime import datetime, timezone
import os
from pathlib import Path
import signal
import subprocess
import time
from typing import Sequence
 
from .models import ErrorCode
 
 
@dataclass(frozen=True)
class ProcessResult:
    argv_redacted: tuple[str, ...]
    started: bool
    pid: int | None
    exit_code: int | None
    timed_out: bool
    terminate_issued: bool
    exited: bool
    liveness_unknown: bool
    stdout_bytes: bytes
    stderr_bytes: bytes
    stdout_truncated: bool
    stderr_truncated: bool
    started_at_utc: str | None
    ended_at_utc: str | None
    elapsed_ms: int | None
    error_code: ErrorCode | None
 
 
class ProcessSupervisor:
    def __init__(self, *, max_stream_bytes: int = 1_048_576, kill_wait_ms: int = 5_000):
        self.max_stream_bytes = max_stream_bytes
        self.kill_wait_ms = kill_wait_ms
 
    @staticmethod
    def _utc_now() -> str:
        return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
 
    def run(self, argv: Sequence[str], *, timeout_ms: int, cwd: Path | None = None,
            stdin_bytes: bytes | None = None, redacted_argv: Sequence[str] | None = None) -> ProcessResult:
        if not argv or timeout_ms <= 0:
            raise ValueError("argv and positive timeout required")
        shown = tuple(redacted_argv or argv)
        started_at = self._utc_now()
        begin = time.monotonic()
        deadline = begin + timeout_ms / 1000
        creationflags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
        try:
            proc = subprocess.Popen(
                tuple(argv), cwd=str(cwd) if cwd else None, stdin=subprocess.PIPE if stdin_bytes is not None else subprocess.DEVNULL,
                stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=False, creationflags=creationflags,
                start_new_session=(os.name != "nt"),
            )
        except OSError:
            return ProcessResult(shown, False, None, None, False, False, True, False, b"", b"", False,
                                 False, started_at, self._utc_now(), round((time.monotonic()-begin)*1000),
                                 ErrorCode.PROCESS_START_FAILED)
        timed_out = False
        terminate_issued = False
        liveness_unknown = False
        try:
            termination_reserve_ms = min(self.kill_wait_ms, max(100, min(1_000, timeout_ms // 5)),
                                         max(1, timeout_ms - 1))
            normal_wait = max(0.001, (timeout_ms - termination_reserve_ms) / 1000)
            out, err = proc.communicate(stdin_bytes, timeout=normal_wait)
        except subprocess.TimeoutExpired:
            timed_out = True
            terminate_issued = True
            self._terminate_tree(proc, max(0.001, deadline - time.monotonic()))
            try:
                kill_remaining = max(0.0, deadline - time.monotonic())
                if kill_remaining <= 0:
                    raise subprocess.TimeoutExpired(tuple(argv), timeout_ms / 1000)
                out, err = proc.communicate(timeout=min(self.kill_wait_ms / 1000, kill_remaining))
            except subprocess.TimeoutExpired:
                out, err = b"", b""
                liveness_unknown = True
                try:
                    proc.kill()
                except OSError:
                    pass
        elapsed = round((time.monotonic() - begin) * 1000)
        out_trim, out_trunc = self._cap(out)
        err_trim, err_trunc = self._cap(err)
        exited = proc.poll() is not None
        code = None
        if liveness_unknown:
            code = ErrorCode.PROCESS_LIVENESS_UNKNOWN
        elif timed_out:
            code = ErrorCode.PROCESS_TIMEOUT
        elif out_trunc or err_trunc:
            code = ErrorCode.PROCESS_OUTPUT_LIMIT
        return ProcessResult(shown, True, proc.pid, proc.returncode if exited else None, timed_out, terminate_issued,
                             exited, liveness_unknown, out_trim, err_trim, out_trunc, err_trunc,
                             started_at, self._utc_now(), elapsed, code)
 
    def _cap(self, data: bytes) -> tuple[bytes, bool]:
        if len(data) <= self.max_stream_bytes:
            return data, False
        return data[:self.max_stream_bytes], True
 
    @staticmethod
    def _terminate_tree(proc: subprocess.Popen[bytes], timeout_seconds: float = 5.0) -> None:
        try:
            if os.name == "nt":
                subprocess.run(("taskkill", "/PID", str(proc.pid), "/T", "/F"), shell=False,
                               stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                               timeout=max(0.001, min(5.0, timeout_seconds)))
            else:
                os.killpg(proc.pid, signal.SIGKILL)
        except (OSError, subprocess.SubprocessError):
            try:
                proc.kill()
            except OSError:
                pass