8 min read

Does remote process cleanup work after SSH cancellation?

Remote process cleanup after SSH cancellation needs process groups, durable status, child-process tests, and an honest plan for disconnects.

Does remote process cleanup work after SSH cancellation?

A cancelled agent run is not the same event as a stopped remote command. The local process can exit cleanly while the SSH transport is still alive, the transport can disappear while the remote shell keeps running, and the shell can die while its children continue in another process group. If you collapse those events into one status called "cancelled", you will eventually leave a database migration, package install, test worker, or deployment helper running after the agent has reported that it stopped.

The fix is not a clever signal trap by itself. You need a cancellation contract that names the remote run, creates a killable boundary around its descendants, preserves enough output to diagnose an interrupted run, and has a remote-side answer for a controller that vanishes. Build and test that contract before you let an agent run commands with side effects.

SSH cancellation has four separate hops

A cancellation request must cross four boundaries: the agent decides to stop, the local supervisor stops or signals its SSH client, the SSH protocol carries a channel event or signal, and the remote host acts on that event. Each hop can fail independently.

RFC 4254 separates these concepts. It defines a channel close message and separately defines a signal channel request for names such as TERM, INT, and HUP. A channel close is a transport event. It does not mean "send SIGTERM to every remote descendant." The RFC also says data sent before a close should be delivered if possible. "If possible" is doing a lot of work when a laptop sleeps, a network route drops, or a local process is force-quit.

That distinction catches a common bad design:

  1. An agent starts ssh host long-command.
  2. The user presses cancel.
  3. The agent runner kills its local child process.
  4. The UI marks the job cancelled.
  5. long-command, or one of its children, continues on the host.

The fifth line is not a corner case. It is the default result whenever the server has no reason to terminate the command, or the command has detached from the session before the connection disappeared.

A useful cancellation contract says exactly what the local side will attempt and exactly what the remote side owns:

  • The launcher creates one identifiable remote run with a random run ID.
  • The remote wrapper starts the workload in its own process group or session.
  • A normal cancel sends TERM to that process group and records the result.
  • The wrapper escalates to KILL only after a defined grace period.
  • A remote deadline or lease ends work when the controller never returns.
  • Output and final status survive the SSH stream.

Do not call a task cancelled until you have one of two outcomes: a confirmed final remote record, or an explicit "state unknown" result. Pretending certainty after a connection loss makes incident response slower because everyone starts from a false premise.

A remote PID is not enough to clean up children

Killing the remote shell PID is only safe when the shell never forks, starts a pipeline, launches a background process, or invokes a tool that spawns helpers. That is a small set of commands.

Consider this ordinary remote command:

build-assets | tee build.log &
wait

The shell has one PID. The pipeline has more processes. tee may still write its log after the shell exits. A compiler may start worker processes. A package manager may hand work to a service. If you send kill -TERM "$shell_pid", you have terminated one member of a larger group and learned very little about the rest.

Process groups give you the right unit of cancellation for a short-lived remote run. On Linux, each process belongs to a process group, and each process group belongs to a session. Terminal-generated signals go to the foreground process group, which is why terminal behavior can look more magical than it is. The Linux setpgid(2) documentation also makes clear that a child inherits its parent's process group unless something changes it.

For an agent-controlled command, create a fresh session for the workload. The session leader normally has a PID equal to its PGID and SID. Then a negative PID in kill addresses the process group:

kill -TERM -- -"$pgid"

The leading minus sign is the difference between terminating a process and terminating its process group. The -- matters too. It prevents a malformed value from being parsed as an option.

Do not blindly assume that the workload PID is its process group ID. Check it at launch. A shell wrapper, a service manager, or a program that calls setpgid can change the tree. This is the smallest inspection command worth keeping in your test kit:

ps -o pid=,ppid=,pgid=,sid=,stat=,etime=,command= -p "$pid"

Typical output has this shape:

24182  24177  24182  24182 Ss       00:03 bash ./worker.sh /tmp/agent-runs/6c4...

Here, PID, PGID, and SID all match. That is evidence that kill -TERM -- -24182 targets the intended boundary. If the PGID does not match the run record, fail the launch instead of guessing.

