|
| 1 | +import codecs |
1 | 2 | import io |
2 | 3 | import logging |
| 4 | +import os |
| 5 | +import selectors |
3 | 6 | import shlex |
4 | 7 | import subprocess |
5 | 8 | import tarfile |
|
10 | 13 | from dataclasses import dataclass |
11 | 14 | from pathlib import Path |
12 | 15 | from shutil import which |
| 16 | +from typing import Literal |
13 | 17 |
|
14 | 18 | from app.app_configs import ( |
15 | 19 | PYTHON_EXECUTOR_DOCKER_BIN, |
|
20 | 24 | BaseExecutor, |
21 | 25 | EntryKind, |
22 | 26 | ExecutionResult, |
| 27 | + StreamChunk, |
| 28 | + StreamEvent, |
| 29 | + StreamResult, |
23 | 30 | WorkspaceEntry, |
24 | 31 | wrap_last_line_interactive, |
25 | 32 | ) |
@@ -406,3 +413,159 @@ def execute_python( |
406 | 413 | duration_ms=duration_ms, |
407 | 414 | files=workspace_snapshot, |
408 | 415 | ) |
| 416 | + |
| 417 | + def _terminate_process(self, ctx: _ExecContext, timed_out: bool) -> None: |
| 418 | + """Kill the process on timeout or wait for normal exit.""" |
| 419 | + if timed_out: |
| 420 | + subprocess.run( |
| 421 | + [ |
| 422 | + self.docker_binary, |
| 423 | + "exec", |
| 424 | + ctx.container_name, |
| 425 | + "pkill", |
| 426 | + "-9", |
| 427 | + "python", |
| 428 | + ], |
| 429 | + capture_output=True, |
| 430 | + ) |
| 431 | + ctx.proc.kill() |
| 432 | + ctx.proc.wait() |
| 433 | + |
| 434 | + def execute_python_streaming( |
| 435 | + self, |
| 436 | + *, |
| 437 | + code: str, |
| 438 | + stdin: str | None, |
| 439 | + timeout_ms: int, |
| 440 | + max_output_bytes: int, |
| 441 | + cpu_time_limit_sec: int | None = None, |
| 442 | + memory_limit_mb: int | None = None, |
| 443 | + files: Sequence[tuple[str, bytes]] | None = None, |
| 444 | + last_line_interactive: bool = True, |
| 445 | + ) -> Generator[StreamEvent, None, None]: |
| 446 | + """Execute Python code and yield output chunks as they arrive via SSE. |
| 447 | +
|
| 448 | + Yields StreamChunk events during execution, then a single StreamResult |
| 449 | + at the end containing exit_code, timing, and workspace files. |
| 450 | + """ |
| 451 | + with self._run_in_container( |
| 452 | + code=code, |
| 453 | + cpu_time_limit_sec=cpu_time_limit_sec, |
| 454 | + memory_limit_mb=memory_limit_mb, |
| 455 | + timeout_ms=timeout_ms, |
| 456 | + files=files, |
| 457 | + last_line_interactive=last_line_interactive, |
| 458 | + ) as ctx: |
| 459 | + _write_stdin(ctx.proc, stdin) |
| 460 | + |
| 461 | + deadline = time.monotonic() + (timeout_ms / 1000.0) |
| 462 | + timed_out = yield from _stream_process_output(ctx.proc, deadline, max_output_bytes) |
| 463 | + |
| 464 | + self._terminate_process(ctx, timed_out) |
| 465 | + workspace_snapshot = self._extract_workspace_snapshot(ctx.container_name) |
| 466 | + |
| 467 | + duration_ms = int((time.perf_counter() - ctx.start) * 1000) |
| 468 | + exit_code = None if timed_out else ctx.proc.returncode |
| 469 | + |
| 470 | + yield StreamResult( |
| 471 | + exit_code=exit_code, |
| 472 | + timed_out=timed_out, |
| 473 | + duration_ms=duration_ms, |
| 474 | + files=workspace_snapshot, |
| 475 | + ) |
| 476 | + |
| 477 | + |
| 478 | +def _write_stdin(proc: subprocess.Popen[bytes], stdin: str | None) -> None: |
| 479 | + """Write optional stdin data and close the pipe.""" |
| 480 | + if proc.stdin is None: |
| 481 | + raise RuntimeError("Failed to open subprocess stdin pipe") |
| 482 | + if stdin is not None: |
| 483 | + proc.stdin.write(stdin.encode("utf-8")) |
| 484 | + proc.stdin.close() |
| 485 | + |
| 486 | + |
| 487 | +class _StreamTracker: |
| 488 | + """Per-stream state for incremental decoding with truncation.""" |
| 489 | + |
| 490 | + __slots__ = ("stream", "decoder", "bytes_sent", "max_bytes") |
| 491 | + |
| 492 | + def __init__(self, stream: Literal["stdout", "stderr"], max_bytes: int) -> None: |
| 493 | + self.stream = stream |
| 494 | + self.decoder = codecs.getincrementaldecoder("utf-8")("replace") |
| 495 | + self.bytes_sent = 0 |
| 496 | + self.max_bytes = max_bytes |
| 497 | + |
| 498 | + def decode_chunk(self, data: bytes) -> StreamChunk | None: |
| 499 | + """Decode a raw chunk and return a ``StreamChunk`` if within limits.""" |
| 500 | + chunk: StreamChunk | None = None |
| 501 | + if self.bytes_sent < self.max_bytes: |
| 502 | + allowed = self.max_bytes - self.bytes_sent |
| 503 | + text = self.decoder.decode(data[:allowed], False) |
| 504 | + if text: |
| 505 | + chunk = StreamChunk(stream=self.stream, data=text) |
| 506 | + self.bytes_sent += len(data) |
| 507 | + return chunk |
| 508 | + |
| 509 | + def flush(self) -> StreamChunk | None: |
| 510 | + """Flush the decoder and return a final chunk if any bytes remain.""" |
| 511 | + text = self.decoder.decode(b"", True) |
| 512 | + if text: |
| 513 | + return StreamChunk(stream=self.stream, data=text) |
| 514 | + return None |
| 515 | + |
| 516 | + |
| 517 | +def _stream_process_output( |
| 518 | + proc: subprocess.Popen[bytes], |
| 519 | + deadline: float, |
| 520 | + max_output_bytes: int, |
| 521 | +) -> Generator[StreamChunk, None, bool]: |
| 522 | + """Read stdout/stderr incrementally and yield ``StreamChunk`` events. |
| 523 | +
|
| 524 | + Returns ``True`` if the process timed out, ``False`` otherwise. |
| 525 | + """ |
| 526 | + if proc.stdout is None or proc.stderr is None: |
| 527 | + raise RuntimeError("Failed to open subprocess output pipes") |
| 528 | + |
| 529 | + sel = selectors.DefaultSelector() |
| 530 | + sel.register(proc.stdout, selectors.EVENT_READ, "stdout") |
| 531 | + sel.register(proc.stderr, selectors.EVENT_READ, "stderr") |
| 532 | + |
| 533 | + trackers: dict[str, _StreamTracker] = { |
| 534 | + "stdout": _StreamTracker("stdout", max_output_bytes), |
| 535 | + "stderr": _StreamTracker("stderr", max_output_bytes), |
| 536 | + } |
| 537 | + fds: dict[str, int] = { |
| 538 | + "stdout": proc.stdout.fileno(), |
| 539 | + "stderr": proc.stderr.fileno(), |
| 540 | + } |
| 541 | + timed_out = False |
| 542 | + chunk_size = 4096 |
| 543 | + |
| 544 | + try: |
| 545 | + while sel.get_map(): |
| 546 | + remaining = deadline - time.monotonic() |
| 547 | + if remaining <= 0: |
| 548 | + timed_out = True |
| 549 | + break |
| 550 | + |
| 551 | + events = sel.select(timeout=min(remaining, 5.0)) |
| 552 | + |
| 553 | + for key, _ in events: |
| 554 | + stream_name: str = key.data |
| 555 | + data = os.read(fds[stream_name], chunk_size) |
| 556 | + if not data: |
| 557 | + sel.unregister(key.fileobj) |
| 558 | + continue |
| 559 | + |
| 560 | + chunk = trackers[stream_name].decode_chunk(data) |
| 561 | + if chunk is not None: |
| 562 | + yield chunk |
| 563 | + finally: |
| 564 | + sel.close() |
| 565 | + |
| 566 | + for tracker in trackers.values(): |
| 567 | + chunk = tracker.flush() |
| 568 | + if chunk is not None: |
| 569 | + yield chunk |
| 570 | + |
| 571 | + return timed_out |
0 commit comments