go-delve/delve · error

thread already exited %d

Error message

thread already exited %d

What it means

After successfully ptrace-attaching to a new thread and calling waitpid on it, Delve found the thread's wait status reports it has already exited. The thread died in the window between attach and the wait, so it cannot be added to the debugger's thread list.

Source

Thrown at pkg/proc/native/proc_linux.go:369

		ptraceOptions = ptraceOptionsFollowExec
	}

	var err error
	if attach {
		dbp.execPtraceFunc(func() { err = sys.PtraceAttach(tid) })
		if err != nil && err != sys.EPERM {
			// Do not return err if err == EPERM,
			// we may already be tracing this thread due to
			// PTRACE_O_TRACECLONE. We will surely blow up later
			// if we truly don't have permissions.
			return nil, fmt.Errorf("could not attach to new thread %d %s", tid, err)
		}
		pid, status, err := dbp.waitFast(tid)
		if err != nil {
			return nil, err
		}
		if status.Exited() {
			return nil, fmt.Errorf("thread already exited %d", pid)
		}
	}

	dbp.execPtraceFunc(func() { err = syscall.PtraceSetOptions(tid, ptraceOptions) })
	if err == syscall.ESRCH {
		if _, _, err = dbp.waitFast(tid); err != nil {
			return nil, fmt.Errorf("error while waiting after adding thread: %d %s", tid, err)
		}
		dbp.execPtraceFunc(func() { err = syscall.PtraceSetOptions(tid, ptraceOptions) })
		if err == syscall.ESRCH {
			return nil, err
		}
		if err != nil {
			return nil, fmt.Errorf("could not set options for new traced thread %d %s", tid, err)
		}
	}

	dbp.threads[tid] = &nativeThread{

View on GitHub (pinned to a23773e6c3)

Solutions

  1. Retry the operation (re-run updateThreadList); the dead thread will simply be absent from /proc on the next scan.
  2. Treat this as non-fatal in the caller: skip the TID and continue adding remaining threads instead of aborting the whole attach.
  3. Attach to a quiescent process: pause or reduce thread churn in the target if you control it.
  4. Attach earlier in the target's lifecycle (before heavy thread churn) rather than mid-shutdown.

Example fix

// before: any addThread error aborts the scan
if _, err := dbp.addThread(tid, tid != dbp.pid); err != nil {
    return err
}
// after: skip threads that exited between glob and attach
if _, err := dbp.addThread(tid, tid != dbp.pid); err != nil {
    if strings.Contains(err.Error(), "thread already exited") {
        continue
    }
    return err
}
Defensive patterns

Strategy: validation

Validate before calling

// verify the thread still exists before operating on it
func threadExists(pid, tid int) bool {
    _, err := os.Stat(fmt.Sprintf("/proc/%d/task/%d", pid, tid))
    return err == nil
}

Type guard

func isThreadExitedErr(err error) bool {
    return err != nil && strings.Contains(err.Error(), "thread already exited")
}

Try / catch

if _, err := dbp.addThread(tid, true); err != nil {
    if isThreadExitedErr(err) {
        continue // benign race, thread is gone
    }
    return err
}

Prevention

When it happens

Trigger: addThread(tid, attach=true): sys.PtraceAttach succeeded (or returned EPERM which was tolerated), dbp.waitFast(tid) returned a status whose Exited() is true — i.e. the TID ceased to exist right after attach; typically happens when enumerating /proc/<pid>/task/* for a process with rapidly exiting threads.

Common situations: dlv attach to a busy server process spawning/destroying threads (thread pools resizing); attaching to a process that is itself shutting down; Go programs with many short-lived goroutine-backed OS threads exiting during scan.

Related errors


AI-assisted analysis of go-delve/delve@a23773e6c3 (2026-08-31). Data as JSON: /api/errors/68d7722ae0c1dc91. Report an issue: GitHub.