A process group still has limits. A child can call setsid, a container runtime can move a process elsewhere, and a workload can ask a service manager to run something outside the group. That behavior is sometimes legitimate. It also means your cancellation guarantee has ended at that handoff. Treat detached work as a distinct job type with its own identity, stop operation, and audit record.

A cancellation wrapper needs a real cleanup path

A remote shell wrapper should own the workload PID, trap expected termination signals, target the workload group, wait briefly, and write a final record. It should not use pkill command-name, scrape a loose process list, or kill every process belonging to an account. Those shortcuts work right up to the day two agent runs share a user, a hostname changes, or a command name matches someone else's work.

This Linux-oriented fixture is deliberately plain. It creates a protected run directory, starts one workload in a new session, writes output to files, and terminates the workload's group when the wrapper receives TERM, INT, or HUP.

#!/usr/bin/env bash
set -Eeuo pipefail

run_id=${1:?run ID required}
shift
run_dir="${HOME}/.agent-runs/${run_id}"
umask 077
mkdir -p "$run_dir"

child_pid=""
child_pgid=""
finished=0

write_status() {
  local state=$1
  local code=${2:-}
  local tmp="$run_dir/status.tmp"
  printf '{"run_id":"%s","state":"%s","exit_code":"%s"}\n' \
    "$run_id" "$state" "$code" >"$tmp"
  mv "$tmp" "$run_dir/status.json"
}

stop_group() {
  if [[ -z ${child_pgid:-} ]]; then
    return
  fi

  kill -TERM -- "-$child_pgid" 2>/dev/null || true
  for _ in 1 2 3 4 5; do
    if ! kill -0 -- "-$child_pgid" 2>/dev/null; then
      return
    fi
    sleep 1
  done
  kill -KILL -- "-$child_pgid" 2>/dev/null || true
}

cancel() {
  local signal=$1
  trap - TERM INT HUP
  write_status "cancelling:$signal"
  stop_group
  wait "$child_pid" 2>/dev/null || true
  write_status "cancelled:$signal"
  finished=1
  exit 143
}

trap 'cancel TERM' TERM
trap 'cancel INT' INT
trap 'cancel HUP' HUP

write_status "starting"
setsid "$@" >"$run_dir/stdout.log" 2>"$run_dir/stderr.log" &
child_pid=$!
child_pgid=$(ps -o pgid= -p "$child_pid" | tr -d ' ')

if [[ "$child_pgid" != "$child_pid" ]]; then
  printf 'unexpected PGID for %s: %s\n' "$child_pid" "$child_pgid" \
    >"$run_dir/stderr.log"
  kill -TERM "$child_pid" 2>/dev/null || true
  write_status "launch_failed"
  exit 70
fi

printf '%s\n' "$child_pid" >"$run_dir/pid"
printf '%s\n' "$child_pgid" >"$run_dir/pgid"
write_status "running"

set +e
wait "$child_pid"
code=$?
set -e

if [[ $finished -eq 0 ]]; then
  write_status "finished" "$code"
fi
exit "$code"

This wrapper prevents one specific failure: a cancellation signal reaches the wrapper, but it only kills itself and strands the workload. It does not promise to stop intentionally detached descendants. It should not make that promise.

The Linux setsid(2) manual says that setsid() creates a new session and makes the caller the leader of a new process group, initially with no controlling terminal. The util-linux setsid command runs a program in that new session, forking when needed. That is why this is a practical boundary for a command run, not a magic cleanup switch.

Keep the wrapper narrow. It should launch, record, stop, and report. Do not bury business logic in it. The workload should still have its own transaction handling, temporary-file cleanup, and idempotency rules.

A clean transport disconnect is not a cleanup guarantee

SSH users often infer too much from a terminal test. They run a command with a PTY, close the terminal, see a process exit after SIGHUP, and decide that disconnect cleanup works. Then an agent uses a non-interactive SSH channel without a PTY, and the behavior changes.

A PTY creates terminal semantics. A terminal hangup can lead to SIGHUP delivery, but only under the conditions that govern controlling terminals and foreground process groups. The Linux manual describes SIGHUP as a hangup on a controlling terminal or death of a controlling process. That description does not say every SSH disconnection signals every process started through SSH.

Non-interactive SSH is usually the better default for agents because it gives cleaner output and fewer shell startup surprises. It also removes any accidental dependency on terminal behavior. Use a PTY only when the remote program needs one, such as an old installer that refuses to run otherwise. Then document that the PTY is part of the command's behavior and test it separately.

