返回 DeepSeek-Reasonix
shutdown.go
根目录 / desktop / shutdown.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "sort"
9 "strings"
10 "sync"
11 "time"
12
13 "reasonix/internal/config"
14 "reasonix/internal/control"
15 "reasonix/internal/repair"
16 "reasonix/internal/stats"
17 )
18
19 const (
20 shutdownReasonUserQuit = "user_quit"
21 shutdownReasonUpdateRestart = "update_restart"
22 shutdownReasonStartupFailure = "startup_failure"
23 shutdownReasonConnectionLost = "connection_lost"
24 shutdownReasonSystemSignal = "system_signal"
25 )
26
27 type shutdownRequest struct {
28 RequestID string `json:"requestId"`
29 Reason string `json:"reason"`
30 }
31
32 type shutdownStatus struct {
33 RequestID string `json:"requestId"`
34 Reason string `json:"reason"`
35 Phase string `json:"phase"`
36 Outcome string `json:"outcome"`
37 Completed bool `json:"completed"`
38 Retryable bool `json:"retryable"`
39 ErrorCode string `json:"errorCode,omitempty"`
40 Error string `json:"error,omitempty"`
41 UpdatedAt string `json:"updatedAt"`
42 }
43
44 type desktopShutdownItem struct {
45 tab *WorkspaceTab
46 ctrl control.SessionAPI
47 readOnly bool
48 }
49
50 // desktopShutdownCoordinator owns the one process shutdown transaction. It
51 // retains successful save and close steps so a retry resumes at the failed
52 // step instead of closing an already released session a second time.
53 type desktopShutdownCoordinator struct {
54 mu sync.Mutex
55 running bool
56 done chan struct{}
57 status shutdownStatus
58 frozen bool
59 items []desktopShutdownItem
60 saved map[string]bool
61 finished map[string]bool
62 }
63
64 type shutdownStepError struct {
65 code string
66 err error
67 }
68
69 func (e *shutdownStepError) Error() string { return e.err.Error() }
70 func (e *shutdownStepError) Unwrap() error { return e.err }
71
72 func normalizeShutdownReason(reason string) string {
73 switch strings.TrimSpace(reason) {
74 case shutdownReasonUserQuit, shutdownReasonUpdateRestart, shutdownReasonStartupFailure,
75 shutdownReasonConnectionLost, shutdownReasonSystemSignal:
76 return strings.TrimSpace(reason)
77 default:
78 return shutdownReasonConnectionLost
79 }
80 }
81
82 func (a *App) shutdownState() *desktopShutdownCoordinator {
83 a.shutdownMu.Lock()
84 defer a.shutdownMu.Unlock()
85 if a.shutdownCoordinator == nil {
86 a.shutdownCoordinator = &desktopShutdownCoordinator{
87 saved: map[string]bool{},
88 finished: map[string]bool{},
89 status: shutdownStatus{Phase: "idle", Outcome: "not_started"},
90 }
91 }
92 return a.shutdownCoordinator
93 }
94
95 func (a *App) shutdownStatus(requestID string) shutdownStatus {
96 c := a.shutdownState()
97 c.mu.Lock()
98 defer c.mu.Unlock()
99 status := c.status
100 if requestID != "" && status.RequestID != "" && requestID != status.RequestID {
101 return shutdownStatus{
102 RequestID: requestID, Phase: "idle", Outcome: "not_started", Retryable: true,
103 ErrorCode: "shutdown_request_not_found",
104 }
105 }
106 return status
107 }
108
109 func (a *App) requestShutdown(ctx context.Context, request shutdownRequest) (shutdownStatus, error) {
110 request.RequestID = strings.TrimSpace(request.RequestID)
111 if request.RequestID == "" {
112 return shutdownStatus{}, &shutdownStepError{code: "invalid_request", err: errors.New("shutdown requestId is required")}
113 }
114 request.Reason = normalizeShutdownReason(request.Reason)
115 c := a.shutdownState()
116
117 c.mu.Lock()
118 if c.status.Completed {
119 status := c.status
120 c.mu.Unlock()
121 return status, nil
122 }
123 if c.running {
124 done := c.done
125 c.mu.Unlock()
126 select {
127 case <-done:
128 status := a.shutdownStatus("")
129 if status.Outcome == "failed" {
130 return status, errors.New(status.Error)
131 }
132 return status, nil
133 case <-ctx.Done():
134 return a.shutdownStatus(""), ctx.Err()
135 }
136 }
137 // A retry resumes the original transaction. Later EOF, signal, or RPC
138 // requests must not rewrite the trigger that started it.
139 if c.status.RequestID != "" {
140 request.RequestID = c.status.RequestID
141 request.Reason = c.status.Reason
142 }
143 c.running = true
144 c.done = make(chan struct{})
145 c.status = shutdownStatus{
146 RequestID: request.RequestID,
147 Reason: request.Reason,
148 Phase: "preparing", Outcome: "in_progress",
149 UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
150 }
151 done := c.done
152 c.mu.Unlock()
153
154 err := a.runShutdown(c)
155 if err != nil {
156 status := a.shutdownStatus("")
157 a.lifecycle.tracker.markShutdown(status.Reason, status.Phase, "failed")
158 }
159 c.mu.Lock()
160 if err != nil {
161 var step *shutdownStepError
162 code := "shutdown_failed"
163 if errors.As(err, &step) {
164 code = step.code
165 }
166 c.status.Outcome = "failed"
167 c.status.Retryable = true
168 c.status.ErrorCode = code
169 c.status.Error = err.Error()
170 c.status.Completed = false
171 } else {
172 c.status.Phase = "completed"
173 c.status.Outcome = "success"
174 c.status.Completed = true
175 c.status.Retryable = false
176 c.status.ErrorCode = ""
177 c.status.Error = ""
178 }
179 c.status.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
180 c.running = false
181 close(done)
182 status := c.status
183 c.mu.Unlock()
184 return status, err
185 }
186
187 func (c *desktopShutdownCoordinator) setPhase(phase string) {
188 c.mu.Lock()
189 c.status.Phase = phase
190 c.status.Outcome = "in_progress"
191 c.status.ErrorCode = ""
192 c.status.Error = ""
193 c.status.Retryable = false
194 c.status.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
195 c.mu.Unlock()
196 }
197
198 func (c *desktopShutdownCoordinator) runStep(name string, run func()) {
199 c.mu.Lock()
200 done := c.finished[name]
201 c.mu.Unlock()
202 if done {
203 return
204 }
205 run()
206 c.mu.Lock()
207 c.finished[name] = true
208 c.mu.Unlock()
209 }
210
211 func (a *App) runShutdown(c *desktopShutdownCoordinator) (err error) {
212 defer func() {
213 if recovered := recover(); recovered != nil {
214 err = &shutdownStepError{code: "cleanup_panic", err: fmt.Errorf("shutdown cleanup panic: %v", recovered)}
215 }
216 }()
217
218 c.mu.Lock()
219 frozen := c.frozen
220 reason := c.status.Reason
221 c.mu.Unlock()
222 if !frozen {
223 c.setPhase("cancelling_background")
224 a.lifecycle.tracker.markShutdown(reason, "cancelling_background", "in_progress")
225 a.shuttingDown.Store(true)
226 a.stopHistoricalImports()
227 a.cancelSessionExports()
228 a.cancelSessionNavigation()
229 a.cancelAllTabBuilds()
230 a.stopSessionCatalog(250 * time.Millisecond)
231 c.mu.Lock()
232 c.frozen = true
233 c.mu.Unlock()
234 }
235
236 // Use the normal runtime lock order and never hold App.mu while invoking a
237 // controller. This prevents callback re-entry deadlocks during snapshots.
238 c.setPhase("waiting_runtime_rebuild")
239 a.lifecycle.tracker.markShutdown(reason, "waiting_runtime_rebuild", "in_progress")
240 a.runtimeRebuildMu.Lock()
241 defer a.runtimeRebuildMu.Unlock()
242 c.setPhase("waiting_runtime_admission")
243 a.lifecycle.tracker.markShutdown(reason, "waiting_runtime_admission", "in_progress")
244 a.runtimeAdmissionMu.Lock()
245 defer a.runtimeAdmissionMu.Unlock()
246
247 c.mu.Lock()
248 needsItems := c.items == nil
249 c.mu.Unlock()
250 if needsItems {
251 a.mu.RLock()
252 tabs := a.runtimeTabsLocked()
253 items := make([]desktopShutdownItem, 0, len(tabs))
254 for _, tab := range tabs {
255 if tab.Ctrl != nil {
256 items = append(items, desktopShutdownItem{tab: tab, ctrl: tab.Ctrl, readOnly: tab.ReadOnly})
257 }
258 }
259 a.mu.RUnlock()
260 sort.Slice(items, func(i, j int) bool { return items[i].tab.ID < items[j].tab.ID })
261 c.mu.Lock()
262 c.items = items
263 c.mu.Unlock()
264 }
265 c.mu.Lock()
266 items := append([]desktopShutdownItem(nil), c.items...)
267 reason = c.status.Reason
268 c.mu.Unlock()
269
270 c.setPhase("saving")
271 a.lifecycle.tracker.markShutdown(reason, "saving", "in_progress")
272 for _, item := range items {
273 if item.readOnly {
274 continue
275 }
276 c.mu.Lock()
277 saved := c.saved[item.tab.ID]
278 c.mu.Unlock()
279 if saved {
280 continue
281 }
282 if err := item.ctrl.SnapshotForShutdown(); err != nil {
283 a.lifecycle.tracker.markShutdown(reason, "saving", "failed")
284 return &shutdownStepError{code: "session_save_failed", err: fmt.Errorf("save session %s: %w", item.tab.ID, err)}
285 }
286 c.mu.Lock()
287 c.saved[item.tab.ID] = true
288 c.mu.Unlock()
289 }
290
291 c.setPhase("closing")
292 a.lifecycle.tracker.markShutdown(reason, "closing", "in_progress")
293 a.shutdownBody(c, items)
294 a.lifecycle.tracker.markShutdown(reason, "completed", "success")
295 if reason == shutdownReasonUserQuit || reason == shutdownReasonUpdateRestart {
296 a.lifecycle.tracker.clean()
297 }
298 return nil
299 }
300
301 // completeDesktopShutdown remains the small panic-preserving primitive used by
302 // lifecycle compatibility tests and callers outside the coordinated App path.
303 func completeDesktopShutdown(tracker *desktopLifecycleTracker, body func()) {
304 tracker.stopWriter()
305 tracker.mark("shutting_down")
306 body()
307 tracker.clean()
308 }
309
310 func (a *App) shutdownBody(c *desktopShutdownCoordinator, items []desktopShutdownItem) {
311 if a.desktopDrafts != nil {
312 c.runStep("desktop-drafts", func() { _ = a.desktopDrafts.Close() })
313 }
314 c.runStep("workspace-preview", a.stopWorkspacePreviewOrigin)
315 if a.desktopShell.coordinator != nil {
316 c.runStep("shell-coordinator", a.desktopShell.coordinator.stop)
317 }
318 if a.workspaceHub != nil {
319 c.runStep("workspace-hub", a.workspaceHub.close)
320 }
321 c.runStep("remote-windows", a.closeAllRemoteWindows)
322 c.runStep("deferred-rebuild", a.stopDeferredRebuildRetry)
323 c.runStep("takeover-mirrors-stop", a.stopTakeoverMirrors)
324 c.runStep("history-index", a.stopHistoryIndexMigration)
325 if a.heartbeat != nil {
326 c.runStep("heartbeat", a.heartbeat.Stop)
327 }
328 c.runStep("bot-runtime", a.stopBotRuntime)
329 c.runStep("remote-runtime", a.stopRemoteRuntime)
330 c.runStep("tray", a.stopTray)
331 if a.terminals != nil {
332 c.runStep("terminals", a.terminals.closeAll)
333 }
334 c.runStep("window-state", a.saveWindowStateSync)
335
336 for _, item := range items {
337 c.runStep("session:"+item.tab.ID, func() {
338 item.ctrl.Close()
339 if !a.returnTakeoverLeaseForShutdown(item.tab) {
340 item.tab.releaseSessionLease()
341 }
342 a.mu.Lock()
343 a.releaseSessionRuntimeLocked(item.tab)
344 a.mu.Unlock()
345 })
346 }
347 c.runStep("takeover-mirrors-end", a.endTakeoverMirrors)
348 c.runStep("update-health", func() {
349 if !a.startupReady.Load() {
350 return
351 }
352 if err := a.commitPendingUpdateHealth(); err != nil {
353 slog.Warn("desktop: commit healthy update during shutdown", "err", err)
354 }
355 if archived, err := archiveSupersededPendingUpdateAfterReady(); err != nil {
356 slog.Warn("desktop: retire superseded update during shutdown", "err", err)
357 } else if archived {
358 slog.Info("desktop: archived superseded update transaction during shutdown")
359 }
360 _ = repair.RecordHealthyConfig(version)
361 })
362 c.runStep("shared-hosts", a.closeAllSharedHosts)
363 c.runStep("derived-state", func() {
364 flushCtx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
365 defer cancel()
366 if err := stats.Flush(flushCtx, config.StatsDir()); err != nil {
367 slog.Warn("desktop: flush shutdown stats", "err", err)
368 }
369 if err := flushDesktopDerivedCatalogs(flushCtx); err != nil {
370 slog.Warn("desktop: flush derived catalogs", "err", err)
371 }
372 })
373 if a.topicState != nil {
374 c.runStep("topic-state", a.topicState.close)
375 }
376 c.runStep("session-services", a.closeSessionServices)
377 }
378
378 lines GO