Repository: go-delve/delve

Issue:
Performance: huge memory usage issue caused by trie.Trie.Add()
<img width="3840" height="1916" alt="Image" src="https://github.com/user-attachments/assets/24a4d44e-06f9-469d-aa39-8eae0858d419" />

I obtained a delve heap flame graph with extremely high memory usage, from which it can be seen that calls to trie.Add() occupy nearly 8GB of memory. I suspect that when the number of function definitions is extremely large, the trie will become a major performance bottleneck.


Discussion:
@aarzilli: I wish we could delete PackageVariables.@jayantxie: `terminal.(*Term).Run` should also be optimized, but I have no idea for now.@aarzilli: In what way?@aarzilli: i.e. what is about Term.Run that is slow?@jayantxie: There are two feasible approaches: one is to optimize the implementation of trie/v3 by using a radix tree to construct the prefix tree. This avoids creating a tree node for every rune—for example, in the case of `mypackage/internal/private.veryLongFunctionName`, the current implementation would create dozens of nodes. If optimized to a radix tree, nodes would only be split when necessary. However, this optimization requires significant effort.
A simpler workaround is lazy initialization, where the trie tree is only initialized when the fuzzy search functionality for functions is used. The trade-off is that users may experience a brief delay the first time they use code completion, but this is generally acceptable.
@jayantxie: @aarzilli It seems that the memory issue with `func (t *Term) Run()` here still hasn't been resolved. This problem should be quite serious, right? Don't you consider fixing it? @aarzilli: What is the issue with (*Term).Run?@jayantxie: @aarzilli As shown in the flame graph, in a large Go program, if the number of functions is large, the cost of `func (t *Term) Run()` to support autocompletion is very high. Therefore, I propose changing it to lazy init, loading the functions trie only when necessary.@derekparker: @jayantxie can you give me steps to reproduce this? I am working on more patches to optimize trie, and would like to test against this case.@jayantxie: @derekparker I can't provide you with the binary file reproduced by our internal environment, but I can give you a script that simulates this binary file. Their characteristics are similar, both having a large number of function definitions, which leads to high memory consumption when constructing the trie. 
You can save this code block to the `generate_functions.go` file, then run `go run generate_functions.go 200000`, and afterward, you can verify it by taking a heap profile of delve.
```
package main

import (
	"fmt"
	"math/rand"
	"os"
	"strconv"
	"time"
)

// Generate random string with seed for reproducibility
func randomStringWithSeed(length int, r *rand.Rand) string {
	const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
	b := make([]byte, length)
	for i := range b {
		b[i] = charset[r.Intn(len(charset))]
	}
	return string(b)
}

func main() {
	if len(os.Args) != 2 {
		fmt.Println("Usage: go run generate_functions.go <number_of_functions>")
		os.Exit(1)
	}

	numFuncs, err := strconv.Atoi(os.Args[1])
	if err != nil {
		fmt.Printf("Error: Please provide a valid number of functions\n")
		os.Exit(1)
	}

	if numFuncs <= 0 {
		fmt.Printf("Error: Number of functions must be positive\n")
		os.Exit(1)
	}

	fmt.Printf("Generating Go program with %d functions...\n", numFuncs)

	// Generate output file
	outputFile := "generated_program.go"
	file, err := os.Create(outputFile)
	if err != nil {
		fmt.Printf("Error creating file: %v\n", err)
		os.Exit(1)
	}
	defer file.Close()

	// Write package declaration and imports
	file.WriteString("package main\n\n")
	file.WriteString("import (\n")
	file.WriteString("\t\"fmt\"\n")
	file.WriteString("\t\"runtime\"\n")
	file.WriteString("\t\"time\"\n")
	file.WriteString(")\n\n")

	// Generate functions - each function uses completely random names
	// Use fixed seed to ensure function names are consistent between generation and calling
	baseSeed := time.Now().UnixNano()

	for i := 0; i < numFuncs; i++ {
		// Generate random function name, ensuring each function name is unique
		seededRand := rand.New(rand.NewSource(baseSeed + int64(i)))
		randomPart := randomStringWithSeed(8, seededRand)
		funcName := fmt.Sprintf("f%s_%d", randomPart, i)

		// Create different logic for each function to avoid compiler optimization
		file.WriteString(fmt.Sprintf("func %s() int {\n", funcName))
		file.WriteString(fmt.Sprintf("\t// Function %d - testing trie memory usage\n", i))

		// Add different computation logic
		switch i % 10 {
		case 0:
			file.WriteString(fmt.Sprintf("\tx := %d\n", i))
			file.WriteString("\ty := x * 2\n")
			file.WriteString("\treturn y\n")
		case 1:
			file.WriteString(fmt.Sprintf("\ta := %d\n", i%100))
			file.WriteString("\tb := a + 50\n")
			file.WriteString("\treturn b - a\n")
		case 2:
			file.WriteString(fmt.Sprintf("\tval := %.1f\n", float64(i)+0.5))
			file.WriteString("\treturn int(val * 2)\n")
		case 3:
			file.WriteString(fmt.Sprintf("\tbase := %d\n", i))
			file.WriteString("\tresult := base << 1\n")
			file.WriteString("\treturn result >> 1\n")
		case 4:
			file.WriteString(fmt.Sprintf("\tnum := %d\n", i%1000))
			file.WriteString("\tif num < 500 {\n")
			file.WriteString("\t\treturn num * 2\n")
			file.WriteString("\t}\n")
			file.WriteString("\treturn num\n")
		case 5:
			file.WriteString(fmt.Sprintf("\tcount := %d\n", i))
			file.WriteString("\ttotal := 0\n")
			file.WriteString("\tfor j := 0; j < count && j < 10; j++ {\n")
			file.WriteString("\t\ttotal += j\n")
			file.WriteString("\t}\n")
			file.WriteString("\treturn total\n")
		case 6:
			file.WriteString(fmt.Sprintf("\tx := %d\n", i%100))
			file.WriteString(fmt.Sprintf("\ty := %d\n", (i+1)%100))
			file.WriteString("\treturn x + y - (x + y)/2\n")
		case 7:
			file.WriteString(fmt.Sprintf("\tval := %d\n", i))
			file.WriteString("\tswitch val % 3 {\n")
			file.WriteString("\tcase 0:\n")
			file.WriteString("\t\treturn val\n")
			file.WriteString("\tcase 1:\n")
			file.WriteString("\t\treturn val * 2\n")
			file.WriteString("\tcase 2:\n")
			file.WriteString("\t\treturn val / 2\n")
			file.WriteString("\t}\n")
			file.WriteString("\treturn val\n")
		case 8:
			file.WriteString(fmt.Sprintf("\tbase := %d\n", i%50))
			file.WriteString("\treturn base * (base + 1) / 2\n")
		case 9:
			file.WriteString(fmt.Sprintf("\tnum := %d\n", i))
			file.WriteString("\treturn (num * (num + 1)) % 1000\n")
		}
		file.WriteString("}\n\n")
	}

	// Generate helper function to call other functions
	file.WriteString("// Helper function to call functions by index\n")
	file.WriteString("func callFunctionByIndex(index int) int {\n")
	file.WriteString("\tswitch index {\n")

	// Regenerate random function names to match previously generated functions
	for i := 0; i < numFuncs; i++ {
		seededRand := rand.New(rand.NewSource(baseSeed + int64(i)))
		randomPart := randomStringWithSeed(8, seededRand)
		funcName := fmt.Sprintf("f%s_%d", randomPart, i)
		file.WriteString(fmt.Sprintf("\tcase %d:\n", i))
		file.WriteString(fmt.Sprintf("\t\treturn %s()\n", funcName))
	}

	file.WriteString("\tdefault:\n")
	file.WriteString("\t\treturn 0\n")
	file.WriteString("\t}\n")
	file.WriteString("}\n\n")

	// Generate main function to call all functions
	file.WriteString("func main() {\n")
	file.WriteString("\tfmt.Println(\"Starting program with\", runtime.NumGoroutine(), \"goroutines\")\n")
	file.WriteString("\tstart := time.Now()\n")
	file.WriteString("\n")
	file.WriteString("\t// Call functions in batches to avoid overwhelming the stack\n")
	file.WriteString("\tbatchSize := 100\n")
	file.WriteString(fmt.Sprintf("\ttotalBatches := %d / batchSize\n", numFuncs))
	file.WriteString("\ttotal := 0\n")
	file.WriteString("\n")
	file.WriteString("\tfor batch := 0; batch < totalBatches; batch++ {\n")
	file.WriteString("\t\tbatchTotal := 0\n")
	file.WriteString("\t\tfor i := 0; i < batchSize; i++ {\n")
	file.WriteString("\t\t\tfuncIndex := batch*batchSize + i\n")
	file.WriteString("\t\t\tif funcIndex >= ")
	file.WriteString(strconv.Itoa(numFuncs))
	file.WriteString(" {\n")
	file.WriteString("\t\t\t\tbreak\n")
	file.WriteString("\t\t\t}\n")
	file.WriteString("\n")

	// Generate function calls - simplified function calling logic
	file.WriteString("\t\t\t// Call function directly by index\n")
	file.WriteString("\t\t\tbatchTotal += callFunctionByIndex(funcIndex)\n")
	file.WriteString("\t\t}\n")
	file.WriteString("\t\ttotal += batchTotal\n")
	file.WriteString("\t\tfmt.Printf(\"Batch %d completed, subtotal: %d\\n\", batch+1, batchTotal)\n")
	file.WriteString("\t}\n")
	file.WriteString("\n")
	file.WriteString("\tduration := time.Since(start)\n")
	file.WriteString("\tfmt.Printf(\"Total: %d\\n\", total)\n")
	file.WriteString("\tfmt.Printf(\"Duration: %v\\n\", duration)\n")
	file.WriteString("\tfmt.Printf(\"Final goroutine count: %d\\n\", runtime.NumGoroutine())\n")
	file.WriteString("\tfmt.Println(\"Program completed successfully\")\n")
	file.WriteString("\n")
	file.WriteString("\t// Set breakpoint here for debugging\n")
	file.WriteString("\tdebugHook := func() {\n")
	file.WriteString("\t\tfmt.Println(\"Debugging hook - set breakpoint here\")\n")
	file.WriteString("\t}\n")
	file.WriteString("\tdebugHook()\n")
	file.WriteString("}\n")

	fmt.Printf("Generated %d functions in %s\n", numFuncs, outputFile)
	fmt.Println("You can now build and debug this program with Delve:")
	fmt.Printf("  go build -o test_program %s\n", outputFile)
	fmt.Println("  dlv exec ./test_program")
}

```@jayantxie: The heap profile of delve sampled by my reproduction:
<img width="3840" height="1916" alt="Image" src="https://github.com/user-attachments/assets/eaa778d2-2918-4c57-81a3-894ea658840b" />@derekparker: It's really hard to compare the two, given the initial report has 9.5GB of heap usage and the example generated program is sitting around 40MB.@jayantxie: If you want to fully reproduce the program constructed by that 9 Gb scenario, it will be more complex because it is code generated from a large number of thrift description files, containing a large number of serialization/deserialization functions, which in turn are more prominent in both the number of functions and the length of individual function names. This case is relatively extreme, but it is indeed an obvious issue in our scenario debugging.

