| 1 | package sessiontemp |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "runtime" |
| 8 | "sync" |
| 9 | "testing" |
| 10 | "time" |
| 11 | ) |
| 12 | |
| 13 | func TestAcquireSharesGeneration(t *testing.T) { |
| 14 | m := newForTest(t.TempDir()) |
| 15 | m.Retain() |
| 16 | defer m.Release() |
| 17 | |
| 18 | a, err := m.Acquire() |
| 19 | if err != nil { |
| 20 | t.Fatal(err) |
| 21 | } |
| 22 | b, err := m.Acquire() |
| 23 | if err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | if a.Dir() == "" || a.Dir() != b.Dir() { |
| 27 | t.Fatalf("dirs = %q, %q; want same non-empty dir", a.Dir(), b.Dir()) |
| 28 | } |
| 29 | info, err := os.Stat(a.Dir()) |
| 30 | if err != nil || !info.IsDir() { |
| 31 | t.Fatalf("dir stat: %v", err) |
| 32 | } |
| 33 | // Windows does not expose POSIX directory permission bits. The |
| 34 | // cross-platform contract is that the manager creates a private directory; |
| 35 | // the exact 0700 mode is meaningful only on Unix-like systems. |
| 36 | if runtime.GOOS != "windows" { |
| 37 | if perm := info.Mode().Perm(); perm != 0o700 { |
| 38 | t.Fatalf("dir perm = %o, want 0700", perm) |
| 39 | } |
| 40 | } |
| 41 | a.Release() |
| 42 | b.Release() |
| 43 | if _, err := os.Stat(a.Dir()); err != nil { |
| 44 | t.Fatalf("active generation should remain while manager owned: %v", err) |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | func TestRotateIsolatesNewCommands(t *testing.T) { |
| 49 | m := newForTest(t.TempDir()) |
| 50 | m.Retain() |
| 51 | defer m.Release() |
| 52 | |
| 53 | old, err := m.Acquire() |
| 54 | if err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | oldDir := old.Dir() |
| 58 | if err := os.WriteFile(filepath.Join(oldDir, "keep.txt"), []byte("x"), 0o600); err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | |
| 62 | m.Rotate() |
| 63 | fresh, err := m.Acquire() |
| 64 | if err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | if fresh.Dir() == oldDir { |
| 68 | t.Fatal("rotate should create a new directory") |
| 69 | } |
| 70 | if _, err := os.Stat(filepath.Join(fresh.Dir(), "keep.txt")); !os.IsNotExist(err) { |
| 71 | t.Fatalf("new generation must not see old files: %v", err) |
| 72 | } |
| 73 | // Old generation remains while leased. |
| 74 | if _, err := os.Stat(filepath.Join(oldDir, "keep.txt")); err != nil { |
| 75 | t.Fatalf("leased old generation deleted early: %v", err) |
| 76 | } |
| 77 | old.Release() |
| 78 | if _, err := os.Stat(oldDir); !os.IsNotExist(err) { |
| 79 | t.Fatalf("old generation should be removed after last lease: %v", err) |
| 80 | } |
| 81 | fresh.Release() |
| 82 | } |
| 83 | |
| 84 | func TestLastLeaseDeletesRetiredGeneration(t *testing.T) { |
| 85 | m := newForTest(t.TempDir()) |
| 86 | m.Retain() |
| 87 | |
| 88 | lease, err := m.Acquire() |
| 89 | if err != nil { |
| 90 | t.Fatal(err) |
| 91 | } |
| 92 | dir := lease.Dir() |
| 93 | m.Release() // last controller owner retires current generation |
| 94 | if _, err := os.Stat(dir); err != nil { |
| 95 | t.Fatalf("retired generation with live lease must remain: %v", err) |
| 96 | } |
| 97 | lease.Release() |
| 98 | if _, err := os.Stat(dir); !os.IsNotExist(err) { |
| 99 | t.Fatalf("directory should be deleted after last lease: %v", err) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | func TestHotRebuildRetainRelease(t *testing.T) { |
| 104 | m := newForTest(t.TempDir()) |
| 105 | m.Retain() // old controller |
| 106 | lease, err := m.Acquire() |
| 107 | if err != nil { |
| 108 | t.Fatal(err) |
| 109 | } |
| 110 | dir := lease.Dir() |
| 111 | lease.Release() |
| 112 | |
| 113 | m.Retain() // replacement controller |
| 114 | m.Release() // old controller closes — must not delete while new owns |
| 115 | if _, err := os.Stat(dir); err != nil { |
| 116 | t.Fatalf("hot rebuild must keep generation: %v", err) |
| 117 | } |
| 118 | |
| 119 | again, err := m.Acquire() |
| 120 | if err != nil { |
| 121 | t.Fatal(err) |
| 122 | } |
| 123 | if again.Dir() != dir { |
| 124 | t.Fatalf("hot rebuild should reuse generation: got %q want %q", again.Dir(), dir) |
| 125 | } |
| 126 | again.Release() |
| 127 | m.Release() |
| 128 | if _, err := os.Stat(dir); !os.IsNotExist(err) { |
| 129 | t.Fatalf("final release should delete: %v", err) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | func TestAcquireCreateFailureDoesNotFallback(t *testing.T) { |
| 134 | m := newForTest(t.TempDir()) |
| 135 | m.Retain() |
| 136 | defer m.Release() |
| 137 | m.mkDir = func(string) (string, error) { |
| 138 | return "", os.ErrPermission |
| 139 | } |
| 140 | if _, err := m.Acquire(); err == nil { |
| 141 | t.Fatal("want create failure") |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | func TestAcquireAfterLastOwnerReleaseIsSealed(t *testing.T) { |
| 146 | m := newForTest(t.TempDir()) |
| 147 | m.Retain() |
| 148 | lease, err := m.Acquire() |
| 149 | if err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | dir := lease.Dir() |
| 153 | |
| 154 | // Force Release → delayed Acquire ordering with a channel barrier. |
| 155 | released := make(chan struct{}) |
| 156 | acquired := make(chan error, 1) |
| 157 | go func() { |
| 158 | <-released |
| 159 | _, err := m.Acquire() |
| 160 | acquired <- err |
| 161 | }() |
| 162 | |
| 163 | m.Release() // last owner — seals |
| 164 | close(released) |
| 165 | err = <-acquired |
| 166 | if err == nil { |
| 167 | t.Fatal("Acquire after last Release must fail closed") |
| 168 | } |
| 169 | if !errors.Is(err, ErrUnavailable) { |
| 170 | t.Fatalf("error = %v, want ErrUnavailable", err) |
| 171 | } |
| 172 | if !m.Sealed() { |
| 173 | t.Fatal("manager should be sealed") |
| 174 | } |
| 175 | |
| 176 | // Live lease still pins the directory until it releases. |
| 177 | if _, err := os.Stat(dir); err != nil { |
| 178 | t.Fatalf("leased generation deleted while sealed: %v", err) |
| 179 | } |
| 180 | lease.Release() |
| 181 | if _, err := os.Stat(dir); !os.IsNotExist(err) { |
| 182 | t.Fatalf("generation should delete after last lease on sealed manager: %v", err) |
| 183 | } |
| 184 | |
| 185 | // Retain after seal must not reopen. |
| 186 | m.Retain() |
| 187 | if _, err := m.Acquire(); err == nil { |
| 188 | t.Fatal("Retain after seal must not reopen Acquire") |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | func TestAcquireWithoutOwnerFailsClosed(t *testing.T) { |
| 193 | m := newForTest(t.TempDir()) |
| 194 | if _, err := m.Acquire(); err == nil { |
| 195 | t.Fatal("Acquire with zero owners must fail") |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | func TestProcessCleanupRunsOncePerRoot(t *testing.T) { |
| 200 | resetProcessCleanupForTest() |
| 201 | root := t.TempDir() |
| 202 | // Plant a stale dir that would be eligible if cleanup ran with an old now. |
| 203 | // We only count whether cleanupStaleOnce marks the root done. |
| 204 | cleanupStaleOnce(root, time.Now) |
| 205 | cleanupStaleOnce(root, time.Now) |
| 206 | processCleanup.Lock() |
| 207 | key := canonicalTempRoot(root) |
| 208 | _, ok := processCleanup.done[key] |
| 209 | n := len(processCleanup.done) |
| 210 | processCleanup.Unlock() |
| 211 | if !ok { |
| 212 | t.Fatal("root not marked cleaned") |
| 213 | } |
| 214 | if n != 1 { |
| 215 | t.Fatalf("cleanup map size = %d, want 1 entry for one root", n) |
| 216 | } |
| 217 | // A second New against the real TempDir should not panic; first process |
| 218 | // New still uses process-level once. |
| 219 | _ = New() |
| 220 | _ = New() |
| 221 | } |
| 222 | |
| 223 | func TestConcurrentAcquireRotateReleaseRace(t *testing.T) { |
| 224 | m := newForTest(t.TempDir()) |
| 225 | m.Retain() |
| 226 | defer m.Release() |
| 227 | |
| 228 | var wg sync.WaitGroup |
| 229 | for i := 0; i < 32; i++ { |
| 230 | wg.Add(1) |
| 231 | go func() { |
| 232 | defer wg.Done() |
| 233 | for j := 0; j < 50; j++ { |
| 234 | lease, err := m.Acquire() |
| 235 | if err != nil { |
| 236 | t.Errorf("acquire: %v", err) |
| 237 | return |
| 238 | } |
| 239 | _ = os.WriteFile(filepath.Join(lease.Dir(), "x"), []byte("1"), 0o600) |
| 240 | if j%7 == 0 { |
| 241 | m.Rotate() |
| 242 | } |
| 243 | lease.Release() |
| 244 | } |
| 245 | }() |
| 246 | } |
| 247 | wg.Wait() |
| 248 | } |
| 249 | |
| 250 | func TestStaleCleanup(t *testing.T) { |
| 251 | root := t.TempDir() |
| 252 | now := time.Now() |
| 253 | |
| 254 | // Fresh dir — skip. |
| 255 | fresh, err := os.MkdirTemp(root, dirPrefix) |
| 256 | if err != nil { |
| 257 | t.Fatal(err) |
| 258 | } |
| 259 | |
| 260 | // Active locked dir older than 24h — skip. |
| 261 | active, err := os.MkdirTemp(root, dirPrefix) |
| 262 | if err != nil { |
| 263 | t.Fatal(err) |
| 264 | } |
| 265 | release, err := filelockAcquire(filepath.Join(active, ownerLockName)) |
| 266 | if err != nil { |
| 267 | t.Fatal(err) |
| 268 | } |
| 269 | defer release() |
| 270 | // Chtimes after lock creation: writing the lock file refreshes dir mtime. |
| 271 | if err := os.Chtimes(active, now.Add(-48*time.Hour), now.Add(-48*time.Hour)); err != nil { |
| 272 | t.Fatal(err) |
| 273 | } |
| 274 | |
| 275 | // Stale unlocked dir — delete. |
| 276 | stale, err := os.MkdirTemp(root, dirPrefix) |
| 277 | if err != nil { |
| 278 | t.Fatal(err) |
| 279 | } |
| 280 | // Create and release lock file so TryAcquire can succeed, then age the dir. |
| 281 | lockPath := filepath.Join(stale, ownerLockName) |
| 282 | r, err := filelockAcquire(lockPath) |
| 283 | if err != nil { |
| 284 | t.Fatal(err) |
| 285 | } |
| 286 | r() |
| 287 | if err := os.Chtimes(stale, now.Add(-48*time.Hour), now.Add(-48*time.Hour)); err != nil { |
| 288 | t.Fatal(err) |
| 289 | } |
| 290 | |
| 291 | // Unrelated directory — skip. |
| 292 | other := filepath.Join(root, "not-reasonix") |
| 293 | if err := os.Mkdir(other, 0o700); err != nil { |
| 294 | t.Fatal(err) |
| 295 | } |
| 296 | if err := os.Chtimes(other, now.Add(-48*time.Hour), now.Add(-48*time.Hour)); err != nil { |
| 297 | t.Fatal(err) |
| 298 | } |
| 299 | |
| 300 | // Symlink to a foreign target — remove only the symlink entry, not the target. |
| 301 | foreign := filepath.Join(t.TempDir(), "foreign-target") |
| 302 | if err := os.Mkdir(foreign, 0o700); err != nil { |
| 303 | t.Fatal(err) |
| 304 | } |
| 305 | marker := filepath.Join(foreign, "marker") |
| 306 | if err := os.WriteFile(marker, []byte("safe"), 0o600); err != nil { |
| 307 | t.Fatal(err) |
| 308 | } |
| 309 | link := filepath.Join(root, dirPrefix+"link") |
| 310 | if err := os.Symlink(foreign, link); err != nil { |
| 311 | t.Fatal(err) |
| 312 | } |
| 313 | if err := os.Chtimes(link, now.Add(-48*time.Hour), now.Add(-48*time.Hour)); err != nil { |
| 314 | // Some platforms cannot chtimes symlinks; fall back to cleaning with a |
| 315 | // forced old now so age check uses Lstat mtime of the link when set. |
| 316 | _ = err |
| 317 | } |
| 318 | |
| 319 | cleanupStale(root, func() time.Time { return now }) |
| 320 | |
| 321 | if _, err := os.Stat(fresh); err != nil { |
| 322 | t.Fatalf("fresh dir removed: %v", err) |
| 323 | } |
| 324 | if _, err := os.Stat(active); err != nil { |
| 325 | t.Fatalf("active locked dir removed: %v", err) |
| 326 | } |
| 327 | if _, err := os.Stat(stale); !os.IsNotExist(err) { |
| 328 | t.Fatalf("stale dir should be removed: %v", err) |
| 329 | } |
| 330 | if _, err := os.Stat(other); err != nil { |
| 331 | t.Fatalf("unrelated dir removed: %v", err) |
| 332 | } |
| 333 | if _, err := os.Stat(marker); err != nil { |
| 334 | t.Fatalf("symlink cleanup deleted foreign target: %v", err) |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | func filelockAcquire(path string) (func(), error) { |
| 339 | // Local import shim for tests in this package. |
| 340 | return tryLockForTest(path) |
| 341 | } |
| 342 |