diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e6ff7f..e1cf5d7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,6 +25,16 @@ This runs: go vet, gofmt, build, unit tests (with race detector), public audit, 4. Run `./scripts/run-tests.sh` 5. Open a pull request +## Optional live test + +`test-run-signal-live.sh` verifies that `shellroute run` forwards SIGTERM to the child process, ends the API session cleanly, and then exits by SIGTERM the way the child did. It creates one real paid session and requires `--live`: + +```bash +./scripts/test-run-signal-live.sh --live [COUNTRY] +``` + +Not part of `run-tests.sh` or CI. Run manually before releasing signal-handling changes. + ## DCO All commits must be signed off (`git commit -s`). This certifies you wrote the code or have the right to submit it under the Apache 2.0 license. diff --git a/internal/cli/connect.go b/internal/cli/connect.go index 9d6d90b..5abd740 100644 --- a/internal/cli/connect.go +++ b/internal/cli/connect.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "os" - "os/signal" "syscall" "time" @@ -127,8 +126,13 @@ func runConnectHeadless(cfg *config.Config, country string) error { client := api.New(cfg.APIURL, cfg.APIKey) + // Stop signals are handled from here on: during startup they abort the + // session, afterwards they end it. + ctx, cancel := context.WithCancel(context.Background()) + sigs := NewSignalHandler(cancel, syscall.SIGINT, syscall.SIGTERM) + sess, err := session.Start( - context.Background(), + ctx, client, &api.SessionCreateRequest{ Country: country, @@ -140,10 +144,16 @@ func runConnectHeadless(cfg *config.Config, country string) error { session.StartOpts{Mode: "proxy"}, ) if err != nil { + sigs.Stop() return handleSessionError(err) } + if sigs.StartupSignal() != 0 { + return waitAndDisconnect(sess, sigs) + } + if sess.GetExitIP() == "" { + sigs.Stop() sess.Stop() display.Error("Connection failed — no working upstream. Try again.") return fmt.Errorf("no exit IP") @@ -156,13 +166,13 @@ func runConnectHeadless(cfg *config.Config, country string) error { outputEnv(sess) } - return waitAndDisconnect(sess) + return waitAndDisconnect(sess, sigs) } -func waitAndDisconnect(sess *session.Session) error { - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - <-sigCh +// waitAndDisconnect blocks until a stop signal, then ends the session. +func waitAndDisconnect(sess *session.Session, sigs *SignalHandler) error { + sigs.Wait() + sigs.Stop() fmt.Fprintln(os.Stderr) diff --git a/internal/cli/run.go b/internal/cli/run.go index 382f0eb..a7bff04 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -104,9 +104,13 @@ func runRun(cmd *cobra.Command, args []string) error { client := api.New(cfg.APIURL, cfg.APIKey) - // Start session + // Stop signals are handled from here on: during startup they abort the + // session, once the child runs they are forwarded to it. + ctx, cancel := context.WithCancel(context.Background()) + sigs := NewSignalHandler(cancel, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + sess, err := session.Start( - context.Background(), + ctx, client, &api.SessionCreateRequest{ Country: country, @@ -118,10 +122,18 @@ func runRun(cmd *cobra.Command, args []string) error { session.StartOpts{TrackRelays: true, Mode: "run"}, ) if err != nil { + sigs.Stop() return handleSessionError(err) } + if sig := sigs.StartupSignal(); sig != 0 { + sigs.Stop() + endRunSession(sess) + exitFromSignal(sig) + } + if sess.GetExitIP() == "" { + sigs.Stop() sess.Stop() display.Error("Connection failed — no working upstream. Try again.") return fmt.Errorf("no exit IP") @@ -140,6 +152,7 @@ func runRun(cmd *cobra.Command, args []string) error { childCmd.Env = buildProxyEnv(os.Environ(), sess.ProxyURL()) if err := childCmd.Start(); err != nil { + sigs.Stop() sess.Stop() return fmt.Errorf("failed to start command: %w", err) } @@ -169,33 +182,40 @@ func runRun(cmd *cobra.Command, args []string) error { fmt.Fprintln(os.Stderr, "\n Connection lost during execution. Command killed.") } + sigs.Attach(-childCmd.Process.Pid, &childRunning, killCancel) + childErr := childCmd.Wait() childRunning.Store(false) close(killCancel) + sigs.Stop() - // Tear down session - resp, stopErr := sess.Stop() - if !runNoStat { - if childErr != nil { - display.Error("Command failed: %s", args[0]) - } - if stopErr != nil { - display.Error("Failed to end session. Try again.") - } else { - display.SessionSummary("shellroute session ended.", resp.DurationSec, resp.BytesTotal, resp.CostUSD, resp.BalanceUSD) - if resp.BalanceUSD <= 0.001 { - display.Warn("Balance depleted. Top up at https://console.shellroute.com, then reconnect.") - } - } + // A signal death is reported by exiting the same way, not as a failure. + exitErr, _ := childErr.(*exec.ExitError) + if childErr != nil && !runNoStat && (exitErr == nil || childSignal(exitErr) == 0) { + display.Error("Command failed: %s", args[0]) } + endRunSession(sess) - if childErr != nil { - if exitErr, ok := childErr.(*exec.ExitError); ok { - os.Exit(exitErr.ExitCode()) - } - return childErr + if exitErr != nil { + exitAsChild(exitErr) + } + return childErr +} + +// endRunSession ends the session and prints the summary unless --no-stat. +func endRunSession(sess *session.Session) { + resp, err := sess.Stop() + if runNoStat { + return + } + if err != nil { + display.Error("Failed to end session. Try again.") + return + } + display.SessionSummary("shellroute session ended.", resp.DurationSec, resp.BytesTotal, resp.CostUSD, resp.BalanceUSD) + if resp.BalanceUSD <= 0.001 { + display.Warn("Balance depleted. Top up at https://console.shellroute.com, then reconnect.") } - return nil } const defaultNoProxy = "localhost,127.0.0.1,::1" diff --git a/internal/cli/run_exit_test.go b/internal/cli/run_exit_test.go new file mode 100644 index 0000000..b3b03da --- /dev/null +++ b/internal/cli/run_exit_test.go @@ -0,0 +1,142 @@ +//go:build !windows + +package cli + +import ( + "errors" + "fmt" + "os" + "os/exec" + "syscall" + "testing" + "time" +) + +// exitAsChild and exitFromSignal end the process, so each case runs in a +// re-executed test binary driven by TestHelperProcess. + +func TestHelperProcess(t *testing.T) { + switch os.Getenv("SR_TEST_HELPER") { + case "": + return + case "exit-as-child": + var cmd *exec.Cmd + if code := os.Getenv("SR_TEST_CHILD_EXIT"); code != "" { + cmd = exec.Command("bash", "-c", "exit "+code) + } else { + cmd = exec.Command("sleep", "30") + } + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + fmt.Println(err) + os.Exit(99) + } + if name := os.Getenv("SR_TEST_CHILD_SIG"); name != "" { + time.Sleep(200 * time.Millisecond) + syscall.Kill(-cmd.Process.Pid, signalByName(name)) + } + err := cmd.Wait() + var ee *exec.ExitError + if errors.As(err, &ee) { + exitAsChild(ee) + } + if err != nil { + fmt.Println(err) + os.Exit(99) + } + os.Exit(0) + case "exit-from-signal": + exitFromSignal(signalByName(os.Getenv("SR_TEST_SIG"))) + case "ignored-hup": + helperIgnoredHUP() + } +} + +func signalByName(name string) syscall.Signal { + switch name { + case "INT": + return syscall.SIGINT + case "TERM": + return syscall.SIGTERM + case "HUP": + return syscall.SIGHUP + case "KILL": + return syscall.SIGKILL + case "USR1": + return syscall.SIGUSR1 + } + panic("unknown signal " + name) +} + +type helperResult struct { + code int // exit status, or -1 when killed by a signal + sig syscall.Signal + out string +} + +// runHelper re-executes the test binary running only TestHelperProcess. +// With hupIgnored, SIGHUP is ignored on entry, as under nohup. +func runHelper(t *testing.T, hupIgnored bool, env ...string) helperResult { + t.Helper() + var cmd *exec.Cmd + if hupIgnored { + cmd = exec.Command("bash", "-c", `trap '' HUP; exec "$0" -test.run='^TestHelperProcess$'`, os.Args[0]) + } else { + cmd = exec.Command(os.Args[0], "-test.run=^TestHelperProcess$") + } + cmd.Env = append(os.Environ(), env...) + out, err := cmd.CombinedOutput() + res := helperResult{out: string(out)} + if err == nil { + return res + } + var ee *exec.ExitError + if !errors.As(err, &ee) { + t.Fatalf("helper: %v", err) + } + ws := ee.Sys().(syscall.WaitStatus) + if ws.Signaled() { + res.code, res.sig = -1, ws.Signal() + } else { + res.code = ws.ExitStatus() + } + return res +} + +func TestExitAsChild(t *testing.T) { + cases := []struct { + name string + env []string + wantCode int + wantSig syscall.Signal + }{ + {"exit code kept", []string{"SR_TEST_CHILD_EXIT=7"}, 7, 0}, + {"SIGTERM re-raised", []string{"SR_TEST_CHILD_SIG=TERM"}, -1, syscall.SIGTERM}, + {"SIGINT re-raised", []string{"SR_TEST_CHILD_SIG=INT"}, -1, syscall.SIGINT}, + {"SIGHUP re-raised", []string{"SR_TEST_CHILD_SIG=HUP"}, -1, syscall.SIGHUP}, + {"SIGKILL re-raised", []string{"SR_TEST_CHILD_SIG=KILL"}, -1, syscall.SIGKILL}, + {"other signal uses 128+n", []string{"SR_TEST_CHILD_SIG=USR1"}, 128 + int(syscall.SIGUSR1), 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + res := runHelper(t, false, append(c.env, "SR_TEST_HELPER=exit-as-child")...) + if res.code != c.wantCode || res.sig != c.wantSig { + t.Errorf("got code=%d sig=%v, want code=%d sig=%v\n%s", res.code, res.sig, c.wantCode, c.wantSig, res.out) + } + }) + } +} + +// exitFromSignal must not re-enable a signal that is ignored on entry: with +// SIGHUP ignored (nohup), it falls back to exit status 129. +func TestExitFromSignal_IgnoredFallsBack(t *testing.T) { + res := runHelper(t, true, "SR_TEST_HELPER=exit-from-signal", "SR_TEST_SIG=HUP") + if res.code != 129 || res.sig != 0 { + t.Errorf("got code=%d sig=%v, want code=129\n%s", res.code, res.sig, res.out) + } + + res = runHelper(t, false, "SR_TEST_HELPER=exit-from-signal", "SR_TEST_SIG=HUP") + if res.sig != syscall.SIGHUP { + t.Errorf("got code=%d sig=%v, want killed by SIGHUP\n%s", res.code, res.sig, res.out) + } +} diff --git a/internal/cli/run_signal.go b/internal/cli/run_signal.go new file mode 100644 index 0000000..d05f300 --- /dev/null +++ b/internal/cli/run_signal.go @@ -0,0 +1,160 @@ +//go:build !windows + +package cli + +import ( + "context" + "os" + "os/exec" + "os/signal" + "sync" + "sync/atomic" + "syscall" + "time" +) + +// SignalHandler owns stop-signal handling for the life of one session. +// +// Before Attach, a stop signal cancels session startup. After Attach, the +// first signal is forwarded to the child process group and escalated to +// SIGKILL if the child does not exit in time; a second signal during that +// wait kills immediately. +type SignalHandler struct { + ch chan os.Signal + cancelStartup context.CancelFunc + escalateAfter time.Duration + + mu sync.Mutex + startupSig syscall.Signal // first signal before Attach, 0 if none + fired chan struct{} // closed with startupSig + attached bool + pid int + running *atomic.Bool + killCancel chan struct{} + stopOnce sync.Once +} + +// NewSignalHandler registers for sigs immediately, so no window exists +// between creating the session and starting the child. A signal ignored on +// entry (nohup, a shell trap with an empty action) stays ignored: Notify would +// re-enable it, and the child, which inherited the ignore, would end up +// SIGKILLed after the escalation. +func NewSignalHandler(cancelStartup context.CancelFunc, sigs ...os.Signal) *SignalHandler { + h := &SignalHandler{ + ch: make(chan os.Signal, 2), // room for a second signal during escalation + cancelStartup: cancelStartup, + escalateAfter: 5 * time.Second, + fired: make(chan struct{}), + } + var wanted []os.Signal + for _, s := range sigs { + if !signal.Ignored(s) { + wanted = append(wanted, s) + } + } + if len(wanted) > 0 { // Notify with no signals would relay every signal + signal.Notify(h.ch, wanted...) + } + go h.loop() + return h +} + +func (h *SignalHandler) loop() { + for s := range h.ch { + sig := s.(syscall.Signal) + h.mu.Lock() + if !h.attached { + if h.startupSig == 0 { + h.startupSig = sig + close(h.fired) + h.cancelStartup() + } + h.mu.Unlock() + continue + } + pid, running, killCancel := h.pid, h.running, h.killCancel + h.mu.Unlock() + + if !running.Load() { + continue + } + syscall.Kill(pid, sig) + select { + case <-time.After(h.escalateAfter): + if running.Load() { + syscall.Kill(pid, syscall.SIGKILL) + } + case <-h.ch: // second signal + if running.Load() { + syscall.Kill(pid, syscall.SIGKILL) + } + case <-killCancel: + } + } +} + +// StartupSignal returns the signal received before Attach, or 0. +func (h *SignalHandler) StartupSignal() syscall.Signal { + h.mu.Lock() + defer h.mu.Unlock() + return h.startupSig +} + +// Wait blocks until a signal arrives; for modes that run no child. +func (h *SignalHandler) Wait() syscall.Signal { + <-h.fired + return h.StartupSignal() +} + +// Attach starts forwarding signals to pid (negative = process group). +// A signal that arrived before Attach is forwarded now. +func (h *SignalHandler) Attach(pid int, running *atomic.Bool, killCancel chan struct{}) { + h.mu.Lock() + h.attached = true + h.pid, h.running, h.killCancel = pid, running, killCancel + sig := h.startupSig + h.mu.Unlock() + if sig != 0 { + select { + case h.ch <- sig: + default: // buffer full: the pending signals get forwarded instead + } + } +} + +// Stop unregisters the handler; later signals get default handling. +func (h *SignalHandler) Stop() { + h.stopOnce.Do(func() { + signal.Stop(h.ch) + close(h.ch) + }) +} + +// childSignal returns the signal that terminated the child, or 0. +func childSignal(err *exec.ExitError) syscall.Signal { + if ws, ok := err.Sys().(syscall.WaitStatus); ok && ws.Signaled() { + return ws.Signal() + } + return 0 +} + +// exitAsChild ends the process the way the child ended: same exit code, or +// the same signal, so shells and systemd see the real cause. +func exitAsChild(err *exec.ExitError) { + if sig := childSignal(err); sig != 0 { + exitFromSignal(sig) + } + os.Exit(err.ExitCode()) +} + +// exitFromSignal re-raises sig on this process. Only stop signals and SIGKILL +// are re-raised; the Go runtime turns others (SIGSEGV, SIGQUIT, ...) into a +// crash dump. Falls back to the 128+n shell convention when sig is ignored. +func exitFromSignal(sig syscall.Signal) { + switch sig { + case syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGKILL: + syscall.Kill(os.Getpid(), sig) + time.Sleep(250 * time.Millisecond) + } + os.Exit(128 + int(sig)) +} diff --git a/internal/cli/run_signal_test.go b/internal/cli/run_signal_test.go new file mode 100644 index 0000000..09802ff --- /dev/null +++ b/internal/cli/run_signal_test.go @@ -0,0 +1,259 @@ +//go:build !windows + +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "os/signal" + "sync/atomic" + "syscall" + "testing" + "time" +) + +// All tests drive the production SignalHandler. Signals are sent to the test +// process itself and reach the handler through signal.Notify, as in production. + +func startChild(t *testing.T, script string) *exec.Cmd { + t.Helper() + cmd := exec.Command("bash", "-c", script) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + time.Sleep(100 * time.Millisecond) // let trap register + return cmd +} + +// childHandler is a running child with a handler attached to its process group. +type childHandler struct { + cmd *exec.Cmd + h *SignalHandler + running atomic.Bool + cancel chan struct{} + done chan error // child's Wait result +} + +func newHandler(sigs ...syscall.Signal) (*SignalHandler, context.Context) { + ctx, cancel := context.WithCancel(context.Background()) + var osSigs []os.Signal + for _, s := range sigs { + osSigs = append(osSigs, s) + } + return NewSignalHandler(cancel, osSigs...), ctx +} + +func attachHandler(t *testing.T, script string, escalateAfter time.Duration) *childHandler { + t.Helper() + c := &childHandler{cmd: startChild(t, script), cancel: make(chan struct{}), done: make(chan error, 1)} + c.running.Store(true) + c.h, _ = newHandler(syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + c.h.escalateAfter = escalateAfter + c.h.Attach(-c.cmd.Process.Pid, &c.running, c.cancel) + go func() { c.done <- c.cmd.Wait() }() + return c +} + +// wait returns the child's exit error once it is gone and the handler is stopped. +func (c *childHandler) wait(t *testing.T) error { + t.Helper() + var err error + select { + case err = <-c.done: + case <-time.After(5 * time.Second): + syscall.Kill(-c.cmd.Process.Pid, syscall.SIGKILL) + <-c.done + t.Error("child still running after 5s") + } + c.running.Store(false) + close(c.cancel) + c.h.Stop() + return err +} + +func exitStatus(t *testing.T, err error) (code int, sig syscall.Signal) { + t.Helper() + if err == nil { + return 0, 0 + } + var ee *exec.ExitError + if !errors.As(err, &ee) { + t.Fatalf("unexpected error: %v", err) + } + ws := ee.Sys().(syscall.WaitStatus) + if ws.Signaled() { + return -1, ws.Signal() + } + return ws.ExitStatus(), 0 +} + +func TestHandler_ForwardSIGTERM(t *testing.T) { + c := attachHandler(t, `trap 'exit 42' TERM; while true; do sleep 0.1; done`, 5*time.Second) + syscall.Kill(syscall.Getpid(), syscall.SIGTERM) + if code, _ := exitStatus(t, c.wait(t)); code != 42 { + t.Errorf("exit code = %d, want 42", code) + } +} + +func TestHandler_ForwardSIGHUP(t *testing.T) { + c := attachHandler(t, `trap 'exit 43' HUP; while true; do sleep 0.1; done`, 5*time.Second) + syscall.Kill(syscall.Getpid(), syscall.SIGHUP) + if code, _ := exitStatus(t, c.wait(t)); code != 43 { + t.Errorf("exit code = %d, want 43", code) + } +} + +// Child ignores SIGTERM: handler escalates to SIGKILL after escalateAfter. +func TestHandler_EscalateToKILL(t *testing.T) { + c := attachHandler(t, `trap '' TERM; while true; do sleep 0.1; done`, 500*time.Millisecond) + syscall.Kill(syscall.Getpid(), syscall.SIGTERM) + if _, sig := exitStatus(t, c.wait(t)); sig != syscall.SIGKILL { + t.Errorf("signal = %v, want SIGKILL", sig) + } +} + +// A second signal during the escalation wait kills immediately. +func TestHandler_SecondSignalImmediateKILL(t *testing.T) { + c := attachHandler(t, `trap '' TERM INT; while true; do sleep 0.1; done`, 30*time.Second) + start := time.Now() + syscall.Kill(syscall.Getpid(), syscall.SIGTERM) + time.Sleep(200 * time.Millisecond) + syscall.Kill(syscall.Getpid(), syscall.SIGINT) + _, sig := exitStatus(t, c.wait(t)) + if sig != syscall.SIGKILL { + t.Errorf("signal = %v, want SIGKILL", sig) + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Errorf("took %v — second signal should have killed immediately", elapsed) + } +} + +// Child exits promptly: no SIGKILL, exit code preserved. +func TestHandler_NoEscalateIfChildExits(t *testing.T) { + c := attachHandler(t, `trap 'exit 0' TERM; while true; do sleep 0.1; done`, 300*time.Millisecond) + syscall.Kill(syscall.Getpid(), syscall.SIGTERM) + if code, sig := exitStatus(t, c.wait(t)); code != 0 || sig != 0 { + t.Errorf("exit = (%d, %v), want clean exit 0", code, sig) + } +} + +// A signal after the child is gone must not touch its (possibly reused) pgid. +func TestHandler_SignalAfterChildExitIgnored(t *testing.T) { + c := attachHandler(t, `exit 0`, 5*time.Second) + <-c.done + c.running.Store(false) + syscall.Kill(syscall.Getpid(), syscall.SIGTERM) + time.Sleep(100 * time.Millisecond) // still registered: the handler must swallow it + close(c.cancel) + c.h.Stop() +} + +func TestHandler_StopWithoutSignal(t *testing.T) { + h, ctx := newHandler(syscall.SIGTERM) + h.Stop() + if ctx.Err() != nil { + t.Error("startup cancelled without a signal") + } +} + +// Before Attach, the first signal cancels startup and is remembered. +func TestHandler_StartupSignalCancels(t *testing.T) { + h, ctx := newHandler(syscall.SIGINT, syscall.SIGTERM) + defer h.Stop() + + syscall.Kill(syscall.Getpid(), syscall.SIGTERM) + select { + case <-ctx.Done(): + case <-time.After(2 * time.Second): + t.Fatal("startup not cancelled after SIGTERM") + } + if sig := h.Wait(); sig != syscall.SIGTERM { + t.Errorf("Wait() = %v, want SIGTERM", sig) + } + + syscall.Kill(syscall.Getpid(), syscall.SIGINT) + time.Sleep(100 * time.Millisecond) + if sig := h.StartupSignal(); sig != syscall.SIGTERM { + t.Errorf("StartupSignal() = %v after second signal, want SIGTERM", sig) + } +} + +// A signal that arrived before Attach is forwarded to the child on Attach. +func TestHandler_AttachForwardsStartupSignal(t *testing.T) { + h, ctx := newHandler(syscall.SIGTERM) + syscall.Kill(syscall.Getpid(), syscall.SIGTERM) + select { + case <-ctx.Done(): + case <-time.After(2 * time.Second): + t.Fatal("startup not cancelled after SIGTERM") + } + + c := &childHandler{h: h, cmd: startChild(t, `trap 'exit 42' TERM; while true; do sleep 0.1; done`), cancel: make(chan struct{}), done: make(chan error, 1)} + c.running.Store(true) + go func() { c.done <- c.cmd.Wait() }() + h.Attach(-c.cmd.Process.Pid, &c.running, c.cancel) + + if code, _ := exitStatus(t, c.wait(t)); code != 42 { + t.Errorf("exit code = %d, want 42 (startup signal forwarded)", code) + } +} + +// Under nohup (SIGHUP ignored on entry) the handler must leave SIGHUP alone: +// the child inherited the ignore, so a forwarded SIGHUP would only lead to +// SIGKILL. Other stop signals keep working. Runs via TestHelperProcess. +func TestHandler_LeavesIgnoredSignalsAlone(t *testing.T) { + res := runHelper(t, true, "SR_TEST_HELPER=ignored-hup") + if res.code != 0 || res.sig != 0 { + t.Errorf("helper: code=%d sig=%v\n%s", res.code, res.sig, res.out) + } +} + +func helperIgnoredHUP() { + fail := func(msg string) { + fmt.Println(msg) + os.Exit(1) + } + if !signal.Ignored(syscall.SIGHUP) { + fail("precondition: SIGHUP not ignored on entry") + } + cmd := exec.Command("bash", "-c", `while true; do sleep 0.1; done`) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + fail(err.Error()) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + + var running atomic.Bool + running.Store(true) + killCancel := make(chan struct{}) + _, cancel := context.WithCancel(context.Background()) + h := NewSignalHandler(cancel, syscall.SIGHUP, syscall.SIGTERM) + h.escalateAfter = 300 * time.Millisecond + h.Attach(-cmd.Process.Pid, &running, killCancel) + + if !signal.Ignored(syscall.SIGHUP) { + fail("handler re-enabled an ignored SIGHUP") + } + syscall.Kill(syscall.Getpid(), syscall.SIGHUP) // ignored: nothing may reach the child + select { + case err := <-done: + fail("child died after ignored SIGHUP: " + err.Error()) + case <-time.After(time.Second): + } + + syscall.Kill(syscall.Getpid(), syscall.SIGTERM) // still handled + select { + case <-done: + case <-time.After(3 * time.Second): + fail("child still running after SIGTERM") + } + running.Store(false) + close(killCancel) + h.Stop() + os.Exit(0) +} diff --git a/internal/session/control.go b/internal/session/control.go index fd5fe61..1ef8ec1 100644 --- a/internal/session/control.go +++ b/internal/session/control.go @@ -162,7 +162,7 @@ func (c *Controller) autoRotateWithContext(ctx context.Context) { return } if sess.GetExitIP() == "" { - if ip := detectExitIPRetry(sess.Port, 15*time.Second); ip != "" { + if ip := detectExitIPRetry(ctx, sess.Port, 15*time.Second); ip != "" { sess.SetExitIP(ip) } } @@ -344,7 +344,7 @@ func (c *Controller) httpConnect(w http.ResponseWriter, r *http.Request) { if c.connectTimeout > 0 { retryTimeout = c.connectTimeout } - if ip := detectExitIPRetry(sess.Port, retryTimeout); ip != "" { + if ip := detectExitIPRetry(context.Background(), sess.Port, retryTimeout); ip != "" { sess.SetExitIP(ip) } } @@ -509,7 +509,7 @@ func (c *Controller) httpRotate(w http.ResponseWriter, r *http.Request) { sess := c.sess c.mu.Unlock() if sess != nil && sess.GetExitIP() == "" { - if ip := detectExitIPRetry(sess.Port, 20*time.Second); ip != "" { + if ip := detectExitIPRetry(context.Background(), sess.Port, 20*time.Second); ip != "" { sess.SetExitIP(ip) } } diff --git a/internal/session/control_monitor.go b/internal/session/control_monitor.go index 0d8d236..6d473ba 100644 --- a/internal/session/control_monitor.go +++ b/internal/session/control_monitor.go @@ -69,7 +69,7 @@ func (c *Controller) monitor(ctx context.Context) { c.mu.Unlock() // Re-detect exit IP — it may have changed after recovery - if ip := detectExitIP(sessPort); ip != "" { + if ip := detectExitIP(ctx, sessPort); ip != "" { c.mu.Lock() if c.sess != nil { c.sess.SetExitIP(ip) diff --git a/internal/session/session.go b/internal/session/session.go index 0bbb231..8abebe0 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -201,7 +201,7 @@ func startWithResponse(ctx context.Context, client *api.Client, resp *api.Sessio } connectStart := time.Now() if s.GetExitIP() == "" { - if ip := detectExitIPRetry(port, connectTimeout); ip != "" { + if ip := detectExitIPRetry(ctx, port, connectTimeout); ip != "" { s.SetExitIP(ip) } } @@ -354,14 +354,19 @@ func gatewayNeedsTLS(endpoint string) bool { return host != "localhost" && host != "127.0.0.1" && host != "::1" } -// detectExitIPRetry tries to detect exit IP with retries up to the given timeout. -func detectExitIPRetry(port int, timeout time.Duration) string { +// detectExitIPRetry tries to detect exit IP with retries up to the given +// timeout, or until ctx is cancelled. +func detectExitIPRetry(ctx context.Context, port int, timeout time.Duration) string { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { - if ip := detectExitIP(port); ip != "" { + if ip := detectExitIP(ctx, port); ip != "" { return ip } - time.Sleep(2 * time.Second) + select { + case <-ctx.Done(): + return "" + case <-time.After(2 * time.Second): + } } return "" } @@ -371,7 +376,7 @@ var ipDetectEndpoints = []string{ "https://api.ipify.org", } -func detectExitIP(port int) string { +func detectExitIP(ctx context.Context, port int) string { proxyURL := fmt.Sprintf("http://127.0.0.1:%d", port) transport := &http.Transport{ Proxy: func(*http.Request) (*neturl.URL, error) { @@ -384,7 +389,11 @@ func detectExitIP(port int) string { } for _, endpoint := range ipDetectEndpoints { - resp, err := client.Get(endpoint) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + continue + } + resp, err := client.Do(req) if err != nil { continue } diff --git a/internal/session/session_detect_test.go b/internal/session/session_detect_test.go new file mode 100644 index 0000000..28df1f8 --- /dev/null +++ b/internal/session/session_detect_test.go @@ -0,0 +1,38 @@ +package session + +import ( + "context" + "testing" + "time" +) + +// Nothing listens on port 1, so detection can only end by timeout or cancel. + +func TestDetectExitIPRetry_CancelledBeforeStart(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + start := time.Now() + if ip := detectExitIPRetry(ctx, 1, 30*time.Second); ip != "" { + t.Fatalf("ip = %q, want empty", ip) + } + if d := time.Since(start); d > 2*time.Second { + t.Fatalf("took %v, want immediate return on cancelled context", d) + } +} + +func TestDetectExitIPRetry_CancelledWhileWaiting(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(300 * time.Millisecond) + cancel() + }() + + start := time.Now() + if ip := detectExitIPRetry(ctx, 1, 30*time.Second); ip != "" { + t.Fatalf("ip = %q, want empty", ip) + } + if d := time.Since(start); d > 3*time.Second { + t.Fatalf("took %v, want return shortly after cancel", d) + } +} diff --git a/scripts/test-run-signal-live.sh b/scripts/test-run-signal-live.sh new file mode 100755 index 0000000..e955940 --- /dev/null +++ b/scripts/test-run-signal-live.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash +set -uo pipefail + +# Live signal-handling test for shellroute run. +# Creates one real (paid) session to verify that SIGTERM is forwarded to the +# child, that shellroute then exits by SIGTERM like the child (what systemd +# stop and shells expect), and that the session ends cleanly. +# +# Usage: +# ./scripts/test-run-signal-live.sh --live [COUNTRY] +# +# Requirements: +# - Authenticated shellroute (shellroute login or SHELLROUTE_API_KEY) +# - Go toolchain (builds from current checkout) + +SCRIPT_NAME="$(basename "$0")" +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +usage() { + cat </dev/null; then + kill -TERM "$SR_PID" 2>/dev/null + local w=0 + while kill -0 "$SR_PID" 2>/dev/null && [ $w -lt 25 ]; do + sleep 1; w=$((w + 1)) + done + # Only SIGKILL if still alive (avoid PID reuse) + if kill -0 "$SR_PID" 2>/dev/null; then + kill -9 "$SR_PID" 2>/dev/null + fi + wait "$SR_PID" 2>/dev/null + fi + # Kill child process group by exact PGID + if [ -f "$PGID_FILE" ]; then + local pgid + pgid=$(cat "$PGID_FILE" 2>/dev/null) + if [ -n "$pgid" ] && [ "$pgid" -gt 0 ] 2>/dev/null && kill -0 -- "-$pgid" 2>/dev/null; then + kill -9 -- "-$pgid" 2>/dev/null + fi + fi + [ -d "$WORK_DIR" ] && rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +# --- Build from current checkout --- +echo "=== Building shellroute from current checkout ===" +(cd "$REPO_ROOT" && go build -o "$WORK_DIR/shellroute" ./cmd/shellroute) 2>&1 +if [ $? -ne 0 ]; then + echo "FAIL: build failed" + exit 1 +fi +SR="$WORK_DIR/shellroute" +echo "Built: $SR" + +# --- Verify auth --- +if ! "$SR" balance >/dev/null 2>&1; then + echo "FAIL: not authenticated. Run shellroute login first." + exit 1 +fi +echo "Auth: ok" + +echo "" +echo "=== Running shellroute run $COUNTRY with signal test ===" + +# --- Child script: writes PGID, signals readiness, reports SIGTERM, then dies by it --- +CHILD_SCRIPT=' +pgid_file="$1" +ready_file="$2" +echo $$ > "$pgid_file" +trap '"'"'echo CHILD_GOT_SIGTERM >&2; trap - TERM; kill -TERM $$'"'"' TERM +touch "$ready_file" +while true; do sleep 0.1; done +' + +# Launch shellroute run in background +"$SR" run "$COUNTRY" -- bash -c "$CHILD_SCRIPT" -- "$PGID_FILE" "$READY_FILE" \ + >"$WORK_DIR/stdout" 2>"$WORK_DIR/stderr" & +SR_PID=$! + +# --- Wait for child readiness (max 60s) --- +echo "Waiting for child readiness..." +WAITED=0 +while [ ! -f "$READY_FILE" ] && [ $WAITED -lt 60 ]; do + sleep 1 + WAITED=$((WAITED + 1)) + if ! kill -0 "$SR_PID" 2>/dev/null; then + echo "FAIL: shellroute exited before child was ready." + echo "--- stdout ---" + cat "$WORK_DIR/stdout" + echo "--- stderr ---" + cat "$WORK_DIR/stderr" + exit 1 + fi +done + +if [ ! -f "$READY_FILE" ]; then + echo "FAIL: child did not signal readiness within 60s." + exit 1 +fi +echo "Child ready after ${WAITED}s." + +# --- Send SIGTERM to shellroute process --- +echo "Sending SIGTERM to shellroute (PID $SR_PID)..." +kill -TERM "$SR_PID" + +# --- Wait for shellroute to exit (max 20s: 5s escalation + 15s API timeout) --- +WAITED=0 +while kill -0 "$SR_PID" 2>/dev/null && [ $WAITED -lt 20 ]; do + sleep 1 + WAITED=$((WAITED + 1)) +done + +if kill -0 "$SR_PID" 2>/dev/null; then + echo "FAIL: shellroute did not exit within 20s after SIGTERM." + echo "--- stdout ---" + cat "$WORK_DIR/stdout" + echo "--- stderr ---" + cat "$WORK_DIR/stderr" + exit 1 +fi + +wait "$SR_PID" 2>/dev/null +SR_EXIT=$? +SR_PID="" + +echo "Shellroute exited (code $SR_EXIT) after ${WAITED}s." + +# --- Verify results --- +PASS=0 +FAIL=0 + +show_output() { + echo "--- stdout ---" + cat "$WORK_DIR/stdout" + echo "--- stderr ---" + cat "$WORK_DIR/stderr" +} + +echo "" +echo "=== Verification ===" + +# 1. Shellroute died by SIGTERM like its child (bash reports 128+15) +if [ "$SR_EXIT" -eq 143 ]; then + echo " PASS: shellroute exited by SIGTERM (143), mirroring the child" + PASS=$((PASS + 1)) +else + echo " FAIL: shellroute exit status $SR_EXIT, want 143 (killed by SIGTERM)" + FAIL=$((FAIL + 1)) + show_output +fi + +# 2. Child received SIGTERM +if grep -q "CHILD_GOT_SIGTERM" "$WORK_DIR/stderr"; then + echo " PASS: child received SIGTERM" + PASS=$((PASS + 1)) +else + echo " FAIL: child did not print CHILD_GOT_SIGTERM" + FAIL=$((FAIL + 1)) + show_output +fi + +# 3. Session ended cleanly +if grep -qF "shellroute session ended." "$WORK_DIR/stderr"; then + echo " PASS: session ended cleanly" + PASS=$((PASS + 1)) +else + echo " FAIL: no 'shellroute session ended.' in stderr" + FAIL=$((FAIL + 1)) + show_output +fi + +# 4. No child process group remains (check exact PGID) +if [ -f "$PGID_FILE" ]; then + CHILD_PGID=$(cat "$PGID_FILE") + if [ -n "$CHILD_PGID" ] && [ "$CHILD_PGID" -gt 0 ] 2>/dev/null; then + if kill -0 -- "-$CHILD_PGID" 2>/dev/null; then + echo " FAIL: child process group $CHILD_PGID still running" + FAIL=$((FAIL + 1)) + else + echo " PASS: child process group $CHILD_PGID no longer exists" + PASS=$((PASS + 1)) + fi + else + echo " FAIL: invalid PGID in file: '$CHILD_PGID'" + FAIL=$((FAIL + 1)) + fi +else + echo " FAIL: child did not write PGID file" + FAIL=$((FAIL + 1)) +fi + +echo "" +echo "=== Results ===" +echo "Passed: $PASS Failed: $FAIL" + +if [ $FAIL -gt 0 ]; then + exit 1 +fi + +echo "" +echo "Signal handling verified: SIGTERM → forwarded to child → session ended → shellroute exited by SIGTERM."