Solution patch:
diff --git a/pkg/proc/types.go b/pkg/proc/types.go
index 01a7675c..ea434340 100644
--- a/pkg/proc/types.go
+++ b/pkg/proc/types.go
@@ -149,7 +149,7 @@ func dwarfToRuntimeType(bi *BinaryInfo, mem MemoryReadWriter, typ godwarf.Type)
 		return 0, false, false, nil
 	}
 
-	mds, err := LoadModuleData(bi, mem)
+	mds, err := bi.getModuleData(mem)
 	if err != nil {
 		return 0, false, false, err
 	}
diff --git a/pkg/proc/variables.go b/pkg/proc/variables.go
index 54594d74..b24225c1 100644
--- a/pkg/proc/variables.go
+++ b/pkg/proc/variables.go
@@ -2105,7 +2105,7 @@ func (v *Variable) loadInterface(recurseLevel int, loadData bool, cfg LoadConfig
 		return
 	}
 
-	mds, err := LoadModuleData(_type.bi, _type.mem)
+	mds, err := _type.bi.getModuleData(_type.mem)
 	if err != nil {
 		v.Unreadable = fmt.Errorf("error loading module data: %v", err)
 		return
diff --git a/pkg/terminal/command.go b/pkg/terminal/command.go
index 1eeee7db..b8d29757 100644
--- a/pkg/terminal/command.go
+++ b/pkg/terminal/command.go
@@ -1128,13 +1128,7 @@ func (t *Term) formatGoroutine(g *api.Goroutine, fgl api.FormatGoroutineLoc) str
 	}
 
 	if (g.Status == api.GoroutineWaiting || g.Status == api.GoroutineSyscall) && g.WaitReason != 0 {
-		var wr string
-		if g.WaitReason > 0 && g.WaitReason < int64(len(waitReasonStrings)) {
-			wr = waitReasonStrings[g.WaitReason]
-		} else {
-			wr = fmt.Sprintf("unknown wait reason %d", g.WaitReason)
-		}
-		fmt.Fprintf(buf, " [%s", wr)
+		fmt.Fprintf(buf, " [%s", api.WaitReasonString(t.goVersion(), g.WaitReason))
 		if g.WaitSince > 0 {
 			fmt.Fprintf(buf, " %d", g.WaitSince)
 		}
@@ -1144,43 +1138,6 @@ func (t *Term) formatGoroutine(g *api.Goroutine, fgl api.FormatGoroutineLoc) str
 	return buf.String()
 }
 
