返回 DeepSeek-Reasonix
kill_windows.go
根目录 / internal / proc / kill_windows.go
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
203 lines GO