tailscale/tailscale · error
waiting on terminated process handles: %w
Error message
waiting on terminated process handles: %w
What it means
RestartableProcesses.Terminate kills each process with TerminateProcess, then waits on the surviving handles in batches of 64 (_MAXIMUM_WAIT_OBJECTS) using WaitForMultipleObjects(bWaitAll=true). This variant is the direct syscall failure: WaitForMultipleObjects returned an error rather than a wait result, and the loop breaks, leaving remaining processes unwaited and their exit codes unset.
Source
Thrown at util/winutil/restartmgr_windows.go:514
v.hasExitCode = true
}
v.Close()
} else {
errs = append(errs, &terminationError{rp: v, err: err})
}
continue
}
procs = append(procs, v)
handles = append(handles, v.handle)
}
for len(handles) > 0 {
// WaitForMultipleObjects can only wait on _MAXIMUM_WAIT_OBJECTS handles per
// call, so we batch them as necessary.
count := uint32(min(len(handles), _MAXIMUM_WAIT_OBJECTS))
waitCode, err := windows.WaitForMultipleObjects(handles[:count], true, millis)
if err != nil {
errs = append(errs, fmt.Errorf("waiting on terminated process handles: %w", err))
break
}
if e := windows.Errno(waitCode); e == windows.WAIT_TIMEOUT {
errs = append(errs, fmt.Errorf("waiting on terminated process handles: %w", error(e)))
break
}
if waitCode >= windows.WAIT_OBJECT_0 && waitCode < (windows.WAIT_OBJECT_0+count) {
// The first count process handles have all been signaled. Close them out.
for _, proc := range procs[:count] {
if err := windows.GetExitCodeProcess(proc.handle, &proc.exitCode); err != nil {
logf("GetExitCodeProcess failed: %v", err)
} else {
proc.hasExitCode = true
}
proc.Close()
}
procs = procs[count:]
handles = handles[count:]View on GitHub (pinned to 6e0912f979)
Solutions
- Serialize all Terminate/Close calls for a given RestartableProcesses set behind one owner goroutine
- Never close RestartableProcess handles from callbacks while Terminate is in flight
- Audit for double Terminate calls; the second pass sees closed handles
Example fix
// before
go rps.Terminate(logf, 1, timeout) // racing...
rps.Terminate(logf, 1, timeout) // second concurrent call
// after
// single owner: all shutdown goes through one channel
terminateCh <- struct{}{}
// only the owner goroutine calls rps.Terminate and later rps.Close Defensive patterns
Strategy: validation
Validate before calling
// ensure exactly one goroutine owns the process set
// (pseudo): a mutex or single-owner channel guarding rps
var mu sync.Mutex
func terminateAll(rps RestartableProcesses) error {
mu.Lock()
defer mu.Unlock()
return rps.Terminate(logf, 1, 30*time.Second)
} Try / catch
if err := rps.Terminate(logf, 1, timeout); err != nil {
if errors.Is(err, windows.ERROR_INVALID_HANDLE) {
// concurrent close: audit lifecycle ownership rather than retry
logf("handle closed during wait; terminating skipped")
return nil
}
return err
} Prevention
- Serialize Terminate/Close calls per RestartableProcesses set
- Never call proc.Close() from callbacks while Terminate waits
- Design shutdown with a single owner goroutine
When it happens
Trigger: A handle in the batch was closed by a concurrent Close() (use-after-close / double-close); a handle value is not a valid waitable object; concurrent invocation of Terminate from two goroutines on the same RestartableProcesses set.
Common situations: Multiple shutdown paths racing (timer-driven shutdown plus explicit Terminate); calling proc.Close() from a progress callback while Terminate is still waiting; refactors that made the process set shared.
Related errors
- GetProcessTimes: %w
- WaitForSingleObject: %w
- failed to create a user policy lock: %w
- OpenProcess(%d[%#X]): %w
- OpenProcessToken: %w
AI-assisted analysis of tailscale/tailscale@6e0912f979 (2026-08-18).
Data as JSON: /api/errors/64f77094149c6ba9.
Report an issue: GitHub.