-var waitReasonStrings = [...]string{
-	"",
-	"GC assist marking",
-	"IO wait",
-	"chan receive (nil chan)",
-	"chan send (nil chan)",
-	"dumping heap",
-	"garbage collection",
-	"garbage collection scan",
-	"panicwait",
-	"select",
-	"select (no cases)",
-	"GC assist wait",
-	"GC sweep wait",
-	"GC scavenge wait",
-	"chan receive",
-	"chan send",
-	"finalizer wait",
-	"force gc (idle)",
-	"semacquire",
-	"sleep",
-	"sync.Cond.Wait",
-	"timer goroutine (idle)",
-	"trace reader (blocked)",
-	"wait for GC cycle",
-	"GC worker (idle)",
-	"preempted",
-	"debug call",
-	"GC mark termination",
-	"stopping the world",
-	"flushing proc caches",
-	"trace goroutine status",
-	"trace proc status",
-	"page trace flush",
-	"coroutine",
-}
-
 func writeGoroutineLong(t *Term, w io.Writer, g *api.Goroutine, prefix string) {
 	fmt.Fprintf(w, "%sGoroutine %d:\n%s\tRuntime: %s\n%s\tUser: %s\n%s\tGo: %s\n%s\tStart: %s\n",
 		prefix, g.ID,
diff --git a/pkg/terminal/terminal.go b/pkg/terminal/terminal.go
index aeb39cc7..9b28b169 100644
--- a/pkg/terminal/terminal.go
+++ b/pkg/terminal/terminal.go
@@ -17,6 +17,7 @@ import (
 	"github.com/go-delve/liner"
 
 	"github.com/go-delve/delve/pkg/config"
+	"github.com/go-delve/delve/pkg/goversion"
 	"github.com/go-delve/delve/pkg/locspec"
 	"github.com/go-delve/delve/pkg/terminal/colorize"
 	"github.com/go-delve/delve/pkg/terminal/starbind"
@@ -83,6 +84,8 @@ type Term struct {
 
 	downloadsMu         sync.Mutex
 	downloadsInProgress bool
+
+	goVersionCache *goversion.GoVersion
 }
 
 type displayEntry struct {
@@ -309,18 +312,14 @@ func (t *Term) Run() (int, error) {
 	signal.Notify(ch, syscall.SIGINT)
 	go t.sigintGuard(ch, multiClient)
 
-	fns := trie.New[any]()
 	cmds := trie.New[any]()
-	funcs, _ := t.client.ListFunctions("", 0)
-	for _, fn := range funcs {
-		fns.Add(fn, nil)
-	}
 	for _, cmd := range t.cmds.cmds {
 		for _, alias := range cmd.aliases {
 			cmds.Add(alias, nil)
 		}
 	}
 
+	var fns *trie.Trie[any]
 	var locs *trie.Trie[any]
 
 	t.line.SetCompleter(func(line string) (c []string) {
@@ -329,6 +328,13 @@ func (t *Term) Run() (int, error) {
 		case "break", "trace", "continue":
 			if spc := strings.LastIndex(line, " "); spc > 0 {
 				prefix := line[:spc] + " "
+				if fns == nil {
+					fns = trie.New[any]()
+					funcs, _ := t.client.ListFunctions("", 0)
+					for _, fn := range funcs {
+						fns.Add(fn, nil)
+					}
+				}
 				funcs := fns.FuzzySearch(line[spc+1:])
 				for _, f := range funcs {
 					c = append(c, prefix+f)
@@ -673,6 +679,16 @@ func (t *Term) RedirectTo(w io.Writer) {
 	t.stdout.pw.w = w
 }
 
+func (t *Term) goVersion() *goversion.GoVersion {
+	if t.goVersionCache != nil {
+		return t.goVersionCache
+	}
+	vers := t.client.GetVersion()
+	v, _ := goversion.Parse(vers.TargetGoVersion)
+	t.goVersionCache = &v
+	return t.goVersionCache
+}
+
 // isErrProcessExited returns true if `err` is an RPC error equivalent of proc.ErrProcessExited
 func isErrProcessExited(err error) bool {
 	rpcError, ok := err.(rpc.ServerError)
diff --git a/service/api/waitreason.go b/service/api/waitreason.go
new file mode 100644
index 00000000..db46a6e4
--- /dev/null
+++ b/service/api/waitreason.go
@@ -0,0 +1,287 @@
+package api
+
+import (
+	"fmt"
+
+	"github.com/go-delve/delve/pkg/goversion"
+)
+
+var waitReasonStrings1dot18 = []string{
+	"",
+	"GC assist marking",
+	"IO wait",
+	"chan receive (nil chan)",
+	"chan send (nil chan)",
+	"dumping heap",
+	"garbage collection",
+	"garbage collection scan",
+	"panicwait",
+	"select",
+	"select (no cases)",
+	"GC assist wait",
+	"GC sweep wait",
+	"GC scavenge wait",
+	"chan receive",
+	"chan send",
+	"finalizer wait",
+	"force gc (idle)",
+	"semacquire",
+	"sleep",
+	"sync.Cond.Wait",
+	"timer goroutine (idle)",
+	"trace reader (blocked)",
+	"wait for GC cycle",
+	"GC worker (idle)",
+	"preempted",
+	"debug call",
+}
+
+var waitReasonStrings1dot20 = []string{
+	"",
+	"GC assist marking",
+	"IO wait",
+	"chan receive (nil chan)",
+	"chan send (nil chan)",
+	"dumping heap",
+	"garbage collection",
+	"garbage collection scan",
+	"panicwait",
+	"select",
+	"select (no cases)",
+	"GC assist wait",
+	"GC sweep wait",
+	"GC scavenge wait",
+	"chan receive",
+	"chan send",
+	"finalizer wait",
+	"force gc (idle)",
+	"semacquire",
+	"sleep",
+	"sync.Cond.Wait",
+	"sync.Mutex.Lock",
+	"sync.RWMutex.RLock",
+	"sync.RWMutex.Lock",
+	"trace reader (blocked)",
+	"wait for GC cycle",
+	"GC worker (idle)",
+	"GC worker (active)",
+	"preempted",
+	"debug call",
+	"GC mark termination",
+	"stopping the world",
+}
+
+var waitReasonStrings1dot22 = []string{
+	"",
+	"GC assist marking",
+	"IO wait",
+	"chan receive (nil chan)",
+	"chan send (nil chan)",
+	"dumping heap",
+	"garbage collection",
+	"garbage collection scan",
+	"panicwait",
+	"select",
+	"select (no cases)",
+	"GC assist wait",
+	"GC sweep wait",
+	"GC scavenge wait",
+	"chan receive",
+	"chan send",
+	"finalizer wait",
+	"force gc (idle)",
+	"semacquire",
+	"sleep",
+	"sync.Cond.Wait",
+	"sync.Mutex.Lock",
+	"sync.RWMutex.RLock",
+	"sync.RWMutex.Lock",
+	"trace reader (blocked)",
+	"wait for GC cycle",
+	"GC worker (idle)",
+	"GC worker (active)",
+	"preempted",
+	"debug call",
+	"GC mark termination",
+	"stopping the world",
+	"flushing proc caches",
+	"trace goroutine status",
+	"trace proc status",
+	"page trace flush",
+	"coroutine",
+}
+
+var waitReasonStrings1dot24 = []string{
+	"",
+	"GC assist marking",
+	"IO wait",
+	"chan receive (nil chan)",
+	"chan send (nil chan)",
+	"dumping heap",
+	"garbage collection",
+	"garbage collection scan",
+	"panicwait",
+	"select",
+	"select (no cases)",
+	"GC assist wait",
+	"GC sweep wait",
+	"GC scavenge wait",
+	"chan receive",
+	"chan send",
+	"finalizer wait",
+	"force gc (idle)",
+	"semacquire",
+	"sleep",
+	"sync.Cond.Wait",
+	"sync.Mutex.Lock",
+	"sync.RWMutex.RLock",
+	"sync.RWMutex.Lock",
+	"sync.WaitGroup.Wait",
+	"trace reader (blocked)",
+	"wait for GC cycle",
+	"GC worker (idle)",
+	"GC worker (active)",
+	"preempted",
+	"debug call",
+	"GC mark termination",
+	"stopping the world",
+	"flushing proc caches",
+	"trace goroutine status",
+	"trace proc status",
+	"page trace flush",
+	"coroutine",
+	"GC weak to strong wait",
+	"synctest.Run",
+	"synctest.Wait",
+	"chan receive (synctest)",
+	"chan send (synctest)",
+}
+
+var waitReasonStrings1dot25 = []string{
+	"",
+	"GC assist marking",
+	"IO wait",
+	"chan receive (nil chan)",
+	"chan send (nil chan)",
+	"dumping heap",
+	"garbage collection",
+	"garbage collection scan",
+	"panicwait",
+	"select",
+	"select (no cases)",
+	"GC assist wait",
+	"GC sweep wait",
+	"GC scavenge wait",
+	"chan receive",
+	"chan send",
+	"finalizer wait",
+	"force gc (idle)",
+	"GOMAXPROCS updater (idle)",
+	"semacquire",
+	"sleep",
+	"sync.Cond.Wait",
+	"sync.Mutex.Lock",
+	"sync.RWMutex.RLock",
+	"sync.RWMutex.Lock",
+	"sync.WaitGroup.Wait",
+	"trace reader (blocked)",
+	"wait for GC cycle",
+	"GC worker (idle)",
+	"GC worker (active)",
+	"preempted",
+	"debug call",
+	"GC mark termination",
+	"stopping the world",
+	"flushing proc caches",
+	"trace goroutine status",
+	"trace proc status",
+	"page trace flush",
+	"coroutine",
+	"GC weak to strong wait",
+	"synctest.Run",
+	"synctest.Wait",
+	"chan receive (durable)",
+	"chan send (durable)",
+	"select (durable)",
+	"sync.WaitGroup.Wait (durable)",
+	"cleanup wait",
+}
+
+var waitReasonStrings1dot26 = []string{
+	"",
+	"GC assist marking",
+	"IO wait",
+	"dumping heap",
+	"garbage collection",
+	"garbage collection scan",
+	"panicwait",
+	"GC assist wait",
+	"GC sweep wait",
+	"GC scavenge wait",
+	"finalizer wait",
+	"force gc (idle)",
+	"GOMAXPROCS updater (idle)",
+	"semacquire",
+	"sleep",
+	"chan receive (nil chan)",
+	"chan send (nil chan)",
+	"select (no cases)",
+	"select",
+	"chan receive",
+	"chan send",
+	"sync.Cond.Wait",
+	"sync.Mutex.Lock",
+	"sync.RWMutex.RLock",
+	"sync.RWMutex.Lock",
+	"sync.WaitGroup.Wait",
+	"trace reader (blocked)",
+	"wait for GC cycle",
+	"GC worker (idle)",
+	"GC worker (active)",
+	"preempted",
+	"debug call",
+	"GC mark termination",
+	"stopping the world",
+	"flushing proc caches",
+	"trace goroutine status",
+	"trace proc status",
+	"page trace flush",
+	"coroutine",
+	"GC weak to strong wait",
+	"synctest.Run",
+	"synctest.Wait",
+	"chan receive (durable)",
+	"chan send (durable)",
+	"select (durable)",
+	"sync.WaitGroup.Wait (durable)",
+	"cleanup wait",
+}
+
+func WaitReasonString(goVersion *goversion.GoVersion, waitReason int64) string {
+	var waitReasonStrings []string
+
+	switch goVersion.Minor {
+	case 18, 19:
+		waitReasonStrings = waitReasonStrings1dot18
+	case 20, 21:
+		waitReasonStrings = waitReasonStrings1dot20
+	case 22, 23:
+		waitReasonStrings = waitReasonStrings1dot22
+	case 24:
+		waitReasonStrings = waitReasonStrings1dot24
+	case 25:
+		waitReasonStrings = waitReasonStrings1dot25
+	case 26:
+		waitReasonStrings = waitReasonStrings1dot26
+	}
+
+	if goVersion.Major != 1 {
+		waitReasonStrings = nil
+	}
+
+	if waitReasonStrings != nil && waitReason > 0 && waitReason < int64(len(waitReasonStrings)) {
+		return waitReasonStrings[waitReason]
+	} else {
+		return fmt.Sprintf("unknown wait reason %d", waitReason)
+	}
+}
diff --git a/service/client.go b/service/client.go
index e7f57b3c..1734aed9 100644
--- a/service/client.go
+++ b/service/client.go
@@ -9,6 +9,9 @@ import (
 // Client represents a debugger service client. All client methods are
 // synchronous.
 type Client interface {
+	// GetVersion returns assorted version information.
+	GetVersion() *api.GetVersionOut
+
 	// ProcessPid returns the pid of the process we are debugging.
 	ProcessPid() int
 
diff --git a/service/dap/server.go b/service/dap/server.go
index cb6ca55f..99920cae 100644
--- a/service/dap/server.go
+++ b/service/dap/server.go
@@ -160,12 +160,6 @@ type Session struct {
 	// changing the state of the running process at the same time.
 	changeStateMu sync.Mutex
 
-	// stdoutReader the program's stdout.
-	stdoutReader io.ReadCloser
-
-	// stderrReader the program's stderr.
-	stderrReader io.ReadCloser
-
 	// preTerminatedWG the WaitGroup that needs to wait before sending a terminated event.
 	preTerminatedWG sync.WaitGroup
 }
@@ -1060,10 +1054,10 @@ func (s *Session) onLaunchRequest(request *dap.LaunchRequest) {
 	argsToLog.Cwd, _ = filepath.Abs(args.Cwd)
 	s.config.log.Debugf("launching binary '%s' with config: %s", debugbinary, prettyPrint(argsToLog))
 
-	redirected := false
+	remoteOut := false
 	switch args.OutputMode {
 	case "remote":
-		redirected = true
+		remoteOut = true
 	case "local", "":
 		// noting
 	default:
@@ -1072,7 +1066,13 @@ func (s *Session) onLaunchRequest(request *dap.LaunchRequest) {
 		return
 	}
 
-	redirectedFunc := func(stdoutReader io.ReadCloser, stderrReader io.ReadCloser) {
+	if remoteOut && (args.StdoutTo != "" || args.StderrTo != "") {
+		s.sendShowUserErrorResponse(request.Request, FailedToLaunch, "Failed to launch",
+			"output == \"remote\" can not be used together with stdoutTo or stderrTo")
+		return
+	}
+
+	remoteOutFunc := func(stdoutReader io.ReadCloser, stderrReader io.ReadCloser) {
 		runReadFunc := func(reader io.ReadCloser, category string) {
 			defer s.preTerminatedWG.Done()
 			defer reader.Close()
@@ -1107,7 +1107,7 @@ func (s *Session) onLaunchRequest(request *dap.LaunchRequest) {
 
 	if args.NoDebug {
 		s.mu.Lock()
-		cmd, err := s.newNoDebugProcess(debugbinary, args.Args, s.config.Debugger.WorkingDir, redirected)
+		cmd, stdoutReader, stderrReader, err := s.newNoDebugProcess(debugbinary, args.Args, s.config.Debugger.WorkingDir, remoteOut, args.StdinFrom, args.StdoutTo, args.StderrTo)
 		s.mu.Unlock()
 		if err != nil {
 			s.sendShowUserErrorResponse(request.Request, FailedToLaunch, "Failed to launch", err.Error())
@@ -1119,8 +1119,8 @@ func (s *Session) onLaunchRequest(request *dap.LaunchRequest) {
 
 		// Start the program on a different goroutine, so we can listen for disconnect request.
 		go func() {
-			if redirected {
-				redirectedFunc(s.stdoutReader, s.stderrReader)
+			if remoteOut {
+				remoteOutFunc(stdoutReader, stderrReader)
 			}
 
 			if err := cmd.Wait(); err != nil {
@@ -1134,8 +1134,12 @@ func (s *Session) onLaunchRequest(request *dap.LaunchRequest) {
 		return
 	}
 
+	s.config.Debugger.Stdin = args.StdinFrom
+	s.config.Debugger.Stdout = proc.OutputRedirect{Path: args.StdoutTo}
+	s.config.Debugger.Stderr = proc.OutputRedirect{Path: args.StderrTo}
+
 	var closeAll func()
-	if redirected {
+	if remoteOut {
 		var (
 			readers         [2]io.ReadCloser
 			outputRedirects [2]proc.OutputRedirect
@@ -1153,7 +1157,7 @@ func (s *Session) onLaunchRequest(request *dap.LaunchRequest) {
 		s.config.Debugger.Stdout = outputRedirects[0]
 		s.config.Debugger.Stderr = outputRedirects[1]
 
-		redirectedFunc(readers[0], readers[1])
+		remoteOutFunc(readers[0], readers[1])
 		closeAll = func() {
 			for index := range readers {
 				if closeErr := readers[index].Close(); closeErr != nil {
@@ -1218,32 +1222,66 @@ func (s *Session) getPackageDir(pkg string) string {
 
 // newNoDebugProcess is called from onLaunchRequest (run goroutine) and
 // requires holding mu lock. It prepares process exec.Cmd to be started.
-func (s *Session) newNoDebugProcess(program string, targetArgs []string, wd string, redirected bool) (cmd *exec.Cmd, err error) {
+func (s *Session) newNoDebugProcess(program string, targetArgs []string, wd string, remoteOut bool, stdinFrom, stdoutTo, stderrTo string) (cmd *exec.Cmd, stdoutReader, stderrReader io.ReadCloser, err error) {
 	if s.noDebugProcess != nil {
-		return nil, errors.New("another launch request is in progress")
+		return nil, nil, nil, errors.New("another launch request is in progress")
 	}
 
 	cmd = exec.Command(program, targetArgs...)
 	cmd.Stdin, cmd.Dir = os.Stdin, wd
 
-	if redirected {
-		if s.stderrReader, err = cmd.StderrPipe(); err != nil {
-			return nil, err
+	if stdinFrom != "" {
+		fh, err := os.Open(stdinFrom)
+		if err != nil {
+			return nil, nil, nil, fmt.Errorf("could not open stdin file: %v", err)
 		}
+		cmd.Stdin = fh
+	}
 
-		if s.stdoutReader, err = cmd.StdoutPipe(); err != nil {
-			return nil, err
+	if remoteOut {
+		if stderrReader, err = cmd.StderrPipe(); err != nil {
+			return nil, nil, nil, err
+		}
+
+		if stdoutReader, err = cmd.StdoutPipe(); err != nil {
+			return nil, nil, nil, err
 		}
 	} else {
-		cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
+		var err error
+		toclose := []*os.File{}
+		create := func(redirect string, dflt *os.File) (f *os.File) {
+			if redirect != "" {
+				f, err = os.Create(redirect)
+				toclose = append(toclose, f)
+
+				return f
+			}
+
+			return dflt
+		}
+		defer func() {
+			for _, f := range toclose {
+				f.Close()
+			}
+		}()
+
+		cmd.Stdout = create(stdoutTo, os.Stdout)
+		if err != nil {
+			return nil, nil, nil, fmt.Errorf("could not create stdout file: %v", err)
+		}
+
+		cmd.Stderr = create(stderrTo, os.Stderr)
+		if err != nil {
+			return nil, nil, nil, fmt.Errorf("could not create stderr file: %v", err)
+		}
 	}
 
 	if err = cmd.Start(); err != nil {
-		return nil, err
+		return nil, nil, nil, err
 	}
 
 	s.noDebugProcess = &process{Cmd: cmd, exited: make(chan struct{})}
-	return cmd, nil
+	return cmd, stdoutReader, stderrReader, nil
 }
 
 // stopNoDebugProcess is called from Stop (main goroutine) and
@@ -3121,8 +3159,11 @@ func (s *Session) onRestartRequest(request *dap.RestartRequest) {
 	s.setHaltRequested(true)
 	_, err := s.halt()
 	if err != nil {
-		s.sendErrorResponse(request.Request, FailedToLaunch, "Cannot restart", fmt.Sprintf("cannot restart process: %v", err))
-		return
+		var errProcessExited proc.ErrProcessExited
+		if !errors.As(err, &errProcessExited) {
+			s.sendErrorResponse(request.Request, FailedToLaunch, "Cannot restart", fmt.Sprintf("cannot restart process: %v", err))
+			return
+		}
 	}
 
 	// Cannot restart attached processes
diff --git a/service/dap/types.go b/service/dap/types.go
index 8ce33e80..a7e451d9 100644
--- a/service/dap/types.go
+++ b/service/dap/types.go
@@ -159,6 +159,10 @@ type LaunchConfig struct {
 	// The output mode specifies how to handle the program's output.
 	OutputMode string `json:"outputMode,omitempty"`
 
+	StdinFrom string `json:"stdinFrom,omitempty"` // Redirects stadard input to a file
+	StdoutTo  string `json:"stdoutTo,omitempty"`  // Redirects standard output to a file
+	StderrTo  string `json:"stderrTo,omitempty"`  // Redirects standard error to a file
+
 	LaunchAttachCommonConfig
 }
 
diff --git a/service/rpc2/client.go b/service/rpc2/client.go
index fb6d3436..91154484 100644
--- a/service/rpc2/client.go
+++ b/service/rpc2/client.go
@@ -48,6 +48,12 @@ func NewClientFromConn(conn net.Conn) *RPCClient {
 	return newFromRPCClient(jsonrpc.NewClient(conn))
 }
 
+func (c *RPCClient) GetVersion() *api.GetVersionOut {
+	out := new(api.GetVersionOut)
+	c.call("GetVersion", api.GetVersionIn{}, out)
+	return out
+}
+
 func (c *RPCClient) ProcessPid() int {
 	out := new(ProcessPidOut)
 	c.call("ProcessPid", ProcessPidIn{}, out)



Test patch:
diff --git a/cmd/dlv/dlv_test.go b/cmd/dlv/dlv_test.go
index 45acecf4..55d22b8b 100644
--- a/cmd/dlv/dlv_test.go
+++ b/cmd/dlv/dlv_test.go
@@ -518,6 +518,9 @@ func TestTypecheckRPC(t *testing.T) {
 		case "SetReturnValuesLoadConfig", "Disconnect", "SetEventsFn":
 			// support functions
 			continue
+		case "GetVersion":
+			// rpccommon.go method
+			continue
 		}
 
 		if fndecl.Name.Name == "Continue" || fndecl.Name.Name == "Rewind" || fndecl.Name.Name == "DirectionCongruentContinue" {
diff --git a/service/dap/server_test.go b/service/dap/server_test.go
index 9649d59e..240a407d 100644
--- a/service/dap/server_test.go
+++ b/service/dap/server_test.go
@@ -7795,6 +7795,7 @@ func TestSetExceptionBreakpoints(t *testing.T) {
 					// on the target's panic and runTestBuildFlags, which expects a
 					// terminated event, will fail the test.
 				},
+				disconnect: true,
 			}},
 		)
 	}, 0, true)
@@ -7896,6 +7897,10 @@ func TestRedirect(t *testing.T) {
 }
 
 func TestBreakpointAfterDisconnect(t *testing.T) {
+	if runtime.GOOS == "freebsd" {
+		// This test when run on FreeBSD will cause subsequent tests to sometimes fail.
+		t.Skip("causes problems")
+	}
 	fixture := protest.BuildFixture(t, "testnextnethttp", protest.AllNonOptimized)
 
 	cmd := exec.Command(fixture.Path)
@@ -7986,6 +7991,69 @@ func TestBreakpointAfterDisconnect(t *testing.T) {
 	cmd.Process.Kill()
 }
 
+func TestRedirects(t *testing.T) {
+	var (
+		infile  = "redirect-input.txt"
+		outfile = "redirect-output.txt"
+	)
+	runTest(t, "redirect", func(client *daptest.Client, fixture protest.Fixture) {
+		infile = filepath.Join(filepath.Dir(fixture.Source), infile)
+		outfile = filepath.Join(filepath.Dir(fixture.Source), outfile)
+		client.InitializeRequest()
+		client.ExpectInitializeResponseAndCapabilities(t)
+		client.LaunchRequestWithArgs(map[string]any{
+			"mode": "exec", "program": fixture.Path, "stdinFrom": infile, "stdoutTo": outfile,
+		})
+		client.ExpectProcessEvent(t)
+		client.ExpectInitializedEvent(t)
+		client.ExpectLaunchResponse(t)
+
+		client.ContinueRequest(1)
+		client.ExpectContinueResponse(t)
+		client.ExpectTerminatedEvent(t)
+
+		buf, err := os.ReadFile(outfile)
+		if err != nil {
+			t.Fatalf("error reading output file: %v", err)
+		}
+		t.Logf("output %q", buf)
+		if !strings.HasPrefix(string(buf), "Redirect test") {
+			t.Fatalf("Wrong output %q", string(buf))
+		}
+
+		client.RestartRequest(map[string]any{
+			"arguments": map[string]any{
+				"request": "launch",
+				"rebuild": false,
+			},
+		})
+		client.ExpectRestartResponse(t)
+		client.ExpectInitializedEvent(t)
+
+		client.ContinueRequest(1)
+		client.ExpectContinueResponse(t)
+		client.ExpectTerminatedEvent(t)
+
+		buf2, err := os.ReadFile(outfile)
+		if err != nil {
+			t.Fatalf("error reading output file (second time): %v", err)
+		}
+		t.Logf("output %q", buf2)
+		if !strings.HasPrefix(string(buf2), "Redirect test") {
+			t.Fatalf("Wrong output (second time) %q", string(buf))
+		}
+		if string(buf2) == string(buf) {
+			t.Fatalf("Expected output change got %q", string(buf))
+		}
+		os.Remove(outfile)
+
+		client.DisconnectRequest()
+		client.ExpectOutputEvent(t)
+		client.ExpectOutputEvent(t)
+		client.ExpectDisconnectResponse(t)
+	})
+}
+
 type discard struct {
 }
 
diff --git a/service/test/integration2_test.go b/service/test/integration2_test.go
index cb64a162..ec5fdcd8 100644
--- a/service/test/integration2_test.go
+++ b/service/test/integration2_test.go
@@ -3094,45 +3094,17 @@ func TestClientServer_breakpointOnFuncWithABIWrapper(t *testing.T) {
 	})
 }
 
-var waitReasonStrings = [...]string{
-	"",
-	"GC assist marking",
-	"IO wait",
-	"chan receive (nil chan)",
-	"chan send (nil chan)",
-	"dumping heap",
-	"garbage collection",
-	"garbage collection scan",
-	"panicwait",
-	"select",
-	"select (no cases)",
-	"GC assist wait",
-	"GC sweep wait",
-	"GC scavenge wait",
-	"chan receive",
-	"chan send",
-	"finalizer wait",
-	"force gc (idle)",
-	"semacquire",
-	"sleep",
-	"sync.Cond.Wait",
-	"timer goroutine (idle)",
-	"trace reader (blocked)",
-	"wait for GC cycle",
-	"GC worker (idle)",
-	"preempted",
-	"debug call",
-}
-
 func TestClientServer_chanGoroutines(t *testing.T) {
 	withTestClient2("changoroutines", t, func(c service.Client) {
+		ver := c.GetVersion()
+		goVer := goversion.ParseProducer(ver.TargetGoVersion)
 		state := <-c.Continue()
 		assertNoError(state.Err, t, "Continue()")
 
 		countRecvSend := func(gs []*api.Goroutine) (recvq, sendq int) {
 			for _, g := range gs {
-				t.Logf("\tID: %d WaitReason: %s\n", g.ID, waitReasonStrings[g.WaitReason])
-				switch waitReasonStrings[g.WaitReason] {
+				t.Logf("\tID: %d WaitReason: %s\n", g.ID, api.WaitReasonString(&goVer, g.WaitReason))
+				switch api.WaitReasonString(&goVer, g.WaitReason) {
 				case "chan send":
 					sendq++
 				case "chan receive":