返回 DeepSeek-Reasonix
startup_health_test.go
根目录 / desktop / startup_health_test.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "path/filepath"
7 "sync"
8 "testing"
9 "time"
10
11 "reasonix/internal/control"
12 "reasonix/internal/repair"
13 )
14
15 type shutdownSnapshotController struct {
16 control.SessionAPI
17 calls []string
18 normalSnapshots int
19 sessionPath string
20 shutdown func() error
21 close func()
22 }
23
24 func TestShutdownSaveFailureKeepsEveryControllerOpenAndRetryResumes(t *testing.T) {
25 isolateDesktopUserDirs(t)
26 first := &shutdownSnapshotController{SessionAPI: control.New(control.Options{Label: "first"})}
27 secondAttempts := 0
28 second := &shutdownSnapshotController{SessionAPI: control.New(control.Options{Label: "second"})}
29 second.shutdown = func() error {
30 secondAttempts++
31 if secondAttempts == 1 {
32 return errors.New("disk full")
33 }
34 return nil
35 }
36 a := NewApp()
37 a.tabs["first"] = &WorkspaceTab{ID: "first", Ctrl: first}
38 a.tabs["second"] = &WorkspaceTab{ID: "second", Ctrl: second}
39 a.tabOrder = []string{"first", "second"}
40
41 failed, err := a.requestShutdown(context.Background(), shutdownRequest{RequestID: "attempt-1", Reason: shutdownReasonUserQuit})
42 if err == nil || failed.Phase != "saving" || failed.ErrorCode != "session_save_failed" || !failed.Retryable {
43 t.Fatalf("failed shutdown = %+v, %v", failed, err)
44 }
45 if len(first.calls) != 1 || first.calls[0] != "shutdown-snapshot" || len(second.calls) != 1 {
46 t.Fatalf("save failure closed a controller: first=%v second=%v", first.calls, second.calls)
47 }
48
49 completed, err := a.requestShutdown(context.Background(), shutdownRequest{RequestID: "attempt-1", Reason: shutdownReasonConnectionLost})
50 if err != nil || !completed.Completed || completed.Outcome != "success" {
51 t.Fatalf("retry shutdown = %+v, %v", completed, err)
52 }
53 if completed.Reason != shutdownReasonUserQuit {
54 t.Fatalf("retry rewrote original shutdown reason: %+v", completed)
55 }
56 if len(first.calls) != 2 || first.calls[1] != "close" {
57 t.Fatalf("already-saved controller was not closed exactly once: %v", first.calls)
58 }
59 if len(second.calls) != 3 || second.calls[1] != "shutdown-snapshot" || second.calls[2] != "close" {
60 t.Fatalf("failed save did not retry before close: %v", second.calls)
61 }
62 }
63
64 func TestConnectionLossCleanupPreservesAbnormalTerminationEvidence(t *testing.T) {
65 isolateDesktopUserDirs(t)
66 a := NewApp()
67 tracker := lifecycleTrackerForTest(t, t.TempDir(), 4242, "connection-loss")
68 tracker.state.PID = 4242
69 if err := tracker.start(); err != nil {
70 t.Fatal(err)
71 }
72 a.lifecycle.tracker = tracker
73 status, err := a.requestShutdown(context.Background(), shutdownRequest{
74 RequestID: "connection-loss", Reason: shutdownReasonConnectionLost,
75 })
76 if err != nil || !status.Completed {
77 t.Fatalf("connection loss cleanup = %+v, %v", status, err)
78 }
79 state, err := readDesktopLifecycleState(tracker.path)
80 if err != nil {
81 t.Fatalf("connection loss evidence was removed: %v", err)
82 }
83 if state.TerminationReason != shutdownReasonConnectionLost || state.CleanupOutcome != "success" || state.Phase != "completed" {
84 t.Fatalf("connection loss evidence = %+v", state)
85 }
86 }
87
88 func (c *shutdownSnapshotController) Snapshot() error {
89 c.normalSnapshots++
90 return nil
91 }
92
93 func (c *shutdownSnapshotController) SnapshotForShutdown() error {
94 c.calls = append(c.calls, "shutdown-snapshot")
95 if c.shutdown != nil {
96 return c.shutdown()
97 }
98 return nil
99 }
100
101 func (c *shutdownSnapshotController) SessionPath() string {
102 if c.sessionPath != "" {
103 return c.sessionPath
104 }
105 if c.SessionAPI != nil {
106 return c.SessionAPI.SessionPath()
107 }
108 return ""
109 }
110
111 func (c *shutdownSnapshotController) Close() {
112 c.calls = append(c.calls, "close")
113 if c.close != nil {
114 c.close()
115 }
116 if c.SessionAPI != nil {
117 c.SessionAPI.Close()
118 }
119 }
120
121 func TestShutdownCloseRetrySkipsCompletedResources(t *testing.T) {
122 isolateDesktopUserDirs(t)
123 first := &shutdownSnapshotController{SessionAPI: control.New(control.Options{Label: "first"})}
124 second := &shutdownSnapshotController{SessionAPI: control.New(control.Options{Label: "second"})}
125 closeAttempts := 0
126 second.close = func() {
127 closeAttempts++
128 if closeAttempts == 1 {
129 panic("close failed")
130 }
131 }
132 a := NewApp()
133 a.tabs["first"] = &WorkspaceTab{ID: "first", Ctrl: first}
134 a.tabs["second"] = &WorkspaceTab{ID: "second", Ctrl: second}
135 a.tabOrder = []string{"first", "second"}
136
137 failed, err := a.requestShutdown(context.Background(), shutdownRequest{RequestID: "close-1", Reason: shutdownReasonUserQuit})
138 if err == nil || failed.Phase != "closing" || failed.ErrorCode != "cleanup_panic" {
139 t.Fatalf("failed close = %+v, %v", failed, err)
140 }
141 completed, err := a.requestShutdown(context.Background(), shutdownRequest{RequestID: "close-1", Reason: shutdownReasonUserQuit})
142 if err != nil || !completed.Completed {
143 t.Fatalf("retry close = %+v, %v", completed, err)
144 }
145 if got := first.calls; len(got) != 2 || got[0] != "shutdown-snapshot" || got[1] != "close" {
146 t.Fatalf("completed controller repeated: %v", got)
147 }
148 if got := second.calls; len(got) != 3 || got[0] != "shutdown-snapshot" || got[1] != "close" || got[2] != "close" {
149 t.Fatalf("failed controller did not resume: %v", got)
150 }
151 }
152
153 func TestShutdownWaitsForRuntimeLifecycleMutation(t *testing.T) {
154 isolateDesktopUserDirs(t)
155 app := NewApp()
156 app.runtimeAdmissionMu.Lock()
157 admissionHeld := true
158 defer func() {
159 if admissionHeld {
160 app.runtimeAdmissionMu.Unlock()
161 }
162 }()
163
164 done := make(chan struct{})
165 go func() {
166 app.shutdown(context.Background())
167 close(done)
168 }()
169 deadline := time.Now().Add(5 * time.Second)
170 for app.runtimeRebuildMu.TryLock() {
171 app.runtimeRebuildMu.Unlock()
172 if time.Now().After(deadline) {
173 t.Fatal("shutdown did not enter the runtime lifecycle barrier")
174 }
175 time.Sleep(time.Millisecond)
176 }
177 select {
178 case <-done:
179 t.Fatal("shutdown bypassed an in-flight runtime lifecycle mutation")
180 default:
181 }
182 if status := app.shutdownStatus(""); status.Phase != "waiting_runtime_admission" {
183 t.Fatalf("blocked shutdown phase = %q, want waiting_runtime_admission", status.Phase)
184 }
185
186 app.runtimeAdmissionMu.Unlock()
187 admissionHeld = false
188 select {
189 case <-done:
190 case <-time.After(5 * time.Second):
191 t.Fatal("shutdown did not resume after the runtime lifecycle mutation completed")
192 }
193 }
194
195 func TestShutdownDoesNotWaitForCancelledControllerBuild(t *testing.T) {
196 isolateDesktopUserDirs(t)
197 app := NewApp()
198 tab := app.createTabEntryWithID("global", "", "", "blocked-build")
199 app.mu.Lock()
200 app.tabs[tab.ID] = tab
201 app.tabOrder = []string{tab.ID}
202 app.activeTabID = tab.ID
203 app.mu.Unlock()
204
205 started := make(chan struct{})
206 release := make(chan struct{})
207 unblock := sync.OnceFunc(func() { close(release) })
208 t.Cleanup(unblock)
209 app.tabBuildStartHook = func(string) {
210 close(started)
211 <-release
212 }
213 buildDone := make(chan struct{})
214 go func() {
215 app.startTabControllerBuild(tab)
216 close(buildDone)
217 }()
218 <-started
219
220 shutdownDone := make(chan struct{})
221 go func() {
222 app.shutdown(context.Background())
223 close(shutdownDone)
224 }()
225 select {
226 case <-shutdownDone:
227 // Completion while the build remains blocked proves non-dependence.
228 // The guard is for deadlocks, not shutdown persistence performance.
229 unblock()
230 case <-time.After(5 * time.Second):
231 unblock()
232 t.Fatal("shutdown waited for a cancelled controller build")
233 }
234 select {
235 case <-buildDone:
236 case <-time.After(5 * time.Second):
237 t.Fatal("cancelled controller build did not finish")
238 }
239 }
240
241 func TestShutdownCancelsBlockedSessionOpen(t *testing.T) {
242 app, _, target, _, _ := canonicalWorkspaceOpenFixture(t)
243 started := make(chan struct{})
244 release := make(chan struct{})
245 unblock := sync.OnceFunc(func() { close(release) })
246 t.Cleanup(unblock)
247 cancelled := make(chan struct{})
248 app.sessionOpenBuildHook = func(ctx context.Context) {
249 close(started)
250 select {
251 case <-ctx.Done():
252 close(cancelled)
253 case <-release:
254 }
255 }
256
257 openDone := make(chan error, 1)
258 go func() {
259 _, err := app.OpenSession(target.Ref())
260 openDone <- err
261 }()
262 <-started
263
264 shutdownDone := make(chan error, 1)
265 go func() {
266 _, err := app.requestShutdown(context.Background(), shutdownRequest{RequestID: "cancel-session-open", Reason: shutdownReasonUserQuit})
267 shutdownDone <- err
268 }()
269
270 // Observe cancellation itself before waiting for unrelated shutdown work
271 // such as session persistence and window-state writes.
272 select {
273 case <-cancelled:
274 case <-time.After(5 * time.Second):
275 t.Fatal("session open did not observe shutdown cancellation")
276 }
277 select {
278 case err := <-shutdownDone:
279 if err != nil {
280 t.Fatalf("shutdown after cancelling session open: %v", err)
281 }
282 case <-time.After(5 * time.Second):
283 t.Fatal("shutdown did not complete after session open observed cancellation")
284 }
285 select {
286 case err := <-openDone:
287 if !errors.Is(err, context.Canceled) {
288 t.Fatalf("cancelled session open = %v, want context canceled", err)
289 }
290 case <-time.After(5 * time.Second):
291 t.Fatal("cancelled session open did not return")
292 }
293 }
294
295 // TestShutdownRecordsLKGOnlyAfterReady pins that last-known-good config is only
296 // written after the window reached domReady. A quit before paint must not
297 // rewrite the LKG snapshot from an incomplete boot.
298 func TestShutdownRecordsLKGOnlyAfterReady(t *testing.T) {
299 isolateDesktopUserDirs(t)
300 a := NewApp()
301 // Pre-ready shutdown is a no-op for LKG (startupReady is false).
302 a.shutdown(context.Background())
303 a.startupReady.Store(true)
304 // Post-ready shutdown attempts RecordHealthyConfig; missing user config is fine.
305 a.shutdown(context.Background())
306 }
307
308 func TestCaptureAndCommitPendingUpdateHealthUsesExactStartupIdentity(t *testing.T) {
309 originalRead := readPendingUpdateForHealth
310 originalMark := markPendingUpdateHealthyAfterReady
311 t.Cleanup(func() {
312 readPendingUpdateForHealth = originalRead
313 markPendingUpdateHealthyAfterReady = originalMark
314 })
315 tx := &repair.UpdateTransaction{
316 SchemaVersion: 1,
317 ToVersion: version,
318 CreatedAt: "2026-08-05T00:00:00Z",
319 Platform: "darwin/arm64",
320 TargetKind: "app-bundle",
321 TargetPath: "/Applications/Reasonix.app",
322 BackupPath: "/Applications/Reasonix.app.reasonix-update-backup",
323 }
324 readPendingUpdateForHealth = func() (*repair.UpdateTransaction, error) { return tx, nil }
325 app := NewApp()
326 capturePendingUpdateHealthIdentity(app)
327 wantID := repair.UpdateTransactionID(tx)
328 if app.healthyUpdateCreatedAt != tx.CreatedAt || app.healthyUpdateTransactionID != wantID {
329 t.Fatalf("captured health identity=(%q,%q), want (%q,%q)", app.healthyUpdateCreatedAt, app.healthyUpdateTransactionID, tx.CreatedAt, wantID)
330 }
331 called := false
332 markPendingUpdateHealthyAfterReady = func(running, createdAt, transactionID string) error {
333 called = true
334 if running != version || createdAt != tx.CreatedAt || transactionID != wantID {
335 t.Fatalf("health commit=(%q,%q,%q)", running, createdAt, transactionID)
336 }
337 return nil
338 }
339 if err := app.commitPendingUpdateHealth(); err != nil {
340 t.Fatal(err)
341 }
342 if !called {
343 t.Fatal("exact startup transaction was not committed")
344 }
345 }
346
347 func TestCapturePendingUpdateHealthAcceptsVersionPrefixMismatch(t *testing.T) {
348 originalRead := readPendingUpdateForHealth
349 originalVersion := version
350 t.Cleanup(func() {
351 readPendingUpdateForHealth = originalRead
352 version = originalVersion
353 })
354 version = "1.21.0"
355 readPendingUpdateForHealth = func() (*repair.UpdateTransaction, error) {
356 return &repair.UpdateTransaction{
357 ToVersion: "v1.21.0",
358 CreatedAt: "2026-08-07T00:00:00Z",
359 TargetKind: "file",
360 }, nil
361 }
362 app := &App{}
363 capturePendingUpdateHealthIdentity(app)
364 if app.healthyUpdateCreatedAt == "" || app.healthyUpdateTransactionID == "" {
365 t.Fatalf("expected health identity for v-prefix mismatch, got createdAt=%q id=%q",
366 app.healthyUpdateCreatedAt, app.healthyUpdateTransactionID)
367 }
368 }
369
370 func TestCapturePendingUpdateHealthRejectsDifferentTargetVersion(t *testing.T) {
371 originalRead := readPendingUpdateForHealth
372 t.Cleanup(func() { readPendingUpdateForHealth = originalRead })
373 readPendingUpdateForHealth = func() (*repair.UpdateTransaction, error) {
374 return &repair.UpdateTransaction{ToVersion: version + "-other", CreatedAt: "2026-08-05T00:00:00Z"}, nil
375 }
376 app := NewApp()
377 capturePendingUpdateHealthIdentity(app)
378 if app.healthyUpdateCreatedAt != "" || app.healthyUpdateTransactionID != "" {
379 t.Fatalf("captured unrelated transaction: %+v", app)
380 }
381 }
382
383 func TestShutdownUsesDurableSnapshotBeforeClosingController(t *testing.T) {
384 isolateDesktopUserDirs(t)
385 ctrl := &shutdownSnapshotController{SessionAPI: control.New(control.Options{Label: "shutdown"})}
386 a := NewApp()
387 a.tabs["tab"] = &WorkspaceTab{ID: "tab", Ctrl: ctrl}
388 a.tabOrder = []string{"tab"}
389
390 a.shutdown(context.Background())
391
392 if ctrl.normalSnapshots != 0 {
393 t.Fatalf("ordinary Snapshot calls = %d, want shutdown-specific persistence", ctrl.normalSnapshots)
394 }
395 if len(ctrl.calls) != 2 || ctrl.calls[0] != "shutdown-snapshot" || ctrl.calls[1] != "close" {
396 t.Fatalf("shutdown call order = %v, want [shutdown-snapshot close]", ctrl.calls)
397 }
398 }
399
400 func TestShutdownPersistsRecoveryPathCommittedAfterCallback(t *testing.T) {
401 isolateDesktopUserDirs(t)
402 dir := t.TempDir()
403 originalPath := filepath.Join(dir, "original.jsonl")
404 recoveryPath := filepath.Join(dir, "original-recovery.jsonl")
405 a := NewApp()
406 ctrl := &shutdownSnapshotController{
407 SessionAPI: control.New(control.Options{Label: "shutdown", SessionPath: originalPath}),
408 sessionPath: originalPath,
409 }
410 tab := &WorkspaceTab{ID: "tab", Ctrl: ctrl, SessionPath: originalPath}
411 a.tabs[tab.ID] = tab
412 a.tabOrder = []string{tab.ID}
413 a.activeTabID = tab.ID
414 ctrl.shutdown = func() error {
415 err := a.handleTabSessionRecovered(tab)(control.SessionRecoveryInfo{
416 OriginalPath: originalPath,
417 RecoveryPath: recoveryPath,
418 })
419 if err == nil {
420 // Force a newer ordinary layout write while Controller still exposes
421 // the old path. The recovery lease must keep this write anchored to
422 // recovery instead of undoing the callback's first save.
423 a.mu.Lock()
424 a.saveTabsLocked()
425 a.mu.Unlock()
426 // Controller.commitRecoveredSession updates its path only after the
427 // callback succeeds. Mirror that ordering exactly.
428 ctrl.sessionPath = recoveryPath
429 }
430 return err
431 }
432
433 a.shutdown(context.Background())
434
435 saved := loadTabsFile()
436 if len(saved.Tabs) != 1 || saved.Tabs[0].ID != tab.ID {
437 t.Fatalf("saved tabs = %+v, want recovered tab %q", saved.Tabs, tab.ID)
438 }
439 if got := saved.Tabs[0].SessionPath; got != recoveryPath {
440 t.Fatalf("saved shutdown session path = %q, want recovery path %q", got, recoveryPath)
441 }
442 if len(ctrl.calls) != 2 || ctrl.calls[0] != "shutdown-snapshot" || ctrl.calls[1] != "close" {
443 t.Fatalf("shutdown call order = %v, want [shutdown-snapshot close]", ctrl.calls)
444 }
445 }
446
446 lines GO