File size: 9,438 Bytes
9d0fd45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
"""Per-exec cgroup v2 isolation β€” consumer side of the worker-shell contract."""

from __future__ import annotations

import contextlib
import logging
import os
import threading
import time
import uuid
from pathlib import Path

logger = logging.getLogger(__name__)

_ROOT_ENV = "WORKER_EXEC_CGROUP_ROOT"
MEM_MAX_ENV = "WORKER_EXEC_MEM_MAX_BYTES"
_PIDS_MAX_ENV = "WORKER_EXEC_PIDS_MAX"

# Probe-once cache, mirroring bwrap_available() / data_cap_effective():
# None = not probed yet; "" = probed and unavailable.
_ROOT_CACHE: str | None = None
_ROOT_PROBED = False
_ROOT_PROBE_LOCK = threading.Lock()
_MKDIR_FAIL_WARNED = False


def exec_cgroup_root() -> str | None:
    """The delegated ``execs/`` directory, or ``None`` when the feature is dark.

    Probed once and cached: env unset is the normal fail-open state on every
    deployment that predates the worker-shell contract and stays silent; env
    set but unusable is a platform-side misconfiguration and warns once.

    The probe is locked and ``_ROOT_PROBED`` is published LAST. Setting it
    before the isdir/access syscalls (which release the GIL) let a concurrent
    cold-start exec read PROBED=True with the cache still empty and silently
    run without its cgroup β€” one uncontained command is exactly the hole this
    module exists to close. The unlocked fast path is safe under the GIL
    because the cache is complete by the time the flag is visible.
    """
    global _ROOT_CACHE, _ROOT_PROBED
    if _ROOT_PROBED:
        return _ROOT_CACHE or None
    with _ROOT_PROBE_LOCK:
        if _ROOT_PROBED:
            return _ROOT_CACHE or None
        cache = ""
        raw = (os.environ.get(_ROOT_ENV) or "").strip()
        if raw:
            if os.path.isdir(raw) and os.access(raw, os.W_OK):
                cache = raw
                logger.info("per-exec cgroup isolation armed at %s", raw)
            else:
                logger.warning(
                    "%s=%r is set but not a writable directory; per-exec cgroup "
                    "isolation stays OFF and commands run with only the "
                    "per-process ulimit cap", _ROOT_ENV, raw,
                )
        _ROOT_CACHE = cache
        _ROOT_PROBED = True
        return cache or None


def _env_int(name: str) -> int | None:
    raw = (os.environ.get(name) or "").strip()
    try:
        value = int(raw)
    except ValueError:
        return None
    return value if value > 0 else None