There are three disconnect cases worth naming:

The client sends a deliberate cancel

The local supervisor still has a live connection and can send a protocol signal, or it can open a separate authenticated control command that signals the recorded PGID. This is the best case. The remote wrapper receives TERM, cleans up, and writes cancelled:TERM.

Do not rely on an SSH client receiving local SIGINT to do exactly this unless you have tested the client library and invocation you ship. A terminal client, an embedded SSH library, and an MCP tool may map local cancellation differently. Some will close a socket. Some will terminate the local process. Some can send an SSH signal request. Those are different implementations of an interface that users call "cancel."

The local client crashes or loses its network

The remote command may continue. The server cannot distinguish a temporary routing problem from a user who intends work to continue unless your protocol tells it how. A remote lease is the honest answer.

At launch, write a deadline_epoch into the remote run directory. A local supervisor renews it while the run remains authorized. A remote watchdog checks it and calls the same process-group cleanup path after it expires. Pick a lease duration that fits the operation. A five-minute lease might fit a shell command. It may be reckless for a build with long but normal quiet phases.

The remote host fails or reboots

You may lose both process and final status. Do not report "cancelled" or "completed" merely because the SSH connection ended. Mark the run unknown until a later reconciliation reads the host's journal, deployment state, lock record, or application-specific result.

The hard part is not emitting a status word. The hard part is refusing to emit a status word that your system cannot support.

Partial output proves observation, not completion

Lock SSH actions completely
While the vault is locked, Sallyport denies every SSH action instead of letting an agent retain usable credentials.

A stream answers "what bytes did the client receive so far?" It does not answer "what state did the remote command leave behind?" This mistake appears when a remote program prints done before flushing its last file, or when the network drops after the command completed but before the client received the SSH exit status.

Keep two records:

  • stdout.log and stderr.log hold diagnostic output as the workload writes it.
  • status.json is a small final record written atomically after the wrapper observes exit or handles cancellation.

The mv in the wrapper matters. Write a temporary status file in the same directory, then rename it into place. Readers see either the previous whole file or the new whole file. They should never parse half of a JSON document and invent a result.

Output itself needs rules. A command can buffer output heavily when stdout is a file instead of a terminal. If progress matters, have the workload emit explicit line-oriented status to stderr or use an application-level progress file. Do not solve buffering by allocating a PTY for every command. That changes behavior and can merge stdout with stderr, which makes audit and failure analysis worse.

Treat these cases differently in your agent UI or journal:

Remote recordStream stateMeaning
finished, exit code presentcompleteThe wrapper observed normal completion.
cancelled:TERMmay end abruptlyThe wrapper began cancellation and stopped its group.
cancelling:TERM onlydisconnectedCleanup started, but the final record was not observed. Reconcile.
running only, lease validdisconnectedWork may still be running. Do not retry blindly.
no usable recorddisconnectedState is unknown. Check side effects before relaunching.

Avoid placing access tokens, unredacted configuration dumps, or credentials in these logs. SSH credential injection can keep the private key away from the agent, but a remote command can still print secrets that it read from its own environment or configuration. Output retention is part of the command design, not an afterthought.

Test process trees instead of testing one sleepy shell

trap 'exit' TERM; sleep 600 is a weak cancellation test. It proves that one foreground shell can receive one signal. It does not test children, process groups, delayed cleanup, output persistence, or a remote shell that disappears at the wrong moment.

Use a workload that creates a visible process tree and records each signal. Save this as worker.sh on a disposable Linux host:

#!/usr/bin/env bash
set -Eeuo pipefail
run_dir=${1:?run directory required}

note() {
  printf '%s pid=%s pgid=%s %s\n' \
    "$(date +%s)" "$$" "$(ps -o pgid= -p $$ | tr -d ' ')" "$1" \
    >>"$run_dir/worker.log"
}

trap 'note TERM; exit 143' TERM
trap 'note INT; exit 130' INT
trap 'note HUP; exit 129' HUP

(
  trap 'note grandchild_TERM; exit 143' TERM
  trap 'note grandchild_HUP; exit 129' HUP
  while :; do
    note grandchild_tick
    sleep 1
  done
) &
grandchild=$!

note "started grandchild=$grandchild"
while :; do
  note parent_tick
  sleep 1
