Skip to content

fix(modal): cancel the command's process group, not the sandbox - #155

Closed
exiao wants to merge 11 commits into
live-configfrom
fix/modal-cancel-process-group
Closed

fix(modal): cancel the command's process group, not the sandbox#155
exiao wants to merge 11 commits into
live-configfrom
fix/modal-cancel-process-group

Conversation

@exiao

@exiao exiao commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Replaces #151. Same symptom, one-tenth the machinery, because the premise #151 was built on turns out to be false.

Symptom

Every Modal-backed worker lane (dev, code-reviewer, pr-babysitter-modal) wedged mid-session: every shell command returned exit 1 with empty output, including true and pwd. Workers blocked their cards asking a human to "restore the terminal backend." 29 of the 64 kanban blocks since the 2026-07-24 Modal migration carry this signature.

Root cause

_run_bash's cancel_fn called sandbox.terminate() — tearing down the entire sandbox to stop one command. _ThreadedProcessHandle.kill() (base.py:364) fires cancel_fn on any command timeout, and a slow patch write or test run is enough. Nothing recreated the sandbox, so every later sandbox.exec hit a terminated one and surfaced as exit 1 with an empty pipe, permanently.

The Modal SDK's ContainerProcess exposes only poll/wait/stdout/stderr with no kill, so the original code reached for the only cancel the sandbox object offered.

The fix

Don't destroy the sandbox to stop a command. The sandbox is a Linux box.

Run each command under setsid so it leads its own process group, record that group id in a /tmp pid file, and have cancel() signal -<pgid>: SIGTERM, then SIGKILL after a 5s grace period. That reaches the command and every descendant it spawned — the actual goal, since the runaway is typically a grandchild like an unbounded find — and leaves the sandbox running.

Exit status, stdout, and stderr pass through unchanged. If setsid is missing, the wrapper falls back to a plain background job: cancellation then reaches only the command itself, still better than destroying the sandbox.

Why not #151

#151 accepted "cancel must destroy the sandbox" as a constraint and built recovery machinery underneath it: liveness polling, generation counters, an RLock plus a _respawning reentrancy flag, sync-state reset, deletion tombstones retained across respawns, and rm-batching for the resulting oversized replay. 383 lines across 4 files, 9 commits, 22 review rounds, and 3 P2 threads still open (concurrent-respawn races, per-batch timeouts, failed deletion batches).

All of that complexity exists to survive a wound we inflict on ourselves. Nobody checked whether the wound was necessary. It isn't.

The one genuinely independent bug #151 surfaced — a dead sandbox reaching the agent as a bare exit 1 rather than a real error — already shipped separately as #152.

tools/environments/file_sync.py is untouched here; its #151 changes existed only to support respawning.

Verification — live, against real Modal

The same script drives the real wedge (baseline command → cancel a running command → command again) on both revisions.

BEFORE (origin/live-config):

STEP1 rc=0 'BASELINE\n'
STEP2 cancelled rc=137
STEP3 after cancel rc=1 ''          <- WEDGED

AFTER (this branch):

STEP1 baseline rc=0 'BASELINE\ngit version 2.47.3\n'
sleeps before cancel: 4
STEP2 cancelled rc=143
survivors after cancel: 0           <- descendants reaped too
STEP3 after cancel rc=0 'STILL_WORKS\ngit version 2.47.3\n'
sandbox.poll() = None               <- sandbox alive
exit-code fidelity rc=42
stdout/stderr rc=3 'o\n\ne\n'
login rc=0 'LOGIN\n'

Also verified directly against the Modal SDK: a process-group kill of a command whose child sleep outlives its parent reaps every descendant (survivors: 0) while sandbox.poll() stays None and later execs return 0. A command that traps and ignores SIGTERM is still killed by the escalation.

Unit: 11 new tests in tests/tools/test_modal_cancel_process_group.py. 90 passed across the modal + file_sync suites on 3 consecutive runs (no flake, random order). 112 passed across the terminal/env suites. ruff clean.

Diff

  • tools/environments/modal.py_wrap_for_group_cancel() + process-group cancel()
  • tests/tools/test_modal_cancel_process_group.py — new

Patch note: ~/.hermes/plans/hermes-patches/modal-cancel-process-group.md

_run_bash's cancel_fn called sandbox.terminate(), so any command timeout
destroyed the whole sandbox. _ThreadedProcessHandle.kill() fires cancel_fn
on ANY timeout, and nothing recreated the sandbox, so every later exec hit
a terminated sandbox and returned exit 1 with empty output for the rest of
the session. Modal-backed worker lanes responded by blocking their cards
asking a human to restore the terminal: 29 of 64 kanban blocks since the
2026-07-24 migration carry this signature.

