Skip to content

Reap Azure Bastion tunnel process tree on exit/interrupt - #411

Merged
mkultraWasHere merged 5 commits into
mainfrom
fix/azure-tunnels
Aug 4, 2026
Merged

Reap Azure Bastion tunnel process tree on exit/interrupt#411
mkultraWasHere merged 5 commits into
mainfrom
fix/azure-tunnels

Conversation

@mkultraWasHere

Copy link
Copy Markdown
Contributor

dreadgoad commands that reach Azure hosts (validate, provision, health-check, …) leaked az network bastion tunnel subprocesses, accumulating orphaned tunnels (and open Bastion sessions) across runs.

Fixed

  • az network bastion tunnel subprocesses no longer leak on normal exit — ProvisionTunnel.Close() now kills the whole process group, reaping the az shell wrapper and its python child (previously only the wrapper was killed, orphaning the child and its tunnel).
  • validate no longer orphans its tunnel on Ctrl+C / SIGTERM — the root command context is now signal-aware, so the command unwinds and runs deferred cleanup instead of the process dying with the tunnel still up.

Changed

  • Bastion tunnel starts in its own process group (Setpgid) via exec.CommandContext, so a cancelled context also tears down the whole group.
  • validate runs under the signal-aware context (cmd.Context()) and defers the provider Drain() on every exit path (normal, error, interrupt); Drain is idempotent.

Notes

  • Mirrors the existing process-group teardown pattern in internal/ansible/runner.go.
  • New provision_tunnel_test.go covers child-tree reaping and nil-safety; verified end-to-end against a live range (interrupt mid-validate → zero orphaned tunnels). Signal handling is Unix-only (test is //go:build !windows).

`az network bastion tunnel` is a shell wrapper that spawns a python child,
so ProvisionTunnel.Close() killing only cmd.Process orphaned the python
child and leaked the tunnel. On top of that, `validate` ran under
context.Background() and only drained at the end, so Ctrl+C/SIGTERM killed
the process before any teardown ran, orphaning the whole tree.

- Start the tunnel in its own process group (Setpgid) via CommandContext and
  kill the whole group in Close() and the early-error paths.
- Make the root command context signal-aware (SIGINT/SIGTERM) so ctx-aware
  commands unwind and run deferred cleanup instead of hard-exiting.
- validate uses cmd.Context() and defers the provider Drain on every path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

This PR addresses leaked Azure Bastion tunnel subprocesses by ensuring the az network bastion tunnel process tree is reaped on shutdown, and by wiring CLI execution to a signal-aware context so deferred cleanup runs on interrupt/termination.

Changes:

  • Rework Bastion tunnel lifecycle management to kill the entire process group (wrapper + spawned child).
  • Run validate under the root command’s context and defer provider teardown so tunnels/clients are drained on all exit paths.
  • Add Unix-only tests covering child-tree reaping behavior and nil-safety.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
cli/internal/azure/provision_tunnel.go Kill Bastion tunnel process groups to avoid orphaned child processes.
cli/internal/azure/provision_tunnel_test.go Add regression tests to ensure process-tree reaping and nil-safety.
cli/cmd/validate.go Use signal-aware command context and defer provider draining for cleanup on interrupt.
cli/cmd/root.go Execute root command under a signal-aware context.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread cli/cmd/root.go
Comment thread cli/internal/azure/provision_tunnel_test.go Outdated
Comment thread cli/internal/azure/provision_tunnel.go Outdated
mkultraWasHere and others added 3 commits August 4, 2026 17:30
Follow-up hardening on the tunnel teardown added in c6e2350.

killBastionTunnel and cmd.Cancel both derived a process group via
syscall.Getpgid and then kill(-pgid). If the command was ever started
without SysProcAttr.Setpgid, Getpgid reports the *caller's* group, so
that negative-pid kill would take down dreadgoad itself along with its
foreground process group. Both sites now require pgid == pid before
signalling the group and otherwise fall back to a single-process kill.
Not reachable from the current call sites, which all set Setpgid, but
the helper takes an arbitrary *exec.Cmd and the failure mode is severe.

signal.NotifyContext only cancels ctx; it leaves its handler installed
and silently drops later signals, so the "a second signal force-quits"
behavior the root command documented did not exist and a hung teardown
would trap the user. Watch the signals on a separate channel and exit
130 on the second one.

Covered by TestKillBastionTunnelSpareOwnProcessGroup, which re-execs the
test binary in its own process group so a regression fails the test
rather than killing `go test` and the developer's shell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses review feedback on #411.

killBastionTunnel slept the full grace period after SIGTERM on every
Close(), so a tunnel that shut down instantly still cost 500ms on the
command's exit path. Poll for the group to drain and escalate to SIGKILL
only if it outlives the deadline.

The reason this needs a concurrent reap: an unreaped child stays a zombie
and keeps answering kill(pid, 0), so polling the group while still
holding the wait would never observe the exit. cmd.Wait now runs in a
goroutine and the poll keys off that.

Also read the child PID through the newline in the process-tree test
rather than trusting a single Read to return the whole line.

TestKillBastionTunnelReapsChildTree drops from ~0.51s to ~0.02s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
provisionPlaybooks already deferred socksTunnel.Close(), but both entry
points ran under context.Background(), so Ctrl+C/SIGTERM killed the
process before any defer executed and the `az network bastion tunnel`
tree survived. Same leak c6e2350 fixed for `validate`, on the command
that holds the tunnel longest.

runProvision and runLabReset now take cmd.Context(). Both reach the
tunnel through the shared provisionPlaybooks.

Cancellation changes what a playbook failure means: an interrupt reaches
ansible-playbook directly through the shared foreground process group,
so the attempt returns as an ordinary failure and the retry loop would
classify it and announce a retry it cannot perform. RunPlaybookWithRetry
now checks ctx at the top of each attempt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

@mkultraWasHere
mkultraWasHere marked this pull request as ready for review August 4, 2026 22:36
Found during a security pass over this branch.

killBastionTunnel reaps the subprocess with cmd.Wait, so two Close calls
racing on one exec.Cmd is a genuine data race — reproduced in isolation,
the detector reports a write/write inside os/exec.(*Cmd).Wait.

Not reachable today: the double Drain in `validate` is serialized because
RunTUI waits on runDone before returning. But winrmRunner.close reads and
nils r.tunnel outside its mutex while documenting itself as safe to call
multiple times, so the only thing preventing the race is caller ordering
that nothing enforces. Put the guarantee in Close via sync.Once instead
of relying on every caller staying serialized.

TestProvisionTunnelCloseIsRaceFree fails under -race without the guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mkultraWasHere
mkultraWasHere added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit e3bf482 Aug 4, 2026
9 checks passed
@mkultraWasHere
mkultraWasHere deleted the fix/azure-tunnels branch August 4, 2026 23:31
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.

2 participants