From e64c30ccab44818424cfb496292fb16328861a09 Mon Sep 17 00:00:00 2001 From: Saleh Date: Fri, 3 Jul 2026 17:31:35 +0300 Subject: [PATCH] pkg/terminal: add exit -d to detach without killing the process When attached to a running process, exit prompts whether to kill it. For scripted or repetitive sessions there was no way to answer that prompt in advance. Add an exit -d flag that detaches from the target, leaving it running, without prompting. exitCommand records the intent on the terminal and handleExit skips the kill prompt and detaches with kill=false. Fixes #2324 --- Documentation/cli/README.md | 4 ++- pkg/terminal/command.go | 15 ++++++++-- pkg/terminal/terminal.go | 9 +++++- pkg/terminal/terminal_test.go | 53 +++++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/Documentation/cli/README.md b/Documentation/cli/README.md index cd81f30c37..2924e3ba72 100644 --- a/Documentation/cli/README.md +++ b/Documentation/cli/README.md @@ -355,10 +355,12 @@ Aliases: x ## exit Exit the debugger. - exit [-c] + exit [-c] [-d] When connected to a headless instance started with the --accept-multiclient, pass -c to resume the execution of the target process before disconnecting. +Pass -d to detach from the target process, leaving it running, without being prompted whether to kill it. + Aliases: quit q ## frame diff --git a/pkg/terminal/command.go b/pkg/terminal/command.go index 68b9aab090..c71e5f7eb6 100644 --- a/pkg/terminal/command.go +++ b/pkg/terminal/command.go @@ -399,9 +399,11 @@ If regex is specified only package variables with a name matching it will be ret Argument -a shows more registers. Individual registers can also be displayed by 'print' and 'display'. See Documentation/cli/expr.md.`}, {aliases: []string{"exit", "quit", "q"}, cmdFn: exitCommand, helpMsg: `Exit the debugger. - exit [-c] + exit [-c] [-d] -When connected to a headless instance started with the --accept-multiclient, pass -c to resume the execution of the target process before disconnecting.`}, +When connected to a headless instance started with the --accept-multiclient, pass -c to resume the execution of the target process before disconnecting. + +Pass -d to detach from the target process, leaving it running, without being prompted whether to kill it.`}, {aliases: []string{"list", "ls", "l"}, cmdFn: listCommand, helpMsg: `Show source code. [goroutine ] [frame ] list [] @@ -3148,7 +3150,8 @@ func (ere ExitRequestError) Error() string { } func exitCommand(t *Term, ctx callContext, args string) error { - if args == "-c" { + switch args { + case "-c": if !t.client.IsMulticlient() { return errors.New("not connected to an --accept-multiclient server") } @@ -3167,6 +3170,12 @@ func exitCommand(t *Term, ctx callContext, args string) error { } } t.quitContinue = true + case "-d": + t.detachNoKill = true + case "": + // no arguments + default: + return fmt.Errorf("unknown argument %q to exit", args) } return ExitRequestError{} } diff --git a/pkg/terminal/terminal.go b/pkg/terminal/terminal.go index 45fb1b1bcd..fa25258890 100644 --- a/pkg/terminal/terminal.go +++ b/pkg/terminal/terminal.go @@ -76,6 +76,10 @@ type Term struct { // should be resumed before quitting. quitContinue bool + // detachNoKill is set to true by exitCommand when passed -d to signal that + // the target process should be left running, without prompting. + detachNoKill bool + longCommandMu sync.Mutex longCommandCancelFlag bool @@ -593,13 +597,16 @@ func (t *Term) handleExit() (int, error) { if doDetach { kill := true - if t.client.AttachedToExistingProcess() { + if t.client.AttachedToExistingProcess() && !t.detachNoKill { answer, err := yesno(t.line, "Would you like to kill the process? [Y/n] ", "yes") if err != nil { return 2, io.EOF } kill = answer } + if t.detachNoKill { + kill = false + } if err := t.client.Detach(kill); err != nil { return 1, err } diff --git a/pkg/terminal/terminal_test.go b/pkg/terminal/terminal_test.go index db7a3c7018..0521186df3 100644 --- a/pkg/terminal/terminal_test.go +++ b/pkg/terminal/terminal_test.go @@ -4,6 +4,10 @@ import ( "errors" "net/rpc" "testing" + + "github.com/go-delve/delve/service" + "github.com/go-delve/delve/service/api" + "github.com/go-delve/liner" ) func TestIsErrProcessExited(t *testing.T) { @@ -23,3 +27,52 @@ func TestIsErrProcessExited(t *testing.T) { } } } + +// fakeExitClient implements just the service.Client methods that handleExit +// needs; the rest are nil and must not be called by the test. +type fakeExitClient struct { + service.Client + multiclient bool + attached bool + detachKill *bool +} + +func (c *fakeExitClient) GetState() (*api.DebuggerState, error) { return &api.DebuggerState{}, nil } +func (c *fakeExitClient) IsMulticlient() bool { return c.multiclient } +func (c *fakeExitClient) AttachedToExistingProcess() bool { return c.attached } +func (c *fakeExitClient) Detach(kill bool) error { c.detachKill = &kill; return nil } + +func TestExitDetachWithoutKill(t *testing.T) { + oldYesNo := yesno + defer func() { yesno = oldYesNo }() + yesnoCalled := false + yesno = func(_ *liner.State, _, _ string) (bool, error) { + yesnoCalled = true + return true, nil // the interactive default kills the process + } + + fc := &fakeExitClient{attached: true} + term := &Term{client: fc} + + // "exit -d" records the intent to detach without killing. + if err := exitCommand(term, callContext{}, "-d"); err == nil { + t.Fatal("exitCommand should return an ExitRequestError") + } + + status, err := term.handleExit() + if err != nil { + t.Fatalf("handleExit returned error: %v", err) + } + if status != 0 { + t.Errorf("exit status = %d, want 0", status) + } + if yesnoCalled { + t.Error("exit -d must not prompt whether to kill the process") + } + if fc.detachKill == nil { + t.Fatal("Detach was not called") + } + if *fc.detachKill { + t.Error("exit -d must detach without killing the process (kill=false)") + } +}