返回 DeepSeek-Reasonix
session_migration_startup_test.go
根目录 / desktop / session_migration_startup_test.go
1 package main
2
3 import (
4 "bytes"
5 "context"
6 "errors"
7 "os"
8 "path/filepath"
9 "reflect"
10 "sync/atomic"
11 "testing"
12 "time"
13
14 "reasonix/internal/config"
15 "reasonix/internal/identitylock"
16 "reasonix/internal/session"
17 )
18
19 func TestStartupReservationReadinessPrecedesRestoredShell(t *testing.T) {
20 isolateDesktopUserDirs(t)
21 app := NewApp()
22 ctx, cancel := context.WithCancel(t.Context())
23 defer cancel()
24 app.tabsRestored = make(chan struct{})
25 var scans, notifications atomic.Int32
26 app.projectTreeCatalogRefreshHook = func() { scans.Add(1) }
27 app.projectTreeChangedHook = func() { notifications.Add(1) }
28 app.startDesktopSessionMigration(ctx)
29 select {
30 case <-app.desktopMigrationDone:
31 case <-time.After(3 * time.Second):
32 cancel()
33 t.Fatal("reservation recovery waited for the shell that depends on it")
34 }
35 app.historicalImports.mu.Lock()
36 pending := app.historicalImports.discoveryPending
37 app.historicalImports.mu.Unlock()
38 if !pending {
39 t.Fatal("historical discovery ran before shell readiness")
40 }
41 app.markTabsRestored()
42 app.historicalImports.workers.Wait()
43 if scans.Load() != 0 || notifications.Load() == 0 {
44 t.Fatalf("startup must notify without scheduling duplicate root scans: scans=%d notifications=%d", scans.Load(), notifications.Load())
45 }
46 app.closeSessionServices()
47 }
48
49 // Startup may discover historical metadata, but only an explicit open/import
50 // request is allowed to convert source content or replay a prepared import.
51 func TestDesktopStartupLeavesColdHistoryForExplicitImport(t *testing.T) {
52 for _, scope := range []string{"global", "project"} {
53 t.Run(scope, func(t *testing.T) {
54 isolateDesktopUserDirs(t)
55 root := config.SessionStoreDir()
56 if scope == "project" {
57 workspace := t.TempDir()
58 root = config.ProjectSessionStoreDir(workspace)
59 if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: workspace}}}); err != nil {
60 t.Fatal(err)
61 }
62 }
63 const id = "startup-cold-history"
64 coldV4MigrationFixture(t, root, id)
65 original := startupHistorySourceBytes(t, root, id)
66 app := NewApp()
67 t.Cleanup(app.closeSessionServices)
68 startupExistingV5Fixture(t, app)
69 runHistoryDiscoveryStartup(t, app)
70 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: id}
71 if _, err := app.desktopSessionService("").Query().Snapshot(t.Context(), ref); !errors.Is(err, session.ErrSessionNotFound) {
72 t.Errorf("startup must not publish historical content without an explicit import: %v", err)
73 }
74 assertStartupHistorySourceUnchanged(t, root, id, original)
75 assertStartupExistingV5Available(t, app)
76 })
77 }
78 }
79
80 func TestDesktopStartupPreservesPreparedImportWithoutWaitingForSource(t *testing.T) {
81 for _, lockName := range []string{"writer", "ownership"} {
82 t.Run(lockName, func(t *testing.T) {
83 isolateDesktopUserDirs(t)
84 root := config.SessionStoreDir()
85 const id = "startup-interrupted-import"
86 coldV4MigrationFixture(t, root, id)
87 original := startupHistorySourceBytes(t, root, id)
88 app := NewApp()
89 t.Cleanup(app.closeSessionServices)
90 startupExistingV5Fixture(t, app)
91 workspace, err := app.ensureDesktopWorkspace(t.Context(), "global", "")
92 if err != nil {
93 t.Fatal(err)
94 }
95 sourcePath := filepath.Join(root, id)
96 fingerprint, err := desktopSourceFingerprint(sourcePath)
97 if err != nil {
98 t.Fatal(err)
99 }
100 opID, err := app.prepareDesktopImport(t.Context(), desktopMigrationSource{scope: "global"}, sourcePath, fingerprint, id, workspace)
101 if err != nil {
102 t.Fatal(err)
103 }
104 before, err := app.workspaceRegistry().Load(t.Context())
105 if err != nil {
106 t.Fatal(err)
107 }
108 ledger := []byte(`{"version":1,"records":{"interrupted-source":{"sourceKey":"interrupted-source","targetSessionId":"startup-interrupted-import","status":"pending","attempts":4}},"futureField":{"preserve":true}}`)
109 if err := os.MkdirAll(filepath.Dir(desktopMigrationLedgerPath()), 0o700); err != nil {
110 t.Fatal(err)
111 }
112 if err := os.WriteFile(desktopMigrationLedgerPath(), ledger, 0o600); err != nil {
113 t.Fatal(err)
114 }
115 lockPath := filepath.Join(sourcePath, "writer.lock")
116 if lockName == "ownership" {
117 lockPath = filepath.Join(root, "."+id+".ownership.lock")
118 }
119 release, err := identitylock.Acquire(t.Context(), lockPath)
120 if err != nil {
121 t.Fatal(err)
122 }
123 defer release()
124 runHistoryDiscoveryStartup(t, app)
125 after, err := app.workspaceRegistry().Load(t.Context())
126 if err != nil {
127 t.Fatal(err)
128 }
129 if !reflect.DeepEqual(before.PendingOperations[opID], after.PendingOperations[opID]) {
130 t.Error("startup changed a prepared historical import without an explicit request")
131 }
132 if body := fileBytes(t, desktopMigrationLedgerPath()); !bytes.Equal(body, ledger) {
133 t.Error("startup modified the historical ledger, including unrecognized fields")
134 }
135 assertStartupHistorySourceUnchanged(t, root, id, original)
136 assertStartupExistingV5Available(t, app)
137 })
138 }
139 }
140
141 func runHistoryDiscoveryStartup(t *testing.T, app *App) {
142 t.Helper()
143 ctx, cancel := context.WithCancel(t.Context())
144 defer cancel()
145 app.ctx = ctx
146 app.startDesktopSessionMigration(ctx)
147 select {
148 case <-app.desktopMigrationDone:
149 // Reservation recovery no longer gates on historical discovery. Tests
150 // inspecting the eventual directory view join that worker explicitly.
151 app.historicalImports.workers.Wait()
152 case <-time.After(5 * time.Second):
153 // Source locks are deliberately retained throughout startup. This is a
154 // deadlock watchdog, not a startup performance requirement.
155 if app.runtimeRebuildMu.TryLock() {
156 app.runtimeRebuildMu.Unlock()
157 } else {
158 t.Error("historical startup wait retained the global runtime lock")
159 }
160 if app.runtimeAdmissionMu.TryRLock() {
161 app.runtimeAdmissionMu.RUnlock()
162 } else {
163 t.Error("historical startup wait blocked runtime admission")
164 }
165 cancel()
166 select {
167 case <-app.desktopMigrationDone:
168 case <-time.After(5 * time.Second):
169 t.Fatal("historical startup ignored cancellation")
170 }
171 t.Error("startup waited for a historical source lock without an import request")
172 }
173 }
174
175 func startupHistorySourceBytes(t *testing.T, root, id string) map[string][]byte {
176 t.Helper()
177 out := make(map[string][]byte)
178 for _, name := range []string{"manifest.json", "events.frames"} {
179 out[name] = fileBytes(t, filepath.Join(root, id, name))
180 }
181 return out
182 }
183
184 func assertStartupHistorySourceUnchanged(t *testing.T, root, id string, before map[string][]byte) {
185 t.Helper()
186 for name, body := range before {
187 if !bytes.Equal(body, fileBytes(t, filepath.Join(root, id, name))) {
188 t.Errorf("historical source %s was modified by startup", name)
189 }
190 }
191 }
192
193 func startupExistingV5Fixture(t *testing.T, app *App) {
194 t.Helper()
195 workspace, err := app.ensureDesktopWorkspace(t.Context(), "global", "")
196 if err != nil {
197 t.Fatal(err)
198 }
199 service := app.desktopSessionService("")
200 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "startup-existing-v5", CWD: globalWorkspaceRoot(), Origin: session.SessionOriginNew})
201 if err != nil {
202 t.Fatal(err)
203 }
204 if err := service.Close(t.Context(), runtime.Ref()); err != nil {
205 t.Fatal(err)
206 }
207 if err := app.workspaceRegistry().AttachSession(t.Context(), "", workspace, runtime.Ref().SessionID, ""); err != nil {
208 t.Fatal(err)
209 }
210 }
211
212 func assertStartupExistingV5Available(t *testing.T, app *App) {
213 t.Helper()
214 if _, err := app.desktopSessionService("").Query().Snapshot(t.Context(), session.SessionRef{HostID: localDesktopHostID, SessionID: "startup-existing-v5"}); err != nil {
215 t.Fatalf("existing v5 session became unavailable: %v", err)
216 }
217 if !app.runtimeRebuildMu.TryLock() {
218 t.Fatal("startup retained the runtime lock")
219 }
220 app.runtimeRebuildMu.Unlock()
221 if !app.runtimeAdmissionMu.TryRLock() {
222 t.Fatal("startup retained the runtime admission lock")
223 }
224 app.runtimeAdmissionMu.RUnlock()
225 }
226
226 lines GO