返回 DeepSeek-Reasonix
management_try_export_test.go
根目录 / internal / session / management_try_export_test.go
1 package session
2
3 import (
4 "bufio"
5 "bytes"
6 "context"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "strings"
15 "sync"
16 "testing"
17 "time"
18
19 "reasonix/internal/identitylock"
20 "reasonix/internal/provider"
21 )
22
23 func TestTryExportColdRetainsSourceAndDoesNotWaitForOwner(t *testing.T) {
24 for _, owner := range []string{"same-process", "other-process"} {
25 for _, lock := range []string{"writer", "ownership"} {
26 t.Run(owner+"/"+lock, func(t *testing.T) {
27 root := filepath.Join(t.TempDir(), "source")
28 service, err := NewService("export-source", NewFilesystemPersistence(root))
29 if err != nil {
30 t.Fatal(err)
31 }
32 t.Cleanup(func() { _ = service.Shutdown(context.Background()) })
33 runtime, err := service.Create(t.Context(), CreateOptions{SessionID: "history"})
34 if err != nil {
35 t.Fatal(err)
36 }
37 content := strings.Repeat("retained historical content ", 20_000)
38 payload, err := json.Marshal(map[string]any{"message": provider.Message{ID: "message", Role: provider.RoleUser, Content: content}})
39 if err != nil {
40 t.Fatal(err)
41 }
42 if _, err := runtime.Session().AppendBatch(t.Context(), "message", []Event{{Kind: "message/complete", Payload: payload}}); err != nil {
43 t.Fatal(err)
44 }
45 ref := runtime.Ref()
46 if err := service.Close(t.Context(), ref); err != nil {
47 t.Fatal(err)
48 }
49 source := filepath.Join(root, ref.SessionID)
50 original := map[string][]byte{}
51 for _, name := range []string{"manifest.json", "events.frames"} {
52 original[name], err = os.ReadFile(filepath.Join(source, name))
53 if err != nil {
54 t.Fatal(err)
55 }
56 }
57 lockPath := filepath.Join(source, "writer.lock")
58 if lock == "ownership" {
59 lockPath = directoryOwnershipPath(source)
60 }
61 var release func()
62 if owner == "other-process" {
63 release = holdTryExportLockInChild(t, lockPath)
64 } else {
65 release, err = identitylock.Acquire(t.Context(), lockPath)
66 if err != nil {
67 t.Fatal(err)
68 }
69 t.Cleanup(release)
70 }
71 destination := filepath.Join(t.TempDir(), "bundle")
72 ctx, cancel := context.WithTimeout(t.Context(), time.Second)
73 err = service.TryExportCold(ctx, ref, destination)
74 cancel()
75 if !errors.Is(err, identitylock.ErrHeld) {
76 t.Fatalf("busy source must return ErrHeld, not wait for deadline: %v", err)
77 }
78 if _, err := os.Stat(destination); !errors.Is(err, os.ErrNotExist) {
79 t.Fatalf("busy source published an export: %v", err)
80 }
81 release()
82 if err := service.TryExportCold(t.Context(), ref, destination); err != nil {
83 t.Fatalf("export did not resume after source release: %v", err)
84 }
85 for name, before := range original {
86 after, err := os.ReadFile(filepath.Join(source, name))
87 if err != nil || !bytes.Equal(before, after) {
88 t.Fatalf("source %s changed: %v", name, err)
89 }
90 }
91 if _, err := Replay(destination, nil); err != nil {
92 t.Fatalf("exported bundle is not readable: %v", err)
93 }
94 if err := os.RemoveAll(root); err != nil {
95 t.Fatal(err)
96 }
97 if _, err := Replay(destination, nil); err != nil {
98 t.Fatalf("export depends on original source: %v", err)
99 }
100 target, err := NewService("export-target", NewFilesystemPersistence(filepath.Join(t.TempDir(), "imported")))
101 if err != nil {
102 t.Fatal(err)
103 }
104 t.Cleanup(func() { _ = target.Shutdown(context.Background()) })
105 imported, err := target.Import(t.Context(), destination)
106 if err != nil {
107 t.Fatal(err)
108 }
109 page := historyPageReady(t, target.Query(), imported, "", 10)
110 if len(page.Messages) != 1 {
111 t.Fatal("export did not preserve the historical message")
112 }
113 stored := page.Messages[0]
114 body := []byte(stored.Inline)
115 if stored.ContentRef != nil {
116 body, err = target.Query().ReadContent(t.Context(), imported, *stored.ContentRef, 0, stored.ContentRef.Bytes)
117 if err != nil {
118 t.Fatal(err)
119 }
120 }
121 var message provider.Message
122 if err := json.Unmarshal(body, &message); err != nil {
123 t.Fatal(err)
124 }
125 if message.Content != content {
126 t.Fatal("export did not preserve the complete historical message")
127 }
128 })
129 }
130 }
131 }
132
133 // The child holds an actual operating-system lock, exercising LockFileEx on
134 // Windows rather than only identitylock's process-local coordination.
135 func TestTryExportColdLockHelper(t *testing.T) {
136 path := os.Getenv("REASONIX_TEST_TRY_EXPORT_LOCK")
137 if path == "" {
138 return
139 }
140 release, err := identitylock.Acquire(t.Context(), path)
141 if err != nil {
142 t.Fatal(err)
143 }
144 defer release()
145 fmt.Fprintln(os.Stdout, "LOCKED")
146 _, _ = io.Copy(io.Discard, os.Stdin)
147 }
148
149 func holdTryExportLockInChild(t *testing.T, path string) func() {
150 t.Helper()
151 ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
152 t.Cleanup(cancel)
153 cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestTryExportColdLockHelper$")
154 cmd.Env = append(os.Environ(), "REASONIX_TEST_TRY_EXPORT_LOCK="+path)
155 var stderr bytes.Buffer
156 cmd.Stderr = &stderr
157 stdin, err := cmd.StdinPipe()
158 if err != nil {
159 t.Fatal(err)
160 }
161 stdout, err := cmd.StdoutPipe()
162 if err != nil {
163 t.Fatal(err)
164 }
165 if err := cmd.Start(); err != nil {
166 t.Fatal(err)
167 }
168 var once sync.Once
169 var waitErr error
170 release := func() {
171 once.Do(func() {
172 _ = stdin.Close()
173 waitErr = cmd.Wait()
174 })
175 if waitErr != nil {
176 t.Errorf("source lock helper failed: %v: %s", waitErr, stderr.String())
177 }
178 }
179 t.Cleanup(release)
180 scanner := bufio.NewScanner(stdout)
181 if !scanner.Scan() || scanner.Text() != "LOCKED" {
182 t.Fatalf("source lock helper did not become ready: %v", scanner.Err())
183 }
184 return release
185 }
186
186 lines GO