返回 DeepSeek-Reasonix
coordinator_windows.go
根目录 / internal / desktopinstance / coordinator_windows.go
1 //go:build windows
2
3 package desktopinstance
4
5 import (
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "time"
11
12 "reasonix/internal/installlayout"
13 "reasonix/internal/proc"
14 )
15
16 const gracefulTimeout = 20 * time.Second
17 const terminateTimeout = 10 * time.Second
18 const startupTimeout = 30 * time.Second
19
20 func closeProcesses(list []*process) {
21 for _, p := range list {
22 p.close()
23 }
24 }
25 func aliveProcesses(list []*process) []*process {
26 var alive []*process
27 for _, p := range list {
28 if p.alive() {
29 alive = append(alive, p)
30 }
31 }
32 return alive
33 }
34 func waitProcesses(list []*process, timeout time.Duration) bool {
35 deadline := time.Now().Add(timeout)
36 for len(aliveProcesses(list)) > 0 {
37 if time.Now().After(deadline) {
38 return false
39 }
40 time.Sleep(100 * time.Millisecond)
41 }
42 return true
43 }
44
45 func requestQuit(p *process, home string) {
46 if !p.alive() {
47 return
48 }
49 if p.status == nil {
50 closeWindows(p)
51 return
52 }
53 cmd := proc.Command(p.image, QuitRequest)
54 cmd.Env = withHome(os.Environ(), home)
55 if cmd.Start() == nil {
56 go func() { _ = cmd.Wait() }()
57 }
58 }
59
60 func withHome(env []string, home string) []string {
61 out := make([]string, 0, len(env)+1)
62 for _, e := range env {
63 key, _, _ := strings.Cut(e, "=")
64 if !strings.EqualFold(key, "REASONIX_HOME") && !strings.EqualFold(key, "REASONIX_DESKTOP_SERVICE") {
65 out = append(out, e)
66 }
67 }
68 return append(out, "REASONIX_HOME="+home)
69 }
70
71 func recoverProcesses(root, profile, home string, list []*process, all, interactive bool) error {
72 defer closeProcesses(list)
73 if len(list) == 0 {
74 return nil
75 }
76 // A whole-install operation may include services and renderers. Only shells
77 // with a known data home can receive a targeted lifecycle request.
78 for _, p := range list {
79 if p.status != nil && p.status.HomeKey == ProfileKey(profile) {
80 requestQuit(p, home)
81 } else if ImageRole(root, p.image) == "shell" {
82 closeWindows(p)
83 }
84 }
85 if waitProcesses(list, gracefulTimeout) {
86 return requireVacant(root, profile, all)
87 }
88 if !interactive {
89 return outcome(ConfirmationRequired, "old Reasonix processes have not exited; interactive recovery is required")
90 }
91 fresh, err := inspect(root, profile, all)
92 if err != nil {
93 return err
94 }
95 defer closeProcesses(fresh)
96 for _, p := range fresh {
97 matched := false
98 for _, old := range list {
99 if old.pid == p.pid && old.created == p.created {
100 matched = true
101 break
102 }
103 }
104 if !matched {
105 return outcome(ConfirmationRequired, "another process appeared during recovery; retry to inspect it")
106 }
107 }
108 live := aliveProcesses(list)
109 // Include only verified descendants, not unrelated instances sharing an exe.
110 if !all {
111 children, err := inspect(root, profile, true)
112 if err != nil {
113 return err
114 }
115 defer closeProcesses(children)
116 ids := map[uint32]*process{}
117 for _, p := range live {
118 ids[p.pid] = p
119 }
120 for changed := true; changed; {
121 changed = false
122 for _, p := range children {
123 parent := ids[p.parent]
124 if ids[p.pid] == nil && parent != nil && parent.alive() && parent.created.Nanoseconds() <= p.created.Nanoseconds() {
125 ids[p.pid] = p
126 live = append(live, p)
127 changed = true
128 }
129 }
130 }
131 }
132 if len(live) == 0 {
133 return nil
134 }
135 if !confirmProcesses(live) {
136 return outcome(Cancelled, "recovery cancelled; existing processes were preserved")
137 }
138 // Consent covers the displayed identities only, including when the dialog
139 // remains open while an old launcher starts another instance.
140 latest, err := inspect(root, profile, all)
141 if err != nil {
142 return err
143 }
144 defer closeProcesses(latest)
145 for _, p := range latest {
146 matched := false
147 for _, approved := range live {
148 if p.pid == approved.pid && p.created == approved.created {
149 matched = true
150 break
151 }
152 }
153 if !matched {
154 return outcome(ConfirmationRequired, "processes changed after confirmation; retry recovery")
155 }
156 }
157 for _, p := range live {
158 if err := p.terminate(); err != nil {
159 return outcome(UnknownOwner, "could not end verified process %d: %v", p.pid, err)
160 }
161 }
162 if !waitProcesses(live, terminateTimeout) {
163 return outcome(ExitTimeout, "old Reasonix processes did not exit")
164 }
165 return requireVacant(root, profile, all)
166 }
167
168 func requireVacant(root, profile string, all bool) error {
169 list, err := inspect(root, profile, all)
170 if err != nil {
171 return err
172 }
173 defer closeProcesses(list)
174 if len(list) != 0 {
175 return outcome(ConfirmationRequired, "another instance appeared during handoff; activation was stopped")
176 }
177 return nil
178 }
179
180 // CheckInstallVacant must be called while holding PrepareInstall's lease.
181 func CheckInstallVacant(root, home string) error {
182 root, profile, err := preparePaths(root, home)
183 if err != nil {
184 return err
185 }
186 return requireVacant(root, profile, true)
187 }
188
189 // PrepareInstall holds process coordination until the caller commits or aborts.
190 func PrepareInstall(root, home string, interactive bool) (release func(), resultErr error) {
191 finish := AttemptLog(home, "prepare-install", root)
192 defer func() { finish(resultErr) }()
193 root, profile, err := preparePaths(root, home)
194 if err != nil {
195 return nil, err
196 }
197 unlock, err := lockInstall(root)
198 if err != nil {
199 return nil, err
200 }
201 list, err := inspect(root, profile, true)
202 if err == nil {
203 err = recoverProcesses(root, profile, home, list, true, interactive)
204 }
205 if err != nil {
206 unlock()
207 return nil, err
208 }
209 return unlock, nil
210 }
211
212 func target(root string) (string, string, error) {
213 current, err := installlayout.ReadCurrent(root)
214 if err != nil {
215 return "", "", err
216 }
217 desktop, err := installlayout.ActiveDesktopPath(root)
218 if err != nil {
219 return "", "", err
220 }
221 return filepath.Join(filepath.Dir(desktop), "app", "Reasonix.exe"), current.ActiveVersion, nil
222 }
223
224 func verify(root, profile, expected string) error {
225 expectedImage := ""
226 if expected != "" {
227 var err error
228 expectedImage, _, err = target(root)
229 if err != nil {
230 return err
231 }
232 expectedImage, err = canonical(expectedImage)
233 if err != nil {
234 return err
235 }
236 }
237 deadline := time.Now().Add(startupTimeout)
238 for {
239 list, err := waitForInspectable(func() ([]*process, error) { return inspect(root, profile, false) },
240 func() time.Duration { return time.Until(deadline) }, time.Sleep)
241 if err != nil {
242 return err
243 }
244 ready := false
245 failed := false
246 for _, p := range list {
247 if p.status != nil {
248 if p.status.Ready(expected) && (expectedImage == "" || strings.EqualFold(p.image, expectedImage)) {
249 child, childErr := openProcess(p.status.ServicePID, 0)
250 if childErr == nil {
251 // A status response alone cannot prove that its advertised
252 // service is still alive or belongs to this release.
253 serviceImage := filepath.Join(filepath.Dir(p.image), "resources", "service", "reasonix-desktop.exe")
254 entryImage := filepath.Join(filepath.Dir(filepath.Dir(p.image)), "reasonix-desktop.exe")
255 ready = ready || strings.EqualFold(child.image, serviceImage) || strings.EqualFold(child.image, entryImage)
256 child.close()
257 }
258 }
259 failed = failed || p.status.Lifecycle == "failed"
260 }
261 }
262 closeProcesses(list)
263 if ready {
264 return nil
265 }
266 if failed {
267 return outcome(StartupFailed, "Reasonix startup failed; use the recovery window or desktop-shell/logs")
268 }
269 if time.Now().After(deadline) {
270 return outcome(StartupFailed, "startup was not verified within 30 seconds; inspect desktop-shell/logs")
271 }
272 time.Sleep(200 * time.Millisecond)
273 }
274 }
275
276 // LaunchAndVerify serializes handoff and never treats launcher exit as readiness.
277 func LaunchAndVerify(root, home string, interactive bool, start func() error, args ...string) (resultErr error) {
278 finish := AttemptLog(home, "launch", root)
279 defer func() { finish(resultErr) }()
280 root, profile, err := preparePaths(root, home)
281 if err != nil {
282 return err
283 }
284 unlock, err := lockInstall(root)
285 if err != nil {
286 return err
287 }
288 defer unlock()
289 list, err := inspect(root, profile, false)
290 if err != nil {
291 return err
292 }
293 for _, p := range list {
294 writeRecoveryLog(home, fmt.Sprintf("inspect pid=%d created=%d image=%q status=%+v", p.pid, p.created.Nanoseconds(), p.image, p.status))
295 }
296 for _, p := range list {
297 if p.status == nil && p.legacyProfile != "" && focusLegacyWindow(p) {
298 // A legacy instance cannot prove readiness. Showing its existing
299 // window handles an ordinary double-click without interrupting work;
300 // update acceptance still uses VerifyCurrent's strict checks.
301 writeRecoveryLog(home, "existing legacy window displayed; health remains unverified")
302 closeProcesses(list)
303 return nil
304 }
305 }
306 for _, p := range list {
307 if p.status != nil && p.status.Lifecycle == "failed" {
308 // A responsive failed shell owns its recovery UI and may still hold
309 // an unsaved renderer. Let the user choose retry/exit there.
310 cmd := proc.Command(p.image, args...)
311 cmd.Env = withHome(os.Environ(), home)
312 err := cmd.Start()
313 if err == nil {
314 go func() { _ = cmd.Wait() }()
315 }
316 closeProcesses(list)
317 writeRecoveryLog(home, "existing recovery window requested; health remains unverified")
318 return err
319 }
320 if p.status != nil && p.status.Lifecycle == "ready" && p.status.Service == "ready" {
321 // Launch the running shell itself, preserving its existing service and drafts.
322 cmd := proc.Command(p.image, args...)
323 cmd.Env = withHome(os.Environ(), home)
324 err := cmd.Start()
325 if err == nil {
326 go func() { _ = cmd.Wait() }()
327 }
328 closeProcesses(list)
329 if err != nil {
330 return err
331 }
332 return verify(root, profile, "")
333 }
334 }
335 waiting := len(list) > 0
336 for _, p := range list {
337 if p.status == nil || (p.status.Lifecycle != "starting" && p.status.Lifecycle != "quitting" && p.status.Lifecycle != "done") {
338 waiting = false
339 }
340 }
341 if waiting {
342 closeProcesses(list)
343 deadline := time.Now().Add(startupTimeout)
344 for {
345 list, err = inspect(root, profile, false)
346 if err != nil {
347 return err
348 }
349 if len(list) == 0 {
350 break
351 }
352 ready := false
353 for _, p := range list {
354 ready = ready || (p.status != nil && p.status.Ready(""))
355 }
356 if ready {
357 closeProcesses(list)
358 return nil
359 }
360 if time.Now().After(deadline) {
361 closeProcesses(list)
362 return outcome(StartupFailed, "the existing instance is still starting or exiting")
363 }
364 closeProcesses(list)
365 time.Sleep(200 * time.Millisecond)
366 }
367 }
368 if err := recoverProcesses(root, profile, home, list, false, interactive); err != nil {
369 return err
370 }
371 _, version, err := target(root)
372 if err != nil {
373 return fmt.Errorf("resolve current release: %w", err)
374 }
375 if err := start(); err != nil {
376 return err
377 }
378 return verify(root, profile, version)
379 }
380
381 // VerifyCurrent is used after activation when the stable launcher owns recovery.
382 func VerifyCurrent(root, home string) error {
383 root, profile, err := preparePaths(root, home)
384 if err != nil {
385 return err
386 }
387 _, version, err := target(root)
388 if err != nil {
389 return err
390 }
391 return verify(root, profile, version)
392 }
393
393 lines GO