done

Run it through the wrapper with a random run ID. In another SSH session, inspect the process tree and the run directory:

run_id=cancel-test-$(date +%s)
ssh host.example './remote-wrapper.sh '"$run_id"' ./worker.sh "$HOME/.agent-runs/'"$run_id"'"'

The exact quoting will differ in a real launcher. That is fine. What must not differ is the test evidence: you need the remote run ID, wrapper PID, workload PGID, log locations, and final status.

Then test these failure paths one at a time:

  1. Send TERM to the wrapper PID. Confirm that both parent and grandchild logged termination and that ps finds no process in the recorded PGID.
  2. Send TERM directly to the workload PID. Confirm that its child behavior is not assumed safe. This test explains why the wrapper targets a group.
  3. Kill the local SSH client without sending a remote signal. Confirm that the workload remains active until its remote lease expires. If it stops immediately, record why, such as PTY hangup behavior, rather than treating that result as universal.
  4. Disconnect after the wrapper writes cancelling:TERM but before it writes its final record. Confirm that reconciliation can distinguish incomplete observation from a fresh running job.
  5. Start two runs under the same account, cancel one, and prove that the other remains alive. This catches dangerous broad pkill and account-wide cleanup logic.

Use ps and pgrep -a -g "$pgid" while testing, then repeat after the grace period. Check the remote status file and logs only after checking the process table. A status record that says "cancelled" while workers remain alive is a bug in your supervisor, not a harmless reporting mismatch.

Parent-death signals help only inside a controlled worker

Verify the action trail
Its encrypted, hash-chained audit log can be verified offline with sp audit verify, without a key.

Linux provides PR_SET_PDEATHSIG, which lets a process ask the kernel to send it a signal when the thread that created it terminates. It can be useful when you own a small native helper that spawns an immediate child and wants that child to stop if the helper dies. The setting survives execve in ordinary cases, but the manual documents important exceptions, including credential changes.

It does not solve remote agent cleanup by itself.

First, the SSH server process is not necessarily the parent you care about. Second, the signal applies to the process that set it, not automatically to all descendants. Third, a remote command that forks, double-forks, or hands work to another service leaves that relationship behind. Fourth, it is Linux-specific, which matters if your fleet has mixed Unix hosts.

Use it only as a reinforcement where the process tree is yours. For example, a small Linux worker can set PR_SET_PDEATHSIG before it execs a single controlled child, while the outer wrapper still owns the process group and lease. That gives you two failure detectors with different scopes. It does not give you permission to skip the group boundary or remote final record.

The same warning applies to nohup, disown, and setsid inside the workload. They are useful when someone deliberately wants work to outlive a terminal. They are incompatible with a promise that cancelling an agent run stops the work. Make the choice explicit at launch.

A second control channel is often cleaner than killing the first one

Keep SSH keys off context
Sallyport injects the SSH credential only when it executes the action, so no key is placed in agent context.

When an agent cancels an in-flight SSH call, its own local execution context may already be tearing down. Depending on that dying process to send a final protocol signal invites races. A separate supervisor process should own cancellation and reconciliation.

One workable design looks like this:

  1. The supervisor generates a cryptographically random run ID and invokes the remote wrapper.
  2. The wrapper records its PID, PGID, start time, and status under a directory named for that run ID.
  3. The supervisor records the run ID and remote host before it begins consuming output.
  4. On cancel, the supervisor opens a fresh control action that reads the run record, verifies expected ownership and age, then signals the recorded PGID.
  5. The supervisor polls the final status record until it sees a terminal state or reaches a reporting deadline.

The control action must validate the run record before it signals anything. At minimum, check that the record directory belongs to the expected user, the PID still exists, the recorded PGID matches ps, and the start time is consistent with the process you launched. Linux can reuse PIDs. A stale PID file plus an unconditional kill is how a failed cleanup script terminates unrelated work weeks later.

Do not put the cleanup command behind a generic agent instruction such as "kill the process for my previous task." The agent should receive an opaque run handle. The supervisor translates that handle into a narrowly scoped remote action. That design also makes auditing readable: a reviewer sees that run 6c4... requested cancellation of PGID 24182 on one host, not that an agent formed an arbitrary kill command.

