| 1 | //go:build windows |
| 2 | |
| 3 | package proc |
| 4 | |
| 5 | import ( |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "os/exec" |
| 10 | "strconv" |
| 11 | "syscall" |
| 12 | "unsafe" |
| 13 | |
| 14 | "golang.org/x/sys/windows" |
| 15 | ) |
| 16 | |
| 17 | // SetProcessGroupKill is a no-op on Windows: the Job Object that StartTracked |
| 18 | // assigns reaps the whole tree on close, so Setpgid (which doesn't exist here) |
| 19 | // is unnecessary. It exists so non-Windows callers can request group kill |
| 20 | // uniformly. |
| 21 | func SetProcessGroupKill(*exec.Cmd) {} |
| 22 | |
| 23 | // KillTree terminates cmd and every descendant it spawned. Process.Kill only |
| 24 | // signals the direct child, so a launcher (cmd.exe → node.exe) leaves the |
| 25 | // grandchild alive holding the inherited stdout/stderr pipes — which makes |
| 26 | // cmd.Wait block forever. taskkill /T walks the live tree and kills it all. |
| 27 | func KillTree(cmd *exec.Cmd) { |
| 28 | if cmd == nil || cmd.Process == nil { |
| 29 | return |
| 30 | } |
| 31 | kill := exec.Command("taskkill", "/F", "/T", "/PID", strconv.Itoa(cmd.Process.Pid)) |
| 32 | HideWindow(kill) |
| 33 | _ = kill.Run() |
| 34 | _ = cmd.Process.Kill() |
| 35 | } |
| 36 | |
| 37 | // StartTracked starts cmd inside a new Job Object whose KILL_ON_JOB_CLOSE flag |
| 38 | // fells the whole tree — including a launcher's detached grandchild (cmd.exe → |
| 39 | // node.exe, as the CodeGraph daemon re-parents itself off the launcher) — when |
| 40 | // the handle closes via KillTracked or an abrupt reasonix exit. The child is |
| 41 | // created suspended and assigned to the job before it runs, so a fast shim can |
| 42 | // no longer exec its grandchild and exit before assignment, orphaning a node |
| 43 | // the job never captured (#3747). It is always resumed before returning, even |
| 44 | // when job assignment fails, so a child is never left wedged suspended. Returns |
| 45 | // the job handle, 0 if it could not be created — then KillTracked relies on |
| 46 | // KillTree alone. |
| 47 | func StartTracked(cmd *exec.Cmd) (uintptr, error) { |
| 48 | return startTracked(cmd, false) |
| 49 | } |
| 50 | |
| 51 | // StartTrackedRequired is the fail-closed form used when orphaned descendants |
| 52 | // would violate the caller's lifecycle contract. The child remains suspended |
| 53 | // until Job Object assignment succeeds. |
| 54 | func StartTrackedRequired(cmd *exec.Cmd) (uintptr, error) { |
| 55 | return startTracked(cmd, true) |
| 56 | } |
| 57 | |
| 58 | func startTracked(cmd *exec.Cmd, requireJob bool) (uintptr, error) { |
| 59 | if cmd.SysProcAttr == nil { |
| 60 | cmd.SysProcAttr = &syscall.SysProcAttr{} |
| 61 | } |
| 62 | cmd.SysProcAttr.CreationFlags |= windows.CREATE_SUSPENDED |
| 63 | if err := cmd.Start(); err != nil { |
| 64 | return 0, err |
| 65 | } |
| 66 | job := assignJob(cmd) |
| 67 | if requireJob && job == 0 { |
| 68 | return 0, errors.Join(ErrProcessTrackingUnavailable, terminateAndReapStartedProcess(cmd, 0)) |
| 69 | } |
| 70 | if err := resumeProcess(uint32(cmd.Process.Pid)); err != nil { |
| 71 | resumeErr := fmt.Errorf("resume suspended process %d: %w", cmd.Process.Pid, err) |
| 72 | cleanupErr := terminateAndReapStartedProcess(cmd, job) |
| 73 | if requireJob { |
| 74 | return 0, errors.Join(ErrProcessTrackingUnavailable, resumeErr, cleanupErr) |
| 75 | } |
| 76 | return 0, errors.Join(resumeErr, cleanupErr) |
| 77 | } |
| 78 | return job, nil |
| 79 | } |
| 80 | |
| 81 | func terminateAndReapStartedProcess(cmd *exec.Cmd, job uintptr) error { |
| 82 | var cleanupErrors []error |
| 83 | if job != 0 { |
| 84 | handle := windows.Handle(job) |
| 85 | if err := windows.TerminateJobObject(handle, 1); err != nil { |
| 86 | cleanupErrors = append(cleanupErrors, fmt.Errorf("terminate job object: %w", err)) |
| 87 | } |
| 88 | if err := windows.CloseHandle(handle); err != nil { |
| 89 | cleanupErrors = append(cleanupErrors, fmt.Errorf("close job object: %w", err)) |
| 90 | } |
| 91 | } |
| 92 | if cmd == nil || cmd.Process == nil { |
| 93 | cleanupErrors = append(cleanupErrors, errors.New("started process is unavailable for termination")) |
| 94 | return errors.Join(cleanupErrors...) |
| 95 | } |
| 96 | if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { |
| 97 | cleanupErrors = append(cleanupErrors, fmt.Errorf("terminate suspended process: %w", err)) |
| 98 | } |
| 99 | waitErr := cmd.Wait() |
| 100 | var exitErr *exec.ExitError |
| 101 | if waitErr != nil && !errors.As(waitErr, &exitErr) && !errors.Is(waitErr, os.ErrProcessDone) { |
| 102 | cleanupErrors = append(cleanupErrors, fmt.Errorf("reap suspended process: %w", waitErr)) |
| 103 | } |
| 104 | if cmd.ProcessState == nil { |
| 105 | cleanupErrors = append(cleanupErrors, errors.New("suspended process was not reaped")) |
| 106 | } |
| 107 | return errors.Join(cleanupErrors...) |
| 108 | } |
| 109 | |
| 110 | func assignJob(cmd *exec.Cmd) uintptr { |
| 111 | if cmd == nil || cmd.Process == nil { |
| 112 | return 0 |
| 113 | } |
| 114 | job, err := windows.CreateJobObject(nil, nil) |
| 115 | if err != nil { |
| 116 | return 0 |
| 117 | } |
| 118 | info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{ |
| 119 | BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{ |
| 120 | LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, |
| 121 | }, |
| 122 | } |
| 123 | if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, |
| 124 | uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil { |
| 125 | _ = windows.CloseHandle(job) |
| 126 | return 0 |
| 127 | } |
| 128 | h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid)) |
| 129 | if err != nil { |
| 130 | _ = windows.CloseHandle(job) |
| 131 | return 0 |
| 132 | } |
| 133 | defer func() { _ = windows.CloseHandle(h) }() |
| 134 | if err := windows.AssignProcessToJobObject(job, h); err != nil { |
| 135 | _ = windows.CloseHandle(job) |
| 136 | return 0 |
| 137 | } |
| 138 | return uintptr(job) |
| 139 | } |
| 140 | |
| 141 | // resumeProcess resumes the primary thread. Before it runs, a CREATE_SUSPENDED |
| 142 | // process cannot create another thread; absence or duplication is fail-closed. |
| 143 | func resumeProcess(pid uint32) error { |
| 144 | snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0) |
| 145 | if err != nil { |
| 146 | return err |
| 147 | } |
| 148 | defer func() { _ = windows.CloseHandle(snap) }() |
| 149 | var te windows.ThreadEntry32 |
| 150 | te.Size = uint32(unsafe.Sizeof(te)) |
| 151 | var threadID uint32 |
| 152 | for err := windows.Thread32First(snap, &te); err == nil; err = windows.Thread32Next(snap, &te) { |
| 153 | if te.OwnerProcessID != pid { |
| 154 | continue |
| 155 | } |
| 156 | if threadID != 0 { |
| 157 | return fmt.Errorf("multiple threads found for suspended process %d", pid) |
| 158 | } |
| 159 | threadID = te.ThreadID |
| 160 | } |
| 161 | if threadID == 0 { |
| 162 | return fmt.Errorf("no thread found for suspended process %d", pid) |
| 163 | } |
| 164 | th, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, threadID) |
| 165 | if err != nil { |
| 166 | return err |
| 167 | } |
| 168 | defer func() { _ = windows.CloseHandle(th) }() |
| 169 | previous, err := windows.ResumeThread(th) |
| 170 | if err != nil { |
| 171 | return err |
| 172 | } |
| 173 | if previous > 1 { |
| 174 | return fmt.Errorf("thread %d remains suspended (previous count %d)", threadID, previous) |
| 175 | } |
| 176 | return nil |
| 177 | } |
| 178 | |
| 179 | // KillTracked terminates cmd's whole process tree. When job (from StartTracked) |
| 180 | // is non-zero, terminating it kills even detached descendants; the KillTree pass |
| 181 | // then catches anything spawned in the gap before the job was assigned. |
| 182 | func KillTracked(cmd *exec.Cmd, job uintptr) { |
| 183 | if job == 0 { |
| 184 | KillTree(cmd) |
| 185 | return |
| 186 | } |
| 187 | FinishTracked(job) |
| 188 | if cmd != nil && cmd.Process != nil { |
| 189 | _ = cmd.Process.Kill() |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | // FinishTracked releases a completed command's Job Object. Closing a job with |
| 194 | // KILL_ON_JOB_CLOSE also terminates descendants without a PID-reuse-prone |
| 195 | // taskkill fallback after cmd.Wait. |
| 196 | func FinishTracked(job uintptr) { |
| 197 | if job == 0 { |
| 198 | return |
| 199 | } |
| 200 | _ = windows.TerminateJobObject(windows.Handle(job), 1) |
| 201 | _ = windows.CloseHandle(windows.Handle(job)) |
| 202 | } |
| 203 |