mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-11 04:00:38 +00:00
fix(code-runtime-python): confirm group death at the deadline; chunk the frame read
The reap-poll deadline arm sent SIGKILL then finalized immediately, declaring quiescence on mere signal delivery while the group was still dying. It now keeps polling for the group to actually empty (bounded by one more reap margin) after its self-sent SIGKILL, so `finished` resolves only on a confirmed-empty group. ProtocolChannel.read_frame read the boot/run handshake frames through FileIO.readline() on the unbuffered fd — one os.read(1) per byte, so a multi-megabyte program burned CPU (RLIMIT_CPU already in force for the run frame) in millions of syscalls before ast.parse. It now reads in chunks into the same _pending buffer the async reader uses; the wrapping os.fdopen is gone. read_frame is this PR's own code (e7f22ed3), not the protocol layer. The chunked read is a syscall-count improvement with no cross-platform-deterministic failure to assert, noted as such in the Agent Note.
This commit is contained in:
@@ -354,12 +354,12 @@ class ProtocolChannel:
|
||||
"""
|
||||
|
||||
def __init__(self, fd: int) -> None:
|
||||
# Unbuffered binary I/O so we never lose frames to an idle flush.
|
||||
self._reader = os.fdopen(fd, "rb", buffering=0, closefd=False)
|
||||
self._fd = fd
|
||||
# Residual bytes read past a frame's newline. Held here, not in the
|
||||
# reading coroutine: the reply pump is cancelled once `done` is posted,
|
||||
# and read-ahead sitting in a local would be lost with it.
|
||||
# Residual bytes read past a frame's newline, shared by the blocking and
|
||||
# async readers. Held here, not in the reading coroutine: the reply pump
|
||||
# is cancelled once `done` is posted, and read-ahead sitting in a local
|
||||
# would be lost with it. Both readers use `os.read(self._fd, ...)`
|
||||
# directly, so no buffered file object wraps the fd.
|
||||
self._pending = bytearray()
|
||||
# Serializes writers: os.write releases the GIL, and a frame larger
|
||||
# than PIPE_BUF is neither atomic nor guaranteed fully consumed by one
|
||||
@@ -374,12 +374,28 @@ class ProtocolChannel:
|
||||
(``boot`` and ``run``), where blocking is what the handshake wants. Reply
|
||||
frames arriving during the program go through :meth:`read_frame_async`,
|
||||
which must not occupy a thread.
|
||||
|
||||
Reads in CHUNKS into the shared ``_pending`` buffer rather than through
|
||||
``FileIO.readline()``: the fd is unbuffered (``buffering=0``), so
|
||||
``readline`` issues one ``os.read(1)`` per byte, and a multi-megabyte
|
||||
``run`` frame — RLIMIT_CPU already in force by then — would burn the
|
||||
budget in millions of syscalls before ``ast.parse`` even runs. The chunk
|
||||
reads and the same residual buffer the async path uses keep read-ahead
|
||||
past a newline for the next frame.
|
||||
"""
|
||||
|
||||
line = self._reader.readline()
|
||||
if not line:
|
||||
return None
|
||||
return _decode_json_plain(line.decode("utf-8"))
|
||||
while True:
|
||||
newline = self._pending.find(b"\n")
|
||||
if newline >= 0:
|
||||
line = bytes(self._pending[:newline])
|
||||
del self._pending[: newline + 1]
|
||||
return _decode_json_plain(line.decode("utf-8"))
|
||||
chunk = os.read(self._fd, _READ_CHUNK_BYTES)
|
||||
if not chunk:
|
||||
# EOF before a newline: drop the partial line, as the host drops
|
||||
# a frame that never completed.
|
||||
return None
|
||||
self._pending.extend(chunk)
|
||||
|
||||
async def read_frame_async(self) -> dict[str, Any] | None:
|
||||
"""Await one JSON-line frame without occupying a thread. ``None`` on EOF.
|
||||
|
||||
@@ -1095,6 +1095,12 @@ export class PythonCodeRuntime extends CodeRuntime {
|
||||
return
|
||||
}
|
||||
const deadline = Date.now() + this.config.graceMs + CLOSE_REAP_MARGIN_MS
|
||||
// Once the deadline forces us to send SIGKILL ourselves, allow one more
|
||||
// reap window for the kernel to tear the group down before giving up:
|
||||
// SIGKILL is asynchronous, so the group is not gone the instant it is
|
||||
// sent. `finalize` only runs on a confirmed-empty group, except at this
|
||||
// final hard bound where nothing more can be done.
|
||||
let hardDeadline = 0
|
||||
const pollGroup = (): void => {
|
||||
if (groupEmpty()) {
|
||||
// The group is gone; the grace SIGKILL is moot. Cancel it (it may not
|
||||
@@ -1104,15 +1110,23 @@ export class PythonCodeRuntime extends CodeRuntime {
|
||||
finalize()
|
||||
return
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
if (hardDeadline === 0 && Date.now() >= deadline) {
|
||||
// Deadline reached with the group still non-empty. This is reachable
|
||||
// when the host event loop was blocked past both timers: Node runs
|
||||
// this poll before the grace SIGKILL timer, so that SIGKILL may never
|
||||
// have fired. Send it HERE before finalizing — idempotent if the timer
|
||||
// already ran — so a SIGTERM-ignoring same-group survivor is actually
|
||||
// reaped rather than released by cancelling an unfired escalation.
|
||||
// have fired. Send it HERE (idempotent if the timer already ran) and
|
||||
// keep polling for the group to actually empty — finalizing on mere
|
||||
// signal delivery would declare quiescence while the group is still
|
||||
// dying. Bound the extra wait by one more reap margin.
|
||||
killGroup('SIGKILL')
|
||||
clearTimeout(graceTimer)
|
||||
hardDeadline = Date.now() + CLOSE_REAP_MARGIN_MS
|
||||
}
|
||||
// Hard bound: only reached if the self-sent SIGKILL never empties the
|
||||
// reachable group (a kernel that never reports ESRCH), which does not
|
||||
// happen in practice — hence the ignore on the branch below.
|
||||
/* v8 ignore next 4 -- SIGKILL empties the reachable group within the reap margin. */
|
||||
if (hardDeadline !== 0 && Date.now() >= hardDeadline) {
|
||||
finalize()
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user