The Modal SDK's ContainerProcess exposes no kill, but the sandbox is a
Linux box: run each command under setsid so it leads its own process
group, record the group id in a /tmp pid file, and signal -<pgid> on
cancel (SIGTERM, then SIGKILL after a grace period). That reaches the
command and every descendant it spawned, which is the actual goal since
the runaway is usually a grandchild, and leaves the sandbox running.

Exit status, stdout, and stderr pass through unchanged. Falls back to a
plain background job when setsid is absent.

Verified live against real Modal on both revisions: before, the third
command returns rc=1 with empty output; after, the cancelled command
returns 143, zero descendants survive, sandbox.poll() stays None, and the
next command returns 0.

Patch note: ~/.hermes/plans/hermes-patches/modal-cancel-process-group.md

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 849b93a4ed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tools/environments/modal.py Outdated
Comment thread tools/environments/modal.py Outdated
Both were introduced by this branch's diff and are real.

P1: a cancel arriving before the wrapper published its pid (exec still
queued) hit the 'no pid file' path and reported success, while the
wrapper went on to start the command, leaving it running after Hermes
returned an interrupted result. cancel() now drops a cancel marker
BEFORE reading the pid file, and the wrapper checks that marker both
before launching and immediately after publishing its pid, closing the
window from both sides.

P2: in the no-setsid fallback the child shares the wrapper's process
group, so $! is a pid, not a pgid. Signalling -$pid there targets a
nonexistent group (ESRCH) and leaves the command running on every
cancel. The recorded value is now tagged G:<pid> (own group, signal the
group) or P:<pid> (fallback, signal the pid), and cancel() resolves the
tag.

Re-verified live on real Modal: cancel still reaps all descendants
(survivors 0), a SIGTERM-ignoring command is still killed (rc=137),
sandbox.poll() stays None, exit codes/stdout/stderr/login unchanged, and
pid files do not accumulate.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7ced793c17

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tools/environments/modal.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dc2be59ebf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tools/environments/modal.py Outdated
Third review finding, also real. $! names the newly forked process
before it has necessarily executed setsid(2), so publishing G:$! from
the parent let a cancel in that window signal a process group that did
not exist yet, take ESRCH, and leave the command running.

The setsid'd shell now publishes its OWN pgid (ps -o pgid= -p $$) and
then execs the command, so the recorded group is only ever one that
already exists. Two consequences handled:

- The wrapper's post-launch cancel-marker check can no longer use the
  group (not published yet), so it signals the direct child pid, valid
  the moment fork returns, and escalates to SIGKILL.
- cancel() no longer treats a missing pid file as 'nothing to do'; it
  retries the read for the grace period, since publication is now
  slightly later than fork.

Verified live on real Modal, including a cancel fired with zero delay
after starting the command (races publication): rc=143, zero survivors.
Descendant reaping, SIGTERM-ignoring kills, exit codes, stdout/stderr,
and login shells all unchanged.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 934ff3443a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tools/environments/modal.py Outdated
Fourth review finding, also real. An image that ships setsid but lacks
procps (or a compatible ps) failed the command substitution while echo
still wrote a bare 'G:'. cancel() then resolved that record to the
target '-', signalled nothing, and the command survived, with the
wrapper's startup marker check already past.

Publish P:$$ instead when ps yields nothing, so the record always names
a real target.

Verified live on real Modal with ps shadowed by a stub that exits 127:
cancel returns 143, zero survivors, sandbox still alive.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b518bf30e7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tools/environments/modal.py Outdated
Fifth review finding, and it points at a simplification. The previous
commit's ps-less fallback recorded P:$$, which signals only the setsid
shell and leaves its children running, exactly the descendant reaping
this PR exists to provide.

But setsid makes that shell a session AND process-group leader, so its
pgid IS its own $$. Publishing G:$$ from inside needs no ps, no tr, and
no fallback branch: group signaling now works on any image, and the
wrapper is shorter than before the finding.

Confirmed on real Modal that a setsid'd shell's $$ equals its pgid
across repeated spawns (pid=4/pgid=4, 25/25, 28/28).

Verified live with ps shadowed by a stub that exits 127 AND the command
spawning a child: cancel returns 143 and zero descendants survive.
Stubborn-SIGTERM (137), immediate-cancel race, exit codes,
stdout/stderr, and login shells all unchanged.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8db79aacbd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tools/environments/modal.py Outdated
Sixth review finding, and this one is created by this PR's own diff.

