remote-exec-grpc 0.2.1__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.
remote_exec/cmdlog.py ADDED
@@ -0,0 +1,159 @@
1
+ """Command log: every executed command together with its output.
2
+
3
+ One text file shared by all executions (each line carries the execution id,
4
+ so concurrent commands can be told apart)::
5
+
6
+ 2026-10-04 10:00:00.120 [3f2a9c1d0b7e] START client=ipv4:10.0.0.7:51234 cwd="/proj" timeout=0 env_keys="" command="make"
7
+ 2026-10-04 10:00:00.250 [3f2a9c1d0b7e] OUT| gcc -c main.c
8
+ 2026-10-04 10:00:00.260 [3f2a9c1d0b7e] ERR| warning: unused variable
9
+ 2026-10-04 10:00:01.900 [3f2a9c1d0b7e] END status=exited exit_code=0 duration=1.780s
10
+
11
+ Output is split into lines per stream (undecodable bytes are replaced).
12
+ When the file would exceed ``max_bytes`` it is renamed to
13
+ ``<name>.<timestamp><suffix>`` and a new file is started; ``keep`` limits how
14
+ many rotated files are retained (0 = keep all). Files are created with mode
15
+ 600 because command output may contain sensitive data. Environment variable
16
+ values are never written.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import os
24
+ from datetime import datetime
25
+ from pathlib import Path
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+ DEFAULT_COMMAND_LOG = "remote-exec-commands.log"
30
+ DEFAULT_MAX_MB = 10.0
31
+ MAX_LINE_BYTES = 64 * 1024 # longer output without newline is written in pieces
32
+
33
+
34
+ def _q(value: str) -> str:
35
+ return json.dumps(value, ensure_ascii=False)
36
+
37
+
38
+ class CommandLog:
39
+ def __init__(self, path: str | os.PathLike, max_bytes: int = int(DEFAULT_MAX_MB * 1024 * 1024), keep: int = 0):
40
+ if max_bytes < 1024:
41
+ raise ValueError("command log size limit must be at least 1 KiB")
42
+ if keep < 0:
43
+ raise ValueError("number of rotated command logs to keep must be >= 0")
44
+ self.path = Path(path).expanduser().absolute()
45
+ self.max_bytes = max_bytes
46
+ self.keep = keep
47
+ self._pending: dict[tuple[str, str], bytearray] = {}
48
+ self._error_reported = False
49
+ self._file = None
50
+ self._open()
51
+
52
+ # ------------------------------------------------------------ public API
53
+
54
+ def start(self, execution_id: str, client: str, cwd: str, command: str, timeout: float, env_keys) -> None:
55
+ self._write(self._line(
56
+ execution_id,
57
+ f"START client={client} cwd={_q(cwd)} timeout={timeout:g} "
58
+ f"env_keys={_q(','.join(sorted(env_keys)))} command={_q(command)}",
59
+ ))
60
+
61
+ def output(self, execution_id: str, stream: str, data: bytes) -> None:
62
+ """Record a chunk of ``stream`` ("stdout"/"stderr"); complete lines are written."""
63
+ key = (execution_id, stream)
64
+ buf = self._pending.setdefault(key, bytearray())
65
+ buf.extend(data)
66
+ lines = []
67
+ while (nl := buf.find(b"\n")) >= 0:
68
+ lines.append(bytes(buf[:nl]))
69
+ del buf[:nl + 1]
70
+ while len(buf) > MAX_LINE_BYTES:
71
+ lines.append(bytes(buf[:MAX_LINE_BYTES]))
72
+ del buf[:MAX_LINE_BYTES]
73
+ if lines:
74
+ self._write("".join(self._output_line(execution_id, stream, line) for line in lines))
75
+
76
+ def end(self, execution_id: str, status: str, exit_code, duration: float) -> None:
77
+ text = []
78
+ for stream in ("stdout", "stderr"):
79
+ rest = self._pending.pop((execution_id, stream), None)
80
+ if rest:
81
+ text.append(self._output_line(execution_id, stream, bytes(rest)))
82
+ text.append(self._line(execution_id, f"END status={status} exit_code={exit_code} duration={duration:.3f}s"))
83
+ self._write("".join(text))
84
+
85
+ def rejected(self, client: str, command: str, reason: str) -> None:
86
+ self._write(self._line("-" * 12, f"REJECTED client={client} reason={_q(reason)} command={_q(command)}"))
87
+
88
+ def close(self) -> None:
89
+ if self._file:
90
+ self._file.close()
91
+ self._file = None
92
+
93
+ # ------------------------------------------------------------ internals
94
+
95
+ @staticmethod
96
+ def _line(execution_id: str, message: str) -> str:
97
+ ts = datetime.now().isoformat(sep=" ", timespec="milliseconds")
98
+ return f"{ts} [{execution_id}] {message}\n"
99
+
100
+ def _output_line(self, execution_id: str, stream: str, raw: bytes) -> str:
101
+ text = raw.decode("utf-8", errors="replace").rstrip("\r")
102
+ return self._line(execution_id, f"{'OUT' if stream == 'stdout' else 'ERR'}| {text}")
103
+
104
+ def _open(self) -> None:
105
+ self.path.parent.mkdir(parents=True, exist_ok=True)
106
+ fd = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
107
+ self._file = os.fdopen(fd, "a", encoding="utf-8")
108
+ self._size = os.fstat(fd).st_size
109
+
110
+ def _write(self, text: str) -> None:
111
+ if self._file is None:
112
+ return
113
+ data_len = len(text.encode("utf-8"))
114
+ try:
115
+ if self._size > 0 and self._size + data_len > self.max_bytes:
116
+ self._rotate()
117
+ self._file.write(text)
118
+ self._file.flush()
119
+ self._size += data_len
120
+ except OSError as exc:
121
+ # Never let logging problems (e.g. disk full) break command execution.
122
+ if not self._error_reported:
123
+ log.error("cannot write command log %s: %s (further errors suppressed)", self.path, exc)
124
+ self._error_reported = True
125
+
126
+ def _rotated_name(self) -> Path:
127
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
128
+ candidate = self.path.with_name(f"{self.path.stem}.{stamp}{self.path.suffix}")
129
+ n = 1
130
+ while candidate.exists():
131
+ candidate = self.path.with_name(f"{self.path.stem}.{stamp}-{n}{self.path.suffix}")
132
+ n += 1
133
+ return candidate
134
+
135
+ def rotated_files(self) -> list[Path]:
136
+ """Rotated files, oldest first (by last write time, then name)."""
137
+ pattern = f"{self.path.stem}.*{self.path.suffix}"
138
+ files = []
139
+ for p in self.path.parent.glob(pattern):
140
+ if p != self.path:
141
+ try:
142
+ files.append((p.stat().st_mtime, p.name, p))
143
+ except OSError:
144
+ pass
145
+ return [p for _, _, p in sorted(files)]
146
+
147
+ def _rotate(self) -> None:
148
+ self._file.close()
149
+ self._file = None
150
+ try:
151
+ os.replace(self.path, self._rotated_name())
152
+ if self.keep:
153
+ for old in self.rotated_files()[:-self.keep]:
154
+ try:
155
+ old.unlink()
156
+ except OSError:
157
+ pass
158
+ finally:
159
+ self._open() # if the rename failed, keep appending to the current file
remote_exec/config.py ADDED
@@ -0,0 +1,202 @@
1
+ """Configuration loading.
2
+
3
+ Client config (YAML), default location ``$REMOTE_EXEC_CONFIG`` or
4
+ ``${XDG_CONFIG_HOME:-~/.config}/remote-exec/config.yaml``::
5
+
6
+ defaults: # optional, merged into every server entry
7
+ token_file: ~/.config/remote-exec/token
8
+ servers:
9
+ fpga01:
10
+ host: fpga01.example.local
11
+ port: 50052 # optional, defaults to 50051
12
+ description: FPGA builds (Vivado, Questa)
13
+
14
+ Server config (YAML, only with ``remote-exec-server --config``) uses the long
15
+ option names of the server CLI as keys, e.g. ``host``, ``port``, ``token_file``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from dataclasses import dataclass, field, fields
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import yaml
26
+
27
+ DEFAULT_PORT = 50051
28
+ DEFAULT_HOST = "127.0.0.1"
29
+ CONFIG_ENV_VAR = "REMOTE_EXEC_CONFIG"
30
+ TOKEN_ENV_VAR = "REMOTE_EXEC_TOKEN"
31
+
32
+
33
+ class ConfigError(Exception):
34
+ pass
35
+
36
+
37
+ def default_client_config_path() -> Path:
38
+ if env := os.environ.get(CONFIG_ENV_VAR):
39
+ return Path(env).expanduser()
40
+ base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
41
+ return Path(base) / "remote-exec" / "config.yaml"
42
+
43
+
44
+ def validate_port(value: Any, where: str = "port") -> int:
45
+ if isinstance(value, bool):
46
+ raise ConfigError(f"{where}: invalid port {value!r}")
47
+ try:
48
+ port = int(value)
49
+ except (TypeError, ValueError):
50
+ raise ConfigError(f"{where}: invalid port {value!r}") from None
51
+ if not 1 <= port <= 65535:
52
+ raise ConfigError(f"{where}: port {port} out of range 1-65535")
53
+ return port
54
+
55
+
56
+ def split_host_port(value: str, default_port: int = DEFAULT_PORT) -> tuple[str, int]:
57
+ """Parse ``host``, ``host:port``, ``[v6]`` or ``[v6]:port``."""
58
+ value = value.strip()
59
+ if value.startswith("["):
60
+ host, sep, rest = value[1:].partition("]")
61
+ if not sep:
62
+ raise ConfigError(f"invalid address {value!r}")
63
+ if not rest:
64
+ return host, default_port
65
+ if not rest.startswith(":"):
66
+ raise ConfigError(f"invalid address {value!r}")
67
+ return host, validate_port(rest[1:], value)
68
+ if value.count(":") == 1:
69
+ host, port = value.split(":")
70
+ return host, validate_port(port, value)
71
+ return value, default_port # plain host or bare IPv6
72
+
73
+
74
+ def format_address(host: str, port: int) -> str:
75
+ return f"[{host}]:{port}" if ":" in host else f"{host}:{port}"
76
+
77
+
78
+ def _load_yaml(path: Path) -> dict:
79
+ try:
80
+ data = yaml.safe_load(path.read_text()) or {}
81
+ except OSError as exc:
82
+ raise ConfigError(f"cannot read config {path}: {exc.strerror or exc}") from exc
83
+ except yaml.YAMLError as exc:
84
+ raise ConfigError(f"invalid YAML in {path}: {exc}") from exc
85
+ if not isinstance(data, dict):
86
+ raise ConfigError(f"{path}: top level must be a mapping")
87
+ return data
88
+
89
+
90
+ # ---------------------------------------------------------------- client
91
+
92
+
93
+ @dataclass
94
+ class ServerEndpoint:
95
+ name: str
96
+ host: str
97
+ port: int = DEFAULT_PORT
98
+ description: str = ""
99
+ token: str | None = None
100
+ token_file: str | None = None
101
+ tls: bool = False
102
+ ca_cert: str | None = None
103
+ client_cert: str | None = None
104
+ client_key: str | None = None
105
+ tls_server_name: str | None = None
106
+ connect_timeout: float | None = None
107
+
108
+ @property
109
+ def address(self) -> str:
110
+ return format_address(self.host, self.port)
111
+
112
+ @property
113
+ def uses_tls(self) -> bool:
114
+ return self.tls or bool(self.ca_cert or self.client_cert)
115
+
116
+
117
+ _ENDPOINT_KEYS = {f.name for f in fields(ServerEndpoint)} - {"name"}
118
+
119
+
120
+ def _make_endpoint(name: str, raw: Any, defaults: dict, where: str) -> ServerEndpoint:
121
+ if not isinstance(raw, dict):
122
+ raise ConfigError(f"{where}: server entry must be a mapping")
123
+ if "token" in raw or "token_file" in raw:
124
+ # A server's own credential replaces the default one entirely; otherwise a
125
+ # default `token` would win over the server's `token_file`.
126
+ defaults = {k: v for k, v in defaults.items() if k not in ("token", "token_file")}
127
+ merged = {**defaults, **raw}
128
+ unknown = set(merged) - _ENDPOINT_KEYS
129
+ if unknown:
130
+ raise ConfigError(f"{where}: unknown key(s): {', '.join(sorted(unknown))}")
131
+ if not merged.get("host"):
132
+ raise ConfigError(f"{where}: 'host' is required")
133
+ merged["host"] = str(merged["host"])
134
+ merged["port"] = validate_port(merged.get("port", DEFAULT_PORT), f"{where}.port")
135
+ for key in ("token_file", "ca_cert", "client_cert", "client_key"):
136
+ if merged.get(key):
137
+ merged[key] = os.path.expanduser(str(merged[key]))
138
+ return ServerEndpoint(name=name, **merged)
139
+
140
+
141
+ @dataclass
142
+ class ClientConfig:
143
+ servers: dict[str, ServerEndpoint] = field(default_factory=dict)
144
+ defaults: dict = field(default_factory=dict)
145
+ path: Path | None = None
146
+
147
+ def resolve(self, target: str) -> ServerEndpoint:
148
+ """Look ``target`` up by name; otherwise treat it as ``host[:port]``."""
149
+ if target in self.servers:
150
+ return self.servers[target]
151
+ if not target:
152
+ raise ConfigError("empty server name")
153
+ host, port = split_host_port(target)
154
+ defaults = {k: v for k, v in self.defaults.items() if k != "port"}
155
+ return _make_endpoint(target, {**defaults, "host": host, "port": port}, {}, f"server {target!r}")
156
+
157
+
158
+ def load_client_config(path: str | os.PathLike | None = None) -> ClientConfig:
159
+ """Load the client config. A missing *default* config is not an error."""
160
+ explicit = path is not None
161
+ cfg_path = Path(path).expanduser() if explicit else default_client_config_path()
162
+ if not cfg_path.exists():
163
+ if explicit or os.environ.get(CONFIG_ENV_VAR):
164
+ raise ConfigError(f"config file not found: {cfg_path}")
165
+ return ClientConfig(path=None)
166
+
167
+ data = _load_yaml(cfg_path)
168
+ unknown = set(data) - {"servers", "defaults"}
169
+ if unknown:
170
+ raise ConfigError(f"{cfg_path}: unknown top-level key(s): {', '.join(sorted(unknown))}")
171
+ defaults = data.get("defaults") or {}
172
+ servers_raw = data.get("servers") or {}
173
+ if not isinstance(defaults, dict) or not isinstance(servers_raw, dict):
174
+ raise ConfigError(f"{cfg_path}: 'defaults' and 'servers' must be mappings")
175
+ servers = {
176
+ str(name): _make_endpoint(str(name), raw, defaults, f"{cfg_path}: servers.{name}")
177
+ for name, raw in servers_raw.items()
178
+ }
179
+ return ClientConfig(servers=servers, defaults=defaults, path=cfg_path)
180
+
181
+
182
+ # ---------------------------------------------------------------- server
183
+
184
+
185
+ SERVER_CONFIG_KEYS = {
186
+ "host", "port", "listen", "token_file", "tls_cert", "tls_key", "tls_client_ca",
187
+ "max_concurrent", "max_timeout", "shell", "kill_grace", "log_level", "log_file",
188
+ "insecure_no_auth", "allow_root", "tools",
189
+ "command_log", "command_log_max_mb", "command_log_keep", "no_command_log",
190
+ }
191
+
192
+
193
+ def load_server_config(path: str | os.PathLike) -> dict[str, Any]:
194
+ cfg_path = Path(path).expanduser()
195
+ data = _load_yaml(cfg_path)
196
+ data = {k.replace("-", "_"): v for k, v in data.items()}
197
+ unknown = set(data) - SERVER_CONFIG_KEYS
198
+ if unknown:
199
+ raise ConfigError(f"{cfg_path}: unknown key(s): {', '.join(sorted(unknown))}")
200
+ if "port" in data:
201
+ data["port"] = validate_port(data["port"], f"{cfg_path}: port")
202
+ return data
@@ -0,0 +1,287 @@
1
+ """Subprocess execution with streaming output and process-group cleanup.
2
+
3
+ This module knows nothing about gRPC. It turns a :class:`CommandSpec` into an
4
+ async stream of :class:`ExecEvent` objects, which keeps it reusable for future
5
+ APIs (e.g. Submit/GetStatus/GetResult backed by a task registry).
6
+
7
+ Process model
8
+ -------------
9
+ Every command runs as ``<shell> -c <command>`` in a *new session*
10
+ (``start_new_session=True``), so the shell and everything it spawns share a
11
+ process group whose id equals the shell's pid. Cleanup always targets the whole
12
+ group (SIGTERM, then SIGKILL after a grace period) so that tools like Vivado do
13
+ not leave orphaned children behind. This happens when:
14
+
15
+ * the command times out,
16
+ * the consumer stops iterating (client disconnect / cancellation),
17
+ * the main process exits but left background processes in its group.
18
+
19
+ A process that deliberately escapes the group (``setsid``/double fork) cannot be
20
+ tracked this way; under systemd the unit's cgroup still covers it on stop.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import enum
27
+ import logging
28
+ import os
29
+ import signal
30
+ import uuid
31
+ from collections.abc import AsyncIterator, Mapping
32
+ from dataclasses import dataclass, field
33
+ from datetime import datetime, timezone
34
+
35
+ log = logging.getLogger(__name__)
36
+
37
+ READ_CHUNK_SIZE = 64 * 1024
38
+ # Bounded so a slow consumer applies back-pressure all the way to the process
39
+ # (its pipe fills up and it blocks), instead of buffering unbounded output.
40
+ QUEUE_MAX_CHUNKS = 64
41
+ DEFAULT_KILL_GRACE = 5.0
42
+ # After the main process exited (and its group was cleaned up) wait at most
43
+ # this long for the pipes to reach EOF. Only matters if an escaped process
44
+ # still holds the pipes open.
45
+ PIPE_DRAIN_TIMEOUT = 5.0
46
+
47
+
48
+ class Stream(enum.Enum):
49
+ STDOUT = "stdout"
50
+ STDERR = "stderr"
51
+
52
+
53
+ class Status(enum.Enum):
54
+ EXITED = "exited"
55
+ TIMEOUT = "timeout"
56
+ ERROR = "error"
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class CommandSpec:
61
+ command: str
62
+ cwd: str | None = None
63
+ env: Mapping[str, str] = field(default_factory=dict)
64
+ timeout: float | None = None
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Started:
69
+ execution_id: str
70
+ pid: int
71
+ start_time: datetime
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Output:
76
+ stream: Stream
77
+ data: bytes
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class Finished:
82
+ execution_id: str
83
+ status: Status
84
+ exit_code: int
85
+ signal: int
86
+ start_time: datetime
87
+ end_time: datetime
88
+ message: str = ""
89
+
90
+
91
+ ExecEvent = Started | Output | Finished
92
+
93
+
94
+ def utcnow() -> datetime:
95
+ return datetime.now(timezone.utc)
96
+
97
+
98
+ def _signal_process_group(pgid: int, sig: int) -> bool:
99
+ """Send ``sig`` to a process group. Returns False if the group is gone."""
100
+ try:
101
+ os.killpg(pgid, sig)
102
+ return True
103
+ except ProcessLookupError:
104
+ return False
105
+ except PermissionError:
106
+ # Should not happen for our own children; treat as gone rather than crash.
107
+ log.warning("permission denied signalling process group %d", pgid)
108
+ return False
109
+
110
+
111
+ async def terminate_process_group(pgid: int, grace: float = DEFAULT_KILL_GRACE) -> None:
112
+ """SIGTERM the group, wait up to ``grace`` seconds, then SIGKILL it."""
113
+ if not _signal_process_group(pgid, signal.SIGTERM):
114
+ return
115
+ loop = asyncio.get_running_loop()
116
+ deadline = loop.time() + grace
117
+ while loop.time() < deadline:
118
+ await asyncio.sleep(0.05)
119
+ if not _signal_process_group(pgid, 0):
120
+ return
121
+ log.info("process group %d survived SIGTERM for %.1fs, sending SIGKILL", pgid, grace)
122
+ _signal_process_group(pgid, signal.SIGKILL)
123
+
124
+
125
+ class Executor:
126
+ """Runs commands; tracks background cleanup tasks so shutdown can await them."""
127
+
128
+ def __init__(self, shell: str = "/bin/sh", kill_grace: float = DEFAULT_KILL_GRACE):
129
+ self.shell = shell
130
+ self.kill_grace = kill_grace
131
+ self._cleanup_tasks: set[asyncio.Task] = set()
132
+ self._running_pgids: set[int] = set()
133
+
134
+ @property
135
+ def running_count(self) -> int:
136
+ return len(self._running_pgids)
137
+
138
+ def build_env(self, extra: Mapping[str, str]) -> dict[str, str]:
139
+ env = dict(os.environ)
140
+ env.update(extra)
141
+ return env
142
+
143
+ async def run(self, spec: CommandSpec, execution_id: str | None = None) -> AsyncIterator[ExecEvent]:
144
+ """Run ``spec`` and yield events. Always ends with exactly one Finished.
145
+
146
+ If the consumer stops early (``aclose()`` or task cancellation) the
147
+ process group is terminated in the background.
148
+ """
149
+ execution_id = execution_id or uuid.uuid4().hex
150
+ loop = asyncio.get_running_loop()
151
+ start_time = utcnow()
152
+
153
+ try:
154
+ proc = await asyncio.create_subprocess_exec(
155
+ self.shell,
156
+ "-c",
157
+ spec.command,
158
+ stdin=asyncio.subprocess.DEVNULL,
159
+ stdout=asyncio.subprocess.PIPE,
160
+ stderr=asyncio.subprocess.PIPE,
161
+ cwd=spec.cwd or None,
162
+ env=self.build_env(spec.env),
163
+ start_new_session=True,
164
+ )
165
+ except OSError as exc:
166
+ where = f" (cwd={spec.cwd})" if spec.cwd else ""
167
+ yield Finished(
168
+ execution_id, Status.ERROR, -1, 0, start_time, utcnow(),
169
+ f"failed to start command{where}: {exc.strerror or exc}",
170
+ )
171
+ return
172
+
173
+ pgid = proc.pid # session leader => pgid == pid
174
+ self._running_pgids.add(pgid)
175
+ queue: asyncio.Queue[tuple[Stream, bytes | None]] = asyncio.Queue(QUEUE_MAX_CHUNKS)
176
+
177
+ async def pump(reader: asyncio.StreamReader, stream: Stream) -> None:
178
+ try:
179
+ while chunk := await reader.read(READ_CHUNK_SIZE):
180
+ await queue.put((stream, chunk))
181
+ finally:
182
+ # put_nowait could fail on a full queue; the consumer is gone
183
+ # in that case anyway (we are being cancelled).
184
+ try:
185
+ queue.put_nowait((stream, None))
186
+ except asyncio.QueueFull:
187
+ pass
188
+
189
+ async def wait_and_reap() -> int:
190
+ rc = await proc.wait()
191
+ # Kill whatever the command left behind in its process group.
192
+ await terminate_process_group(pgid, self.kill_grace)
193
+ return rc
194
+
195
+ timed_out = False
196
+ killer: asyncio.Task | None = None
197
+
198
+ def on_timeout() -> None:
199
+ nonlocal timed_out, killer
200
+ timed_out = True
201
+ log.info("execution %s timed out after %ss, terminating pgid %d", execution_id, spec.timeout, pgid)
202
+ killer = asyncio.ensure_future(terminate_process_group(pgid, self.kill_grace))
203
+
204
+ pumps = [
205
+ asyncio.ensure_future(pump(proc.stdout, Stream.STDOUT)),
206
+ asyncio.ensure_future(pump(proc.stderr, Stream.STDERR)),
207
+ ]
208
+ waiter = asyncio.ensure_future(wait_and_reap())
209
+ timer = loop.call_later(spec.timeout, on_timeout) if spec.timeout else None
210
+ get_task: asyncio.Future | None = None
211
+ finished = False
212
+
213
+ try:
214
+ yield Started(execution_id, proc.pid, start_time)
215
+
216
+ open_streams = 2
217
+ drain_deadline: float | None = None
218
+ while open_streams:
219
+ wait_timeout = None
220
+ if waiter.done():
221
+ if drain_deadline is None:
222
+ drain_deadline = loop.time() + PIPE_DRAIN_TIMEOUT
223
+ wait_timeout = drain_deadline - loop.time()
224
+ if wait_timeout <= 0:
225
+ log.warning("execution %s: pipes still open after process exit; giving up", execution_id)
226
+ break
227
+ if get_task is None:
228
+ get_task = asyncio.ensure_future(queue.get())
229
+ waitables = {get_task} if waiter.done() else {get_task, waiter}
230
+ done, _ = await asyncio.wait(waitables, timeout=wait_timeout, return_when=asyncio.FIRST_COMPLETED)
231
+ if get_task in done:
232
+ stream, data = get_task.result()
233
+ get_task = None
234
+ if data is None:
235
+ open_streams -= 1
236
+ else:
237
+ yield Output(stream, data)
238
+
239
+ returncode = await waiter
240
+ if timer:
241
+ timer.cancel()
242
+ if killer:
243
+ await killer
244
+ sig = -returncode if returncode < 0 else 0
245
+ exit_code = 128 + sig if sig else returncode
246
+ if timed_out:
247
+ status, message = Status.TIMEOUT, f"command timed out after {spec.timeout:g}s"
248
+ else:
249
+ status, message = Status.EXITED, ""
250
+ finished = True
251
+ yield Finished(execution_id, status, exit_code, sig, start_time, utcnow(), message)
252
+ finally:
253
+ if timer:
254
+ timer.cancel()
255
+ if get_task is not None:
256
+ get_task.cancel()
257
+ for task in pumps:
258
+ task.cancel()
259
+ if not finished:
260
+ # Consumer went away (client disconnect / cancel). Must not
261
+ # await here: we may be running inside a cancelled task.
262
+ log.info("execution %s aborted, terminating pgid %d", execution_id, pgid)
263
+ waiter.cancel()
264
+ _signal_process_group(pgid, signal.SIGTERM)
265
+ self._spawn_cleanup(proc, pgid)
266
+ self._running_pgids.discard(pgid)
267
+
268
+ def _spawn_cleanup(self, proc: asyncio.subprocess.Process, pgid: int) -> None:
269
+ async def cleanup() -> None:
270
+ await terminate_process_group(pgid, self.kill_grace)
271
+ try:
272
+ await asyncio.wait_for(proc.wait(), self.kill_grace)
273
+ except asyncio.TimeoutError:
274
+ log.warning("process %d did not exit after SIGKILL", proc.pid)
275
+
276
+ task = asyncio.ensure_future(cleanup())
277
+ self._cleanup_tasks.add(task)
278
+ task.add_done_callback(self._cleanup_tasks.discard)
279
+
280
+ def kill_all(self) -> None:
281
+ """Synchronously SIGKILL all running process groups (last resort)."""
282
+ for pgid in list(self._running_pgids):
283
+ _signal_process_group(pgid, signal.SIGKILL)
284
+
285
+ async def wait_cleanups(self, timeout: float | None = None) -> None:
286
+ if self._cleanup_tasks:
287
+ await asyncio.wait(set(self._cleanup_tasks), timeout=timeout)