返回 DeepSeek-Reasonix
session_purge_sources_test.go
根目录 / desktop / session_purge_sources_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "testing"
11 "time"
12
13 "reasonix/desktop/internal/workspacestate"
14 "reasonix/internal/config"
15 "reasonix/internal/identitylock"
16 "reasonix/internal/session"
17 )
18
19 func TestPurgeMigratedSourcesProtectsOtherOwnersAndChangedContent(t *testing.T) {
20 for _, scenario := range []string{"exclusive", "active_owner", "archived_owner", "pending_import", "recovery", "changed", "older_purge", "missing_root"} {
21 t.Run(scenario, func(t *testing.T) {
22 isolateDesktopUserDirs(t)
23 root := config.SessionStoreDir()
24 coldV4MigrationFixture(t, root, "source")
25 app := newHistoricalLifecycleApp(t)
26 key := historicalLifecycleID(t, app, "source")
27 result, err := app.ImportHistoricalSession(key)
28 if err != nil {
29 t.Fatal(err)
30 }
31 state, err := app.workspaceRegistry().Load(t.Context())
32 if err != nil {
33 t.Fatal(err)
34 }
35 mapping := state.SourceMappings[key]
36 var otherID string
37 switch scenario {
38 case "active_owner", "archived_owner":
39 other := addLifecycleFixtureSession(t, app, "other-owner")
40 otherID = other.SessionID
41 shared := mapping
42 shared.SourceKey += ":review:other"
43 shared.SessionID = otherID
44 if err := app.workspaceRegistry().RecordSource(t.Context(), shared, workspacestate.Presentation{}); err != nil {
45 t.Fatal(err)
46 }
47 if scenario == "archived_owner" {
48 if err := app.ArchiveCanonicalSession(other); err != nil {
49 t.Fatal(err)
50 }
51 }
52 case "pending_import":
53 pending := mapping
54 pending.SourceKey, pending.SessionID = key+":review:pending", "pending-owner"
55 if err := app.workspaceRegistry().BeginOperation(t.Context(), workspacestate.Operation{ID: "pending-source-import", Kind: "import", Lifecycle: workspacestate.Active, Mapping: &pending}); err != nil {
56 t.Fatal(err)
57 }
58 case "recovery":
59 if err := app.sourceRecovery(t.Context(), mapping.Path, "canonical", "source_changed_after_adoption", "global", ""); err != nil {
60 t.Fatal(err)
61 }
62 case "changed":
63 // A prior application continued the old source after migration.
64 path := filepath.Join(mapping.Path, "events.frames")
65 body, err := os.ReadFile(path)
66 if err != nil {
67 t.Fatal(err)
68 }
69 if err := os.WriteFile(path, append(body, []byte("later source bytes")...), 0600); err != nil {
70 t.Fatal(err)
71 }
72 }
73 if err := app.ArchiveCanonicalSession(result.Session); err != nil {
74 t.Fatal(err)
75 }
76 if scenario == "older_purge" {
77 state, err = app.workspaceRegistry().Load(t.Context())
78 if err != nil {
79 t.Fatal(err)
80 }
81 if err := app.workspaceRegistry().BeginPurge(t.Context(), result.Session.SessionID, state.Generation); err != nil {
82 t.Fatal(err)
83 }
84 }
85 before, err := desktopSourceFingerprint(mapping.Path)
86 if err != nil {
87 t.Fatal(err)
88 }
89 verifyPath := mapping.Path
90 if scenario == "missing_root" {
91 if err := os.Rename(root, root+"-offline"); err != nil {
92 t.Fatal(err)
93 }
94 verifyPath = filepath.Join(root+"-offline", "source")
95 }
96 if err := app.PurgeCanonicalSession(result.Session); err != nil {
97 t.Fatal(err)
98 }
99 if scenario == "exclusive" {
100 if _, err := os.Stat(mapping.Path); !os.IsNotExist(err) {
101 t.Fatalf("exclusive original retained: %v", err)
102 }
103 } else if after, err := desktopSourceFingerprint(verifyPath); err != nil || after != before {
104 t.Fatalf("protected original changed: %v", err)
105 }
106 state, err = app.workspaceRegistry().Load(t.Context())
107 if err != nil || workspacestate.ClassifyPurge(state, result.Session.SessionID) != workspacestate.PurgeCommitted {
108 t.Fatalf("purge incomplete: %v", err)
109 }
110 if state.SourceMappings[key].SessionID != result.Session.SessionID {
111 t.Fatal("deletion lost source tombstone")
112 }
113 if otherID != "" && state.SessionStates[otherID].Lifecycle == workspacestate.Deleted {
114 t.Fatal("purge deleted another source owner")
115 }
116 if otherID != "" {
117 other := session.SessionRef{HostID: localDesktopHostID, SessionID: otherID}
118 if scenario == "active_owner" {
119 if err := app.ArchiveCanonicalSession(other); err != nil {
120 t.Fatal(err)
121 }
122 }
123 if err := app.PurgeCanonicalSession(other); err != nil {
124 t.Fatal(err)
125 }
126 if _, err := os.Stat(mapping.Path); !os.IsNotExist(err) {
127 t.Fatalf("last source owner left the exclusive original: %v", err)
128 }
129 }
130 })
131 }
132 }
133
134 func TestPurgeMigratedSourcesBusyWriterResumesFromTombstone(t *testing.T) {
135 isolateDesktopUserDirs(t)
136 root := config.SessionStoreDir()
137 coldV4MigrationFixture(t, root, "busy-source")
138 app := newHistoricalLifecycleApp(t)
139 key := historicalLifecycleID(t, app, "busy-source")
140 result, err := app.ImportHistoricalSession(key)
141 if err != nil {
142 t.Fatal(err)
143 }
144 if err := app.ArchiveCanonicalSession(result.Session); err != nil {
145 t.Fatal(err)
146 }
147 release, err := identitylock.TryAcquire(filepath.Join(root, "busy-source", "writer.lock"))
148 if err != nil {
149 t.Fatal(err)
150 }
151 defer release()
152 if err := app.PurgeCanonicalSession(result.Session); !errors.Is(err, identitylock.ErrHeld) {
153 t.Fatalf("purge ignored source writer: %v", err)
154 }
155 state, err := app.workspaceRegistry().Load(t.Context())
156 if err != nil || workspacestate.ClassifyPurge(state, result.Session.SessionID) != workspacestate.PurgeTombstoned {
157 t.Fatalf("missing resumable tombstone: %v", err)
158 }
159 var plan workspacestate.PurgeSourceCleanup
160 if err := json.Unmarshal(state.PendingOperations["purge-"+result.Session.SessionID].Request, &plan); err != nil || len(plan.Sources) != 1 {
161 t.Fatalf("source cleanup intent lost: %+v %v", plan, err)
162 }
163 release()
164 app.closeSessionServices()
165 app = newHistoricalLifecycleApp(t)
166 if err := app.recoverDesktopSessionOperations(t.Context()); err != nil {
167 t.Fatal(err)
168 }
169 if _, err := os.Stat(filepath.Join(root, "busy-source")); !os.IsNotExist(err) {
170 t.Fatalf("resumed purge retained source: %v", err)
171 }
172 if _, err := app.ImportHistoricalSession(key); err == nil {
173 t.Fatal("purged source was imported again")
174 }
175 }
176
177 func TestPurgeMigratedSourcesCrashRestart(t *testing.T) {
178 for _, phase := range []string{"after-tombstone", "before-source-cleanup", "after-source-cleanup"} {
179 t.Run(phase, func(t *testing.T) {
180 isolateDesktopUserDirs(t)
181 root := config.SessionStoreDir()
182 coldV4MigrationFixture(t, root, "crash-source")
183 app := newHistoricalLifecycleApp(t)
184 key := historicalLifecycleID(t, app, "crash-source")
185 result, err := app.ImportHistoricalSession(key)
186 if err != nil {
187 t.Fatal(err)
188 }
189 if err := app.ArchiveCanonicalSession(result.Session); err != nil {
190 t.Fatal(err)
191 }
192 req := lifecycleRequest(t, app, result.Session, "source-crash", "purge")
193 body, err := json.Marshal(req)
194 if err != nil {
195 t.Fatal(err)
196 }
197 app.stopHistoricalImports()
198 app.closeSessionServices()
199 for _, checkpoint := range []string{phase, "", ""} {
200 ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
201 cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestPurgeCommandCrashHelper$")
202 cmd.Env = append(os.Environ(), "REASONIX_PURGE_APP_ROOT="+app.desktopSessions.root, "REASONIX_PURGE_APP_REGISTRY="+app.workspaceRegistry().Path(), "REASONIX_PURGE_APP_REQUEST="+string(body), "REASONIX_PURGE_APP_POINT="+checkpoint)
203 output, err := cmd.CombinedOutput()
204 cancel()
205 var exit *exec.ExitError
206 if checkpoint != "" {
207 if !errors.As(err, &exit) || exit.ExitCode() != 23 {
208 t.Fatalf("source checkpoint not reached: %v %s", err, output)
209 }
210 } else if err != nil {
211 t.Fatalf("source recovery failed: %v %s", err, output)
212 }
213 }
214 if _, err := os.Stat(filepath.Join(root, "crash-source")); !os.IsNotExist(err) {
215 t.Fatalf("original survived crash recovery: %v", err)
216 }
217 state, err := workspacestate.NewStore(app.workspaceRegistry().Path()).Load(t.Context())
218 if err != nil || state.SourceMappings[key].SessionID != result.Session.SessionID || workspacestate.ClassifyPurge(state, result.Session.SessionID) != workspacestate.PurgeCommitted {
219 t.Fatalf("recovery lost deletion evidence: %v", err)
220 }
221 })
222 }
223 }
224
224 lines GO