| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "os/exec" |
| 9 | "path/filepath" |
| 10 | "runtime" |
| 11 | "strings" |
| 12 | "sync" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "reasonix/internal/filelock" |
| 17 | "reasonix/internal/fileutil" |
| 18 | ) |
| 19 | |
| 20 | func TestSessionSidecarSavesUseDurableAtomicWrite(t *testing.T) { |
| 21 | tests := []struct { |
| 22 | name string |
| 23 | path func(string) string |
| 24 | save func(string) error |
| 25 | }{ |
| 26 | { |
| 27 | name: "titles", |
| 28 | path: sessionTitlesPath, |
| 29 | save: func(dir string) error { |
| 30 | return saveSessionTitles(dir, map[string]string{"session.jsonl": "Durable title"}) |
| 31 | }, |
| 32 | }, |
| 33 | { |
| 34 | name: "displays", |
| 35 | path: sessionDisplayPath, |
| 36 | save: func(dir string) error { |
| 37 | return saveSessionDisplays(dir, sessionDisplayMap{ |
| 38 | "session.jsonl": {messageDisplayKey("expanded prompt"): "visible prompt"}, |
| 39 | }) |
| 40 | }, |
| 41 | }, |
| 42 | { |
| 43 | name: "planner displays", |
| 44 | path: sessionPlannerDisplayPath, |
| 45 | save: func(dir string) error { |
| 46 | return saveSessionPlannerDisplays(dir, sessionPlannerDisplayMap{ |
| 47 | "session.jsonl": {{UserHash: "prompt-digest", Messages: []HistoryMessage{}}}, |
| 48 | }) |
| 49 | }, |
| 50 | }, |
| 51 | } |
| 52 | |
| 53 | for _, tt := range tests { |
| 54 | t.Run(tt.name, func(t *testing.T) { |
| 55 | dir := t.TempDir() |
| 56 | path := tt.path(dir) |
| 57 | previousCrashPoint := fileutil.CrashPoint |
| 58 | t.Cleanup(func() { fileutil.CrashPoint = previousCrashPoint }) |
| 59 | usedDurableWrite := false |
| 60 | fileutil.CrashPoint = func(op, gotPath string) { |
| 61 | if op == "atomic-write" && gotPath == path { |
| 62 | usedDurableWrite = true |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | if err := tt.save(dir); err != nil { |
| 67 | t.Fatalf("save %s sidecar: %v", tt.name, err) |
| 68 | } |
| 69 | if !usedDurableWrite { |
| 70 | t.Fatalf("%s sidecar bypassed fileutil.AtomicWriteFile", tt.name) |
| 71 | } |
| 72 | if runtime.GOOS != "windows" { |
| 73 | info, err := os.Stat(path) |
| 74 | if err != nil { |
| 75 | t.Fatalf("stat %s sidecar: %v", tt.name, err) |
| 76 | } |
| 77 | if got := info.Mode().Perm(); got != 0o600 { |
| 78 | t.Fatalf("%s sidecar mode = %o, want 600", tt.name, got) |
| 79 | } |
| 80 | } |
| 81 | }) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | func TestSetSessionTitleHonorsSidecarLock(t *testing.T) { |
| 86 | dir := t.TempDir() |
| 87 | release, err := filelock.Acquire(context.Background(), sessionTitlesPath(dir)+".lock") |
| 88 | if err != nil { |
| 89 | t.Fatalf("acquire title sidecar lock: %v", err) |
| 90 | } |
| 91 | previousTimeout := sessionTitlesQueueTimeout |
| 92 | sessionTitlesQueueTimeout = 40 * time.Millisecond |
| 93 | t.Cleanup(func() { sessionTitlesQueueTimeout = previousTimeout }) |
| 94 | |
| 95 | err = setSessionTitle(dir, filepath.Join(dir, "locked.jsonl"), "Locked") |
| 96 | if err == nil { |
| 97 | release() |
| 98 | t.Fatal("setSessionTitle succeeded while the title sidecar lock was held") |
| 99 | } |
| 100 | release() |
| 101 | if err := setSessionTitle(dir, filepath.Join(dir, "locked.jsonl"), "Saved"); err != nil { |
| 102 | t.Fatalf("setSessionTitle after release: %v", err) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | func TestRecordSessionDisplayExternalLockUsesShortBudget(t *testing.T) { |
| 107 | if os.Getenv("REASONIX_DISPLAY_LOCK_HELPER") != "" { |
| 108 | dir := os.Getenv("REASONIX_DISPLAY_LOCK_DIR") |
| 109 | release, err := filelock.Acquire(context.Background(), sessionDisplayPath(dir)+".lock") |
| 110 | if err != nil { |
| 111 | t.Fatalf("acquire external display lock: %v", err) |
| 112 | } |
| 113 | defer release() |
| 114 | if err := os.WriteFile(filepath.Join(dir, "display-lock.ready"), []byte("ready"), 0o600); err != nil { |
| 115 | t.Fatal(err) |
| 116 | } |
| 117 | if !waitForPlannerDisplayTestFile(filepath.Join(dir, "display-lock.release"), 10*time.Second) { |
| 118 | t.Fatal("timed out waiting to release external display lock") |
| 119 | } |
| 120 | return |
| 121 | } |
| 122 | |
| 123 | dir := t.TempDir() |
| 124 | var output strings.Builder |
| 125 | cmd := exec.Command(os.Args[0], "-test.run=^TestRecordSessionDisplayExternalLockUsesShortBudget$") |
| 126 | cmd.Env = append(os.Environ(), |
| 127 | "REASONIX_DISPLAY_LOCK_HELPER=1", |
| 128 | "REASONIX_DISPLAY_LOCK_DIR="+dir, |
| 129 | ) |
| 130 | cmd.Stdout = &output |
| 131 | cmd.Stderr = &output |
| 132 | if err := cmd.Start(); err != nil { |
| 133 | t.Fatalf("start external display-lock helper: %v", err) |
| 134 | } |
| 135 | releasePath := filepath.Join(dir, "display-lock.release") |
| 136 | releaseHelper := func() { |
| 137 | _ = os.WriteFile(releasePath, []byte("release"), 0o600) |
| 138 | } |
| 139 | if !waitForPlannerDisplayTestFile(filepath.Join(dir, "display-lock.ready"), 5*time.Second) { |
| 140 | releaseHelper() |
| 141 | _ = cmd.Wait() |
| 142 | t.Fatalf("external display-lock helper did not start: %s", output.String()) |
| 143 | } |
| 144 | |
| 145 | previousTimeout := sessionDisplayExternalLockTimeout |
| 146 | sessionDisplayExternalLockTimeout = 100 * time.Millisecond |
| 147 | t.Cleanup(func() { sessionDisplayExternalLockTimeout = previousTimeout }) |
| 148 | started := time.Now() |
| 149 | err := recordSessionDisplay(dir, filepath.Join(dir, "session.jsonl"), "expanded prompt", "visible prompt") |
| 150 | elapsed := time.Since(started) |
| 151 | releaseHelper() |
| 152 | if waitErr := cmd.Wait(); waitErr != nil { |
| 153 | t.Fatalf("external display-lock helper failed: %v\n%s", waitErr, output.String()) |
| 154 | } |
| 155 | if !errors.Is(err, context.DeadlineExceeded) { |
| 156 | t.Fatalf("record display error = %v, want external lock deadline exceeded", err) |
| 157 | } |
| 158 | if elapsed >= 2*time.Second { |
| 159 | t.Fatalf("record display waited %v, want the short external lock budget", elapsed) |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | func TestSetSessionTitleSerializesConcurrentUpdates(t *testing.T) { |
| 164 | dir := t.TempDir() |
| 165 | const sessions = 32 |
| 166 | errs := make(chan error, sessions) |
| 167 | var wg sync.WaitGroup |
| 168 | for i := range sessions { |
| 169 | wg.Add(1) |
| 170 | go func(i int) { |
| 171 | defer wg.Done() |
| 172 | path := filepath.Join(dir, fmt.Sprintf("session-%02d.jsonl", i)) |
| 173 | errs <- setSessionTitle(dir, path, fmt.Sprintf("Title %02d", i)) |
| 174 | }(i) |
| 175 | } |
| 176 | wg.Wait() |
| 177 | close(errs) |
| 178 | for err := range errs { |
| 179 | if err != nil { |
| 180 | t.Fatalf("setSessionTitle: %v", err) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | titles := loadSessionTitles(dir) |
| 185 | if len(titles) != sessions { |
| 186 | t.Fatalf("title count = %d, want %d: %#v", len(titles), sessions, titles) |
| 187 | } |
| 188 | for i := range sessions { |
| 189 | key := fmt.Sprintf("session-%02d.jsonl", i) |
| 190 | if got := titles[key]; got != fmt.Sprintf("Title %02d", i) { |
| 191 | t.Fatalf("%s title = %q, want retained concurrent value", key, got) |
| 192 | } |
| 193 | } |
| 194 | } |
| 195 |