{"record":{"id":"64f77094149c6ba9","repo":"tailscale/tailscale","slug":"waiting-on-terminated-process-handles-w","errorCode":null,"errorMessage":"waiting on terminated process handles: %w","messagePattern":"waiting on terminated process handles: %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"util/winutil/restartmgr_windows.go","lineNumber":514,"sourceCode":"\t\t\t\t\tv.hasExitCode = true\n\t\t\t\t}\n\t\t\t\tv.Close()\n\t\t\t} else {\n\t\t\t\terrs = append(errs, &terminationError{rp: v, err: err})\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tprocs = append(procs, v)\n\t\thandles = append(handles, v.handle)\n\t}\n\n\tfor len(handles) > 0 {\n\t\t// WaitForMultipleObjects can only wait on _MAXIMUM_WAIT_OBJECTS handles per\n\t\t// call, so we batch them as necessary.\n\t\tcount := uint32(min(len(handles), _MAXIMUM_WAIT_OBJECTS))\n\t\twaitCode, err := windows.WaitForMultipleObjects(handles[:count], true, millis)\n\t\tif err != nil {\n\t\t\terrs = append(errs, fmt.Errorf(\"waiting on terminated process handles: %w\", err))\n\t\t\tbreak\n\t\t}\n\t\tif e := windows.Errno(waitCode); e == windows.WAIT_TIMEOUT {\n\t\t\terrs = append(errs, fmt.Errorf(\"waiting on terminated process handles: %w\", error(e)))\n\t\t\tbreak\n\t\t}\n\t\tif waitCode >= windows.WAIT_OBJECT_0 && waitCode < (windows.WAIT_OBJECT_0+count) {\n\t\t\t// The first count process handles have all been signaled. Close them out.\n\t\t\tfor _, proc := range procs[:count] {\n\t\t\t\tif err := windows.GetExitCodeProcess(proc.handle, &proc.exitCode); err != nil {\n\t\t\t\t\tlogf(\"GetExitCodeProcess failed: %v\", err)\n\t\t\t\t} else {\n\t\t\t\t\tproc.hasExitCode = true\n\t\t\t\t}\n\t\t\t\tproc.Close()\n\t\t\t}\n\t\t\tprocs = procs[count:]\n\t\t\thandles = handles[count:]","sourceCodeStart":496,"sourceCodeEnd":532,"githubUrl":"https://github.com/tailscale/tailscale/blob/6e0912f97994f927632b34ae9e63b53d6516a6ac/util/winutil/restartmgr_windows.go#L496-L532","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// before\ngo rps.Terminate(logf, 1, timeout)   // racing...\nrps.Terminate(logf, 1, timeout)      // second concurrent call\n\n// after\n// single owner: all shutdown goes through one channel\nterminateCh <- struct{}{}\n// only the owner goroutine calls rps.Terminate and later rps.Close","handlingStrategy":"validation","validationCode":"// ensure exactly one goroutine owns the process set\n// (pseudo): a mutex or single-owner channel guarding rps\nvar mu sync.Mutex\n\nfunc terminateAll(rps RestartableProcesses) error {\n    mu.Lock()\n    defer mu.Unlock()\n    return rps.Terminate(logf, 1, 30*time.Second)\n}","typeGuard":null,"tryCatchPattern":"if err := rps.Terminate(logf, 1, timeout); err != nil {\n    if errors.Is(err, windows.ERROR_INVALID_HANDLE) {\n        // concurrent close: audit lifecycle ownership rather than retry\n        logf(\"handle closed during wait; terminating skipped\")\n        return nil\n    }\n    return err\n}","preventionTips":["Serialize Terminate/Close calls per RestartableProcesses set","Never call proc.Close() from callbacks while Terminate waits","Design shutdown with a single owner goroutine"],"tags":["go","windows","waitformultipleobjects","process","race-condition","handle"],"backgroundTag":"waitformultipleobjects-failed","analyzedSha":"6e0912f97994f927632b34ae9e63b53d6516a6ac","analyzedAt":"2026-08-18T08:17:25.280Z","contentChangedAt":"2026-08-18T08:17:25.280Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}