Skip to content

fix(agent): cancel a persistent-session command by resetting the session - #109

Merged
Daily-AC merged 5 commits into
mainfrom
fix/46-persistent-session-cancel
Sep 18, 2026
Merged

Daily-AC merged 5 commits into
mainfrom
fix/46-persistent-session-cancel

Conversation

@Daily-AC

@Daily-AC Daily-AC commented Sep 18, 2026

Copy link
Copy Markdown
Owner

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

container cancel
Unix SysProcAttr.Setpgid — the shell leads a new group whose id is its own pid SIGKILL to -pgid
Windows created suspended, assigned to a job object with no breakaway rights, then resumed TerminateJobObject

Creating 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 -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 because the agent asks Closed() 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 carries WaitDelay so 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. reap runs the moment cmd.Wait returns, under the lock the kill takes.

P2 — a request holding a cancelled session. It came back session closed for 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 Start now 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:

test pre-fix result
TestLateWatcherCannotFireForTheRequestThatFollowedIt a watcher belonging to a finished request killed the session 1 times
TestStaleCancelCallbackCannotKillTheNextCommand an uncancelled command was stopped by an earlier request's cancellation
TestEscapedDescendantCannotHoldACancelledSession the cancel waited 2.001123s for a process that had left the container
TestFailedKillIsReportedWhileTheCommandStillRuns the command never returned
TestRequestHoldingACancelledSessionGetsAFreshShell reported session closed before the command was submitted

The last two bound on sessionWaitDelay/2 on 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

gofmt -l . && go build ./... && go vet ./... && go test ./...   # clean, rebased on main
go test -race ./internal/server/ ./internal/agent/              # clean
GOOS=windows GOARCH=amd64 go build ./...                        # clean

Chain level: the device-side sleep 600 ended 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_MARK empty.

The cost, stated where callers read it

Cancelling loses the session's working directory and environment. The exec catalog entry and the regenerated docs/contract.md say so and point at --oneshot.

Not verified here

  • Windows was not run. The job-object and suspended-resume paths cross-compile and vet; nothing executed against a real powershell.exe.
  • GOOS=windows go vet ./... fails on syscall.Kill in internal/client/exec_cancel_test.go and internal/agent/delegation_test.go. Both are pre-existing on main and outside this diff. This branch's own test files are build-tagged !windows.
  • What still escapes: on Unix, anything that leaves the process group — setsid(), a double-fork, and more easily setpgid() including the set -m that 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.Create over WMI) is created by that service and is not in the job.

🤖 Generated with Claude Code

@Daily-AC
Daily-AC force-pushed the fix/46-persistent-session-cancel branch from b2ac832 to e7058ec Compare September 18, 2026 09:47
@Daily-AC Daily-AC changed the title fix(agent): cancel a persistent-session command without losing the session fix(agent): cancel a persistent-session command by resetting the session Sep 18, 2026
张以琳 and others added 4 commits September 18, 2026 18:14
…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>
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
Daily-AC merged commit e20af31 into main Sep 18, 2026
4 checks passed
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>
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.

Persistent session: controller disconnect or Ctrl-C does not stop the running command

1 participant