| 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 |