The SDK exec deadline and _wait_for_process's local deadline were both
exactly `timeout`. Before this PR a tie was harmless: Modal reaping the
ContainerProcess killed the command itself. Now the real command is a
background child in its own setsid session, so if Modal's deadline won,
it would kill the outer bash while the command kept running, the handle
would report completion, and cancel() would never fire to reap the
group. A warm exec starting inside the poll loop's ~200ms backoff is
enough to lose that race.

Give the SDK deadline 15s of headroom so the local deadline always wins
and cancellation is what stops a command.

Verified against the real backend through the full execute() path, not
generated strings: execute('sleep 300 & sleep 300', timeout=10) returns
'[Command timed out after 10s]', and 4s later zero sleep processes
survive and the sandbox still serves commands.
@exiao

exiao commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Adversarial self-review

Reviewing my own PR against the case for rejecting it. Six review findings landed on this branch and every one of them was real and caused by this diff, so the honest summary is that the idea was right on the first commit and the implementation needed six rounds to be correct. What's left that a reviewer should push on:

1. The wrapper is shell string-building, and that's a real smell. It's ~15 lines of generated bash embedded in Python f-strings, and the escaping is load-bearing (shlex.quote on both the command and the nested inner script). Findings 3, 4 and 5 were all bugs inside that generated shell, which is evidence the medium is error-prone. The defense is that the alternative (a Python-side control channel) means a second exec, more round-trips, and more moving parts than the thing it replaces. But if a reviewer wants this rewritten as a small static shell function shipped into the image once and invoked with arguments, that's a legitimate call and I'd take it.

2. The fallback path is the weakest part and is nearly untested live. When setsid is genuinely absent the command shares the wrapper's process group, so cancel() signals a bare pid and any children it spawned survive. I verified the ps-missing case live, but not a real setsid-missing image, because every image we ship has it. So the fallback is correct-by-reading, not correct-by-observation. Two defensible responses: drop the fallback and fail loudly if setsid is missing (simpler, no half-working path), or keep it as a documented degradation. I chose the second; the first is arguably better engineering.

3. Cancellation is now best-effort where it used to be absolute. sandbox.terminate() was catastrophic but certain: the command was definitely dead. A signal can be ignored, and a process wedged in uninterruptible D-state won't die from SIGKILL either. In that scenario the old code eventually recovered a usable sandbox and this code leaves a stuck process running. I think that's the right trade (it turns a session-ending event into a single stuck command) but it is a genuine behavior regression in the tail, and nobody should merge this believing cancellation is now strictly stronger.

4. Every command now pays for cancellation it will never use. mkdir -p, a pid-file write, and an extra setsid fork on every exec, plus a /tmp file per command. Cheap, but not free, and the vast majority of commands are never cancelled. I measured no meaningful latency change, but I didn't benchmark it properly, and "I didn't notice" is not data.

5. The tests are mostly assertions about generated strings. Fourteen of the twenty unit tests inspect the shell text rather than executing it, which is exactly the class of test that passes while the behavior is broken; findings 3-5 all slipped past green tests of that shape. The live probes are what actually establish correctness here, and they run by hand, not in CI. A reviewer would be right to ask for a marked integration test that runs the real wedge against a real sandbox, so this can't silently regress.

6. I deleted #151's file_sync.py work rather than porting it. Its deletion-tombstone handling was written to survive sandbox respawns. No respawns now, so it's unnecessary here, but if a sandbox ever does die for an unrelated reason (Modal-side eviction, OOM), we have no recovery path at all: #151 would have rebuilt, this PR won't. That's a deliberate scope choice, not an oversight, and the right follow-up is a small, separate recovery PR rather than smuggling one back into this one.

What I'd insist on before merging: nothing blocking. What I'd want after: an integration test for the wedge, and a decision on whether the no-setsid fallback should exist at all.

Simplification pass. Three of this PR's six review findings existed only
because the setsid design had a window between fork and the process
group actually existing:

  - publishing $! before setsid(2) had run (signalled a nonexistent group)
  - the no-setsid fallback that could only signal a bare pid
  - deriving the pgid via ps/tr, which slim images may not ship

`set -m` removes the window entirely. With job control on, bash puts the
backgrounded command in a NEW process group at fork time and $! IS that
group's id, so the recorded value is a valid signal target the instant it
exists. That deletes the G:/P: tagging, the tag parsing in cancel(), the
setsid/ps probing, the fallback branch, and the wait-for-publication
retry loop.

Net -83 lines against the previous head, and the remaining wrapper is 8
shell statements.

