fix(agent): cancel a persistent-session command by resetting the session - #109
Merged
Merged
Conversation
Daily-AC
force-pushed
the
fix/46-persistent-session-cancel
branch
from
September 18, 2026 09:47
b2ac832 to
e7058ec
Compare
…ssion `wanctl exec` on the persistent-session path ignored the request context that #37 introduced, so Ctrl-C or a dropped controller killed the controller and nothing else: the command ran to completion on the device with no one reading its output. Cancelling here is not the same problem as cancelling a one-shot. A one-shot owns its shell, so the existing hook kills the whole tree, shell included. A session's shell holds the working directory and environment later connections rely on, and it must survive — this is option (a) from the issue. The agent holds no handle on the command: the session runs commands by writing them to the shell's stdin, so the shell forks them, not us. What the agent does hold is the shell's pid, and everything the shell started for the command running now is a descendant of it. So cancelling means: snapshot the process table, take the subtree below the session shell, kill it from the leaves up, and leave the shell alone. The shell's wait returns, it prints the end-of-command marker it was already given, and the session is ready for the next command with its cwd intact. A cancelled command reports ctx.Err() rather than the exit status the shell printed for it, so the caller can tell "the controller stopped this" from "the command failed" — the same distinction the one-shot path makes. The snapshot and the kill are the per-OS pieces: `ps -A -o pid,ppid` plus SIGKILL on Unix (one spelling that works on macOS, procps and toybox), and a toolhelp process snapshot plus TerminateProcess on Windows, which needs no cgo and flashes no console window. The subtree walk between them is shared and tested on every OS. Two limits are inherent and documented rather than worked around: a command that forks nothing has nothing to kill (a shell builtin loop, or a PowerShell cmdlet such as Start-Sleep, which runs inside powershell.exe), and a process the session deliberately left in the background is a descendant too, so it goes with the foreground command. Fixes #46 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reworks the first attempt at #46 after review. Option (a) — kill the foreground child, keep the shell — cannot be made correct when the shell is fed through stdin, so this implements option (b): a cancelled session is destroyed and the next command on that target builds a fresh one. Four findings made (a) structural rather than buggy. The agent submits a line, not a process, so killing the child it happens to be running leaves the rest of that line to execute in the surviving shell: cancelling `sleep 600; echo x > f; cd d; export X=1` killed the sleep and then wrote the file, changed directory and set the variable while reporting the command cancelled. A pid/ppid snapshot cannot prove descent, because a parent pid outlives its owner and gets reused, so an unrelated older process lands in the kill list. Under `set -e` the SIGKILL made the shell exit without a marker, so the mechanism lost the very shell it existed to keep. And `ps -A -o pid,ppid` is absent from BusyBox builds without DESKTOP, where the cancel silently did nothing and still reported success. The shell now runs inside an OS process container: its own process group on Unix (Setpgid, SIGKILL to -pgid), a job object with no breakaway rights on Windows (TerminateJobObject). Both are single kernel operations on a set the kernel maintains, so neither needs `ps`, neither can be defeated by pid reuse, and neither leaves part of the line running. The job also carries KILL_ON_JOB_CLOSE so an agent that exits takes its sessions with it, and Close now tears down the container instead of orphaning the shell's children. A cancellation is bound to the request that armed it. Disarming takes the same lock the firing path holds, so it waits for an in-flight cancellation rather than signalling it to stop, and it happens while the request still holds the session lock. A cancellation therefore completes inside its own request or does nothing; it can no longer kill the next command, which used to come back as an uncancelled exit 137. A request whose context is already cancelled when it takes the session lock runs nothing, and a kill that fails is reported rather than swallowed. The cost is stated where callers read it: the exec catalog entry and docs/contract.md say that aborting resets the shared session and point at --oneshot. ADR 0011 records why (a) was abandoned and what still escapes. Fixes #46 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TerminateJobObject was given an ERROR_ACCESS_DENIED escape hatch copied from the process-handle code it replaced. It does not belong here: the job is one this process created and still holds, and terminating a job whose processes have all exited succeeds, so there is no benign failure to forgive. Swallowing it would report a clean cancellation for a kill that did not happen. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d wait Third round on #46. Six findings from review, all in how the cancellation is scoped rather than in what it kills. A watcher could wake after its own request had disarmed and after the next one armed, and the gate's "is anything armed" flag reads true in that state: 50 misfires in 100 runs, destroying a session whose context was never cancelled. Every arming now takes a generation, the watcher carries the one it was armed with, and firing does nothing unless that generation is still live. Cancellation waited for the shell's descendants without meaning to. Their stdout is an inherited OS pipe, so a process that left the container — `set -m` puts a background job in its own group, setsid() leaves outright — still holds the write end after the kill, and the copier never sees EOF. The command never returned, Closed() never answered, and since the agent asks Closed() while holding the lock that guards every session on the device, one escaped process stalled all of them. The cancellation now closes the read half itself, Closed() is atomic and takes no lock, and the reaper carries a WaitDelay so its goroutine and the pipe's descriptors are released on a bound. This is also what makes a kill that failed reach the caller while the command it could not stop is still running, rather than whenever that command happens to end. A process-group id is only a number, and reusable once the group is gone, so the Unix container now signals at most once per lifetime and never after the shell is reaped — until then the leader is at worst a zombie and the kernel will not reuse its pid, so the number is still unambiguous. reap runs the moment cmd.Wait returns, under the lock the kill takes. A request that had already acquired a session someone else then cancelled came back "session closed" for a command that never reached the device. The session now distinguishes that case, and the agent answers it by acquiring a fresh session and running once. Nothing else is retried: every other error leaves open the possibility that the command did run. On Windows the shell is created suspended, assigned to the job, and only then resumed, so a shell that could not be contained never runs an instruction and cannot fork a worker outside the job first. Every failure after Start now goes through one cleanup that kills, reaps and closes both ends of the output pipe; three injected failures previously left three copier goroutines blocked. ADR 0011 is corrected accordingly: setpgid()/`set -m` added to what escapes on Unix, the Windows claim narrowed to processes the contained shell creates itself, and the "cannot be defeated by pid reuse" line replaced by what actually holds. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Daily-AC
force-pushed
the
fix/46-persistent-session-cancel
branch
from
September 18, 2026 10:15
67f7afb to
6f18e26
Compare
This was referenced Sep 18, 2026
Open
The exec contract promised that aborting kills "the command and everything it started". The cancel kills a process group on Unix and a job on Windows, and a process that took itself out of either — setsid, setpgid, a shell with job control, or one created through an external service — survives. ADR 0011 records that boundary; the description callers read did not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Daily-AC
added a commit
that referenced
this pull request
Sep 18, 2026
* docs: portal changelog entry for v0.11.0 Covers the merged work since v0.10.0: the portal-served WebFetch skill and catalog-rendered discovery instructions (#106), grants of up to 24 hours with the matching exec timeout and job allowance (#107), elevated commands under Android bypass mode plus the approval card and exec-elevated rules (#108), cancelling a persistent-session command (#109), controller-only hosts on update (#105), the macOS Screen Recording remedy (#102), and the 17 rewritten tool descriptions (#103). Adding this file is what moves CurrentVersion to v0.11.0, since the version is derived from the changelog file names. PR #104 is still open, so its transport fix is left as a marked TODO in the file rather than announced as shipped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * changelog: add the slow-link transport fix to v0.11.0 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: 张以琳 <zhangyilin@thunder.com.cn> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #46. Option (b): a cancelled session is destroyed, and the next command on that target builds a fresh one. ADR:
docs/adr/0011-session-cancel-resets-session.md.Round 3. Round 1 implemented option (a) and was blocked; round 2 rebuilt it on option (b) and was blocked on how the cancellation is scoped. This round fixes those six findings. Every one has a regression test ported from the reviewer's probes, and each was confirmed to fail against the previous behaviour.
What the container does
SysProcAttr.Setpgid— the shell leads a new group whose id is its own pidSIGKILLto-pgidTerminateJobObjectCreating suspended closes the window in which a wrapper shell could fork a worker before the assignment landed: a shell that cannot be contained never runs an instruction.
Round-2 findings
P1 — the gate had no request identity. A watcher could wake after its own request disarmed and after the next one armed; a shared "is anything armed" flag reads true in that state, measured at 50 misfires in 100. Every arming now takes a generation, the watcher carries the one it was armed with, and firing does nothing unless that generation is still live.
P1 — cancellation waited on the shell's descendants. Their stdout is an inherited OS pipe, so a process that left the container (
set -mputs a background job in its own group;setsid()leaves outright) still holds the write end after the kill, and the copier never sees EOF. The command never returned,Closed()never answered, and because the agent asksClosed()while holding the lock that guards every session on the device, one escaped process stalled all of them.Three changes: the cancellation closes the read half itself,
Closed()is now atomic and takes no lock, and the reaper carriesWaitDelayso its goroutine and the pipe's descriptors are released on a bound rather than never. Cutting the output is also what makes a failed kill reach the caller while the command it could not stop is still running, instead of whenever that command happens to end.P2 — double
Kill(-pgid). A process-group id is only a number and is reusable once the group is gone. The Unix container now signals at most once per lifetime and never after the shell is reaped; until then the leader is at worst a zombie and the kernel will not reuse its pid, so the number is still unambiguous.reapruns the momentcmd.Waitreturns, under the lock the kill takes.P2 — a request holding a cancelled session. It came back
session closedfor a command that never reached the device. The session now distinguishes that case (ErrSessionUnusable), and the agent answers it by acquiring a fresh session and running once. Nothing else is retried: every other error leaves open the possibility that the command did run.P2 — Windows suspended create. Above.
P2 — post-Start cleanup. Every failure after
Startnow goes through one path that kills, reaps and closes both ends of the output pipe. Three injected failures previously left three copier goroutines blocked.Regressions, and that they discriminate
Each was run against the pre-fix code:
TestLateWatcherCannotFireForTheRequestThatFollowedIta watcher belonging to a finished request killed the session 1 timesTestStaleCancelCallbackCannotKillTheNextCommandan uncancelled command was stopped by an earlier request's cancellationTestEscapedDescendantCannotHoldACancelledSessionthe cancel waited 2.001123s for a process that had left the containerTestFailedKillIsReportedWhileTheCommandStillRunsthe command never returnedTestRequestHoldingACancelledSessionGetsAFreshShellreported session closed before the command was submittedThe last two bound on
sessionWaitDelay/2on purpose. The reaper eventually closes the pipes anyway, so a looser bound passed without the cancellation cutting the output at all — the 2.001 s above is exactly that, and is why the bound was tightened.Also:
TestContainerKillsOnceAndNeverAfterReap,TestShellThatCannotBeContainedIsCleanedUp(goroutine count across three failed starts), plus the round-2 set — compound statement after the cancel is not executed, pre-cancelled request runs nothing,set -e, and the chain-level acceptance.Verification
Chain level: the device-side
sleep 600ended 52 ms after the controller stream closed, the next exec works, and it sees a fresh session — not the cancelled one's cwd, and$WANCTL_MARKempty.The cost, stated where callers read it
Cancelling loses the session's working directory and environment. The
execcatalog entry and the regenerateddocs/contract.mdsay so and point at--oneshot.Not verified here
powershell.exe.GOOS=windows go vet ./...fails onsyscall.Killininternal/client/exec_cancel_test.goandinternal/agent/delegation_test.go. Both are pre-existing onmainand outside this diff. This branch's own test files are build-tagged!windows.setsid(), a double-fork, and more easilysetpgid()including theset -mthat job control uses. What such a process can no longer do is hold the session open. On Windows nothing the contained shell creates escapes; a process it asks another service to create (Win32_Process.Createover WMI) is created by that service and is not in the job.🤖 Generated with Claude Code