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

  1. Serialize all Terminate/Close calls for a given RestartableProcesses set behind one owner goroutine
  2. Never close RestartableProcess handles from callbacks while Terminate is in flight
  3. 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

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


AI-assisted analysis of tailscale/tailscale@6e0912f979 (2026-08-18). Data as JSON: /api/errors/64f77094149c6ba9. Report an issue: GitHub.