Verified live on real Modal, full matrix: descendants reaped
(survivors 0), SIGTERM-ignoring command killed (137), zero-delay cancel
that races publication (143, 0 survivors), sandbox alive throughout
(poll() None), exit codes 42/3/0 exact, stdout+stderr, login shell,
no-newline output, heredoc stdin, and the execute() timeout path leaving
0 orphans.

A/B'd the execute()-timeout latency against the setsid head over 3 runs
each: median 96.6s (setsid) vs 97.7s (set -m). No regression; that cost
is inherent to the timeout path, not the design.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 40c8cedbc3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tools/environments/modal.py Outdated
Comment on lines +84 to +85
f"if [ -e {cancel_file} ]; then "
f'kill -TERM -"$__hermes_pgid" 2>/dev/null; fi; '

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escalate cancellation in the late-marker branch

When cancellation touches the marker after the wrapper's initial check but before the PID file is published, the cancellation exec reads no PID and exits; this branch then becomes the only cancellation path, but it sends only SIGTERM. A command that ignores TERM therefore remains in wait and can continue after Hermes reports the timeout or interrupt. Fresh evidence beyond the resolved earlier comment is that the new job-control wrapper reintroduces the TERM-only post-publication branch; apply the same grace-period SIGKILL escalation here or make the cancellation exec wait for PID publication, and exercise this interleaving through the real remote path rather than string assertions.

AGENTS.md reference: AGENTS.md:L55-L55

Useful? React with 👍 / 👎.

Comment on lines +514 to +517
# _wait_for_process, which is exactly `timeout`. If Modal fired
# first it would kill the outer bash while the real command,
# now a background child in its own setsid session, kept
# running: the handle would report completion and cancel()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment incorrectly references 'setsid session' but the implementation uses job control (set -m), not setsid. While this is just a documentation issue, it's misleading:

# Give the SDK's own deadline headroom over the local one in
# _wait_for_process, which is exactly `timeout`. If Modal fired
# first it would kill the outer bash while the real command,
# now a background child in its own process group, kept

Should say 'process group' instead of 'setsid session' to match the actual implementation.

Suggested change
# _wait_for_process, which is exactly `timeout`. If Modal fired
# first it would kill the outer bash while the real command,
# now a background child in its own setsid session, kept
# running: the handle would report completion and cancel()
# _wait_for_process, which is exactly `timeout`. If Modal fired
# first it would kill the outer bash while the real command,
# now a background child in its own process group, kept
# running: the handle would report completion and cancel()

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Two review findings on the job-control rewrite, both real.

P2: when a cancel lands after the wrapper's initial marker check but
before the pid file is published, cancel() reads no pid and exits, so
the wrapper's own post-launch marker check is the ONLY cancellation path
for that command. It sent TERM only, so a command that ignores TERM sat
in wait() and kept running past the reported timeout. It now escalates
to SIGKILL after the same grace period cancel() uses. My simplification
reintroduced this; the setsid version had the escalation.

Also fix a stale comment that still said 'setsid session' after the
switch to job control.

Verified live: a zero-delay cancel against "trap '' TERM; sleep 200"
(forcing the late-marker path) returns 137, and zero processes survive.
Full suite 122 passed, ruff clean.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c556ef3902

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# Cancelled before we were scheduled: never start the command.
f"if [ -e {cancel_file} ]; then exit {128 + 15}; fi; "
# Job control: the background job leads a new group, and $! IS its pgid.
f"set -m; bash {flags} {quoted} & __hermes_pgid=$!; "

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Suppress monitor-mode job completion output

On every normally completed Modal command, set -m makes non-interactive Bash emit a [1]+ Done ... or Exit ... job notification on stderr when wait returns. _do() concatenates stderr into the returned output, and CWD extraction preserves content after its marker, so even true returns bogus text while internal cat reads and code-execution RPC payloads gain extra bytes. Disable monitor mode after capturing $! or otherwise suppress these notifications, and cover the wrapper with a real-shell/Modal behavior test rather than only string assertions.

AGENTS.md reference: AGENTS.md:L55-L55

Useful? React with 👍 / 👎.

Review raised a P1 that `set -m` would emit '[1]+ Done' job notices on
stderr for every completed command, corrupting internal cat reads and RPC
payloads. Not reproducible: bash only prints job notifications in an
INTERACTIVE shell, and the sandbox exec is non-interactive ($- has no i).

Checked against real Modal at the raw SDK level, below _run_bash's
stdout/stderr concatenation: stderr is empty for true, echo hi, exit 7
and sleep 1, byte-identical to the same commands with monitor mode off.
Through _run_bash: 'true' returns '', 'cat /etc/hostname' returns exactly
'debuerreotype\n', and a user command that backgrounds its own job
returns only 'started\n'.

