tailscale/tailscale · error

WaitForSingleObject: %w

Error message

WaitForSingleObject: %w

What it means

RunProcessInSession creates a child in the target session then blocks in WaitForSingleObject(pi.Process, timeoutMillis). This is the raw syscall failure branch (err != nil), distinct from wait results like WAIT_TIMEOUT which are handled separately. With a freshly created, still-open process handle this is nearly impossible and signals handle misuse.

Source

Thrown at util/winutil/restartmgr_windows.go:758

// cmdLineInfo and will reside inside the session identified by sessID, with the
// security token whose logon is associated with sessID. The child process's
// environment will be inherited from the session token's environment.
func RunProcessInSession(sessID SessionID, cmdLineInfo CommandLineInfo, timeout time.Duration) (uint32, error) {
	timeoutMillis, err := wingoes.DurationToTimeoutMilliseconds(timeout)
	if err != nil {
		return 1, err
	}

	pi, err := startProcessInSessionInternal(sessID, cmdLineInfo, 0)
	if err != nil {
		return 1, err
	}
	windows.CloseHandle(pi.Thread)
	defer windows.CloseHandle(pi.Process)

	waitCode, err := windows.WaitForSingleObject(pi.Process, timeoutMillis)
	if err != nil {
		return 1, fmt.Errorf("WaitForSingleObject: %w", err)
	}
	if e := windows.Errno(waitCode); e == windows.WAIT_TIMEOUT {
		return 1, e
	}
	if waitCode != windows.WAIT_OBJECT_0 {
		// This should not be possible; log
		return 1, fmt.Errorf("unexpected state from WaitForSingleObject: %d", waitCode)
	}

	var exitCode uint32
	if err := windows.GetExitCodeProcess(pi.Process, &exitCode); err != nil {
		return 1, err
	}
	return exitCode, nil
}

func startProcessInSessionInternal(sessID SessionID, cmdLineInfo CommandLineInfo, extraFlags uint32) (*windows.ProcessInformation, error) {
	if err := cmdLineInfo.Validate(); err != nil {

View on GitHub (pinned to 6e0912f979)

Solutions

  1. Ensure nothing closes the process handle before RunProcessInSession returns
  2. Validate the timeout: negative or overflow-large durations are converted by wingoes.DurationToTimeoutMilliseconds - log the millis value
  3. Treat recurrence as an integrity bug: audit all CloseHandle paths for the child process

Example fix

// before
pi, _ := startProcessInSession(sessID, cli)
go func() { pi.Process.Close() }() // races with internal wait

// after
// let RunProcessInSession own the full lifecycle; do not close pi externally
exitCode, err := winutil.RunProcessInSession(sessID, cli, 30*time.Second)
Defensive patterns

Strategy: try-catch

Try / catch

exitCode, err := winutil.RunProcessInSession(sessID, cli, timeout)
if err != nil {
    if errors.Is(err, windows.ERROR_INVALID_HANDLE) {
        // handle lifecycle bug in caller; do not retry blindly
        return fmt.Errorf("process handle invalidated during wait: %w", err)
    }
    return err
}

Prevention

When it happens

Trigger: pi.Process closed concurrently before the wait (the deferred CloseHandle firing early via a wrapper); an invalid timeout value after conversion; memory corruption of the handle variable.

Common situations: Wrapping RunProcessInSession with custom context/timeout logic that closes handles; copying ProcessInformation structs after partial close.

Related errors


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