| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "strconv" |
| 7 | "sync" |
| 8 | "sync/atomic" |
| 9 | "testing" |
| 10 | "time" |
| 11 | ) |
| 12 | |
| 13 | func lifecycleTrackerForTest(t *testing.T, root string, pid int, runID string) *desktopLifecycleTracker { |
| 14 | t.Helper() |
| 15 | tracker := newDesktopLifecycleTracker(root, "v1.23.0", "stable") |
| 16 | tracker.state.PID = pid |
| 17 | tracker.state.RunID = runID |
| 18 | tracker.path = filepath.Join(tracker.dir, runID+".json") |
| 19 | tracker.processAlive = func(int) bool { return false } |
| 20 | return tracker |
| 21 | } |
| 22 | |
| 23 | func TestDesktopLifecycleDeadRecordIsConsumedOnce(t *testing.T) { |
| 24 | root := t.TempDir() |
| 25 | dead := lifecycleTrackerForTest(t, root, 4242, "dead") |
| 26 | if err := dead.start(); err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | dead.mark("healthy") |
| 30 | dead.stopWriter() |
| 31 | |
| 32 | reader := lifecycleTrackerForTest(t, root, os.Getpid(), "reader") |
| 33 | got := reader.consumePrevious(true) |
| 34 | if len(got) != 1 || got[0].Phase != "healthy" || got[0].Version != "v1.23.0" { |
| 35 | t.Fatalf("observations = %+v", got) |
| 36 | } |
| 37 | if replay := reader.consumePrevious(true); len(replay) != 0 { |
| 38 | t.Fatalf("lifecycle record replayed: %+v", replay) |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | func TestDesktopLifecycleClaimIsRestoredWhenPendingQueueWriteFails(t *testing.T) { |
| 43 | root := t.TempDir() |
| 44 | dead := lifecycleTrackerForTest(t, root, 4242, "dead-retry") |
| 45 | if err := dead.start(); err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | dead.stopWriter() |
| 49 | reader := lifecycleTrackerForTest(t, root, os.Getpid(), "reader") |
| 50 | first := reader.consumePrevious(true) |
| 51 | if len(first) != 1 { |
| 52 | t.Fatalf("first claim = %+v", first) |
| 53 | } |
| 54 | reader.finalizeObservation(first[0], false) |
| 55 | second := reader.consumePrevious(true) |
| 56 | if len(second) != 1 || second[0].RunID != "dead-retry" { |
| 57 | t.Fatalf("restored claim was not retryable: %+v", second) |
| 58 | } |
| 59 | reader.finalizeObservation(second[0], true) |
| 60 | if _, err := os.Stat(dead.path); !os.IsNotExist(err) { |
| 61 | t.Fatalf("persisted evidence was not acknowledged: %v", err) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | func TestDesktopLifecycleConcurrentConsumersClaimOnce(t *testing.T) { |
| 66 | root := t.TempDir() |
| 67 | dead := lifecycleTrackerForTest(t, root, 4242, "dead-concurrent") |
| 68 | if err := dead.start(); err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | dead.stopWriter() |
| 72 | |
| 73 | const observers = 16 |
| 74 | start := make(chan struct{}) |
| 75 | var ready sync.WaitGroup |
| 76 | var group sync.WaitGroup |
| 77 | var reports atomic.Int32 |
| 78 | for observer := range observers { |
| 79 | ready.Add(1) |
| 80 | group.Go(func() { |
| 81 | reader := lifecycleTrackerForTest(t, root, os.Getpid(), "reader-"+strconv.Itoa(observer)) |
| 82 | ready.Done() |
| 83 | <-start |
| 84 | if len(reader.consumePrevious(true)) == 1 { |
| 85 | reports.Add(1) |
| 86 | } |
| 87 | }) |
| 88 | } |
| 89 | ready.Wait() |
| 90 | close(start) |
| 91 | group.Wait() |
| 92 | if got := reports.Load(); got != 1 { |
| 93 | t.Fatalf("concurrent reports = %d, want 1", got) |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | func TestDesktopDiagnosticsOwnershipIsNonBlockingAndExclusive(t *testing.T) { |
| 98 | oldVersion := version |
| 99 | version = "v1.23.0" |
| 100 | t.Cleanup(func() { version = oldVersion }) |
| 101 | |
| 102 | first := NewApp() |
| 103 | prepareDesktopDiagnostics(first) |
| 104 | t.Cleanup(func() { |
| 105 | first.lifecycle.tracker.clean() |
| 106 | first.releaseDesktopDiagnosticsOwnership() |
| 107 | }) |
| 108 | if !first.diagnosticsOwner { |
| 109 | t.Fatal("first process did not claim diagnostics ownership") |
| 110 | } |
| 111 | |
| 112 | second := NewApp() |
| 113 | prepareDesktopDiagnostics(second) |
| 114 | if second.diagnosticsOwner { |
| 115 | second.releaseDesktopDiagnosticsOwnership() |
| 116 | t.Fatal("second process unexpectedly claimed diagnostics ownership") |
| 117 | } |
| 118 | |
| 119 | first.lifecycle.tracker.clean() |
| 120 | first.releaseDesktopDiagnosticsOwnership() |
| 121 | prepareDesktopDiagnostics(second) |
| 122 | t.Cleanup(func() { |
| 123 | second.lifecycle.tracker.clean() |
| 124 | second.releaseDesktopDiagnosticsOwnership() |
| 125 | }) |
| 126 | if !second.diagnosticsOwner { |
| 127 | t.Fatal("ownership was not released for the next process") |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | func TestDesktopDiagnosticsSkipsNonPrimaryLaunchModes(t *testing.T) { |
| 132 | oldVersion := version |
| 133 | t.Cleanup(func() { |
| 134 | version = oldVersion |
| 135 | }) |
| 136 | |
| 137 | version = "dev" |
| 138 | dev := NewApp() |
| 139 | prepareDesktopDiagnostics(dev) |
| 140 | if dev.diagnosticsOwner { |
| 141 | t.Fatal("dev build claimed diagnostics ownership") |
| 142 | } |
| 143 | |
| 144 | } |
| 145 | |
| 146 | func TestDesktopLifecycleLiveRecordIsPreserved(t *testing.T) { |
| 147 | root := t.TempDir() |
| 148 | live := lifecycleTrackerForTest(t, root, 4242, "live") |
| 149 | if err := live.start(); err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | t.Cleanup(live.stopWriter) |
| 153 | |
| 154 | reader := lifecycleTrackerForTest(t, root, os.Getpid(), "reader") |
| 155 | reader.processAlive = func(pid int) bool { return pid == 4242 } |
| 156 | if got := reader.consumePrevious(true); len(got) != 0 { |
| 157 | t.Fatalf("live record observed: %+v", got) |
| 158 | } |
| 159 | if _, err := os.Stat(live.path); err != nil { |
| 160 | t.Fatalf("live record was removed: %v", err) |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | func TestDesktopLifecycleOptOutConsumesWithoutReporting(t *testing.T) { |
| 165 | root := t.TempDir() |
| 166 | dead := lifecycleTrackerForTest(t, root, 4242, "dead") |
| 167 | if err := dead.start(); err != nil { |
| 168 | t.Fatal(err) |
| 169 | } |
| 170 | dead.stopWriter() |
| 171 | |
| 172 | reader := lifecycleTrackerForTest(t, root, os.Getpid(), "reader") |
| 173 | if got := reader.consumePrevious(false); len(got) != 0 { |
| 174 | t.Fatalf("opt-out returned observations: %+v", got) |
| 175 | } |
| 176 | if _, err := os.Stat(dead.path); !os.IsNotExist(err) { |
| 177 | t.Fatalf("opt-out did not consume dead record: %v", err) |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | func TestDesktopLifecycleUnknownSchemaIsPreserved(t *testing.T) { |
| 182 | root := t.TempDir() |
| 183 | future := lifecycleTrackerForTest(t, root, 4242, "future") |
| 184 | future.state.SchemaVersion = desktopLifecycleSchemaVersion + 1 |
| 185 | if err := future.start(); err != nil { |
| 186 | t.Fatal(err) |
| 187 | } |
| 188 | future.stopWriter() |
| 189 | |
| 190 | reader := lifecycleTrackerForTest(t, root, os.Getpid(), "reader") |
| 191 | if got := reader.consumePrevious(true); len(got) != 0 { |
| 192 | t.Fatalf("future lifecycle record observed: %+v", got) |
| 193 | } |
| 194 | state, err := readDesktopLifecycleState(future.path) |
| 195 | if err != nil || state.SchemaVersion != desktopLifecycleSchemaVersion+1 { |
| 196 | t.Fatalf("future lifecycle record was not preserved: state=%+v err=%v", state, err) |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | func TestDesktopLifecycleUnknownSchemaWithoutCurrentFieldsIsNeverPruned(t *testing.T) { |
| 201 | root := t.TempDir() |
| 202 | future := lifecycleTrackerForTest(t, root, 4242, "future-fields") |
| 203 | future.state.SchemaVersion = desktopLifecycleSchemaVersion + 1 |
| 204 | future.state.PID = 0 |
| 205 | future.state.Phase = "" |
| 206 | if err := future.start(); err != nil { |
| 207 | t.Fatal(err) |
| 208 | } |
| 209 | future.stopWriter() |
| 210 | old := time.Now().UTC().Add(-2 * desktopLifecycleRetention) |
| 211 | if err := os.Chtimes(future.path, old, old); err != nil { |
| 212 | t.Fatal(err) |
| 213 | } |
| 214 | |
| 215 | reader := lifecycleTrackerForTest(t, root, os.Getpid(), "reader") |
| 216 | reader.now = func() time.Time { return time.Now().UTC() } |
| 217 | if got := reader.consumePrevious(true); len(got) != 0 { |
| 218 | t.Fatalf("future lifecycle record observed: %+v", got) |
| 219 | } |
| 220 | if _, err := os.Stat(future.path); err != nil { |
| 221 | t.Fatalf("future lifecycle record without v2 fields was pruned: %v", err) |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | func TestDesktopLifecycleCleanRemovesCurrentRecord(t *testing.T) { |
| 226 | tracker := lifecycleTrackerForTest(t, t.TempDir(), os.Getpid(), "current") |
| 227 | base := time.Date(2026, 8, 10, 1, 0, 0, 0, time.UTC) |
| 228 | tracker.now = func() time.Time { return base } |
| 229 | if err := tracker.start(); err != nil { |
| 230 | t.Fatal(err) |
| 231 | } |
| 232 | tracker.mark("shutting_down") |
| 233 | state, err := readDesktopLifecycleState(tracker.path) |
| 234 | if err != nil || state.Phase != "shutting_down" || state.UpdatedAt != base.Format(time.RFC3339Nano) { |
| 235 | t.Fatalf("state = %+v err=%v", state, err) |
| 236 | } |
| 237 | tracker.clean() |
| 238 | if _, err := os.Stat(tracker.path); !os.IsNotExist(err) { |
| 239 | t.Fatalf("clean lifecycle record remains: %v", err) |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | func TestDesktopLifecycleAsyncWriterKeepsLatestPhase(t *testing.T) { |
| 244 | tracker := lifecycleTrackerForTest(t, t.TempDir(), os.Getpid(), "async") |
| 245 | if err := tracker.start(); err != nil { |
| 246 | t.Fatal(err) |
| 247 | } |
| 248 | tracker.markAsync("ready") |
| 249 | tracker.markAsync("healthy") |
| 250 | tracker.stopWriter() |
| 251 | // stopWriter leaves after 250ms so quitting never blocks on diagnostic I/O. |
| 252 | // That budget is not a flush guarantee — one atomic replace on a loaded |
| 253 | // Windows runner has taken 500ms — so wait for the drain before reading. |
| 254 | select { |
| 255 | case <-tracker.writerDone: |
| 256 | case <-time.After(30 * time.Second): |
| 257 | t.Fatal("lifecycle writer never drained") |
| 258 | } |
| 259 | |
| 260 | state, err := readDesktopLifecycleState(tracker.path) |
| 261 | if err != nil || state.Phase != "healthy" { |
| 262 | t.Fatalf("async lifecycle state = %+v err=%v", state, err) |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | func TestDesktopShutdownPanicPreservesLifecycleRecord(t *testing.T) { |
| 267 | tracker := lifecycleTrackerForTest(t, t.TempDir(), os.Getpid(), "shutdown-panic") |
| 268 | if err := tracker.start(); err != nil { |
| 269 | t.Fatal(err) |
| 270 | } |
| 271 | |
| 272 | func() { |
| 273 | defer func() { _ = recover() }() |
| 274 | completeDesktopShutdown(tracker, func() { panic("teardown failed") }) |
| 275 | }() |
| 276 | state, err := readDesktopLifecycleState(tracker.path) |
| 277 | if err != nil || state.Phase != "shutting_down" { |
| 278 | t.Fatalf("panic lifecycle state = %+v err=%v", state, err) |
| 279 | } |
| 280 | } |
| 281 |