Add a test pinning the property that makes this safe (monitor mode is
enabled inline in the wrapper, never exported into the command's
environment) so a future refactor cannot silently make the finding true.
@exiao

exiao commented Jul 26, 2026

Copy link
Copy Markdown
Owner Author

Checked this against real Modal rather than by reading, and it does not reproduce. Bash only prints [1]+ Done / Exit N job notifications in an interactive shell; the sandbox exec is non-interactive (case "$-" in *i*) reports NO), and monitor mode alone does not enable them.

Raw stderr at the SDK level, below _do()'s stdout/stderr concatenation, running the actual wrapper this PR generates:

interactive? NO
'true'       rc=0 stdout='' stderr=''
'echo hi'    rc=0 stdout='hi\n' stderr=''
'exit 7'     rc=7 stdout='' stderr=''
'sleep 1'    rc=0 stdout='' stderr=''
[no -m] 'true'     stderr=''      <- control: monitor mode OFF
[no -m] 'echo hi'  stderr=''      <- byte-identical

And through the real _run_bash path, including the internal cat read the finding calls out:

'true'                  rc=0 out=''
'exit 0'                rc=0 out=''
'exit 7'                rc=7 out=''
'cat /etc/hostname'     rc=0 out='debuerreotype\n'
'printf no-newline'     rc=0 out='no-newline'
'sleep 1 (bg); echo started; wait'  out='started\n'

true returns empty, cat returns exactly its file bytes, and a user command that backgrounds its own job returns only its own output. No [1], Done, Exit, or Terminated anywhere.

I've added test_monitor_mode_is_scoped_so_it_cannot_announce_jobs pinning the property that keeps this true: monitor mode is enabled inline in the wrapper we exec and never exported into the command's environment (no export SHELLOPTS, no set -o monitor). If someone later hoists it, that test fails.

Resolving as not-a-defect on the evidence above. Happy to reopen if you can produce a sandbox image where a non-interactive bash emits the notice.

The setsid session comment nit was already fixed in c556ef3 (now reads "background job in its own process group"); that thread is against a stale head.

The _MODAL_EXEC_TIMEOUT_HEADROOM comment still said "the setsid'd
command" after the switch to job control. The remaining mention in
_wrap_for_group_cancel's docstring is deliberate: it explains why the
design does NOT depend on setsid or ps being installed.
@exiao

exiao commented Jul 26, 2026

Copy link
Copy Markdown
Owner Author

Closing this PR: it stopped converging (10 bot comments concentrated on tools/environments/modal.py across 3 review waves (>= 8 on one file over >= 3 waves)). This is a review loop chasing an unbounded edge-case vein, not a PR one more fix will land. A fresh minimal rewrite from the original plan has been routed to the orchestrator (kanban t_16413dde), which will open a new PR. The branch fix/modal-cancel-process-group is kept for reference; do not reopen this PR.

@exiao exiao closed this Jul 26, 2026
@exiao

exiao commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #160, which is merge-ready. Staying closed — but the record should show this PR had the right idea first.

I worked the same problem independently without knowing this branch existed, tested the premises against live Modal, and arrived at the same conclusion: don't destroy the sandbox to stop a command, signal the command's process group. This PR got there first, and its core diff is smaller than mine (110 vs 145 lines in tools/environments/modal.py).

Its set -m approach is genuinely elegant — backgrounding under job control makes $! the pgid directly, so there is no window where the pid is known but the group isn't, and no dependency on setsid or ps being in the image. And the launch-race handling here (cancel writes a marker file first; the wrapper refuses to launch if the marker already exists) is arguably better than #160's, which carries Python-side pending-cancel state replayed by the launcher.

#160 ships only because it is finished: 0 open review threads vs 2 here, CI green, and all five bot findings reproduced on live Modal with a regression test for each that I verified fails against the previous logic.

On the closure itself: this was closed as non-convergent at 10 bot comments across 3 waves. Worth noting that one of the two threads still open at closing time was the monitor-mode P1 that the comment above it had already disproved with live SDK-level stderr captures. The detector counted comment volume, not whether the findings were still valid, so a PR with a correct design and a disproved blocker got closed while its weaker sibling (#151) stayed open. Flagging for the detector's heuristics, not to reopen this.

The rewrite card that this closure spawned (kanban t_7ff8579c) is now blocked with: "Restore or retry the Modal VM; every shell command now exits 1 with no output." The card dispatched to rewrite the sandbox-cancellation fix was killed by the exact bug it was sent to fix. Cancelling it in favor of #160.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant