返回 DeepSeek-Reasonix
manual_creation_manager_test.go
根目录 / desktop / manual_creation_manager_test.go
1 package main
2
3 import (
4 "bufio"
5 "context"
6 "crypto/sha256"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "strings"
14 "sync/atomic"
15 "testing"
16 "time"
17
18 "reasonix/desktop/internal/sessionui"
19 "reasonix/internal/identitylock"
20 "reasonix/internal/session"
21 )
22
23 func seedManualCreation(t *testing.T, a *App, id, phase string) (ManualSessionCreationView, sessionui.Record) {
24 t.Helper()
25 workspace, err := a.ensureDesktopWorkspace(t.Context(), "global", "")
26 if err != nil {
27 t.Fatal(err)
28 }
29 sum := sha256.Sum256([]byte(id))
30 v := ManualSessionCreationView{OperationID: id, WorkspaceID: workspace, Scope: "global", Phase: phase,
31 Ref: session.SessionRef{HostID: "local", SessionID: fmt.Sprintf("desktop-manual-%x", sum[:16])},
32 TopicID: fmt.Sprintf("manual-%x", sum[:16]), Settings: a.defaultDraftSettings("global", "")}
33 b, _ := json.Marshal(v)
34 var fields map[string]json.RawMessage
35 _ = json.Unmarshal(b, &fields)
36 fields["future"] = json.RawMessage(`{"preserve":true}`)
37 b, _ = json.Marshal(fields)
38 r, err := a.sessionUIStore().Save(t.Context(), "creation", id, "0", b)
39 if err != nil {
40 t.Fatal(err)
41 }
42 return v, r
43 }
44
45 // A real second process is required: zero-byte file existence is not ownership.
46 func TestManualCreationLockProcess(t *testing.T) {
47 path := os.Getenv("REASONIX_TEST_CREATION_LOCK")
48 if path == "" {
49 return
50 }
51 release, err := identitylock.TryAcquire(path)
52 if err != nil {
53 t.Fatal(err)
54 }
55 defer release()
56 fmt.Println("locked")
57 _, _ = bufio.NewReader(os.Stdin).ReadString('\n')
58 }
59
60 func holdManualCreationInProcess(t *testing.T, a *App, v ManualSessionCreationView) func() {
61 t.Helper()
62 dir := filepath.Join(filepath.Dir(a.sessionUIStore().Path()), "manual-creation-locks")
63 if err := os.MkdirAll(dir, 0700); err != nil {
64 t.Fatal(err)
65 }
66 cmd := exec.Command(os.Args[0], "-test.run=^TestManualCreationLockProcess$")
67 cmd.Env = append(os.Environ(), "REASONIX_TEST_CREATION_LOCK="+filepath.Join(dir, v.Ref.SessionID+".lock"))
68 out, err := cmd.StdoutPipe()
69 if err != nil {
70 t.Fatal(err)
71 }
72 in, err := cmd.StdinPipe()
73 if err != nil {
74 t.Fatal(err)
75 }
76 cmd.Stderr = os.Stderr
77 if err := cmd.Start(); err != nil {
78 t.Fatal(err)
79 }
80 t.Cleanup(func() {
81 _ = in.Close()
82 if cmd.ProcessState == nil {
83 _ = cmd.Process.Kill()
84 _ = cmd.Wait()
85 }
86 })
87 line, err := bufio.NewReader(out).ReadString('\n')
88 if err != nil || strings.TrimSpace(line) != "locked" {
89 t.Fatalf("child lock handshake: %q %v", line, err)
90 }
91 return func() {
92 _ = in.Close()
93 if err := cmd.Wait(); err != nil {
94 t.Fatal(err)
95 }
96 }
97 }
98
99 func TestManualCreationRecoversAfterOtherProcessReleasesLock(t *testing.T) {
100 a := newManualSessionTestApp(t)
101 v, before := seedManualCreation(t, a, "recovery-after-owner-exit", "starting")
102 release := holdManualCreationInProcess(t, a, v)
103 a.markTabsRestored()
104 a.reconcileManualSessionCreations()
105 m := a.creationManager()
106 waitFor(t, "observed process lock", func() bool { p := m.Snapshot(v.OperationID); return p != nil && p.Status == "waiting_lock" })
107 current, err := a.sessionUIStore().Get(t.Context(), "creation", v.OperationID)
108 if err != nil || current.Revision != before.Revision {
109 t.Fatal("waiting mutated the record", err)
110 }
111 // A former owner may have written another starting revision during the wait.
112 _, err = a.sessionUIStore().Save(t.Context(), "creation", v.OperationID, current.Revision, current.Payload)
113 if err != nil {
114 t.Fatal(err)
115 }
116 release()
117 a.manualCreationTasks.Wait()
118 got, err := a.GetManualSessionCreation(v.OperationID)
119 if err != nil || got.Phase != "ready" || got.Ref != v.Ref {
120 t.Fatalf("automatic recovery: %+v %v", got, err)
121 }
122 stored, _ := a.sessionUIStore().Get(t.Context(), "creation", v.OperationID)
123 if !strings.Contains(string(stored.Payload), `"future":{"preserve":true}`) || strings.Contains(string(stored.Payload), `"progress"`) {
124 t.Fatalf("payload compatibility: %s", stored.Payload)
125 }
126 }
127
128 type creationStorageFault struct {
129 manualCreationStore
130 failures atomic.Int32
131 saves atomic.Int32
132 err error
133 }
134
135 type creationBusyError struct{}
136
137 func (creationBusyError) Error() string { return "injected busy" }
138 func (creationBusyError) Code() int { return 5 }
139
140 func (s *creationStorageFault) Save(ctx context.Context, kind, key, expected string, payload json.RawMessage, submissions ...sessionui.Record) (sessionui.Record, error) {
141 var v ManualSessionCreationView
142 _ = json.Unmarshal(payload, &v)
143 if v.Phase == "ready" {
144 s.saves.Add(1)
145 if s.failures.Add(-1) >= 0 {
146 return sessionui.Record{}, s.err
147 }
148 }
149 return s.manualCreationStore.Save(ctx, kind, key, expected, payload, submissions...)
150 }
151
152 func TestManualCreationResultRetryDoesNotRebuild(t *testing.T) {
153 a := newManualSessionTestApp(t)
154 v, _ := seedManualCreation(t, a, "creation-result-retry", "starting")
155 m := a.creationManager()
156 fault := &creationStorageFault{manualCreationStore: m.store, err: creationBusyError{}}
157 fault.failures.Store(2)
158 m.store = fault
159 var builds atomic.Int32
160 m.execute = func(context.Context, ManualSessionCreationView, func(string)) error { builds.Add(1); return nil }
161 m.Ensure(v.OperationID, "recovery", "")
162 waitFor(t, "saving retry", func() bool { p := m.Snapshot(v.OperationID); return p != nil && p.Status == "retrying_storage" })
163 for range 20 {
164 m.Ensure(v.OperationID, "retry", "1")
165 }
166 a.manualCreationTasks.Wait()
167 if builds.Load() != 1 || fault.saves.Load() != 3 {
168 t.Fatalf("builds=%d saves=%d", builds.Load(), fault.saves.Load())
169 }
170 got, _ := a.GetManualSessionCreation(v.OperationID)
171 if got.Phase != "ready" {
172 t.Fatalf("result=%+v", got)
173 }
174 }
175
176 func TestManualCreationShutdownDoesNotReleaseRunningOwner(t *testing.T) {
177 a := newManualSessionTestApp(t)
178 v, _ := seedManualCreation(t, a, "creation-blocked-exit", "starting")
179 m := a.creationManager()
180 entered, release := make(chan struct{}), make(chan struct{})
181 m.execute = func(context.Context, ManualSessionCreationView, func(string)) error {
182 close(entered)
183 <-release
184 return nil
185 }
186 m.Ensure(v.OperationID, "recovery", "")
187 <-entered
188 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
189 defer cancel()
190 if err := m.CancelAndWait(ctx); !errors.Is(err, context.DeadlineExceeded) {
191 t.Fatalf("stop=%v", err)
192 }
193 path := filepath.Join(filepath.Dir(a.sessionUIStore().Path()), "manual-creation-locks", v.Ref.SessionID+".lock")
194 unlock, err := identitylock.TryAcquire(path)
195 if err == nil {
196 unlock()
197 t.Fatal("still-running owner released its lock")
198 }
199 if !errors.Is(err, identitylock.ErrHeld) {
200 t.Fatal(err)
201 }
202 if _, err := a.sessionUIStore().Get(t.Context(), "creation", v.OperationID); err != nil {
203 t.Fatal("store was closed", err)
204 }
205 close(release)
206 if err := m.CancelAndWait(t.Context()); err != nil {
207 t.Fatal(err)
208 }
209 got, _ := a.GetManualSessionCreation(v.OperationID)
210 if got.Phase != "starting" {
211 t.Fatalf("interruption lost recoverability: %+v", got)
212 }
213 }
214
215 func TestManualCreationFutureSchemaBlocksWithoutRetryLoop(t *testing.T) {
216 a := newManualSessionTestApp(t)
217 v, _ := seedManualCreation(t, a, "creation-future-schema", "starting")
218 m := a.creationManager()
219 fault := &creationStorageFault{manualCreationStore: m.store, err: sessionui.ErrFutureVersion}
220 fault.failures.Store(100)
221 m.store = fault
222 m.execute = func(context.Context, ManualSessionCreationView, func(string)) error { return nil }
223 m.Ensure(v.OperationID, "recovery", "")
224 a.manualCreationTasks.Wait()
225 p := m.Snapshot(v.OperationID)
226 if p == nil || p.Status != "blocked" || p.ErrorCode != "unsupported_ui_schema" {
227 t.Fatalf("progress=%+v", p)
228 }
229 m.Ensure(v.OperationID, "recovery", "")
230 if fault.saves.Load() != 1 {
231 t.Fatal("fatal storage error retried automatically")
232 }
233 data, err := json.Marshal(a.manualCreationDiagnosticReport())
234 if err != nil || !strings.Contains(string(data), "unsupported_ui_schema") || strings.Contains(string(data), "settings") {
235 t.Fatalf("diagnostics=%s %v", data, err)
236 }
237 }
238
238 lines GO