class ExecCgroup:
    """One created ``exec-<id>/`` directory. Kill and remove via :meth:`close`."""

    __slots__ = ("path",)

    def __init__(self, path: Path) -> None:
        self.path = path

    @property
    def procs_path(self) -> str:
        """The ``cgroup.procs`` file the exec's shell writes ``$$`` into."""
        return str(self.path / "cgroup.procs")

    @property
    def current_path(self) -> str:
        """``memory.current`` β€” instantaneous usage of the whole exec tree."""
        return str(self.path / "memory.current")

    @property
    def kill_path(self) -> str:
        return str(self.path / "cgroup.kill")

    def _read_int(self, name: str) -> int | None:
        try:
            return int((self.path / name).read_text().strip())
        except (OSError, ValueError):
            return None

    def oom_kills(self) -> int:
        """Kernel OOM kills charged to THIS exec's cgroup.

        Reads the exec cgroup's own ``memory.events`` β€” never the container's,
        whose ``oom_kill`` counter is RECURSIVE and accumulates every per-exec
        kill below it (a healthy container reads as OOMKilled after two of
        them; measured on a real worker node).
        """
        try:
            body = (self.path / "memory.events").read_text()
        except OSError:
            return 0
        total = 0
        for line in body.splitlines():
            fields = line.split()
            if len(fields) == 2 and fields[0] in ("oom_kill", "oom_group_kill"):
                with contextlib.suppress(ValueError):
                    total = max(total, int(fields[1]))
        return total

    def oom_note(self) -> str | None:
        """Human-readable account of a group kill, or ``None`` if none happened.

        The whole point of synthesising this: ``memory.oom.group=1`` SIGKILLs
        the shell along with the runaway, so the model sees a bare exit 137
        with no MemoryError and no traceback β€” nothing it could self-correct
        from. The cgroup still holds the numbers until we rmdir it.
        """
        if self.oom_kills() <= 0:
            return None
        # memory.peak saturates AT the limit (the allocation that would pass it
        # fails), so peak is a floor of what the tree wanted, not an overshoot.
        peak = self._read_int("memory.peak")
        limit = self._read_int("memory.max")
        peak_s = f" (peak usage {peak // (1024 * 1024)}MB)" if peak else ""
        limit_s = f"its {limit // (1024 * 1024)}MB" if limit else "its"
        return (
            f"[memory limit] this command's process tree hit {limit_s} "
            f"per-command memory limit{peak_s} and was killed as a group. "
            "The limit counts ALL its processes together: lower the "
            "parallelism (each concurrent worker holds its own copy), process "
            "the data in chunks, and avoid large anonymous mmap regions."
        )

    def kill(self) -> None:
        """Atomically SIGKILL every process in the exec tree (``cgroup.kill``).

        Unlike ``killpg`` this also reaches descendants that left the process
        group via ``setsid`` / ``start_new_session`` β€” cgroup membership is
        inherited across fork/exec and cannot be shed from inside.
        """
        with contextlib.suppress(OSError), open(self.kill_path, "w") as fh:
            fh.write("1")

    def close(self) -> None:
        """Kill the tree and remove the directory. Best effort, never raises.

        The rmdir is NOT optional hygiene: an exec cgroup pins node-level slab
        memory that no container limit accounts for, and a worker leaking one
        per exec eventually takes the NODE NotReady (kubernetes KEP-5474
        measured ~42k leaked groups exhausting 14GB). The worker shell sweeps
        leftovers at task end as a backstop for SIGKILL'd harnesses, but the
        normal path is this method, on the exec's own ``finally``.
        """
        self.kill()
        # rmdir succeeds only once every member process is gone; SIGKILL'd
        # processes can take a moment to be reaped, hence the short retry.
        for _ in range(10):
            try:
                self.path.rmdir()
                return
            except OSError:
                time.sleep(0.05)
        logger.warning(
            "exec cgroup %s not removable after kill; leaving it to the "
            "worker-shell sweep", self.path,
        )


def create_exec_cgroup() -> ExecCgroup | None:
    """Create and configure one ``exec-<id>/`` cgroup, or ``None`` (fail-open).

    Limits come from the platform-provided env verbatim; a missing variable
    skips its file rather than inventing a value. Each write is suppressed
    individually β€” one controller not being enabled must not cost the exec the
    others.

    ``memory.high`` is deliberately NOT set even though the platform suggests
    a value: it throttles instead of killing, which turns an over-memory
    ``soffice`` into a silent tool timeout β€” a harder signal for the model
    than a group kill with a synthesized explanation. Revisit once
    ``memory.events`` data from production says otherwise.
    """
    global _MKDIR_FAIL_WARNED
    root = exec_cgroup_root()
    if root is None:
        return None
    path = Path(root) / f"exec-{os.getpid()}-{uuid.uuid4().hex[:8]}"
    try:
        # exist_ok=False: names must never collide across threads β€” landing two
        # execs in one cgroup would kill both when either overruns.
        path.mkdir(exist_ok=False)
    except OSError as exc:
        # Warn once per failure streak: a persistent cause (cgroupfs turned
        # read-only, execs/ swept away) would otherwise log every exec.
        if not _MKDIR_FAIL_WARNED:
            _MKDIR_FAIL_WARNED = True
            logger.warning("per-exec cgroup mkdir failed (%s); running uncontained", exc)
        return None
    _MKDIR_FAIL_WARNED = False
    cg = ExecCgroup(path)
    mem_max = _env_int(MEM_MAX_ENV)
    settings: list[tuple[str, str]] = []
    if mem_max:
        settings.append(("memory.max", str(mem_max)))
        # Load-bearing, not hygiene: with swap available the kernel swaps
        # anonymous pages out instead of ever reaching memory.max, so nothing
        # is killed AND MAP_SHARED writes keep landing (reproduced under
        # Docker Desktop). EKS nodes have no swap today; do not rely on that.
        settings.append(("memory.swap.max", "0"))
        # Make THIS cgroup the oom_domain: the kernel kills the whole exec
        # tree here and never walks up to the container's oom.group=1.
        settings.append(("memory.oom.group", "1"))
    pids_max = _env_int(_PIDS_MAX_ENV)
    if pids_max:
        settings.append(("pids.max", str(pids_max)))
    for name, value in settings:
        try:
            (path / name).write_text(value)
        except OSError as exc:
            logger.warning("per-exec cgroup: writing %s=%s failed: %s", name, value, exc)
    return cg