fix(code-runtime-python): clear stale SIGKILL timer and clamp inherited soft rlimit

Two further review findings on the CPython backend:
- The grace-window SIGKILL timer was left armed after settlement, so on a
  normal completion a kill(-pid) could fire up to graceMs later and strike a
  recycled pgid once the kernel reused the leader's pid. settle() now clears
  the timer the moment the process group is confirmed empty (the normal path
  and when the poll sees the survivor gone), bounding the reuse window to the
  genuine-survivor case where the group cannot be empty to reuse.
- _clamped bounded rlimits by the inherited hard limit only, silently raising
  an inherited soft limit stricter than the request (loosening RLIMIT_AS or
  deferring RLIMIT_CPU SIGXCPU). It now clamps each side against its own
  inherited counterpart and pins soft under hard, keeping the strictest of
  configured and inherited. Adds an inherited-soft-limit regression test.

Agent Note expanded to seven fixes with the two new rejected alternatives;
zh pair re-recorded.
This commit is contained in:
Chinesezjc
2026-08-31 14:21:19 +08:00
committed by Tianyi Cui
parent 6cb70e6e69
commit ff604dc876
6 changed files with 84 additions and 26 deletions
@@ -1064,24 +1064,27 @@ export class PythonCodeRuntime extends CodeRuntime {
}
resolve({ ...result, logs })
// `finished` is what teardown awaits to honor "no subprocess outlives the
// fiber". When no escalation ran (normal completion, no kill) or the group
// is already empty, resolve it now. Otherwise a same-group descendant that
// ignored SIGTERM but released the pipes is still alive here (its `close`
// is what got us to settle); withhold `finished` until the grace-window
// SIGKILL has emptied the group. The poll timers are REF'd on purpose: a
// short-lived host (a one-shot headless run, a config subprocess) would
// otherwise exit before the unref'd SIGKILL timer fired, reparenting the
// survivor to init — the leak this await exists to prevent. The wait is
// bounded by the same graceMs + margin the SIGKILL escalation uses, so a
// truly unreapable process (it cannot be, since it is in the group
// `kill(-pid)` reaches) could not hang disposal.
// fiber". When no escalation ran (normal completion, no kill) or the
// group is already empty, cancel the pending SIGKILL and resolve now.
// Clearing it is what bounds the PID-reuse hazard: an armed `kill(-pid)`
// left to fire up to graceMs later could hit a RECYCLED pgid once the
// kernel reused the leader's pid, SIGKILLing an unrelated group. So the
// timer stays armed only while a real survivor exists — a same-group
// descendant that ignored SIGTERM but released the pipes, still alive
// here because its `close` is what got us to settle. In that case
// withhold `finished` and poll the group on REF'd timers (a short-lived
// host would otherwise exit before the unref'd SIGKILL fired, reparenting
// the survivor to init), clearing the timer the moment the group empties;
// the wait is bounded by the same graceMs + margin the escalation uses.
if (!killing || groupEmpty()) {
if (graceTimer !== undefined) clearTimeout(graceTimer)
finishResolve()
return
}
const deadline = Date.now() + this.config.graceMs + CLOSE_REAP_MARGIN_MS
const pollGroup = (): void => {
if (groupEmpty() || Date.now() >= deadline) {
if (graceTimer !== undefined) clearTimeout(graceTimer)
finishResolve()
return
}