For autonomous coding agents, Sallyport can execute SSH actions without exposing the SSH key to the agent. That keeps credential custody separate from the cancellation contract, which still needs the run handle, group check, remote lease, and final record.

The right retry decision depends on the side effect

A command that only reads a file can usually be retried after an unknown disconnect. A command that creates a user, applies a migration, rotates a certificate, or starts a deployment cannot. The SSH layer cannot tell you whether the operation became safe to repeat.

Give side-effecting remote commands an idempotency token derived from the run ID. The remote program should store the token with the operation's result and return the existing result if it sees the same token again. If that is impossible, add a preflight query that identifies whether the requested change already happened.

Do not use cleanup as a substitute for idempotency. Even a perfect TERM can arrive after a remote API accepted a request but before the command printed its response. Even a KILL can land after a database transaction committed. Process cleanup answers whether your worker is still executing. It does not roll back external effects.

Keep three outcomes in the control plane: completed, cancelled with confirmed cleanup, and unknown. Unknown is uncomfortable, but it gives the next operator the information they need: inspect the remote state before issuing another action. That is far better than a green retry button backed by wishful thinking.

A cancellation feature is ready when you can interrupt it at every boundary and explain the surviving process tree, the output on disk, the final record, and the retry decision. If you cannot do that on a disposable host, do not trust it against a production host at 2 a.m.

FAQ

How do I stop a remote SSH command when an AI agent cancels it?

Treat cancellation as a separate control action, not as the local SSH client's exit. Send a signal to a recorded remote process group, wait for a bounded cleanup period, then verify the run's final state from a durable record. If the control path is already gone, a remote lease or watchdog must decide when to stop the work.

Does closing an SSH connection kill the remote command?

No. An SSH channel close ends the channel; it does not promise that the server will deliver SIGTERM to the command or its descendants. A PTY can change the behavior through terminal hangup signals, but that is still not a cleanup contract you should depend on.

How do I kill child processes started by a remote shell script?

Use a dedicated process group or session for each remote run and signal the negative PGID, such as kill -TERM -- -12345. Then inspect the PGID before relying on it. Killing only the shell PID leaves background children, pipelines, and subprocesses behind.

Should I use setsid for remote agent commands?

A normal setsid invocation creates a new session and process group for the launched command, which gives the supervisor one group to terminate. Do not use it for work that should survive cancellation. Verify the resulting SID and PGID with ps because wrappers and service managers can alter the tree.

How do I clean up after an SSH disconnect or network failure?

A remote process should have a maximum runtime or renewable lease if leaving it running would be unsafe or expensive. The lease must live on the remote host, because a dropped client cannot be trusted to report that it disappeared. A cancellation signal can stop work promptly, while the lease handles the case where that signal never arrives.

Can I trust partial output after an SSH connection drops?

Stream output for observation, but write it to a remote file or journal as well. Store a separate completion record only after the command exits and cleanup finishes. A broken stream tells you that delivery failed, not whether the command completed.

Is PR_SET_PDEATHSIG enough for remote process cleanup?

It is Linux-specific and only helps a process notice that its direct parent died. PR_SET_PDEATHSIG does not automatically cover grandchildren, and privilege changes can clear the setting. Use it as a local reinforcement inside a purpose-built worker, not as the only stop mechanism for arbitrary commands.

Should an agent allocate a PTY for remote SSH commands?

Usually no. A terminal changes signal delivery, buffering, and program behavior, while a non-interactive command gives you cleaner machine output. Allocate a PTY only when the remote program truly needs terminal semantics, then test hangup behavior separately.

What should an SSH cancellation audit record contain?

Record a random run ID, remote host identity, starting PID, PGID, SID, launch time, requested command, and the final result. Keep the remote record protected from other users and make the final status update atomic. A PID alone is weak evidence because PIDs can be reused.

Can an agent safely run destructive work over SSH?

Do not dispatch an unbounded SSH command and call the job cancelled when the local process exits. Require a run handle, a process-group check, a cleanup deadline, and a final remote status. Sallyport can keep the SSH credential out of the agent, but the remote command design still decides whether cancellation is honest.

Sallyport

Sallyport runs API calls and SSH commands for your AI agent. The keys stay in a local vault on your Mac; you approve each run and every action lands in a sealed journal.

© 2026 Sallyport · Open source under Apache-2.0 · Oleg Sotnikov