Merge pull request #120 from bnema/feat/incremental-session-snapshots
feat(snapshot): add incremental session persistence
105 files · +13110 -4220
M go.mod +4 -1
| @@ -2,7 +2,10 @@ module github.com/bnema/vev | |||
| 2 | 2 | ||
| 3 | 3 | go 1.26 | |
| 4 | 4 | ||
| 5 | - | require github.com/stretchr/testify v1.11.1 | |
| 5 | + | require ( | |
| 6 | + | github.com/stretchr/testify v1.11.1 | |
| 7 | + | golang.org/x/sys v0.47.0 | |
| 8 | + | ) | |
| 6 | 9 | ||
| 7 | 10 | require ( | |
| 8 | 11 | github.com/davecgh/go-spew v1.1.1 // indirect | |
M go.sum +2 -0 generated file: collapsed
A internal/adapters/snapshot/atomic.go +416 -0
| @@ -0,0 +1,416 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "syscall" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/pkg/safedir" | |
| 12 | + | "golang.org/x/sys/unix" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func (r *Repository) ensureSession(key string) error { | |
| 16 | + | for _, directory := range []struct { | |
| 17 | + | path string | |
| 18 | + | phase string | |
| 19 | + | }{ | |
| 20 | + | {r.dir, "repository"}, | |
| 21 | + | {filepath.Join(r.dir, repositorySessionsDir), "sessions"}, | |
| 22 | + | {r.sessionPath(key), "session"}, | |
| 23 | + | {filepath.Join(r.sessionPath(key), repositoryObjectsDir), "objects"}, | |
| 24 | + | {filepath.Join(r.sessionPath(key), repositoryGenerations), "generations"}, | |
| 25 | + | } { | |
| 26 | + | if err := r.ensurePrivateDirectoryPhase(directory.path, directory.phase); err != nil { | |
| 27 | + | return fmt.Errorf("create snapshot repository directory: %w", err) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | return nil | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func (r *Repository) ensurePrivateDirectory(dir string) error { | |
| 34 | + | return r.ensurePrivateDirectoryPhase(dir, "snapshot directory") | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) error { | |
| 38 | + | // The configured root is the trust boundary. Once it is private, every | |
| 39 | + | // descendant is created through a pinned parent descriptor rather than via | |
| 40 | + | // a path that an attacker can replace between checks. | |
| 41 | + | if filepath.Clean(dir) == filepath.Clean(r.dir) { | |
| 42 | + | _, statErr := os.Lstat(dir) | |
| 43 | + | created := errors.Is(statErr, os.ErrNotExist) | |
| 44 | + | if statErr != nil && !created { | |
| 45 | + | return statErr | |
| 46 | + | } | |
| 47 | + | if err := safedir.EnsurePrivate(dir); err != nil { | |
| 48 | + | return err | |
| 49 | + | } | |
| 50 | + | if created { | |
| 51 | + | if hook := r.hooks.syncDirectory; hook != nil { | |
| 52 | + | if err := hook(filepath.Dir(dir)); err != nil { | |
| 53 | + | return fmt.Errorf("%s parent directory sync: %w", phase, err) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | if err := syncDirectory(filepath.Dir(dir)); err != nil { | |
| 57 | + | return fmt.Errorf("%s parent directory sync: %w", phase, err) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | return nil | |
| 61 | + | } | |
| 62 | + | if err := r.ensurePrivateDirectoryPhase(filepath.Dir(dir), "snapshot directory"); err != nil { | |
| 63 | + | return err | |
| 64 | + | } | |
| 65 | + | parent, name, err := r.repositoryParent(dir) | |
| 66 | + | if err != nil { | |
| 67 | + | return err | |
| 68 | + | } | |
| 69 | + | created := false | |
| 70 | + | mkdirErr := unix.Mkdirat(parent, name, 0o700) | |
| 71 | + | if mkdirErr == nil { | |
| 72 | + | created = true | |
| 73 | + | } | |
| 74 | + | parentCloseErr := r.closeDescriptor(parent, "close snapshot parent directory") | |
| 75 | + | if mkdirErr != nil && !errors.Is(mkdirErr, syscall.EEXIST) { | |
| 76 | + | err = mkdirErr | |
| 77 | + | joinCloseError(&err, "close snapshot parent directory", parentCloseErr) | |
| 78 | + | return err | |
| 79 | + | } | |
| 80 | + | if parentCloseErr != nil { | |
| 81 | + | return fmt.Errorf("close snapshot parent directory: %w", parentCloseErr) | |
| 82 | + | } | |
| 83 | + | opened, err := r.openDirectory(dir) | |
| 84 | + | if err != nil { | |
| 85 | + | return err | |
| 86 | + | } | |
| 87 | + | var st syscall.Stat_t | |
| 88 | + | statErr := syscall.Fstat(int(opened.Fd()), &st) | |
| 89 | + | closeErr := opened.Close() | |
| 90 | + | if statErr != nil { | |
| 91 | + | return statErr | |
| 92 | + | } | |
| 93 | + | if closeErr != nil { | |
| 94 | + | return closeErr | |
| 95 | + | } | |
| 96 | + | if int(st.Uid) != os.Geteuid() || st.Mode&0o777 != 0o700 { | |
| 97 | + | return fmt.Errorf("snapshot directory is not private") | |
| 98 | + | } | |
| 99 | + | if created { | |
| 100 | + | if err := r.syncDirectory(filepath.Dir(dir)); err != nil { | |
| 101 | + | return fmt.Errorf("%s parent directory sync: %w", phase, err) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | return nil | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // writeImmutable publishes data with link(2), whose EEXIST behavior prevents a | |
| 108 | + | // raced target from being overwritten. A competing target is accepted only | |
| 109 | + | // after verifier reads it through the same secure descriptor path as Load. | |
| 110 | + | func (r *Repository) writeImmutable(path string, data []byte, verifier func([]byte) error) error { | |
| 111 | + | dir := filepath.Dir(path) | |
| 112 | + | phase := "object shard" | |
| 113 | + | if filepath.Base(dir) == repositoryGenerations { | |
| 114 | + | phase = "generation" | |
| 115 | + | } | |
| 116 | + | if err := r.ensurePrivateDirectoryPhase(dir, phase); err != nil { | |
| 117 | + | return err | |
| 118 | + | } | |
| 119 | + | return r.withAtomicTemp(dir, data, func(tempPath string) (bool, error) { | |
| 120 | + | if err := r.installImmutable(tempPath, path); err != nil { | |
| 121 | + | if !errors.Is(err, os.ErrExist) { | |
| 122 | + | return true, err | |
| 123 | + | } | |
| 124 | + | existing, readErr := r.readBounded(path) | |
| 125 | + | if readErr != nil { | |
| 126 | + | return true, readErr | |
| 127 | + | } | |
| 128 | + | return true, verifier(existing) | |
| 129 | + | } | |
| 130 | + | // link retains the temporary name, so cleanup must remove and sync it. | |
| 131 | + | return true, nil | |
| 132 | + | }) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // writeMutable is used only for HEAD, the sole authoritative pointer allowed | |
| 136 | + | // to advance. The old or fully synced new HEAD is therefore always recoverable. | |
| 137 | + | func (r *Repository) writeMutable(path string, data []byte) error { | |
| 138 | + | dir := filepath.Dir(path) | |
| 139 | + | if err := r.ensurePrivateDirectory(dir); err != nil { | |
| 140 | + | return err | |
| 141 | + | } | |
| 142 | + | return r.withAtomicTemp(dir, data, func(tempPath string) (bool, error) { | |
| 143 | + | if err := r.rename(tempPath, path); err != nil { | |
| 144 | + | return true, err | |
| 145 | + | } | |
| 146 | + | // rename consumed tempPath. Its directory sync persists both rename and | |
| 147 | + | // the absence of the temporary entry. | |
| 148 | + | if err := r.syncDirectory(dir); err != nil { | |
| 149 | + | return true, err | |
| 150 | + | } | |
| 151 | + | return false, nil | |
| 152 | + | }) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // withAtomicTemp prepares a private, durable temporary file, invokes publish, | |
| 156 | + | // then closes and removes the temporary entry when publish reports that its | |
| 157 | + | // operation did not consume it. Cleanup errors are joined after the operation | |
| 158 | + | // error in the same order as the former immutable and mutable write paths. | |
| 159 | + | func (r *Repository) withAtomicTemp(dir string, data []byte, publish func(string) (removeTemp bool, err error)) error { | |
| 160 | + | temp, err := r.createTemp(dir) | |
| 161 | + | if err != nil { | |
| 162 | + | return err | |
| 163 | + | } | |
| 164 | + | tempPath := temp.Name() | |
| 165 | + | closed := false | |
| 166 | + | cleanup := func(cause error) error { | |
| 167 | + | if !closed { | |
| 168 | + | if err := r.closeFile(temp); err != nil { | |
| 169 | + | cause = errors.Join(cause, fmt.Errorf("close snapshot temporary file: %w", err)) | |
| 170 | + | } | |
| 171 | + | closed = true | |
| 172 | + | } | |
| 173 | + | if err := r.remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 174 | + | cause = errors.Join(cause, err) | |
| 175 | + | } else if err == nil { | |
| 176 | + | cause = errors.Join(cause, r.syncDirectory(dir)) | |
| 177 | + | } | |
| 178 | + | return cause | |
| 179 | + | } | |
| 180 | + | if err := temp.Chmod(0o600); err != nil { | |
| 181 | + | return cleanup(err) | |
| 182 | + | } | |
| 183 | + | if err := r.writeFile(temp, data); err != nil { | |
| 184 | + | return cleanup(err) | |
| 185 | + | } | |
| 186 | + | if err := r.syncFile(temp); err != nil { | |
| 187 | + | return cleanup(err) | |
| 188 | + | } | |
| 189 | + | if err := r.closeFile(temp); err != nil { | |
| 190 | + | closed = true | |
| 191 | + | return cleanup(fmt.Errorf("close snapshot temporary file: %w", err)) | |
| 192 | + | } | |
| 193 | + | closed = true | |
| 194 | + | removeTemp, err := publish(tempPath) | |
| 195 | + | if !removeTemp { | |
| 196 | + | return err | |
| 197 | + | } | |
| 198 | + | return cleanup(err) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func (r *Repository) createTemp(dir string) (*os.File, error) { | |
| 202 | + | if r.hooks.createTemp != nil { | |
| 203 | + | if err := r.hooks.createTemp(dir); err != nil { | |
| 204 | + | return nil, err | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | return r.createTempAt(dir) | |
| 208 | + | } | |
| 209 | + | func (r *Repository) writeFile(f *os.File, data []byte) error { | |
| 210 | + | if r.hooks.writeTemp != nil { | |
| 211 | + | if err := r.hooks.writeTemp(f.Name()); err != nil { | |
| 212 | + | return err | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | _, err := f.Write(data) | |
| 216 | + | return err | |
| 217 | + | } | |
| 218 | + | func (r *Repository) syncFile(f *os.File) error { | |
| 219 | + | if r.hooks.syncFile != nil { | |
| 220 | + | if err := r.hooks.syncFile(f.Name()); err != nil { | |
| 221 | + | return err | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | return f.Sync() | |
| 225 | + | } | |
| 226 | + | func (r *Repository) closeFile(f *os.File) error { | |
| 227 | + | var injected error | |
| 228 | + | if r.hooks.closeFile != nil { | |
| 229 | + | injected = r.hooks.closeFile(f.Name()) | |
| 230 | + | } | |
| 231 | + | return errors.Join(injected, f.Close()) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // closeDescriptor closes a raw descriptor while keeping its close boundary | |
| 235 | + | // fault-injectable for tests. Callers add operation context before returning. | |
| 236 | + | func (r *Repository) closeDescriptor(fd int, operation string) error { | |
| 237 | + | var injected error | |
| 238 | + | if r.hooks.closeDescriptor != nil { | |
| 239 | + | injected = r.hooks.closeDescriptor(operation) | |
| 240 | + | } | |
| 241 | + | return errors.Join(injected, syscall.Close(fd)) | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func (r *Repository) closeRepositoryFile(f *os.File, operation string) error { | |
| 245 | + | var injected error | |
| 246 | + | if r.hooks.closeDescriptor != nil { | |
| 247 | + | injected = r.hooks.closeDescriptor(operation) | |
| 248 | + | } | |
| 249 | + | return errors.Join(injected, f.Close()) | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // joinCloseError retains a primary operation failure and appends contextual | |
| 253 | + | // close failures, including cleanup-only paths, without suppressing either. | |
| 254 | + | func joinCloseError(primary *error, operation string, closeErr error) { | |
| 255 | + | if closeErr != nil { | |
| 256 | + | *primary = errors.Join(*primary, fmt.Errorf("%s: %w", operation, closeErr)) | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func (r *Repository) installImmutable(oldPath, newPath string) (err error) { | |
| 261 | + | if r.hooks.installImmutable != nil { | |
| 262 | + | if err := r.hooks.installImmutable(newPath); err != nil { | |
| 263 | + | return err | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | oldFD, oldName, err := r.repositoryParent(oldPath) | |
| 267 | + | if err != nil { | |
| 268 | + | return err | |
| 269 | + | } | |
| 270 | + | defer func() { | |
| 271 | + | joinCloseError(&err, "close source snapshot parent directory", r.closeDescriptor(oldFD, "close source snapshot parent directory")) | |
| 272 | + | }() | |
| 273 | + | newFD, newName, err := r.repositoryParent(newPath) | |
| 274 | + | if err != nil { | |
| 275 | + | return err | |
| 276 | + | } | |
| 277 | + | defer func() { | |
| 278 | + | joinCloseError(&err, "close destination snapshot parent directory", r.closeDescriptor(newFD, "close destination snapshot parent directory")) | |
| 279 | + | }() | |
| 280 | + | return unix.Linkat(oldFD, oldName, newFD, newName, 0) | |
| 281 | + | } | |
| 282 | + | func (r *Repository) rename(oldPath, newPath string) (err error) { | |
| 283 | + | if r.hooks.rename != nil { | |
| 284 | + | if err := r.hooks.rename(newPath); err != nil { | |
| 285 | + | return err | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | oldFD, oldName, err := r.repositoryParent(oldPath) | |
| 289 | + | if err != nil { | |
| 290 | + | return err | |
| 291 | + | } | |
| 292 | + | defer func() { | |
| 293 | + | joinCloseError(&err, "close source snapshot parent directory", r.closeDescriptor(oldFD, "close source snapshot parent directory")) | |
| 294 | + | }() | |
| 295 | + | newFD, newName, err := r.repositoryParent(newPath) | |
| 296 | + | if err != nil { | |
| 297 | + | return err | |
| 298 | + | } | |
| 299 | + | defer func() { | |
| 300 | + | joinCloseError(&err, "close destination snapshot parent directory", r.closeDescriptor(newFD, "close destination snapshot parent directory")) | |
| 301 | + | }() | |
| 302 | + | return unix.Renameat(oldFD, oldName, newFD, newName) | |
| 303 | + | } | |
| 304 | + | func (r *Repository) remove(path string) (err error) { | |
| 305 | + | if r.hooks.remove != nil { | |
| 306 | + | if err := r.hooks.remove(path); err != nil { | |
| 307 | + | return err | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | fd, name, err := r.repositoryParent(path) | |
| 311 | + | if err != nil { | |
| 312 | + | return err | |
| 313 | + | } | |
| 314 | + | defer func() { | |
| 315 | + | joinCloseError(&err, "close snapshot parent directory", r.closeDescriptor(fd, "close snapshot parent directory")) | |
| 316 | + | }() | |
| 317 | + | unlinkErr := unix.Unlinkat(fd, name, 0) | |
| 318 | + | if unlinkErr == nil { | |
| 319 | + | return nil | |
| 320 | + | } | |
| 321 | + | var stat unix.Stat_t | |
| 322 | + | if err := unix.Fstatat(fd, name, &stat, unix.AT_SYMLINK_NOFOLLOW); err != nil || !directoryUnlinkRetry(unlinkErr, uint32(stat.Mode)) { | |
| 323 | + | return unlinkErr | |
| 324 | + | } | |
| 325 | + | return unix.Unlinkat(fd, name, unix.AT_REMOVEDIR) | |
| 326 | + | } | |
| 327 | + | func (r *Repository) syncDirectory(dir string) error { | |
| 328 | + | if r.hooks.syncDirectory != nil { | |
| 329 | + | if err := r.hooks.syncDirectory(dir); err != nil { | |
| 330 | + | return err | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | return syncDirectory(dir) | |
| 334 | + | } | |
| 335 | + | func syncDirectory(dir string) error { | |
| 336 | + | f, err := os.Open(dir) | |
| 337 | + | if err != nil { | |
| 338 | + | return err | |
| 339 | + | } | |
| 340 | + | if err := f.Sync(); err != nil { | |
| 341 | + | _ = f.Close() | |
| 342 | + | return err | |
| 343 | + | } | |
| 344 | + | return f.Close() | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | func (r *Repository) readOptionalBounded(path string) ([]byte, bool, error) { | |
| 348 | + | data, err := r.readBounded(path) | |
| 349 | + | if errors.Is(err, os.ErrNotExist) { | |
| 350 | + | return nil, false, nil | |
| 351 | + | } | |
| 352 | + | return data, err == nil, err | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // readBounded opens the final component with O_NOFOLLOW, validates the opened | |
| 356 | + | // descriptor, then reads a hard bounded amount. It deliberately never trusts | |
| 357 | + | // path metadata obtained before opening the descriptor. | |
| 358 | + | func (r *Repository) readBounded(path string) ([]byte, error) { | |
| 359 | + | f, err := r.repositoryFD(path, syscall.O_RDONLY|syscall.O_NONBLOCK, 0) | |
| 360 | + | if err != nil { | |
| 361 | + | return nil, err | |
| 362 | + | } | |
| 363 | + | return readBoundedFile(f) | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | // readBounded remains for focused descriptor-hardening tests. Repository code | |
| 367 | + | // must use r.readBounded so intermediate components are pinned and no-follow. | |
| 368 | + | func readBounded(path string) ([]byte, error) { | |
| 369 | + | fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) | |
| 370 | + | if err != nil { | |
| 371 | + | return nil, err | |
| 372 | + | } | |
| 373 | + | f := os.NewFile(uintptr(fd), path) | |
| 374 | + | if f == nil { | |
| 375 | + | _ = syscall.Close(fd) | |
| 376 | + | return nil, fmt.Errorf("open snapshot file") | |
| 377 | + | } | |
| 378 | + | return readBoundedFile(f) | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | func readBoundedFile(f *os.File) ([]byte, error) { | |
| 382 | + | fd := int(f.Fd()) | |
| 383 | + | var stat syscall.Stat_t | |
| 384 | + | if err := syscall.Fstat(fd, &stat); err != nil { | |
| 385 | + | _ = f.Close() | |
| 386 | + | return nil, err | |
| 387 | + | } | |
| 388 | + | if stat.Mode&syscall.S_IFMT != syscall.S_IFREG { | |
| 389 | + | _ = f.Close() | |
| 390 | + | return nil, fmt.Errorf("not a regular file") | |
| 391 | + | } | |
| 392 | + | if int(stat.Uid) != os.Geteuid() { | |
| 393 | + | _ = f.Close() | |
| 394 | + | return nil, fmt.Errorf("snapshot file is not owned by effective uid") | |
| 395 | + | } | |
| 396 | + | if stat.Mode&0o077 != 0 { | |
| 397 | + | _ = f.Close() | |
| 398 | + | return nil, fmt.Errorf("snapshot file has unsafe permissions") | |
| 399 | + | } | |
| 400 | + | if stat.Size < 0 || stat.Size > int64(maxRepositoryRead) { | |
| 401 | + | _ = f.Close() | |
| 402 | + | return nil, fmt.Errorf("snapshot file too large") | |
| 403 | + | } | |
| 404 | + | data, readErr := io.ReadAll(io.LimitReader(f, int64(maxRepositoryRead)+1)) | |
| 405 | + | closeErr := f.Close() | |
| 406 | + | if readErr != nil { | |
| 407 | + | return nil, readErr | |
| 408 | + | } | |
| 409 | + | if closeErr != nil { | |
| 410 | + | return nil, closeErr | |
| 411 | + | } | |
| 412 | + | if len(data) > maxRepositoryRead { | |
| 413 | + | return nil, fmt.Errorf("snapshot file too large") | |
| 414 | + | } | |
| 415 | + | return data, nil | |
| 416 | + | } | |
A internal/adapters/snapshot/atomic_test.go +126 -0
| @@ -0,0 +1,126 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "syscall" | |
| 10 | + | "testing" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestWriteImmutableUsesOneFinalDirectorySync(t *testing.T) { | |
| 14 | + | repo := NewRepository(privateDir(t)) | |
| 15 | + | dir := filepath.Join(repo.dir, "immutable") | |
| 16 | + | if err := repo.ensurePrivateDirectory(dir); err != nil { | |
| 17 | + | t.Fatal(err) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | syncs := 0 | |
| 21 | + | repo.hooks.syncDirectory = func(got string) error { | |
| 22 | + | if got == dir { | |
| 23 | + | syncs++ | |
| 24 | + | } | |
| 25 | + | return nil | |
| 26 | + | } | |
| 27 | + | data := []byte("immutable") | |
| 28 | + | if err := repo.writeImmutable(filepath.Join(dir, "object"), data, func(existing []byte) error { | |
| 29 | + | if !bytes.Equal(existing, data) { | |
| 30 | + | return errors.New("unexpected immutable data") | |
| 31 | + | } | |
| 32 | + | return nil | |
| 33 | + | }); err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | if syncs != 1 { | |
| 37 | + | t.Fatalf("successful immutable directory syncs = %d, want 1", syncs) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestWithAtomicTempPreservesLifecycleOrder(t *testing.T) { | |
| 42 | + | repo := NewRepository(privateDir(t)) | |
| 43 | + | dir := filepath.Join(repo.dir, "temporary") | |
| 44 | + | if err := repo.ensurePrivateDirectory(dir); err != nil { | |
| 45 | + | t.Fatal(err) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | var events []string | |
| 49 | + | repo.hooks.createTemp = func(string) error { events = append(events, "create"); return nil } | |
| 50 | + | repo.hooks.writeTemp = func(string) error { events = append(events, "write"); return nil } | |
| 51 | + | repo.hooks.syncFile = func(string) error { events = append(events, "sync file"); return nil } | |
| 52 | + | repo.hooks.closeFile = func(string) error { events = append(events, "close"); return nil } | |
| 53 | + | repo.hooks.remove = func(string) error { events = append(events, "remove"); return nil } | |
| 54 | + | repo.hooks.syncDirectory = func(string) error { events = append(events, "sync directory"); return nil } | |
| 55 | + | ||
| 56 | + | if err := repo.withAtomicTemp(dir, []byte("data"), func(path string) (bool, error) { | |
| 57 | + | events = append(events, "publish") | |
| 58 | + | info, err := os.Stat(path) | |
| 59 | + | if err != nil { | |
| 60 | + | return true, err | |
| 61 | + | } | |
| 62 | + | if got := info.Mode().Perm(); got != 0o600 { | |
| 63 | + | t.Fatalf("temporary file mode = %o, want 600", got) | |
| 64 | + | } | |
| 65 | + | return true, nil | |
| 66 | + | }); err != nil { | |
| 67 | + | t.Fatal(err) | |
| 68 | + | } | |
| 69 | + | want := []string{"create", "write", "sync file", "close", "publish", "remove", "sync directory"} | |
| 70 | + | if len(events) != len(want) { | |
| 71 | + | t.Fatalf("lifecycle = %v, want %v", events, want) | |
| 72 | + | } | |
| 73 | + | for i := range want { | |
| 74 | + | if events[i] != want[i] { | |
| 75 | + | t.Fatalf("lifecycle = %v, want %v", events, want) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestInstallImmutableJoinsPrimaryAndParentCloseErrors(t *testing.T) { | |
| 81 | + | repo := NewRepository(privateDir(t)) | |
| 82 | + | dir := filepath.Join(repo.dir, "objects") | |
| 83 | + | if err := repo.ensurePrivateDirectory(dir); err != nil { | |
| 84 | + | t.Fatal(err) | |
| 85 | + | } | |
| 86 | + | oldPath := filepath.Join(dir, "old") | |
| 87 | + | newPath := filepath.Join(dir, "new") | |
| 88 | + | if err := os.WriteFile(oldPath, []byte("old"), 0o600); err != nil { | |
| 89 | + | t.Fatal(err) | |
| 90 | + | } | |
| 91 | + | if err := os.WriteFile(newPath, []byte("new"), 0o600); err != nil { | |
| 92 | + | t.Fatal(err) | |
| 93 | + | } | |
| 94 | + | closeCause := errors.New("injected source parent close failure") | |
| 95 | + | repo.hooks.closeDescriptor = func(operation string) error { | |
| 96 | + | if operation == "close source snapshot parent directory" { | |
| 97 | + | return closeCause | |
| 98 | + | } | |
| 99 | + | return nil | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | err := repo.installImmutable(oldPath, newPath) | |
| 103 | + | if !errors.Is(err, syscall.EEXIST) || !errors.Is(err, closeCause) { | |
| 104 | + | t.Fatalf("installImmutable error = %v, want link and close failures", err) | |
| 105 | + | } | |
| 106 | + | if !strings.Contains(err.Error(), "close source snapshot parent directory") { | |
| 107 | + | t.Fatalf("installImmutable error = %v, want close operation context", err) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestWriteMutableDoesNotRemoveConsumedTemporaryFile(t *testing.T) { | |
| 112 | + | repo := NewRepository(privateDir(t)) | |
| 113 | + | path := filepath.Join(repo.dir, "HEAD") | |
| 114 | + | repo.hooks.remove = func(string) error { return errors.New("remove must not run") } | |
| 115 | + | ||
| 116 | + | if err := repo.writeMutable(path, []byte("head")); err != nil { | |
| 117 | + | t.Fatalf("writeMutable error = %v", err) | |
| 118 | + | } | |
| 119 | + | got, err := os.ReadFile(path) | |
| 120 | + | if err != nil { | |
| 121 | + | t.Fatal(err) | |
| 122 | + | } | |
| 123 | + | if !bytes.Equal(got, []byte("head")) { | |
| 124 | + | t.Fatalf("HEAD = %q, want %q", got, "head") | |
| 125 | + | } | |
| 126 | + | } | |
A internal/adapters/snapshot/dirent_cookie_darwin.go +28 -0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | //go:build darwin | |
| 2 | + | ||
| 3 | + | package snapshot | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "io" | |
| 8 | + | "syscall" | |
| 9 | + | "unsafe" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // syscall.ReadDirent on Darwin implements Getdirentries by storing the number | |
| 13 | + | // of returned entries in the descriptor seek offset. Dirent.Seekoff is not | |
| 14 | + | // that cursor. A max-sized record buffer lets each call be drained before the | |
| 15 | + | // descriptor-maintained count is saved. | |
| 16 | + | const maintenanceDirentBufferSize = int(unsafe.Sizeof(syscall.Dirent{})) | |
| 17 | + | ||
| 18 | + | func directoryCookie(file maintenanceDirectory, _ *syscall.Dirent) (int64, error) { | |
| 19 | + | return file.Seek(0, io.SeekCurrent) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func drainMaintenanceDirentBatch() bool { return true } | |
| 23 | + | ||
| 24 | + | // Darwin reports EPERM, rather than EISDIR, when unlinkat without | |
| 25 | + | // AT_REMOVEDIR targets a directory. The mode check preserves EPERM for files. | |
| 26 | + | func directoryUnlinkRetry(err error, mode uint32) bool { | |
| 27 | + | return errors.Is(err, syscall.EPERM) && mode&syscall.S_IFMT == syscall.S_IFDIR | |
| 28 | + | } | |
A internal/adapters/snapshot/dirent_cookie_darwin_test.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | //go:build darwin | |
| 2 | + | ||
| 3 | + | package snapshot | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "syscall" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestDirectoryCookieUsesDescriptorEntryCount(t *testing.T) { | |
| 11 | + | file := fakeMaintenanceDirectory{seekOffset: 42} | |
| 12 | + | dirent := syscall.Dirent{Seekoff: 99} | |
| 13 | + | got, err := directoryCookie(file, &dirent) | |
| 14 | + | if err != nil { | |
| 15 | + | t.Fatal(err) | |
| 16 | + | } | |
| 17 | + | if got != 42 { | |
| 18 | + | t.Fatalf("directoryCookie() = %d, want descriptor entry count 42", got) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestDirectoryUnlinkRetryRequiresDarwinDirectoryEPERM(t *testing.T) { | |
| 23 | + | if directoryUnlinkRetry(syscall.EPERM, syscall.S_IFREG) { | |
| 24 | + | t.Fatal("directoryUnlinkRetry(EPERM, regular file) = true, want false") | |
| 25 | + | } | |
| 26 | + | if !directoryUnlinkRetry(syscall.EPERM, syscall.S_IFDIR) { | |
| 27 | + | t.Fatal("directoryUnlinkRetry(EPERM, directory) = false, want true") | |
| 28 | + | } | |
| 29 | + | } | |
A internal/adapters/snapshot/dirent_cookie_linux.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package snapshot | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "syscall" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | const maintenanceDirentBufferSize = 8192 | |
| 11 | + | ||
| 12 | + | // directoryCookie is d_off, the resumable getdents(2) cookie for Linux. | |
| 13 | + | func directoryCookie(_ maintenanceDirectory, dirent *syscall.Dirent) (int64, error) { | |
| 14 | + | return dirent.Off, nil | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func drainMaintenanceDirentBatch() bool { return false } | |
| 18 | + | ||
| 19 | + | func directoryUnlinkRetry(err error, _ uint32) bool { return errors.Is(err, syscall.EISDIR) } | |
A internal/adapters/snapshot/dirent_cookie_linux_test.go +28 -0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package snapshot | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "syscall" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestDirectoryCookieUsesLinuxOffset(t *testing.T) { | |
| 11 | + | dirent := syscall.Dirent{Off: 42} | |
| 12 | + | got, err := directoryCookie(fakeMaintenanceDirectory{}, &dirent) | |
| 13 | + | if err != nil { | |
| 14 | + | t.Fatal(err) | |
| 15 | + | } | |
| 16 | + | if got != 42 { | |
| 17 | + | t.Fatalf("directoryCookie() = %d, want 42", got) | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestDirectoryUnlinkRetryRequiresLinuxEISDIR(t *testing.T) { | |
| 22 | + | if directoryUnlinkRetry(syscall.EPERM, syscall.S_IFDIR) { | |
| 23 | + | t.Fatal("directoryUnlinkRetry(EPERM, directory) = true, want false") | |
| 24 | + | } | |
| 25 | + | if !directoryUnlinkRetry(syscall.EISDIR, syscall.S_IFDIR) { | |
| 26 | + | t.Fatal("directoryUnlinkRetry(EISDIR, directory) = false, want true") | |
| 27 | + | } | |
| 28 | + | } | |
A internal/adapters/snapshot/fd_count_darwin_test.go +28 -0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | //go:build darwin | |
| 2 | + | ||
| 3 | + | package snapshot | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // macOS exposes the calling process descriptor table through /dev/fd. | |
| 11 | + | // Readdirnames avoids os.ReadDir's fstatat calls for descriptors that can | |
| 12 | + | // disappear while the directory is being enumerated. | |
| 13 | + | func openDescriptorCount(t *testing.T) int { | |
| 14 | + | t.Helper() | |
| 15 | + | dir, err := os.Open("/dev/fd") | |
| 16 | + | if err != nil { | |
| 17 | + | t.Fatal(err) | |
| 18 | + | } | |
| 19 | + | names, readErr := dir.Readdirnames(-1) | |
| 20 | + | closeErr := dir.Close() | |
| 21 | + | if readErr != nil { | |
| 22 | + | t.Fatal(readErr) | |
| 23 | + | } | |
| 24 | + | if closeErr != nil { | |
| 25 | + | t.Fatal(closeErr) | |
| 26 | + | } | |
| 27 | + | return len(names) | |
| 28 | + | } | |
A internal/adapters/snapshot/fd_count_linux_test.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package snapshot | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // openDescriptorCount checks the actual Linux descriptor table, not a mocked | |
| 11 | + | // lifecycle signal, so the maintenance leak assertion remains strict. | |
| 12 | + | func openDescriptorCount(t *testing.T) int { | |
| 13 | + | t.Helper() | |
| 14 | + | entries, err := os.ReadDir("/proc/self/fd") | |
| 15 | + | if err != nil { | |
| 16 | + | t.Fatal(err) | |
| 17 | + | } | |
| 18 | + | return len(entries) | |
| 19 | + | } | |
A internal/adapters/snapshot/legacy.go +399 -0
| @@ -0,0 +1,399 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "crypto/sha256" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "os" | |
| 11 | + | "path/filepath" | |
| 12 | + | "sort" | |
| 13 | + | "strings" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/vev/internal/ports" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | const ( | |
| 19 | + | legacyDeleteMarkerPrefix = ".legacy-import-receipt-" | |
| 20 | + | maxLegacyDeleteMarkers = maxLegacySnapshotFiles | |
| 21 | + | maxLegacyDeleteNameBytes = 200 | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | type legacyDeleteMarker struct { | |
| 25 | + | name string | |
| 26 | + | digest [sha256.Size]byte | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | type legacyDirectory interface { | |
| 30 | + | ReadDir(int) ([]os.DirEntry, error) | |
| 31 | + | Close() error | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (r *Repository) openLegacyDirectory(path string) (legacyDirectory, error) { | |
| 35 | + | if r.hooks.openLegacyDirectory != nil { | |
| 36 | + | return r.hooks.openLegacyDirectory(path) | |
| 37 | + | } | |
| 38 | + | return r.openDirectory(path) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // readLegacyImportCandidate reports whether an untrusted root entry can be | |
| 42 | + | // safely imported. Legacy scanning skips entries that disappear or fail secure | |
| 43 | + | // descriptor validation so one hostile .snap file does not block other imports. | |
| 44 | + | func (r *Repository) readLegacyImportCandidate(path string) ([]byte, bool) { | |
| 45 | + | data, err := r.readBounded(path) | |
| 46 | + | return data, err == nil | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // LoadLegacy reads only pre-incremental root .snap files. It is deliberately | |
| 50 | + | // isolated from repository reads so incremental directories are never traversed. | |
| 51 | + | func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot, err error) { | |
| 52 | + | if err := ctx.Err(); err != nil { | |
| 53 | + | return nil, err | |
| 54 | + | } | |
| 55 | + | // One budget covers marker recovery and import. Every directory entry costs | |
| 56 | + | // work, including unrelated attacker-created names, so neither phase can be | |
| 57 | + | // made unbounded by a huge legacy root. | |
| 58 | + | budget := maxDirectoryTraversalEntries | |
| 59 | + | // A marker is an already-authorized deletion, not an import candidate. Finish | |
| 60 | + | // it before exposing any legacy blobs so process restart cannot republish one. | |
| 61 | + | if err := r.cleanupPendingLegacyDeletes(ctx, &budget); err != nil { | |
| 62 | + | return nil, err | |
| 63 | + | } | |
| 64 | + | f, err := r.openLegacyDirectory(r.dir) | |
| 65 | + | if errors.Is(err, os.ErrNotExist) { | |
| 66 | + | return nil, nil | |
| 67 | + | } | |
| 68 | + | if err != nil { | |
| 69 | + | return nil, fmt.Errorf("read legacy snapshot directory: %w", safeFilesystemError(err)) | |
| 70 | + | } | |
| 71 | + | defer func() { | |
| 72 | + | if closeErr := f.Close(); closeErr != nil { | |
| 73 | + | err = errors.Join(err, fmt.Errorf("close legacy snapshot directory: %w", safeFilesystemError(closeErr))) | |
| 74 | + | } | |
| 75 | + | }() | |
| 76 | + | ||
| 77 | + | out = make([]ports.LegacySnapshot, 0, maxLegacySnapshotFiles) | |
| 78 | + | files := 0 | |
| 79 | + | total := 0 | |
| 80 | + | for { | |
| 81 | + | entries, readErr := f.ReadDir(maintenanceBatch) | |
| 82 | + | for _, entry := range entries { | |
| 83 | + | if err := ctx.Err(); err != nil { | |
| 84 | + | return nil, err | |
| 85 | + | } | |
| 86 | + | if budget == 0 { | |
| 87 | + | return nil, ErrDirectoryTraversalBudget | |
| 88 | + | } | |
| 89 | + | budget-- | |
| 90 | + | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".snap") { | |
| 91 | + | continue | |
| 92 | + | } | |
| 93 | + | files++ | |
| 94 | + | if files > maxLegacySnapshotFiles { | |
| 95 | + | return nil, fmt.Errorf("legacy snapshot import has too many files (maximum %d)", maxLegacySnapshotFiles) | |
| 96 | + | } | |
| 97 | + | path := filepath.Join(r.dir, entry.Name()) | |
| 98 | + | data, importable := r.readLegacyImportCandidate(path) | |
| 99 | + | if !importable { | |
| 100 | + | continue | |
| 101 | + | } | |
| 102 | + | if len(data) > maxLegacySnapshotBytes-total { | |
| 103 | + | return nil, fmt.Errorf("legacy snapshot import exceeds aggregate byte limit (%d bytes)", maxLegacySnapshotBytes) | |
| 104 | + | } | |
| 105 | + | total += len(data) | |
| 106 | + | name := strings.TrimSuffix(entry.Name(), ".snap") | |
| 107 | + | if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) { | |
| 108 | + | name = "" | |
| 109 | + | } | |
| 110 | + | killed, tombstoneErr := r.tombstoned(name) | |
| 111 | + | if tombstoneErr != nil { | |
| 112 | + | return nil, fmt.Errorf("read killed session marker: %w", safeFilesystemError(tombstoneErr)) | |
| 113 | + | } | |
| 114 | + | if killed { | |
| 115 | + | continue | |
| 116 | + | } | |
| 117 | + | out = append(out, ports.LegacySnapshot{Name: name, Data: data}) | |
| 118 | + | } | |
| 119 | + | if errors.Is(readErr, io.EOF) { | |
| 120 | + | break | |
| 121 | + | } | |
| 122 | + | if readErr != nil { | |
| 123 | + | return nil, fmt.Errorf("read legacy snapshot directory: %w", safeFilesystemError(readErr)) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | |
| 127 | + | return out, nil | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // DeleteVerifiedLegacy records a hash-bound import receipt before deleting the | |
| 131 | + | // source. LoadLegacy consumes surviving receipts after restart, but only when | |
| 132 | + | // the source still has the exact bytes that were verified and published. | |
| 133 | + | func (r *Repository) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error { | |
| 134 | + | if err := ctx.Err(); err != nil { | |
| 135 | + | return err | |
| 136 | + | } | |
| 137 | + | filename := filenameForName(blob.Name) | |
| 138 | + | lock := r.lockSession("legacy-sync:" + filename) | |
| 139 | + | defer r.unlockSession(lock) | |
| 140 | + | if err := ctx.Err(); err != nil { | |
| 141 | + | return err | |
| 142 | + | } | |
| 143 | + | receipt := legacyDeleteMarker{name: blob.Name, digest: sha256.Sum256(blob.Data)} | |
| 144 | + | if err := r.authorizeLegacyDelete(receipt); err != nil { | |
| 145 | + | return err | |
| 146 | + | } | |
| 147 | + | return r.deleteAuthorizedLegacy(ctx, filename, receipt) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // DeleteLegacy is for deliberate session purges, which do not represent a | |
| 151 | + | // verified import and therefore must not create an import receipt. | |
| 152 | + | func (r *Repository) DeleteLegacy(ctx context.Context, name string) error { | |
| 153 | + | if err := ctx.Err(); err != nil { | |
| 154 | + | return err | |
| 155 | + | } | |
| 156 | + | filename := filenameForName(name) | |
| 157 | + | lock := r.lockSession("legacy-sync:" + filename) | |
| 158 | + | defer r.unlockSession(lock) | |
| 159 | + | return r.deleteLegacyFile(ctx, filename) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func legacyDeleteMarkerFilename(key string) string { return legacyDeleteMarkerPrefix + key } | |
| 163 | + | ||
| 164 | + | func (r *Repository) authorizeLegacyDelete(receipt legacyDeleteMarker) error { | |
| 165 | + | if err := r.ensurePrivateDirectory(r.dir); err != nil { | |
| 166 | + | return fmt.Errorf("create legacy import receipt directory: %w", safeFilesystemError(err)) | |
| 167 | + | } | |
| 168 | + | path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(receipt.name))) | |
| 169 | + | data := append(receipt.digest[:], receipt.name...) | |
| 170 | + | if err := r.writeImmutable(path, data, func(data []byte) error { | |
| 171 | + | if len(data) != sha256.Size+len(receipt.name) || string(data[sha256.Size:]) != receipt.name || !bytes.Equal(data[:sha256.Size], receipt.digest[:]) { | |
| 172 | + | return fmt.Errorf("legacy import receipt identity mismatch") | |
| 173 | + | } | |
| 174 | + | return nil | |
| 175 | + | }); err != nil { | |
| 176 | + | return fmt.Errorf("write legacy import receipt: %w", safeFilesystemError(err)) | |
| 177 | + | } | |
| 178 | + | return nil | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // deleteAuthorizedLegacy deletes only a source whose bytes match the durable | |
| 182 | + | // receipt. A replaced source invalidates the receipt and remains importable. | |
| 183 | + | func (r *Repository) deleteAuthorizedLegacy(ctx context.Context, filename string, receipt legacyDeleteMarker) error { | |
| 184 | + | if _, pending := r.pendingLegacySync.Load(filename); pending { | |
| 185 | + | if err := r.syncPendingLegacy(ctx, filename); err != nil { | |
| 186 | + | return err | |
| 187 | + | } | |
| 188 | + | return r.clearLegacyDeleteAuthorization(receipt.name) | |
| 189 | + | } | |
| 190 | + | path := filepath.Join(r.dir, filename) | |
| 191 | + | data, err := r.readBounded(path) | |
| 192 | + | if err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 193 | + | return fmt.Errorf("read legacy snapshot for deletion: %w", safeFilesystemError(err)) | |
| 194 | + | } | |
| 195 | + | if err == nil && sha256.Sum256(data) != receipt.digest { | |
| 196 | + | return r.clearLegacyDeleteAuthorization(receipt.name) | |
| 197 | + | } | |
| 198 | + | return r.deleteLegacyFile(ctx, filename, receipt.name) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func (r *Repository) deleteLegacyFile(ctx context.Context, filename string, clearReceipt ...string) error { | |
| 202 | + | if _, pending := r.pendingLegacySync.Load(filename); pending { | |
| 203 | + | if err := r.syncPendingLegacy(ctx, filename); err != nil { | |
| 204 | + | return err | |
| 205 | + | } | |
| 206 | + | } else { | |
| 207 | + | path := filepath.Join(r.dir, filename) | |
| 208 | + | if err := r.remove(path); err != nil { | |
| 209 | + | if !errors.Is(err, os.ErrNotExist) { | |
| 210 | + | return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err)) | |
| 211 | + | } | |
| 212 | + | // A missing repository has no deletion to make durable. This preserves | |
| 213 | + | // idempotence for explicit purges before the snapshot root exists. | |
| 214 | + | if _, statErr := os.Lstat(r.dir); errors.Is(statErr, os.ErrNotExist) { | |
| 215 | + | return nil | |
| 216 | + | } else if statErr != nil { | |
| 217 | + | return fmt.Errorf("stat legacy snapshot directory: %w", safeFilesystemError(statErr)) | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | r.pendingLegacySync.Store(filename, struct{}{}) | |
| 221 | + | if err := r.syncPendingLegacy(ctx, filename); err != nil { | |
| 222 | + | return err | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | if len(clearReceipt) != 0 { | |
| 226 | + | return r.clearLegacyDeleteAuthorization(clearReceipt[0]) | |
| 227 | + | } | |
| 228 | + | return nil | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func (r *Repository) clearLegacyDeleteAuthorization(name string) error { | |
| 232 | + | path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(name))) | |
| 233 | + | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 234 | + | return fmt.Errorf("clear legacy import receipt: %w", safeFilesystemError(err)) | |
| 235 | + | } | |
| 236 | + | if err := r.syncDirectory(r.dir); err != nil { | |
| 237 | + | return fmt.Errorf("sync legacy import receipt directory: %w", safeFilesystemError(err)) | |
| 238 | + | } | |
| 239 | + | return nil | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context, budget *int) error { | |
| 243 | + | markers, err := r.pendingLegacyDeleteMarkers(ctx, budget) | |
| 244 | + | if errors.Is(err, os.ErrNotExist) { | |
| 245 | + | return nil | |
| 246 | + | } | |
| 247 | + | if err != nil { | |
| 248 | + | return err | |
| 249 | + | } | |
| 250 | + | for _, marker := range markers { | |
| 251 | + | filename := filenameForName(marker.name) | |
| 252 | + | lock := r.lockSession("legacy-sync:" + filename) | |
| 253 | + | err := r.deleteAuthorizedLegacy(ctx, filename, marker) | |
| 254 | + | r.unlockSession(lock) | |
| 255 | + | if err != nil { | |
| 256 | + | return fmt.Errorf("retry authorized legacy deletion: %w", err) | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | return nil | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context, budget *int) (markers []legacyDeleteMarker, err error) { | |
| 263 | + | f, err := r.openLegacyDirectory(r.dir) | |
| 264 | + | if err != nil { | |
| 265 | + | return nil, err | |
| 266 | + | } | |
| 267 | + | defer func() { | |
| 268 | + | if closeErr := f.Close(); closeErr != nil { | |
| 269 | + | err = errors.Join(err, fmt.Errorf("close legacy deletion marker directory: %w", safeFilesystemError(closeErr))) | |
| 270 | + | } | |
| 271 | + | }() | |
| 272 | + | markers = make([]legacyDeleteMarker, 0, maxLegacyDeleteMarkers) | |
| 273 | + | for { | |
| 274 | + | entries, readErr := f.ReadDir(maintenanceBatch) | |
| 275 | + | for _, entry := range entries { | |
| 276 | + | if err := ctx.Err(); err != nil { | |
| 277 | + | return nil, err | |
| 278 | + | } | |
| 279 | + | if *budget == 0 { | |
| 280 | + | return nil, ErrDirectoryTraversalBudget | |
| 281 | + | } | |
| 282 | + | *budget-- | |
| 283 | + | if entry.IsDir() || !strings.HasPrefix(entry.Name(), legacyDeleteMarkerPrefix) { | |
| 284 | + | continue | |
| 285 | + | } | |
| 286 | + | if len(markers) == maxLegacyDeleteMarkers { | |
| 287 | + | return nil, fmt.Errorf("legacy deletion retry has too many markers (maximum %d)", maxLegacyDeleteMarkers) | |
| 288 | + | } | |
| 289 | + | key := strings.TrimPrefix(entry.Name(), legacyDeleteMarkerPrefix) | |
| 290 | + | if !canonicalSessionKey(key) { | |
| 291 | + | return nil, fmt.Errorf("invalid legacy deletion marker") | |
| 292 | + | } | |
| 293 | + | path := filepath.Join(r.dir, entry.Name()) | |
| 294 | + | data, dataErr := r.readBounded(path) | |
| 295 | + | if dataErr != nil || len(data) < sha256.Size || len(data)-sha256.Size > maxLegacyDeleteNameBytes { | |
| 296 | + | return nil, fmt.Errorf("invalid legacy import receipt") | |
| 297 | + | } | |
| 298 | + | name := string(data[sha256.Size:]) | |
| 299 | + | if sessionKey(name) != key { | |
| 300 | + | return nil, fmt.Errorf("invalid legacy import receipt") | |
| 301 | + | } | |
| 302 | + | marker := legacyDeleteMarker{name: name} | |
| 303 | + | copy(marker.digest[:], data[:sha256.Size]) | |
| 304 | + | markers = append(markers, marker) | |
| 305 | + | } | |
| 306 | + | if errors.Is(readErr, io.EOF) { | |
| 307 | + | break | |
| 308 | + | } | |
| 309 | + | if readErr != nil { | |
| 310 | + | return nil, fmt.Errorf("read legacy deletion markers: %w", safeFilesystemError(readErr)) | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | return markers, nil | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // Tombstone durably marks name as logically killed before either snapshot | |
| 317 | + | // source is deleted. Startup consults this marker before importing legacy or | |
| 318 | + | // restoring incremental state, so a partial offline kill cannot resurrect it. | |
| 319 | + | func (r *Repository) Tombstone(ctx context.Context, name string) error { | |
| 320 | + | if err := ctx.Err(); err != nil { | |
| 321 | + | return err | |
| 322 | + | } | |
| 323 | + | key := sessionKey(name) | |
| 324 | + | lock := r.lockSession(key) | |
| 325 | + | defer r.unlockSession(lock) | |
| 326 | + | if err := ctx.Err(); err != nil { | |
| 327 | + | return err | |
| 328 | + | } | |
| 329 | + | if err := r.ensurePrivateDirectory(r.dir); err != nil { | |
| 330 | + | return fmt.Errorf("create killed session marker directory: %w", safeFilesystemError(err)) | |
| 331 | + | } | |
| 332 | + | path := filepath.Join(r.dir, tombstoneFilename(key)) | |
| 333 | + | if err := r.writeImmutable(path, []byte(name), func(data []byte) error { | |
| 334 | + | if string(data) != name { | |
| 335 | + | return fmt.Errorf("killed session marker identity mismatch") | |
| 336 | + | } | |
| 337 | + | return nil | |
| 338 | + | }); err != nil { | |
| 339 | + | return fmt.Errorf("write killed session marker: %w", safeFilesystemError(err)) | |
| 340 | + | } | |
| 341 | + | return nil | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | // DeleteTombstone removes a marker only after both sources have been durably | |
| 345 | + | // deleted. A missing marker is idempotent for offline-kill retries. | |
| 346 | + | func (r *Repository) DeleteTombstone(ctx context.Context, name string) error { | |
| 347 | + | if err := ctx.Err(); err != nil { | |
| 348 | + | return err | |
| 349 | + | } | |
| 350 | + | key := sessionKey(name) | |
| 351 | + | lock := r.lockSession(key) | |
| 352 | + | defer r.unlockSession(lock) | |
| 353 | + | if err := ctx.Err(); err != nil { | |
| 354 | + | return err | |
| 355 | + | } | |
| 356 | + | if err := r.remove(filepath.Join(r.dir, tombstoneFilename(key))); errors.Is(err, os.ErrNotExist) { | |
| 357 | + | return nil | |
| 358 | + | } else if err != nil { | |
| 359 | + | return fmt.Errorf("delete killed session marker: %w", safeFilesystemError(err)) | |
| 360 | + | } | |
| 361 | + | if err := r.syncDirectory(r.dir); err != nil { | |
| 362 | + | return fmt.Errorf("sync killed session marker directory: %w", safeFilesystemError(err)) | |
| 363 | + | } | |
| 364 | + | return nil | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func tombstoneFilename(key string) string { return ".killed-" + key } | |
| 368 | + | ||
| 369 | + | func (r *Repository) tombstoned(name string) (bool, error) { | |
| 370 | + | if name == "" { | |
| 371 | + | return false, nil | |
| 372 | + | } | |
| 373 | + | path := filepath.Join(r.dir, tombstoneFilename(sessionKey(name))) | |
| 374 | + | if hook := r.hooks.beforeTombstoneCheck; hook != nil { | |
| 375 | + | hook(path) | |
| 376 | + | } | |
| 377 | + | data, err := r.readBounded(path) | |
| 378 | + | if errors.Is(err, os.ErrNotExist) { | |
| 379 | + | return false, nil | |
| 380 | + | } | |
| 381 | + | if err != nil { | |
| 382 | + | return false, err | |
| 383 | + | } | |
| 384 | + | if string(data) != name { | |
| 385 | + | return false, fmt.Errorf("killed session marker identity mismatch") | |
| 386 | + | } | |
| 387 | + | return true, nil | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | func (r *Repository) syncPendingLegacy(ctx context.Context, filename string) error { | |
| 391 | + | if err := ctx.Err(); err != nil { | |
| 392 | + | return err | |
| 393 | + | } | |
| 394 | + | if err := r.syncDirectory(r.dir); err != nil { | |
| 395 | + | return fmt.Errorf("sync legacy snapshot directory: %w", safeFilesystemError(err)) | |
| 396 | + | } | |
| 397 | + | r.pendingLegacySync.Delete(filename) | |
| 398 | + | return nil | |
| 399 | + | } | |
A internal/adapters/snapshot/legacy_pending_delete_test.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestRepositoryLoadLegacyCompletesAuthorizedDeleteAfterRestart(t *testing.T) { | |
| 16 | + | repo := NewRepository(privateDir(t)) | |
| 17 | + | require.NoError(t, os.Mkdir(repo.dir, 0o700)) | |
| 18 | + | path := filepath.Join(repo.dir, filenameForName("named")) | |
| 19 | + | require.NoError(t, os.WriteFile(path, []byte("legacy"), 0o600)) | |
| 20 | + | ||
| 21 | + | injected := errors.New("unlink failed") | |
| 22 | + | repo.hooks.remove = func(path string) error { | |
| 23 | + | if filepath.Base(path) == filenameForName("named") { | |
| 24 | + | return injected | |
| 25 | + | } | |
| 26 | + | return os.Remove(path) | |
| 27 | + | } | |
| 28 | + | // The injected unlink fault is after receipt persistence, simulating a | |
| 29 | + | // crash boundary where publication was verified but source cleanup was not. | |
| 30 | + | require.ErrorIs(t, repo.DeleteVerifiedLegacy(context.Background(), ports.LegacySnapshot{Name: "named", Data: []byte("legacy")}), injected) | |
| 31 | + | ||
| 32 | + | // A new repository has no in-memory retry state. The durable receipt must | |
| 33 | + | // fence import and complete deletion without another publication. | |
| 34 | + | repo = NewRepository(repo.dir) | |
| 35 | + | legacy, err := repo.LoadLegacy(context.Background()) | |
| 36 | + | require.NoError(t, err) | |
| 37 | + | require.Empty(t, legacy) | |
| 38 | + | _, err = os.Stat(path) | |
| 39 | + | require.ErrorIs(t, err, os.ErrNotExist) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestRepositoryReceiptDoesNotDeleteReplacedLegacySource(t *testing.T) { | |
| 43 | + | repo := NewRepository(privateDir(t)) | |
| 44 | + | require.NoError(t, os.Mkdir(repo.dir, 0o700)) | |
| 45 | + | path := filepath.Join(repo.dir, filenameForName("named")) | |
| 46 | + | require.NoError(t, os.WriteFile(path, []byte("verified"), 0o600)) | |
| 47 | + | ||
| 48 | + | injected := errors.New("unlink failed") | |
| 49 | + | repo.hooks.remove = func(path string) error { | |
| 50 | + | if filepath.Base(path) == filenameForName("named") { | |
| 51 | + | return injected | |
| 52 | + | } | |
| 53 | + | return os.Remove(path) | |
| 54 | + | } | |
| 55 | + | require.ErrorIs(t, repo.DeleteVerifiedLegacy(context.Background(), ports.LegacySnapshot{Name: "named", Data: []byte("verified")}), injected) | |
| 56 | + | require.NoError(t, os.WriteFile(path, []byte("replacement"), 0o600)) | |
| 57 | + | ||
| 58 | + | repo = NewRepository(repo.dir) | |
| 59 | + | legacy, err := repo.LoadLegacy(context.Background()) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | require.Equal(t, []ports.LegacySnapshot{{Name: "named", Data: []byte("replacement")}}, legacy) | |
| 62 | + | require.FileExists(t, path) | |
| 63 | + | } | |
A internal/adapters/snapshot/legacy_test.go +141 -0
| @@ -0,0 +1,141 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestRepositoryLegacyReadsOnlyBoundedRootSnapshots(t *testing.T) { | |
| 15 | + | repo := NewRepository(privateDir(t)) | |
| 16 | + | if err := os.MkdirAll(filepath.Join(repo.dir, repositorySessionsDir), 0o700); err != nil { | |
| 17 | + | t.Fatal(err) | |
| 18 | + | } | |
| 19 | + | if err := os.WriteFile(filepath.Join(repo.dir, "named.snap"), []byte("legacy"), 0o600); err != nil { | |
| 20 | + | t.Fatal(err) | |
| 21 | + | } | |
| 22 | + | if err := os.WriteFile(filepath.Join(repo.dir, filenameForName("unsafe/name")), []byte("hashed"), 0o600); err != nil { | |
| 23 | + | t.Fatal(err) | |
| 24 | + | } | |
| 25 | + | if err := os.WriteFile(filepath.Join(repo.dir, repositorySessionsDir, "ignored.snap"), []byte("incremental"), 0o600); err != nil { | |
| 26 | + | t.Fatal(err) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | got, err := repo.LoadLegacy(context.Background()) | |
| 30 | + | if err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | if len(got) != 2 || got[0].Name != "" || got[1].Name != "named" { | |
| 34 | + | t.Fatalf("LoadLegacy = %#v, want root safe and hashed snapshots", got) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestRepositoryLoadLegacyReportsDirectoryCloseError(t *testing.T) { | |
| 39 | + | for _, tt := range []struct { | |
| 40 | + | name string | |
| 41 | + | failOpen int | |
| 42 | + | operation string | |
| 43 | + | }{ | |
| 44 | + | {name: "pending deletion markers", failOpen: 1, operation: "close legacy deletion marker directory"}, | |
| 45 | + | {name: "legacy snapshots", failOpen: 2, operation: "close legacy snapshot directory"}, | |
| 46 | + | } { | |
| 47 | + | t.Run(tt.name, func(t *testing.T) { | |
| 48 | + | closeErr := errors.New("injected directory close failure") | |
| 49 | + | repo := NewRepository(privateDir(t)) | |
| 50 | + | openCalls := 0 | |
| 51 | + | repo.hooks.openLegacyDirectory = func(string) (legacyDirectory, error) { | |
| 52 | + | openCalls++ | |
| 53 | + | if openCalls == tt.failOpen { | |
| 54 | + | return fakeLegacyDirectory{closeErr: closeErr}, nil | |
| 55 | + | } | |
| 56 | + | return fakeLegacyDirectory{}, nil | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | _, err := repo.LoadLegacy(context.Background()) | |
| 60 | + | if !errors.Is(err, closeErr) { | |
| 61 | + | t.Fatalf("LoadLegacy error = %v, want close error", err) | |
| 62 | + | } | |
| 63 | + | if !strings.Contains(err.Error(), tt.operation) { | |
| 64 | + | t.Fatalf("LoadLegacy error = %v, want %q", err, tt.operation) | |
| 65 | + | } | |
| 66 | + | }) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | type fakeLegacyDirectory struct { | |
| 71 | + | closeErr error | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func (d fakeLegacyDirectory) ReadDir(int) ([]os.DirEntry, error) { return nil, io.EOF } | |
| 75 | + | func (d fakeLegacyDirectory) Close() error { return d.closeErr } | |
| 76 | + | ||
| 77 | + | func TestRepositoryLegacyRejectsTooManyAndTooLargeAggregateSnapshots(t *testing.T) { | |
| 78 | + | t.Run("count", func(t *testing.T) { | |
| 79 | + | repo := NewRepository(privateDir(t)) | |
| 80 | + | if err := os.Mkdir(repo.dir, 0o700); err != nil { | |
| 81 | + | t.Fatal(err) | |
| 82 | + | } | |
| 83 | + | for i := 0; i < maxLegacySnapshotFiles+1; i++ { | |
| 84 | + | if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("%03d.snap", i)), []byte("legacy"), 0o600); err != nil { | |
| 85 | + | t.Fatal(err) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | if _, err := repo.LoadLegacy(context.Background()); err == nil || !strings.Contains(err.Error(), "too many") { | |
| 89 | + | t.Fatalf("LoadLegacy error = %v, want actionable count limit", err) | |
| 90 | + | } | |
| 91 | + | }) | |
| 92 | + | t.Run("aggregate bytes", func(t *testing.T) { | |
| 93 | + | repo := NewRepository(privateDir(t)) | |
| 94 | + | if err := os.Mkdir(repo.dir, 0o700); err != nil { | |
| 95 | + | t.Fatal(err) | |
| 96 | + | } | |
| 97 | + | for i, size := range []int{maxLegacySnapshotBytes / 2, maxLegacySnapshotBytes/2 + 1} { | |
| 98 | + | if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("%d.snap", i)), make([]byte, size), 0o600); err != nil { | |
| 99 | + | t.Fatal(err) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | if _, err := repo.LoadLegacy(context.Background()); err == nil || !strings.Contains(err.Error(), "aggregate") { | |
| 103 | + | t.Fatalf("LoadLegacy error = %v, want actionable aggregate limit", err) | |
| 104 | + | } | |
| 105 | + | }) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestRepositoryDeleteLegacyMissingFileDoesNotRequireRoot(t *testing.T) { | |
| 109 | + | repo := NewRepository(privateDir(t)) | |
| 110 | + | if err := repo.DeleteLegacy(context.Background(), "missing"); err != nil { | |
| 111 | + | t.Fatalf("DeleteLegacy missing file: %v", err) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestRepositoryDeleteLegacySurfacesRootSyncAndCanRetry(t *testing.T) { | |
| 116 | + | repo := NewRepository(privateDir(t)) | |
| 117 | + | if err := os.Mkdir(repo.dir, 0o700); err != nil { | |
| 118 | + | t.Fatal(err) | |
| 119 | + | } | |
| 120 | + | if err := os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600); err != nil { | |
| 121 | + | t.Fatal(err) | |
| 122 | + | } | |
| 123 | + | injected := errors.New("root sync") | |
| 124 | + | syncCalls := 0 | |
| 125 | + | repo.hooks.syncDirectory = func(string) error { | |
| 126 | + | syncCalls++ | |
| 127 | + | if syncCalls == 1 { | |
| 128 | + | return injected | |
| 129 | + | } | |
| 130 | + | return nil | |
| 131 | + | } | |
| 132 | + | if err := repo.DeleteLegacy(context.Background(), "named"); !errors.Is(err, injected) { | |
| 133 | + | t.Fatalf("DeleteLegacy error = %v, want root sync error", err) | |
| 134 | + | } | |
| 135 | + | if err := repo.DeleteLegacy(context.Background(), "named"); err != nil { | |
| 136 | + | t.Fatalf("retry DeleteLegacy: %v", err) | |
| 137 | + | } | |
| 138 | + | if syncCalls != 2 { | |
| 139 | + | t.Fatalf("sync calls = %d, want retry to sync absent file", syncCalls) | |
| 140 | + | } | |
| 141 | + | } | |
A internal/adapters/snapshot/load.go +285 -0
| @@ -0,0 +1,285 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/sha256" | |
| 6 | + | "encoding/binary" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "os" | |
| 11 | + | "path/filepath" | |
| 12 | + | "sort" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/vev/internal/ports" | |
| 15 | + | codec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // Directory scans are deliberately finite. Snapshot directories are attacker | |
| 19 | + | // controlled state, so callers get a retryable error instead of retaining an | |
| 20 | + | // arbitrary number of names or spending an arbitrary amount of work. | |
| 21 | + | const ( | |
| 22 | + | directoryTraversalBatch = 64 | |
| 23 | + | maxDirectoryTraversalEntries = 4096 | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | var ( | |
| 27 | + | ErrDirectoryTraversalBudget = errors.New("snapshot directory traversal budget exceeded") | |
| 28 | + | ErrInvalidHEAD = errors.New("invalid snapshot HEAD") | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | func (r *Repository) List(ctx context.Context) ([]string, error) { | |
| 32 | + | if err := ctx.Err(); err != nil { | |
| 33 | + | return nil, err | |
| 34 | + | } | |
| 35 | + | budget := maxDirectoryTraversalEntries | |
| 36 | + | out := make([]string, 0) | |
| 37 | + | sessions := filepath.Join(r.dir, repositorySessionsDir) | |
| 38 | + | err := r.walkDirectory(ctx, sessions, &budget, func(entry os.DirEntry) error { | |
| 39 | + | if !entry.IsDir() || !canonicalSessionKey(entry.Name()) { | |
| 40 | + | return nil | |
| 41 | + | } | |
| 42 | + | generation, ok, err := r.loadNewestGeneration(ctx, entry.Name(), &budget) | |
| 43 | + | if err != nil || !ok { | |
| 44 | + | return err | |
| 45 | + | } | |
| 46 | + | out = append(out, generation.Name) | |
| 47 | + | return nil | |
| 48 | + | }) | |
| 49 | + | if errors.Is(err, os.ErrNotExist) { | |
| 50 | + | return nil, nil | |
| 51 | + | } | |
| 52 | + | if err != nil { | |
| 53 | + | return nil, fmt.Errorf("read snapshot sessions: %w", err) | |
| 54 | + | } | |
| 55 | + | sort.Strings(out) | |
| 56 | + | return out, nil | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) { | |
| 60 | + | if err := ctx.Err(); err != nil { | |
| 61 | + | return ports.SnapshotGeneration{}, err | |
| 62 | + | } | |
| 63 | + | key := sessionKey(name) | |
| 64 | + | lock := r.lockSession(key) | |
| 65 | + | defer r.unlockSession(lock) | |
| 66 | + | if err := ctx.Err(); err != nil { | |
| 67 | + | return ports.SnapshotGeneration{}, err | |
| 68 | + | } | |
| 69 | + | killed, err := r.tombstoned(name) | |
| 70 | + | if err != nil { | |
| 71 | + | return ports.SnapshotGeneration{}, fmt.Errorf("read killed session marker: %w", err) | |
| 72 | + | } | |
| 73 | + | if killed { | |
| 74 | + | return ports.SnapshotGeneration{}, fmt.Errorf("snapshot session %q is killed", name) | |
| 75 | + | } | |
| 76 | + | return r.load(ctx, name, key) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (r *Repository) load(ctx context.Context, name, key string) (ports.SnapshotGeneration, error) { | |
| 80 | + | preferred, preferredDigest, err := r.readHead(key) | |
| 81 | + | skipped := errors.Is(err, ErrInvalidHEAD) | |
| 82 | + | if err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, ErrInvalidHEAD) { | |
| 83 | + | return ports.SnapshotGeneration{}, fmt.Errorf("read snapshot HEAD: %w", err) | |
| 84 | + | } | |
| 85 | + | if preferred != 0 { | |
| 86 | + | got, err := r.loadGeneration(ctx, name, key, preferred) | |
| 87 | + | if err == nil && sha256.Sum256(got.Manifest) == preferredDigest { | |
| 88 | + | return got, nil | |
| 89 | + | } | |
| 90 | + | skipped = true | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | budget := maxDirectoryTraversalEntries | |
| 94 | + | candidates, err := r.generationCandidates(ctx, key, preferred, &budget) | |
| 95 | + | if err != nil { | |
| 96 | + | return ports.SnapshotGeneration{}, err | |
| 97 | + | } | |
| 98 | + | for _, generation := range candidates { | |
| 99 | + | got, err := r.loadGeneration(ctx, name, key, generation) | |
| 100 | + | if err != nil { | |
| 101 | + | skipped = true | |
| 102 | + | continue | |
| 103 | + | } | |
| 104 | + | got.Fallback = skipped || preferred != 0 | |
| 105 | + | return got, nil | |
| 106 | + | } | |
| 107 | + | return ports.SnapshotGeneration{}, fmt.Errorf("no complete snapshot generation for %q", name) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func (r *Repository) currentGeneration(ctx context.Context, name, key string) (uint64, []byte, error) { | |
| 111 | + | generation, digest, err := r.readHead(key) | |
| 112 | + | if err == nil { | |
| 113 | + | got, err := r.loadGeneration(ctx, name, key, generation) | |
| 114 | + | if err == nil && sha256.Sum256(got.Manifest) == digest { | |
| 115 | + | return generation, got.Manifest, nil | |
| 116 | + | } | |
| 117 | + | } else if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, ErrInvalidHEAD) { | |
| 118 | + | return 0, nil, fmt.Errorf("read snapshot HEAD: %w", err) | |
| 119 | + | } | |
| 120 | + | budget := maxDirectoryTraversalEntries | |
| 121 | + | candidates, err := r.generationCandidates(ctx, key, 0, &budget) | |
| 122 | + | if err != nil { | |
| 123 | + | return 0, nil, err | |
| 124 | + | } | |
| 125 | + | for _, generation := range candidates { | |
| 126 | + | got, err := r.loadGeneration(ctx, name, key, generation) | |
| 127 | + | if err == nil { | |
| 128 | + | return generation, got.Manifest, nil | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | return 0, nil, nil | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // loadNewestGeneration validates a single bounded candidate batch in strict | |
| 135 | + | // descending generation order. It is used by List, whose name is discovered | |
| 136 | + | // from each manifest rather than supplied by a caller. | |
| 137 | + | func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budget *int) (ports.SnapshotGeneration, bool, error) { | |
| 138 | + | candidates, err := r.generationCandidates(ctx, key, 0, budget) | |
| 139 | + | if err != nil { | |
| 140 | + | return ports.SnapshotGeneration{}, false, err | |
| 141 | + | } | |
| 142 | + | for _, generation := range candidates { | |
| 143 | + | data, err := r.readBounded(r.manifestPath(key, generation)) | |
| 144 | + | if err != nil { | |
| 145 | + | continue | |
| 146 | + | } | |
| 147 | + | manifest, err := codec.UnmarshalManifest(data) | |
| 148 | + | if err != nil || sessionKey(manifest.Name) != key { | |
| 149 | + | continue | |
| 150 | + | } | |
| 151 | + | killed, err := r.tombstoned(manifest.Name) | |
| 152 | + | if err != nil { | |
| 153 | + | return ports.SnapshotGeneration{}, false, fmt.Errorf("read killed session marker: %w", err) | |
| 154 | + | } | |
| 155 | + | if killed { | |
| 156 | + | return ports.SnapshotGeneration{}, false, nil | |
| 157 | + | } | |
| 158 | + | got, err := r.loadGeneration(ctx, manifest.Name, key, generation) | |
| 159 | + | if err == nil { | |
| 160 | + | return got, true, nil | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | return ports.SnapshotGeneration{}, false, nil | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // generationCandidates reads the generations directory once per load attempt. | |
| 167 | + | // The shared traversal budget bounds both retained names and work; sorting the | |
| 168 | + | // bounded batch preserves strict newest-to-oldest fallback without rescanning. | |
| 169 | + | func (r *Repository) generationCandidates(ctx context.Context, key string, exclude uint64, budget *int) ([]uint64, error) { | |
| 170 | + | candidates := make([]uint64, 0) | |
| 171 | + | err := r.walkDirectory(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget, func(entry os.DirEntry) error { | |
| 172 | + | if entry.IsDir() { | |
| 173 | + | return nil | |
| 174 | + | } | |
| 175 | + | generation, ok := parseGenerationFilename(entry.Name()) | |
| 176 | + | if ok && generation != exclude { | |
| 177 | + | candidates = append(candidates, generation) | |
| 178 | + | } | |
| 179 | + | return nil | |
| 180 | + | }) | |
| 181 | + | if errors.Is(err, os.ErrNotExist) { | |
| 182 | + | return candidates, nil | |
| 183 | + | } | |
| 184 | + | if err != nil { | |
| 185 | + | return nil, err | |
| 186 | + | } | |
| 187 | + | sort.Slice(candidates, func(i, j int) bool { return candidates[i] > candidates[j] }) | |
| 188 | + | return candidates, nil | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // walkDirectory is the common finite directory traversal primitive for reads. | |
| 192 | + | // File.ReadDir maintains a cursor on one descriptor and returns at most one | |
| 193 | + | // small batch, so no full directory enumeration is retained in memory. | |
| 194 | + | func (r *Repository) walkDirectory(ctx context.Context, dir string, budget *int, visit func(os.DirEntry) error) (err error) { | |
| 195 | + | file, err := r.openDirectory(dir) | |
| 196 | + | if err != nil { | |
| 197 | + | return err | |
| 198 | + | } | |
| 199 | + | defer func() { | |
| 200 | + | if closeErr := file.Close(); err == nil && closeErr != nil { | |
| 201 | + | err = closeErr | |
| 202 | + | } | |
| 203 | + | }() | |
| 204 | + | for { | |
| 205 | + | if err := ctx.Err(); err != nil { | |
| 206 | + | return err | |
| 207 | + | } | |
| 208 | + | entries, readErr := file.ReadDir(directoryTraversalBatch) | |
| 209 | + | for _, entry := range entries { | |
| 210 | + | if *budget == 0 { | |
| 211 | + | return ErrDirectoryTraversalBudget | |
| 212 | + | } | |
| 213 | + | *budget-- | |
| 214 | + | if err := visit(entry); err != nil { | |
| 215 | + | return err | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | if errors.Is(readErr, io.EOF) { | |
| 219 | + | return nil | |
| 220 | + | } | |
| 221 | + | if readErr != nil { | |
| 222 | + | return readErr | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func (r *Repository) loadGeneration(ctx context.Context, name, key string, generation uint64) (ports.SnapshotGeneration, error) { | |
| 228 | + | data, err := r.readBounded(r.manifestPath(key, generation)) | |
| 229 | + | if err != nil { | |
| 230 | + | return ports.SnapshotGeneration{}, err | |
| 231 | + | } | |
| 232 | + | manifest, err := codec.UnmarshalManifest(data) | |
| 233 | + | if err != nil || manifest.Name != name || manifest.Generation != generation { | |
| 234 | + | return ports.SnapshotGeneration{}, fmt.Errorf("invalid manifest") | |
| 235 | + | } | |
| 236 | + | refs := manifestRefs(manifest) | |
| 237 | + | if refs == nil || !withinGenerationBudget(len(data), refs) { | |
| 238 | + | return ports.SnapshotGeneration{}, fmt.Errorf("snapshot generation too large") | |
| 239 | + | } | |
| 240 | + | objects := make(map[ports.SnapshotDigest][]byte, len(refs)) | |
| 241 | + | for digest, ref := range refs { | |
| 242 | + | if err := ctx.Err(); err != nil { | |
| 243 | + | return ports.SnapshotGeneration{}, err | |
| 244 | + | } | |
| 245 | + | object, err := r.readBounded(r.objectPath(key, digest)) | |
| 246 | + | if err != nil { | |
| 247 | + | return ports.SnapshotGeneration{}, err | |
| 248 | + | } | |
| 249 | + | if sha256.Sum256(object) != digest || !validObject(object, ref) { | |
| 250 | + | return ports.SnapshotGeneration{}, fmt.Errorf("invalid object") | |
| 251 | + | } | |
| 252 | + | objects[digest] = object | |
| 253 | + | } | |
| 254 | + | return ports.SnapshotGeneration{Name: manifest.Name, Generation: generation, Manifest: data, Objects: objects}, nil | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func marshalHead(generation uint64, digest ports.SnapshotDigest) []byte { | |
| 258 | + | out := make([]byte, 4+8+sha256.Size) | |
| 259 | + | copy(out, "VEVH") | |
| 260 | + | binary.BigEndian.PutUint64(out[4:12], generation) | |
| 261 | + | copy(out[12:], digest[:]) | |
| 262 | + | return out | |
| 263 | + | } | |
| 264 | + | func (r *Repository) readHead(key string) (uint64, ports.SnapshotDigest, error) { | |
| 265 | + | path := r.headPath(key) | |
| 266 | + | if hook := r.hooks.beforeHeadRead; hook != nil { | |
| 267 | + | if err := hook(path); err != nil { | |
| 268 | + | return 0, ports.SnapshotDigest{}, err | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | data, err := r.readBounded(path) | |
| 272 | + | if err != nil { | |
| 273 | + | return 0, ports.SnapshotDigest{}, err | |
| 274 | + | } | |
| 275 | + | if len(data) != 4+8+sha256.Size || string(data[:4]) != "VEVH" { | |
| 276 | + | return 0, ports.SnapshotDigest{}, ErrInvalidHEAD | |
| 277 | + | } | |
| 278 | + | generation := binary.BigEndian.Uint64(data[4:12]) | |
| 279 | + | var digest ports.SnapshotDigest | |
| 280 | + | copy(digest[:], data[12:]) | |
| 281 | + | if generation == 0 { | |
| 282 | + | return 0, ports.SnapshotDigest{}, ErrInvalidHEAD | |
| 283 | + | } | |
| 284 | + | return generation, digest, nil | |
| 285 | + | } | |
A internal/adapters/snapshot/load_bounds_test.go +112 -0
| @@ -0,0 +1,112 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "testing" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestRepositoryListStopsAtTraversalBudget(t *testing.T) { | |
| 13 | + | repo := NewRepository(privateDir(t)) | |
| 14 | + | sessions := filepath.Join(repo.dir, repositorySessionsDir) | |
| 15 | + | if err := os.MkdirAll(sessions, 0o700); err != nil { | |
| 16 | + | t.Fatal(err) | |
| 17 | + | } | |
| 18 | + | for i := 0; i < maxDirectoryTraversalEntries+1; i++ { | |
| 19 | + | if err := os.Mkdir(filepath.Join(sessions, fmt.Sprintf("hostile-%05d", i)), 0o700); err != nil { | |
| 20 | + | t.Fatal(err) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | _, err := repo.List(context.Background()) | |
| 25 | + | if !errors.Is(err, ErrDirectoryTraversalBudget) { | |
| 26 | + | t.Fatalf("List error = %v, want directory traversal budget", err) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestRepositoryLoadStopsAtGenerationTraversalBudget(t *testing.T) { | |
| 31 | + | repo := NewRepository(privateDir(t)) | |
| 32 | + | key := sessionKey("named") | |
| 33 | + | generations := filepath.Join(repo.sessionPath(key), repositoryGenerations) | |
| 34 | + | if err := os.MkdirAll(generations, 0o700); err != nil { | |
| 35 | + | t.Fatal(err) | |
| 36 | + | } | |
| 37 | + | for generation := uint64(1); generation <= uint64(maxDirectoryTraversalEntries+1); generation++ { | |
| 38 | + | if err := os.WriteFile(filepath.Join(generations, generationFilename(generation)), nil, 0o600); err != nil { | |
| 39 | + | t.Fatal(err) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | _, err := repo.Load(context.Background(), "named") | |
| 44 | + | if !errors.Is(err, ErrDirectoryTraversalBudget) { | |
| 45 | + | t.Fatalf("Load error = %v, want directory traversal budget", err) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestRepositoryLoadFallsBackNewestToOldestWithoutGenerationEnumeration(t *testing.T) { | |
| 50 | + | repo := NewRepository(privateDir(t)) | |
| 51 | + | for generation := uint64(1); generation <= 3; generation++ { | |
| 52 | + | if err := repo.Publish(context.Background(), repositoryPublication(t, "named", generation, []byte(fmt.Sprintf("state-%d", generation)))); err != nil { | |
| 53 | + | t.Fatal(err) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | key := sessionKey("named") | |
| 57 | + | publication := repositoryPublication(t, "named", 3, []byte("state-3")) | |
| 58 | + | if err := os.Remove(repo.objectPath(key, publication.Objects[0].Digest)); err != nil { | |
| 59 | + | t.Fatal(err) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | got, err := repo.Load(context.Background(), "named") | |
| 63 | + | if err != nil { | |
| 64 | + | t.Fatal(err) | |
| 65 | + | } | |
| 66 | + | if got.Generation != 2 || !got.Fallback { | |
| 67 | + | t.Fatalf("Load = generation %d fallback %v, want generation 2 fallback true", got.Generation, got.Fallback) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestRepositoryMaintainDoesNotCollectBeyondRetainedMetadataBudget(t *testing.T) { | |
| 72 | + | repo := NewRepository(privateDir(t)) | |
| 73 | + | for generation := uint64(1); generation <= uint64(maxMaintenanceMarkedGenerations+1); generation++ { | |
| 74 | + | if err := repo.Publish(context.Background(), repositoryPublication(t, "named", generation, []byte(fmt.Sprintf("state-%d", generation)))); err != nil { | |
| 75 | + | t.Fatal(err) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | for pass := 0; pass < maxMaintenanceMarkedGenerations*3; pass++ { | |
| 79 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 80 | + | t.Fatal(err) | |
| 81 | + | } | |
| 82 | + | if repo.maintenanceSessions[sessionKey("named")] == nil { | |
| 83 | + | break | |
| 84 | + | } | |
| 85 | + | if pass == maxMaintenanceMarkedGenerations*3-1 { | |
| 86 | + | t.Fatal("maintenance did not complete after bounded object-shard traversal") | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | if _, err := os.Lstat(repo.manifestPath(sessionKey("named"), 1)); err != nil { | |
| 90 | + | t.Fatalf("old manifest collected after mark budget overflow: %v", err) | |
| 91 | + | } | |
| 92 | + | if state := repo.maintenanceSessions[sessionKey("named")]; state != nil { | |
| 93 | + | t.Fatalf("overflow maintenance state retained = %#v, want reset", state) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestMaintenanceMarkStateIsCapped(t *testing.T) { | |
| 98 | + | references := &sessionMaintenance{marked: make(map[uint64]manifestMaintenance)} | |
| 99 | + | if !references.canRetainManifest(maxMaintenanceReferences) { | |
| 100 | + | t.Fatal("state rejected references at its documented ceiling") | |
| 101 | + | } | |
| 102 | + | if references.canRetainManifest(1) { | |
| 103 | + | t.Fatal("state accepted references beyond its documented ceiling") | |
| 104 | + | } | |
| 105 | + | generations := &sessionMaintenance{marked: make(map[uint64]manifestMaintenance)} | |
| 106 | + | for generation := uint64(1); generation <= maxMaintenanceMarkedGenerations; generation++ { | |
| 107 | + | generations.marked[generation] = manifestMaintenance{} | |
| 108 | + | } | |
| 109 | + | if generations.canRetainManifest(0) { | |
| 110 | + | t.Fatal("state accepted generations beyond its documented ceiling") | |
| 111 | + | } | |
| 112 | + | } | |
A internal/adapters/snapshot/load_major_test.go +88 -0
| @@ -0,0 +1,88 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "syscall" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestRepositoryLoadPropagatesOperationalHeadErrors(t *testing.T) { | |
| 13 | + | repo := NewRepository(privateDir(t)) | |
| 14 | + | require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state")))) | |
| 15 | + | ||
| 16 | + | repo.hooks.beforeHeadRead = func(string) error { return os.ErrPermission } | |
| 17 | + | ||
| 18 | + | _, err := repo.Load(context.Background(), "named") | |
| 19 | + | require.ErrorIs(t, err, os.ErrPermission) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestRepositoryCurrentGenerationPropagatesOperationalHeadErrors(t *testing.T) { | |
| 23 | + | repo := NewRepository(privateDir(t)) | |
| 24 | + | require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state")))) | |
| 25 | + | ||
| 26 | + | repo.hooks.beforeHeadRead = func(string) error { return syscall.EIO } | |
| 27 | + | ||
| 28 | + | _, _, err := repo.currentGeneration(context.Background(), "named", sessionKey("named")) | |
| 29 | + | require.ErrorIs(t, err, syscall.EIO) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestRepositoryLoadFallsBackFromInvalidHead(t *testing.T) { | |
| 33 | + | repo := NewRepository(privateDir(t)) | |
| 34 | + | require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state")))) | |
| 35 | + | require.NoError(t, os.WriteFile(repo.headPath(sessionKey("named")), []byte("invalid"), 0o600)) | |
| 36 | + | ||
| 37 | + | got, err := repo.Load(context.Background(), "named") | |
| 38 | + | require.NoError(t, err) | |
| 39 | + | require.Equal(t, uint64(1), got.Generation) | |
| 40 | + | require.True(t, got.Fallback) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestRepositoryLoadFallsBackAcrossMultipleCorruptCandidates(t *testing.T) { | |
| 44 | + | repo := NewRepository(privateDir(t)) | |
| 45 | + | for generation := uint64(1); generation <= 4; generation++ { | |
| 46 | + | require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", generation, []byte{byte(generation)}))) | |
| 47 | + | } | |
| 48 | + | key := sessionKey("named") | |
| 49 | + | for _, generation := range []uint64{4, 3} { | |
| 50 | + | publication := repositoryPublication(t, "named", generation, []byte{byte(generation)}) | |
| 51 | + | require.NoError(t, os.Remove(repo.objectPath(key, publication.Objects[0].Digest))) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | got, err := repo.Load(context.Background(), "named") | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | require.Equal(t, uint64(2), got.Generation) | |
| 57 | + | require.True(t, got.Fallback) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestRepositoryLoadHoldsSessionLockAcrossTombstoneCheck(t *testing.T) { | |
| 61 | + | repo := NewRepository(privateDir(t)) | |
| 62 | + | require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state")))) | |
| 63 | + | ||
| 64 | + | tombstoneCheckStarted := make(chan struct{}) | |
| 65 | + | releaseTombstoneCheck := make(chan struct{}) | |
| 66 | + | repo.hooks.beforeTombstoneCheck = func(string) { | |
| 67 | + | close(tombstoneCheckStarted) | |
| 68 | + | <-releaseTombstoneCheck | |
| 69 | + | } | |
| 70 | + | loadDone := make(chan error, 1) | |
| 71 | + | go func() { | |
| 72 | + | _, err := repo.Load(context.Background(), "named") | |
| 73 | + | loadDone <- err | |
| 74 | + | }() | |
| 75 | + | <-tombstoneCheckStarted | |
| 76 | + | ||
| 77 | + | tombstoneDone := make(chan error, 1) | |
| 78 | + | go func() { tombstoneDone <- repo.Tombstone(context.Background(), "named") }() | |
| 79 | + | select { | |
| 80 | + | case err := <-tombstoneDone: | |
| 81 | + | t.Fatalf("Tombstone completed before Load released the session lock: %v", err) | |
| 82 | + | default: | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | close(releaseTombstoneCheck) | |
| 86 | + | require.NoError(t, <-loadDone) | |
| 87 | + | require.NoError(t, <-tombstoneDone) | |
| 88 | + | } | |
A internal/adapters/snapshot/maintenance.go +129 -0
| @@ -0,0 +1,129 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | const maintenanceBatch = 64 | |
| 12 | + | ||
| 13 | + | // These caps bound metadata retained across resumable GC passes. If a session | |
| 14 | + | // exceeds either cap, marking remains conservative and no destructive sweep or | |
| 15 | + | // generation collection occurs in that cycle. | |
| 16 | + | const ( | |
| 17 | + | maxMaintenanceMarkedGenerations = 128 | |
| 18 | + | maxMaintenanceReferences = 8192 | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // Maintain reaps a bounded amount of stale state. Continuation handles are | |
| 22 | + | // repository-owned, so a stable prefix cannot starve entries later in a large | |
| 23 | + | // directory. Cancellation and errors discard those handles. | |
| 24 | + | func (r *Repository) Maintain(ctx context.Context) (err error) { | |
| 25 | + | r.maintenanceMu.Lock() | |
| 26 | + | defer r.maintenanceMu.Unlock() | |
| 27 | + | if err := ctx.Err(); err != nil { | |
| 28 | + | r.resetMaintenance() | |
| 29 | + | return err | |
| 30 | + | } | |
| 31 | + | defer func() { | |
| 32 | + | if err != nil { | |
| 33 | + | r.resetMaintenance() | |
| 34 | + | } | |
| 35 | + | }() | |
| 36 | + | if r.maintenanceCursors == nil { | |
| 37 | + | r.maintenanceCursors = make(map[string]*maintenanceCursor) | |
| 38 | + | r.maintenanceSessions = make(map[string]*sessionMaintenance) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | budget := maintenanceBatch | |
| 42 | + | if _, err := r.removeTemps(ctx, r.dir, &budget, "root-temps"); err != nil || budget == 0 { | |
| 43 | + | return err | |
| 44 | + | } | |
| 45 | + | if r.maintenanceQuarantine != nil { | |
| 46 | + | changed, done, err := r.maintainQuarantine(ctx, &budget) | |
| 47 | + | if err != nil { | |
| 48 | + | return err | |
| 49 | + | } | |
| 50 | + | if changed { | |
| 51 | + | if err := r.syncDirectory(filepath.Join(r.dir, repositorySessionsDir)); err != nil { | |
| 52 | + | return fmt.Errorf("sync snapshot maintenance directory: %w", safeFilesystemError(err)) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | if done { | |
| 56 | + | r.maintenanceQuarantine = nil | |
| 57 | + | } | |
| 58 | + | return nil | |
| 59 | + | } | |
| 60 | + | // Resume an incomplete session before discovering another one. This keeps | |
| 61 | + | // retained marks (and their session lock/epoch references) bounded even | |
| 62 | + | // when a hostile repository contains arbitrarily many partial sessions. | |
| 63 | + | for key := range r.maintenanceSessions { | |
| 64 | + | lock := r.lockSession(key) | |
| 65 | + | err = r.maintainSession(ctx, key, &budget) | |
| 66 | + | r.unlockSession(lock) | |
| 67 | + | return err | |
| 68 | + | } | |
| 69 | + | sessions := filepath.Join(r.dir, repositorySessionsDir) | |
| 70 | + | entries, _, err := r.readMaintenanceDir(sessions, maintenanceBatch, "sessions") | |
| 71 | + | if errors.Is(err, os.ErrNotExist) { | |
| 72 | + | return nil | |
| 73 | + | } | |
| 74 | + | if err != nil { | |
| 75 | + | return fmt.Errorf("read snapshot maintenance sessions: %w", safeFilesystemError(err)) | |
| 76 | + | } | |
| 77 | + | for i, entry := range entries { | |
| 78 | + | if budget == 0 { | |
| 79 | + | r.requeueMaintenanceEntries(sessions, "sessions", entries[i:]) | |
| 80 | + | return nil | |
| 81 | + | } | |
| 82 | + | if err := ctx.Err(); err != nil { | |
| 83 | + | return err | |
| 84 | + | } | |
| 85 | + | path := filepath.Join(sessions, entry.name) | |
| 86 | + | if isQuarantine(entry.name) { | |
| 87 | + | r.maintenanceQuarantine = &quarantineMaintenance{root: path, current: path} | |
| 88 | + | changed, done, err := r.maintainQuarantine(ctx, &budget) | |
| 89 | + | if err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 90 | + | return fmt.Errorf("remove snapshot quarantine %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 91 | + | } | |
| 92 | + | if changed { | |
| 93 | + | if err := ctx.Err(); err != nil { | |
| 94 | + | return err | |
| 95 | + | } | |
| 96 | + | if err := r.syncDirectory(sessions); err != nil { | |
| 97 | + | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | if done { | |
| 101 | + | r.maintenanceQuarantine = nil | |
| 102 | + | } | |
| 103 | + | if i+1 < len(entries) { | |
| 104 | + | r.requeueMaintenanceEntries(sessions, "sessions", entries[i+1:]) | |
| 105 | + | } | |
| 106 | + | return nil | |
| 107 | + | } | |
| 108 | + | if !entry.isDir || !canonicalSessionKey(entry.name) { | |
| 109 | + | continue | |
| 110 | + | } | |
| 111 | + | lock := r.lockSession(entry.name) | |
| 112 | + | err = r.maintainSession(ctx, entry.name, &budget) | |
| 113 | + | r.unlockSession(lock) | |
| 114 | + | if err != nil { | |
| 115 | + | return err | |
| 116 | + | } | |
| 117 | + | if r.maintenanceSessions[entry.name] != nil { | |
| 118 | + | return nil | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | return nil | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // maintenanceCursor is deliberately reopenable metadata, not an open file. | |
| 125 | + | // On Linux, d_off is the seek cookie returned by getdents(2); seeking a newly | |
| 126 | + | // opened descriptor to it resumes the directory stream. Go's File.ReadDir | |
| 127 | + | // cannot be used here: its 8 KiB internal getdents buffer advances the kernel | |
| 128 | + | // offset beyond a short ReadDir result (see os/dir_unix.go), so saving SeekCur | |
| 129 | + | // after ReadDir would skip buffered entries. | |
A internal/adapters/snapshot/maintenance_cursor.go +285 -0
| @@ -0,0 +1,285 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strings" | |
| 11 | + | "syscall" | |
| 12 | + | "unsafe" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // maxMaintenanceCursors bounds all retained directory seek cookies, including | |
| 16 | + | // cursors for attacker-controlled object shards. Maintenance processes only | |
| 17 | + | // one shard at a time, so normal operation stays well below this ceiling. | |
| 18 | + | const maxMaintenanceCursors = 8 | |
| 19 | + | ||
| 20 | + | var errMaintenanceCursorLimit = errors.New("snapshot maintenance cursor limit") | |
| 21 | + | ||
| 22 | + | type maintenanceCursor struct { | |
| 23 | + | offset int64 | |
| 24 | + | pending []maintenanceDirEntry | |
| 25 | + | done bool | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | type maintenanceDirEntry struct { | |
| 29 | + | name string | |
| 30 | + | isDir bool | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func (r *Repository) resetMaintenance() { | |
| 34 | + | for key := range r.maintenanceSessions { | |
| 35 | + | r.clearSessionMaintenance(key) | |
| 36 | + | } | |
| 37 | + | r.maintenanceCursors = nil | |
| 38 | + | r.maintenanceSessions = nil | |
| 39 | + | r.maintenanceQuarantine = nil | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func maintenanceCursorID(dir, purpose string) string { return purpose + "\x00" + dir } | |
| 43 | + | ||
| 44 | + | // readMaintenanceDir advances one named directory cursor. Its continuation is | |
| 45 | + | // only a Linux seek cookie and at most one call's worth of pending names. The | |
| 46 | + | // descriptor used to read a call is always closed before this method returns. | |
| 47 | + | // Callers must return entries they could not process with | |
| 48 | + | // requeueMaintenanceEntries before yielding for budget exhaustion. | |
| 49 | + | func (r *Repository) readMaintenanceDir(dir string, n int, purpose string) ([]maintenanceDirEntry, bool, error) { | |
| 50 | + | id := maintenanceCursorID(dir, purpose) | |
| 51 | + | cursor := r.maintenanceCursors[id] | |
| 52 | + | if cursor != nil && len(cursor.pending) != 0 { | |
| 53 | + | count := min(n, len(cursor.pending)) | |
| 54 | + | entries := cursor.pending[:count] | |
| 55 | + | cursor.pending = cursor.pending[count:] | |
| 56 | + | done := cursor.done && len(cursor.pending) == 0 | |
| 57 | + | if done { | |
| 58 | + | delete(r.maintenanceCursors, id) | |
| 59 | + | } | |
| 60 | + | return entries, done, nil | |
| 61 | + | } | |
| 62 | + | if cursor == nil { | |
| 63 | + | if len(r.maintenanceCursors) >= maxMaintenanceCursors { | |
| 64 | + | return nil, false, errMaintenanceCursorLimit | |
| 65 | + | } | |
| 66 | + | cursor = &maintenanceCursor{} | |
| 67 | + | r.maintenanceCursors[id] = cursor | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | entries, done, err := r.readMaintenanceDirent(dir, n, cursor) | |
| 71 | + | if err != nil { | |
| 72 | + | delete(r.maintenanceCursors, id) | |
| 73 | + | return nil, false, err | |
| 74 | + | } | |
| 75 | + | if done { | |
| 76 | + | delete(r.maintenanceCursors, id) | |
| 77 | + | } | |
| 78 | + | return entries, done, nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // maintenanceDirectory is the small syscall surface needed to resume a | |
| 82 | + | // bounded getdents cursor. It permits deterministic fault injection without | |
| 83 | + | // retaining directory descriptors between maintenance passes. | |
| 84 | + | type maintenanceDirectory interface { | |
| 85 | + | Seek(int64, int) (int64, error) | |
| 86 | + | ReadDirent([]byte) (int, error) | |
| 87 | + | Close() error | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | type osMaintenanceDirectory struct{ file *os.File } | |
| 91 | + | ||
| 92 | + | func (d osMaintenanceDirectory) Seek(offset int64, whence int) (int64, error) { | |
| 93 | + | return syscall.Seek(int(d.file.Fd()), offset, whence) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func (d osMaintenanceDirectory) ReadDirent(buffer []byte) (int, error) { | |
| 97 | + | return syscall.ReadDirent(int(d.file.Fd()), buffer) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func (d osMaintenanceDirectory) Close() error { return d.file.Close() } | |
| 101 | + | ||
| 102 | + | func (r *Repository) openMaintenanceDirectory(dir string) (maintenanceDirectory, error) { | |
| 103 | + | if open := r.hooks.openMaintenanceDirectory; open != nil { | |
| 104 | + | return open(dir) | |
| 105 | + | } | |
| 106 | + | file, err := r.openDirectory(dir) | |
| 107 | + | if err != nil { | |
| 108 | + | return nil, err | |
| 109 | + | } | |
| 110 | + | return osMaintenanceDirectory{file: file}, nil | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // maintenanceDirectoryError adds stable operation context and removes any | |
| 114 | + | // filesystem path while retaining the underlying cause for errors.Is. | |
| 115 | + | func maintenanceDirectoryError(operation string, err error) error { | |
| 116 | + | return fmt.Errorf("%s: %w", operation, safeFilesystemError(err)) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // readMaintenanceDirent saves a resumable directory cursor. Linux d_off lets | |
| 120 | + | // a large getdents batch stop at limit. Darwin's syscall.ReadDirent instead | |
| 121 | + | // stores an entry count in the descriptor offset, so every returned buffer | |
| 122 | + | // must be drained before that descriptor-maintained count is saved. | |
| 123 | + | func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *maintenanceCursor) (entries []maintenanceDirEntry, done bool, err error) { | |
| 124 | + | return r.readMaintenanceDirentWithDrain(dir, limit, cursor, drainMaintenanceDirentBatch(), directoryCookie) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // readMaintenanceDirentWithDrain contains the shared traversal logic. The | |
| 128 | + | // explicit parameters permit platform-independent verification of Darwin's | |
| 129 | + | // buffer-draining semantics with deterministic fake directories. | |
| 130 | + | func (r *Repository) readMaintenanceDirentWithDrain(dir string, limit int, cursor *maintenanceCursor, drainBuffer bool, cookie func(maintenanceDirectory, *syscall.Dirent) (int64, error)) (entries []maintenanceDirEntry, done bool, err error) { | |
| 131 | + | file, err := r.openMaintenanceDirectory(dir) | |
| 132 | + | if err != nil { | |
| 133 | + | return nil, false, maintenanceDirectoryError("open maintenance directory", err) | |
| 134 | + | } | |
| 135 | + | defer func() { | |
| 136 | + | if closeErr := file.Close(); closeErr != nil { | |
| 137 | + | closeErr = maintenanceDirectoryError("close maintenance directory", closeErr) | |
| 138 | + | if err != nil { | |
| 139 | + | err = errors.Join(err, closeErr) | |
| 140 | + | } else { | |
| 141 | + | entries = nil | |
| 142 | + | done = false | |
| 143 | + | err = closeErr | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | }() | |
| 147 | + | if cursor.offset != 0 { | |
| 148 | + | if _, err := file.Seek(cursor.offset, io.SeekStart); err != nil { | |
| 149 | + | return nil, false, maintenanceDirectoryError("seek maintenance directory", err) | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | entries = make([]maintenanceDirEntry, 0, limit) | |
| 154 | + | buffer := make([]byte, maintenanceDirentBufferSize) | |
| 155 | + | for { | |
| 156 | + | count, err := file.ReadDirent(buffer) | |
| 157 | + | if err != nil { | |
| 158 | + | return nil, false, maintenanceDirectoryError("read maintenance directory", err) | |
| 159 | + | } | |
| 160 | + | if count < 0 || count > len(buffer) { | |
| 161 | + | return nil, false, syscall.EIO | |
| 162 | + | } | |
| 163 | + | if count == 0 { | |
| 164 | + | return entries, true, nil | |
| 165 | + | } | |
| 166 | + | for data := buffer[:count]; len(data) != 0 && (len(entries) < limit || drainBuffer); { | |
| 167 | + | record, nameBytes, reclen, decodeErr := decodeMaintenanceDirent(data) | |
| 168 | + | if decodeErr != nil { | |
| 169 | + | return nil, false, decodeErr | |
| 170 | + | } | |
| 171 | + | data = data[reclen:] | |
| 172 | + | // Advance past every raw record, including dot and disappeared entries. | |
| 173 | + | offset, cookieErr := cookie(file, record) | |
| 174 | + | if cookieErr != nil { | |
| 175 | + | return nil, false, maintenanceDirectoryError("tell maintenance directory", cookieErr) | |
| 176 | + | } | |
| 177 | + | cursor.offset = offset | |
| 178 | + | if end := strings.IndexByte(string(nameBytes), 0); end >= 0 { | |
| 179 | + | nameBytes = nameBytes[:end] | |
| 180 | + | } | |
| 181 | + | name := string(nameBytes) | |
| 182 | + | if name == "." || name == ".." || name == "" { | |
| 183 | + | continue | |
| 184 | + | } | |
| 185 | + | stat, err := r.stat(filepath.Join(dir, name)) | |
| 186 | + | if errors.Is(err, os.ErrNotExist) { | |
| 187 | + | continue | |
| 188 | + | } | |
| 189 | + | if err != nil { | |
| 190 | + | // A final symlink is not traversed and is treated as a non-directory; | |
| 191 | + | // deletion remains dirfd-relative and therefore unlinks only the link. | |
| 192 | + | if errors.Is(err, syscall.ELOOP) { | |
| 193 | + | entries = append(entries, maintenanceDirEntry{name: name}) | |
| 194 | + | continue | |
| 195 | + | } | |
| 196 | + | return nil, false, maintenanceDirectoryError("stat maintenance directory entry", err) | |
| 197 | + | } | |
| 198 | + | entries = append(entries, maintenanceDirEntry{name: name, isDir: stat.Mode&syscall.S_IFMT == syscall.S_IFDIR}) | |
| 199 | + | } | |
| 200 | + | if len(entries) >= limit { | |
| 201 | + | if drainBuffer && len(entries) > limit { | |
| 202 | + | // Darwin has already advanced the descriptor past this entire | |
| 203 | + | // buffer, so retain only this final buffer's excess. | |
| 204 | + | cursor.pending = append(cursor.pending, entries[limit:]...) | |
| 205 | + | } | |
| 206 | + | return entries[:limit], false, nil | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // decodeMaintenanceDirent copies a bounded raw record into aligned storage | |
| 212 | + | // before reading its fields. syscall.ReadDirent writes to a byte buffer, whose | |
| 213 | + | // start and record boundaries are not guaranteed to satisfy syscall.Dirent's | |
| 214 | + | // alignment requirements on Darwin. | |
| 215 | + | func decodeMaintenanceDirent(data []byte) (*syscall.Dirent, []byte, int, error) { | |
| 216 | + | nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name)) | |
| 217 | + | if len(data) < nameOffset { | |
| 218 | + | return nil, nil, 0, syscall.EIO | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | var record syscall.Dirent | |
| 222 | + | header := unsafe.Slice((*byte)(unsafe.Pointer(&record)), nameOffset) | |
| 223 | + | copy(header, data[:nameOffset]) | |
| 224 | + | reclen := int(record.Reclen) | |
| 225 | + | if reclen < nameOffset || reclen > len(data) || reclen > int(unsafe.Sizeof(record)) { | |
| 226 | + | return nil, nil, 0, syscall.EIO | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | recordBytes := unsafe.Slice((*byte)(unsafe.Pointer(&record)), reclen) | |
| 230 | + | copy(recordBytes, data[:reclen]) | |
| 231 | + | name := unsafe.Slice((*byte)(unsafe.Pointer(&record.Name[0])), reclen-nameOffset) | |
| 232 | + | return &record, name, reclen, nil | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func (r *Repository) requeueMaintenanceEntries(dir, purpose string, entries []maintenanceDirEntry) { | |
| 236 | + | if len(entries) == 0 { | |
| 237 | + | return | |
| 238 | + | } | |
| 239 | + | id := maintenanceCursorID(dir, purpose) | |
| 240 | + | cursor := r.maintenanceCursors[id] | |
| 241 | + | if cursor == nil { | |
| 242 | + | cursor = &maintenanceCursor{done: true} | |
| 243 | + | r.maintenanceCursors[id] = cursor | |
| 244 | + | } | |
| 245 | + | cursor.pending = append(entries, cursor.pending...) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func (r *Repository) removeTemps(ctx context.Context, dir string, budget *int, purpose string) (bool, error) { | |
| 249 | + | if *budget == 0 { | |
| 250 | + | return false, nil | |
| 251 | + | } | |
| 252 | + | entries, done, err := r.readMaintenanceDir(dir, maintenanceBatch, purpose) | |
| 253 | + | if errors.Is(err, os.ErrNotExist) { | |
| 254 | + | return true, nil | |
| 255 | + | } | |
| 256 | + | if err != nil { | |
| 257 | + | return false, fmt.Errorf("read snapshot maintenance directory: %w", safeFilesystemError(err)) | |
| 258 | + | } | |
| 259 | + | for i, entry := range entries { | |
| 260 | + | if *budget == 0 { | |
| 261 | + | r.requeueMaintenanceEntries(dir, purpose, entries[i:]) | |
| 262 | + | return false, nil | |
| 263 | + | } | |
| 264 | + | if err := ctx.Err(); err != nil { | |
| 265 | + | return false, err | |
| 266 | + | } | |
| 267 | + | if entry.isDir || !strings.HasPrefix(entry.name, ".tmp-") { | |
| 268 | + | continue | |
| 269 | + | } | |
| 270 | + | path := filepath.Join(dir, entry.name) | |
| 271 | + | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 272 | + | return false, fmt.Errorf("remove snapshot maintenance file %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 273 | + | } | |
| 274 | + | if err := ctx.Err(); err != nil { | |
| 275 | + | return false, err | |
| 276 | + | } | |
| 277 | + | if err := r.syncDirectory(dir); err != nil { | |
| 278 | + | return false, fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 279 | + | } | |
| 280 | + | *budget-- | |
| 281 | + | } | |
| 282 | + | return done, nil | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // maintenancePath avoids exposing the repository root while retaining a | |
A internal/adapters/snapshot/maintenance_delete.go +199 -0
| @@ -0,0 +1,199 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strings" | |
| 11 | + | "syscall" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Delete makes a session unavailable by durably moving it out of the canonical | |
| 15 | + | // namespace. Maintain reaps the private quarantine later; Delete never restores | |
| 16 | + | // a quarantined directory. | |
| 17 | + | func (r *Repository) Delete(ctx context.Context, name string) error { | |
| 18 | + | if err := ctx.Err(); err != nil { | |
| 19 | + | return err | |
| 20 | + | } | |
| 21 | + | key := sessionKey(name) | |
| 22 | + | lock := r.lockSession(key) | |
| 23 | + | defer r.unlockSession(lock) | |
| 24 | + | if err := ctx.Err(); err != nil { | |
| 25 | + | return err | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | canonical := r.sessionPath(key) | |
| 29 | + | sessions := filepath.Dir(canonical) | |
| 30 | + | // A prior rename may have succeeded while its parent sync failed. Complete | |
| 31 | + | // that durability boundary before considering a canonical directory: this | |
| 32 | + | // also leaves a newly recreated session untouched. | |
| 33 | + | pending, err := r.pendingQuarantine(sessions, key) | |
| 34 | + | if err != nil { | |
| 35 | + | return fmt.Errorf("stat deleting snapshot session %q: %w", key, safeFilesystemError(err)) | |
| 36 | + | } | |
| 37 | + | if pending { | |
| 38 | + | if err := ctx.Err(); err != nil { | |
| 39 | + | return err | |
| 40 | + | } | |
| 41 | + | if err := r.syncDirectory(sessions); err != nil { | |
| 42 | + | return fmt.Errorf("sync deleted snapshot session directory %q: %w", key, safeFilesystemError(err)) | |
| 43 | + | } | |
| 44 | + | return nil | |
| 45 | + | } | |
| 46 | + | if dir, err := r.openDirectory(canonical); errors.Is(err, os.ErrNotExist) { | |
| 47 | + | return nil | |
| 48 | + | } else if err != nil { | |
| 49 | + | return fmt.Errorf("stat snapshot session %q: %w", key, safeFilesystemError(err)) | |
| 50 | + | } else if err := dir.Close(); err != nil { | |
| 51 | + | return fmt.Errorf("close snapshot session %q: %w", key, safeFilesystemError(err)) | |
| 52 | + | } | |
| 53 | + | quarantine := filepath.Join(sessions, deletingSessionName(key)) | |
| 54 | + | if err := ctx.Err(); err != nil { | |
| 55 | + | return err | |
| 56 | + | } | |
| 57 | + | // A renamed session can be replaced by a later publication. Invalidate any | |
| 58 | + | // mark made for the old namespace before that replacement becomes possible. | |
| 59 | + | r.invalidateStorageEpoch(key) | |
| 60 | + | if err := r.rename(canonical, quarantine); err != nil { | |
| 61 | + | return fmt.Errorf("quarantine snapshot session %q: %w", key, safeFilesystemError(err)) | |
| 62 | + | } | |
| 63 | + | if err := ctx.Err(); err != nil { | |
| 64 | + | return err | |
| 65 | + | } | |
| 66 | + | if err := r.syncDirectory(sessions); err != nil { | |
| 67 | + | return fmt.Errorf("sync deleted snapshot session directory %q: %w", key, safeFilesystemError(err)) | |
| 68 | + | } | |
| 69 | + | return nil | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // deletingSessionName is a deterministic pending-delete record. Its presence | |
| 73 | + | // is checked directly rather than scanning an attacker-controlled sessions | |
| 74 | + | // directory, and it prevents a retry from deleting a session republished after | |
| 75 | + | // the initial rename reached disk but its directory sync failed. | |
| 76 | + | func deletingSessionName(key string) string { return ".deleting-" + key } | |
| 77 | + | ||
| 78 | + | func (r *Repository) pendingQuarantine(dir, key string) (bool, error) { | |
| 79 | + | path := filepath.Join(dir, deletingSessionName(key)) | |
| 80 | + | if hook := r.hooks.beforePendingQuarantineCheck; hook != nil { | |
| 81 | + | hook(path) | |
| 82 | + | } | |
| 83 | + | file, err := r.openDirectory(path) | |
| 84 | + | if errors.Is(err, os.ErrNotExist) { | |
| 85 | + | return false, nil | |
| 86 | + | } | |
| 87 | + | if err != nil { | |
| 88 | + | return false, err | |
| 89 | + | } | |
| 90 | + | return true, file.Close() | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // Maintain reaps a bounded amount of stale state. Continuation handles are | |
| 94 | + | // repository-owned, so a stable prefix cannot starve entries later in a large | |
| 95 | + | // directory. Cancellation and errors discard those handles: a restarted | |
| 96 | + | // repository (and the next fresh cycle) always begins at a safe boundary. | |
| 97 | + | func isQuarantine(name string) bool { return strings.HasPrefix(name, ".deleting-") } | |
| 98 | + | ||
| 99 | + | // quarantineMaintenance retains one root-to-current path, never an unbounded | |
| 100 | + | // stack of directory handles or names. Parent paths are recovered after a | |
| 101 | + | // removal, while descent resumes from current on the next budgeted step. | |
| 102 | + | type quarantineMaintenance struct { | |
| 103 | + | root string | |
| 104 | + | current string | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // consumeQuarantineWork charges every filesystem operation that can be driven | |
| 108 | + | // by quarantine contents. This makes deep and wide trees subject to exactly | |
| 109 | + | // the same maintenanceBatch ceiling as removal work. | |
| 110 | + | func (r *Repository) consumeQuarantineWork(budget *int, operation string) bool { | |
| 111 | + | if *budget == 0 { | |
| 112 | + | return false | |
| 113 | + | } | |
| 114 | + | *budget-- | |
| 115 | + | if hook := r.hooks.beforeMaintenanceWork; hook != nil { | |
| 116 | + | hook(operation) | |
| 117 | + | } | |
| 118 | + | return true | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // maintainQuarantine makes one bounded depth-first traversal step at a time. | |
| 122 | + | // It deliberately reopens a directory to select its first child: every | |
| 123 | + | // successful child removal changes that prefix, so no directory cursor is | |
| 124 | + | // retained at each hostile nesting level. | |
| 125 | + | func (r *Repository) maintainQuarantine(ctx context.Context, budget *int) (changed, done bool, err error) { | |
| 126 | + | state := r.maintenanceQuarantine | |
| 127 | + | if state == nil { | |
| 128 | + | return false, true, nil | |
| 129 | + | } | |
| 130 | + | for *budget > 0 { | |
| 131 | + | if err := ctx.Err(); err != nil { | |
| 132 | + | return changed, false, err | |
| 133 | + | } | |
| 134 | + | if !r.consumeQuarantineWork(budget, "stat") { | |
| 135 | + | return changed, false, nil | |
| 136 | + | } | |
| 137 | + | stat, err := r.stat(state.current) | |
| 138 | + | if errors.Is(err, os.ErrNotExist) { | |
| 139 | + | if state.current == state.root { | |
| 140 | + | return changed, true, nil | |
| 141 | + | } | |
| 142 | + | state.current = filepath.Dir(state.current) | |
| 143 | + | continue | |
| 144 | + | } | |
| 145 | + | if err != nil && !errors.Is(err, syscall.ELOOP) { | |
| 146 | + | return changed, false, err | |
| 147 | + | } | |
| 148 | + | if err != nil || stat.Mode&syscall.S_IFMT != syscall.S_IFDIR { | |
| 149 | + | if !r.consumeQuarantineWork(budget, "remove") { | |
| 150 | + | return changed, false, nil | |
| 151 | + | } | |
| 152 | + | if err := r.remove(state.current); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 153 | + | return changed, false, err | |
| 154 | + | } | |
| 155 | + | changed = true | |
| 156 | + | state.current = filepath.Dir(state.current) | |
| 157 | + | continue | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | if !r.consumeQuarantineWork(budget, "open") { | |
| 161 | + | return changed, false, nil | |
| 162 | + | } | |
| 163 | + | dir, err := r.openDirectory(state.current) | |
| 164 | + | if err != nil { | |
| 165 | + | return changed, false, err | |
| 166 | + | } | |
| 167 | + | if !r.consumeQuarantineWork(budget, "read") { | |
| 168 | + | _ = dir.Close() | |
| 169 | + | return changed, false, nil | |
| 170 | + | } | |
| 171 | + | entries, readErr := dir.ReadDir(1) | |
| 172 | + | closeErr := dir.Close() | |
| 173 | + | if readErr != nil && !errors.Is(readErr, io.EOF) { | |
| 174 | + | return changed, false, readErr | |
| 175 | + | } | |
| 176 | + | if closeErr != nil { | |
| 177 | + | return changed, false, closeErr | |
| 178 | + | } | |
| 179 | + | if len(entries) == 0 { | |
| 180 | + | if !r.consumeQuarantineWork(budget, "remove") { | |
| 181 | + | return changed, false, nil | |
| 182 | + | } | |
| 183 | + | if err := r.remove(state.current); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 184 | + | return changed, false, err | |
| 185 | + | } | |
| 186 | + | changed = true | |
| 187 | + | if state.current == state.root { | |
| 188 | + | return changed, true, nil | |
| 189 | + | } | |
| 190 | + | state.current = filepath.Dir(state.current) | |
| 191 | + | continue | |
| 192 | + | } | |
| 193 | + | if !r.consumeQuarantineWork(budget, "descend") { | |
| 194 | + | return changed, false, nil | |
| 195 | + | } | |
| 196 | + | state.current = filepath.Join(state.current, entries[0].Name()) | |
| 197 | + | } | |
| 198 | + | return changed, false, nil | |
| 199 | + | } | |
A internal/adapters/snapshot/maintenance_error.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func maintenancePath(root, path string) string { | |
| 11 | + | rel, err := filepath.Rel(root, path) | |
| 12 | + | if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { | |
| 13 | + | return filepath.Base(path) | |
| 14 | + | } | |
| 15 | + | return rel | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | type safePathError struct { | |
| 19 | + | op string | |
| 20 | + | cause error | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func (e safePathError) Error() string { return e.op + ": " + e.cause.Error() } | |
| 24 | + | func (e safePathError) Unwrap() error { return e.cause } | |
| 25 | + | ||
| 26 | + | // safeFilesystemError preserves error matching without including a filesystem | |
| 27 | + | // path from terminal-facing error text. | |
| 28 | + | func safeFilesystemError(err error) error { | |
| 29 | + | var pathError *os.PathError | |
| 30 | + | if errors.As(err, &pathError) { | |
| 31 | + | return safePathError{op: pathError.Op, cause: pathError.Err} | |
| 32 | + | } | |
| 33 | + | return err | |
| 34 | + | } | |
A internal/adapters/snapshot/maintenance_gc.go +414 -0
| @@ -0,0 +1,414 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/sha256" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "sort" | |
| 11 | + | "strings" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | codec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | type manifestMaintenance struct { | |
| 18 | + | refs map[ports.SnapshotDigest]codec.ObjectRef | |
| 19 | + | complete bool | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | type sessionMaintenance struct { | |
| 23 | + | lock *sessionMutex | |
| 24 | + | token string | |
| 25 | + | epoch uint64 | |
| 26 | + | marked map[uint64]manifestMaintenance | |
| 27 | + | referenceCount int | |
| 28 | + | uncertain bool | |
| 29 | + | conservative bool | |
| 30 | + | markDone bool | |
| 31 | + | manifestQueue []uint64 | |
| 32 | + | objectTempShard string | |
| 33 | + | objectTempsDone bool | |
| 34 | + | sweepShard string | |
| 35 | + | sweepRootDone bool | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // canRetainManifest reserves bounded state before retaining a manifest's | |
| 39 | + | // reference map. The caller must leave the map unretained when it returns | |
| 40 | + | // false, and make the cycle conservative instead. | |
| 41 | + | func (s *sessionMaintenance) canRetainManifest(referenceCount int) bool { | |
| 42 | + | if referenceCount < 0 || len(s.marked) >= maxMaintenanceMarkedGenerations { | |
| 43 | + | return false | |
| 44 | + | } | |
| 45 | + | if referenceCount > maxMaintenanceReferences-s.referenceCount { | |
| 46 | + | return false | |
| 47 | + | } | |
| 48 | + | s.referenceCount += referenceCount | |
| 49 | + | return true | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (r *Repository) maintainSession(ctx context.Context, key string, budget *int) error { | |
| 53 | + | if err := ctx.Err(); err != nil { | |
| 54 | + | return err | |
| 55 | + | } | |
| 56 | + | state, err := r.sessionMaintenanceState(key) | |
| 57 | + | if err != nil { | |
| 58 | + | return err | |
| 59 | + | } | |
| 60 | + | if _, err := r.removeTemps(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget, "generation-temps:"+key); err != nil || *budget == 0 { | |
| 61 | + | return err | |
| 62 | + | } | |
| 63 | + | if !state.objectTempsDone { | |
| 64 | + | done, err := r.removeObjectTemps(ctx, key, state, budget) | |
| 65 | + | if err != nil || !done || *budget == 0 { | |
| 66 | + | return err | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | if !state.markDone { | |
| 70 | + | if err := r.markSession(ctx, key, state); err != nil { | |
| 71 | + | return err | |
| 72 | + | } | |
| 73 | + | if !state.markDone { | |
| 74 | + | return nil | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | if state.epoch != r.storageEpoch(key) { | |
| 78 | + | // A publication may have left an unpublished manifest after this mark. | |
| 79 | + | // Discard all mark and sweep cursors; the next pass starts from it. | |
| 80 | + | r.clearSessionMaintenance(key) | |
| 81 | + | return nil | |
| 82 | + | } | |
| 83 | + | if state.uncertain { | |
| 84 | + | // An unreadable, invalid, or unretained manifest may reference any | |
| 85 | + | // existing blob. Do not collect manifests or sweep in this cycle; retry | |
| 86 | + | // marking from a fresh directory pass. | |
| 87 | + | r.clearSessionMaintenance(key) | |
| 88 | + | return nil | |
| 89 | + | } | |
| 90 | + | if err := r.removeObsoleteManifests(ctx, key, state, budget); err != nil || *budget == 0 { | |
| 91 | + | return err | |
| 92 | + | } | |
| 93 | + | if err := r.sweepSession(ctx, key, state, budget); err != nil { | |
| 94 | + | return err | |
| 95 | + | } | |
| 96 | + | return nil | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func (r *Repository) sessionMaintenanceState(key string) (*sessionMaintenance, error) { | |
| 100 | + | token, conservative, err := r.maintenanceToken(key) | |
| 101 | + | if err != nil { | |
| 102 | + | return nil, err | |
| 103 | + | } | |
| 104 | + | state := r.maintenanceSessions[key] | |
| 105 | + | if state == nil || state.token != token || state.epoch != r.storageEpoch(key) { | |
| 106 | + | r.clearSessionMaintenance(key) | |
| 107 | + | state = &sessionMaintenance{ | |
| 108 | + | lock: r.retainSessionState(key), | |
| 109 | + | token: token, | |
| 110 | + | epoch: r.storageEpoch(key), | |
| 111 | + | conservative: conservative, | |
| 112 | + | marked: make(map[uint64]manifestMaintenance), | |
| 113 | + | } | |
| 114 | + | r.maintenanceSessions[key] = state | |
| 115 | + | } | |
| 116 | + | return state, nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // maintenanceToken is the publication boundary. A missing or corrupt HEAD is | |
| 120 | + | // itself stable maintenance state: we retain every classified reference until a | |
| 121 | + | // valid publication changes that state, rather than restarting the mark pass. | |
| 122 | + | func (r *Repository) maintenanceToken(key string) (string, bool, error) { | |
| 123 | + | data, exists, err := r.readOptionalBounded(r.headPath(key)) | |
| 124 | + | if err != nil { | |
| 125 | + | return "", false, err | |
| 126 | + | } | |
| 127 | + | if !exists { | |
| 128 | + | return "missing", true, nil | |
| 129 | + | } | |
| 130 | + | sum := sha256.Sum256(data) | |
| 131 | + | if _, _, err := r.readHead(key); err != nil { | |
| 132 | + | return fmt.Sprintf("invalid:%x", sum), true, nil | |
| 133 | + | } | |
| 134 | + | return fmt.Sprintf("valid:%x", sum), false, nil | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func (r *Repository) clearSessionMaintenance(key string) { | |
| 138 | + | if state := r.maintenanceSessions[key]; state != nil && state.lock != nil { | |
| 139 | + | r.releaseSessionReference(state.lock) | |
| 140 | + | } | |
| 141 | + | delete(r.maintenanceSessions, key) | |
| 142 | + | prefix := "\x00" + filepath.Clean(r.sessionPath(key)) | |
| 143 | + | for id := range r.maintenanceCursors { | |
| 144 | + | if strings.HasSuffix(id, prefix) || strings.Contains(id, ":"+key+":") || strings.Contains(id, ":"+key+"\x00") { | |
| 145 | + | delete(r.maintenanceCursors, id) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func (r *Repository) markSession(ctx context.Context, key string, state *sessionMaintenance) error { | |
| 151 | + | dir := filepath.Join(r.sessionPath(key), repositoryGenerations) | |
| 152 | + | entries, done, err := r.readMaintenanceDir(dir, maintenanceBatch, "mark-generations:"+key) | |
| 153 | + | if errors.Is(err, os.ErrNotExist) { | |
| 154 | + | state.markDone = true | |
| 155 | + | return nil | |
| 156 | + | } | |
| 157 | + | if err != nil { | |
| 158 | + | return fmt.Errorf("read snapshot generations: %w", safeFilesystemError(err)) | |
| 159 | + | } | |
| 160 | + | for _, entry := range entries { | |
| 161 | + | if err := ctx.Err(); err != nil { | |
| 162 | + | return err | |
| 163 | + | } | |
| 164 | + | if entry.isDir { | |
| 165 | + | continue | |
| 166 | + | } | |
| 167 | + | generation, ok := parseGenerationFilename(entry.name) | |
| 168 | + | if !ok { | |
| 169 | + | continue | |
| 170 | + | } | |
| 171 | + | data, err := r.readBounded(r.manifestPath(key, generation)) | |
| 172 | + | if err != nil { | |
| 173 | + | state.uncertain = true | |
| 174 | + | continue | |
| 175 | + | } | |
| 176 | + | manifest, err := codec.UnmarshalManifest(data) | |
| 177 | + | if err != nil || manifest.Generation != generation || sessionKey(manifest.Name) != key { | |
| 178 | + | state.uncertain = true | |
| 179 | + | continue | |
| 180 | + | } | |
| 181 | + | refs, validRefs := maintenanceManifestRefs(manifest) | |
| 182 | + | if !validRefs || !withinGenerationBudget(len(data), refs) || !state.canRetainManifest(len(refs)) { | |
| 183 | + | state.uncertain = true | |
| 184 | + | continue | |
| 185 | + | } | |
| 186 | + | // Valid but incomplete generations are marked too. This protects blobs | |
| 187 | + | // written before a failed or in-flight publication reaches HEAD. | |
| 188 | + | _, loadErr := r.loadGeneration(ctx, manifest.Name, key, generation) | |
| 189 | + | state.marked[generation] = manifestMaintenance{refs: refs, complete: loadErr == nil} | |
| 190 | + | } | |
| 191 | + | if done { | |
| 192 | + | state.markDone = true | |
| 193 | + | if !state.conservative { | |
| 194 | + | state.manifestQueue = retainedManifestQueue(state.marked) | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | return nil | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // maintenanceManifestRefs applies the GC reference ceiling while building its | |
| 201 | + | // map, rather than first materializing a hostile manifest's entire reference | |
| 202 | + | // set. Conflicting references are invalid just as they are for publication. | |
| 203 | + | func maintenanceManifestRefs(manifest codec.Manifest) (map[ports.SnapshotDigest]codec.ObjectRef, bool) { | |
| 204 | + | refs := make(map[ports.SnapshotDigest]codec.ObjectRef) | |
| 205 | + | add := func(ref codec.ObjectRef) bool { | |
| 206 | + | if old, ok := refs[ref.Digest]; ok { | |
| 207 | + | return old == ref | |
| 208 | + | } | |
| 209 | + | if len(refs) == maxMaintenanceReferences { | |
| 210 | + | return false | |
| 211 | + | } | |
| 212 | + | refs[ref.Digest] = ref | |
| 213 | + | return true | |
| 214 | + | } | |
| 215 | + | for _, tab := range manifest.Tabs { | |
| 216 | + | for _, pane := range tab.Panes { | |
| 217 | + | for _, ref := range pane.Sealed { | |
| 218 | + | if !add(ref) { | |
| 219 | + | return nil, false | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | if !add(pane.Tail) || !add(pane.Visible) { | |
| 223 | + | return nil, false | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | return refs, true | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // completeGenerationsDescending returns only complete generations in canonical | |
| 231 | + | // newest-first order for both manifest and object retention. | |
| 232 | + | func completeGenerationsDescending(marked map[uint64]manifestMaintenance) []uint64 { | |
| 233 | + | complete := make([]uint64, 0, len(marked)) | |
| 234 | + | for generation, item := range marked { | |
| 235 | + | if item.complete { | |
| 236 | + | complete = append(complete, generation) | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | sort.Slice(complete, func(i, j int) bool { return complete[i] > complete[j] }) | |
| 240 | + | return complete | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func retainedManifestQueue(marked map[uint64]manifestMaintenance) []uint64 { | |
| 244 | + | complete := completeGenerationsDescending(marked) | |
| 245 | + | if len(complete) <= 2 { | |
| 246 | + | return nil | |
| 247 | + | } | |
| 248 | + | return complete[2:] | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func (r *Repository) removeObsoleteManifests(ctx context.Context, key string, state *sessionMaintenance, budget *int) error { | |
| 252 | + | for len(state.manifestQueue) != 0 && *budget > 0 { | |
| 253 | + | if err := ctx.Err(); err != nil { | |
| 254 | + | return err | |
| 255 | + | } | |
| 256 | + | generation := state.manifestQueue[0] | |
| 257 | + | path := r.manifestPath(key, generation) | |
| 258 | + | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 259 | + | return fmt.Errorf("remove snapshot manifest %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 260 | + | } | |
| 261 | + | if err := ctx.Err(); err != nil { | |
| 262 | + | return err | |
| 263 | + | } | |
| 264 | + | if err := r.syncDirectory(filepath.Dir(path)); err != nil { | |
| 265 | + | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 266 | + | } | |
| 267 | + | state.manifestQueue = state.manifestQueue[1:] | |
| 268 | + | *budget-- | |
| 269 | + | } | |
| 270 | + | return nil | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // removeObjectTemps performs at most one shard traversal or one root-entry | |
| 274 | + | // discovery per Maintain call. Empty attacker-controlled shards consume no | |
| 275 | + | // removal budget, so this fixed step ceiling prevents an unbounded scan of | |
| 276 | + | // them while retaining only the root cursor and one active shard cursor. | |
| 277 | + | func (r *Repository) removeObjectTemps(ctx context.Context, key string, state *sessionMaintenance, budget *int) (bool, error) { | |
| 278 | + | root := filepath.Join(r.sessionPath(key), repositoryObjectsDir) | |
| 279 | + | if *budget == 0 { | |
| 280 | + | return false, nil | |
| 281 | + | } | |
| 282 | + | if state.objectTempShard != "" { | |
| 283 | + | done, err := r.removeTemps(ctx, filepath.Join(root, state.objectTempShard), budget, "object-temps:"+key+":"+state.objectTempShard) | |
| 284 | + | if err != nil || !done { | |
| 285 | + | return false, err | |
| 286 | + | } | |
| 287 | + | state.objectTempShard = "" | |
| 288 | + | return state.objectTempsDone, nil | |
| 289 | + | } | |
| 290 | + | if state.objectTempsDone { | |
| 291 | + | return true, nil | |
| 292 | + | } | |
| 293 | + | entries, done, err := r.readMaintenanceDir(root, 1, "object-temps-shards:"+key) | |
| 294 | + | if errors.Is(err, os.ErrNotExist) { | |
| 295 | + | state.objectTempsDone = true | |
| 296 | + | return true, nil | |
| 297 | + | } | |
| 298 | + | if err != nil { | |
| 299 | + | return false, fmt.Errorf("read snapshot object shards: %w", safeFilesystemError(err)) | |
| 300 | + | } | |
| 301 | + | state.objectTempsDone = done | |
| 302 | + | if len(entries) != 0 && entries[0].isDir { | |
| 303 | + | state.objectTempShard = entries[0].name | |
| 304 | + | } | |
| 305 | + | return state.objectTempsDone && state.objectTempShard == "", nil | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | func (r *Repository) sweepSession(ctx context.Context, key string, state *sessionMaintenance, budget *int) error { | |
| 309 | + | // Every bounded sweep batch is tied to the mark's storage epoch. Do not use | |
| 310 | + | // stale references after any publication or replacement. | |
| 311 | + | if state.epoch != r.storageEpoch(key) { | |
| 312 | + | r.clearSessionMaintenance(key) | |
| 313 | + | return nil | |
| 314 | + | } | |
| 315 | + | referenced := retainedReferences(state.marked, state.conservative) | |
| 316 | + | root := filepath.Join(r.sessionPath(key), repositoryObjectsDir) | |
| 317 | + | if state.sweepShard != "" && *budget > 0 { | |
| 318 | + | shard := state.sweepShard | |
| 319 | + | done, err := r.sweepShard(ctx, filepath.Join(root, shard), referenced, budget, key, shard) | |
| 320 | + | if err != nil { | |
| 321 | + | return err | |
| 322 | + | } | |
| 323 | + | if !done { | |
| 324 | + | return nil | |
| 325 | + | } | |
| 326 | + | state.sweepShard = "" | |
| 327 | + | // Release this shard's cursor before discovering another shard. | |
| 328 | + | return nil | |
| 329 | + | } | |
| 330 | + | if !state.sweepRootDone { | |
| 331 | + | entries, done, err := r.readMaintenanceDir(root, 1, "sweep-shards:"+key) | |
| 332 | + | if errors.Is(err, os.ErrNotExist) { | |
| 333 | + | state.sweepRootDone = true | |
| 334 | + | } else if err != nil { | |
| 335 | + | return fmt.Errorf("read snapshot objects: %w", safeFilesystemError(err)) | |
| 336 | + | } else { | |
| 337 | + | state.sweepRootDone = done | |
| 338 | + | if len(entries) != 0 && entries[0].isDir { | |
| 339 | + | state.sweepShard = entries[0].name | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | if state.sweepRootDone && state.sweepShard == "" { | |
| 344 | + | r.clearSessionMaintenance(key) | |
| 345 | + | } | |
| 346 | + | return nil | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | func retainedReferences(marked map[uint64]manifestMaintenance, conservative bool) map[ports.SnapshotDigest]struct{} { | |
| 350 | + | if conservative { | |
| 351 | + | references := make(map[ports.SnapshotDigest]struct{}) | |
| 352 | + | for _, item := range marked { | |
| 353 | + | for digest := range item.refs { | |
| 354 | + | references[digest] = struct{}{} | |
| 355 | + | } | |
| 356 | + | } | |
| 357 | + | return references | |
| 358 | + | } | |
| 359 | + | complete := completeGenerationsDescending(marked) | |
| 360 | + | keep := make(map[uint64]bool, 2) | |
| 361 | + | for _, generation := range complete { | |
| 362 | + | if len(keep) == 2 { | |
| 363 | + | break | |
| 364 | + | } | |
| 365 | + | keep[generation] = true | |
| 366 | + | } | |
| 367 | + | references := make(map[ports.SnapshotDigest]struct{}) | |
| 368 | + | for generation, item := range marked { | |
| 369 | + | if !item.complete || keep[generation] { | |
| 370 | + | for digest := range item.refs { | |
| 371 | + | references[digest] = struct{}{} | |
| 372 | + | } | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | return references | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | func (r *Repository) sweepShard(ctx context.Context, dir string, referenced map[ports.SnapshotDigest]struct{}, budget *int, key, shard string) (bool, error) { | |
| 379 | + | entries, done, err := r.readMaintenanceDir(dir, maintenanceBatch, "sweep-objects:"+key+":"+shard) | |
| 380 | + | if errors.Is(err, os.ErrNotExist) { | |
| 381 | + | return true, nil | |
| 382 | + | } | |
| 383 | + | if err != nil { | |
| 384 | + | return false, fmt.Errorf("read snapshot object shard: %w", safeFilesystemError(err)) | |
| 385 | + | } | |
| 386 | + | for i, object := range entries { | |
| 387 | + | if *budget == 0 { | |
| 388 | + | r.requeueMaintenanceEntries(dir, "sweep-objects:"+key+":"+shard, entries[i:]) | |
| 389 | + | return false, nil | |
| 390 | + | } | |
| 391 | + | if err := ctx.Err(); err != nil { | |
| 392 | + | return false, err | |
| 393 | + | } | |
| 394 | + | digest, ok := parseObjectDigest(object.name) | |
| 395 | + | if !ok || object.isDir { | |
| 396 | + | continue | |
| 397 | + | } | |
| 398 | + | if _, used := referenced[digest]; used { | |
| 399 | + | continue | |
| 400 | + | } | |
| 401 | + | path := filepath.Join(dir, object.name) | |
| 402 | + | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 403 | + | return false, fmt.Errorf("remove snapshot object %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 404 | + | } | |
| 405 | + | if err := ctx.Err(); err != nil { | |
| 406 | + | return false, err | |
| 407 | + | } | |
| 408 | + | if err := r.syncDirectory(dir); err != nil { | |
| 409 | + | return false, fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 410 | + | } | |
| 411 | + | *budget-- | |
| 412 | + | } | |
| 413 | + | return done, nil | |
| 414 | + | } | |
A internal/adapters/snapshot/maintenance_hardening_test.go +141 -0
| @@ -0,0 +1,141 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "testing" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestRepositoryMaintainBoundsCursorsAcrossHostileObjectShards(t *testing.T) { | |
| 13 | + | repo := NewRepository(privateDir(t)) | |
| 14 | + | key := sessionKey("named") | |
| 15 | + | objects := filepath.Join(repo.sessionPath(key), repositoryObjectsDir) | |
| 16 | + | const shards = maintenanceBatch * 2 | |
| 17 | + | for i := 0; i < shards; i++ { | |
| 18 | + | shard := filepath.Join(objects, fmt.Sprintf("%03d", i)) | |
| 19 | + | if err := os.MkdirAll(shard, 0o700); err != nil { | |
| 20 | + | t.Fatal(err) | |
| 21 | + | } | |
| 22 | + | if err := os.WriteFile(filepath.Join(shard, ".tmp-stale"), []byte("stale"), 0o600); err != nil { | |
| 23 | + | t.Fatal(err) | |
| 24 | + | } | |
| 25 | + | // Force a resumable cursor in every attacker-controlled shard. | |
| 26 | + | for j := 0; j < maintenanceBatch+1; j++ { | |
| 27 | + | if err := os.WriteFile(filepath.Join(shard, fmt.Sprintf("live-%03d", j)), []byte("live"), 0o600); err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | for pass := 0; pass < shards*4; pass++ { | |
| 34 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 35 | + | t.Fatal(err) | |
| 36 | + | } | |
| 37 | + | if got := len(repo.maintenanceCursors); got > maxMaintenanceCursors { | |
| 38 | + | t.Fatalf("retained maintenance cursors = %d, want at most %d", got, maxMaintenanceCursors) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | for i := 0; i < shards; i++ { | |
| 42 | + | _, err := os.Lstat(filepath.Join(objects, fmt.Sprintf("%03d", i), ".tmp-stale")) | |
| 43 | + | if !errors.Is(err, os.ErrNotExist) { | |
| 44 | + | t.Fatalf("stale temporary object in shard %d = %v, want removed", i, err) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestRepositoryMaintainBoundsEmptyObjectTempShards(t *testing.T) { | |
| 50 | + | repo := NewRepository(privateDir(t)) | |
| 51 | + | key := sessionKey("named") | |
| 52 | + | objects := filepath.Join(repo.sessionPath(key), repositoryObjectsDir) | |
| 53 | + | const shards = maintenanceBatch * 2 | |
| 54 | + | for i := 0; i < shards; i++ { | |
| 55 | + | if err := os.MkdirAll(filepath.Join(objects, fmt.Sprintf("%03d", i)), 0o700); err != nil { | |
| 56 | + | t.Fatal(err) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | openedShards := make(map[string]struct{}, shards) | |
| 61 | + | opensThisPass := 0 | |
| 62 | + | tempPass := false | |
| 63 | + | repo.hooks.openMaintenanceDirectory = func(dir string) (maintenanceDirectory, error) { | |
| 64 | + | if tempPass && filepath.Dir(dir) == objects { | |
| 65 | + | opensThisPass++ | |
| 66 | + | openedShards[dir] = struct{}{} | |
| 67 | + | } | |
| 68 | + | file, err := os.Open(dir) | |
| 69 | + | if err != nil { | |
| 70 | + | return nil, err | |
| 71 | + | } | |
| 72 | + | return osMaintenanceDirectory{file: file}, nil | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | for pass := 0; pass < shards*3; pass++ { | |
| 76 | + | opensThisPass = 0 | |
| 77 | + | tempPass = true | |
| 78 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 79 | + | t.Fatal(err) | |
| 80 | + | } | |
| 81 | + | tempPass = false | |
| 82 | + | if opensThisPass > 1 { | |
| 83 | + | t.Fatalf("empty object shards opened in maintenance pass %d = %d, want at most 1", pass, opensThisPass) | |
| 84 | + | } | |
| 85 | + | state := repo.maintenanceSessions[key] | |
| 86 | + | if state != nil && state.objectTempsDone { | |
| 87 | + | if got := len(openedShards); got != shards { | |
| 88 | + | t.Fatalf("empty object shards opened = %d, want %d", got, shards) | |
| 89 | + | } | |
| 90 | + | return | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | t.Fatal("empty object temp shards were not eventually traversed") | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestRepositoryMaintainBoundsAndResumesDeepWideQuarantine(t *testing.T) { | |
| 97 | + | repo := NewRepository(privateDir(t)) | |
| 98 | + | quarantine := filepath.Join(repo.dir, repositorySessionsDir, ".deleting-hostile") | |
| 99 | + | deep := quarantine | |
| 100 | + | for i := 0; i < maintenanceBatch*2; i++ { | |
| 101 | + | deep = filepath.Join(deep, "d") | |
| 102 | + | } | |
| 103 | + | leaf := filepath.Join(deep, "leaf") | |
| 104 | + | if err := os.MkdirAll(filepath.Dir(leaf), 0o700); err != nil { | |
| 105 | + | t.Fatal(err) | |
| 106 | + | } | |
| 107 | + | if err := os.WriteFile(leaf, []byte("stale"), 0o600); err != nil { | |
| 108 | + | t.Fatal(err) | |
| 109 | + | } | |
| 110 | + | for i := 0; i < maintenanceBatch*2; i++ { | |
| 111 | + | if err := os.WriteFile(filepath.Join(quarantine, fmt.Sprintf("wide-%03d", i)), []byte("stale"), 0o600); err != nil { | |
| 112 | + | t.Fatal(err) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | work := 0 | |
| 117 | + | repo.hooks.beforeMaintenanceWork = func(string) { work++ } | |
| 118 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 119 | + | t.Fatal(err) | |
| 120 | + | } | |
| 121 | + | if work == 0 || work > maintenanceBatch { | |
| 122 | + | t.Fatalf("quarantine work in one maintenance call = %d, want 1..%d", work, maintenanceBatch) | |
| 123 | + | } | |
| 124 | + | if _, err := os.Lstat(leaf); err != nil { | |
| 125 | + | t.Fatalf("deep quarantine leaf removed despite bounded traversal: %v", err) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | for pass := 0; pass < 1000; pass++ { | |
| 129 | + | work = 0 | |
| 130 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 131 | + | t.Fatal(err) | |
| 132 | + | } | |
| 133 | + | if work > maintenanceBatch { | |
| 134 | + | t.Fatalf("quarantine work in pass %d = %d, want at most %d", pass, work, maintenanceBatch) | |
| 135 | + | } | |
| 136 | + | if _, err := os.Lstat(quarantine); errors.Is(err, os.ErrNotExist) { | |
| 137 | + | return | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | t.Fatal("deep and wide quarantine was not eventually removed") | |
| 141 | + | } | |
A internal/adapters/snapshot/maintenance_test.go +1010 -0
| @@ -0,0 +1,1010 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/sha256" | |
| 6 | + | "encoding/binary" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "os" | |
| 11 | + | "path/filepath" | |
| 12 | + | "slices" | |
| 13 | + | "strings" | |
| 14 | + | "syscall" | |
| 15 | + | "testing" | |
| 16 | + | "unsafe" | |
| 17 | + | ||
| 18 | + | "github.com/bnema/vev/internal/ports" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | func TestReadMaintenanceDirentPreservesCursorAndCloseFailuresWithoutPaths(t *testing.T) { | |
| 22 | + | privatePath := filepath.Join(t.TempDir(), "private-maintenance-directory") | |
| 23 | + | closeCause := errors.New("injected close failure") | |
| 24 | + | seekCause := errors.New("injected seek failure") | |
| 25 | + | readCause := errors.New("injected read failure") | |
| 26 | + | ||
| 27 | + | for _, tc := range []struct { | |
| 28 | + | name string | |
| 29 | + | offset int64 | |
| 30 | + | seekErr error | |
| 31 | + | readErr error | |
| 32 | + | wantCause []error | |
| 33 | + | wantOps []string | |
| 34 | + | }{ | |
| 35 | + | { | |
| 36 | + | name: "close only", | |
| 37 | + | wantCause: []error{closeCause}, | |
| 38 | + | wantOps: []string{"close maintenance directory"}, | |
| 39 | + | }, | |
| 40 | + | { | |
| 41 | + | name: "seek and close", | |
| 42 | + | offset: 1, | |
| 43 | + | seekErr: &os.PathError{Op: "seek", Path: privatePath, Err: seekCause}, | |
| 44 | + | wantCause: []error{seekCause, closeCause}, | |
| 45 | + | wantOps: []string{"seek maintenance directory", "close maintenance directory"}, | |
| 46 | + | }, | |
| 47 | + | { | |
| 48 | + | name: "read and close", | |
| 49 | + | readErr: &os.PathError{Op: "read", Path: privatePath, Err: readCause}, | |
| 50 | + | wantCause: []error{readCause, closeCause}, | |
| 51 | + | wantOps: []string{"read maintenance directory", "close maintenance directory"}, | |
| 52 | + | }, | |
| 53 | + | } { | |
| 54 | + | t.Run(tc.name, func(t *testing.T) { | |
| 55 | + | repo := NewRepository(privateDir(t)) | |
| 56 | + | repo.hooks.openMaintenanceDirectory = func(string) (maintenanceDirectory, error) { | |
| 57 | + | return fakeMaintenanceDirectory{ | |
| 58 | + | seekErr: tc.seekErr, | |
| 59 | + | readErr: tc.readErr, | |
| 60 | + | closeErr: &os.PathError{Op: "close", Path: privatePath, Err: closeCause}, | |
| 61 | + | }, nil | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | _, _, err := repo.readMaintenanceDirent(privatePath, 1, &maintenanceCursor{offset: tc.offset}) | |
| 65 | + | if err == nil { | |
| 66 | + | t.Fatal("readMaintenanceDirent error = nil, want failure") | |
| 67 | + | } | |
| 68 | + | for _, cause := range tc.wantCause { | |
| 69 | + | if !errors.Is(err, cause) { | |
| 70 | + | t.Errorf("errors.Is(%v, %v) = false, want true", err, cause) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | for _, operation := range tc.wantOps { | |
| 74 | + | if !strings.Contains(err.Error(), operation) { | |
| 75 | + | t.Errorf("error = %q, want stable operation context %q", err, operation) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | if strings.Contains(err.Error(), privatePath) { | |
| 79 | + | t.Errorf("error leaks private path: %q", err) | |
| 80 | + | } | |
| 81 | + | }) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestReadMaintenanceDirentRejectsRecordShorterThanNameOffset(t *testing.T) { | |
| 86 | + | repo := NewRepository(privateDir(t)) | |
| 87 | + | nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name)) | |
| 88 | + | recordData := make([]byte, nameOffset) | |
| 89 | + | reclenOffset := unsafe.Offsetof(syscall.Dirent{}.Reclen) | |
| 90 | + | binary.NativeEndian.PutUint16(recordData[reclenOffset:], 1) | |
| 91 | + | ||
| 92 | + | repo.hooks.openMaintenanceDirectory = func(string) (maintenanceDirectory, error) { | |
| 93 | + | return fakeMaintenanceDirectory{data: recordData}, nil | |
| 94 | + | } | |
| 95 | + | _, _, err := repo.readMaintenanceDirent("unused", 1, &maintenanceCursor{}) | |
| 96 | + | if !errors.Is(err, syscall.EIO) { | |
| 97 | + | t.Fatalf("readMaintenanceDirent error = %v, want EIO", err) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestDecodeMaintenanceDirentBoundsRecords(t *testing.T) { | |
| 102 | + | nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name)) | |
| 103 | + | reclenOffset := unsafe.Offsetof(syscall.Dirent{}.Reclen) | |
| 104 | + | valid := make([]byte, nameOffset+len("entry")+1) | |
| 105 | + | binary.NativeEndian.PutUint16(valid[reclenOffset:], uint16(len(valid))) | |
| 106 | + | copy(valid[nameOffset:], "entry") | |
| 107 | + | ||
| 108 | + | tests := []struct { | |
| 109 | + | name string | |
| 110 | + | data []byte | |
| 111 | + | wantName string | |
| 112 | + | wantErr bool | |
| 113 | + | }{ | |
| 114 | + | {name: "truncated header", data: make([]byte, nameOffset-1), wantErr: true}, | |
| 115 | + | {name: "short record", data: append([]byte(nil), valid...), wantErr: true}, | |
| 116 | + | {name: "truncated record", data: valid[:nameOffset], wantErr: true}, | |
| 117 | + | {name: "record exceeds aligned storage", data: make([]byte, int(unsafe.Sizeof(syscall.Dirent{}))+1), wantErr: true}, | |
| 118 | + | {name: "valid", data: valid, wantName: "entry"}, | |
| 119 | + | } | |
| 120 | + | binary.NativeEndian.PutUint16(tests[1].data[reclenOffset:], uint16(nameOffset-1)) | |
| 121 | + | binary.NativeEndian.PutUint16(tests[2].data[reclenOffset:], uint16(len(valid))) | |
| 122 | + | binary.NativeEndian.PutUint16(tests[3].data[reclenOffset:], uint16(len(tests[3].data))) | |
| 123 | + | ||
| 124 | + | for _, tc := range tests { | |
| 125 | + | t.Run(tc.name, func(t *testing.T) { | |
| 126 | + | record, name, reclen, err := decodeMaintenanceDirent(tc.data) | |
| 127 | + | if tc.wantErr { | |
| 128 | + | if !errors.Is(err, syscall.EIO) { | |
| 129 | + | t.Fatalf("decodeMaintenanceDirent() error = %v, want EIO", err) | |
| 130 | + | } | |
| 131 | + | return | |
| 132 | + | } | |
| 133 | + | if err != nil { | |
| 134 | + | t.Fatal(err) | |
| 135 | + | } | |
| 136 | + | if record == nil { | |
| 137 | + | t.Fatal("decodeMaintenanceDirent() record = nil") | |
| 138 | + | } | |
| 139 | + | if reclen != len(valid) { | |
| 140 | + | t.Fatalf("decodeMaintenanceDirent() reclen = %d, want %d", reclen, len(valid)) | |
| 141 | + | } | |
| 142 | + | if got := string(name[:len(tc.wantName)]); got != tc.wantName { | |
| 143 | + | t.Fatalf("decodeMaintenanceDirent() name = %q, want %q", got, tc.wantName) | |
| 144 | + | } | |
| 145 | + | }) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | type fakeMaintenanceDirectory struct { | |
| 150 | + | data []byte | |
| 151 | + | seekOffset int64 | |
| 152 | + | seekErr error | |
| 153 | + | readErr error | |
| 154 | + | closeErr error | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func (d fakeMaintenanceDirectory) Seek(int64, int) (int64, error) { return d.seekOffset, d.seekErr } | |
| 158 | + | func (d fakeMaintenanceDirectory) ReadDirent(buffer []byte) (int, error) { | |
| 159 | + | if d.readErr != nil { | |
| 160 | + | return 0, d.readErr | |
| 161 | + | } | |
| 162 | + | return copy(buffer, d.data), nil | |
| 163 | + | } | |
| 164 | + | func (d fakeMaintenanceDirectory) Close() error { return d.closeErr } | |
| 165 | + | ||
| 166 | + | func TestReadMaintenanceDirentDrainsDarwinBatchesWithoutStarvation(t *testing.T) { | |
| 167 | + | dir := privateDir(t) | |
| 168 | + | if err := os.Mkdir(dir, 0o700); err != nil { | |
| 169 | + | t.Fatal(err) | |
| 170 | + | } | |
| 171 | + | batches := [][]string{ | |
| 172 | + | {"one", "two"}, | |
| 173 | + | {"three", "four", "five", "six"}, | |
| 174 | + | } | |
| 175 | + | for _, batch := range batches { | |
| 176 | + | for _, name := range batch { | |
| 177 | + | if err := os.WriteFile(filepath.Join(dir, name), nil, 0o600); err != nil { | |
| 178 | + | t.Fatal(err) | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | file := &fakeMultiBatchMaintenanceDirectory{batches: [][]byte{ | |
| 184 | + | maintenanceDirentBatch(batches[0]...), | |
| 185 | + | maintenanceDirentBatch(batches[1]...), | |
| 186 | + | }} | |
| 187 | + | repo := NewRepository(dir) | |
| 188 | + | repo.hooks.openMaintenanceDirectory = func(string) (maintenanceDirectory, error) { return file, nil } | |
| 189 | + | cursor := &maintenanceCursor{} | |
| 190 | + | ||
| 191 | + | entries, done, err := repo.readMaintenanceDirentWithDrain(dir, 4, cursor, true, maintenanceTestDirectoryCookie) | |
| 192 | + | if err != nil { | |
| 193 | + | t.Fatal(err) | |
| 194 | + | } | |
| 195 | + | if done { | |
| 196 | + | t.Fatal("readMaintenanceDirentWithDrain() done = true, want false before EOF") | |
| 197 | + | } | |
| 198 | + | if got, want := maintenanceEntryNames(entries), []string{"one", "two", "three", "four"}; !slices.Equal(got, want) { | |
| 199 | + | t.Fatalf("first entries = %v, want %v", got, want) | |
| 200 | + | } | |
| 201 | + | if got, want := maintenanceEntryNames(cursor.pending), []string{"five", "six"}; !slices.Equal(got, want) { | |
| 202 | + | t.Fatalf("pending entries = %v, want exactly final-buffer excess %v", got, want) | |
| 203 | + | } | |
| 204 | + | if got := file.reads; got != 2 { | |
| 205 | + | t.Fatalf("ReadDirent calls = %d, want 2 to fill the requested batch", got) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | id := maintenanceCursorID(dir, "test") | |
| 209 | + | repo.maintenanceCursors = map[string]*maintenanceCursor{id: cursor} | |
| 210 | + | entries, done, err = repo.readMaintenanceDir(dir, 4, "test") | |
| 211 | + | if err != nil { | |
| 212 | + | t.Fatal(err) | |
| 213 | + | } | |
| 214 | + | if done { | |
| 215 | + | t.Fatal("pending batch done = true, want false before EOF") | |
| 216 | + | } | |
| 217 | + | if got, want := maintenanceEntryNames(entries), []string{"five", "six"}; !slices.Equal(got, want) { | |
| 218 | + | t.Fatalf("pending entries = %v, want %v", got, want) | |
| 219 | + | } | |
| 220 | + | if got := file.reads; got != 2 { | |
| 221 | + | t.Fatalf("ReadDirent calls while delivering pending entries = %d, want 2", got) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | entries, done, err = repo.readMaintenanceDir(dir, 4, "test") | |
| 225 | + | if err != nil { | |
| 226 | + | t.Fatal(err) | |
| 227 | + | } | |
| 228 | + | if !done { | |
| 229 | + | t.Fatal("EOF batch done = false, want true") | |
| 230 | + | } | |
| 231 | + | if len(entries) != 0 { | |
| 232 | + | t.Fatalf("EOF entries = %v, want none", entries) | |
| 233 | + | } | |
| 234 | + | if _, ok := repo.maintenanceCursors[id]; ok { | |
| 235 | + | t.Fatal("EOF cursor retained after pending entries were delivered") | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | func maintenanceEntryNames(entries []maintenanceDirEntry) []string { | |
| 240 | + | names := make([]string, len(entries)) | |
| 241 | + | for i, entry := range entries { | |
| 242 | + | names[i] = entry.name | |
| 243 | + | } | |
| 244 | + | return names | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | func maintenanceDirentBatch(names ...string) []byte { | |
| 248 | + | nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name)) | |
| 249 | + | reclenOffset := unsafe.Offsetof(syscall.Dirent{}.Reclen) | |
| 250 | + | var data []byte | |
| 251 | + | for _, name := range names { | |
| 252 | + | record := make([]byte, nameOffset+len(name)+1) | |
| 253 | + | binary.NativeEndian.PutUint16(record[reclenOffset:], uint16(len(record))) | |
| 254 | + | copy(record[nameOffset:], name) | |
| 255 | + | data = append(data, record...) | |
| 256 | + | } | |
| 257 | + | return data | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | type fakeMultiBatchMaintenanceDirectory struct { | |
| 261 | + | batches [][]byte | |
| 262 | + | index int | |
| 263 | + | reads int | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func (d *fakeMultiBatchMaintenanceDirectory) Seek(offset int64, whence int) (int64, error) { | |
| 267 | + | if whence == io.SeekCurrent { | |
| 268 | + | return int64(d.index), nil | |
| 269 | + | } | |
| 270 | + | d.index = int(offset) | |
| 271 | + | return offset, nil | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func (d *fakeMultiBatchMaintenanceDirectory) ReadDirent(buffer []byte) (int, error) { | |
| 275 | + | d.reads++ | |
| 276 | + | if d.index == len(d.batches) { | |
| 277 | + | return 0, nil | |
| 278 | + | } | |
| 279 | + | batch := d.batches[d.index] | |
| 280 | + | d.index++ | |
| 281 | + | return copy(buffer, batch), nil | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func (d *fakeMultiBatchMaintenanceDirectory) Close() error { return nil } | |
| 285 | + | ||
| 286 | + | func maintenanceTestDirectoryCookie(file maintenanceDirectory, _ *syscall.Dirent) (int64, error) { | |
| 287 | + | return file.Seek(0, io.SeekCurrent) | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func TestRepositoryDeleteQuarantinesCanonicalSessionBeforeCleanup(t *testing.T) { | |
| 291 | + | repo := NewRepository(privateDir(t)) | |
| 292 | + | if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil { | |
| 293 | + | t.Fatal(err) | |
| 294 | + | } | |
| 295 | + | if err := repo.Delete(context.Background(), "named"); err != nil { | |
| 296 | + | t.Fatal(err) | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | canonical := repo.sessionPath(sessionKey("named")) | |
| 300 | + | if _, err := os.Lstat(canonical); !errors.Is(err, os.ErrNotExist) { | |
| 301 | + | t.Fatalf("canonical session after Delete = %v, want not exist", err) | |
| 302 | + | } | |
| 303 | + | entries, err := os.ReadDir(filepath.Join(repo.dir, repositorySessionsDir)) | |
| 304 | + | if err != nil { | |
| 305 | + | t.Fatal(err) | |
| 306 | + | } | |
| 307 | + | if len(entries) != 1 || !isQuarantine(entries[0].Name()) || !entries[0].IsDir() { | |
| 308 | + | t.Fatalf("session entries after Delete = %#v, want one quarantine", entries) | |
| 309 | + | } | |
| 310 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 311 | + | t.Fatal(err) | |
| 312 | + | } | |
| 313 | + | entries, err = os.ReadDir(filepath.Join(repo.dir, repositorySessionsDir)) | |
| 314 | + | if err != nil { | |
| 315 | + | t.Fatal(err) | |
| 316 | + | } | |
| 317 | + | if len(entries) != 0 { | |
| 318 | + | t.Fatalf("session entries after Maintain = %#v, want none", entries) | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | func TestRepositoryDeleteReturnsRootSyncFailureAfterQuarantine(t *testing.T) { | |
| 323 | + | repo := NewRepository(privateDir(t)) | |
| 324 | + | if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil { | |
| 325 | + | t.Fatal(err) | |
| 326 | + | } | |
| 327 | + | injected := errors.New("sessions sync") | |
| 328 | + | repo.hooks.syncDirectory = func(string) error { return injected } | |
| 329 | + | if err := repo.Delete(context.Background(), "named"); !errors.Is(err, injected) { | |
| 330 | + | t.Fatalf("Delete error = %v, want root sync failure", err) | |
| 331 | + | } | |
| 332 | + | if _, err := os.Lstat(repo.sessionPath(sessionKey("named"))); !errors.Is(err, os.ErrNotExist) { | |
| 333 | + | t.Fatalf("canonical session after failed sync = %v, want not exist", err) | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | func TestRepositoryDeleteWaitsForPublication(t *testing.T) { | |
| 338 | + | repo := NewRepository(privateDir(t)) | |
| 339 | + | if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("first"))); err != nil { | |
| 340 | + | t.Fatal(err) | |
| 341 | + | } | |
| 342 | + | entered := make(chan struct{}) | |
| 343 | + | release := make(chan struct{}) | |
| 344 | + | repo.hooks.beforeHeadWrite = func(string) error { | |
| 345 | + | close(entered) | |
| 346 | + | <-release | |
| 347 | + | return nil | |
| 348 | + | } | |
| 349 | + | publishDone := make(chan error, 1) | |
| 350 | + | go func() { | |
| 351 | + | publishDone <- repo.Publish(context.Background(), repositoryPublication(t, "named", 2, []byte("second"))) | |
| 352 | + | }() | |
| 353 | + | <-entered | |
| 354 | + | deleteReached := make(chan struct{}) | |
| 355 | + | deleteRelease := make(chan struct{}) | |
| 356 | + | repo.hooks.beforeSessionLock = waitAtSessionLock(deleteReached, deleteRelease) | |
| 357 | + | deleteDone := make(chan error, 1) | |
| 358 | + | go func() { deleteDone <- repo.Delete(context.Background(), "named") }() | |
| 359 | + | <-deleteReached | |
| 360 | + | // Delete is parked at the session-lock boundary while Publish owns it. | |
| 361 | + | close(deleteRelease) | |
| 362 | + | close(release) | |
| 363 | + | if err := <-publishDone; err != nil { | |
| 364 | + | t.Fatal(err) | |
| 365 | + | } | |
| 366 | + | if err := <-deleteDone; err != nil { | |
| 367 | + | t.Fatal(err) | |
| 368 | + | } | |
| 369 | + | if _, err := os.Lstat(repo.sessionPath(sessionKey("named"))); !errors.Is(err, os.ErrNotExist) { | |
| 370 | + | t.Fatalf("canonical session after concurrent Delete = %v, want not exist", err) | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | func TestRepositoryMaintainRetainsNewestTwoCompleteGenerations(t *testing.T) { | |
| 375 | + | repo := NewRepository(privateDir(t)) | |
| 376 | + | for generation := uint64(1); generation <= 3; generation++ { | |
| 377 | + | if err := repo.Publish(context.Background(), repositoryPublication(t, "named", generation, []byte{byte(generation)})); err != nil { | |
| 378 | + | t.Fatal(err) | |
| 379 | + | } | |
| 380 | + | } | |
| 381 | + | key := sessionKey("named") | |
| 382 | + | for pass := 0; pass < maintenanceBatch; pass++ { | |
| 383 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 384 | + | t.Fatal(err) | |
| 385 | + | } | |
| 386 | + | _, err := os.Lstat(repo.manifestPath(key, 1)) | |
| 387 | + | if errors.Is(err, os.ErrNotExist) { | |
| 388 | + | break | |
| 389 | + | } | |
| 390 | + | if err != nil { | |
| 391 | + | t.Fatalf("old manifest during Maintain = %v", err) | |
| 392 | + | } | |
| 393 | + | if pass == maintenanceBatch-1 { | |
| 394 | + | t.Fatal("old manifest was not eventually collected") | |
| 395 | + | } | |
| 396 | + | } | |
| 397 | + | for _, generation := range []uint64{2, 3} { | |
| 398 | + | if _, err := os.Lstat(repo.manifestPath(key, generation)); err != nil { | |
| 399 | + | t.Fatalf("retained manifest %d: %v", generation, err) | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | if _, err := os.Lstat(repo.manifestPath(key, 1)); !errors.Is(err, os.ErrNotExist) { | |
| 403 | + | t.Fatalf("old manifest after Maintain = %v, want not exist", err) | |
| 404 | + | } | |
| 405 | + | for _, generation := range []uint64{2, 3} { | |
| 406 | + | if _, err := repo.Load(context.Background(), "named"); err != nil { | |
| 407 | + | t.Fatalf("Load after collecting generation %d: %v", generation, err) | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | func TestRepositoryMaintainPreservesIncompleteManifestReferences(t *testing.T) { | |
| 413 | + | repo := NewRepository(privateDir(t)) | |
| 414 | + | for generation := uint64(1); generation <= 3; generation++ { | |
| 415 | + | if err := repo.Publish(context.Background(), repositoryPublication(t, "named", generation, []byte{byte(generation)})); err != nil { | |
| 416 | + | t.Fatal(err) | |
| 417 | + | } | |
| 418 | + | } | |
| 419 | + | key := sessionKey("named") | |
| 420 | + | manifest := repositoryPublication(t, "named", 3, []byte{3}) | |
| 421 | + | if err := os.Remove(repo.objectPath(key, manifest.Objects[1].Digest)); err != nil { | |
| 422 | + | t.Fatal(err) | |
| 423 | + | } | |
| 424 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 425 | + | t.Fatal(err) | |
| 426 | + | } | |
| 427 | + | if _, err := os.Lstat(repo.manifestPath(key, 3)); err != nil { | |
| 428 | + | t.Fatalf("incomplete manifest removed: %v", err) | |
| 429 | + | } | |
| 430 | + | if _, err := os.Lstat(repo.objectPath(key, manifest.Objects[0].Digest)); err != nil { | |
| 431 | + | t.Fatalf("object referenced by incomplete manifest removed: %v", err) | |
| 432 | + | } | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | func TestRepositoryCancellationAfterSessionLockWaitPreventsMutations(t *testing.T) { | |
| 436 | + | t.Run("publish", func(t *testing.T) { | |
| 437 | + | repo := NewRepository(privateDir(t)) | |
| 438 | + | key := sessionKey("named") | |
| 439 | + | lock := repo.lockSession(key) | |
| 440 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 441 | + | reached := make(chan struct{}) | |
| 442 | + | release := make(chan struct{}) | |
| 443 | + | repo.hooks.beforeSessionLock = waitAtSessionLock(reached, release) | |
| 444 | + | done := make(chan error, 1) | |
| 445 | + | go func() { done <- repo.Publish(ctx, repositoryPublication(t, "named", 1, []byte("state"))) }() | |
| 446 | + | <-reached | |
| 447 | + | cancel() | |
| 448 | + | close(release) | |
| 449 | + | repo.unlockSession(lock) | |
| 450 | + | if err := <-done; !errors.Is(err, context.Canceled) { | |
| 451 | + | t.Fatalf("Publish error = %v, want canceled", err) | |
| 452 | + | } | |
| 453 | + | if _, err := os.Lstat(repo.dir); !errors.Is(err, os.ErrNotExist) { | |
| 454 | + | t.Fatalf("Publish mutated canceled repository: %v", err) | |
| 455 | + | } | |
| 456 | + | }) | |
| 457 | + | t.Run("load and delete", func(t *testing.T) { | |
| 458 | + | repo := NewRepository(privateDir(t)) | |
| 459 | + | if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil { | |
| 460 | + | t.Fatal(err) | |
| 461 | + | } | |
| 462 | + | key := sessionKey("named") | |
| 463 | + | for _, operation := range []struct { | |
| 464 | + | name string | |
| 465 | + | run func(context.Context) error | |
| 466 | + | }{ | |
| 467 | + | {"load", func(ctx context.Context) error { _, err := repo.Load(ctx, "named"); return err }}, | |
| 468 | + | {"delete", func(ctx context.Context) error { return repo.Delete(ctx, "named") }}, | |
| 469 | + | } { | |
| 470 | + | t.Run(operation.name, func(t *testing.T) { | |
| 471 | + | lock := repo.lockSession(key) | |
| 472 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 473 | + | reached := make(chan struct{}) | |
| 474 | + | release := make(chan struct{}) | |
| 475 | + | repo.hooks.beforeSessionLock = waitAtSessionLock(reached, release) | |
| 476 | + | done := make(chan error, 1) | |
| 477 | + | go func() { done <- operation.run(ctx) }() | |
| 478 | + | <-reached | |
| 479 | + | cancel() | |
| 480 | + | close(release) | |
| 481 | + | repo.unlockSession(lock) | |
| 482 | + | if err := <-done; !errors.Is(err, context.Canceled) { | |
| 483 | + | t.Fatalf("%s error = %v, want canceled", operation.name, err) | |
| 484 | + | } | |
| 485 | + | repo.hooks.beforeSessionLock = nil | |
| 486 | + | if _, err := repo.Load(context.Background(), "named"); err != nil { | |
| 487 | + | t.Fatalf("%s mutated session: %v", operation.name, err) | |
| 488 | + | } | |
| 489 | + | }) | |
| 490 | + | } | |
| 491 | + | }) | |
| 492 | + | t.Run("maintain", func(t *testing.T) { | |
| 493 | + | repo := NewRepository(privateDir(t)) | |
| 494 | + | if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil { | |
| 495 | + | t.Fatal(err) | |
| 496 | + | } | |
| 497 | + | lock := repo.lockSession(sessionKey("named")) | |
| 498 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 499 | + | reached := make(chan struct{}) | |
| 500 | + | release := make(chan struct{}) | |
| 501 | + | repo.hooks.beforeSessionLock = waitAtSessionLock(reached, release) | |
| 502 | + | done := make(chan error, 1) | |
| 503 | + | go func() { done <- repo.Maintain(ctx) }() | |
| 504 | + | <-reached | |
| 505 | + | cancel() | |
| 506 | + | close(release) | |
| 507 | + | repo.unlockSession(lock) | |
| 508 | + | if err := <-done; !errors.Is(err, context.Canceled) { | |
| 509 | + | t.Fatalf("Maintain error = %v, want canceled", err) | |
| 510 | + | } | |
| 511 | + | repo.hooks.beforeSessionLock = nil | |
| 512 | + | if _, err := repo.Load(context.Background(), "named"); err != nil { | |
| 513 | + | t.Fatalf("Maintain mutated session: %v", err) | |
| 514 | + | } | |
| 515 | + | }) | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | func waitAtSessionLock(reached chan<- struct{}, release <-chan struct{}) func(string) { | |
| 519 | + | return func(string) { | |
| 520 | + | close(reached) | |
| 521 | + | <-release | |
| 522 | + | } | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | func TestRepositoryDeleteRetriesPendingQuarantineSyncWithoutDeletingRecreatedSession(t *testing.T) { | |
| 526 | + | repo := NewRepository(privateDir(t)) | |
| 527 | + | if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("old"))); err != nil { | |
| 528 | + | t.Fatal(err) | |
| 529 | + | } | |
| 530 | + | injected := errors.New("sessions sync") | |
| 531 | + | repo.hooks.syncDirectory = func(string) error { return injected } | |
| 532 | + | if err := repo.Delete(context.Background(), "named"); !errors.Is(err, injected) { | |
| 533 | + | t.Fatalf("Delete error = %v, want sync failure", err) | |
| 534 | + | } | |
| 535 | + | repo.hooks.syncDirectory = nil | |
| 536 | + | if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("new"))); err != nil { | |
| 537 | + | t.Fatal(err) | |
| 538 | + | } | |
| 539 | + | if err := repo.Delete(context.Background(), "named"); err != nil { | |
| 540 | + | t.Fatalf("retry Delete: %v", err) | |
| 541 | + | } | |
| 542 | + | if _, err := repo.Load(context.Background(), "named"); err != nil { | |
| 543 | + | t.Fatalf("recreated session was deleted: %v", err) | |
| 544 | + | } | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | func TestRepositoryMaintainResumesNestedQuarantineWithinBatch(t *testing.T) { | |
| 548 | + | repo := NewRepository(privateDir(t)) | |
| 549 | + | quarantine := filepath.Join(repo.dir, repositorySessionsDir, ".deleting-named-test") | |
| 550 | + | for i := 0; i < maintenanceBatch+1; i++ { | |
| 551 | + | path := filepath.Join(quarantine, "nested", string(rune('a'+i))) | |
| 552 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 553 | + | t.Fatal(err) | |
| 554 | + | } | |
| 555 | + | if err := os.WriteFile(path, []byte("stale"), 0o600); err != nil { | |
| 556 | + | t.Fatal(err) | |
| 557 | + | } | |
| 558 | + | } | |
| 559 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 560 | + | t.Fatal(err) | |
| 561 | + | } | |
| 562 | + | if _, err := os.Lstat(quarantine); errors.Is(err, os.ErrNotExist) { | |
| 563 | + | t.Fatalf("quarantine after one bounded call = %v, want remaining tree", err) | |
| 564 | + | } | |
| 565 | + | for i := 0; i < 12; i++ { | |
| 566 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 567 | + | t.Fatal(err) | |
| 568 | + | } | |
| 569 | + | if _, err := os.Lstat(quarantine); errors.Is(err, os.ErrNotExist) { | |
| 570 | + | return | |
| 571 | + | } | |
| 572 | + | } | |
| 573 | + | if _, err := os.Lstat(quarantine); !errors.Is(err, os.ErrNotExist) { | |
| 574 | + | t.Fatalf("quarantine after resumed maintenance = %v, want removed", err) | |
| 575 | + | } | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | func TestRepositoryMaintainClosesContinuationDirectories(t *testing.T) { | |
| 579 | + | repo := NewRepository(privateDir(t)) | |
| 580 | + | sessions := filepath.Join(repo.dir, repositorySessionsDir) | |
| 581 | + | for i := 0; i < maintenanceBatch+1; i++ { | |
| 582 | + | generations := filepath.Join(sessions, fmt.Sprintf("named-%03d", i), repositoryGenerations) | |
| 583 | + | if err := os.MkdirAll(generations, 0o700); err != nil { | |
| 584 | + | t.Fatal(err) | |
| 585 | + | } | |
| 586 | + | for j := 0; j < maintenanceBatch+1; j++ { | |
| 587 | + | if err := os.WriteFile(filepath.Join(generations, fmt.Sprintf("unclassified-%03d", j)), []byte("state"), 0o600); err != nil { | |
| 588 | + | t.Fatal(err) | |
| 589 | + | } | |
| 590 | + | } | |
| 591 | + | } | |
| 592 | + | before := openDescriptorCount(t) | |
| 593 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 594 | + | t.Fatal(err) | |
| 595 | + | } | |
| 596 | + | if got := openDescriptorCount(t); got > before+4 { | |
| 597 | + | t.Fatalf("open descriptors after bounded maintenance = %d, want at most %d", got, before+4) | |
| 598 | + | } | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | func TestRepositoryMaintainBoundsQueuedShardNames(t *testing.T) { | |
| 602 | + | repo := NewRepository(privateDir(t)) | |
| 603 | + | key := sessionKey("named") | |
| 604 | + | root := filepath.Join(repo.sessionPath(key), repositoryObjectsDir) | |
| 605 | + | for i := 0; i < maintenanceBatch*2; i++ { | |
| 606 | + | if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("%02x", i)), 0o700); err != nil { | |
| 607 | + | t.Fatal(err) | |
| 608 | + | } | |
| 609 | + | } | |
| 610 | + | repo.maintenanceCursors = make(map[string]*maintenanceCursor) | |
| 611 | + | state := &sessionMaintenance{marked: make(map[uint64]manifestMaintenance), markDone: true} | |
| 612 | + | for i := 0; i < maintenanceBatch+2; i++ { | |
| 613 | + | budget := maintenanceBatch | |
| 614 | + | if err := repo.sweepSession(context.Background(), key, state, &budget); err != nil { | |
| 615 | + | t.Fatal(err) | |
| 616 | + | } | |
| 617 | + | if state.sweepShard != "" && len(repo.maintenanceCursors) > maxMaintenanceCursors { | |
| 618 | + | t.Fatalf("retained maintenance cursors = %d, want at most %d", len(repo.maintenanceCursors), maxMaintenanceCursors) | |
| 619 | + | } | |
| 620 | + | } | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | func TestRepositoryMaintainResumesCursorAfterDirectoryMutation(t *testing.T) { | |
| 624 | + | repo := NewRepository(privateDir(t)) | |
| 625 | + | if err := os.Mkdir(repo.dir, 0o700); err != nil { | |
| 626 | + | t.Fatal(err) | |
| 627 | + | } | |
| 628 | + | for i := 0; i < maintenanceBatch+1; i++ { | |
| 629 | + | if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("live-%03d", i)), []byte("live"), 0o600); err != nil { | |
| 630 | + | t.Fatal(err) | |
| 631 | + | } | |
| 632 | + | } | |
| 633 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 634 | + | t.Fatal(err) | |
| 635 | + | } | |
| 636 | + | // Mutating entries ahead of the saved cookie must not reset traversal to | |
| 637 | + | // the prefix or lose a stale entry appended after that cookie. | |
| 638 | + | if err := os.Remove(filepath.Join(repo.dir, "live-000")); err != nil { | |
| 639 | + | t.Fatal(err) | |
| 640 | + | } | |
| 641 | + | stale := filepath.Join(repo.dir, ".tmp-appended") | |
| 642 | + | if err := os.WriteFile(stale, []byte("stale"), 0o600); err != nil { | |
| 643 | + | t.Fatal(err) | |
| 644 | + | } | |
| 645 | + | for i := 0; i < 4; i++ { | |
| 646 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 647 | + | t.Fatal(err) | |
| 648 | + | } | |
| 649 | + | if _, err := os.Lstat(stale); errors.Is(err, os.ErrNotExist) { | |
| 650 | + | return | |
| 651 | + | } | |
| 652 | + | } | |
| 653 | + | t.Fatal("stale entry appended after cursor mutation was not eventually removed") | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | func TestRepositoryMaintainDoesNotStarveLaterTemporaryEntries(t *testing.T) { | |
| 657 | + | repo := NewRepository(privateDir(t)) | |
| 658 | + | if err := os.Mkdir(repo.dir, 0o700); err != nil { | |
| 659 | + | t.Fatal(err) | |
| 660 | + | } | |
| 661 | + | // The live entries precede the stale entries in directory insertion order. | |
| 662 | + | // A fresh ReadDir from the start on every call would never reach the latter. | |
| 663 | + | for i := 0; i < maintenanceBatch+1; i++ { | |
| 664 | + | if err := os.WriteFile(filepath.Join(repo.dir, "live-"+string(rune('a'+i))), []byte("live"), 0o600); err != nil { | |
| 665 | + | t.Fatal(err) | |
| 666 | + | } | |
| 667 | + | } | |
| 668 | + | for i := 0; i < maintenanceBatch+1; i++ { | |
| 669 | + | if err := os.WriteFile(filepath.Join(repo.dir, ".tmp-later-"+string(rune('a'+i))), []byte("stale"), 0o600); err != nil { | |
| 670 | + | t.Fatal(err) | |
| 671 | + | } | |
| 672 | + | } | |
| 673 | + | for i := 0; i < 4; i++ { | |
| 674 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 675 | + | t.Fatal(err) | |
| 676 | + | } | |
| 677 | + | } | |
| 678 | + | entries, err := os.ReadDir(repo.dir) | |
| 679 | + | if err != nil { | |
| 680 | + | t.Fatal(err) | |
| 681 | + | } | |
| 682 | + | for _, entry := range entries { | |
| 683 | + | if strings.HasPrefix(entry.Name(), ".tmp-") { | |
| 684 | + | t.Fatalf("stale entry %q was starved", entry.Name()) | |
| 685 | + | } | |
| 686 | + | } | |
| 687 | + | } | |
| 688 | + | ||
| 689 | + | func TestRepositoryMaintainMarksLargeGenerationSetBeforeSweeping(t *testing.T) { | |
| 690 | + | repo := NewRepository(privateDir(t)) | |
| 691 | + | var newest ports.SnapshotPublication | |
| 692 | + | for generation := uint64(1); generation <= maintenanceBatch+2; generation++ { | |
| 693 | + | publication := repositoryPublication(t, "named", generation, []byte(fmt.Sprintf("state-%d", generation))) | |
| 694 | + | if err := repo.Publish(context.Background(), publication); err != nil { | |
| 695 | + | t.Fatal(err) | |
| 696 | + | } | |
| 697 | + | newest = publication | |
| 698 | + | } | |
| 699 | + | key := sessionKey("named") | |
| 700 | + | stale := sha256.Sum256([]byte("stale object")) | |
| 701 | + | stalePath := repo.objectPath(key, stale) | |
| 702 | + | if err := os.MkdirAll(filepath.Dir(stalePath), 0o700); err != nil { | |
| 703 | + | t.Fatal(err) | |
| 704 | + | } | |
| 705 | + | if err := os.WriteFile(stalePath, []byte("stale object"), 0o600); err != nil { | |
| 706 | + | t.Fatal(err) | |
| 707 | + | } | |
| 708 | + | ||
| 709 | + | // The first call has classified only one manifest batch. It must not sweep | |
| 710 | + | // even an otherwise stale object, nor a blob owned by a later manifest. | |
| 711 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 712 | + | t.Fatal(err) | |
| 713 | + | } | |
| 714 | + | if _, err := os.Lstat(stalePath); err != nil { | |
| 715 | + | t.Fatalf("stale object swept before complete mark pass: %v", err) | |
| 716 | + | } | |
| 717 | + | for _, object := range newest.Objects { | |
| 718 | + | if _, err := os.Lstat(repo.objectPath(key, object.Digest)); err != nil { | |
| 719 | + | t.Fatalf("object referenced by not-yet-classified manifest removed: %v", err) | |
| 720 | + | } | |
| 721 | + | } | |
| 722 | + | ||
| 723 | + | for i := 0; i < 600; i++ { | |
| 724 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 725 | + | t.Fatal(err) | |
| 726 | + | } | |
| 727 | + | if _, err := os.Lstat(stalePath); errors.Is(err, os.ErrNotExist) { | |
| 728 | + | break | |
| 729 | + | } | |
| 730 | + | if i == 599 { | |
| 731 | + | t.Fatal("stale object was not eventually swept") | |
| 732 | + | } | |
| 733 | + | } | |
| 734 | + | for _, generation := range []uint64{maintenanceBatch + 1, maintenanceBatch + 2} { | |
| 735 | + | if _, err := os.Lstat(repo.manifestPath(key, generation)); err != nil { | |
| 736 | + | t.Fatalf("newest complete manifest %d removed: %v", generation, err) | |
| 737 | + | } | |
| 738 | + | } | |
| 739 | + | } | |
| 740 | + | ||
| 741 | + | func TestRepositoryMaintainUsesFixedRemovalBatch(t *testing.T) { | |
| 742 | + | repo := NewRepository(privateDir(t)) | |
| 743 | + | if err := os.Mkdir(repo.dir, 0o700); err != nil { | |
| 744 | + | t.Fatal(err) | |
| 745 | + | } | |
| 746 | + | for i := 0; i < maintenanceBatch+1; i++ { | |
| 747 | + | if err := os.WriteFile(filepath.Join(repo.dir, ".tmp-"+string(rune('a'+i))), []byte("stale"), 0o600); err != nil { | |
| 748 | + | t.Fatal(err) | |
| 749 | + | } | |
| 750 | + | } | |
| 751 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 752 | + | t.Fatal(err) | |
| 753 | + | } | |
| 754 | + | entries, err := os.ReadDir(repo.dir) | |
| 755 | + | if err != nil { | |
| 756 | + | t.Fatal(err) | |
| 757 | + | } | |
| 758 | + | if len(entries) != 1 { | |
| 759 | + | t.Fatalf("remaining stale entries = %d, want 1 after one batch", len(entries)) | |
| 760 | + | } | |
| 761 | + | } | |
| 762 | + | ||
| 763 | + | func TestRepositoryMaintainQueuesFetchedSessionsPastWorkBudget(t *testing.T) { | |
| 764 | + | repo := NewRepository(privateDir(t)) | |
| 765 | + | if err := os.MkdirAll(filepath.Join(repo.dir, repositorySessionsDir), 0o700); err != nil { | |
| 766 | + | t.Fatal(err) | |
| 767 | + | } | |
| 768 | + | if err := os.WriteFile(filepath.Join(repo.dir, ".tmp-consume-budget"), []byte("stale"), 0o600); err != nil { | |
| 769 | + | t.Fatal(err) | |
| 770 | + | } | |
| 771 | + | ||
| 772 | + | removed := make(map[string]int) | |
| 773 | + | for i := 0; i < maintenanceBatch+1; i++ { | |
| 774 | + | path := filepath.Join(repo.dir, repositorySessionsDir, fmt.Sprintf(".deleting-%03d", i)) | |
| 775 | + | if err := os.Mkdir(path, 0o700); err != nil { | |
| 776 | + | t.Fatal(err) | |
| 777 | + | } | |
| 778 | + | } | |
| 779 | + | repo.hooks.remove = func(path string) error { | |
| 780 | + | if strings.HasPrefix(filepath.Base(path), ".deleting-") { | |
| 781 | + | removed[path]++ | |
| 782 | + | } | |
| 783 | + | return nil | |
| 784 | + | } | |
| 785 | + | ||
| 786 | + | sessions := filepath.Join(repo.dir, repositorySessionsDir) | |
| 787 | + | f, err := os.Open(sessions) | |
| 788 | + | if err != nil { | |
| 789 | + | t.Fatal(err) | |
| 790 | + | } | |
| 791 | + | fetched, err := f.ReadDir(maintenanceBatch) | |
| 792 | + | if err != nil { | |
| 793 | + | t.Fatal(err) | |
| 794 | + | } | |
| 795 | + | unread, err := f.ReadDir(1) | |
| 796 | + | if err != nil { | |
| 797 | + | t.Fatal(err) | |
| 798 | + | } | |
| 799 | + | if err := f.Close(); err != nil { | |
| 800 | + | t.Fatal(err) | |
| 801 | + | } | |
| 802 | + | queued := fetched[len(fetched)-1].Name() | |
| 803 | + | for i := 0; i < maintenanceBatch+4; i++ { | |
| 804 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 805 | + | t.Fatal(err) | |
| 806 | + | } | |
| 807 | + | if _, err := os.Lstat(filepath.Join(sessions, queued)); errors.Is(err, os.ErrNotExist) { | |
| 808 | + | break | |
| 809 | + | } else if i == maintenanceBatch+3 { | |
| 810 | + | t.Fatalf("fetched but unprocessed session %q = %v, want eventually removed", queued, err) | |
| 811 | + | } | |
| 812 | + | } | |
| 813 | + | if _, err := os.Lstat(filepath.Join(sessions, unread[0].Name())); err != nil { | |
| 814 | + | t.Fatalf("unread session %q = %v, want queued entry to run first", unread[0].Name(), err) | |
| 815 | + | } | |
| 816 | + | ||
| 817 | + | for i := 0; i < maintenanceBatch+4; i++ { | |
| 818 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 819 | + | t.Fatal(err) | |
| 820 | + | } | |
| 821 | + | entries, err := os.ReadDir(sessions) | |
| 822 | + | if err != nil { | |
| 823 | + | t.Fatal(err) | |
| 824 | + | } | |
| 825 | + | if len(entries) == 0 { | |
| 826 | + | break | |
| 827 | + | } | |
| 828 | + | } | |
| 829 | + | entries, err := os.ReadDir(sessions) | |
| 830 | + | if err != nil { | |
| 831 | + | t.Fatal(err) | |
| 832 | + | } | |
| 833 | + | if len(entries) != 0 { | |
| 834 | + | t.Fatalf("queued sessions after resumed maintenance = %d, want none", len(entries)) | |
| 835 | + | } | |
| 836 | + | if len(removed) != maintenanceBatch+1 { | |
| 837 | + | t.Fatalf("removed sessions = %d, want %d", len(removed), maintenanceBatch+1) | |
| 838 | + | } | |
| 839 | + | for path, calls := range removed { | |
| 840 | + | if calls != 1 { | |
| 841 | + | t.Fatalf("remove calls for %q = %d, want one", path, calls) | |
| 842 | + | } | |
| 843 | + | } | |
| 844 | + | } | |
| 845 | + | ||
| 846 | + | func TestRepositoryMaintainClassifiesUnpublishedGenerationsBeforeSweep(t *testing.T) { | |
| 847 | + | for _, head := range []struct { | |
| 848 | + | name string | |
| 849 | + | set func(t *testing.T, repo *Repository, key string) | |
| 850 | + | }{ | |
| 851 | + | { | |
| 852 | + | name: "missing", | |
| 853 | + | set: func(t *testing.T, repo *Repository, key string) { | |
| 854 | + | t.Helper() | |
| 855 | + | if err := os.Remove(repo.headPath(key)); err != nil { | |
| 856 | + | t.Fatal(err) | |
| 857 | + | } | |
| 858 | + | }, | |
| 859 | + | }, | |
| 860 | + | { | |
| 861 | + | name: "corrupt", | |
| 862 | + | set: func(t *testing.T, repo *Repository, key string) { | |
| 863 | + | t.Helper() | |
| 864 | + | if err := os.WriteFile(repo.headPath(key), []byte("corrupt"), 0o600); err != nil { | |
| 865 | + | t.Fatal(err) | |
| 866 | + | } | |
| 867 | + | }, | |
| 868 | + | }, | |
| 869 | + | } { | |
| 870 | + | t.Run(head.name, func(t *testing.T) { | |
| 871 | + | repo := NewRepository(privateDir(t)) | |
| 872 | + | key := sessionKey("named") | |
| 873 | + | publications := make([]ports.SnapshotPublication, 0, maintenanceBatch+2) | |
| 874 | + | for generation := uint64(1); generation <= maintenanceBatch+2; generation++ { | |
| 875 | + | publication := repositoryPublication(t, "named", generation, []byte(fmt.Sprintf("state-%d", generation))) | |
| 876 | + | if err := repo.Publish(context.Background(), publication); err != nil { | |
| 877 | + | t.Fatal(err) | |
| 878 | + | } | |
| 879 | + | publications = append(publications, publication) | |
| 880 | + | } | |
| 881 | + | head.set(t, repo, key) | |
| 882 | + | stale := sha256.Sum256([]byte("unpublished stale object")) | |
| 883 | + | stalePath := repo.objectPath(key, stale) | |
| 884 | + | if err := os.MkdirAll(filepath.Dir(stalePath), 0o700); err != nil { | |
| 885 | + | t.Fatal(err) | |
| 886 | + | } | |
| 887 | + | if err := os.WriteFile(stalePath, []byte("unpublished stale object"), 0o600); err != nil { | |
| 888 | + | t.Fatal(err) | |
| 889 | + | } | |
| 890 | + | ||
| 891 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 892 | + | t.Fatal(err) | |
| 893 | + | } | |
| 894 | + | if _, err := os.Lstat(stalePath); err != nil { | |
| 895 | + | t.Fatalf("stale object swept before full manifest classification: %v", err) | |
| 896 | + | } | |
| 897 | + | for _, publication := range publications { | |
| 898 | + | if _, err := os.Lstat(repo.objectPath(key, publication.Objects[0].Digest)); err != nil { | |
| 899 | + | t.Fatalf("object for unpublished generation %d removed early: %v", publication.Generation, err) | |
| 900 | + | } | |
| 901 | + | } | |
| 902 | + | ||
| 903 | + | for i := 0; i < 600; i++ { | |
| 904 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 905 | + | t.Fatal(err) | |
| 906 | + | } | |
| 907 | + | if _, err := os.Lstat(stalePath); errors.Is(err, os.ErrNotExist) { | |
| 908 | + | break | |
| 909 | + | } else if i == 599 { | |
| 910 | + | t.Fatal("stale object was not swept after complete classification") | |
| 911 | + | } | |
| 912 | + | } | |
| 913 | + | for _, publication := range publications { | |
| 914 | + | if _, err := os.Lstat(repo.objectPath(key, publication.Objects[0].Digest)); err != nil { | |
| 915 | + | t.Fatalf("object for unpublished generation %d removed: %v", publication.Generation, err) | |
| 916 | + | } | |
| 917 | + | } | |
| 918 | + | }) | |
| 919 | + | } | |
| 920 | + | } | |
| 921 | + | ||
| 922 | + | func TestRepositoryMaintainRestartsMarkAfterFailedPublication(t *testing.T) { | |
| 923 | + | repo := NewRepository(privateDir(t)) | |
| 924 | + | key := sessionKey("named") | |
| 925 | + | first := publicationWithTailShard(t, "named", 1, "ff") | |
| 926 | + | stalePaths := make([]string, 0, maintenanceBatch+1) | |
| 927 | + | for i := 0; i < maintenanceBatch+1; i++ { | |
| 928 | + | digest := digestInShard(t, "00", i) | |
| 929 | + | path := repo.objectPath(key, digest) | |
| 930 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 931 | + | t.Fatal(err) | |
| 932 | + | } | |
| 933 | + | if err := os.WriteFile(path, []byte("stale"), 0o600); err != nil { | |
| 934 | + | t.Fatal(err) | |
| 935 | + | } | |
| 936 | + | stalePaths = append(stalePaths, path) | |
| 937 | + | } | |
| 938 | + | if err := repo.Publish(context.Background(), first); err != nil { | |
| 939 | + | t.Fatal(err) | |
| 940 | + | } | |
| 941 | + | ||
| 942 | + | // This completes marking but exhausts the sweep budget in the stale shard, | |
| 943 | + | // leaving the next sweep batch pending. | |
| 944 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 945 | + | t.Fatal(err) | |
| 946 | + | } | |
| 947 | + | staleRemaining := false | |
| 948 | + | for _, path := range stalePaths { | |
| 949 | + | if _, err := os.Lstat(path); err == nil { | |
| 950 | + | staleRemaining = true | |
| 951 | + | break | |
| 952 | + | } | |
| 953 | + | } | |
| 954 | + | if !staleRemaining { | |
| 955 | + | t.Fatal("sweep did not leave a pending stale object") | |
| 956 | + | } | |
| 957 | + | ||
| 958 | + | second := publicationWithTailShard(t, "named", 2, "ff") | |
| 959 | + | injected := errors.New("before HEAD") | |
| 960 | + | repo.hooks.beforeHeadWrite = func(string) error { return injected } | |
| 961 | + | if err := repo.Publish(context.Background(), second); !errors.Is(err, injected) { | |
| 962 | + | t.Fatalf("Publish error = %v, want injected failure", err) | |
| 963 | + | } | |
| 964 | + | repo.hooks.beforeHeadWrite = nil | |
| 965 | + | ||
| 966 | + | for i := 0; i < 20; i++ { | |
| 967 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 968 | + | t.Fatal(err) | |
| 969 | + | } | |
| 970 | + | for _, object := range second.Objects { | |
| 971 | + | if _, err := os.Lstat(repo.objectPath(key, object.Digest)); err != nil { | |
| 972 | + | t.Fatalf("object referenced by failed publication removed on pass %d: %v", i, err) | |
| 973 | + | } | |
| 974 | + | } | |
| 975 | + | staleRemaining = false | |
| 976 | + | for _, path := range stalePaths { | |
| 977 | + | if _, err := os.Lstat(path); err == nil { | |
| 978 | + | staleRemaining = true | |
| 979 | + | break | |
| 980 | + | } | |
| 981 | + | } | |
| 982 | + | } | |
| 983 | + | if staleRemaining { | |
| 984 | + | t.Fatal("stale object was not eventually swept") | |
| 985 | + | } | |
| 986 | + | } | |
| 987 | + | ||
| 988 | + | func publicationWithTailShard(t *testing.T, name string, generation uint64, shard string) ports.SnapshotPublication { | |
| 989 | + | t.Helper() | |
| 990 | + | for i := 0; i < 4096; i++ { | |
| 991 | + | publication := repositoryPublication(t, name, generation, []byte(fmt.Sprintf("state-%d-%d", generation, i))) | |
| 992 | + | if fmt.Sprintf("%02x", publication.Objects[0].Digest[0]) == shard { | |
| 993 | + | return publication | |
| 994 | + | } | |
| 995 | + | } | |
| 996 | + | t.Fatalf("did not find publication tail in shard %q", shard) | |
| 997 | + | return ports.SnapshotPublication{} | |
| 998 | + | } | |
| 999 | + | ||
| 1000 | + | func digestInShard(t *testing.T, shard string, n int) ports.SnapshotDigest { | |
| 1001 | + | t.Helper() | |
| 1002 | + | for i := 0; i < 4096; i++ { | |
| 1003 | + | digest := sha256.Sum256([]byte(fmt.Sprintf("stale-%d-%d", n, i))) | |
| 1004 | + | if fmt.Sprintf("%02x", digest[0]) == shard { | |
| 1005 | + | return digest | |
| 1006 | + | } | |
| 1007 | + | } | |
| 1008 | + | t.Fatalf("did not find stale digest in shard %q", shard) | |
| 1009 | + | return ports.SnapshotDigest{} | |
| 1010 | + | } | |
A internal/adapters/snapshot/offline_kill_tombstone_test.go +53 -0
| @@ -0,0 +1,53 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestRepositoryTombstoneBlocksIncrementalRestoreAndLegacyImport(t *testing.T) { | |
| 13 | + | repo := NewRepository(privateDir(t)) | |
| 14 | + | require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state")))) | |
| 15 | + | require.NoError(t, os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600)) | |
| 16 | + | ||
| 17 | + | require.NoError(t, repo.Tombstone(context.Background(), "named")) | |
| 18 | + | ||
| 19 | + | names, err := repo.List(context.Background()) | |
| 20 | + | require.NoError(t, err) | |
| 21 | + | require.NotContains(t, names, "named") | |
| 22 | + | legacy, err := repo.LoadLegacy(context.Background()) | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | for _, snapshot := range legacy { | |
| 25 | + | require.NotEqual(t, "named", snapshot.Name) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestRepositoryDeleteLegacyRetriesDurabilityAfterTombstone(t *testing.T) { | |
| 30 | + | repo := NewRepository(privateDir(t)) | |
| 31 | + | require.NoError(t, os.Mkdir(repo.dir, 0o700)) | |
| 32 | + | require.NoError(t, os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600)) | |
| 33 | + | require.NoError(t, repo.Tombstone(context.Background(), "named")) | |
| 34 | + | ||
| 35 | + | calls := 0 | |
| 36 | + | repo.hooks.syncDirectory = func(string) error { | |
| 37 | + | calls++ | |
| 38 | + | if calls == 1 { | |
| 39 | + | return os.ErrPermission | |
| 40 | + | } | |
| 41 | + | return nil | |
| 42 | + | } | |
| 43 | + | require.ErrorIs(t, repo.DeleteLegacy(context.Background(), "named"), os.ErrPermission) | |
| 44 | + | ||
| 45 | + | // A retry after process restart has no in-memory pending-sync state. | |
| 46 | + | repo = NewRepository(repo.dir) | |
| 47 | + | repo.hooks.syncDirectory = func(string) error { | |
| 48 | + | calls++ | |
| 49 | + | return nil | |
| 50 | + | } | |
| 51 | + | require.NoError(t, repo.DeleteLegacy(context.Background(), "named")) | |
| 52 | + | require.Equal(t, 2, calls, "explicit purge retries only the pending source-directory sync") | |
| 53 | + | } | |
A internal/adapters/snapshot/path.go +78 -0
| @@ -0,0 +1,78 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/sha256" | |
| 5 | + | "encoding/hex" | |
| 6 | + | "fmt" | |
| 7 | + | "path/filepath" | |
| 8 | + | "regexp" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`) | |
| 16 | + | ||
| 17 | + | const maxSnapshotFileSize = 16 + (256 << 20) | |
| 18 | + | ||
| 19 | + | // filenameForName maps a session name to its deterministic legacy filename. | |
| 20 | + | // It remains only for the one-way v3 import path. | |
| 21 | + | func filenameForName(name string) string { | |
| 22 | + | if safeNameRE.MatchString(name) { | |
| 23 | + | return name + ".snap" | |
| 24 | + | } | |
| 25 | + | sum := sha256.Sum256([]byte(name)) | |
| 26 | + | return "@" + hex.EncodeToString(sum[:])[:40] + ".snap" | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (r *Repository) sessionPath(key string) string { | |
| 30 | + | return filepath.Join(r.dir, repositorySessionsDir, key) | |
| 31 | + | } | |
| 32 | + | func (r *Repository) objectPath(key string, digest ports.SnapshotDigest) string { | |
| 33 | + | hexDigest := hex.EncodeToString(digest[:]) | |
| 34 | + | return filepath.Join(r.sessionPath(key), repositoryObjectsDir, hexDigest[:2], hexDigest) | |
| 35 | + | } | |
| 36 | + | func (r *Repository) manifestPath(key string, generation uint64) string { | |
| 37 | + | return filepath.Join(r.sessionPath(key), repositoryGenerations, generationFilename(generation)) | |
| 38 | + | } | |
| 39 | + | func (r *Repository) headPath(key string) string { | |
| 40 | + | return filepath.Join(r.sessionPath(key), repositoryHead) | |
| 41 | + | } | |
| 42 | + | func sessionKey(name string) string { | |
| 43 | + | if safeNameRE.MatchString(name) && name != "." && name != ".." { | |
| 44 | + | return name | |
| 45 | + | } | |
| 46 | + | sum := sha256.Sum256([]byte(name)) | |
| 47 | + | return "@" + hex.EncodeToString(sum[:]) | |
| 48 | + | } | |
| 49 | + | func canonicalSessionKey(key string) bool { | |
| 50 | + | if safeNameRE.MatchString(key) && key != "." && key != ".." { | |
| 51 | + | return true | |
| 52 | + | } | |
| 53 | + | if len(key) != 65 || key[0] != '@' { | |
| 54 | + | return false | |
| 55 | + | } | |
| 56 | + | _, err := hex.DecodeString(key[1:]) | |
| 57 | + | return err == nil && strings.ToLower(key[1:]) == key[1:] | |
| 58 | + | } | |
| 59 | + | func generationFilename(generation uint64) string { | |
| 60 | + | return fmt.Sprintf("%0*d.manifest", generationWidth, generation) | |
| 61 | + | } | |
| 62 | + | func parseGenerationFilename(name string) (uint64, bool) { | |
| 63 | + | if !strings.HasSuffix(name, ".manifest") || len(name) != generationWidth+len(".manifest") { | |
| 64 | + | return 0, false | |
| 65 | + | } | |
| 66 | + | n := strings.TrimSuffix(name, ".manifest") | |
| 67 | + | generation, err := strconv.ParseUint(n, 10, 64) | |
| 68 | + | return generation, err == nil && generationFilename(generation) == name && generation != 0 | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func parseObjectDigest(name string) (ports.SnapshotDigest, bool) { | |
| 72 | + | var digest ports.SnapshotDigest | |
| 73 | + | if len(name) != hex.EncodedLen(len(digest)) || strings.ToLower(name) != name { | |
| 74 | + | return digest, false | |
| 75 | + | } | |
| 76 | + | _, err := hex.Decode(digest[:], []byte(name)) | |
| 77 | + | return digest, err == nil | |
| 78 | + | } | |
A internal/adapters/snapshot/path_security_test.go +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) { | |
| 14 | + | for _, target := range []string{repositoryGenerations, repositoryObjectsDir} { | |
| 15 | + | t.Run(target, func(t *testing.T) { | |
| 16 | + | repo := NewRepository(privateDir(t)) | |
| 17 | + | publication := repositoryPublication(t, "named", 1, []byte("state")) | |
| 18 | + | if err := repo.Publish(context.Background(), publication); err != nil { | |
| 19 | + | t.Fatal(err) | |
| 20 | + | } | |
| 21 | + | key := sessionKey(publication.Name) | |
| 22 | + | inside := filepath.Join(repo.sessionPath(key), target) | |
| 23 | + | outside := t.TempDir() | |
| 24 | + | guard := filepath.Join(outside, "must-not-change") | |
| 25 | + | if err := os.WriteFile(guard, []byte("guard"), 0o600); err != nil { | |
| 26 | + | t.Fatal(err) | |
| 27 | + | } | |
| 28 | + | if err := os.Rename(inside, inside+"-real"); err != nil { | |
| 29 | + | t.Fatal(err) | |
| 30 | + | } | |
| 31 | + | if err := os.Symlink(outside, inside); err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | if _, err := repo.Load(context.Background(), publication.Name); err == nil { | |
| 35 | + | t.Fatal("Load succeeded through symlinked repository component") | |
| 36 | + | } | |
| 37 | + | // Maintenance must reject the replacement rather than following it. | |
| 38 | + | if err := repo.Maintain(context.Background()); err == nil { | |
| 39 | + | t.Fatal("Maintain succeeded through symlinked repository component") | |
| 40 | + | } | |
| 41 | + | got, err := os.ReadFile(guard) | |
| 42 | + | if err != nil || string(got) != "guard" { | |
| 43 | + | t.Fatalf("outside guard = %q, %v", got, err) | |
| 44 | + | } | |
| 45 | + | }) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestStatReturnsInjectedCloseErrorWithContext(t *testing.T) { | |
| 50 | + | repo := NewRepository(privateDir(t)) | |
| 51 | + | if err := repo.ensurePrivateDirectory(repo.dir); err != nil { | |
| 52 | + | t.Fatal(err) | |
| 53 | + | } | |
| 54 | + | path := filepath.Join(repo.dir, "state") | |
| 55 | + | if err := os.WriteFile(path, []byte("state"), 0o600); err != nil { | |
| 56 | + | t.Fatal(err) | |
| 57 | + | } | |
| 58 | + | closeCause := errors.New("injected stat close failure") | |
| 59 | + | repo.hooks.closeDescriptor = func(got string) error { | |
| 60 | + | if got == "close snapshot file" { | |
| 61 | + | return closeCause | |
| 62 | + | } | |
| 63 | + | return nil | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | _, err := repo.stat(path) | |
| 67 | + | if !errors.Is(err, closeCause) || !strings.Contains(err.Error(), "close snapshot file") { | |
| 68 | + | t.Fatalf("stat error = %v, want contextual close failure", err) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestRepositoryLoadLegacyChargesUnrelatedRootEntries(t *testing.T) { | |
| 73 | + | repo := NewRepository(privateDir(t)) | |
| 74 | + | if err := os.MkdirAll(repo.dir, 0o700); err != nil { | |
| 75 | + | t.Fatal(err) | |
| 76 | + | } | |
| 77 | + | for i := 0; i <= maxDirectoryTraversalEntries; i++ { | |
| 78 | + | if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("unrelated-%05d", i)), nil, 0o600); err != nil { | |
| 79 | + | t.Fatal(err) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | if _, err := repo.LoadLegacy(context.Background()); !errors.Is(err, ErrDirectoryTraversalBudget) { | |
| 83 | + | t.Fatalf("LoadLegacy error = %v, want traversal budget error", err) | |
| 84 | + | } | |
| 85 | + | } | |
A internal/adapters/snapshot/publish.go +331 -0
| @@ -0,0 +1,331 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "crypto/sha256" | |
| 7 | + | "fmt" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | codec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPublication) error { | |
| 14 | + | if err := ctx.Err(); err != nil { | |
| 15 | + | return err | |
| 16 | + | } | |
| 17 | + | key := sessionKey(publication.Name) | |
| 18 | + | lock := r.lockSession(key) | |
| 19 | + | defer r.unlockSession(lock) | |
| 20 | + | if err := ctx.Err(); err != nil { | |
| 21 | + | return err | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | refs, err := validatePublicationManifest(publication) | |
| 25 | + | if err != nil { | |
| 26 | + | return err | |
| 27 | + | } | |
| 28 | + | if err := ctx.Err(); err != nil { | |
| 29 | + | return err | |
| 30 | + | } | |
| 31 | + | // A failed publication can still leave immutable blobs or a manifest behind. | |
| 32 | + | // Invalidate an in-progress GC mark before creating any publication storage. | |
| 33 | + | r.invalidateStorageEpoch(key) | |
| 34 | + | if err := r.ensureSession(key); err != nil { | |
| 35 | + | return err | |
| 36 | + | } | |
| 37 | + | // The common replay path needs neither a manifest decode nor a reference | |
| 38 | + | // map. Checking the immutable bytes directly also keeps its descriptor | |
| 39 | + | // reads beneath the pinned repository root. | |
| 40 | + | if generation, _, err := r.readHead(key); err == nil && generation == publication.Generation { | |
| 41 | + | if current, err := r.readBounded(r.manifestPath(key, generation)); err == nil && bytes.Equal(current, publication.Manifest) { | |
| 42 | + | return nil | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | current, currentManifest, currentRefs, err := r.currentPublication(ctx, publication.Name, key) | |
| 47 | + | if err != nil { | |
| 48 | + | return err | |
| 49 | + | } | |
| 50 | + | if publication.Generation < current || publication.Generation > current+1 { | |
| 51 | + | return fmt.Errorf("snapshot generation %d, current %d: immutable conflict", publication.Generation, current) | |
| 52 | + | } | |
| 53 | + | if publication.Generation == current { | |
| 54 | + | if !bytes.Equal(currentManifest, publication.Manifest) { | |
| 55 | + | return fmt.Errorf("snapshot generation %d: immutable conflict", publication.Generation) | |
| 56 | + | } | |
| 57 | + | return nil | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Retained refs from the authoritative generation were verified when their | |
| 61 | + | // immutable files were installed. They are intentionally not re-read when a | |
| 62 | + | // producer supplies them again. Every other referenced object is checked | |
| 63 | + | // from disk once, or validated and installed from the supplied bytes. | |
| 64 | + | supplied, err := suppliedNecessaryObjects(refs, currentRefs, publication.Objects) | |
| 65 | + | if err != nil { | |
| 66 | + | return err | |
| 67 | + | } | |
| 68 | + | for digest, ref := range refs { | |
| 69 | + | if retainedObject(currentRefs, digest, ref) { | |
| 70 | + | continue | |
| 71 | + | } | |
| 72 | + | if err := ctx.Err(); err != nil { | |
| 73 | + | return err | |
| 74 | + | } | |
| 75 | + | path := r.objectPath(key, digest) | |
| 76 | + | exists, err := r.verifyObjectFile(path, digest, ref) | |
| 77 | + | if err != nil { | |
| 78 | + | return fmt.Errorf("verify object %x: %w", digest, err) | |
| 79 | + | } | |
| 80 | + | if exists { | |
| 81 | + | continue | |
| 82 | + | } | |
| 83 | + | objects := supplied[digest] | |
| 84 | + | if len(objects) == 0 { | |
| 85 | + | return fmt.Errorf("missing referenced object %x", digest) | |
| 86 | + | } | |
| 87 | + | object, err := r.validateSuppliedObject(objects, digest, ref) | |
| 88 | + | if err != nil { | |
| 89 | + | return err | |
| 90 | + | } | |
| 91 | + | if r.hooks.beforeBlobWrite != nil { | |
| 92 | + | if err := r.hooks.beforeBlobWrite(path); err != nil { | |
| 93 | + | return err | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | if err := ctx.Err(); err != nil { | |
| 97 | + | return err | |
| 98 | + | } | |
| 99 | + | if err := r.writeImmutable(path, object.Data, func(existing []byte) error { | |
| 100 | + | if r.objectDigest(existing) != digest || !validObject(existing, ref) { | |
| 101 | + | return fmt.Errorf("existing immutable object is invalid") | |
| 102 | + | } | |
| 103 | + | return nil | |
| 104 | + | }); err != nil { | |
| 105 | + | return fmt.Errorf("write object: %w", err) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | manifestPath := r.manifestPath(key, publication.Generation) | |
| 110 | + | existing, exists, err := r.readOptionalBounded(manifestPath) | |
| 111 | + | if err != nil { | |
| 112 | + | return err | |
| 113 | + | } | |
| 114 | + | if exists { | |
| 115 | + | if !bytes.Equal(existing, publication.Manifest) { | |
| 116 | + | return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation) | |
| 117 | + | } | |
| 118 | + | } else { | |
| 119 | + | if r.hooks.beforeManifestWrite != nil { | |
| 120 | + | if err := r.hooks.beforeManifestWrite(manifestPath); err != nil { | |
| 121 | + | return err | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | if err := ctx.Err(); err != nil { | |
| 125 | + | return err | |
| 126 | + | } | |
| 127 | + | if err := r.writeImmutable(manifestPath, publication.Manifest, func(existing []byte) error { | |
| 128 | + | if !bytes.Equal(existing, publication.Manifest) { | |
| 129 | + | return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation) | |
| 130 | + | } | |
| 131 | + | return nil | |
| 132 | + | }); err != nil { | |
| 133 | + | return fmt.Errorf("write manifest: %w", err) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | if r.hooks.beforeHeadWrite != nil { | |
| 137 | + | if err := r.hooks.beforeHeadWrite(r.headPath(key)); err != nil { | |
| 138 | + | return err | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | if err := ctx.Err(); err != nil { | |
| 142 | + | return err | |
| 143 | + | } | |
| 144 | + | if err := r.writeMutable(r.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest))); err != nil { | |
| 145 | + | return fmt.Errorf("write HEAD: %w", err) | |
| 146 | + | } | |
| 147 | + | return nil | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // currentPublication takes the normal HEAD path without loading every object | |
| 151 | + | // in the current generation. A damaged HEAD or manifest still uses the full | |
| 152 | + | // fallback scan, because only Load's object validation can establish a safe | |
| 153 | + | // replacement authoritative generation in that recovery case. | |
| 154 | + | func (r *Repository) currentPublication(ctx context.Context, name, key string) (uint64, []byte, map[ports.SnapshotDigest]codec.ObjectRef, error) { | |
| 155 | + | generation, digest, err := r.readHead(key) | |
| 156 | + | if err == nil { | |
| 157 | + | data, readErr := r.readBounded(r.manifestPath(key, generation)) | |
| 158 | + | if readErr == nil && sha256.Sum256(data) == digest { | |
| 159 | + | refs, validateErr := validateManifest(data, name, generation) | |
| 160 | + | if validateErr == nil { | |
| 161 | + | return generation, data, refs, nil | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | generation, data, err := r.currentGeneration(ctx, name, key) | |
| 167 | + | if err != nil || generation == 0 { | |
| 168 | + | return generation, data, nil, err | |
| 169 | + | } | |
| 170 | + | refs, err := validateManifest(data, name, generation) | |
| 171 | + | if err != nil { | |
| 172 | + | return 0, nil, nil, err | |
| 173 | + | } | |
| 174 | + | return generation, data, refs, nil | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func validatePublication(p ports.SnapshotPublication) (map[ports.SnapshotDigest]codec.ObjectRef, map[ports.SnapshotDigest]ports.SnapshotObject, error) { | |
| 178 | + | refs, err := validatePublicationManifest(p) | |
| 179 | + | if err != nil { | |
| 180 | + | return nil, nil, err | |
| 181 | + | } | |
| 182 | + | supplied, err := suppliedNecessaryObjects(refs, nil, p.Objects) | |
| 183 | + | if err != nil { | |
| 184 | + | return nil, nil, err | |
| 185 | + | } | |
| 186 | + | objects := make(map[ports.SnapshotDigest]ports.SnapshotObject, len(supplied)) | |
| 187 | + | for digest, entries := range supplied { | |
| 188 | + | object, err := validateSuppliedObject(entries, digest, refs[digest], nil) | |
| 189 | + | if err != nil { | |
| 190 | + | return nil, nil, err | |
| 191 | + | } | |
| 192 | + | objects[digest] = object | |
| 193 | + | } | |
| 194 | + | return refs, objects, nil | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func validatePublicationManifest(p ports.SnapshotPublication) (map[ports.SnapshotDigest]codec.ObjectRef, error) { | |
| 198 | + | return validateManifest(p.Manifest, p.Name, p.Generation) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func validateManifest(data []byte, name string, generation uint64) (map[ports.SnapshotDigest]codec.ObjectRef, error) { | |
| 202 | + | manifest, err := codec.UnmarshalManifest(data) | |
| 203 | + | if err != nil { | |
| 204 | + | return nil, fmt.Errorf("invalid manifest: %w", err) | |
| 205 | + | } | |
| 206 | + | if manifest.Name != name || manifest.Generation != generation || generation == 0 { | |
| 207 | + | return nil, fmt.Errorf("manifest name or generation does not match publication") | |
| 208 | + | } | |
| 209 | + | refs := manifestRefs(manifest) | |
| 210 | + | if refs == nil { | |
| 211 | + | return nil, fmt.Errorf("conflicting manifest references") | |
| 212 | + | } | |
| 213 | + | if !withinGenerationBudget(len(data), refs) { | |
| 214 | + | return nil, fmt.Errorf("snapshot generation too large") | |
| 215 | + | } | |
| 216 | + | return refs, nil | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // suppliedNecessaryObjects first rejects unreferenced input without inspecting | |
| 220 | + | // its payload. Objects retained from currentRefs cannot affect publication and | |
| 221 | + | // are left out so callers do not pay to hash or copy a full history again. | |
| 222 | + | func suppliedNecessaryObjects(refs, currentRefs map[ports.SnapshotDigest]codec.ObjectRef, supplied []ports.SnapshotObject) (map[ports.SnapshotDigest][]ports.SnapshotObject, error) { | |
| 223 | + | out := make(map[ports.SnapshotDigest][]ports.SnapshotObject) | |
| 224 | + | for _, object := range supplied { | |
| 225 | + | if _, used := refs[object.Digest]; !used { | |
| 226 | + | return nil, fmt.Errorf("unreferenced object") | |
| 227 | + | } | |
| 228 | + | if retainedObject(currentRefs, object.Digest, refs[object.Digest]) { | |
| 229 | + | continue | |
| 230 | + | } | |
| 231 | + | out[object.Digest] = append(out[object.Digest], object) | |
| 232 | + | } | |
| 233 | + | return out, nil | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func retainedObject(currentRefs map[ports.SnapshotDigest]codec.ObjectRef, digest ports.SnapshotDigest, ref codec.ObjectRef) bool { | |
| 237 | + | currentRef, ok := currentRefs[digest] | |
| 238 | + | return ok && currentRef == ref | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func (r *Repository) validateSuppliedObject(objects []ports.SnapshotObject, digest ports.SnapshotDigest, ref codec.ObjectRef) (ports.SnapshotObject, error) { | |
| 242 | + | return validateSuppliedObject(objects, digest, ref, r) | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func validateSuppliedObject(objects []ports.SnapshotObject, digest ports.SnapshotDigest, ref codec.ObjectRef, repository *Repository) (ports.SnapshotObject, error) { | |
| 246 | + | object := objects[0] | |
| 247 | + | for _, candidate := range objects { | |
| 248 | + | actual := sha256.Sum256(candidate.Data) | |
| 249 | + | if repository != nil { | |
| 250 | + | actual = repository.objectDigest(candidate.Data) | |
| 251 | + | } | |
| 252 | + | if actual != candidate.Digest || candidate.Digest != digest { | |
| 253 | + | return ports.SnapshotObject{}, fmt.Errorf("object digest mismatch") | |
| 254 | + | } | |
| 255 | + | if !bytes.Equal(candidate.Data, object.Data) { | |
| 256 | + | return ports.SnapshotObject{}, fmt.Errorf("conflicting object") | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | kind, payload, err := codec.PreflightObject(object.Data) | |
| 260 | + | if err != nil || kind != ref.Kind || len(object.Data) != int(ref.Size) || len(payload) == 0 { | |
| 261 | + | return ports.SnapshotObject{}, fmt.Errorf("invalid object envelope") | |
| 262 | + | } | |
| 263 | + | if repository != nil && repository.hooks.beforeObjectCopy != nil { | |
| 264 | + | repository.hooks.beforeObjectCopy(object.Data) | |
| 265 | + | } | |
| 266 | + | return ports.SnapshotObject{Digest: digest, Data: append([]byte(nil), object.Data...)}, nil | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func withinGenerationBudget(manifestSize int, refs map[ports.SnapshotDigest]codec.ObjectRef) bool { | |
| 270 | + | if manifestSize < 0 || manifestSize > maxRepositoryRead { | |
| 271 | + | return false | |
| 272 | + | } | |
| 273 | + | total := uint64(manifestSize) | |
| 274 | + | for _, ref := range refs { | |
| 275 | + | if uint64(ref.Size) > uint64(maxRepositoryRead)-total { | |
| 276 | + | return false | |
| 277 | + | } | |
| 278 | + | total += uint64(ref.Size) | |
| 279 | + | } | |
| 280 | + | return true | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func manifestRefs(manifest codec.Manifest) map[ports.SnapshotDigest]codec.ObjectRef { | |
| 284 | + | refs := make(map[ports.SnapshotDigest]codec.ObjectRef) | |
| 285 | + | add := func(ref codec.ObjectRef) bool { | |
| 286 | + | if old, ok := refs[ref.Digest]; ok && old != ref { | |
| 287 | + | return false | |
| 288 | + | } | |
| 289 | + | refs[ref.Digest] = ref | |
| 290 | + | return true | |
| 291 | + | } | |
| 292 | + | for _, tab := range manifest.Tabs { | |
| 293 | + | for _, pane := range tab.Panes { | |
| 294 | + | for _, ref := range pane.Sealed { | |
| 295 | + | if !add(ref) { | |
| 296 | + | return nil | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | if !add(pane.Tail) || !add(pane.Visible) { | |
| 300 | + | return nil | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | return refs | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | func validObject(data []byte, ref codec.ObjectRef) bool { | |
| 308 | + | kind, payload, err := codec.PreflightObject(data) | |
| 309 | + | return err == nil && kind == ref.Kind && len(payload) > 0 && len(data) == int(ref.Size) | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | func (r *Repository) objectDigest(data []byte) ports.SnapshotDigest { | |
| 313 | + | if r.hooks.beforeObjectHash != nil { | |
| 314 | + | r.hooks.beforeObjectHash(data) | |
| 315 | + | } | |
| 316 | + | return sha256.Sum256(data) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | func (r *Repository) verifyObjectFile(path string, digest ports.SnapshotDigest, ref codec.ObjectRef) (bool, error) { | |
| 320 | + | if r.hooks.beforeObjectRead != nil { | |
| 321 | + | r.hooks.beforeObjectRead(path) | |
| 322 | + | } | |
| 323 | + | data, exists, err := r.readOptionalBounded(path) | |
| 324 | + | if err != nil || !exists { | |
| 325 | + | return exists, err | |
| 326 | + | } | |
| 327 | + | if r.objectDigest(data) != digest || !validObject(data, ref) { | |
| 328 | + | return true, fmt.Errorf("existing immutable object is invalid") | |
| 329 | + | } | |
| 330 | + | return true, nil | |
| 331 | + | } | |
A internal/adapters/snapshot/publish_efficiency_test.go +198 -0
| @@ -0,0 +1,198 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/sha256" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | codec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestRepositoryPublishSkipsUnchangedTenThousandObjectHistory(t *testing.T) { | |
| 16 | + | const historyObjects = 10_000 | |
| 17 | + | ||
| 18 | + | repo := NewRepository(privateDir(t)) | |
| 19 | + | first, second := largeIncrementalPublications(t, "named", historyObjects) | |
| 20 | + | seedCompletePublication(t, repo, first) | |
| 21 | + | ||
| 22 | + | accounting := installFilesystemPublishAccounting(repo) | |
| 23 | + | if err := repo.Publish(context.Background(), second); err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | // Only the new mutable tail and visible objects need work. The 10k sealed | |
| 27 | + | // objects are retained history supplied again by the producer, not input | |
| 28 | + | // that Publish needs to read, hash, copy, or write. | |
| 29 | + | requireFilesystemPublishAccounting(t, accounting, filesystemPublishAccounting{ | |
| 30 | + | objectReads: 2, objectHashes: 2, objectCopies: 2, | |
| 31 | + | tempCreates: 4, tempWrites: 4, fileSyncs: 4, | |
| 32 | + | objectWrites: 2, manifestWrites: 1, headWrites: 1, | |
| 33 | + | immutableInstalls: 3, mutableRenames: 1, tempRemoves: 3, directorySyncs: 4, | |
| 34 | + | }) | |
| 35 | + | ||
| 36 | + | // Replaying the now-current generation still parses the 10k references, but | |
| 37 | + | // must not allocate one object-sized buffer per retained history entry. | |
| 38 | + | allocations := testing.AllocsPerRun(3, func() { | |
| 39 | + | if err := repo.Publish(context.Background(), second); err != nil { | |
| 40 | + | panic(err) | |
| 41 | + | } | |
| 42 | + | }) | |
| 43 | + | if allocations > 300 { | |
| 44 | + | t.Fatalf("unchanged 10k history Publish allocations = %.0f, want <= 300; retained objects were likely copied", allocations) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | got, err := repo.Load(context.Background(), second.Name) | |
| 48 | + | if err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | if got.Generation != second.Generation { | |
| 52 | + | t.Fatalf("published generation = %d, want %d", got.Generation, second.Generation) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | type filesystemPublishAccounting struct { | |
| 57 | + | objectReads, objectHashes, objectCopies int | |
| 58 | + | tempCreates, tempWrites, fileSyncs int | |
| 59 | + | objectWrites, manifestWrites, headWrites int | |
| 60 | + | immutableInstalls, mutableRenames, tempRemoves, directorySyncs int | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func installFilesystemPublishAccounting(repo *Repository) *filesystemPublishAccounting { | |
| 64 | + | accounting := &filesystemPublishAccounting{} | |
| 65 | + | repo.hooks.beforeObjectRead = func(string) { accounting.objectReads++ } | |
| 66 | + | repo.hooks.beforeObjectHash = func([]byte) { accounting.objectHashes++ } | |
| 67 | + | repo.hooks.beforeObjectCopy = func([]byte) { accounting.objectCopies++ } | |
| 68 | + | repo.hooks.createTemp = func(string) error { accounting.tempCreates++; return nil } | |
| 69 | + | repo.hooks.writeTemp = func(string) error { accounting.tempWrites++; return nil } | |
| 70 | + | repo.hooks.syncFile = func(string) error { accounting.fileSyncs++; return nil } | |
| 71 | + | repo.hooks.beforeBlobWrite = func(string) error { accounting.objectWrites++; return nil } | |
| 72 | + | repo.hooks.beforeManifestWrite = func(string) error { accounting.manifestWrites++; return nil } | |
| 73 | + | repo.hooks.beforeHeadWrite = func(string) error { accounting.headWrites++; return nil } | |
| 74 | + | repo.hooks.installImmutable = func(string) error { accounting.immutableInstalls++; return nil } | |
| 75 | + | repo.hooks.rename = func(string) error { accounting.mutableRenames++; return nil } | |
| 76 | + | repo.hooks.remove = func(string) error { accounting.tempRemoves++; return nil } | |
| 77 | + | repo.hooks.syncDirectory = func(string) error { accounting.directorySyncs++; return nil } | |
| 78 | + | return accounting | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func requireFilesystemPublishAccounting(t *testing.T, got *filesystemPublishAccounting, want filesystemPublishAccounting) { | |
| 82 | + | t.Helper() | |
| 83 | + | if *got != want { | |
| 84 | + | t.Fatalf("filesystem Publish accounting = %+v, want %+v", *got, want) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func BenchmarkRepositoryPublishUnchangedTenThousandObjectHistory(b *testing.B) { | |
| 89 | + | first, second := largeIncrementalPublications(b, "named", 10_000) | |
| 90 | + | repo := NewRepository(filepath.Join(b.TempDir(), "vev")) | |
| 91 | + | seedCompletePublication(b, repo, first) | |
| 92 | + | accounting := installFilesystemPublishAccounting(repo) | |
| 93 | + | ||
| 94 | + | b.ReportAllocs() | |
| 95 | + | b.ResetTimer() | |
| 96 | + | for b.Loop() { | |
| 97 | + | if err := repo.Publish(context.Background(), second); err != nil { | |
| 98 | + | b.Fatal(err) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | b.StopTimer() | |
| 102 | + | // The benchmark deliberately replays the current generation so it can run | |
| 103 | + | // repeatedly without fixture writes. Its bounds catch a regression that | |
| 104 | + | // reads, hashes, or copies all 10k retained objects on the fast path. | |
| 105 | + | if accounting.objectReads > 2 || accounting.objectHashes > 2 || accounting.objectCopies > 2 { | |
| 106 | + | b.Fatalf("unchanged 10k history per-run object work = %d/%d/%d, want <= 2/2/2", accounting.objectReads, accounting.objectHashes, accounting.objectCopies) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func TestRepositoryPublishVerifiesNecessaryExistingObjectOnce(t *testing.T) { | |
| 111 | + | repo := NewRepository(privateDir(t)) | |
| 112 | + | first := repositoryPublication(t, "named", 1, []byte("first")) | |
| 113 | + | if err := repo.Publish(context.Background(), first); err != nil { | |
| 114 | + | t.Fatal(err) | |
| 115 | + | } | |
| 116 | + | second := repositoryPublication(t, "named", 2, []byte("second")) | |
| 117 | + | newTail := second.Objects[0] | |
| 118 | + | key := sessionKey(second.Name) | |
| 119 | + | if err := os.MkdirAll(filepath.Dir(repo.objectPath(key, newTail.Digest)), 0o700); err != nil { | |
| 120 | + | t.Fatal(err) | |
| 121 | + | } | |
| 122 | + | if err := os.WriteFile(repo.objectPath(key, newTail.Digest), newTail.Data, 0o600); err != nil { | |
| 123 | + | t.Fatal(err) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | var reads, hashes, copies int | |
| 127 | + | repo.hooks.beforeObjectRead = func(string) { reads++ } | |
| 128 | + | repo.hooks.beforeObjectHash = func([]byte) { hashes++ } | |
| 129 | + | repo.hooks.beforeObjectCopy = func([]byte) { copies++ } | |
| 130 | + | if err := repo.Publish(context.Background(), second); err != nil { | |
| 131 | + | t.Fatal(err) | |
| 132 | + | } | |
| 133 | + | // The existing new tail is checked from disk once. The unchanged visible | |
| 134 | + | // object is retained from generation one and needs no further work. | |
| 135 | + | if reads != 1 || hashes != 1 || copies != 0 { | |
| 136 | + | t.Fatalf("necessary object reads/hashes/copies = %d/%d/%d, want 1/1/0", reads, hashes, copies) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func largeIncrementalPublications(t testing.TB, name string, count int) (ports.SnapshotPublication, ports.SnapshotPublication) { | |
| 141 | + | t.Helper() | |
| 142 | + | sealed := make([]codec.ObjectRef, 0, count) | |
| 143 | + | objects := make([]ports.SnapshotObject, 0, count+2) | |
| 144 | + | for i := range count { | |
| 145 | + | object, err := codec.MarshalObject(codec.HistoryChunk, []byte(fmt.Sprintf("history-%d", i))) | |
| 146 | + | if err != nil { | |
| 147 | + | t.Fatal(err) | |
| 148 | + | } | |
| 149 | + | sealed = append(sealed, codec.ObjectRef{Kind: codec.HistoryChunk, Digest: object.Digest, Size: uint32(len(object.Data))}) | |
| 150 | + | objects = append(objects, object) | |
| 151 | + | } | |
| 152 | + | makePublication := func(generation uint64, tailPayload, visiblePayload string) ports.SnapshotPublication { | |
| 153 | + | t.Helper() | |
| 154 | + | tail, err := codec.MarshalObject(codec.HistoryTail, []byte(tailPayload)) | |
| 155 | + | if err != nil { | |
| 156 | + | t.Fatal(err) | |
| 157 | + | } | |
| 158 | + | visible, err := codec.MarshalObject(codec.Visible, []byte(visiblePayload)) | |
| 159 | + | if err != nil { | |
| 160 | + | t.Fatal(err) | |
| 161 | + | } | |
| 162 | + | manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Sealed: sealed, Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}}) | |
| 163 | + | if err != nil { | |
| 164 | + | t.Fatal(err) | |
| 165 | + | } | |
| 166 | + | return ports.SnapshotPublication{Name: name, Generation: generation, Manifest: manifest, Objects: append(append([]ports.SnapshotObject(nil), objects...), tail, visible)} | |
| 167 | + | } | |
| 168 | + | return makePublication(1, "tail-1", "visible-1"), makePublication(2, "tail-2", "visible-2") | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func seedCompletePublication(t testing.TB, repo *Repository, publication ports.SnapshotPublication) { | |
| 172 | + | t.Helper() | |
| 173 | + | key := sessionKey(publication.Name) | |
| 174 | + | if err := repo.ensureSession(key); err != nil { | |
| 175 | + | t.Fatal(err) | |
| 176 | + | } | |
| 177 | + | manifest, err := codec.UnmarshalManifest(publication.Manifest) | |
| 178 | + | if err != nil { | |
| 179 | + | t.Fatal(err) | |
| 180 | + | } | |
| 181 | + | for _, object := range publication.Objects { | |
| 182 | + | if err := os.MkdirAll(filepath.Dir(repo.objectPath(key, object.Digest)), 0o700); err != nil { | |
| 183 | + | t.Fatal(err) | |
| 184 | + | } | |
| 185 | + | if err := os.WriteFile(repo.objectPath(key, object.Digest), object.Data, 0o600); err != nil { | |
| 186 | + | t.Fatal(err) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | if err := os.WriteFile(repo.manifestPath(key, publication.Generation), publication.Manifest, 0o600); err != nil { | |
| 190 | + | t.Fatal(err) | |
| 191 | + | } | |
| 192 | + | if err := os.WriteFile(repo.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest)), 0o600); err != nil { | |
| 193 | + | t.Fatal(err) | |
| 194 | + | } | |
| 195 | + | if refs := manifestRefs(manifest); refs == nil { | |
| 196 | + | t.Fatal("invalid seeded manifest") | |
| 197 | + | } | |
| 198 | + | } | |
A internal/adapters/snapshot/repository.go +178 -0
| @@ -0,0 +1,178 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const ( | |
| 10 | + | repositorySessionsDir = "sessions" | |
| 11 | + | repositoryObjectsDir = "objects" | |
| 12 | + | repositoryGenerations = "generations" | |
| 13 | + | repositoryHead = "HEAD" | |
| 14 | + | generationWidth = 20 | |
| 15 | + | maxRepositoryRead = maxSnapshotFileSize | |
| 16 | + | // Legacy import is deliberately smaller than the per-file parser ceiling: | |
| 17 | + | // importing old snapshots must not accumulate an unbounded startup payload. | |
| 18 | + | maxLegacySnapshotFiles = 64 | |
| 19 | + | maxLegacySnapshotBytes = 8 << 20 | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // Repository is the crash-safe, content-addressed session snapshot store. | |
| 23 | + | // Store remains available separately for the one-way legacy bridge. | |
| 24 | + | type Repository struct { | |
| 25 | + | dir string | |
| 26 | + | hooks repositoryHooks | |
| 27 | + | ||
| 28 | + | // sessionStateMu owns both maps. A caller retains a reference before it | |
| 29 | + | // waits on a session mutex, so an idle entry can never be removed while a | |
| 30 | + | // waiter still holds its old mutex. Epoch entries share that lifetime. | |
| 31 | + | sessionStateMu sync.Mutex | |
| 32 | + | locks map[string]*sessionMutex | |
| 33 | + | storageEpochs map[string]uint64 | |
| 34 | + | nextEpoch uint64 | |
| 35 | + | ||
| 36 | + | // maintenanceMu owns bounded continuation metadata: seek cookies and | |
| 37 | + | // pending directory entries. Directory descriptors are opened and closed | |
| 38 | + | // for each maintenance call. | |
| 39 | + | maintenanceMu sync.Mutex | |
| 40 | + | maintenanceCursors map[string]*maintenanceCursor | |
| 41 | + | maintenanceSessions map[string]*sessionMaintenance | |
| 42 | + | maintenanceQuarantine *quarantineMaintenance | |
| 43 | + | ||
| 44 | + | // pendingLegacySync records an unlink whose root-directory sync failed. | |
| 45 | + | // It is keyed by the deterministic legacy filename and shares a per-file | |
| 46 | + | // lock with DeleteLegacy so a retry cannot acknowledge a recreated file. | |
| 47 | + | pendingLegacySync sync.Map // map[string]struct{} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // repositoryHooks makes each persistence boundary fault-injectable. Hooks run | |
| 51 | + | // immediately before their respective syscall and are intentionally package | |
| 52 | + | // private so production callers cannot weaken repository guarantees. | |
| 53 | + | type repositoryHooks struct { | |
| 54 | + | beforeBlobWrite func(string) error | |
| 55 | + | beforeManifestWrite func(string) error | |
| 56 | + | // Object hooks instrument the publication path in package tests. They keep | |
| 57 | + | // the steady-state cost of retained history observable without exposing a | |
| 58 | + | // production metrics surface. | |
| 59 | + | beforeObjectRead func(string) | |
| 60 | + | beforeObjectHash func([]byte) | |
| 61 | + | beforeObjectCopy func([]byte) | |
| 62 | + | beforeHeadRead func(string) error | |
| 63 | + | beforeHeadWrite func(string) error | |
| 64 | + | beforeTombstoneCheck func(string) | |
| 65 | + | beforePendingQuarantineCheck func(string) | |
| 66 | + | createTemp func(string) error | |
| 67 | + | writeTemp func(string) error | |
| 68 | + | syncFile func(string) error | |
| 69 | + | closeFile func(string) error | |
| 70 | + | closeDescriptor func(string) error | |
| 71 | + | installImmutable func(string) error | |
| 72 | + | rename func(string) error | |
| 73 | + | syncDirectory func(string) error | |
| 74 | + | remove func(string) error | |
| 75 | + | openMaintenanceDirectory func(string) (maintenanceDirectory, error) | |
| 76 | + | // beforeMaintenanceWork observes each budgeted quarantine filesystem step. | |
| 77 | + | // It is test-only instrumentation for hostile traversal bounds. | |
| 78 | + | beforeMaintenanceWork func(string) | |
| 79 | + | openLegacyDirectory func(string) (legacyDirectory, error) | |
| 80 | + | beforeSessionLock func(string) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | var _ ports.SnapshotRepository = (*Repository)(nil) | |
| 84 | + | var _ ports.LegacySnapshotSource = (*Repository)(nil) | |
| 85 | + | ||
| 86 | + | // NewRepository creates a repository rooted at dir. It does not create files | |
| 87 | + | // until the first publication, so merely constructing it is side-effect free. | |
| 88 | + | func NewRepository(dir string) *Repository { | |
| 89 | + | return &Repository{ | |
| 90 | + | dir: dir, | |
| 91 | + | locks: make(map[string]*sessionMutex), | |
| 92 | + | storageEpochs: make(map[string]uint64), | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // invalidateStorageEpoch must be called with lockSession(key) held. The | |
| 97 | + | // registry mutex makes accesses for different session keys race-free too. | |
| 98 | + | func (r *Repository) invalidateStorageEpoch(key string) { | |
| 99 | + | r.sessionStateMu.Lock() | |
| 100 | + | r.nextEpoch++ | |
| 101 | + | r.storageEpochs[key] = r.nextEpoch | |
| 102 | + | r.sessionStateMu.Unlock() | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // storageEpoch must be called with lockSession(key) held. | |
| 106 | + | func (r *Repository) storageEpoch(key string) uint64 { | |
| 107 | + | r.sessionStateMu.Lock() | |
| 108 | + | epoch := r.storageEpochs[key] | |
| 109 | + | r.sessionStateMu.Unlock() | |
| 110 | + | return epoch | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // sessionMutex is retained by each waiter and each resumable maintenance | |
| 114 | + | // state. That reference is what makes eviction safe: a new caller cannot lock | |
| 115 | + | // a replacement mutex until all users of the prior mutex have left it. | |
| 116 | + | type sessionMutex struct { | |
| 117 | + | key string | |
| 118 | + | mu sync.Mutex | |
| 119 | + | references int | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func (r *Repository) lockSession(key string) *sessionMutex { | |
| 123 | + | r.sessionStateMu.Lock() | |
| 124 | + | lock := r.locks[key] | |
| 125 | + | if lock == nil { | |
| 126 | + | lock = &sessionMutex{key: key} | |
| 127 | + | r.locks[key] = lock | |
| 128 | + | } | |
| 129 | + | lock.references++ | |
| 130 | + | r.sessionStateMu.Unlock() | |
| 131 | + | if hook := r.hooks.beforeSessionLock; hook != nil { | |
| 132 | + | hook(key) | |
| 133 | + | } | |
| 134 | + | lock.mu.Lock() | |
| 135 | + | return lock | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func (r *Repository) unlockSession(lock *sessionMutex) { | |
| 139 | + | lock.mu.Unlock() | |
| 140 | + | r.releaseSessionReference(lock) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func (r *Repository) retainSessionState(key string) *sessionMutex { | |
| 144 | + | r.sessionStateMu.Lock() | |
| 145 | + | lock := r.locks[key] | |
| 146 | + | // sessionMaintenanceState is called while the active session lock is held. | |
| 147 | + | if lock == nil { | |
| 148 | + | panic("snapshot: retain missing session lock") | |
| 149 | + | } | |
| 150 | + | lock.references++ | |
| 151 | + | r.sessionStateMu.Unlock() | |
| 152 | + | return lock | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func (r *Repository) releaseSessionReference(lock *sessionMutex) { | |
| 156 | + | r.sessionStateMu.Lock() | |
| 157 | + | lock.references-- | |
| 158 | + | if lock.references < 0 { | |
| 159 | + | r.sessionStateMu.Unlock() | |
| 160 | + | panic("snapshot: release of unretained session lock") | |
| 161 | + | } | |
| 162 | + | if lock.references == 0 { | |
| 163 | + | if r.locks[lock.key] != lock { | |
| 164 | + | r.sessionStateMu.Unlock() | |
| 165 | + | panic("snapshot: session lock registry mismatch") | |
| 166 | + | } | |
| 167 | + | delete(r.locks, lock.key) | |
| 168 | + | delete(r.storageEpochs, lock.key) | |
| 169 | + | } | |
| 170 | + | r.sessionStateMu.Unlock() | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // sessionStateCounts is test evidence that idle per-session state is evicted. | |
| 174 | + | func (r *Repository) sessionStateCounts() (locks, epochs int) { | |
| 175 | + | r.sessionStateMu.Lock() | |
| 176 | + | defer r.sessionStateMu.Unlock() | |
| 177 | + | return len(r.locks), len(r.storageEpochs) | |
| 178 | + | } | |
A internal/adapters/snapshot/repository_durability_test.go +183 -0
| @@ -0,0 +1,183 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | codec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestRepositoryImmutableInstallDoesNotOverwriteRacedTarget(t *testing.T) { | |
| 16 | + | repo := NewRepository(privateDir(t)) | |
| 17 | + | pub := repositoryPublication(t, "named", 1, []byte("state")) | |
| 18 | + | object := pub.Objects[0] | |
| 19 | + | target := repo.objectPath(sessionKey(pub.Name), object.Digest) | |
| 20 | + | repo.hooks.installImmutable = func(path string) error { | |
| 21 | + | if path == target { | |
| 22 | + | return os.WriteFile(path, []byte("attacker bytes"), 0o600) | |
| 23 | + | } | |
| 24 | + | return nil | |
| 25 | + | } | |
| 26 | + | if err := repo.Publish(context.Background(), pub); err == nil { | |
| 27 | + | t.Fatal("Publish succeeded after raced immutable target") | |
| 28 | + | } | |
| 29 | + | got, err := os.ReadFile(target) | |
| 30 | + | if err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | if string(got) != "attacker bytes" { | |
| 34 | + | t.Fatalf("raced target = %q, want attacker bytes", got) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func prepareHeadStage(t *testing.T, repo *Repository, pub ports.SnapshotPublication) { | |
| 39 | + | t.Helper() | |
| 40 | + | key := sessionKey(pub.Name) | |
| 41 | + | prepareObjects(t, repo, pub) | |
| 42 | + | if err := repo.writeImmutable(repo.manifestPath(key, pub.Generation), pub.Manifest, func(data []byte) error { | |
| 43 | + | if !bytes.Equal(data, pub.Manifest) { | |
| 44 | + | return errors.New("invalid manifest") | |
| 45 | + | } | |
| 46 | + | return nil | |
| 47 | + | }); err != nil { | |
| 48 | + | t.Fatal(err) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func manifestReference(t *testing.T, data []byte, digest ports.SnapshotDigest) codec.ObjectRef { | |
| 53 | + | t.Helper() | |
| 54 | + | manifest, err := codec.UnmarshalManifest(data) | |
| 55 | + | if err != nil { | |
| 56 | + | t.Fatal(err) | |
| 57 | + | } | |
| 58 | + | ref, ok := manifestRefs(manifest)[digest] | |
| 59 | + | if !ok { | |
| 60 | + | t.Fatal("missing object reference") | |
| 61 | + | } | |
| 62 | + | return ref | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestRepositoryFaultsAtEveryPublicationBoundary(t *testing.T) { | |
| 66 | + | cases := []struct { | |
| 67 | + | location string | |
| 68 | + | operation string | |
| 69 | + | }{ | |
| 70 | + | {"blob", "create"}, {"blob", "write"}, {"blob", "sync-file"}, {"blob", "close"}, {"blob", "install"}, {"blob", "sync-directory"}, | |
| 71 | + | {"manifest", "create"}, {"manifest", "write"}, {"manifest", "sync-file"}, {"manifest", "close"}, {"manifest", "install"}, {"manifest", "sync-directory"}, | |
| 72 | + | {"HEAD", "create"}, {"HEAD", "write"}, {"HEAD", "sync-file"}, {"HEAD", "close"}, {"HEAD", "rename"}, {"HEAD", "sync-directory"}, | |
| 73 | + | } | |
| 74 | + | for _, tc := range cases { | |
| 75 | + | t.Run(tc.location+"/"+tc.operation, func(t *testing.T) { | |
| 76 | + | repo := NewRepository(privateDir(t)) | |
| 77 | + | first := repositoryPublication(t, "named", 1, []byte("one")) | |
| 78 | + | if err := repo.Publish(context.Background(), first); err != nil { | |
| 79 | + | t.Fatal(err) | |
| 80 | + | } | |
| 81 | + | second := repositoryPublication(t, "named", 2, []byte("two")) | |
| 82 | + | key := sessionKey(second.Name) | |
| 83 | + | if err := repo.ensureSession(key); err != nil { | |
| 84 | + | t.Fatal(err) | |
| 85 | + | } | |
| 86 | + | preparePublicationLocation(t, repo, second, tc.location) | |
| 87 | + | injectBoundary(repo, tc.location, tc.operation, second) | |
| 88 | + | if err := repo.Publish(context.Background(), second); err == nil { | |
| 89 | + | t.Fatal("Publish succeeded with injected persistence failure") | |
| 90 | + | } | |
| 91 | + | repo.hooks = repositoryHooks{} | |
| 92 | + | got, err := repo.Load(context.Background(), "named") | |
| 93 | + | if err != nil { | |
| 94 | + | t.Fatal(err) | |
| 95 | + | } | |
| 96 | + | if got.Generation != 1 && got.Generation != 2 { | |
| 97 | + | t.Fatalf("authoritative generation = %d, want old or new", got.Generation) | |
| 98 | + | } | |
| 99 | + | }) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // preparePublicationLocation advances setup only far enough to target the | |
| 104 | + | // requested persistence location in the publication fault matrix. | |
| 105 | + | func preparePublicationLocation(t *testing.T, repo *Repository, pub ports.SnapshotPublication, location string) { | |
| 106 | + | t.Helper() | |
| 107 | + | switch location { | |
| 108 | + | case "manifest": | |
| 109 | + | prepareObjects(t, repo, pub) | |
| 110 | + | case "HEAD": | |
| 111 | + | prepareHeadStage(t, repo, pub) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func injectBoundary(repo *Repository, location, operation string, pub ports.SnapshotPublication) { | |
| 116 | + | key := sessionKey(pub.Name) | |
| 117 | + | dir := filepath.Dir(repo.objectPath(key, pub.Objects[0].Digest)) | |
| 118 | + | if location == "manifest" { | |
| 119 | + | dir = filepath.Dir(repo.manifestPath(key, pub.Generation)) | |
| 120 | + | } | |
| 121 | + | if location == "HEAD" { | |
| 122 | + | dir = filepath.Dir(repo.headPath(key)) | |
| 123 | + | } | |
| 124 | + | fail := errors.New("injected persistence failure") | |
| 125 | + | repo.hooks.createTemp = func(got string) error { | |
| 126 | + | if operation == "create" && got == dir { | |
| 127 | + | return fail | |
| 128 | + | } | |
| 129 | + | return nil | |
| 130 | + | } | |
| 131 | + | repo.hooks.writeTemp = func(path string) error { | |
| 132 | + | if operation == "write" && filepath.Dir(path) == dir { | |
| 133 | + | return fail | |
| 134 | + | } | |
| 135 | + | return nil | |
| 136 | + | } | |
| 137 | + | repo.hooks.syncFile = func(path string) error { | |
| 138 | + | if operation == "sync-file" && filepath.Dir(path) == dir { | |
| 139 | + | return fail | |
| 140 | + | } | |
| 141 | + | return nil | |
| 142 | + | } | |
| 143 | + | repo.hooks.closeFile = func(path string) error { | |
| 144 | + | if operation == "close" && filepath.Dir(path) == dir { | |
| 145 | + | return fail | |
| 146 | + | } | |
| 147 | + | return nil | |
| 148 | + | } | |
| 149 | + | repo.hooks.installImmutable = func(path string) error { | |
| 150 | + | if operation == "install" && filepath.Dir(path) == dir { | |
| 151 | + | return fail | |
| 152 | + | } | |
| 153 | + | return nil | |
| 154 | + | } | |
| 155 | + | repo.hooks.rename = func(path string) error { | |
| 156 | + | if operation == "rename" && location == "HEAD" && path == repo.headPath(key) { | |
| 157 | + | return fail | |
| 158 | + | } | |
| 159 | + | return nil | |
| 160 | + | } | |
| 161 | + | repo.hooks.syncDirectory = func(got string) error { | |
| 162 | + | if operation == "sync-directory" && got == dir { | |
| 163 | + | return fail | |
| 164 | + | } | |
| 165 | + | return nil | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func prepareObjects(t *testing.T, repo *Repository, pub ports.SnapshotPublication) { | |
| 170 | + | t.Helper() | |
| 171 | + | key := sessionKey(pub.Name) | |
| 172 | + | for _, object := range pub.Objects { | |
| 173 | + | ref := manifestReference(t, pub.Manifest, object.Digest) | |
| 174 | + | if err := repo.writeImmutable(repo.objectPath(key, object.Digest), object.Data, func(data []byte) error { | |
| 175 | + | if !bytes.Equal(data, object.Data) || !validObject(data, ref) { | |
| 176 | + | return errors.New("invalid object") | |
| 177 | + | } | |
| 178 | + | return nil | |
| 179 | + | }); err != nil { | |
| 180 | + | t.Fatal(err) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | } | |
A internal/adapters/snapshot/repository_fault_test.go +204 -0
| @@ -0,0 +1,204 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | codec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestRepositoryHeadFailureKeepsOldCompleteGeneration(t *testing.T) { | |
| 15 | + | dir := privateDir(t) | |
| 16 | + | repo := NewRepository(dir) | |
| 17 | + | first := repositoryPublication(t, "named", 1, []byte("one")) | |
| 18 | + | if err := repo.Publish(context.Background(), first); err != nil { | |
| 19 | + | t.Fatal(err) | |
| 20 | + | } | |
| 21 | + | second := repositoryPublication(t, "named", 2, []byte("two")) | |
| 22 | + | repo.hooks.beforeHeadWrite = func(string) error { return errors.New("injected HEAD failure") } | |
| 23 | + | if err := repo.Publish(context.Background(), second); err == nil { | |
| 24 | + | t.Fatal("Publish succeeded with injected HEAD failure") | |
| 25 | + | } | |
| 26 | + | repo.hooks.beforeHeadWrite = nil | |
| 27 | + | got, err := repo.Load(context.Background(), "named") | |
| 28 | + | if err != nil { | |
| 29 | + | t.Fatal(err) | |
| 30 | + | } | |
| 31 | + | if got.Generation != 1 { | |
| 32 | + | t.Fatalf("generation after failed publish = %d, want 1", got.Generation) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestRepositoryCleanupFaultsSurfaceForImmutableAndMutablePublication(t *testing.T) { | |
| 37 | + | primary := errors.New("primary write failure") | |
| 38 | + | cleanup := errors.New("cleanup failure") | |
| 39 | + | cases := []struct { | |
| 40 | + | name string | |
| 41 | + | location string | |
| 42 | + | cleanup string | |
| 43 | + | }{ | |
| 44 | + | {name: "immutable/close temporary", location: "manifest", cleanup: "close"}, | |
| 45 | + | {name: "immutable/remove temporary", location: "manifest", cleanup: "remove"}, | |
| 46 | + | {name: "immutable/sync after temporary removal", location: "manifest", cleanup: "sync-after-remove"}, | |
| 47 | + | {name: "mutable/close temporary", location: "HEAD", cleanup: "close"}, | |
| 48 | + | {name: "mutable/remove temporary", location: "HEAD", cleanup: "remove"}, | |
| 49 | + | {name: "mutable/sync after temporary removal", location: "HEAD", cleanup: "sync-after-remove"}, | |
| 50 | + | } | |
| 51 | + | for _, tc := range cases { | |
| 52 | + | t.Run(tc.name, func(t *testing.T) { | |
| 53 | + | repo := NewRepository(privateDir(t)) | |
| 54 | + | first := repositoryPublication(t, "named", 1, []byte("one")) | |
| 55 | + | if err := repo.Publish(context.Background(), first); err != nil { | |
| 56 | + | t.Fatal(err) | |
| 57 | + | } | |
| 58 | + | second := repositoryPublication(t, "named", 2, []byte("two")) | |
| 59 | + | key := sessionKey(second.Name) | |
| 60 | + | if tc.location == "manifest" { | |
| 61 | + | prepareObjects(t, repo, second) | |
| 62 | + | } else { | |
| 63 | + | prepareHeadStage(t, repo, second) | |
| 64 | + | } | |
| 65 | + | dir := filepath.Dir(repo.manifestPath(key, second.Generation)) | |
| 66 | + | phase := "write manifest" | |
| 67 | + | if tc.location == "HEAD" { | |
| 68 | + | dir = filepath.Dir(repo.headPath(key)) | |
| 69 | + | phase = "write HEAD" | |
| 70 | + | } | |
| 71 | + | repo.hooks.writeTemp = func(path string) error { | |
| 72 | + | if filepath.Dir(path) == dir { | |
| 73 | + | return primary | |
| 74 | + | } | |
| 75 | + | return nil | |
| 76 | + | } | |
| 77 | + | repo.hooks.closeFile = func(path string) error { | |
| 78 | + | if tc.cleanup == "close" && filepath.Dir(path) == dir { | |
| 79 | + | return cleanup | |
| 80 | + | } | |
| 81 | + | return nil | |
| 82 | + | } | |
| 83 | + | repo.hooks.remove = func(string) error { | |
| 84 | + | if tc.cleanup == "remove" { | |
| 85 | + | return cleanup | |
| 86 | + | } | |
| 87 | + | return nil | |
| 88 | + | } | |
| 89 | + | repo.hooks.syncDirectory = func(path string) error { | |
| 90 | + | if tc.cleanup == "sync-after-remove" && path == dir { | |
| 91 | + | return cleanup | |
| 92 | + | } | |
| 93 | + | return nil | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | err := repo.Publish(context.Background(), second) | |
| 97 | + | if !errors.Is(err, primary) || !errors.Is(err, cleanup) || !strings.Contains(err.Error(), phase) { | |
| 98 | + | t.Fatalf("Publish error = %v, want %q and both primary and cleanup failures", err, phase) | |
| 99 | + | } | |
| 100 | + | if tc.cleanup != "remove" { | |
| 101 | + | matches, globErr := filepath.Glob(filepath.Join(dir, ".tmp-*")) | |
| 102 | + | if globErr != nil || len(matches) != 0 { | |
| 103 | + | t.Fatalf("temporary files after successful cleanup = %v, glob error = %v", matches, globErr) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | repo.hooks = repositoryHooks{} | |
| 107 | + | assertRepositoryCompleteGeneration(t, repo, "named", 1, 2) | |
| 108 | + | }) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestRepositoryNewDirectorySyncFaultsAreIndependent(t *testing.T) { | |
| 113 | + | cases := []struct { | |
| 114 | + | name string | |
| 115 | + | phase string | |
| 116 | + | occurrence int | |
| 117 | + | directory func(*Repository, string) string | |
| 118 | + | }{ | |
| 119 | + | {name: "repository", phase: "repository", occurrence: 1, directory: func(_ *Repository, root string) string { return filepath.Dir(root) }}, | |
| 120 | + | {name: "sessions", phase: "sessions", occurrence: 1, directory: func(repo *Repository, _ string) string { return repo.dir }}, | |
| 121 | + | {name: "session", phase: "session", occurrence: 1, directory: func(repo *Repository, _ string) string { return filepath.Join(repo.dir, repositorySessionsDir) }}, | |
| 122 | + | {name: "objects", phase: "objects", occurrence: 1, directory: func(repo *Repository, _ string) string { return repo.sessionPath(sessionKey("named")) }}, | |
| 123 | + | {name: "generations", phase: "generations", occurrence: 2, directory: func(repo *Repository, _ string) string { return repo.sessionPath(sessionKey("named")) }}, | |
| 124 | + | {name: "object shard", phase: "object shard", occurrence: 1, directory: func(repo *Repository, _ string) string { | |
| 125 | + | return filepath.Join(repo.sessionPath(sessionKey("named")), repositoryObjectsDir) | |
| 126 | + | }}, | |
| 127 | + | } | |
| 128 | + | for _, tc := range cases { | |
| 129 | + | t.Run(tc.name, func(t *testing.T) { | |
| 130 | + | parent := privateDir(t) | |
| 131 | + | root := filepath.Join(parent, "repository") | |
| 132 | + | repo := NewRepository(root) | |
| 133 | + | wantDirectory := tc.directory(repo, root) | |
| 134 | + | calls := 0 | |
| 135 | + | injected := errors.New("new directory sync failure") | |
| 136 | + | repo.hooks.syncDirectory = func(directory string) error { | |
| 137 | + | if directory == wantDirectory { | |
| 138 | + | calls++ | |
| 139 | + | if calls == tc.occurrence { | |
| 140 | + | return injected | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | return nil | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | publication := repositoryPublication(t, "named", 1, []byte("one")) | |
| 147 | + | err := repo.Publish(context.Background(), publication) | |
| 148 | + | if !errors.Is(err, injected) || !strings.Contains(err.Error(), tc.phase) { | |
| 149 | + | t.Fatalf("Publish error = %v, want %q-labelled directory sync failure", err, tc.phase) | |
| 150 | + | } | |
| 151 | + | repo.hooks = repositoryHooks{} | |
| 152 | + | // A first-generation failure cannot have an older complete generation; | |
| 153 | + | // it must not expose an incomplete one as if it were loadable. | |
| 154 | + | if _, loadErr := repo.Load(context.Background(), "named"); loadErr == nil || !strings.Contains(loadErr.Error(), "no complete snapshot generation") { | |
| 155 | + | t.Fatalf("Load error after incomplete first generation = %v, want no complete generation", loadErr) | |
| 156 | + | } | |
| 157 | + | if err := repo.Publish(context.Background(), publication); err != nil { | |
| 158 | + | t.Fatal(err) | |
| 159 | + | } | |
| 160 | + | assertRepositoryCompleteGeneration(t, repo, "named", 1) | |
| 161 | + | }) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func assertRepositoryCompleteGeneration(t *testing.T, repo *Repository, name string, generations ...uint64) { | |
| 166 | + | t.Helper() | |
| 167 | + | got, err := repo.Load(context.Background(), name) | |
| 168 | + | if err != nil { | |
| 169 | + | t.Fatal(err) | |
| 170 | + | } | |
| 171 | + | for _, generation := range generations { | |
| 172 | + | if got.Generation == generation && len(got.Manifest) != 0 && len(got.Objects) != 0 { | |
| 173 | + | return | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | t.Fatalf("Load = incomplete or mixed generation %+v, want one of %v", got, generations) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func TestRepositoryLoadFallsBackFromIncompleteNewestGeneration(t *testing.T) { | |
| 180 | + | dir := privateDir(t) | |
| 181 | + | repo := NewRepository(dir) | |
| 182 | + | first := repositoryPublication(t, "named", 1, []byte("one")) | |
| 183 | + | if err := repo.Publish(context.Background(), first); err != nil { | |
| 184 | + | t.Fatal(err) | |
| 185 | + | } | |
| 186 | + | second := repositoryPublication(t, "named", 2, []byte("two")) | |
| 187 | + | if err := repo.Publish(context.Background(), second); err != nil { | |
| 188 | + | t.Fatal(err) | |
| 189 | + | } | |
| 190 | + | manifest, err := codec.UnmarshalManifest(second.Manifest) | |
| 191 | + | if err != nil { | |
| 192 | + | t.Fatal(err) | |
| 193 | + | } | |
| 194 | + | if err := os.Remove(repo.objectPath(sessionKey("named"), manifest.Tabs[0].Panes[0].Tail.Digest)); err != nil { | |
| 195 | + | t.Fatal(err) | |
| 196 | + | } | |
| 197 | + | got, err := repo.Load(context.Background(), "named") | |
| 198 | + | if err != nil { | |
| 199 | + | t.Fatal(err) | |
| 200 | + | } | |
| 201 | + | if got.Generation != 1 || !got.Fallback { | |
| 202 | + | t.Fatalf("Load = generation %d fallback %v, want generation 1 fallback true", got.Generation, got.Fallback) | |
| 203 | + | } | |
| 204 | + | } | |
A internal/adapters/snapshot/repository_hardening_test.go +83 -0
| @@ -0,0 +1,83 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "syscall" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | codec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestReadBoundedRejectsUnsafeFiles(t *testing.T) { | |
| 14 | + | dir := t.TempDir() | |
| 15 | + | regular := filepath.Join(dir, "regular") | |
| 16 | + | if err := os.WriteFile(regular, []byte("safe"), 0o600); err != nil { | |
| 17 | + | t.Fatal(err) | |
| 18 | + | } | |
| 19 | + | tooLarge := filepath.Join(dir, "large") | |
| 20 | + | f, err := os.Create(tooLarge) | |
| 21 | + | if err != nil { | |
| 22 | + | t.Fatal(err) | |
| 23 | + | } | |
| 24 | + | if err := f.Truncate(maxRepositoryRead + 1); err != nil { | |
| 25 | + | t.Fatal(err) | |
| 26 | + | } | |
| 27 | + | if err := f.Close(); err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | unsafeMode := filepath.Join(dir, "mode") | |
| 31 | + | if err := os.WriteFile(unsafeMode, []byte("unsafe"), 0o644); err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | link := filepath.Join(dir, "link") | |
| 35 | + | if err := os.Symlink(regular, link); err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | fifo := filepath.Join(dir, "fifo") | |
| 39 | + | if err := syscall.Mkfifo(fifo, 0o600); err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | for _, path := range []string{tooLarge, unsafeMode, link, fifo} { | |
| 44 | + | t.Run(filepath.Base(path), func(t *testing.T) { | |
| 45 | + | if _, err := readBounded(path); err == nil { | |
| 46 | + | t.Fatal("readBounded accepted unsafe file") | |
| 47 | + | } | |
| 48 | + | }) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | wrongOwner := filepath.Join(dir, "owner") | |
| 52 | + | if os.Geteuid() == 0 { | |
| 53 | + | if err := os.WriteFile(wrongOwner, []byte("unsafe"), 0o600); err != nil { | |
| 54 | + | t.Fatal(err) | |
| 55 | + | } | |
| 56 | + | if err := os.Chown(wrongOwner, 1, -1); err != nil { | |
| 57 | + | t.Fatal(err) | |
| 58 | + | } | |
| 59 | + | if _, err := readBounded(wrongOwner); err == nil { | |
| 60 | + | t.Fatal("readBounded accepted wrong-owner file") | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestValidatePublicationRejectsUnloadableAggregate(t *testing.T) { | |
| 66 | + | pub := repositoryPublication(t, "named", 1, []byte("state")) | |
| 67 | + | manifest, err := codec.UnmarshalManifest(pub.Manifest) | |
| 68 | + | if err != nil { | |
| 69 | + | t.Fatal(err) | |
| 70 | + | } | |
| 71 | + | manifest.Tabs[0].Panes[0].Tail.Size = uint32(maxRepositoryRead) | |
| 72 | + | manifest.Tabs[0].Panes[0].Visible.Size = uint32(maxRepositoryRead) | |
| 73 | + | pub.Manifest, err = codec.MarshalManifest(manifest) | |
| 74 | + | if err != nil { | |
| 75 | + | t.Fatal(err) | |
| 76 | + | } | |
| 77 | + | pub.Objects = nil // validation permits omitted objects for immutable reuse. | |
| 78 | + | if _, _, err := validatePublication(pub); err == nil { | |
| 79 | + | t.Fatal("validatePublication accepted unloadable aggregate") | |
| 80 | + | } else if errors.Is(err, os.ErrNotExist) { | |
| 81 | + | t.Fatalf("validatePublication returned unrelated error: %v", err) | |
| 82 | + | } | |
| 83 | + | } | |
A internal/adapters/snapshot/repository_maintenance_test.go +62 -0
| @@ -0,0 +1,62 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestSafeFilesystemErrorDoesNotExposePath(t *testing.T) { | |
| 13 | + | cause := errors.New("permission denied") | |
| 14 | + | err := safeFilesystemError(&os.PathError{Op: "remove", Path: "\x1b[31munsafe", Err: cause}) | |
| 15 | + | if !errors.Is(err, cause) || strings.Contains(err.Error(), "\x1b[31munsafe") { | |
| 16 | + | t.Fatalf("safe filesystem error = %q", err) | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func TestRepositoryMaintenanceFilesystemErrorsHaveSafeContext(t *testing.T) { | |
| 21 | + | injected := errors.New("injected filesystem failure") | |
| 22 | + | cases := []struct { | |
| 23 | + | name string | |
| 24 | + | run func(*Repository) error | |
| 25 | + | want string | |
| 26 | + | }{ | |
| 27 | + | { | |
| 28 | + | name: "delete sync", | |
| 29 | + | run: func(repo *Repository) error { | |
| 30 | + | publication := repositoryPublication(t, "named", 1, []byte("state")) | |
| 31 | + | if err := repo.Publish(context.Background(), publication); err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | repo.hooks.syncDirectory = func(string) error { return injected } | |
| 35 | + | return repo.Delete(context.Background(), "named") | |
| 36 | + | }, | |
| 37 | + | want: `sync deleted snapshot session directory "named"`, | |
| 38 | + | }, | |
| 39 | + | { | |
| 40 | + | name: "maintain remove", | |
| 41 | + | run: func(repo *Repository) error { | |
| 42 | + | if err := os.Mkdir(repo.dir, 0o700); err != nil { | |
| 43 | + | t.Fatal(err) | |
| 44 | + | } | |
| 45 | + | if err := os.WriteFile(filepath.Join(repo.dir, ".tmp-stale"), []byte("stale"), 0o600); err != nil { | |
| 46 | + | t.Fatal(err) | |
| 47 | + | } | |
| 48 | + | repo.hooks.remove = func(string) error { return injected } | |
| 49 | + | return repo.Maintain(context.Background()) | |
| 50 | + | }, | |
| 51 | + | want: `remove snapshot maintenance file ".tmp-stale"`, | |
| 52 | + | }, | |
| 53 | + | } | |
| 54 | + | for _, tc := range cases { | |
| 55 | + | t.Run(tc.name, func(t *testing.T) { | |
| 56 | + | err := tc.run(NewRepository(privateDir(t))) | |
| 57 | + | if !errors.Is(err, injected) || !strings.Contains(err.Error(), tc.want) { | |
| 58 | + | t.Fatalf("error = %v, want context %q and injected error", err, tc.want) | |
| 59 | + | } | |
| 60 | + | }) | |
| 61 | + | } | |
| 62 | + | } | |
A internal/adapters/snapshot/repository_phase6_test.go +107 -0
| @@ -0,0 +1,107 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "sync" | |
| 10 | + | "sync/atomic" | |
| 11 | + | "testing" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestRepositoryPublishDeleteManySessionsRaceFree(t *testing.T) { | |
| 17 | + | repo := NewRepository(privateDir(t)) | |
| 18 | + | const sessions = 128 | |
| 19 | + | ||
| 20 | + | publications := make([]ports.SnapshotPublication, sessions) | |
| 21 | + | for i := range publications { | |
| 22 | + | name := fmt.Sprintf("named-%03d", i) | |
| 23 | + | publications[i] = repositoryPublication(t, name, 1, []byte(name)) | |
| 24 | + | if err := repo.Publish(context.Background(), publications[i]); err != nil { | |
| 25 | + | t.Fatalf("initial Publish(%q): %v", name, err) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | var wg sync.WaitGroup | |
| 30 | + | for _, publication := range publications { | |
| 31 | + | wg.Add(2) | |
| 32 | + | go func() { | |
| 33 | + | defer wg.Done() | |
| 34 | + | if err := repo.Publish(context.Background(), publication); err != nil { | |
| 35 | + | t.Errorf("Publish(%q): %v", publication.Name, err) | |
| 36 | + | } | |
| 37 | + | }() | |
| 38 | + | go func() { | |
| 39 | + | defer wg.Done() | |
| 40 | + | if err := repo.Delete(context.Background(), publication.Name); err != nil { | |
| 41 | + | t.Errorf("Delete(%q): %v", publication.Name, err) | |
| 42 | + | } | |
| 43 | + | }() | |
| 44 | + | } | |
| 45 | + | wg.Wait() | |
| 46 | + | ||
| 47 | + | locks, epochs := repo.sessionStateCounts() | |
| 48 | + | if locks != 0 || epochs != 0 { | |
| 49 | + | t.Fatalf("retained session state = %d locks, %d epochs, want none", locks, epochs) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestRepositoryBoundsAndEvictsPartialMaintenanceSessionState(t *testing.T) { | |
| 54 | + | repo := NewRepository(privateDir(t)) | |
| 55 | + | for _, key := range []string{"named-000", "named-001"} { | |
| 56 | + | generations := filepath.Join(repo.sessionPath(key), repositoryGenerations) | |
| 57 | + | for i := 0; i < maintenanceBatch+1; i++ { | |
| 58 | + | if err := os.MkdirAll(generations, 0o700); err != nil { | |
| 59 | + | t.Fatal(err) | |
| 60 | + | } | |
| 61 | + | if err := os.WriteFile(filepath.Join(generations, fmt.Sprintf("unclassified-%03d", i)), []byte("state"), 0o600); err != nil { | |
| 62 | + | t.Fatal(err) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 67 | + | t.Fatal(err) | |
| 68 | + | } | |
| 69 | + | locks, epochs := repo.sessionStateCounts() | |
| 70 | + | if locks != 1 || epochs != 0 { | |
| 71 | + | t.Fatalf("partial maintenance state = %d locks, %d epochs, want one lock and no epochs", locks, epochs) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 75 | + | cancel() | |
| 76 | + | if err := repo.Maintain(ctx); !errors.Is(err, context.Canceled) { | |
| 77 | + | t.Fatalf("canceled Maintain error = %v, want context canceled", err) | |
| 78 | + | } | |
| 79 | + | locks, epochs = repo.sessionStateCounts() | |
| 80 | + | if locks != 0 || epochs != 0 { | |
| 81 | + | t.Fatalf("session state after maintenance reset = %d locks, %d epochs, want none", locks, epochs) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestRepositoryDeleteUsesDeterministicPendingQuarantine(t *testing.T) { | |
| 86 | + | repo := NewRepository(privateDir(t)) | |
| 87 | + | if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil { | |
| 88 | + | t.Fatal(err) | |
| 89 | + | } | |
| 90 | + | sessions := filepath.Join(repo.dir, repositorySessionsDir) | |
| 91 | + | for i := 0; i < maxDirectoryTraversalEntries*2; i++ { | |
| 92 | + | if err := os.Mkdir(filepath.Join(sessions, fmt.Sprintf("unrelated-%05d", i)), 0o700); err != nil { | |
| 93 | + | t.Fatal(err) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | var checks atomic.Int32 | |
| 97 | + | repo.hooks.beforePendingQuarantineCheck = func(string) { checks.Add(1) } | |
| 98 | + | if err := repo.Delete(context.Background(), "named"); err != nil { | |
| 99 | + | t.Fatal(err) | |
| 100 | + | } | |
| 101 | + | if got := checks.Load(); got != 1 { | |
| 102 | + | t.Fatalf("pending quarantine checks = %d, want one deterministic lookup", got) | |
| 103 | + | } | |
| 104 | + | if _, err := os.Lstat(filepath.Join(sessions, deletingSessionName(sessionKey("named")))); err != nil { | |
| 105 | + | t.Fatalf("deterministic quarantine missing: %v", err) | |
| 106 | + | } | |
| 107 | + | } | |
A internal/adapters/snapshot/repository_test.go +71 -0
| @@ -0,0 +1,71 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | codec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestRepositoryPublishesAndLoadsCompleteGeneration(t *testing.T) { | |
| 12 | + | t.Parallel() | |
| 13 | + | dir := privateDir(t) | |
| 14 | + | repo := NewRepository(dir) | |
| 15 | + | pub := repositoryPublication(t, "named", 1, []byte("state")) | |
| 16 | + | if err := repo.Publish(context.Background(), pub); err != nil { | |
| 17 | + | t.Fatalf("Publish: %v", err) | |
| 18 | + | } | |
| 19 | + | got, err := repo.Load(context.Background(), "named") | |
| 20 | + | if err != nil { | |
| 21 | + | t.Fatalf("Load: %v", err) | |
| 22 | + | } | |
| 23 | + | if got.Name != "named" || got.Generation != 1 || len(got.Objects) != 2 { | |
| 24 | + | t.Fatalf("Load = %#v", got) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestRepositoryDoesNotRewriteVerifiedImmutableBlob(t *testing.T) { | |
| 29 | + | t.Parallel() | |
| 30 | + | dir := privateDir(t) | |
| 31 | + | repo := NewRepository(dir) | |
| 32 | + | pub := repositoryPublication(t, "named", 1, []byte("state")) | |
| 33 | + | if err := repo.Publish(context.Background(), pub); err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | writes := 0 | |
| 37 | + | repo.hooks.beforeBlobWrite = func(string) error { writes++; return nil } | |
| 38 | + | pub.Generation = 2 | |
| 39 | + | manifest, err := codec.UnmarshalManifest(pub.Manifest) | |
| 40 | + | if err != nil { | |
| 41 | + | t.Fatal(err) | |
| 42 | + | } | |
| 43 | + | manifest.Generation = 2 | |
| 44 | + | pub.Manifest, err = codec.MarshalManifest(manifest) | |
| 45 | + | if err != nil { | |
| 46 | + | t.Fatal(err) | |
| 47 | + | } | |
| 48 | + | if err := repo.Publish(context.Background(), pub); err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | if writes != 0 { | |
| 52 | + | t.Fatalf("blob writes = %d, want 0", writes) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func repositoryPublication(t *testing.T, name string, generation uint64, payload []byte) ports.SnapshotPublication { | |
| 57 | + | t.Helper() | |
| 58 | + | tail, err := codec.MarshalObject(codec.HistoryTail, payload) | |
| 59 | + | if err != nil { | |
| 60 | + | t.Fatal(err) | |
| 61 | + | } | |
| 62 | + | visible, err := codec.MarshalObject(codec.Visible, []byte("visible")) | |
| 63 | + | if err != nil { | |
| 64 | + | t.Fatal(err) | |
| 65 | + | } | |
| 66 | + | manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}}) | |
| 67 | + | if err != nil { | |
| 68 | + | t.Fatal(err) | |
| 69 | + | } | |
| 70 | + | return ports.SnapshotPublication{Name: name, Generation: generation, Manifest: manifest, Objects: []ports.SnapshotObject{tail, visible}} | |
| 71 | + | } | |
A internal/adapters/snapshot/safe_path.go +187 -0
| @@ -0,0 +1,187 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/rand" | |
| 5 | + | "encoding/hex" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strings" | |
| 11 | + | "syscall" | |
| 12 | + | ||
| 13 | + | "golang.org/x/sys/unix" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // repositoryFD opens a repository-relative path one component at a time. Each | |
| 17 | + | // directory is pinned by an O_NOFOLLOW descriptor before its child is opened, | |
| 18 | + | // so a concurrent rename or symlink replacement cannot redirect an operation | |
| 19 | + | // outside the repository. | |
| 20 | + | func (r *Repository) repositoryFD(path string, finalFlags int, mode uint32) (*os.File, error) { | |
| 21 | + | rel, ok := r.repositoryRelative(path) | |
| 22 | + | if !ok { | |
| 23 | + | return nil, fmt.Errorf("snapshot path outside repository") | |
| 24 | + | } | |
| 25 | + | root, err := syscall.Open(r.dir, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0) | |
| 26 | + | if err != nil { | |
| 27 | + | return nil, err | |
| 28 | + | } | |
| 29 | + | if rel == "." { | |
| 30 | + | if finalFlags&syscall.O_DIRECTORY == 0 { | |
| 31 | + | err = fmt.Errorf("invalid snapshot root operation") | |
| 32 | + | joinCloseError(&err, "close snapshot root directory", r.closeDescriptor(root, "close snapshot root directory")) | |
| 33 | + | return nil, err | |
| 34 | + | } | |
| 35 | + | file := os.NewFile(uintptr(root), path) | |
| 36 | + | if file == nil { | |
| 37 | + | err = fmt.Errorf("open snapshot root") | |
| 38 | + | joinCloseError(&err, "close snapshot root directory", r.closeDescriptor(root, "close snapshot root directory")) | |
| 39 | + | return nil, err | |
| 40 | + | } | |
| 41 | + | return file, nil | |
| 42 | + | } | |
| 43 | + | parts := strings.Split(filepath.Clean(rel), string(filepath.Separator)) | |
| 44 | + | fd := root | |
| 45 | + | for i, part := range parts { | |
| 46 | + | if part == "" || part == "." || part == ".." { | |
| 47 | + | err = fmt.Errorf("invalid snapshot path") | |
| 48 | + | joinCloseError(&err, "close snapshot path directory", r.closeDescriptor(fd, "close snapshot path directory")) | |
| 49 | + | return nil, err | |
| 50 | + | } | |
| 51 | + | flags := syscall.O_RDONLY | syscall.O_CLOEXEC | syscall.O_NOFOLLOW | |
| 52 | + | if i != len(parts)-1 { | |
| 53 | + | flags |= syscall.O_DIRECTORY | |
| 54 | + | } else { | |
| 55 | + | flags = finalFlags | syscall.O_CLOEXEC | syscall.O_NOFOLLOW | |
| 56 | + | } | |
| 57 | + | next, openErr := unix.Openat(fd, part, flags, mode) | |
| 58 | + | closeErr := r.closeDescriptor(fd, "close snapshot path parent directory") | |
| 59 | + | if openErr != nil { | |
| 60 | + | err = openErr | |
| 61 | + | joinCloseError(&err, "close snapshot path parent directory", closeErr) | |
| 62 | + | return nil, err | |
| 63 | + | } | |
| 64 | + | if closeErr != nil { | |
| 65 | + | err = fmt.Errorf("close snapshot path parent directory: %w", closeErr) | |
| 66 | + | joinCloseError(&err, "close snapshot path child directory", r.closeDescriptor(next, "close snapshot path child directory")) | |
| 67 | + | return nil, err | |
| 68 | + | } | |
| 69 | + | fd = next | |
| 70 | + | } | |
| 71 | + | file := os.NewFile(uintptr(fd), path) | |
| 72 | + | if file == nil { | |
| 73 | + | err = fmt.Errorf("open snapshot path") | |
| 74 | + | joinCloseError(&err, "close snapshot path descriptor", r.closeDescriptor(fd, "close snapshot path descriptor")) | |
| 75 | + | return nil, err | |
| 76 | + | } | |
| 77 | + | return file, nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (r *Repository) repositoryParent(path string) (int, string, error) { | |
| 81 | + | rel, ok := r.repositoryRelative(path) | |
| 82 | + | if !ok || rel == "." { | |
| 83 | + | return -1, "", fmt.Errorf("snapshot path outside repository") | |
| 84 | + | } | |
| 85 | + | parts := strings.Split(filepath.Clean(rel), string(filepath.Separator)) | |
| 86 | + | if len(parts) == 0 || parts[len(parts)-1] == "." || parts[len(parts)-1] == ".." { | |
| 87 | + | return -1, "", fmt.Errorf("invalid snapshot path") | |
| 88 | + | } | |
| 89 | + | fd, err := syscall.Open(r.dir, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0) | |
| 90 | + | if err != nil { | |
| 91 | + | return -1, "", err | |
| 92 | + | } | |
| 93 | + | for _, part := range parts[:len(parts)-1] { | |
| 94 | + | if part == "" || part == "." || part == ".." { | |
| 95 | + | err = fmt.Errorf("invalid snapshot path") | |
| 96 | + | joinCloseError(&err, "close snapshot parent directory", r.closeDescriptor(fd, "close snapshot parent directory")) | |
| 97 | + | return -1, "", err | |
| 98 | + | } | |
| 99 | + | next, openErr := unix.Openat(fd, part, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0) | |
| 100 | + | closeErr := r.closeDescriptor(fd, "close snapshot parent directory") | |
| 101 | + | if openErr != nil { | |
| 102 | + | err = openErr | |
| 103 | + | joinCloseError(&err, "close snapshot parent directory", closeErr) | |
| 104 | + | return -1, "", err | |
| 105 | + | } | |
| 106 | + | if closeErr != nil { | |
| 107 | + | err = fmt.Errorf("close snapshot parent directory: %w", closeErr) | |
| 108 | + | joinCloseError(&err, "close snapshot child directory", r.closeDescriptor(next, "close snapshot child directory")) | |
| 109 | + | return -1, "", err | |
| 110 | + | } | |
| 111 | + | fd = next | |
| 112 | + | } | |
| 113 | + | return fd, parts[len(parts)-1], nil | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func (r *Repository) repositoryRelative(path string) (string, bool) { | |
| 117 | + | if path == r.dir { | |
| 118 | + | return ".", true | |
| 119 | + | } | |
| 120 | + | prefix := r.dir + string(filepath.Separator) | |
| 121 | + | if !strings.HasPrefix(path, prefix) { | |
| 122 | + | return "", false | |
| 123 | + | } | |
| 124 | + | rel := strings.TrimPrefix(path, prefix) | |
| 125 | + | if rel == "" || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) { | |
| 126 | + | return "", false | |
| 127 | + | } | |
| 128 | + | return rel, true | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func (r *Repository) openDirectory(path string) (*os.File, error) { | |
| 132 | + | return r.repositoryFD(path, syscall.O_RDONLY|syscall.O_DIRECTORY, 0) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func (r *Repository) stat(path string) (st syscall.Stat_t, err error) { | |
| 136 | + | file, err := r.repositoryFD(path, syscall.O_RDONLY|syscall.O_NONBLOCK, 0) | |
| 137 | + | if err != nil { | |
| 138 | + | return syscall.Stat_t{}, err | |
| 139 | + | } | |
| 140 | + | defer func() { | |
| 141 | + | joinCloseError(&err, "close snapshot file", r.closeRepositoryFile(file, "close snapshot file")) | |
| 142 | + | }() | |
| 143 | + | if err := syscall.Fstat(int(file.Fd()), &st); err != nil { | |
| 144 | + | return syscall.Stat_t{}, err | |
| 145 | + | } | |
| 146 | + | return st, nil | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // createTempAt creates an exclusively owned temporary file in an already | |
| 150 | + | // validated directory. os.CreateTemp(path) would resolve that path again. | |
| 151 | + | func (r *Repository) createTempAt(dir string) (temp *os.File, err error) { | |
| 152 | + | fd, err := r.openDirectory(dir) | |
| 153 | + | if err != nil { | |
| 154 | + | return nil, err | |
| 155 | + | } | |
| 156 | + | defer func() { | |
| 157 | + | if closeErr := r.closeRepositoryFile(fd, "close snapshot temporary directory"); closeErr != nil { | |
| 158 | + | if temp != nil { | |
| 159 | + | joinCloseError(&err, "close snapshot temporary file after directory close failure", r.closeRepositoryFile(temp, "close snapshot temporary file after directory close failure")) | |
| 160 | + | temp = nil | |
| 161 | + | } | |
| 162 | + | joinCloseError(&err, "close snapshot temporary directory", closeErr) | |
| 163 | + | } | |
| 164 | + | }() | |
| 165 | + | for range 100 { | |
| 166 | + | var random [8]byte | |
| 167 | + | if _, err := rand.Read(random[:]); err != nil { | |
| 168 | + | return nil, err | |
| 169 | + | } | |
| 170 | + | name := ".tmp-" + hex.EncodeToString(random[:]) | |
| 171 | + | tempFD, err := unix.Openat(int(fd.Fd()), name, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0o600) | |
| 172 | + | if errors.Is(err, syscall.EEXIST) { | |
| 173 | + | continue | |
| 174 | + | } | |
| 175 | + | if err != nil { | |
| 176 | + | return nil, err | |
| 177 | + | } | |
| 178 | + | temp = os.NewFile(uintptr(tempFD), filepath.Join(dir, name)) | |
| 179 | + | if temp == nil { | |
| 180 | + | err = fmt.Errorf("open snapshot temporary file") | |
| 181 | + | joinCloseError(&err, "close snapshot temporary descriptor", r.closeDescriptor(tempFD, "close snapshot temporary descriptor")) | |
| 182 | + | return nil, err | |
| 183 | + | } | |
| 184 | + | return temp, nil | |
| 185 | + | } | |
| 186 | + | return nil, fmt.Errorf("create snapshot temporary file: too many collisions") | |
| 187 | + | } | |
D internal/adapters/snapshot/store.go +0 -168
| @@ -1,168 +0,0 @@ | |||
| 1 | - | package snapshot | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "crypto/sha256" | |
| 5 | - | "encoding/hex" | |
| 6 | - | "errors" | |
| 7 | - | "fmt" | |
| 8 | - | "io" | |
| 9 | - | "log/slog" | |
| 10 | - | "os" | |
| 11 | - | "path/filepath" | |
| 12 | - | "regexp" | |
| 13 | - | "strings" | |
| 14 | - | ||
| 15 | - | "github.com/bnema/vev/internal/ports" | |
| 16 | - | "github.com/bnema/vev/pkg/safedir" | |
| 17 | - | ) | |
| 18 | - | ||
| 19 | - | var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`) | |
| 20 | - | ||
| 21 | - | const maxSnapshotFileSize = 16 + (256 << 20) | |
| 22 | - | ||
| 23 | - | // Store persists raw session snapshot blobs on disk. | |
| 24 | - | type Store struct { | |
| 25 | - | dir string | |
| 26 | - | log *slog.Logger | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | var _ ports.SnapshotStore = (*Store)(nil) | |
| 30 | - | ||
| 31 | - | // NewStore creates a file-backed snapshot store rooted at dir. | |
| 32 | - | func NewStore(dir string) *Store { | |
| 33 | - | return &Store{dir: dir, log: slog.Default()} | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // Write atomically writes data for name. The previous snapshot remains in place | |
| 37 | - | // until the final rename succeeds. | |
| 38 | - | func (s *Store) Write(name string, data []byte) error { | |
| 39 | - | if err := safedir.EnsurePrivate(s.dir); err != nil { | |
| 40 | - | return fmt.Errorf("create snapshot dir: %w", err) | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | tmp, err := os.CreateTemp(s.dir, ".tmp-*") | |
| 44 | - | if err != nil { | |
| 45 | - | return fmt.Errorf("create temp snapshot: %w", err) | |
| 46 | - | } | |
| 47 | - | tmpName := tmp.Name() | |
| 48 | - | keepTmp := false | |
| 49 | - | defer func() { | |
| 50 | - | if !keepTmp { | |
| 51 | - | _ = os.Remove(tmpName) | |
| 52 | - | } | |
| 53 | - | }() | |
| 54 | - | ||
| 55 | - | if err := tmp.Chmod(0o600); err != nil { | |
| 56 | - | _ = tmp.Close() | |
| 57 | - | return fmt.Errorf("chmod temp snapshot: %w", err) | |
| 58 | - | } | |
| 59 | - | if _, err := tmp.Write(data); err != nil { | |
| 60 | - | _ = tmp.Close() | |
| 61 | - | return fmt.Errorf("write temp snapshot: %w", err) | |
| 62 | - | } | |
| 63 | - | if err := tmp.Sync(); err != nil { | |
| 64 | - | _ = tmp.Close() | |
| 65 | - | return fmt.Errorf("sync temp snapshot: %w", err) | |
| 66 | - | } | |
| 67 | - | if err := tmp.Close(); err != nil { | |
| 68 | - | return fmt.Errorf("close temp snapshot: %w", err) | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | path := filepath.Join(s.dir, filenameForName(name)) | |
| 72 | - | if err := os.Rename(tmpName, path); err != nil { | |
| 73 | - | return fmt.Errorf("rename snapshot: %w", err) | |
| 74 | - | } | |
| 75 | - | keepTmp = true | |
| 76 | - | syncDir(s.dir) | |
| 77 | - | return nil | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | // Load returns all raw .snap blobs. It does not validate the blob format. | |
| 81 | - | func (s *Store) Load() ([]ports.SnapshotBlob, error) { | |
| 82 | - | entries, err := os.ReadDir(s.dir) | |
| 83 | - | if errors.Is(err, os.ErrNotExist) { | |
| 84 | - | return nil, nil | |
| 85 | - | } | |
| 86 | - | if err != nil { | |
| 87 | - | return nil, fmt.Errorf("read snapshot dir: %w", err) | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | blobs := make([]ports.SnapshotBlob, 0, len(entries)) | |
| 91 | - | for _, entry := range entries { | |
| 92 | - | base := entry.Name() | |
| 93 | - | path := filepath.Join(s.dir, base) | |
| 94 | - | if strings.HasPrefix(base, ".tmp-") { | |
| 95 | - | if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 96 | - | s.log.Warn("remove stale snapshot temp", "path", path, "err", err) | |
| 97 | - | } | |
| 98 | - | continue | |
| 99 | - | } | |
| 100 | - | if entry.IsDir() || !strings.HasSuffix(base, ".snap") { | |
| 101 | - | continue | |
| 102 | - | } | |
| 103 | - | data, err := readSnapshotFileBounded(path) | |
| 104 | - | if err != nil { | |
| 105 | - | s.log.Warn("read snapshot", "path", path, "err", err) | |
| 106 | - | continue | |
| 107 | - | } | |
| 108 | - | name := strings.TrimSuffix(base, ".snap") | |
| 109 | - | if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) { | |
| 110 | - | name = "" | |
| 111 | - | } | |
| 112 | - | blobs = append(blobs, ports.SnapshotBlob{Name: name, Data: data}) | |
| 113 | - | } | |
| 114 | - | return blobs, nil | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | // Delete removes the deterministic snapshot file for name. Missing files are ignored. | |
| 118 | - | func (s *Store) Delete(name string) error { | |
| 119 | - | err := os.Remove(filepath.Join(s.dir, filenameForName(name))) | |
| 120 | - | if errors.Is(err, os.ErrNotExist) { | |
| 121 | - | return nil | |
| 122 | - | } | |
| 123 | - | if err != nil { | |
| 124 | - | return fmt.Errorf("delete snapshot: %w", err) | |
| 125 | - | } | |
| 126 | - | syncDir(s.dir) | |
| 127 | - | return nil | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | func readSnapshotFileBounded(path string) ([]byte, error) { | |
| 131 | - | f, err := os.Open(path) | |
| 132 | - | if err != nil { | |
| 133 | - | return nil, err | |
| 134 | - | } | |
| 135 | - | defer func() { _ = f.Close() }() | |
| 136 | - | info, err := f.Stat() | |
| 137 | - | if err != nil { | |
| 138 | - | return nil, err | |
| 139 | - | } | |
| 140 | - | if info.Size() > maxSnapshotFileSize { | |
| 141 | - | return nil, fmt.Errorf("snapshot too large: %d bytes", info.Size()) | |
| 142 | - | } | |
| 143 | - | data, err := io.ReadAll(io.LimitReader(f, maxSnapshotFileSize+1)) | |
| 144 | - | if err != nil { | |
| 145 | - | return nil, err | |
| 146 | - | } | |
| 147 | - | if len(data) > maxSnapshotFileSize { | |
| 148 | - | return nil, fmt.Errorf("snapshot too large: %d bytes", len(data)) | |
| 149 | - | } | |
| 150 | - | return data, nil | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | func filenameForName(name string) string { | |
| 154 | - | if safeNameRE.MatchString(name) { | |
| 155 | - | return name + ".snap" | |
| 156 | - | } | |
| 157 | - | sum := sha256.Sum256([]byte(name)) | |
| 158 | - | return "@" + hex.EncodeToString(sum[:])[:40] + ".snap" | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | func syncDir(dir string) { | |
| 162 | - | f, err := os.Open(dir) | |
| 163 | - | if err != nil { | |
| 164 | - | return | |
| 165 | - | } | |
| 166 | - | defer func() { _ = f.Close() }() | |
| 167 | - | _ = f.Sync() | |
| 168 | - | } | |
D internal/adapters/snapshot/store_test.go +0 -192
| @@ -1,192 +0,0 @@ | |||
| 1 | - | package snapshot | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "crypto/sha256" | |
| 5 | - | "encoding/hex" | |
| 6 | - | "errors" | |
| 7 | - | "os" | |
| 8 | - | "path/filepath" | |
| 9 | - | "runtime" | |
| 10 | - | "sort" | |
| 11 | - | "strings" | |
| 12 | - | "testing" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | func privateDir(t *testing.T) string { | |
| 16 | - | t.Helper() | |
| 17 | - | return filepath.Join(t.TempDir(), "vev") | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | func TestStoreWriteLoadRoundTripAndSupersede(t *testing.T) { | |
| 21 | - | t.Parallel() | |
| 22 | - | dir := privateDir(t) | |
| 23 | - | store := NewStore(dir) | |
| 24 | - | ||
| 25 | - | if err := store.Write("alpha", []byte("first")); err != nil { | |
| 26 | - | t.Fatalf("Write first: %v", err) | |
| 27 | - | } | |
| 28 | - | if err := store.Write("alpha", []byte("second")); err != nil { | |
| 29 | - | t.Fatalf("Write second: %v", err) | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | got := loadMap(t, store) | |
| 33 | - | if string(got["alpha"]) != "second" { | |
| 34 | - | t.Fatalf("loaded alpha = %q, want second", got["alpha"]) | |
| 35 | - | } | |
| 36 | - | info, err := os.Stat(filepath.Join(dir, "alpha.snap")) | |
| 37 | - | if err != nil { | |
| 38 | - | t.Fatalf("stat snapshot: %v", err) | |
| 39 | - | } | |
| 40 | - | if gotMode := info.Mode().Perm(); gotMode != 0o600 { | |
| 41 | - | t.Fatalf("snapshot mode = %v, want 0600", gotMode) | |
| 42 | - | } | |
| 43 | - | if dirInfo, err := os.Stat(dir); err != nil { | |
| 44 | - | t.Fatalf("stat dir: %v", err) | |
| 45 | - | } else if gotMode := dirInfo.Mode().Perm(); gotMode != 0o700 { | |
| 46 | - | t.Fatalf("dir mode = %v, want 0700", gotMode) | |
| 47 | - | } | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | func TestStoreDelete(t *testing.T) { | |
| 51 | - | t.Parallel() | |
| 52 | - | dir := privateDir(t) | |
| 53 | - | store := NewStore(dir) | |
| 54 | - | ||
| 55 | - | if err := store.Write("gone", []byte("data")); err != nil { | |
| 56 | - | t.Fatalf("Write: %v", err) | |
| 57 | - | } | |
| 58 | - | if err := store.Delete("gone"); err != nil { | |
| 59 | - | t.Fatalf("Delete existing: %v", err) | |
| 60 | - | } | |
| 61 | - | if err := store.Delete("gone"); err != nil { | |
| 62 | - | t.Fatalf("Delete missing: %v", err) | |
| 63 | - | } | |
| 64 | - | if got := loadMap(t, store); len(got) != 0 { | |
| 65 | - | t.Fatalf("Load after delete = %#v, want empty", got) | |
| 66 | - | } | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | func TestStoreUnsafeNameUsesDeterministicHash(t *testing.T) { | |
| 70 | - | t.Parallel() | |
| 71 | - | dir := privateDir(t) | |
| 72 | - | store := NewStore(dir) | |
| 73 | - | name := "../unsafe/session name" | |
| 74 | - | ||
| 75 | - | if err := store.Write(name, []byte("raw")); err != nil { | |
| 76 | - | t.Fatalf("Write unsafe: %v", err) | |
| 77 | - | } | |
| 78 | - | sum := sha256.Sum256([]byte(name)) | |
| 79 | - | wantBase := "@" + hex.EncodeToString(sum[:])[:40] + ".snap" | |
| 80 | - | if _, err := os.Stat(filepath.Join(dir, wantBase)); err != nil { | |
| 81 | - | t.Fatalf("stat hashed snapshot %q: %v", wantBase, err) | |
| 82 | - | } | |
| 83 | - | loaded, err := store.Load() | |
| 84 | - | if err != nil { | |
| 85 | - | t.Fatalf("Load: %v", err) | |
| 86 | - | } | |
| 87 | - | if len(loaded) != 1 || loaded[0].Name != "" || string(loaded[0].Data) != "raw" { | |
| 88 | - | t.Fatalf("Load unsafe = %#v, want one nameless raw blob", loaded) | |
| 89 | - | } | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | func TestStoreMissingDirAndStaleTmpCleanup(t *testing.T) { | |
| 93 | - | t.Parallel() | |
| 94 | - | dir := filepath.Join(t.TempDir(), "missing") | |
| 95 | - | store := NewStore(dir) | |
| 96 | - | ||
| 97 | - | loaded, err := store.Load() | |
| 98 | - | if err != nil { | |
| 99 | - | t.Fatalf("Load missing dir: %v", err) | |
| 100 | - | } | |
| 101 | - | if len(loaded) != 0 { | |
| 102 | - | t.Fatalf("Load missing dir len = %d, want 0", len(loaded)) | |
| 103 | - | } | |
| 104 | - | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 105 | - | t.Fatalf("MkdirAll: %v", err) | |
| 106 | - | } | |
| 107 | - | if err := os.WriteFile(filepath.Join(dir, ".tmp-stale"), []byte("tmp"), 0o600); err != nil { | |
| 108 | - | t.Fatalf("write stale tmp: %v", err) | |
| 109 | - | } | |
| 110 | - | if err := os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("ignore"), 0o600); err != nil { | |
| 111 | - | t.Fatalf("write ignored: %v", err) | |
| 112 | - | } | |
| 113 | - | if err := os.WriteFile(filepath.Join(dir, "beta.snap"), []byte("beta"), 0o600); err != nil { | |
| 114 | - | t.Fatalf("write snap: %v", err) | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | got := loadMap(t, store) | |
| 118 | - | if string(got["beta"]) != "beta" || len(got) != 1 { | |
| 119 | - | t.Fatalf("Load = %#v, want only beta", got) | |
| 120 | - | } | |
| 121 | - | if _, err := os.Stat(filepath.Join(dir, ".tmp-stale")); !errors.Is(err, os.ErrNotExist) { | |
| 122 | - | t.Fatalf("stale tmp stat err = %v, want not exist", err) | |
| 123 | - | } | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | func TestStoreLoadRejectsOversizedFileBeforeRead(t *testing.T) { | |
| 127 | - | t.Parallel() | |
| 128 | - | dir := privateDir(t) | |
| 129 | - | store := NewStore(dir) | |
| 130 | - | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 131 | - | t.Fatalf("MkdirAll: %v", err) | |
| 132 | - | } | |
| 133 | - | if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil { | |
| 134 | - | t.Fatalf("write ok: %v", err) | |
| 135 | - | } | |
| 136 | - | tooLarge := filepath.Join(dir, "huge.snap") | |
| 137 | - | f, err := os.Create(tooLarge) | |
| 138 | - | if err != nil { | |
| 139 | - | t.Fatalf("create huge: %v", err) | |
| 140 | - | } | |
| 141 | - | if err := f.Truncate(maxSnapshotFileSize + 1); err != nil { | |
| 142 | - | _ = f.Close() | |
| 143 | - | t.Fatalf("truncate huge: %v", err) | |
| 144 | - | } | |
| 145 | - | if err := f.Close(); err != nil { | |
| 146 | - | t.Fatalf("close huge: %v", err) | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | got := loadMap(t, store) | |
| 150 | - | if len(got) != 1 || string(got["ok"]) != "ok" { | |
| 151 | - | t.Fatalf("Load = %#v, want only ok", got) | |
| 152 | - | } | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | func TestStoreLoadSkipsUnreadableFile(t *testing.T) { | |
| 156 | - | if runtime.GOOS == "windows" || os.Geteuid() == 0 { | |
| 157 | - | t.Skip("permission bits do not make file unreadable on this platform/user") | |
| 158 | - | } | |
| 159 | - | t.Parallel() | |
| 160 | - | dir := privateDir(t) | |
| 161 | - | store := NewStore(dir) | |
| 162 | - | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 163 | - | t.Fatalf("MkdirAll: %v", err) | |
| 164 | - | } | |
| 165 | - | if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil { | |
| 166 | - | t.Fatalf("write ok: %v", err) | |
| 167 | - | } | |
| 168 | - | bad := filepath.Join(dir, "bad.snap") | |
| 169 | - | if err := os.WriteFile(bad, []byte("bad"), 0o000); err != nil { | |
| 170 | - | t.Fatalf("write bad: %v", err) | |
| 171 | - | } | |
| 172 | - | t.Cleanup(func() { _ = os.Chmod(bad, 0o600) }) | |
| 173 | - | ||
| 174 | - | got := loadMap(t, store) | |
| 175 | - | if len(got) != 1 || string(got["ok"]) != "ok" { | |
| 176 | - | t.Fatalf("Load = %#v, want only ok", got) | |
| 177 | - | } | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | func loadMap(t *testing.T, store *Store) map[string][]byte { | |
| 181 | - | t.Helper() | |
| 182 | - | loaded, err := store.Load() | |
| 183 | - | if err != nil { | |
| 184 | - | t.Fatalf("Load: %v", err) | |
| 185 | - | } | |
| 186 | - | sort.Slice(loaded, func(i, j int) bool { return strings.Compare(loaded[i].Name, loaded[j].Name) < 0 }) | |
| 187 | - | out := make(map[string][]byte, len(loaded)) | |
| 188 | - | for _, blob := range loaded { | |
| 189 | - | out[blob.Name] = blob.Data | |
| 190 | - | } | |
| 191 | - | return out | |
| 192 | - | } | |
A internal/adapters/snapshot/test_helpers_test.go +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func privateDir(t *testing.T) string { | |
| 9 | + | t.Helper() | |
| 10 | + | return filepath.Join(t.TempDir(), "vev") | |
| 11 | + | } | |
A internal/app/offline_kill_retry_test.go +88 -0
| @@ -0,0 +1,88 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/persist" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type offlineSnapshotFake struct { | |
| 15 | + | calls []string | |
| 16 | + | deleteErr error | |
| 17 | + | deleteLegacyErr error | |
| 18 | + | tombstoneErr error | |
| 19 | + | deleteTombstoneErr error | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (f *offlineSnapshotFake) Tombstone(context.Context, string) error { | |
| 23 | + | f.calls = append(f.calls, "tombstone") | |
| 24 | + | return f.tombstoneErr | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (f *offlineSnapshotFake) Delete(context.Context, string) error { | |
| 28 | + | f.calls = append(f.calls, "incremental") | |
| 29 | + | return f.deleteErr | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (f *offlineSnapshotFake) DeleteLegacy(context.Context, string) error { | |
| 33 | + | f.calls = append(f.calls, "legacy") | |
| 34 | + | return f.deleteLegacyErr | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (f *offlineSnapshotFake) DeleteTombstone(context.Context, string) error { | |
| 38 | + | f.calls = append(f.calls, "delete tombstone") | |
| 39 | + | return f.deleteTombstoneErr | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestRunKillOfflineRetriesSnapshotDeletion(t *testing.T) { | |
| 43 | + | for _, tt := range []struct { | |
| 44 | + | name string | |
| 45 | + | incrementalErr error | |
| 46 | + | legacyErr error | |
| 47 | + | }{ | |
| 48 | + | {name: "legacy failure", legacyErr: errors.New("legacy unlink failed")}, | |
| 49 | + | {name: "incremental failure", incrementalErr: errors.New("incremental quarantine failed")}, | |
| 50 | + | {name: "both failures", incrementalErr: errors.New("incremental quarantine failed"), legacyErr: errors.New("legacy unlink failed")}, | |
| 51 | + | } { | |
| 52 | + | t.Run(tt.name, func(t *testing.T) { | |
| 53 | + | stateRoot, runtimeRoot := t.TempDir(), t.TempDir() | |
| 54 | + | t.Setenv("XDG_STATE_HOME", stateRoot) | |
| 55 | + | t.Setenv("XDG_RUNTIME_DIR", runtimeRoot) | |
| 56 | + | ||
| 57 | + | p, err := persist.Open(filepath.Join(stateRoot, "vev")) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | now := time.Now().UnixNano() | |
| 60 | + | require.NoError(t, p.Save(persist.Record{Name: "named", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now})) | |
| 61 | + | require.NoError(t, p.Close()) | |
| 62 | + | ||
| 63 | + | fake := &offlineSnapshotFake{deleteErr: tt.incrementalErr, deleteLegacyErr: tt.legacyErr} | |
| 64 | + | originalFactory := newOfflineSnapshotRepository | |
| 65 | + | newOfflineSnapshotRepository = func(string) offlineSnapshotSource { return fake } | |
| 66 | + | t.Cleanup(func() { newOfflineSnapshotRepository = originalFactory }) | |
| 67 | + | ||
| 68 | + | err = runKill(context.Background(), "named", false, false) | |
| 69 | + | if tt.incrementalErr != nil { | |
| 70 | + | require.ErrorIs(t, err, tt.incrementalErr) | |
| 71 | + | } | |
| 72 | + | if tt.legacyErr != nil { | |
| 73 | + | require.ErrorIs(t, err, tt.legacyErr) | |
| 74 | + | } | |
| 75 | + | require.Equal(t, []string{"tombstone", "incremental", "legacy"}, fake.calls) | |
| 76 | + | records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev")) | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | require.Len(t, records, 1, "a failed source delete must retain retry identity") | |
| 79 | + | ||
| 80 | + | fake.deleteErr, fake.deleteLegacyErr = nil, nil | |
| 81 | + | require.NoError(t, runKill(context.Background(), "named", false, false)) | |
| 82 | + | require.Equal(t, []string{"tombstone", "incremental", "legacy", "tombstone", "incremental", "legacy", "delete tombstone"}, fake.calls) | |
| 83 | + | records, err = persist.LoadReadOnly(filepath.Join(stateRoot, "vev")) | |
| 84 | + | require.NoError(t, err) | |
| 85 | + | require.Empty(t, records) | |
| 86 | + | }) | |
| 87 | + | } | |
| 88 | + | } | |
M internal/app/run.go +44 -5
| @@ -450,7 +450,8 @@ func runDaemon() (retErr error) { | |||
| 450 | 450 | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | |
| 451 | 451 | daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New())) | |
| 452 | 452 | daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome)) | |
| 453 | - | daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir()))) | |
| 453 | + | snapshotRepository := snapshotadapter.NewRepository(snapshotDir()) | |
| 454 | + | daemonOpts = append(daemonOpts, daemon.WithSnapshotRepository(snapshotRepository, snapshotRepository)) | |
| 454 | 455 | daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticefile.New(platform.StateDir()))) | |
| 455 | 456 | storePath := persist.StorePath(platform.StateDir()) | |
| 456 | 457 | var storeErr error | |
| @@ -1185,6 +1186,27 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) { | |||
| 1185 | 1186 | _ = tw.Flush() | |
| 1186 | 1187 | } | |
| 1187 | 1188 | ||
| 1189 | + | // offlineSnapshotSource owns the durable source and tombstone transitions for | |
| 1190 | + | // a daemon-less kill. It is intentionally local to the composition root: the | |
| 1191 | + | // daemon uses the repository through its narrower ports. | |
| 1192 | + | type offlineSnapshotSource interface { | |
| 1193 | + | Tombstone(context.Context, string) error | |
| 1194 | + | Delete(context.Context, string) error | |
| 1195 | + | DeleteLegacy(context.Context, string) error | |
| 1196 | + | DeleteTombstone(context.Context, string) error | |
| 1197 | + | } | |
| 1198 | + | ||
| 1199 | + | var newOfflineSnapshotRepository = func(dir string) offlineSnapshotSource { | |
| 1200 | + | return snapshotadapter.NewRepository(dir) | |
| 1201 | + | } | |
| 1202 | + | ||
| 1203 | + | func phaseError(phase string, err error) error { | |
| 1204 | + | if err == nil { | |
| 1205 | + | return nil | |
| 1206 | + | } | |
| 1207 | + | return fmt.Errorf("vev: %s: %w", phase, err) | |
| 1208 | + | } | |
| 1209 | + | ||
| 1188 | 1210 | // runKill asks the daemon to terminate a named session, every session, or the daemon. | |
| 1189 | 1211 | func runKill(ctx context.Context, name string, all, daemon bool) error { | |
| 1190 | 1212 | transport, err := realDial(ctx, ipc.SocketDir()) | |
| @@ -1214,17 +1236,34 @@ func runKill(ctx context.Context, name string, all, daemon bool) error { | |||
| 1214 | 1236 | if openErr != nil { | |
| 1215 | 1237 | return fmt.Errorf("vev: opening stored sessions: %w", openErr) | |
| 1216 | 1238 | } | |
| 1239 | + | snapshots := newOfflineSnapshotRepository(snapshotDir()) | |
| 1240 | + | // The durable marker is the commit point for a logical kill. Keep it and | |
| 1241 | + | // the persisted record until both independently recoverable sources have | |
| 1242 | + | // crossed their deletion durability boundaries. | |
| 1243 | + | if tombstoneErr := snapshots.Tombstone(ctx, name); tombstoneErr != nil { | |
| 1244 | + | _ = p.Close() | |
| 1245 | + | return fmt.Errorf("vev: marking session killed: %w", tombstoneErr) | |
| 1246 | + | } | |
| 1247 | + | incrementalErr := snapshots.Delete(ctx, name) | |
| 1248 | + | legacyErr := snapshots.DeleteLegacy(ctx, name) | |
| 1249 | + | if incrementalErr != nil || legacyErr != nil { | |
| 1250 | + | _ = p.Close() | |
| 1251 | + | return errors.Join( | |
| 1252 | + | phaseError("deleting incremental session snapshot", incrementalErr), | |
| 1253 | + | phaseError("deleting legacy session snapshot", legacyErr), | |
| 1254 | + | ) | |
| 1255 | + | } | |
| 1217 | 1256 | if deleteErr := p.Delete(name); deleteErr != nil { | |
| 1218 | 1257 | _ = p.Close() | |
| 1219 | 1258 | return fmt.Errorf("vev: deleting stored session: %w", deleteErr) | |
| 1220 | 1259 | } | |
| 1260 | + | if tombstoneErr := snapshots.DeleteTombstone(ctx, name); tombstoneErr != nil { | |
| 1261 | + | _ = p.Close() | |
| 1262 | + | return fmt.Errorf("vev: clearing killed session marker: %w", tombstoneErr) | |
| 1263 | + | } | |
| 1221 | 1264 | if closeErr := p.Close(); closeErr != nil { | |
| 1222 | 1265 | return fmt.Errorf("vev: closing stored sessions: %w", closeErr) | |
| 1223 | 1266 | } | |
| 1224 | - | snapshots := snapshotadapter.NewStore(snapshotDir()) | |
| 1225 | - | if deleteErr := snapshots.Delete(name); deleteErr != nil { | |
| 1226 | - | return fmt.Errorf("vev: deleting session snapshot: %w", deleteErr) | |
| 1227 | - | } | |
| 1228 | 1267 | printKillSuccess(name, all, daemon) | |
| 1229 | 1268 | return nil | |
| 1230 | 1269 | } | |
M internal/app/run_test.go +5 -8
| @@ -352,10 +352,7 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) { | |||
| 352 | 352 | if err := p.Close(); err != nil { | |
| 353 | 353 | t.Fatalf("Close error = %v", err) | |
| 354 | 354 | } | |
| 355 | - | snapshots := snapshot.NewStore(filepath.Join(stateRoot, "vev", "snapshots")) | |
| 356 | - | if err := snapshots.Write("stored", []byte("snapshot bytes")); err != nil { | |
| 357 | - | t.Fatalf("snapshot Write error = %v", err) | |
| 358 | - | } | |
| 355 | + | snapshots := snapshot.NewRepository(filepath.Join(stateRoot, "vev", "snapshots")) | |
| 359 | 356 | ||
| 360 | 357 | got := captureStdout(t, func() { | |
| 361 | 358 | if err := runKill(context.Background(), "stored", false, false); err != nil { | |
| @@ -372,12 +369,12 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) { | |||
| 372 | 369 | if len(records) != 0 { | |
| 373 | 370 | t.Fatalf("records after kill = %#v, want none", records) | |
| 374 | 371 | } | |
| 375 | - | blobs, err := snapshots.Load() | |
| 372 | + | names, err := snapshots.List(context.Background()) | |
| 376 | 373 | if err != nil { | |
| 377 | - | t.Fatalf("snapshot Load error = %v", err) | |
| 374 | + | t.Fatalf("snapshot List error = %v", err) | |
| 378 | 375 | } | |
| 379 | - | if len(blobs) != 0 { | |
| 380 | - | t.Fatalf("snapshots after kill = %#v, want none", blobs) | |
| 376 | + | if len(names) != 0 { | |
| 377 | + | t.Fatalf("snapshots after kill = %#v, want none", names) | |
| 381 | 378 | } | |
| 382 | 379 | } | |
| 383 | 380 | ||
M internal/ports/mocks/store_mock.go +514 -65
| @@ -5,6 +5,8 @@ | |||
| 5 | 5 | package portsmocks | |
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 8 | 10 | "github.com/bnema/vev/internal/ports" | |
| 9 | 11 | mock "github.com/stretchr/testify/mock" | |
| 10 | 12 | ) | |
| @@ -334,13 +336,13 @@ func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Ca | |||
| 334 | 336 | return _c | |
| 335 | 337 | } | |
| 336 | 338 | ||
| 337 | - | // NewMockSnapshotStore creates a new instance of MockSnapshotStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 339 | + | // NewMockSnapshotRepository creates a new instance of MockSnapshotRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 338 | 340 | // The first argument is typically a *testing.T value. | |
| 339 | - | func NewMockSnapshotStore(t interface { | |
| 341 | + | func NewMockSnapshotRepository(t interface { | |
| 340 | 342 | mock.TestingT | |
| 341 | 343 | Cleanup(func()) | |
| 342 | - | }) *MockSnapshotStore { | |
| 343 | - | mock := &MockSnapshotStore{} | |
| 344 | + | }) *MockSnapshotRepository { | |
| 345 | + | mock := &MockSnapshotRepository{} | |
| 344 | 346 | mock.Mock.Test(t) | |
| 345 | 347 | ||
| 346 | 348 | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| @@ -348,163 +350,350 @@ func NewMockSnapshotStore(t interface { | |||
| 348 | 350 | return mock | |
| 349 | 351 | } | |
| 350 | 352 | ||
| 351 | - | // MockSnapshotStore is an autogenerated mock type for the SnapshotStore type | |
| 352 | - | type MockSnapshotStore struct { | |
| 353 | + | // MockSnapshotRepository is an autogenerated mock type for the SnapshotRepository type | |
| 354 | + | type MockSnapshotRepository struct { | |
| 353 | 355 | mock.Mock | |
| 354 | 356 | } | |
| 355 | 357 | ||
| 356 | - | type MockSnapshotStore_Expecter struct { | |
| 358 | + | type MockSnapshotRepository_Expecter struct { | |
| 357 | 359 | mock *mock.Mock | |
| 358 | 360 | } | |
| 359 | 361 | ||
| 360 | - | func (_m *MockSnapshotStore) EXPECT() *MockSnapshotStore_Expecter { | |
| 361 | - | return &MockSnapshotStore_Expecter{mock: &_m.Mock} | |
| 362 | + | func (_m *MockSnapshotRepository) EXPECT() *MockSnapshotRepository_Expecter { | |
| 363 | + | return &MockSnapshotRepository_Expecter{mock: &_m.Mock} | |
| 362 | 364 | } | |
| 363 | 365 | ||
| 364 | - | // Delete provides a mock function for the type MockSnapshotStore | |
| 365 | - | func (_mock *MockSnapshotStore) Delete(name string) error { | |
| 366 | - | ret := _mock.Called(name) | |
| 366 | + | // Delete provides a mock function for the type MockSnapshotRepository | |
| 367 | + | func (_mock *MockSnapshotRepository) Delete(context1 context.Context, s string) error { | |
| 368 | + | ret := _mock.Called(context1, s) | |
| 367 | 369 | ||
| 368 | 370 | if len(ret) == 0 { | |
| 369 | 371 | panic("no return value specified for Delete") | |
| 370 | 372 | } | |
| 371 | 373 | ||
| 372 | 374 | var r0 error | |
| 373 | - | if returnFunc, ok := ret.Get(0).(func(string) error); ok { | |
| 374 | - | r0 = returnFunc(name) | |
| 375 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 376 | + | r0 = returnFunc(context1, s) | |
| 375 | 377 | } else { | |
| 376 | 378 | r0 = ret.Error(0) | |
| 377 | 379 | } | |
| 378 | 380 | return r0 | |
| 379 | 381 | } | |
| 380 | 382 | ||
| 381 | - | // MockSnapshotStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |
| 382 | - | type MockSnapshotStore_Delete_Call struct { | |
| 383 | + | // MockSnapshotRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |
| 384 | + | type MockSnapshotRepository_Delete_Call struct { | |
| 383 | 385 | *mock.Call | |
| 384 | 386 | } | |
| 385 | 387 | ||
| 386 | 388 | // Delete is a helper method to define mock.On call | |
| 387 | - | // - name string | |
| 388 | - | func (_e *MockSnapshotStore_Expecter) Delete(name any) *MockSnapshotStore_Delete_Call { | |
| 389 | - | return &MockSnapshotStore_Delete_Call{Call: _e.mock.On("Delete", name)} | |
| 389 | + | // - context1 context.Context | |
| 390 | + | // - s string | |
| 391 | + | func (_e *MockSnapshotRepository_Expecter) Delete(context1 any, s any) *MockSnapshotRepository_Delete_Call { | |
| 392 | + | return &MockSnapshotRepository_Delete_Call{Call: _e.mock.On("Delete", context1, s)} | |
| 390 | 393 | } | |
| 391 | 394 | ||
| 392 | - | func (_c *MockSnapshotStore_Delete_Call) Run(run func(name string)) *MockSnapshotStore_Delete_Call { | |
| 395 | + | func (_c *MockSnapshotRepository_Delete_Call) Run(run func(context1 context.Context, s string)) *MockSnapshotRepository_Delete_Call { | |
| 393 | 396 | _c.Call.Run(func(args mock.Arguments) { | |
| 394 | - | var arg0 string | |
| 397 | + | var arg0 context.Context | |
| 395 | 398 | if args[0] != nil { | |
| 396 | - | arg0 = args[0].(string) | |
| 399 | + | arg0 = args[0].(context.Context) | |
| 400 | + | } | |
| 401 | + | var arg1 string | |
| 402 | + | if args[1] != nil { | |
| 403 | + | arg1 = args[1].(string) | |
| 397 | 404 | } | |
| 398 | 405 | run( | |
| 399 | 406 | arg0, | |
| 407 | + | arg1, | |
| 400 | 408 | ) | |
| 401 | 409 | }) | |
| 402 | 410 | return _c | |
| 403 | 411 | } | |
| 404 | 412 | ||
| 405 | - | func (_c *MockSnapshotStore_Delete_Call) Return(err error) *MockSnapshotStore_Delete_Call { | |
| 413 | + | func (_c *MockSnapshotRepository_Delete_Call) Return(err error) *MockSnapshotRepository_Delete_Call { | |
| 406 | 414 | _c.Call.Return(err) | |
| 407 | 415 | return _c | |
| 408 | 416 | } | |
| 409 | 417 | ||
| 410 | - | func (_c *MockSnapshotStore_Delete_Call) RunAndReturn(run func(name string) error) *MockSnapshotStore_Delete_Call { | |
| 418 | + | func (_c *MockSnapshotRepository_Delete_Call) RunAndReturn(run func(context1 context.Context, s string) error) *MockSnapshotRepository_Delete_Call { | |
| 411 | 419 | _c.Call.Return(run) | |
| 412 | 420 | return _c | |
| 413 | 421 | } | |
| 414 | 422 | ||
| 415 | - | // Load provides a mock function for the type MockSnapshotStore | |
| 416 | - | func (_mock *MockSnapshotStore) Load() ([]ports.SnapshotBlob, error) { | |
| 417 | - | ret := _mock.Called() | |
| 423 | + | // DeleteTombstone provides a mock function for the type MockSnapshotRepository | |
| 424 | + | func (_mock *MockSnapshotRepository) DeleteTombstone(context1 context.Context, s string) error { | |
| 425 | + | ret := _mock.Called(context1, s) | |
| 418 | 426 | ||
| 419 | 427 | if len(ret) == 0 { | |
| 420 | - | panic("no return value specified for Load") | |
| 428 | + | panic("no return value specified for DeleteTombstone") | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | var r0 error | |
| 432 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 433 | + | r0 = returnFunc(context1, s) | |
| 434 | + | } else { | |
| 435 | + | r0 = ret.Error(0) | |
| 436 | + | } | |
| 437 | + | return r0 | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | // MockSnapshotRepository_DeleteTombstone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteTombstone' | |
| 441 | + | type MockSnapshotRepository_DeleteTombstone_Call struct { | |
| 442 | + | *mock.Call | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | // DeleteTombstone is a helper method to define mock.On call | |
| 446 | + | // - context1 context.Context | |
| 447 | + | // - s string | |
| 448 | + | func (_e *MockSnapshotRepository_Expecter) DeleteTombstone(context1 any, s any) *MockSnapshotRepository_DeleteTombstone_Call { | |
| 449 | + | return &MockSnapshotRepository_DeleteTombstone_Call{Call: _e.mock.On("DeleteTombstone", context1, s)} | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | func (_c *MockSnapshotRepository_DeleteTombstone_Call) Run(run func(context1 context.Context, s string)) *MockSnapshotRepository_DeleteTombstone_Call { | |
| 453 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 454 | + | var arg0 context.Context | |
| 455 | + | if args[0] != nil { | |
| 456 | + | arg0 = args[0].(context.Context) | |
| 457 | + | } | |
| 458 | + | var arg1 string | |
| 459 | + | if args[1] != nil { | |
| 460 | + | arg1 = args[1].(string) | |
| 461 | + | } | |
| 462 | + | run( | |
| 463 | + | arg0, | |
| 464 | + | arg1, | |
| 465 | + | ) | |
| 466 | + | }) | |
| 467 | + | return _c | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | func (_c *MockSnapshotRepository_DeleteTombstone_Call) Return(err error) *MockSnapshotRepository_DeleteTombstone_Call { | |
| 471 | + | _c.Call.Return(err) | |
| 472 | + | return _c | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | func (_c *MockSnapshotRepository_DeleteTombstone_Call) RunAndReturn(run func(context1 context.Context, s string) error) *MockSnapshotRepository_DeleteTombstone_Call { | |
| 476 | + | _c.Call.Return(run) | |
| 477 | + | return _c | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | // List provides a mock function for the type MockSnapshotRepository | |
| 481 | + | func (_mock *MockSnapshotRepository) List(context1 context.Context) ([]string, error) { | |
| 482 | + | ret := _mock.Called(context1) | |
| 483 | + | ||
| 484 | + | if len(ret) == 0 { | |
| 485 | + | panic("no return value specified for List") | |
| 421 | 486 | } | |
| 422 | 487 | ||
| 423 | - | var r0 []ports.SnapshotBlob | |
| 488 | + | var r0 []string | |
| 424 | 489 | var r1 error | |
| 425 | - | if returnFunc, ok := ret.Get(0).(func() ([]ports.SnapshotBlob, error)); ok { | |
| 426 | - | return returnFunc() | |
| 490 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok { | |
| 491 | + | return returnFunc(context1) | |
| 427 | 492 | } | |
| 428 | - | if returnFunc, ok := ret.Get(0).(func() []ports.SnapshotBlob); ok { | |
| 429 | - | r0 = returnFunc() | |
| 493 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []string); ok { | |
| 494 | + | r0 = returnFunc(context1) | |
| 430 | 495 | } else { | |
| 431 | 496 | if ret.Get(0) != nil { | |
| 432 | - | r0 = ret.Get(0).([]ports.SnapshotBlob) | |
| 497 | + | r0 = ret.Get(0).([]string) | |
| 433 | 498 | } | |
| 434 | 499 | } | |
| 435 | - | if returnFunc, ok := ret.Get(1).(func() error); ok { | |
| 436 | - | r1 = returnFunc() | |
| 500 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 501 | + | r1 = returnFunc(context1) | |
| 437 | 502 | } else { | |
| 438 | 503 | r1 = ret.Error(1) | |
| 439 | 504 | } | |
| 440 | 505 | return r0, r1 | |
| 441 | 506 | } | |
| 442 | 507 | ||
| 443 | - | // MockSnapshotStore_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load' | |
| 444 | - | type MockSnapshotStore_Load_Call struct { | |
| 508 | + | // MockSnapshotRepository_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' | |
| 509 | + | type MockSnapshotRepository_List_Call struct { | |
| 510 | + | *mock.Call | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | // List is a helper method to define mock.On call | |
| 514 | + | // - context1 context.Context | |
| 515 | + | func (_e *MockSnapshotRepository_Expecter) List(context1 any) *MockSnapshotRepository_List_Call { | |
| 516 | + | return &MockSnapshotRepository_List_Call{Call: _e.mock.On("List", context1)} | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | func (_c *MockSnapshotRepository_List_Call) Run(run func(context1 context.Context)) *MockSnapshotRepository_List_Call { | |
| 520 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 521 | + | var arg0 context.Context | |
| 522 | + | if args[0] != nil { | |
| 523 | + | arg0 = args[0].(context.Context) | |
| 524 | + | } | |
| 525 | + | run( | |
| 526 | + | arg0, | |
| 527 | + | ) | |
| 528 | + | }) | |
| 529 | + | return _c | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | func (_c *MockSnapshotRepository_List_Call) Return(strings []string, err error) *MockSnapshotRepository_List_Call { | |
| 533 | + | _c.Call.Return(strings, err) | |
| 534 | + | return _c | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | func (_c *MockSnapshotRepository_List_Call) RunAndReturn(run func(context1 context.Context) ([]string, error)) *MockSnapshotRepository_List_Call { | |
| 538 | + | _c.Call.Return(run) | |
| 539 | + | return _c | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | // Load provides a mock function for the type MockSnapshotRepository | |
| 543 | + | func (_mock *MockSnapshotRepository) Load(context1 context.Context, s string) (ports.SnapshotGeneration, error) { | |
| 544 | + | ret := _mock.Called(context1, s) | |
| 545 | + | ||
| 546 | + | if len(ret) == 0 { | |
| 547 | + | panic("no return value specified for Load") | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | var r0 ports.SnapshotGeneration | |
| 551 | + | var r1 error | |
| 552 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.SnapshotGeneration, error)); ok { | |
| 553 | + | return returnFunc(context1, s) | |
| 554 | + | } | |
| 555 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.SnapshotGeneration); ok { | |
| 556 | + | r0 = returnFunc(context1, s) | |
| 557 | + | } else { | |
| 558 | + | r0 = ret.Get(0).(ports.SnapshotGeneration) | |
| 559 | + | } | |
| 560 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 561 | + | r1 = returnFunc(context1, s) | |
| 562 | + | } else { | |
| 563 | + | r1 = ret.Error(1) | |
| 564 | + | } | |
| 565 | + | return r0, r1 | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | // MockSnapshotRepository_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load' | |
| 569 | + | type MockSnapshotRepository_Load_Call struct { | |
| 445 | 570 | *mock.Call | |
| 446 | 571 | } | |
| 447 | 572 | ||
| 448 | 573 | // Load is a helper method to define mock.On call | |
| 449 | - | func (_e *MockSnapshotStore_Expecter) Load() *MockSnapshotStore_Load_Call { | |
| 450 | - | return &MockSnapshotStore_Load_Call{Call: _e.mock.On("Load")} | |
| 574 | + | // - context1 context.Context | |
| 575 | + | // - s string | |
| 576 | + | func (_e *MockSnapshotRepository_Expecter) Load(context1 any, s any) *MockSnapshotRepository_Load_Call { | |
| 577 | + | return &MockSnapshotRepository_Load_Call{Call: _e.mock.On("Load", context1, s)} | |
| 451 | 578 | } | |
| 452 | 579 | ||
| 453 | - | func (_c *MockSnapshotStore_Load_Call) Run(run func()) *MockSnapshotStore_Load_Call { | |
| 580 | + | func (_c *MockSnapshotRepository_Load_Call) Run(run func(context1 context.Context, s string)) *MockSnapshotRepository_Load_Call { | |
| 454 | 581 | _c.Call.Run(func(args mock.Arguments) { | |
| 455 | - | run() | |
| 582 | + | var arg0 context.Context | |
| 583 | + | if args[0] != nil { | |
| 584 | + | arg0 = args[0].(context.Context) | |
| 585 | + | } | |
| 586 | + | var arg1 string | |
| 587 | + | if args[1] != nil { | |
| 588 | + | arg1 = args[1].(string) | |
| 589 | + | } | |
| 590 | + | run( | |
| 591 | + | arg0, | |
| 592 | + | arg1, | |
| 593 | + | ) | |
| 456 | 594 | }) | |
| 457 | 595 | return _c | |
| 458 | 596 | } | |
| 459 | 597 | ||
| 460 | - | func (_c *MockSnapshotStore_Load_Call) Return(snapshotBlobs []ports.SnapshotBlob, err error) *MockSnapshotStore_Load_Call { | |
| 461 | - | _c.Call.Return(snapshotBlobs, err) | |
| 598 | + | func (_c *MockSnapshotRepository_Load_Call) Return(snapshotGeneration ports.SnapshotGeneration, err error) *MockSnapshotRepository_Load_Call { | |
| 599 | + | _c.Call.Return(snapshotGeneration, err) | |
| 462 | 600 | return _c | |
| 463 | 601 | } | |
| 464 | 602 | ||
| 465 | - | func (_c *MockSnapshotStore_Load_Call) RunAndReturn(run func() ([]ports.SnapshotBlob, error)) *MockSnapshotStore_Load_Call { | |
| 603 | + | func (_c *MockSnapshotRepository_Load_Call) RunAndReturn(run func(context1 context.Context, s string) (ports.SnapshotGeneration, error)) *MockSnapshotRepository_Load_Call { | |
| 466 | 604 | _c.Call.Return(run) | |
| 467 | 605 | return _c | |
| 468 | 606 | } | |
| 469 | 607 | ||
| 470 | - | // Write provides a mock function for the type MockSnapshotStore | |
| 471 | - | func (_mock *MockSnapshotStore) Write(name string, data []byte) error { | |
| 472 | - | ret := _mock.Called(name, data) | |
| 608 | + | // Maintain provides a mock function for the type MockSnapshotRepository | |
| 609 | + | func (_mock *MockSnapshotRepository) Maintain(context1 context.Context) error { | |
| 610 | + | ret := _mock.Called(context1) | |
| 473 | 611 | ||
| 474 | 612 | if len(ret) == 0 { | |
| 475 | - | panic("no return value specified for Write") | |
| 613 | + | panic("no return value specified for Maintain") | |
| 476 | 614 | } | |
| 477 | 615 | ||
| 478 | 616 | var r0 error | |
| 479 | - | if returnFunc, ok := ret.Get(0).(func(string, []byte) error); ok { | |
| 480 | - | r0 = returnFunc(name, data) | |
| 617 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { | |
| 618 | + | r0 = returnFunc(context1) | |
| 481 | 619 | } else { | |
| 482 | 620 | r0 = ret.Error(0) | |
| 483 | 621 | } | |
| 484 | 622 | return r0 | |
| 485 | 623 | } | |
| 486 | 624 | ||
| 487 | - | // MockSnapshotStore_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write' | |
| 488 | - | type MockSnapshotStore_Write_Call struct { | |
| 625 | + | // MockSnapshotRepository_Maintain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Maintain' | |
| 626 | + | type MockSnapshotRepository_Maintain_Call struct { | |
| 489 | 627 | *mock.Call | |
| 490 | 628 | } | |
| 491 | 629 | ||
| 492 | - | // Write is a helper method to define mock.On call | |
| 493 | - | // - name string | |
| 494 | - | // - data []byte | |
| 495 | - | func (_e *MockSnapshotStore_Expecter) Write(name any, data any) *MockSnapshotStore_Write_Call { | |
| 496 | - | return &MockSnapshotStore_Write_Call{Call: _e.mock.On("Write", name, data)} | |
| 630 | + | // Maintain is a helper method to define mock.On call | |
| 631 | + | // - context1 context.Context | |
| 632 | + | func (_e *MockSnapshotRepository_Expecter) Maintain(context1 any) *MockSnapshotRepository_Maintain_Call { | |
| 633 | + | return &MockSnapshotRepository_Maintain_Call{Call: _e.mock.On("Maintain", context1)} | |
| 497 | 634 | } | |
| 498 | 635 | ||
| 499 | - | func (_c *MockSnapshotStore_Write_Call) Run(run func(name string, data []byte)) *MockSnapshotStore_Write_Call { | |
| 636 | + | func (_c *MockSnapshotRepository_Maintain_Call) Run(run func(context1 context.Context)) *MockSnapshotRepository_Maintain_Call { | |
| 500 | 637 | _c.Call.Run(func(args mock.Arguments) { | |
| 501 | - | var arg0 string | |
| 638 | + | var arg0 context.Context | |
| 502 | 639 | if args[0] != nil { | |
| 503 | - | arg0 = args[0].(string) | |
| 640 | + | arg0 = args[0].(context.Context) | |
| 504 | 641 | } | |
| 505 | - | var arg1 []byte | |
| 642 | + | run( | |
| 643 | + | arg0, | |
| 644 | + | ) | |
| 645 | + | }) | |
| 646 | + | return _c | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | func (_c *MockSnapshotRepository_Maintain_Call) Return(err error) *MockSnapshotRepository_Maintain_Call { | |
| 650 | + | _c.Call.Return(err) | |
| 651 | + | return _c | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | func (_c *MockSnapshotRepository_Maintain_Call) RunAndReturn(run func(context1 context.Context) error) *MockSnapshotRepository_Maintain_Call { | |
| 655 | + | _c.Call.Return(run) | |
| 656 | + | return _c | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | // Publish provides a mock function for the type MockSnapshotRepository | |
| 660 | + | func (_mock *MockSnapshotRepository) Publish(context1 context.Context, snapshotPublication ports.SnapshotPublication) error { | |
| 661 | + | ret := _mock.Called(context1, snapshotPublication) | |
| 662 | + | ||
| 663 | + | if len(ret) == 0 { | |
| 664 | + | panic("no return value specified for Publish") | |
| 665 | + | } | |
| 666 | + | ||
| 667 | + | var r0 error | |
| 668 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.SnapshotPublication) error); ok { | |
| 669 | + | r0 = returnFunc(context1, snapshotPublication) | |
| 670 | + | } else { | |
| 671 | + | r0 = ret.Error(0) | |
| 672 | + | } | |
| 673 | + | return r0 | |
| 674 | + | } | |
| 675 | + | ||
| 676 | + | // MockSnapshotRepository_Publish_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Publish' | |
| 677 | + | type MockSnapshotRepository_Publish_Call struct { | |
| 678 | + | *mock.Call | |
| 679 | + | } | |
| 680 | + | ||
| 681 | + | // Publish is a helper method to define mock.On call | |
| 682 | + | // - context1 context.Context | |
| 683 | + | // - snapshotPublication ports.SnapshotPublication | |
| 684 | + | func (_e *MockSnapshotRepository_Expecter) Publish(context1 any, snapshotPublication any) *MockSnapshotRepository_Publish_Call { | |
| 685 | + | return &MockSnapshotRepository_Publish_Call{Call: _e.mock.On("Publish", context1, snapshotPublication)} | |
| 686 | + | } | |
| 687 | + | ||
| 688 | + | func (_c *MockSnapshotRepository_Publish_Call) Run(run func(context1 context.Context, snapshotPublication ports.SnapshotPublication)) *MockSnapshotRepository_Publish_Call { | |
| 689 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 690 | + | var arg0 context.Context | |
| 691 | + | if args[0] != nil { | |
| 692 | + | arg0 = args[0].(context.Context) | |
| 693 | + | } | |
| 694 | + | var arg1 ports.SnapshotPublication | |
| 506 | 695 | if args[1] != nil { | |
| 507 | - | arg1 = args[1].([]byte) | |
| 696 | + | arg1 = args[1].(ports.SnapshotPublication) | |
| 508 | 697 | } | |
| 509 | 698 | run( | |
| 510 | 699 | arg0, | |
| @@ -514,12 +703,272 @@ func (_c *MockSnapshotStore_Write_Call) Run(run func(name string, data []byte)) | |||
| 514 | 703 | return _c | |
| 515 | 704 | } | |
| 516 | 705 | ||
| 517 | - | func (_c *MockSnapshotStore_Write_Call) Return(err error) *MockSnapshotStore_Write_Call { | |
| 706 | + | func (_c *MockSnapshotRepository_Publish_Call) Return(err error) *MockSnapshotRepository_Publish_Call { | |
| 518 | 707 | _c.Call.Return(err) | |
| 519 | 708 | return _c | |
| 520 | 709 | } | |
| 521 | 710 | ||
| 522 | - | func (_c *MockSnapshotStore_Write_Call) RunAndReturn(run func(name string, data []byte) error) *MockSnapshotStore_Write_Call { | |
| 711 | + | func (_c *MockSnapshotRepository_Publish_Call) RunAndReturn(run func(context1 context.Context, snapshotPublication ports.SnapshotPublication) error) *MockSnapshotRepository_Publish_Call { | |
| 712 | + | _c.Call.Return(run) | |
| 713 | + | return _c | |
| 714 | + | } | |
| 715 | + | ||
| 716 | + | // Tombstone provides a mock function for the type MockSnapshotRepository | |
| 717 | + | func (_mock *MockSnapshotRepository) Tombstone(context1 context.Context, s string) error { | |
| 718 | + | ret := _mock.Called(context1, s) | |
| 719 | + | ||
| 720 | + | if len(ret) == 0 { | |
| 721 | + | panic("no return value specified for Tombstone") | |
| 722 | + | } | |
| 723 | + | ||
| 724 | + | var r0 error | |
| 725 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 726 | + | r0 = returnFunc(context1, s) | |
| 727 | + | } else { | |
| 728 | + | r0 = ret.Error(0) | |
| 729 | + | } | |
| 730 | + | return r0 | |
| 731 | + | } | |
| 732 | + | ||
| 733 | + | // MockSnapshotRepository_Tombstone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Tombstone' | |
| 734 | + | type MockSnapshotRepository_Tombstone_Call struct { | |
| 735 | + | *mock.Call | |
| 736 | + | } | |
| 737 | + | ||
| 738 | + | // Tombstone is a helper method to define mock.On call | |
| 739 | + | // - context1 context.Context | |
| 740 | + | // - s string | |
| 741 | + | func (_e *MockSnapshotRepository_Expecter) Tombstone(context1 any, s any) *MockSnapshotRepository_Tombstone_Call { | |
| 742 | + | return &MockSnapshotRepository_Tombstone_Call{Call: _e.mock.On("Tombstone", context1, s)} | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | func (_c *MockSnapshotRepository_Tombstone_Call) Run(run func(context1 context.Context, s string)) *MockSnapshotRepository_Tombstone_Call { | |
| 746 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 747 | + | var arg0 context.Context | |
| 748 | + | if args[0] != nil { | |
| 749 | + | arg0 = args[0].(context.Context) | |
| 750 | + | } | |
| 751 | + | var arg1 string | |
| 752 | + | if args[1] != nil { | |
| 753 | + | arg1 = args[1].(string) | |
| 754 | + | } | |
| 755 | + | run( | |
| 756 | + | arg0, | |
| 757 | + | arg1, | |
| 758 | + | ) | |
| 759 | + | }) | |
| 760 | + | return _c | |
| 761 | + | } | |
| 762 | + | ||
| 763 | + | func (_c *MockSnapshotRepository_Tombstone_Call) Return(err error) *MockSnapshotRepository_Tombstone_Call { | |
| 764 | + | _c.Call.Return(err) | |
| 765 | + | return _c | |
| 766 | + | } | |
| 767 | + | ||
| 768 | + | func (_c *MockSnapshotRepository_Tombstone_Call) RunAndReturn(run func(context1 context.Context, s string) error) *MockSnapshotRepository_Tombstone_Call { | |
| 769 | + | _c.Call.Return(run) | |
| 770 | + | return _c | |
| 771 | + | } | |
| 772 | + | ||
| 773 | + | // NewMockLegacySnapshotSource creates a new instance of MockLegacySnapshotSource. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 774 | + | // The first argument is typically a *testing.T value. | |
| 775 | + | func NewMockLegacySnapshotSource(t interface { | |
| 776 | + | mock.TestingT | |
| 777 | + | Cleanup(func()) | |
| 778 | + | }) *MockLegacySnapshotSource { | |
| 779 | + | mock := &MockLegacySnapshotSource{} | |
| 780 | + | mock.Mock.Test(t) | |
| 781 | + | ||
| 782 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 783 | + | ||
| 784 | + | return mock | |
| 785 | + | } | |
| 786 | + | ||
| 787 | + | // MockLegacySnapshotSource is an autogenerated mock type for the LegacySnapshotSource type | |
| 788 | + | type MockLegacySnapshotSource struct { | |
| 789 | + | mock.Mock | |
| 790 | + | } | |
| 791 | + | ||
| 792 | + | type MockLegacySnapshotSource_Expecter struct { | |
| 793 | + | mock *mock.Mock | |
| 794 | + | } | |
| 795 | + | ||
| 796 | + | func (_m *MockLegacySnapshotSource) EXPECT() *MockLegacySnapshotSource_Expecter { | |
| 797 | + | return &MockLegacySnapshotSource_Expecter{mock: &_m.Mock} | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | // DeleteLegacy provides a mock function for the type MockLegacySnapshotSource | |
| 801 | + | func (_mock *MockLegacySnapshotSource) DeleteLegacy(context1 context.Context, s string) error { | |
| 802 | + | ret := _mock.Called(context1, s) | |
| 803 | + | ||
| 804 | + | if len(ret) == 0 { | |
| 805 | + | panic("no return value specified for DeleteLegacy") | |
| 806 | + | } | |
| 807 | + | ||
| 808 | + | var r0 error | |
| 809 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 810 | + | r0 = returnFunc(context1, s) | |
| 811 | + | } else { | |
| 812 | + | r0 = ret.Error(0) | |
| 813 | + | } | |
| 814 | + | return r0 | |
| 815 | + | } | |
| 816 | + | ||
| 817 | + | // MockLegacySnapshotSource_DeleteLegacy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteLegacy' | |
| 818 | + | type MockLegacySnapshotSource_DeleteLegacy_Call struct { | |
| 819 | + | *mock.Call | |
| 820 | + | } | |
| 821 | + | ||
| 822 | + | // DeleteLegacy is a helper method to define mock.On call | |
| 823 | + | // - context1 context.Context | |
| 824 | + | // - s string | |
| 825 | + | func (_e *MockLegacySnapshotSource_Expecter) DeleteLegacy(context1 any, s any) *MockLegacySnapshotSource_DeleteLegacy_Call { | |
| 826 | + | return &MockLegacySnapshotSource_DeleteLegacy_Call{Call: _e.mock.On("DeleteLegacy", context1, s)} | |
| 827 | + | } | |
| 828 | + | ||
| 829 | + | func (_c *MockLegacySnapshotSource_DeleteLegacy_Call) Run(run func(context1 context.Context, s string)) *MockLegacySnapshotSource_DeleteLegacy_Call { | |
| 830 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 831 | + | var arg0 context.Context | |
| 832 | + | if args[0] != nil { | |
| 833 | + | arg0 = args[0].(context.Context) | |
| 834 | + | } | |
| 835 | + | var arg1 string | |
| 836 | + | if args[1] != nil { | |
| 837 | + | arg1 = args[1].(string) | |
| 838 | + | } | |
| 839 | + | run( | |
| 840 | + | arg0, | |
| 841 | + | arg1, | |
| 842 | + | ) | |
| 843 | + | }) | |
| 844 | + | return _c | |
| 845 | + | } | |
| 846 | + | ||
| 847 | + | func (_c *MockLegacySnapshotSource_DeleteLegacy_Call) Return(err error) *MockLegacySnapshotSource_DeleteLegacy_Call { | |
| 848 | + | _c.Call.Return(err) | |
| 849 | + | return _c | |
| 850 | + | } | |
| 851 | + | ||
| 852 | + | func (_c *MockLegacySnapshotSource_DeleteLegacy_Call) RunAndReturn(run func(context1 context.Context, s string) error) *MockLegacySnapshotSource_DeleteLegacy_Call { | |
| 853 | + | _c.Call.Return(run) | |
| 854 | + | return _c | |
| 855 | + | } | |
| 856 | + | ||
| 857 | + | // DeleteVerifiedLegacy provides a mock function for the type MockLegacySnapshotSource | |
| 858 | + | func (_mock *MockLegacySnapshotSource) DeleteVerifiedLegacy(context1 context.Context, legacySnapshot ports.LegacySnapshot) error { | |
| 859 | + | ret := _mock.Called(context1, legacySnapshot) | |
| 860 | + | ||
| 861 | + | if len(ret) == 0 { | |
| 862 | + | panic("no return value specified for DeleteVerifiedLegacy") | |
| 863 | + | } | |
| 864 | + | ||
| 865 | + | var r0 error | |
| 866 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.LegacySnapshot) error); ok { | |
| 867 | + | r0 = returnFunc(context1, legacySnapshot) | |
| 868 | + | } else { | |
| 869 | + | r0 = ret.Error(0) | |
| 870 | + | } | |
| 871 | + | return r0 | |
| 872 | + | } | |
| 873 | + | ||
| 874 | + | // MockLegacySnapshotSource_DeleteVerifiedLegacy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteVerifiedLegacy' | |
| 875 | + | type MockLegacySnapshotSource_DeleteVerifiedLegacy_Call struct { | |
| 876 | + | *mock.Call | |
| 877 | + | } | |
| 878 | + | ||
| 879 | + | // DeleteVerifiedLegacy is a helper method to define mock.On call | |
| 880 | + | // - context1 context.Context | |
| 881 | + | // - legacySnapshot ports.LegacySnapshot | |
| 882 | + | func (_e *MockLegacySnapshotSource_Expecter) DeleteVerifiedLegacy(context1 any, legacySnapshot any) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call { | |
| 883 | + | return &MockLegacySnapshotSource_DeleteVerifiedLegacy_Call{Call: _e.mock.On("DeleteVerifiedLegacy", context1, legacySnapshot)} | |
| 884 | + | } | |
| 885 | + | ||
| 886 | + | func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) Run(run func(context1 context.Context, legacySnapshot ports.LegacySnapshot)) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call { | |
| 887 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 888 | + | var arg0 context.Context | |
| 889 | + | if args[0] != nil { | |
| 890 | + | arg0 = args[0].(context.Context) | |
| 891 | + | } | |
| 892 | + | var arg1 ports.LegacySnapshot | |
| 893 | + | if args[1] != nil { | |
| 894 | + | arg1 = args[1].(ports.LegacySnapshot) | |
| 895 | + | } | |
| 896 | + | run( | |
| 897 | + | arg0, | |
| 898 | + | arg1, | |
| 899 | + | ) | |
| 900 | + | }) | |
| 901 | + | return _c | |
| 902 | + | } | |
| 903 | + | ||
| 904 | + | func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) Return(err error) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call { | |
| 905 | + | _c.Call.Return(err) | |
| 906 | + | return _c | |
| 907 | + | } | |
| 908 | + | ||
| 909 | + | func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) RunAndReturn(run func(context1 context.Context, legacySnapshot ports.LegacySnapshot) error) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call { | |
| 910 | + | _c.Call.Return(run) | |
| 911 | + | return _c | |
| 912 | + | } | |
| 913 | + | ||
| 914 | + | // LoadLegacy provides a mock function for the type MockLegacySnapshotSource | |
| 915 | + | func (_mock *MockLegacySnapshotSource) LoadLegacy(context1 context.Context) ([]ports.LegacySnapshot, error) { | |
| 916 | + | ret := _mock.Called(context1) | |
| 917 | + | ||
| 918 | + | if len(ret) == 0 { | |
| 919 | + | panic("no return value specified for LoadLegacy") | |
| 920 | + | } | |
| 921 | + | ||
| 922 | + | var r0 []ports.LegacySnapshot | |
| 923 | + | var r1 error | |
| 924 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]ports.LegacySnapshot, error)); ok { | |
| 925 | + | return returnFunc(context1) | |
| 926 | + | } | |
| 927 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []ports.LegacySnapshot); ok { | |
| 928 | + | r0 = returnFunc(context1) | |
| 929 | + | } else { | |
| 930 | + | if ret.Get(0) != nil { | |
| 931 | + | r0 = ret.Get(0).([]ports.LegacySnapshot) | |
| 932 | + | } | |
| 933 | + | } | |
| 934 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 935 | + | r1 = returnFunc(context1) | |
| 936 | + | } else { | |
| 937 | + | r1 = ret.Error(1) | |
| 938 | + | } | |
| 939 | + | return r0, r1 | |
| 940 | + | } | |
| 941 | + | ||
| 942 | + | // MockLegacySnapshotSource_LoadLegacy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadLegacy' | |
| 943 | + | type MockLegacySnapshotSource_LoadLegacy_Call struct { | |
| 944 | + | *mock.Call | |
| 945 | + | } | |
| 946 | + | ||
| 947 | + | // LoadLegacy is a helper method to define mock.On call | |
| 948 | + | // - context1 context.Context | |
| 949 | + | func (_e *MockLegacySnapshotSource_Expecter) LoadLegacy(context1 any) *MockLegacySnapshotSource_LoadLegacy_Call { | |
| 950 | + | return &MockLegacySnapshotSource_LoadLegacy_Call{Call: _e.mock.On("LoadLegacy", context1)} | |
| 951 | + | } | |
| 952 | + | ||
| 953 | + | func (_c *MockLegacySnapshotSource_LoadLegacy_Call) Run(run func(context1 context.Context)) *MockLegacySnapshotSource_LoadLegacy_Call { | |
| 954 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 955 | + | var arg0 context.Context | |
| 956 | + | if args[0] != nil { | |
| 957 | + | arg0 = args[0].(context.Context) | |
| 958 | + | } | |
| 959 | + | run( | |
| 960 | + | arg0, | |
| 961 | + | ) | |
| 962 | + | }) | |
| 963 | + | return _c | |
| 964 | + | } | |
| 965 | + | ||
| 966 | + | func (_c *MockLegacySnapshotSource_LoadLegacy_Call) Return(legacySnapshots []ports.LegacySnapshot, err error) *MockLegacySnapshotSource_LoadLegacy_Call { | |
| 967 | + | _c.Call.Return(legacySnapshots, err) | |
| 968 | + | return _c | |
| 969 | + | } | |
| 970 | + | ||
| 971 | + | func (_c *MockLegacySnapshotSource_LoadLegacy_Call) RunAndReturn(run func(context1 context.Context) ([]ports.LegacySnapshot, error)) *MockLegacySnapshotSource_LoadLegacy_Call { | |
| 523 | 972 | _c.Call.Return(run) | |
| 524 | 973 | return _c | |
| 525 | 974 | } | |
D internal/ports/snapshot_test.go +0 -21
| @@ -1,21 +0,0 @@ | |||
| 1 | - | package ports | |
| 2 | - | ||
| 3 | - | import "testing" | |
| 4 | - | ||
| 5 | - | type snapshotStoreContract struct{} | |
| 6 | - | ||
| 7 | - | func (snapshotStoreContract) Write(name string, data []byte) error { return nil } | |
| 8 | - | func (snapshotStoreContract) Load() ([]SnapshotBlob, error) { return nil, nil } | |
| 9 | - | func (snapshotStoreContract) Delete(name string) error { return nil } | |
| 10 | - | ||
| 11 | - | func TestSnapshotStoreContract(t *testing.T) { | |
| 12 | - | var _ SnapshotStore = snapshotStoreContract{} | |
| 13 | - | ||
| 14 | - | blob := SnapshotBlob{Name: "named-session", Data: []byte("snapshot-bytes")} | |
| 15 | - | if blob.Name != "named-session" { | |
| 16 | - | t.Fatalf("SnapshotBlob.Name = %q, want %q", blob.Name, "named-session") | |
| 17 | - | } | |
| 18 | - | if string(blob.Data) != "snapshot-bytes" { | |
| 19 | - | t.Fatalf("SnapshotBlob.Data = %q, want %q", string(blob.Data), "snapshot-bytes") | |
| 20 | - | } | |
| 21 | - | } | |
M internal/ports/store.go +60 -7
| @@ -1,5 +1,10 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/sha256" | |
| 6 | + | ) | |
| 7 | + | ||
| 3 | 8 | // Store is a small byte-key/value persistence port. | |
| 4 | 9 | // | |
| 5 | 10 | // Implementations may buffer writes; Sync is the durability barrier. | |
| @@ -17,15 +22,63 @@ type Store interface { | |||
| 17 | 22 | Close() error | |
| 18 | 23 | } | |
| 19 | 24 | ||
| 20 | - | // SnapshotBlob is a durable named session snapshot payload. | |
| 21 | - | type SnapshotBlob struct { | |
| 25 | + | // SnapshotDigest is the SHA-256 content address of a complete snapshot object. | |
| 26 | + | type SnapshotDigest [sha256.Size]byte | |
| 27 | + | ||
| 28 | + | type SnapshotObject struct { | |
| 29 | + | Digest SnapshotDigest | |
| 30 | + | Data []byte | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // SnapshotPublication atomically publishes a complete VEVM manifest and any | |
| 34 | + | // newly reachable content-addressed VEVO objects for one named generation. | |
| 35 | + | type SnapshotPublication struct { | |
| 36 | + | Name string | |
| 37 | + | Generation uint64 | |
| 38 | + | Manifest []byte | |
| 39 | + | Objects []SnapshotObject | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // SnapshotGeneration is the caller-owned material needed to restore one | |
| 43 | + | // named generation. Fallback indicates that the repository selected an older | |
| 44 | + | // valid generation after the requested/current generation was unavailable. | |
| 45 | + | type SnapshotGeneration struct { | |
| 46 | + | Name string | |
| 47 | + | Generation uint64 | |
| 48 | + | Manifest []byte | |
| 49 | + | Objects map[SnapshotDigest][]byte | |
| 50 | + | Fallback bool | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // SnapshotRepository persists content-addressed incremental snapshot | |
| 54 | + | // generations. All returned bytes are owned by the caller. | |
| 55 | + | type SnapshotRepository interface { | |
| 56 | + | Publish(context.Context, SnapshotPublication) error | |
| 57 | + | List(context.Context) ([]string, error) | |
| 58 | + | Load(context.Context, string) (SnapshotGeneration, error) | |
| 59 | + | Delete(context.Context, string) error | |
| 60 | + | // Tombstone fences a named-session purge before either incremental or | |
| 61 | + | // legacy data is deleted. It must remain durable until DeleteTombstone. | |
| 62 | + | Tombstone(context.Context, string) error | |
| 63 | + | DeleteTombstone(context.Context, string) error | |
| 64 | + | Maintain(context.Context) error | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // LegacySnapshot is the pre-incremental named-session blob retained only for | |
| 68 | + | // one-way migration. Data is caller-owned. | |
| 69 | + | type LegacySnapshot struct { | |
| 22 | 70 | Name string | |
| 23 | 71 | Data []byte | |
| 24 | 72 | } | |
| 25 | 73 | ||
| 26 | - | // SnapshotStore persists encoded session snapshots by name. | |
| 27 | - | type SnapshotStore interface { | |
| 28 | - | Write(name string, data []byte) error | |
| 29 | - | Load() ([]SnapshotBlob, error) | |
| 30 | - | Delete(name string) error | |
| 74 | + | // LegacySnapshotSource exposes the v3 bridge separately from the new write | |
| 75 | + | // contract. | |
| 76 | + | type LegacySnapshotSource interface { | |
| 77 | + | LoadLegacy(context.Context) ([]LegacySnapshot, error) | |
| 78 | + | // DeleteVerifiedLegacy removes precisely the legacy blob that was verified | |
| 79 | + | // after import. Implementations must persist its identity before unlinking. | |
| 80 | + | DeleteVerifiedLegacy(context.Context, LegacySnapshot) error | |
| 81 | + | // DeleteLegacy is reserved for explicit session purges, which intentionally | |
| 82 | + | // delete by name rather than as part of an import receipt. | |
| 83 | + | DeleteLegacy(context.Context, string) error | |
| 31 | 84 | } | |
M internal/usecase/copy/mode.go +22 -2
| @@ -2,6 +2,7 @@ package copy | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/base64" | |
| 5 | + | "math" | |
| 5 | 6 | "slices" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "unicode" | |
| @@ -27,12 +28,31 @@ func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot { | |||
| 27 | 28 | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | |
| 28 | 29 | } | |
| 29 | 30 | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | |
| 30 | - | history := vt.NewHistory(vt.HistoryConfig{MaxRows: len(rows), ChunkRows: 256}) | |
| 31 | + | history := vt.NewHistory(vt.HistoryConfig{ | |
| 32 | + | MaxRows: len(rows), | |
| 33 | + | MaxCells: snapshotRowsCellBudget(rows), | |
| 34 | + | ChunkRows: 256, | |
| 35 | + | }) | |
| 31 | 36 | for _, row := range rows { | |
| 32 | - | history.Append(row) | |
| 37 | + | if err := history.Append(row); err != nil { | |
| 38 | + | panic("copy snapshot: configured history rejected supplied row") | |
| 39 | + | } | |
| 33 | 40 | } | |
| 34 | 41 | return Snapshot{history: history.SealAndView(), Width: width, Height: height} | |
| 35 | 42 | } | |
| 43 | + | ||
| 44 | + | // snapshotRowsCellBudget returns a capacity that retains every supplied row. | |
| 45 | + | // Saturation is safe because a larger representable capacity does not exist. | |
| 46 | + | func snapshotRowsCellBudget(rows [][]renderer.Cell) int { | |
| 47 | + | cells := 0 | |
| 48 | + | for _, row := range rows { | |
| 49 | + | if len(row) > math.MaxInt-cells { | |
| 50 | + | return math.MaxInt | |
| 51 | + | } | |
| 52 | + | cells += len(row) | |
| 53 | + | } | |
| 54 | + | return cells | |
| 55 | + | } | |
| 36 | 56 | func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height } | |
| 37 | 57 | func (s Snapshot) Row(i int) []renderer.Cell { | |
| 38 | 58 | if i < 0 { | |
M internal/usecase/copy/mode_test.go +17 -2
| @@ -27,6 +27,21 @@ func modeFor(lines []string, height int) *Mode { | |||
| 27 | 27 | return NewMode(NewDocument(NewSnapshotFromRows(rows, 16, height), domain.DefaultWordSeparators)) | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | + | func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) { | |
| 31 | + | wide := make([]renderer.Cell, 321) | |
| 32 | + | for i := range wide { | |
| 33 | + | wide[i] = renderer.Cell{Rune: rune('a' + i%26)} | |
| 34 | + | } | |
| 35 | + | rows := [][]renderer.Cell{wide, row("tail")} | |
| 36 | + | ||
| 37 | + | snapshot := NewSnapshotFromRows(rows, len(wide), len(rows)) | |
| 38 | + | ||
| 39 | + | require.Equal(t, len(rows), snapshot.history.Len()) | |
| 40 | + | for i, want := range rows { | |
| 41 | + | require.Equal(t, want, snapshot.history.Row(i)) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 30 | 45 | func TestCopyModeKeyboardSelection(t *testing.T) { | |
| 31 | 46 | m := modeFor([]string{"alpha", "xy", "bravo", "charlie"}, 2) | |
| 32 | 47 | require.Equal(t, Pos{Row: 3}, m.Cursor()) | |
| @@ -175,9 +190,9 @@ func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) { | |||
| 175 | 190 | const rows = 10_000 | |
| 176 | 191 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256}) | |
| 177 | 192 | for range rows { | |
| 178 | - | history.Append(row("unmatched")) | |
| 193 | + | require.NoError(t, history.Append(row("unmatched"))) | |
| 179 | 194 | } | |
| 180 | - | history.Append(row("target")) | |
| 195 | + | require.NoError(t, history.Append(row("target"))) | |
| 181 | 196 | snapshot := NewSnapshot(history, renderer.NewFrame(16, 1)) | |
| 182 | 197 | view := history.SealAndView() | |
| 183 | 198 | require.Same(t, view.Chunk(0), snapshot.history.Chunk(0)) | |
M internal/usecase/daemon/copymode_test.go +9 -9
| @@ -301,7 +301,7 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) { | |||
| 301 | 301 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 302 | 302 | pane := sess.tabs[0].focusedPane() | |
| 303 | 303 | installTestHistory(pane, vt.HistoryConfig{MaxRows: 4}) | |
| 304 | - | pane.history.Append(testRow("old alpha")) | |
| 304 | + | require.NoError(t, pane.history.Append(testRow("old alpha"))) | |
| 305 | 305 | copy(pane.screen.Frame.Row(0), testRow("live alpha")) | |
| 306 | 306 | ||
| 307 | 307 | d.enterCopyMode(sess, ac) | |
| @@ -330,8 +330,8 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |||
| 330 | 330 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 331 | 331 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 332 | 332 | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) | |
| 333 | - | sess.tabs[0].focusedPane().history.Append(testRow("old1 ")) | |
| 334 | - | sess.tabs[0].focusedPane().history.Append(testRow("old2 ")) | |
| 333 | + | require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old1 "))) | |
| 334 | + | require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old2 "))) | |
| 335 | 335 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 336 | 336 | ||
| 337 | 337 | d.enterCopyMode(sess, ac) | |
| @@ -505,8 +505,8 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||
| 505 | 505 | p, _ := newBlockingPTY(t) | |
| 506 | 506 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 507 | 507 | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) | |
| 508 | - | sess.tabs[0].focusedPane().history.Append(testRow("old1 ")) | |
| 509 | - | sess.tabs[0].focusedPane().history.Append(testRow("old2 ")) | |
| 508 | + | require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old1 "))) | |
| 509 | + | require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old2 "))) | |
| 510 | 510 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 511 | 511 | ||
| 512 | 512 | d.enterCopyMode(sess, ac) | |
| @@ -739,11 +739,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) { | |||
| 739 | 739 | var want *pane | |
| 740 | 740 | if tc.useFloating { | |
| 741 | 741 | want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | |
| 742 | - | want.history.Append(testRow("flt-old")) | |
| 742 | + | require.NoError(t, want.history.Append(testRow("flt-old"))) | |
| 743 | 743 | want.screen.Write([]byte("flt-live")) | |
| 744 | 744 | } else { | |
| 745 | 745 | want = main | |
| 746 | - | want.history.Append(testRow("one-old")) | |
| 746 | + | require.NoError(t, want.history.Append(testRow("one-old"))) | |
| 747 | 747 | want.screen.Write([]byte("one-live")) | |
| 748 | 748 | } | |
| 749 | 749 | ||
| @@ -780,7 +780,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||
| 780 | 780 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |
| 781 | 781 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | |
| 782 | 782 | for i := range 30 { | |
| 783 | - | fp.history.Append(testRow(fmt.Sprintf("old-%02d", i))) | |
| 783 | + | require.NoError(t, fp.history.Append(testRow(fmt.Sprintf("old-%02d", i)))) | |
| 784 | 784 | } | |
| 785 | 785 | fp.screen.Write([]byte("live")) | |
| 786 | 786 | fp.mu.Lock() | |
| @@ -806,7 +806,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) { | |||
| 806 | 806 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |
| 807 | 807 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | |
| 808 | 808 | for i := range 5 { | |
| 809 | - | fp.history.Append(testRow(fmt.Sprintf("old-%d", i))) | |
| 809 | + | require.NoError(t, fp.history.Append(testRow(fmt.Sprintf("old-%d", i)))) | |
| 810 | 810 | } | |
| 811 | 811 | fp.screen.Write([]byte("live")) | |
| 812 | 812 | ||
M internal/usecase/daemon/daemon.go +156 -96
| @@ -7,7 +7,6 @@ import ( | |||
| 7 | 7 | "log/slog" | |
| 8 | 8 | "math" | |
| 9 | 9 | "os" | |
| 10 | - | "slices" | |
| 11 | 10 | "sort" | |
| 12 | 11 | "sync" | |
| 13 | 12 | "sync/atomic" | |
| @@ -17,7 +16,6 @@ import ( | |||
| 17 | 16 | "github.com/bnema/vev/internal/persist" | |
| 18 | 17 | "github.com/bnema/vev/internal/ports" | |
| 19 | 18 | "github.com/bnema/vev/internal/usecase/keys" | |
| 20 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 21 | 19 | ) | |
| 22 | 20 | ||
| 23 | 21 | // Scheduler debounce bounds. Idle updates use the minimum for low latency; | |
| @@ -32,8 +30,13 @@ const debounceInterval = minDebounceInterval | |||
| 32 | 30 | // ptyReadBufSize is the PTY reader's read buffer. | |
| 33 | 31 | const ptyReadBufSize = 32 * 1024 | |
| 34 | 32 | ||
| 35 | - | // defaultScrollbackRows is the per-tab retained history size. | |
| 36 | - | const defaultScrollbackRows = 10_000 | |
| 33 | + | // New and restored panes are bounded by both history rows and cells. The | |
| 34 | + | // cell budget keeps a very wide terminal from retaining disproportionately | |
| 35 | + | // more scrollback than the normal 160-column case. | |
| 36 | + | const ( | |
| 37 | + | defaultScrollbackRows = 10_000 | |
| 38 | + | defaultScrollbackCells = 12_000 * 160 | |
| 39 | + | ) | |
| 37 | 40 | ||
| 38 | 41 | // detachNotifyTimeout bounds the best-effort Detached notification on the | |
| 39 | 42 | // detach/kill/shutdown paths: if a wedged client (full kernel send buffer) | |
| @@ -101,22 +104,32 @@ type Daemon struct { | |||
| 101 | 104 | beforeCopyModeRevalidate func() | |
| 102 | 105 | // beforeCopyMouseMap is a deterministic seam after an immutable copy-input | |
| 103 | 106 | // snapshot is captured and before its mapped position is applied. | |
| 104 | - | beforeCopyMouseMap func() | |
| 105 | - | ptys ports.PTYFactory | |
| 106 | - | clock ports.Clock | |
| 107 | - | log *slog.Logger | |
| 108 | - | runtimeObserver ports.RuntimeObserver | |
| 109 | - | baseEnv []string | |
| 110 | - | shell string | |
| 111 | - | shellArgs []string | |
| 112 | - | shellOverride bool | |
| 113 | - | persist *persist.Persister | |
| 114 | - | persistEnabled bool | |
| 115 | - | snaps ports.SnapshotStore | |
| 116 | - | snapsEnabled bool | |
| 117 | - | noticeStore ports.NoticeStore | |
| 118 | - | snapshotMarshal func(snapcodec.Session) ([]byte, error) | |
| 119 | - | snapshotJobs chan *snapshotCapture | |
| 107 | + | beforeCopyMouseMap func() | |
| 108 | + | ptys ports.PTYFactory | |
| 109 | + | clock ports.Clock | |
| 110 | + | log *slog.Logger | |
| 111 | + | runtimeObserver ports.RuntimeObserver | |
| 112 | + | baseEnv []string | |
| 113 | + | shell string | |
| 114 | + | shellArgs []string | |
| 115 | + | shellOverride bool | |
| 116 | + | persist *persist.Persister | |
| 117 | + | persistEnabled bool | |
| 118 | + | // snapshotRepository is the sole checkpoint contract. legacySnapshots is | |
| 119 | + | // read-only migration input and is never used for new writes. | |
| 120 | + | snapshotRepository ports.SnapshotRepository | |
| 121 | + | legacySnapshots ports.LegacySnapshotSource | |
| 122 | + | snapsEnabled bool | |
| 123 | + | // legacyImportPending holds verified legacy blobs whose source deletion | |
| 124 | + | // failed, so a later import retries only deletion rather than publication. | |
| 125 | + | legacyImportMu sync.Mutex | |
| 126 | + | legacyImportPending map[string][]byte | |
| 127 | + | noticeStore ports.NoticeStore | |
| 128 | + | snapshotJobs chan *snapshotCapture | |
| 129 | + | // snapshotWake wakes the repository scheduler when a session becomes dirty | |
| 130 | + | // or an attempt completes. It is never closed and producers only send | |
| 131 | + | // non-blockingly. | |
| 132 | + | snapshotWake chan struct{} | |
| 120 | 133 | snapshotWorkerMu sync.Mutex | |
| 121 | 134 | snapshotWorkerID uint64 | |
| 122 | 135 | snapshotWorkerCtx context.Context | |
| @@ -127,28 +140,33 @@ type Daemon struct { | |||
| 127 | 140 | // snapshotFinalJobs coalesces terminal captures by session when the bounded | |
| 128 | 141 | // regular queue is full. It retains at most snapshotFinalQueueCapacity named | |
| 129 | 142 | // sessions, each with only its newest terminal state while the worker blocks. | |
| 130 | - | snapshotFinalJobs map[*session]*snapshotCapture | |
| 131 | - | snapshotFinalOrder []*session | |
| 132 | - | snapshotWorkerClosing bool | |
| 133 | - | snapshotWorkerInFlight *snapshotCapture | |
| 134 | - | restoreDone chan struct{} | |
| 135 | - | restoreOnce sync.Once | |
| 136 | - | procCwd func(int) (string, error) | |
| 137 | - | procComm func(int) (string, error) | |
| 138 | - | procArgv func(int) ([]string, error) | |
| 139 | - | procGroupArgv func(int, int) ([]string, error) | |
| 140 | - | dirOrHome func(string) string | |
| 141 | - | bindings atomic.Pointer[keys.Bindings] | |
| 142 | - | codeOverrides atomic.Pointer[map[string]string] | |
| 143 | - | restoreProcessAllowlist atomic.Pointer[map[string]struct{}] | |
| 144 | - | floatingConfig atomic.Pointer[domain.FloatingConfig] | |
| 145 | - | copyConfig atomic.Pointer[domain.CopyConfig] | |
| 146 | - | paletteConfig atomic.Pointer[domain.PaletteConfig] | |
| 147 | - | tabsConfig atomic.Pointer[domain.TabsConfig] | |
| 148 | - | themeConfig atomic.Pointer[themeConfigSnapshot] | |
| 149 | - | barScripts *barScriptState | |
| 150 | - | notices *noticeCenter | |
| 151 | - | resumeParkGrace time.Duration | |
| 143 | + | snapshotFinalJobs map[*session]*snapshotCapture | |
| 144 | + | snapshotFinalOrder []*session | |
| 145 | + | snapshotWorkerClosing bool | |
| 146 | + | snapshotWorkerInFlight *snapshotCapture | |
| 147 | + | // snapshotNoticeMu guards the active global persistence failure signature. | |
| 148 | + | // It is separate from snapshotWorkerMu so notice routing cannot block a | |
| 149 | + | // producer or a repository worker. | |
| 150 | + | snapshotNoticeMu sync.Mutex | |
| 151 | + | snapshotActiveFailureSignature string | |
| 152 | + | restoreDone chan struct{} | |
| 153 | + | restoreOnce sync.Once | |
| 154 | + | procCwd func(int) (string, error) | |
| 155 | + | procComm func(int) (string, error) | |
| 156 | + | procArgv func(int) ([]string, error) | |
| 157 | + | procGroupArgv func(int, int) ([]string, error) | |
| 158 | + | dirOrHome func(string) string | |
| 159 | + | bindings atomic.Pointer[keys.Bindings] | |
| 160 | + | codeOverrides atomic.Pointer[map[string]string] | |
| 161 | + | restoreProcessAllowlist atomic.Pointer[map[string]struct{}] | |
| 162 | + | floatingConfig atomic.Pointer[domain.FloatingConfig] | |
| 163 | + | copyConfig atomic.Pointer[domain.CopyConfig] | |
| 164 | + | paletteConfig atomic.Pointer[domain.PaletteConfig] | |
| 165 | + | tabsConfig atomic.Pointer[domain.TabsConfig] | |
| 166 | + | themeConfig atomic.Pointer[themeConfigSnapshot] | |
| 167 | + | barScripts *barScriptState | |
| 168 | + | notices *noticeCenter | |
| 169 | + | resumeParkGrace time.Duration | |
| 152 | 170 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | |
| 153 | 171 | // (see clipboard.go); empty means use os.TempDir(). | |
| 154 | 172 | tempDir string | |
| @@ -194,15 +212,6 @@ type stoppedSession struct { | |||
| 194 | 212 | purging bool | |
| 195 | 213 | } | |
| 196 | 214 | ||
| 197 | - | func (s stoppedSession) same(other stoppedSession) bool { | |
| 198 | - | return s.name == other.name && | |
| 199 | - | s.cwd == other.cwd && | |
| 200 | - | s.createdAt == other.createdAt && | |
| 201 | - | s.lastUsedSeq == other.lastUsedSeq && | |
| 202 | - | s.purging == other.purging && | |
| 203 | - | slices.Equal(s.tabNames, other.tabNames) | |
| 204 | - | } | |
| 205 | - | ||
| 206 | 215 | type Option func(*Daemon) | |
| 207 | 216 | ||
| 208 | 217 | // WithRuntimeObserver accepts only a composition-root serialized observer. | |
| @@ -232,12 +241,13 @@ func WithStore(store ports.Store) Option { | |||
| 232 | 241 | } | |
| 233 | 242 | } | |
| 234 | 243 | ||
| 235 | - | // WithSnapshotStore enables durable named session snapshots. A nil store keeps | |
| 236 | - | // the daemon in no-op snapshot mode. | |
| 237 | - | func WithSnapshotStore(store ports.SnapshotStore) Option { | |
| 244 | + | // WithSnapshotRepository enables content-addressed incremental snapshots. The | |
| 245 | + | // legacy source is read-only migration input and is never used for new writes. | |
| 246 | + | func WithSnapshotRepository(repository ports.SnapshotRepository, legacy ports.LegacySnapshotSource) Option { | |
| 238 | 247 | return func(d *Daemon) { | |
| 239 | - | d.snaps = store | |
| 240 | - | d.snapsEnabled = store != nil | |
| 248 | + | d.snapshotRepository = repository | |
| 249 | + | d.legacySnapshots = legacy | |
| 250 | + | d.snapsEnabled = repository != nil | |
| 241 | 251 | } | |
| 242 | 252 | } | |
| 243 | 253 | ||
| @@ -343,23 +353,24 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 343 | 353 | shell = "/bin/sh" | |
| 344 | 354 | } | |
| 345 | 355 | d := &Daemon{ | |
| 346 | - | sessions: make(map[domain.SessionID]*session), | |
| 347 | - | stopped: make(map[string]stoppedSession), | |
| 348 | - | parked: make(map[uint64]*parkedAttachment), | |
| 349 | - | ptys: ptys, | |
| 350 | - | clock: clock, | |
| 351 | - | log: log, | |
| 352 | - | baseEnv: os.Environ(), | |
| 353 | - | shell: shell, | |
| 354 | - | persist: persist.New(nil), | |
| 355 | - | dirOrHome: dirOrHome, | |
| 356 | - | done: make(chan struct{}), | |
| 357 | - | restoreDone: make(chan struct{}), | |
| 358 | - | animWake: make(chan struct{}, 1), | |
| 359 | - | snapshotMarshal: snapcodec.Marshal, | |
| 360 | - | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), | |
| 361 | - | notices: newNoticeCenter(), | |
| 362 | - | resumeParkGrace: defaultResumeParkGrace, | |
| 356 | + | sessions: make(map[domain.SessionID]*session), | |
| 357 | + | stopped: make(map[string]stoppedSession), | |
| 358 | + | parked: make(map[uint64]*parkedAttachment), | |
| 359 | + | ptys: ptys, | |
| 360 | + | clock: clock, | |
| 361 | + | log: log, | |
| 362 | + | baseEnv: os.Environ(), | |
| 363 | + | shell: shell, | |
| 364 | + | persist: persist.New(nil), | |
| 365 | + | dirOrHome: dirOrHome, | |
| 366 | + | done: make(chan struct{}), | |
| 367 | + | restoreDone: make(chan struct{}), | |
| 368 | + | animWake: make(chan struct{}, 1), | |
| 369 | + | legacyImportPending: make(map[string][]byte), | |
| 370 | + | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), | |
| 371 | + | snapshotWake: make(chan struct{}, 1), | |
| 372 | + | notices: newNoticeCenter(), | |
| 373 | + | resumeParkGrace: defaultResumeParkGrace, | |
| 363 | 374 | barScripts: &barScriptState{ | |
| 364 | 375 | cfg: barConfigFromDomain(domain.Defaults().Bar), | |
| 365 | 376 | outputs: make(map[domain.SessionID]barScriptOutputs), | |
| @@ -443,7 +454,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||
| 443 | 454 | if d.snapsEnabled { | |
| 444 | 455 | d.startSnapshotEncodeWorker() | |
| 445 | 456 | d.sessWg.Go(func() { | |
| 446 | - | d.snapshotSaver(d.serveCtx) | |
| 457 | + | d.snapshotRepositorySaver(d.serveCtx) | |
| 447 | 458 | }) | |
| 448 | 459 | d.sessWg.Go(func() { | |
| 449 | 460 | d.restoreSnapshots(d.serveCtx) | |
| @@ -486,7 +497,15 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||
| 486 | 497 | // force-close. Finally drain the conn handlers, run one defensive sweep in | |
| 487 | 498 | // case any ordering ever leaves a session behind, and join the session | |
| 488 | 499 | // goroutines (readers unblock via pty.Close, coordinators via ctx cancel). | |
| 489 | - | d.shutdownAll(ports.ReasonServerShutdown) | |
| 500 | + | // One deadline owns every terminal checkpoint wait and the final worker | |
| 501 | + | // join. A repository is allowed to ignore cancellation, so spending a fresh | |
| 502 | + | // interval at either stage would make shutdown exceed its documented bound. | |
| 503 | + | var snapshotDeadline *snapshotShutdownDeadline | |
| 504 | + | if d.snapsEnabled { | |
| 505 | + | snapshotDeadline = newSnapshotShutdownDeadline(d.clock) | |
| 506 | + | defer snapshotDeadline.stop() | |
| 507 | + | } | |
| 508 | + | d.shutdownAllWithSnapshotDeadline(ports.ReasonServerShutdown, snapshotDeadline) | |
| 490 | 509 | d.waitNotifies() | |
| 491 | 510 | d.hardCancel() | |
| 492 | 511 | d.serveCancel() | |
| @@ -495,7 +514,10 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||
| 495 | 514 | // registry snapshot has been removed. | |
| 496 | 515 | d.connWg.Wait() | |
| 497 | 516 | d.attachmentCleanupWg.Wait() | |
| 498 | - | d.stopSnapshotEncodeWorker() | |
| 517 | + | // Forced terminal checkpoints run before shutdown stops producers or the | |
| 518 | + | // worker. The same deadline bounds the worker's final drain, including a | |
| 519 | + | // repository call that ignores cancellation. | |
| 520 | + | d.stopSnapshotEncodeWorkerWithDeadline(snapshotDeadline) | |
| 499 | 521 | d.shutdownAll(ports.ReasonServerShutdown) | |
| 500 | 522 | d.sessWg.Wait() | |
| 501 | 523 | d.waitNotifies() | |
| @@ -509,7 +531,11 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||
| 509 | 531 | // closing under the same lock as the snapshot guarantees no session can be | |
| 510 | 532 | // inserted after the snapshot: route rejects once closing is set, and both run | |
| 511 | 533 | // under d.mu. killSession (which relocks) runs after the lock is released. | |
| 512 | - | func (d *Daemon) shutdownAll(reason uint8) { | |
| 534 | + | func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) { | |
| 535 | + | return d.shutdownAllWithSnapshotDeadline(reason, nil) | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) { | |
| 513 | 539 | d.mu.Lock() | |
| 514 | 540 | d.closing = true | |
| 515 | 541 | for token, parked := range d.parked { | |
| @@ -521,13 +547,57 @@ func (d *Daemon) shutdownAll(reason uint8) { | |||
| 521 | 547 | d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot)) | |
| 522 | 548 | if empty { | |
| 523 | 549 | d.doneOnce.Do(func() { close(d.done) }) | |
| 524 | - | return | |
| 550 | + | return false | |
| 525 | 551 | } | |
| 526 | 552 | for _, s := range snapshot { | |
| 527 | - | if err := d.killSession(s, reason, false); err != nil { | |
| 553 | + | if err := d.killSessionWithSnapshotDeadline(s, reason, false, deadline); err != nil { | |
| 554 | + | checkpointIncomplete = true | |
| 528 | 555 | d.log.Error("closing session with unpersisted terminal state", "err", err) | |
| 529 | 556 | } | |
| 530 | 557 | } | |
| 558 | + | return checkpointIncomplete | |
| 559 | + | } | |
| 560 | + | ||
| 561 | + | // snapshotShutdownDeadline is a single-use shutdown budget shared by forced | |
| 562 | + | // checkpoints and the final snapshot worker join. Its done channel is never | |
| 563 | + | // closed by callers, so a detached worker can safely retain session state until | |
| 564 | + | // an uncooperative repository call returns. | |
| 565 | + | type snapshotShutdownDeadline struct { | |
| 566 | + | done chan struct{} | |
| 567 | + | stopCh chan struct{} | |
| 568 | + | finished chan struct{} | |
| 569 | + | stopOnce sync.Once | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | func newSnapshotShutdownDeadline(clock ports.Clock) *snapshotShutdownDeadline { | |
| 573 | + | deadline := &snapshotShutdownDeadline{done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{})} | |
| 574 | + | timer := clock.NewTimer(snapshotFinalFlushTimeout) | |
| 575 | + | go func() { | |
| 576 | + | defer close(deadline.finished) | |
| 577 | + | select { | |
| 578 | + | case <-timer.C(): | |
| 579 | + | close(deadline.done) | |
| 580 | + | case <-deadline.stopCh: | |
| 581 | + | timer.Stop() | |
| 582 | + | } | |
| 583 | + | }() | |
| 584 | + | return deadline | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | func (d *snapshotShutdownDeadline) stop() { | |
| 588 | + | if d != nil { | |
| 589 | + | d.stopOnce.Do(func() { | |
| 590 | + | close(d.stopCh) | |
| 591 | + | <-d.finished | |
| 592 | + | }) | |
| 593 | + | } | |
| 594 | + | } | |
| 595 | + | ||
| 596 | + | func (d *snapshotShutdownDeadline) Done() <-chan struct{} { | |
| 597 | + | if d == nil { | |
| 598 | + | return nil | |
| 599 | + | } | |
| 600 | + | return d.done | |
| 531 | 601 | } | |
| 532 | 602 | ||
| 533 | 603 | func (d *Daemon) sessionsSnapshotLocked() []*session { | |
| @@ -620,25 +690,15 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |||
| 620 | 690 | d.mu.Lock() | |
| 621 | 691 | target := d.findByNameLocked(k.Name) | |
| 622 | 692 | if target == nil { | |
| 623 | - | if stopped, ok := d.stopped[k.Name]; ok { | |
| 693 | + | if _, ok := d.stopped[k.Name]; ok { | |
| 624 | 694 | d.mu.Unlock() | |
| 625 | - | if d.snapsEnabled && d.snaps != nil { | |
| 626 | - | if err := d.snaps.Delete(k.Name); err != nil { | |
| 627 | - | d.log.Warn("deleting stopped session snapshot failed", "err", err, "session", k.Name) | |
| 628 | - | _ = tr.Send(frameError(ports.ErrInternal, "deleting stopped session snapshot failed")) | |
| 629 | - | return | |
| 630 | - | } | |
| 695 | + | // Stopped sessions follow the same tombstone-backed transaction as | |
| 696 | + | // live and offline purges. In particular, a retained legacy source | |
| 697 | + | // must never outlive metadata after an interrupted control kill. | |
| 698 | + | if err := d.retryStoppedPurge(k.Name); err != nil { | |
| 699 | + | d.log.Warn("deleting stopped session failed", "err", err, "session", k.Name) | |
| 700 | + | _ = tr.Send(frameError(ports.ErrInternal, "deleting stopped session failed")) | |
| 631 | 701 | } | |
| 632 | - | if err := d.persist.Delete(k.Name); err != nil { | |
| 633 | - | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", k.Name) | |
| 634 | - | _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed")) | |
| 635 | - | return | |
| 636 | - | } | |
| 637 | - | d.mu.Lock() | |
| 638 | - | if cur, ok := d.stopped[k.Name]; ok && cur.same(stopped) { | |
| 639 | - | delete(d.stopped, k.Name) | |
| 640 | - | } | |
| 641 | - | d.mu.Unlock() | |
| 642 | 702 | return | |
| 643 | 703 | } | |
| 644 | 704 | } | |
M internal/usecase/daemon/input_test.go +2 -2
| @@ -1237,7 +1237,7 @@ func TestMouseNormalScreenDragUsesPressOwnedDocumentAfterOutputEviction(t *testi | |||
| 1237 | 1237 | ||
| 1238 | 1238 | // Output evicts five lines after Press; the Document remains immutable. | |
| 1239 | 1239 | for range 5 { | |
| 1240 | - | sess.tabs[0].focusedPane().history.Append(testRow("evicted")) | |
| 1240 | + | require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("evicted"))) | |
| 1241 | 1241 | } | |
| 1242 | 1242 | ||
| 1243 | 1243 | // Motion maps the next endpoint through the press-owned Document. | |
| @@ -1764,7 +1764,7 @@ func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T) | |||
| 1764 | 1764 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1765 | 1765 | pane := sess.activeTab().focusedPane() | |
| 1766 | 1766 | for range 8 { | |
| 1767 | - | pane.history.Append(testRow("history")) | |
| 1767 | + | require.NoError(t, pane.history.Append(testRow("history"))) | |
| 1768 | 1768 | } | |
| 1769 | 1769 | d.enterCopyMode(sess, ac) | |
| 1770 | 1770 | mustOutputData(t, sends) | |
M internal/usecase/daemon/notice_persist_test.go +0 -90
| @@ -1,17 +1,13 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "sync/atomic" | |
| 6 | 4 | "testing" | |
| 7 | 5 | ||
| 8 | - | "github.com/stretchr/testify/mock" | |
| 9 | 6 | "github.com/stretchr/testify/require" | |
| 10 | 7 | ||
| 11 | 8 | "github.com/bnema/vev/internal/domain" | |
| 12 | 9 | "github.com/bnema/vev/internal/ports" | |
| 13 | 10 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 14 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 15 | 11 | ) | |
| 16 | 12 | ||
| 17 | 13 | // TestKillSessionServerShutdownSaturatedAppendsNotice proves that when the | |
| @@ -19,92 +15,6 @@ import ( | |||
| 19 | 15 | // no way to retain the session's terminal state and no attached client to | |
| 20 | 16 | // toast, because the daemon is dying — the failure is handed to the | |
| 21 | 17 | // NoticeStore instead of being silently lost. | |
| 22 | - | func TestKillSessionServerShutdownSaturatedAppendsNotice(t *testing.T) { | |
| 23 | - | store := discardSnapshotStore{} | |
| 24 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 25 | - | WithSnapshotStore(store)(d) | |
| 26 | - | startSnapshotEncodeWorker(t, d) | |
| 27 | - | ||
| 28 | - | entered := make(chan struct{}) | |
| 29 | - | release := make(chan struct{}) | |
| 30 | - | defer close(release) | |
| 31 | - | var calls atomic.Int32 | |
| 32 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 33 | - | if calls.Add(1) == 1 { | |
| 34 | - | close(entered) | |
| 35 | - | <-release | |
| 36 | - | } | |
| 37 | - | return snapcodec.Marshal(s) | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | active := newSnapshotTestSession(t, "active", false, "/work") | |
| 41 | - | queued := newSnapshotTestSession(t, "queued", false, "/work") | |
| 42 | - | markSnapshotDirty(active) | |
| 43 | - | markSnapshotDirty(queued) | |
| 44 | - | require.True(t, d.scheduleSnapshot(active)) | |
| 45 | - | <-entered // the worker is now parked inside marshal for active | |
| 46 | - | require.True(t, d.scheduleSnapshot(queued), "one capture waits in the bounded queue") | |
| 47 | - | for i := range snapshotFinalQueueCapacity { | |
| 48 | - | s := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work") | |
| 49 | - | markSnapshotDirty(s) | |
| 50 | - | require.True(t, d.scheduleFinalSnapshot(s), "fill the terminal-retention fallback to capacity") | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | target := newSnapshotTestSession(t, "target", false, "/work") | |
| 54 | - | targetPTY, ok := target.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 55 | - | require.True(t, ok, "target pane PTY must be a MockPTY") | |
| 56 | - | targetPTY.EXPECT().Close().Return(nil).Maybe() | |
| 57 | - | d.mu.Lock() | |
| 58 | - | d.sessions[target.id] = target | |
| 59 | - | d.mu.Unlock() | |
| 60 | - | markSnapshotDirty(target) | |
| 61 | - | ||
| 62 | - | notices := portsmocks.NewMockNoticeStore(t) | |
| 63 | - | var appended domain.Notification | |
| 64 | - | notices.EXPECT().Append(mock.Anything).RunAndReturn(func(n domain.Notification) error { | |
| 65 | - | appended = n | |
| 66 | - | return nil | |
| 67 | - | }).Once() | |
| 68 | - | d.noticeStore = notices | |
| 69 | - | ||
| 70 | - | err := d.killSession(target, ports.ReasonServerShutdown, false) | |
| 71 | - | require.ErrorContains(t, err, "snapshot worker unavailable or saturated") | |
| 72 | - | ||
| 73 | - | d.mu.Lock() | |
| 74 | - | _, stillPresent := d.sessions[target.id] | |
| 75 | - | d.mu.Unlock() | |
| 76 | - | require.False(t, stillPresent, "the session must be torn down even though its state could not be saved") | |
| 77 | - | ||
| 78 | - | require.Equal(t, domain.NoticeSnapshotWrite, appended.Code) | |
| 79 | - | require.Equal(t, domain.NoticeError, appended.Severity) | |
| 80 | - | require.Equal(t, "session target shut down without saving terminal state", appended.Message) | |
| 81 | - | require.NotEmpty(t, appended.Details) | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | // TestKillSessionServerShutdownNotSaturatedSkipsAppend proves the notice store | |
| 85 | - | // is left untouched when the final snapshot is retained normally. | |
| 86 | - | func TestKillSessionServerShutdownNotSaturatedSkipsAppend(t *testing.T) { | |
| 87 | - | store := discardSnapshotStore{} | |
| 88 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 89 | - | WithSnapshotStore(store)(d) | |
| 90 | - | startSnapshotEncodeWorker(t, d) | |
| 91 | - | ||
| 92 | - | notices := portsmocks.NewMockNoticeStore(t) // no .EXPECT(): Append must not be called | |
| 93 | - | d.noticeStore = notices | |
| 94 | - | ||
| 95 | - | target := newSnapshotTestSession(t, "target", false, "/work") | |
| 96 | - | targetPTY, ok := target.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 97 | - | require.True(t, ok, "target pane PTY must be a MockPTY") | |
| 98 | - | targetPTY.EXPECT().Close().Return(nil).Maybe() | |
| 99 | - | d.mu.Lock() | |
| 100 | - | d.sessions[target.id] = target | |
| 101 | - | d.mu.Unlock() | |
| 102 | - | markSnapshotDirty(target) | |
| 103 | - | ||
| 104 | - | err := d.killSession(target, ports.ReasonServerShutdown, false) | |
| 105 | - | require.NoError(t, err) | |
| 106 | - | } | |
| 107 | - | ||
| 108 | 18 | // TestStartupClaimRecordsAndToastsPendingNotices proves notices persisted by | |
| 109 | 19 | // a previous daemon are recorded to history and toasted to the first client | |
| 110 | 20 | // that attaches after this daemon starts before their claim is acknowledged. | |
M internal/usecase/daemon/notify.go +25 -0
| @@ -61,6 +61,31 @@ func (nc *noticeCenter) record(n domain.Notification) domain.Notification { | |||
| 61 | 61 | return n | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | + | // recordSnapshotFailure coalesces a repeated stable snapshot failure in the | |
| 65 | + | // history. Details contains the signature rather than raw error text, so this | |
| 66 | + | // comparison cannot be affected by volatile paths or snapshot content. | |
| 67 | + | func (nc *noticeCenter) recordSnapshotFailure(n domain.Notification) (domain.Notification, bool) { | |
| 68 | + | if n.Count == 0 { | |
| 69 | + | n.Count = 1 | |
| 70 | + | } | |
| 71 | + | nc.mu.Lock() | |
| 72 | + | defer nc.mu.Unlock() | |
| 73 | + | for i := len(nc.ring) - 1; i >= 0; i-- { | |
| 74 | + | previous := &nc.ring[i] | |
| 75 | + | if previous.Code != domain.NoticeSnapshotWrite || previous.SessionID != n.SessionID || previous.Details != n.Details { | |
| 76 | + | continue | |
| 77 | + | } | |
| 78 | + | previous.Count += n.Count | |
| 79 | + | previous.Time = n.Time | |
| 80 | + | return *previous, true | |
| 81 | + | } | |
| 82 | + | nc.ring = append(nc.ring, n) | |
| 83 | + | if len(nc.ring) > noticeHistoryCap { | |
| 84 | + | nc.ring = nc.ring[len(nc.ring)-noticeHistoryCap:] | |
| 85 | + | } | |
| 86 | + | return n, false | |
| 87 | + | } | |
| 88 | + | ||
| 64 | 89 | func (nc *noticeCenter) history() []domain.Notification { | |
| 65 | 90 | nc.mu.Lock() | |
| 66 | 91 | defer nc.mu.Unlock() | |
M internal/usecase/daemon/overlay_runtime_test.go +2 -2
| @@ -199,7 +199,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) { | |||
| 199 | 199 | ac := &attachedClient{} | |
| 200 | 200 | ac.initOverlays() | |
| 201 | 201 | history := newTestHistory(1) | |
| 202 | - | history.Append(testRow("before")) | |
| 202 | + | require.NoError(t, history.Append(testRow("before"))) | |
| 203 | 203 | document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1)), domain.DefaultWordSeparators) | |
| 204 | 204 | ||
| 205 | 205 | ac.overlays.copyMu.Lock() | |
| @@ -207,7 +207,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) { | |||
| 207 | 207 | ac.overlays.copyDocument = document | |
| 208 | 208 | ac.overlays.copyMu.Unlock() | |
| 209 | 209 | ||
| 210 | - | history.Append(testRow("after")) | |
| 210 | + | require.NoError(t, history.Append(testRow("after"))) | |
| 211 | 211 | renderSnap := ac.overlays.SnapshotForRender() | |
| 212 | 212 | defer renderSnap.Unlock() | |
| 213 | 213 | require.NotNil(t, renderSnap.copyMode) | |
M internal/usecase/daemon/pane.go +1 -1
| @@ -47,7 +47,7 @@ func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane { | |||
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | 49 | func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane { | |
| 50 | - | screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows}) | |
| 50 | + | screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells}) | |
| 51 | 51 | return &pane{ | |
| 52 | 52 | id: id, | |
| 53 | 53 | stableID: stableID, | |
M internal/usecase/daemon/performance_benchmark_test.go +387 -77
| @@ -1,9 +1,11 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | "io" | |
| 6 | 7 | "sync" | |
| 8 | + | "sync/atomic" | |
| 7 | 9 | "testing" | |
| 8 | 10 | "time" | |
| 9 | 11 | ||
| @@ -13,18 +15,138 @@ import ( | |||
| 13 | 15 | "github.com/bnema/vev/internal/ports" | |
| 14 | 16 | "github.com/bnema/vev/internal/usecase/layout" | |
| 15 | 17 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 18 | + | "github.com/bnema/vev/pkg/renderer" | |
| 16 | 19 | ) | |
| 17 | 20 | ||
| 18 | - | func TestCountingSnapshotStoreWriteCountsAndRetainsOpaquePayload(t *testing.T) { | |
| 19 | - | store := &countingSnapshotStore{} | |
| 21 | + | func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) { | |
| 22 | + | repository := &countingSnapshotRepository{} | |
| 20 | 23 | payload := []byte{0xff, 0x00, 0xfe} | |
| 24 | + | object := []byte{0x01, 0x02} | |
| 21 | 25 | ||
| 22 | - | require.NoError(t, store.Write("session", payload)) | |
| 23 | - | require.Equal(t, countingSnapshotMetrics{writes: 1, bytes: uint64(len(payload))}, store.metrics()) | |
| 24 | - | require.Equal(t, payload, store.lastPayload()) | |
| 26 | + | require.NoError(t, repository.Publish(context.Background(), ports.SnapshotPublication{Name: "session", Manifest: payload, Objects: []ports.SnapshotObject{{Data: object}}})) | |
| 27 | + | require.Equal(t, countingSnapshotMetrics{writes: 1, manifestBytes: uint64(len(payload)), objectBytes: uint64(len(object)), suppliedObjectBytes: uint64(len(object)), headBytes: snapshotHeadBytes}, repository.metrics()) | |
| 28 | + | require.Equal(t, payload, repository.lastPayload()) | |
| 25 | 29 | ||
| 26 | 30 | payload[0] = 0x01 | |
| 27 | - | require.Equal(t, payload, store.lastPayload(), "the counting sink must retain the payload slice header, not copy it") | |
| 31 | + | require.Equal(t, payload, repository.lastPayload(), "the counting sink must retain the payload slice header, not copy it") | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestCountingSnapshotRepositoryDoesNotRewriteKnownObjects(t *testing.T) { | |
| 35 | + | repository := &countingSnapshotRepository{} | |
| 36 | + | object, err := snapcodec.MarshalObject(snapcodec.HistoryChunk, []byte{0x01}) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | publication := ports.SnapshotPublication{Name: "session", Manifest: []byte("manifest"), Objects: []ports.SnapshotObject{object}} | |
| 39 | + | ||
| 40 | + | require.NoError(t, repository.Publish(context.Background(), publication)) | |
| 41 | + | repository.reset() | |
| 42 | + | require.NoError(t, repository.Publish(context.Background(), publication)) | |
| 43 | + | ||
| 44 | + | require.Zero(t, repository.metrics().objectBytes, "filesystem repositories retain immutable content-addressed objects") | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestIncrementalSnapshotMetricsWriteNoUnchangedHistoryBlobsAndBoundCache(t *testing.T) { | |
| 48 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000}) | |
| 49 | + | ||
| 50 | + | publishBenchmarkSnapshot(t, fixture, 1) | |
| 51 | + | fixture.snapshots.reset() | |
| 52 | + | publishBenchmarkSnapshot(t, fixture, 2) | |
| 53 | + | ||
| 54 | + | metrics := fixture.snapshots.metrics() | |
| 55 | + | require.Zero(t, metrics.historyBlobBytes) | |
| 56 | + | require.Zero(t, metrics.suppliedHistoryBytes) | |
| 57 | + | require.Zero(t, metrics.objectBytes) | |
| 58 | + | require.Positive(t, metrics.manifestBytes) | |
| 59 | + | require.Equal(t, uint64(snapshotHeadBytes), metrics.headBytes) | |
| 60 | + | require.LessOrEqual(t, benchmarkSnapshotCacheBytes(fixture.sess), snapshotChunkCacheLimit) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testing.T) { | |
| 64 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000}) | |
| 65 | + | ||
| 66 | + | markSnapshotDirty(fixture.sess) | |
| 67 | + | require.True(t, fixture.d.scheduleSnapshot(fixture.sess)) | |
| 68 | + | awaitSnapshotClean(t, fixture.sess) | |
| 69 | + | ||
| 70 | + | var copies atomic.Uint64 | |
| 71 | + | fixture.sess.snapshotMu.Lock() | |
| 72 | + | fixture.sess.snapshotChunkCache.copyObject = func(object ports.SnapshotObject) ports.SnapshotObject { | |
| 73 | + | copies.Add(1) | |
| 74 | + | return copySnapshotObject(object) | |
| 75 | + | } | |
| 76 | + | fixture.sess.snapshotMu.Unlock() | |
| 77 | + | fixture.snapshots.reset() | |
| 78 | + | fixture.activePane.mu.Lock() | |
| 79 | + | fixture.activePane.screen.Write([]byte("\x1b[1;1Hchanged")) | |
| 80 | + | frame := fixture.activePane.screen.PrimaryVisibleFrame() | |
| 81 | + | fixture.activePane.mu.Unlock() | |
| 82 | + | for i, r := range "changed" { | |
| 83 | + | require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i) | |
| 84 | + | } | |
| 85 | + | markSnapshotDirty(fixture.sess) | |
| 86 | + | require.True(t, fixture.d.scheduleFinalSnapshot(fixture.sess)) | |
| 87 | + | awaitSnapshotClean(t, fixture.sess) | |
| 88 | + | ||
| 89 | + | metrics := fixture.snapshots.metrics() | |
| 90 | + | require.Equal(t, uint64(1), metrics.writes) | |
| 91 | + | require.Zero(t, metrics.suppliedHistoryBytes, "retained sealed history must not be resupplied") | |
| 92 | + | require.Zero(t, copies.Load(), "retained sealed history must not be deep-copied") | |
| 93 | + | require.Positive(t, metrics.suppliedObjectBytes, "changed tail and visible state must still be supplied") | |
| 94 | + | requireCompleteSnapshotManifest(t, fixture.snapshots, fixture.sess.name) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func requireCompleteSnapshotManifest(t *testing.T, repository *countingSnapshotRepository, name string) { | |
| 98 | + | t.Helper() | |
| 99 | + | repository.mu.Lock() | |
| 100 | + | manifestBytes := append([]byte(nil), repository.last...) | |
| 101 | + | objects := repository.objects[name] | |
| 102 | + | repository.mu.Unlock() | |
| 103 | + | manifest, err := snapcodec.UnmarshalManifest(manifestBytes) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | for _, tab := range manifest.Tabs { | |
| 106 | + | for _, pane := range tab.Panes { | |
| 107 | + | for _, ref := range append(append([]snapcodec.ObjectRef(nil), pane.Sealed...), pane.Tail, pane.Visible) { | |
| 108 | + | _, ok := objects[ref.Digest] | |
| 109 | + | require.True(t, ok, "manifest reference %x is not retained", ref.Digest) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestIncrementalSnapshotMetricScenarios(t *testing.T) { | |
| 116 | + | for _, tt := range []struct { | |
| 117 | + | name string | |
| 118 | + | historyRows int | |
| 119 | + | mutate func(*performanceFixture, int) | |
| 120 | + | wantHistory bool | |
| 121 | + | }{ | |
| 122 | + | {name: "tail-only", historyRows: 9_999, mutate: mutateBenchmarkTail}, | |
| 123 | + | {name: "new-sealed-chunk", historyRows: 10_000, mutate: mutateBenchmarkSealedChunk, wantHistory: true}, | |
| 124 | + | } { | |
| 125 | + | t.Run(tt.name, func(t *testing.T) { | |
| 126 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: tt.historyRows}) | |
| 127 | + | publishBenchmarkSnapshot(t, fixture, 1) | |
| 128 | + | fixture.snapshots.reset() | |
| 129 | + | tt.mutate(fixture, 0) | |
| 130 | + | publishBenchmarkSnapshot(t, fixture, 2) | |
| 131 | + | ||
| 132 | + | metrics := fixture.snapshots.metrics() | |
| 133 | + | if tt.wantHistory { | |
| 134 | + | require.Positive(t, metrics.historyBlobBytes) | |
| 135 | + | } else { | |
| 136 | + | require.Zero(t, metrics.historyBlobBytes) | |
| 137 | + | } | |
| 138 | + | require.LessOrEqual(t, benchmarkSnapshotCacheBytes(fixture.sess), snapshotChunkCacheLimit) | |
| 139 | + | }) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestPerformanceFixtureCellLimitedHistory(t *testing.T) { | |
| 144 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 293, Rows: 40}, panes: 1, historyRows: 10_000}) | |
| 145 | + | fixture.activePane.mu.Lock() | |
| 146 | + | rows, cells, capCells := fixture.activePane.history.Len(), fixture.activePane.history.Cells(), fixture.activePane.history.CellCap() | |
| 147 | + | fixture.activePane.mu.Unlock() | |
| 148 | + | require.Less(t, rows, 10_000) | |
| 149 | + | require.LessOrEqual(t, cells, capCells) | |
| 28 | 150 | } | |
| 29 | 151 | ||
| 30 | 152 | func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) { | |
| @@ -63,7 +185,8 @@ func TestPerformanceFixtureCounters(t *testing.T) { | |||
| 63 | 185 | fixture.captureSnapshot() | |
| 64 | 186 | metrics = fixture.metrics() | |
| 65 | 187 | require.Equal(t, uint64(1), metrics.snapshotWrites) | |
| 66 | - | require.Positive(t, metrics.snapshotBytes) | |
| 188 | + | require.Positive(t, metrics.snapshotManifestBytes) | |
| 189 | + | require.Positive(t, metrics.snapshotObjectBytes) | |
| 67 | 190 | ||
| 68 | 191 | fixture.enterCopySearch() | |
| 69 | 192 | require.Positive(t, fixture.searchMatches()) | |
| @@ -157,13 +280,13 @@ func TestPerformanceFixtureSnapshotCaptureRetainsSealedHistory(t *testing.T) { | |||
| 157 | 280 | require.True(t, ok) | |
| 158 | 281 | require.Len(t, first.tabs, 1) | |
| 159 | 282 | require.Len(t, first.tabs[0].panes, 2) | |
| 160 | - | require.Equal(t, 10_000, first.tabs[0].panes[0].history.Len()) | |
| 161 | - | require.Equal(t, 40, first.tabs[0].panes[0].history.ChunkCount()) | |
| 283 | + | require.Equal(t, 10_000, first.tabs[0].panes[0].sealed.Len()) | |
| 284 | + | require.Positive(t, first.tabs[0].panes[0].sealed.ChunkCount()) | |
| 162 | 285 | ||
| 163 | 286 | second, ok := fixture.d.captureSnapshotState(fixture.sess, 2) | |
| 164 | 287 | require.True(t, ok) | |
| 165 | 288 | secondPane := capturePaneByID(second, first.tabs[0].panes[0].id) | |
| 166 | - | if secondPane == nil || first.tabs[0].panes[0].history.Chunk(0) != secondPane.history.Chunk(0) { | |
| 289 | + | if secondPane == nil || first.tabs[0].panes[0].sealed.Chunk(0) != secondPane.sealed.Chunk(0) { | |
| 167 | 290 | t.Fatal("unchanged sealed history chunk was not reused across captures") | |
| 168 | 291 | } | |
| 169 | 292 | } | |
| @@ -216,14 +339,20 @@ func BenchmarkDaemonHistorySnapshotCapture(b *testing.B) { | |||
| 216 | 339 | benchmarkDaemonSnapshotCapture(b) | |
| 217 | 340 | } | |
| 218 | 341 | ||
| 219 | - | // BenchmarkDaemonHistorySnapshotEncode measures v3 encoding from an already | |
| 220 | - | // immutable capture, without queueing or persistence. | |
| 221 | - | func BenchmarkDaemonHistorySnapshotEncode(b *testing.B) { | |
| 222 | - | benchmarkDaemonSnapshotEncode(b) | |
| 342 | + | // BenchmarkDaemonIncrementalSnapshotRepository measures the complete | |
| 343 | + | // content-addressed publication path against a filesystem-equivalent byte | |
| 344 | + | // counter. Each scenario reports persisted VEVO, VEVM, and VEVH bytes, plus | |
| 345 | + | // allocation/time and the per-session encoded-history cache high-water mark. | |
| 346 | + | func BenchmarkDaemonIncrementalSnapshotRepository(b *testing.B) { | |
| 347 | + | for _, scenario := range snapshotBenchmarkScenarios { | |
| 348 | + | b.Run(scenario.name, func(b *testing.B) { | |
| 349 | + | benchmarkIncrementalSnapshotRepository(b, scenario) | |
| 350 | + | }) | |
| 351 | + | } | |
| 223 | 352 | } | |
| 224 | 353 | ||
| 225 | 354 | // BenchmarkDaemonHistorySnapshotRestore measures terminal-state restoration | |
| 226 | - | // from a validated v3 pane manifest without spawning PTY or session workers. | |
| 355 | + | // from a validated incremental manifest without spawning PTY or session workers. | |
| 227 | 356 | func BenchmarkDaemonHistorySnapshotRestore(b *testing.B) { | |
| 228 | 357 | benchmarkDaemonSnapshotRestore(b) | |
| 229 | 358 | } | |
| @@ -326,7 +455,7 @@ func benchmarkDaemonSnapshotCapture(b *testing.B) { | |||
| 326 | 455 | b.Fatal("invalid snapshot capture fixture") | |
| 327 | 456 | } | |
| 328 | 457 | firstPane := first.tabs[0].panes[0] | |
| 329 | - | firstChunk := firstPane.history.Chunk(0) | |
| 458 | + | firstChunk := firstPane.sealed.Chunk(0) | |
| 330 | 459 | captures := 0 | |
| 331 | 460 | var last *snapshotCapture | |
| 332 | 461 | b.ReportAllocs() | |
| @@ -340,7 +469,7 @@ func benchmarkDaemonSnapshotCapture(b *testing.B) { | |||
| 340 | 469 | } | |
| 341 | 470 | b.StopTimer() | |
| 342 | 471 | lastPane := capturePaneByID(last, firstPane.id) | |
| 343 | - | if captures != b.N || !validBenchmarkCapture(last) || lastPane == nil || lastPane.history.Chunk(0) != firstChunk { | |
| 472 | + | if captures != b.N || !validBenchmarkCapture(last) || lastPane == nil || lastPane.sealed.Chunk(0) != firstChunk { | |
| 344 | 473 | b.Fatal("snapshot capture did not preserve immutable sealed history") | |
| 345 | 474 | } | |
| 346 | 475 | b.ReportMetric(float64(capturePaneCount(last)), "capturepanes/op") | |
| @@ -348,41 +477,159 @@ func benchmarkDaemonSnapshotCapture(b *testing.B) { | |||
| 348 | 477 | } | |
| 349 | 478 | } | |
| 350 | 479 | ||
| 351 | - | func benchmarkDaemonSnapshotEncode(b *testing.B) { | |
| 352 | - | for _, topology := range daemonSnapshotTopologies { | |
| 353 | - | b.Run(topology.name, func(b *testing.B) { | |
| 354 | - | fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000}) | |
| 355 | - | if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) { | |
| 356 | - | b.Fatal("invalid daemon snapshot encode fixture") | |
| 357 | - | } | |
| 358 | - | capture, ok := fixture.d.captureSnapshotState(fixture.sess, 1) | |
| 359 | - | if !ok || !validBenchmarkCapture(capture) { | |
| 360 | - | b.Fatal("invalid snapshot encode fixture") | |
| 361 | - | } | |
| 362 | - | encoded, err := fixture.d.encodeSnapshotCapture(capture) | |
| 363 | - | if err != nil { | |
| 364 | - | b.Fatal(err) | |
| 365 | - | } | |
| 366 | - | if _, err := snapcodec.Unmarshal(encoded); err != nil { | |
| 367 | - | b.Fatalf("invalid v3 snapshot encode fixture: %v", err) | |
| 368 | - | } | |
| 369 | - | var last []byte | |
| 370 | - | b.ReportAllocs() | |
| 371 | - | b.SetBytes(int64(len(encoded))) | |
| 372 | - | b.ResetTimer() | |
| 373 | - | for range b.N { | |
| 374 | - | last, err = fixture.d.encodeSnapshotCapture(capture) | |
| 375 | - | if err != nil { | |
| 376 | - | b.Fatal(err) | |
| 377 | - | } | |
| 378 | - | } | |
| 379 | - | b.StopTimer() | |
| 380 | - | if _, err := snapcodec.Unmarshal(last); err != nil { | |
| 381 | - | b.Fatalf("snapshot encoder produced invalid v3 data: %v", err) | |
| 382 | - | } | |
| 383 | - | b.ReportMetric(float64(len(last)), "snapshotbytes/op") | |
| 384 | - | }) | |
| 480 | + | type snapshotBenchmarkScenario struct { | |
| 481 | + | name string | |
| 482 | + | size domain.Size | |
| 483 | + | historyRows int | |
| 484 | + | baseline bool | |
| 485 | + | mutate func(*performanceFixture, int) | |
| 486 | + | unchanged bool | |
| 487 | + | cellLimited bool | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{ | |
| 491 | + | {name: "initial-10k-x-120", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000}, | |
| 492 | + | {name: "unchanged", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, unchanged: true}, | |
| 493 | + | {name: "visible-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkVisible}, | |
| 494 | + | // Leave one row below the retention cap so the mutation changes only the | |
| 495 | + | // mutable tail and cannot evict a sealed chunk. | |
| 496 | + | {name: "tail-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 9_999, baseline: true, mutate: mutateBenchmarkTail}, | |
| 497 | + | {name: "new-sealed-chunk", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkSealedChunk}, | |
| 498 | + | {name: "cell-limited-293-columns", size: domain.Size{Cols: 293, Rows: 40}, historyRows: 10_000, cellLimited: true}, | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBenchmarkScenario) { | |
| 502 | + | var ( | |
| 503 | + | metrics countingSnapshotMetrics | |
| 504 | + | peakCacheBytes int | |
| 505 | + | ) | |
| 506 | + | b.ReportAllocs() | |
| 507 | + | b.ResetTimer() | |
| 508 | + | for operation := 0; b.Loop(); operation++ { | |
| 509 | + | // A new fixture is deliberately outside the timed region. It gives every | |
| 510 | + | // iteration the same filesystem state: initial has no generation, | |
| 511 | + | // unchanged/visible/tail/sealed begin after one persisted generation. | |
| 512 | + | // Reusing one fixture would eventually turn a tail into a sealed chunk | |
| 513 | + | // and dilute initial-write bytes over later idempotent publications. | |
| 514 | + | b.StopTimer() | |
| 515 | + | fixture := newPerformanceFixture(b, performanceConfig{size: scenario.size, panes: 1, historyRows: scenario.historyRows}) | |
| 516 | + | validateSnapshotBenchmarkFixture(b, fixture, scenario) | |
| 517 | + | generation := uint64(1) | |
| 518 | + | if scenario.baseline { | |
| 519 | + | publishBenchmarkSnapshot(b, fixture, generation) | |
| 520 | + | generation++ | |
| 521 | + | fixture.snapshots.reset() | |
| 522 | + | } | |
| 523 | + | b.StartTimer() | |
| 524 | + | if scenario.mutate != nil { | |
| 525 | + | scenario.mutate(fixture, operation) | |
| 526 | + | } | |
| 527 | + | publishBenchmarkSnapshot(b, fixture, generation) | |
| 528 | + | b.StopTimer() | |
| 529 | + | ||
| 530 | + | metrics = addCountingSnapshotMetrics(metrics, fixture.snapshots.metrics()) | |
| 531 | + | peakCacheBytes = max(peakCacheBytes, benchmarkSnapshotCacheBytes(fixture.sess)) | |
| 532 | + | // B.Loop requires the timer to be running at the next iteration. | |
| 533 | + | b.StartTimer() | |
| 534 | + | } | |
| 535 | + | b.StopTimer() | |
| 536 | + | if scenario.unchanged && metrics.historyBlobBytes != 0 { | |
| 537 | + | b.Fatalf("unchanged snapshot wrote %d history blob bytes", metrics.historyBlobBytes) | |
| 538 | + | } | |
| 539 | + | if peakCacheBytes > snapshotChunkCacheLimit { | |
| 540 | + | b.Fatalf("snapshot cache peak = %d, limit = %d", peakCacheBytes, snapshotChunkCacheLimit) | |
| 385 | 541 | } | |
| 542 | + | benchmarkReportSnapshotRepositoryMetrics(b, metrics, b.N, peakCacheBytes) | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | func validateSnapshotBenchmarkFixture(b *testing.B, fixture *performanceFixture, scenario snapshotBenchmarkScenario) { | |
| 546 | + | b.Helper() | |
| 547 | + | if !fixture.hasHistoryTopology(1, 1, scenario.historyRows) && !scenario.cellLimited { | |
| 548 | + | b.Fatal("invalid incremental snapshot fixture") | |
| 549 | + | } | |
| 550 | + | if !scenario.cellLimited { | |
| 551 | + | return | |
| 552 | + | } | |
| 553 | + | fixture.activePane.mu.Lock() | |
| 554 | + | rows, cells, capCells := fixture.activePane.history.Len(), fixture.activePane.history.Cells(), fixture.activePane.history.CellCap() | |
| 555 | + | fixture.activePane.mu.Unlock() | |
| 556 | + | if rows >= scenario.historyRows || cells > capCells { | |
| 557 | + | b.Fatalf("293-column fixture did not enforce cell limit: rows=%d cells=%d cap=%d", rows, cells, capCells) | |
| 558 | + | } | |
| 559 | + | } | |
| 560 | + | ||
| 561 | + | func addCountingSnapshotMetrics(a, b countingSnapshotMetrics) countingSnapshotMetrics { | |
| 562 | + | return countingSnapshotMetrics{ | |
| 563 | + | writes: a.writes + b.writes, | |
| 564 | + | objectBytes: a.objectBytes + b.objectBytes, | |
| 565 | + | historyBlobBytes: a.historyBlobBytes + b.historyBlobBytes, | |
| 566 | + | suppliedObjectBytes: a.suppliedObjectBytes + b.suppliedObjectBytes, | |
| 567 | + | suppliedHistoryBytes: a.suppliedHistoryBytes + b.suppliedHistoryBytes, | |
| 568 | + | manifestBytes: a.manifestBytes + b.manifestBytes, | |
| 569 | + | headBytes: a.headBytes + b.headBytes, | |
| 570 | + | } | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | func publishBenchmarkSnapshot(b testing.TB, fixture *performanceFixture, generation uint64) { | |
| 574 | + | b.Helper() | |
| 575 | + | capture, ok := fixture.d.captureSnapshotState(fixture.sess, generation) | |
| 576 | + | if !ok { | |
| 577 | + | b.Fatal("snapshot capture unexpectedly rejected benchmark fixture") | |
| 578 | + | } | |
| 579 | + | publication, err := fixture.d.incrementalPublication(capture) | |
| 580 | + | if err != nil { | |
| 581 | + | b.Fatal(err) | |
| 582 | + | } | |
| 583 | + | if err := fixture.snapshots.Publish(context.Background(), publication); err != nil { | |
| 584 | + | b.Fatal(err) | |
| 585 | + | } | |
| 586 | + | markSnapshotCaptureObjectsPublished(capture) | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | func mutateBenchmarkVisible(fixture *performanceFixture, operation int) { | |
| 590 | + | fixture.activePane.mu.Lock() | |
| 591 | + | fixture.activePane.screen.Write([]byte(fmt.Sprintf("\x1b[1;1Hvisible-%08d", operation))) | |
| 592 | + | fixture.activePane.mu.Unlock() | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | func mutateBenchmarkTail(fixture *performanceFixture, operation int) { | |
| 596 | + | fixture.activePane.mu.Lock() | |
| 597 | + | defer fixture.activePane.mu.Unlock() | |
| 598 | + | row := make([]renderer.Cell, fixture.activePane.screen.Frame.Width) | |
| 599 | + | for column := range row { | |
| 600 | + | row[column] = renderer.Cell{Rune: rune('a' + (operation+column)%26)} | |
| 601 | + | } | |
| 602 | + | if err := fixture.activePane.history.Append(row); err != nil { | |
| 603 | + | fixture.t.Fatal(err) | |
| 604 | + | } | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | func mutateBenchmarkSealedChunk(fixture *performanceFixture, operation int) { | |
| 608 | + | for row := 0; row < 256; row++ { | |
| 609 | + | mutateBenchmarkTail(fixture, operation*256+row) | |
| 610 | + | } | |
| 611 | + | } | |
| 612 | + | ||
| 613 | + | func benchmarkSnapshotCacheBytes(sess *session) int { | |
| 614 | + | sess.snapshotMu.Lock() | |
| 615 | + | defer sess.snapshotMu.Unlock() | |
| 616 | + | if sess.snapshotChunkCache == nil { | |
| 617 | + | return 0 | |
| 618 | + | } | |
| 619 | + | return sess.snapshotChunkCache.used | |
| 620 | + | } | |
| 621 | + | ||
| 622 | + | func benchmarkReportSnapshotRepositoryMetrics(b *testing.B, metrics countingSnapshotMetrics, operations int, peakCacheBytes int) { | |
| 623 | + | b.Helper() | |
| 624 | + | if operations == 0 { | |
| 625 | + | return | |
| 626 | + | } | |
| 627 | + | perOperation := float64(operations) | |
| 628 | + | b.ReportMetric(float64(metrics.objectBytes)/perOperation, "objectbytes/op") | |
| 629 | + | b.ReportMetric(float64(metrics.historyBlobBytes)/perOperation, "historyblobbytes/op") | |
| 630 | + | b.ReportMetric(float64(metrics.manifestBytes)/perOperation, "manifestbytes/op") | |
| 631 | + | b.ReportMetric(float64(metrics.headBytes)/perOperation, "headbytes/op") | |
| 632 | + | b.ReportMetric(float64(peakCacheBytes), "peakcachebytes") | |
| 386 | 633 | } | |
| 387 | 634 | ||
| 388 | 635 | func benchmarkDaemonSnapshotRestore(b *testing.B) { | |
| @@ -394,17 +641,17 @@ func benchmarkDaemonSnapshotRestore(b *testing.B) { | |||
| 394 | 641 | if !ok || !validBenchmarkCapture(capture) { | |
| 395 | 642 | b.Fatal("invalid snapshot restore fixture") | |
| 396 | 643 | } | |
| 397 | - | encoded, err := fixture.d.encodeSnapshotCapture(capture) | |
| 644 | + | publication, err := fixture.d.incrementalPublication(capture) | |
| 398 | 645 | if err != nil { | |
| 399 | 646 | b.Fatal(err) | |
| 400 | 647 | } | |
| 401 | - | snapshot, err := snapcodec.Unmarshal(encoded) | |
| 648 | + | snapshot, err := sessionFromGeneration(snapshotGeneration(publication)) | |
| 402 | 649 | if err != nil || len(snapshot.Tabs) != 1 || len(snapshot.Tabs[0].Panes) == 0 { | |
| 403 | - | b.Fatalf("invalid v3 snapshot restore fixture: %v", err) | |
| 650 | + | b.Fatalf("invalid incremental snapshot restore fixture: %v", err) | |
| 404 | 651 | } | |
| 405 | 652 | paneSnapshot := snapshot.Tabs[0].Panes[0] | |
| 406 | 653 | b.ReportAllocs() | |
| 407 | - | b.SetBytes(int64(len(encoded))) | |
| 654 | + | b.SetBytes(int64(snapshotPublicationBytes(publication))) | |
| 408 | 655 | b.ResetTimer() | |
| 409 | 656 | for range b.N { | |
| 410 | 657 | if err := restorePaneTerminal(fixture.activePane, paneSnapshot); err != nil { | |
| @@ -422,7 +669,27 @@ func benchmarkDaemonSnapshotRestore(b *testing.B) { | |||
| 422 | 669 | } | |
| 423 | 670 | ||
| 424 | 671 | func validBenchmarkCapture(capture *snapshotCapture) bool { | |
| 425 | - | return capture != nil && len(capture.tabs) > 0 && len(capture.tabs[0].panes) > 0 && capture.tabs[0].panes[0].history.Len() == 10_000 && capture.tabs[0].panes[0].history.ChunkCount() > 0 | |
| 672 | + | return capture != nil && len(capture.tabs) > 0 && len(capture.tabs[0].panes) > 0 && capture.tabs[0].panes[0].sealed.Len() == 10_000 && capture.tabs[0].panes[0].sealed.ChunkCount() > 0 | |
| 673 | + | } | |
| 674 | + | ||
| 675 | + | func snapshotGeneration(publication ports.SnapshotPublication) ports.SnapshotGeneration { | |
| 676 | + | objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects)) | |
| 677 | + | for _, object := range publication.Objects { | |
| 678 | + | objects[object.Digest] = object.Data | |
| 679 | + | } | |
| 680 | + | return ports.SnapshotGeneration{Name: publication.Name, Generation: publication.Generation, Manifest: publication.Manifest, Objects: objects} | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | func snapshotObjectBytes(publication ports.SnapshotPublication) uint64 { | |
| 684 | + | var bytes uint64 | |
| 685 | + | for _, object := range publication.Objects { | |
| 686 | + | bytes += uint64(len(object.Data)) | |
| 687 | + | } | |
| 688 | + | return bytes | |
| 689 | + | } | |
| 690 | + | ||
| 691 | + | func snapshotPublicationBytes(publication ports.SnapshotPublication) uint64 { | |
| 692 | + | return uint64(len(publication.Manifest)) + snapshotObjectBytes(publication) | |
| 426 | 693 | } | |
| 427 | 694 | ||
| 428 | 695 | func capturePaneCount(capture *snapshotCapture) int { | |
| @@ -473,7 +740,8 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations | |||
| 473 | 740 | b.ReportMetric(float64(metrics.outputBytes)/perOperation, "outputbytes/op") | |
| 474 | 741 | b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op") | |
| 475 | 742 | b.ReportMetric(float64(metrics.snapshotWrites)/perOperation, "snapshotwrites/op") | |
| 476 | - | b.ReportMetric(float64(metrics.snapshotBytes)/perOperation, "snapshotbytes/op") | |
| 743 | + | b.ReportMetric(float64(metrics.snapshotManifestBytes)/perOperation, "snapshotmanifestbytes/op") | |
| 744 | + | b.ReportMetric(float64(metrics.snapshotObjectBytes)/perOperation, "snapshotobjectbytes/op") | |
| 477 | 745 | b.ReportMetric(float64(metrics.coordinatorInvalidations)/perOperation, "coordinatorinvalidations/op") | |
| 478 | 746 | b.ReportMetric(float64(metrics.coordinatorWakes)/perOperation, "coordinatorwakes/op") | |
| 479 | 747 | b.ReportMetric(float64(metrics.coordinatorCoalesced)/perOperation, "coordinatorcoalesced/op") | |
| @@ -587,7 +855,8 @@ type performanceMetrics struct { | |||
| 587 | 855 | outputBytes uint64 | |
| 588 | 856 | outputPayloadBytes uint64 | |
| 589 | 857 | snapshotWrites uint64 | |
| 590 | - | snapshotBytes uint64 | |
| 858 | + | snapshotManifestBytes uint64 | |
| 859 | + | snapshotObjectBytes uint64 | |
| 591 | 860 | coordinatorInvalidations uint64 | |
| 592 | 861 | coordinatorWakes uint64 | |
| 593 | 862 | coordinatorCoalesced uint64 | |
| @@ -674,7 +943,7 @@ type performanceFixture struct { | |||
| 674 | 943 | sess *session | |
| 675 | 944 | ac *attachedClient | |
| 676 | 945 | output *countingOutputTransport | |
| 677 | - | snaps *countingSnapshotStore | |
| 946 | + | snapshots *countingSnapshotRepository | |
| 678 | 947 | activePane *pane | |
| 679 | 948 | liveWrites [][]byte | |
| 680 | 949 | paints int | |
| @@ -727,13 +996,13 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||
| 727 | 996 | sess: sess, | |
| 728 | 997 | ac: ac, | |
| 729 | 998 | output: output, | |
| 730 | - | snaps: &countingSnapshotStore{}, | |
| 999 | + | snapshots: &countingSnapshotRepository{}, | |
| 731 | 1000 | pty: pty, | |
| 732 | 1001 | clock: clock, | |
| 733 | 1002 | liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")}, | |
| 734 | 1003 | resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size}, | |
| 735 | 1004 | } | |
| 736 | - | WithSnapshotStore(fixture.snaps)(d) | |
| 1005 | + | WithSnapshotRepository(fixture.snapshots, nil)(d) | |
| 737 | 1006 | d.startSnapshotEncodeWorker() | |
| 738 | 1007 | t.Cleanup(d.stopSnapshotEncodeWorker) | |
| 739 | 1008 | for tabIndex, tb := range sess.tabs { | |
| @@ -778,7 +1047,7 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR | |||
| 778 | 1047 | for _, p := range tb.panes { | |
| 779 | 1048 | p.mu.Lock() | |
| 780 | 1049 | width := p.screen.Frame.Width | |
| 781 | - | for row := 0; p.history.Len() < historyRows; row++ { | |
| 1050 | + | for row := 0; p.history.Len() < historyRows && row < historyRows+p.screen.Frame.Height; row++ { | |
| 782 | 1051 | p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n")) | |
| 783 | 1052 | } | |
| 784 | 1053 | p.mu.Unlock() | |
| @@ -927,7 +1196,7 @@ func (f *performanceFixture) resized() bool { | |||
| 927 | 1196 | ||
| 928 | 1197 | func (f *performanceFixture) resetMetrics() { | |
| 929 | 1198 | f.output.reset() | |
| 930 | - | f.snaps.reset() | |
| 1199 | + | f.snapshots.reset() | |
| 931 | 1200 | f.renderCaptures = 0 | |
| 932 | 1201 | f.renderCompositions = 0 | |
| 933 | 1202 | f.renderEmissions = 0 | |
| @@ -943,10 +1212,10 @@ func (f *performanceFixture) resetMetrics() { | |||
| 943 | 1212 | ||
| 944 | 1213 | func (f *performanceFixture) metrics() performanceMetrics { | |
| 945 | 1214 | output := f.output.metrics() | |
| 946 | - | snapshots := f.snaps.metrics() | |
| 1215 | + | snapshots := f.snapshots.metrics() | |
| 947 | 1216 | metrics := performanceMetrics{ | |
| 948 | 1217 | outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes, | |
| 949 | - | snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes, | |
| 1218 | + | snapshotWrites: snapshots.writes, snapshotManifestBytes: snapshots.manifestBytes, snapshotObjectBytes: snapshots.objectBytes, | |
| 950 | 1219 | renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions, | |
| 951 | 1220 | resizeRequests: f.resizeRequests, resizeCommits: f.resizeCommits, resizeCommitNanos: f.resizeCommitNanos, | |
| 952 | 1221 | ptyFailures: f.ptyFailures, ptyRetries: f.ptyRetries, | |
| @@ -1010,35 +1279,76 @@ func (t *countingOutputTransport) lastPayload() []byte { | |||
| 1010 | 1279 | return t.last | |
| 1011 | 1280 | } | |
| 1012 | 1281 | ||
| 1013 | - | type countingSnapshotMetrics struct{ writes, bytes uint64 } | |
| 1014 | - | type countingSnapshotStore struct { | |
| 1282 | + | // snapshotHeadBytes is the on-disk VEVH header (magic, generation, and | |
| 1283 | + | // manifest digest) written by the filesystem repository for every generation. | |
| 1284 | + | const snapshotHeadBytes = 4 + 8 + 32 | |
| 1285 | + | ||
| 1286 | + | type countingSnapshotMetrics struct { | |
| 1287 | + | writes, objectBytes, historyBlobBytes, suppliedObjectBytes, suppliedHistoryBytes, manifestBytes, headBytes uint64 | |
| 1288 | + | } | |
| 1289 | + | type countingSnapshotRepository struct { | |
| 1015 | 1290 | mu sync.Mutex | |
| 1016 | 1291 | countingSnapshotMetrics | |
| 1017 | - | last []byte | |
| 1292 | + | objects map[string]map[ports.SnapshotDigest]struct{} | |
| 1293 | + | last []byte | |
| 1018 | 1294 | } | |
| 1019 | 1295 | ||
| 1020 | - | func (s *countingSnapshotStore) Write(_ string, data []byte) error { | |
| 1296 | + | // Publish models the bytes written by the filesystem repository: immutable | |
| 1297 | + | // VEVO objects are written once per session and each generation writes a VEVM | |
| 1298 | + | // manifest plus the mutable VEVH pointer. It intentionally does not measure | |
| 1299 | + | // directory metadata, temporary files, or fsyncs. | |
| 1300 | + | func (s *countingSnapshotRepository) Publish(_ context.Context, publication ports.SnapshotPublication) error { | |
| 1021 | 1301 | s.mu.Lock() | |
| 1302 | + | defer s.mu.Unlock() | |
| 1303 | + | if s.objects == nil { | |
| 1304 | + | s.objects = make(map[string]map[ports.SnapshotDigest]struct{}) | |
| 1305 | + | } | |
| 1306 | + | objects := s.objects[publication.Name] | |
| 1307 | + | if objects == nil { | |
| 1308 | + | objects = make(map[ports.SnapshotDigest]struct{}) | |
| 1309 | + | s.objects[publication.Name] = objects | |
| 1310 | + | } | |
| 1022 | 1311 | s.writes++ | |
| 1023 | - | s.bytes += uint64(len(data)) | |
| 1024 | - | s.last = data | |
| 1025 | - | s.mu.Unlock() | |
| 1312 | + | s.manifestBytes += uint64(len(publication.Manifest)) | |
| 1313 | + | s.headBytes += snapshotHeadBytes | |
| 1314 | + | for _, object := range publication.Objects { | |
| 1315 | + | s.suppliedObjectBytes += uint64(len(object.Data)) | |
| 1316 | + | kind, _, err := snapcodec.PreflightObject(object.Data) | |
| 1317 | + | if err == nil && kind == snapcodec.HistoryChunk { | |
| 1318 | + | s.suppliedHistoryBytes += uint64(len(object.Data)) | |
| 1319 | + | } | |
| 1320 | + | if _, exists := objects[object.Digest]; exists { | |
| 1321 | + | continue | |
| 1322 | + | } | |
| 1323 | + | objects[object.Digest] = struct{}{} | |
| 1324 | + | s.objectBytes += uint64(len(object.Data)) | |
| 1325 | + | if err == nil && kind == snapcodec.HistoryChunk { | |
| 1326 | + | s.historyBlobBytes += uint64(len(object.Data)) | |
| 1327 | + | } | |
| 1328 | + | } | |
| 1329 | + | s.last = publication.Manifest | |
| 1026 | 1330 | return nil | |
| 1027 | 1331 | } | |
| 1028 | - | func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |
| 1029 | - | func (*countingSnapshotStore) Delete(string) error { return nil } | |
| 1030 | - | func (s *countingSnapshotStore) reset() { | |
| 1332 | + | func (*countingSnapshotRepository) List(context.Context) ([]string, error) { return nil, nil } | |
| 1333 | + | func (*countingSnapshotRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) { | |
| 1334 | + | return ports.SnapshotGeneration{}, nil | |
| 1335 | + | } | |
| 1336 | + | func (*countingSnapshotRepository) Delete(context.Context, string) error { return nil } | |
| 1337 | + | func (*countingSnapshotRepository) Tombstone(context.Context, string) error { return nil } | |
| 1338 | + | func (*countingSnapshotRepository) DeleteTombstone(context.Context, string) error { return nil } | |
| 1339 | + | func (*countingSnapshotRepository) Maintain(context.Context) error { return nil } | |
| 1340 | + | func (s *countingSnapshotRepository) reset() { | |
| 1031 | 1341 | s.mu.Lock() | |
| 1032 | 1342 | s.countingSnapshotMetrics = countingSnapshotMetrics{} | |
| 1033 | 1343 | s.last = nil | |
| 1034 | 1344 | s.mu.Unlock() | |
| 1035 | 1345 | } | |
| 1036 | - | func (s *countingSnapshotStore) metrics() countingSnapshotMetrics { | |
| 1346 | + | func (s *countingSnapshotRepository) metrics() countingSnapshotMetrics { | |
| 1037 | 1347 | s.mu.Lock() | |
| 1038 | 1348 | defer s.mu.Unlock() | |
| 1039 | 1349 | return s.countingSnapshotMetrics | |
| 1040 | 1350 | } | |
| 1041 | - | func (s *countingSnapshotStore) lastPayload() []byte { | |
| 1351 | + | func (s *countingSnapshotRepository) lastPayload() []byte { | |
| 1042 | 1352 | s.mu.Lock() | |
| 1043 | 1353 | defer s.mu.Unlock() | |
| 1044 | 1354 | return s.last | |
M internal/usecase/daemon/picker.go +3 -10
| @@ -547,17 +547,10 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 547 | 547 | ||
| 548 | 548 | func (d *Daemon) killPickerTarget(target picker.Target) error { | |
| 549 | 549 | if target.Stopped { | |
| 550 | - | d.mu.Lock() | |
| 551 | - | stopped, ok := d.stopped[target.Name] | |
| 552 | - | if ok && !stopped.purging { | |
| 553 | - | if err := d.persist.Delete(target.Name); err != nil { | |
| 554 | - | d.mu.Unlock() | |
| 555 | - | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name) | |
| 556 | - | return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err) | |
| 557 | - | } | |
| 558 | - | delete(d.stopped, target.Name) | |
| 550 | + | if err := d.retryStoppedPurge(target.Name); err != nil { | |
| 551 | + | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name) | |
| 552 | + | return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err) | |
| 559 | 553 | } | |
| 560 | - | d.mu.Unlock() | |
| 561 | 554 | return nil | |
| 562 | 555 | } | |
| 563 | 556 | d.mu.Lock() | |
M internal/usecase/daemon/picker_test.go +19 -8
| @@ -35,11 +35,21 @@ func (failingDeleteStore) Range(func(k, v []byte) bool) {} | |||
| 35 | 35 | func (failingDeleteStore) Sync() error { return nil } | |
| 36 | 36 | func (failingDeleteStore) Close() error { return nil } | |
| 37 | 37 | ||
| 38 | - | type refusingSnapshotDeleteStore struct{ err error } | |
| 38 | + | type refusingSnapshotDeleteRepository struct{ err error } | |
| 39 | 39 | ||
| 40 | - | func (refusingSnapshotDeleteStore) Write(string, []byte) error { return nil } | |
| 41 | - | func (refusingSnapshotDeleteStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |
| 42 | - | func (s refusingSnapshotDeleteStore) Delete(string) error { return s.err } | |
| 40 | + | func (refusingSnapshotDeleteRepository) Publish(context.Context, ports.SnapshotPublication) error { | |
| 41 | + | return nil | |
| 42 | + | } | |
| 43 | + | func (refusingSnapshotDeleteRepository) List(context.Context) ([]string, error) { return nil, nil } | |
| 44 | + | func (refusingSnapshotDeleteRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) { | |
| 45 | + | return ports.SnapshotGeneration{}, nil | |
| 46 | + | } | |
| 47 | + | func (s refusingSnapshotDeleteRepository) Delete(context.Context, string) error { return s.err } | |
| 48 | + | func (refusingSnapshotDeleteRepository) Tombstone(context.Context, string) error { return nil } | |
| 49 | + | func (refusingSnapshotDeleteRepository) DeleteTombstone(context.Context, string) error { | |
| 50 | + | return nil | |
| 51 | + | } | |
| 52 | + | func (refusingSnapshotDeleteRepository) Maintain(context.Context) error { return nil } | |
| 43 | 53 | ||
| 44 | 54 | func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab { | |
| 45 | 55 | tb := newTab(p, domain.Size{Cols: 80, Rows: 23}) | |
| @@ -796,7 +806,7 @@ func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testin | |||
| 796 | 806 | } | |
| 797 | 807 | ||
| 798 | 808 | func TestPickerPreviewContainsOnlyVisibleFrameRowsWithLargeScrollback(t *testing.T) { | |
| 799 | - | withScrollback := newPickerPreviewTabWithHistoryRows(10_000) | |
| 809 | + | withScrollback := newPickerPreviewTabWithHistoryRows(t, 10_000) | |
| 800 | 810 | require.Equal(t, 10_000, withScrollback.focusedPane().screen.History().Len()) | |
| 801 | 811 | ||
| 802 | 812 | preview := snapshotPickerPreview(withScrollback) | |
| @@ -820,11 +830,12 @@ func TestPickerPreviewContainsOnlyVisibleFrameRowsWithLargeScrollback(t *testing | |||
| 820 | 830 | require.NotContains(t, got, "history-only-marker") | |
| 821 | 831 | } | |
| 822 | 832 | ||
| 823 | - | func newPickerPreviewTabWithHistoryRows(historyRows int) *tab { | |
| 833 | + | func newPickerPreviewTabWithHistoryRows(t testing.TB, historyRows int) *tab { | |
| 834 | + | t.Helper() | |
| 824 | 835 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | |
| 825 | 836 | p := tb.focusedPane() | |
| 826 | 837 | for range historyRows { | |
| 827 | - | p.screen.History().Append(testRow("history-only-marker")) | |
| 838 | + | require.NoError(t, p.screen.History().Append(testRow("history-only-marker"))) | |
| 828 | 839 | } | |
| 829 | 840 | p.screen.Write([]byte("NOW")) | |
| 830 | 841 | return tb | |
| @@ -997,7 +1008,7 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t | |||
| 997 | 1008 | d, from, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 998 | 1009 | defer releaseAll(releases) | |
| 999 | 1010 | cause := errors.New("snapshot delete refused") | |
| 1000 | - | WithSnapshotStore(refusingSnapshotDeleteStore{err: cause})(d) | |
| 1011 | + | WithSnapshotRepository(refusingSnapshotDeleteRepository{err: cause}, nil)(d) | |
| 1001 | 1012 | target := d.sessions[domain.SessionID("recent")] | |
| 1002 | 1013 | ||
| 1003 | 1014 | d.enterPicker(from, ac) | |
M internal/usecase/daemon/render_test.go +1 -1
| @@ -1289,7 +1289,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) { | |||
| 1289 | 1289 | defer releaseFloating() | |
| 1290 | 1290 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |
| 1291 | 1291 | fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3}) | |
| 1292 | - | fp.history.Append(testRow("flt-old")) | |
| 1292 | + | require.NoError(t, fp.history.Append(testRow("flt-old"))) | |
| 1293 | 1293 | fp.screen.Write([]byte("flt-live")) | |
| 1294 | 1294 | installTestFloating(sess.activeTab(), fp, true) | |
| 1295 | 1295 | ||
M internal/usecase/daemon/session.go +268 -77
| @@ -43,6 +43,7 @@ type session struct { | |||
| 43 | 43 | client *attachedClient | |
| 44 | 44 | clipboardQueue []clipboardForward | |
| 45 | 45 | clipboardWorkerRunning bool | |
| 46 | + | renameInProgress bool | |
| 46 | 47 | cwd string | |
| 47 | 48 | terminal terminalEnv | |
| 48 | 49 | // env is the authoritative immutable environment snapshot for future PTY children. | |
| @@ -52,14 +53,37 @@ type session struct { | |||
| 52 | 53 | mruAt atomic.Uint64 | |
| 53 | 54 | snapDirty atomic.Bool | |
| 54 | 55 | snapEligible atomic.Bool | |
| 55 | - | // snapshotMu serializes the dirty generation with worker completion. It is | |
| 56 | - | // intentionally independent from mu: persistence never holds session state | |
| 57 | - | // locks while encoding or writing. | |
| 58 | - | snapshotMu sync.Mutex | |
| 59 | - | snapshotGeneration uint64 | |
| 56 | + | // snapshotMu serializes mutation revisions and repository publication | |
| 57 | + | // generations with worker completion. It is intentionally independent from | |
| 58 | + | // mu: persistence never holds session state locks while encoding or writing. | |
| 59 | + | snapshotMu sync.Mutex | |
| 60 | + | snapshotGeneration uint64 // newest mutation revision | |
| 61 | + | snapshotPublishedGeneration uint64 // newest repository generation | |
| 62 | + | snapshotPublishedMutationRevision uint64 | |
| 63 | + | snapshotNextEligibleAt time.Time | |
| 64 | + | // The coordinator state below is guarded by snapshotMu. A capture can be | |
| 65 | + | // queued globally or in flight, never more than one of each per session. | |
| 66 | + | // Quarantine cancels the session publication context before destructive | |
| 67 | + | // repository operations and publicationDone joins a started publication. | |
| 60 | 68 | snapshotPending bool | |
| 61 | 69 | snapshotPendingCaptures uint | |
| 62 | - | snapshotChanged chan struct{} | |
| 70 | + | // snapshotForcedGeneration is the newest mutation revision a forced | |
| 71 | + | // checkpoint must publish. It survives an older routine capture so worker | |
| 72 | + | // completion can enqueue exactly one forced successor. | |
| 73 | + | snapshotForcedGeneration uint64 | |
| 74 | + | snapshotQueuedCapture *snapshotCapture | |
| 75 | + | snapshotInFlightCapture *snapshotCapture | |
| 76 | + | snapshotPublicationDone chan struct{} | |
| 77 | + | snapshotPublicationContext context.Context | |
| 78 | + | snapshotPublicationCancel context.CancelFunc | |
| 79 | + | snapshotQuarantined bool | |
| 80 | + | snapshotQuarantineEpoch uint64 | |
| 81 | + | // snapshotChunkCache retains encoded sealed history only for this named | |
| 82 | + | // session. snapshotMu owns it so cache touches never contend with pane | |
| 83 | + | // state or mutate bytes retained by queued/in-flight publications. | |
| 84 | + | snapshotChunkCache *snapshotChunkCache | |
| 85 | + | snapshotChanged chan struct{} | |
| 86 | + | snapshotWake chan struct{} | |
| 63 | 87 | // syncGen makes synchronized-output watchdog generations unique across all | |
| 64 | 88 | // panes in this session. | |
| 65 | 89 | syncGen atomic.Uint64 | |
| @@ -193,16 +217,20 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 193 | 217 | lastUsedSeq = stopped.lastUsedSeq | |
| 194 | 218 | } | |
| 195 | 219 | sess := &session{ | |
| 196 | - | id: id, | |
| 197 | - | name: name, | |
| 198 | - | ephemeral: ephemeral, | |
| 199 | - | ctx: sctx, | |
| 200 | - | cancel: cancel, | |
| 201 | - | tabs: tabs, | |
| 202 | - | cwd: cwd, | |
| 203 | - | terminal: term, | |
| 204 | - | env: env, | |
| 205 | - | createdAt: createdAt, | |
| 220 | + | id: id, | |
| 221 | + | name: name, | |
| 222 | + | ephemeral: ephemeral, | |
| 223 | + | ctx: sctx, | |
| 224 | + | cancel: cancel, | |
| 225 | + | tabs: tabs, | |
| 226 | + | cwd: cwd, | |
| 227 | + | terminal: term, | |
| 228 | + | env: env, | |
| 229 | + | createdAt: createdAt, | |
| 230 | + | snapshotWake: d.snapshotWake, | |
| 231 | + | } | |
| 232 | + | if !ephemeral && name != "" { | |
| 233 | + | sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit) | |
| 206 | 234 | } | |
| 207 | 235 | if lastUsedSeq > 0 { | |
| 208 | 236 | sess.mruAt.Store(lastUsedSeq) | |
| @@ -521,15 +549,27 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 521 | 549 | if err := domain.ValidateSessionName(name); err != nil { | |
| 522 | 550 | return err | |
| 523 | 551 | } | |
| 552 | + | ||
| 553 | + | // Reserve the rename while state is inspected, then perform persistence and | |
| 554 | + | // repository I/O without daemon/session locks. The old identity is first | |
| 555 | + | // quarantined and durably purged; committing the new record before that | |
| 556 | + | // transaction completes could leave a restorable old legacy source. | |
| 524 | 557 | d.mu.Lock() | |
| 525 | - | defer d.mu.Unlock() | |
| 526 | 558 | if taken := d.findByNameLocked(name); taken != nil && taken != sess { | |
| 559 | + | d.mu.Unlock() | |
| 527 | 560 | return errSessionNameInUse | |
| 528 | 561 | } | |
| 562 | + | sess.mu.Lock() | |
| 563 | + | if sess.renameInProgress { | |
| 564 | + | sess.mu.Unlock() | |
| 565 | + | d.mu.Unlock() | |
| 566 | + | return errors.New("session rename already in progress") | |
| 567 | + | } | |
| 529 | 568 | if stopped, ok := d.stopped[name]; ok && stopped.name != sess.name { | |
| 569 | + | sess.mu.Unlock() | |
| 570 | + | d.mu.Unlock() | |
| 530 | 571 | return errSessionNameInUse | |
| 531 | 572 | } | |
| 532 | - | sess.mu.Lock() | |
| 533 | 573 | oldName := sess.name | |
| 534 | 574 | wasEphemeral := sess.ephemeral | |
| 535 | 575 | createdAt := sess.createdAt | |
| @@ -538,45 +578,79 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 538 | 578 | createdAt, err = d.allocateLifecycleCreatedAtLocked() | |
| 539 | 579 | if err != nil { | |
| 540 | 580 | sess.mu.Unlock() | |
| 581 | + | d.mu.Unlock() | |
| 541 | 582 | return err | |
| 542 | 583 | } | |
| 543 | 584 | } | |
| 544 | 585 | lastUsedSeq := sess.mruAt.Load() | |
| 545 | - | if wasEphemeral || oldName != name { | |
| 546 | - | record := sess.persistRecordLocked(d.nowUnixNano()) | |
| 547 | - | record.Name = name | |
| 548 | - | record.CreatedAt = createdAt | |
| 549 | - | record.LastUsedSeq = lastUsedSeq | |
| 550 | - | if err := d.persist.Save(record); err != nil { | |
| 551 | - | sess.mu.Unlock() | |
| 552 | - | return err | |
| 553 | - | } | |
| 586 | + | record := sess.persistRecordLocked(d.nowUnixNano()) | |
| 587 | + | record.Name = name | |
| 588 | + | record.CreatedAt = createdAt | |
| 589 | + | record.LastUsedSeq = lastUsedSeq | |
| 590 | + | sess.renameInProgress = true | |
| 591 | + | sess.mu.Unlock() | |
| 592 | + | d.mu.Unlock() | |
| 593 | + | ||
| 594 | + | rollback := func(err error) error { | |
| 595 | + | sess.mu.Lock() | |
| 596 | + | sess.renameInProgress = false | |
| 597 | + | sess.mu.Unlock() | |
| 598 | + | return err | |
| 554 | 599 | } | |
| 600 | + | var quarantine snapshotCoordinatorQuarantine | |
| 555 | 601 | if !wasEphemeral && oldName != name { | |
| 556 | - | if d.snapsEnabled && d.snaps != nil { | |
| 557 | - | if err := d.snaps.Delete(oldName); err != nil { | |
| 558 | - | if cleanupErr := d.persist.Delete(name); cleanupErr != nil { | |
| 559 | - | d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name) | |
| 560 | - | } | |
| 561 | - | sess.mu.Unlock() | |
| 562 | - | return err | |
| 563 | - | } | |
| 602 | + | quarantine = quarantineSnapshotCoordinatorWithEpoch(sess) | |
| 603 | + | <-quarantine.done | |
| 604 | + | if err := d.beginSnapshotPurge(oldName); err != nil { | |
| 605 | + | return rollback(err) | |
| 564 | 606 | } | |
| 565 | - | if err := d.persist.Delete(oldName); err != nil { | |
| 566 | - | if cleanupErr := d.persist.Delete(name); cleanupErr != nil { | |
| 567 | - | d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name) | |
| 568 | - | } | |
| 569 | - | sess.mu.Unlock() | |
| 570 | - | return err | |
| 607 | + | // finishSnapshotPurge preserves the tombstone through both source | |
| 608 | + | // deletions and the old metadata delete, including a retained legacy | |
| 609 | + | // import whose verified source delete is still pending. | |
| 610 | + | if err := d.finishSnapshotPurge(oldName); err != nil { | |
| 611 | + | return rollback(err) | |
| 612 | + | } | |
| 613 | + | } | |
| 614 | + | // For a named rename this is the durable commit point, deliberately after | |
| 615 | + | // the old tombstone transaction. Ephemeral promotion has no old durable | |
| 616 | + | // identity and can create its first record directly. | |
| 617 | + | if wasEphemeral || oldName != name { | |
| 618 | + | if err := d.persist.Save(record); err != nil { | |
| 619 | + | return rollback(err) | |
| 571 | 620 | } | |
| 572 | 621 | } | |
| 622 | + | ||
| 623 | + | d.mu.Lock() | |
| 624 | + | sess.mu.Lock() | |
| 625 | + | // The reservation prevents another rename. A closing session is allowed to | |
| 626 | + | // finish this short commit; killSession subsequently owns its teardown. | |
| 573 | 627 | delete(d.stopped, oldName) | |
| 574 | 628 | delete(d.stopped, name) | |
| 575 | 629 | sess.name = name | |
| 576 | 630 | sess.createdAt = createdAt | |
| 577 | 631 | sess.ephemeral = false | |
| 578 | - | sess.snapEligible.Store(name != "") | |
| 632 | + | sess.renameInProgress = false | |
| 633 | + | // Keep the coordinator in quarantine until the new in-memory identity is | |
| 634 | + | // fully committed. The fresh coordinator below makes the new repository | |
| 635 | + | // start at generation one. | |
| 636 | + | if !wasEphemeral && oldName != name { | |
| 637 | + | sess.snapEligible.Store(false) | |
| 638 | + | } else { | |
| 639 | + | sess.snapEligible.Store(name != "") | |
| 640 | + | } | |
| 579 | 641 | sess.mu.Unlock() | |
| 642 | + | d.mu.Unlock() | |
| 643 | + | if !wasEphemeral && oldName != name { | |
| 644 | + | // A concurrent teardown may have quarantined after this rename did. In | |
| 645 | + | // that case its newer epoch owns the stopped coordinator. | |
| 646 | + | resumeSnapshotCoordinatorForNewIdentity(sess, quarantine) | |
| 647 | + | return nil | |
| 648 | + | } | |
| 649 | + | if wasEphemeral { | |
| 650 | + | sess.snapshotMu.Lock() | |
| 651 | + | sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit) | |
| 652 | + | sess.snapshotMu.Unlock() | |
| 653 | + | } | |
| 580 | 654 | markSnapshotDirty(sess) | |
| 581 | 655 | return nil | |
| 582 | 656 | } | |
| @@ -717,36 +791,155 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 717 | 791 | // ptyReader drains child output into the VT screen and pokes the dirty channel | |
| 718 | 792 | // (non-blocking: a full channel already means a render is pending). On any read | |
| 719 | 793 | ||
| 794 | + | // beginSnapshotPurge commits a logical named-session kill before its live | |
| 795 | + | // identity or persisted metadata is removed. It never runs under daemon or | |
| 796 | + | // session locks. | |
| 797 | + | func (d *Daemon) beginSnapshotPurge(name string) error { | |
| 798 | + | if d.snapshotRepository == nil || name == "" { | |
| 799 | + | return nil | |
| 800 | + | } | |
| 801 | + | return d.snapshotRepository.Tombstone(context.Background(), name) | |
| 802 | + | } | |
| 803 | + | ||
| 804 | + | // finishSnapshotPurge deletes both independently durable snapshot sources, | |
| 805 | + | // then metadata, and only then clears the tombstone. Any failure deliberately | |
| 806 | + | // leaves the marker in place so startup cannot restore or import the name and | |
| 807 | + | // a later live/offline kill can retry the idempotent source deletes. | |
| 808 | + | func (d *Daemon) finishSnapshotPurge(name string) error { | |
| 809 | + | if d.snapshotRepository == nil { | |
| 810 | + | return d.persist.Delete(name) | |
| 811 | + | } | |
| 812 | + | incrementalErr := d.snapshotRepository.Delete(context.Background(), name) | |
| 813 | + | var legacyErr error | |
| 814 | + | if d.legacySnapshots != nil { | |
| 815 | + | legacyErr = d.legacySnapshots.DeleteLegacy(context.Background(), name) | |
| 816 | + | } | |
| 817 | + | if incrementalErr != nil || legacyErr != nil { | |
| 818 | + | return errors.Join(incrementalErr, legacyErr) | |
| 819 | + | } | |
| 820 | + | if err := d.persist.Delete(name); err != nil { | |
| 821 | + | return err | |
| 822 | + | } | |
| 823 | + | return d.snapshotRepository.DeleteTombstone(context.Background(), name) | |
| 824 | + | } | |
| 825 | + | ||
| 826 | + | // retryStoppedPurge completes a previously closed session's durable purge. | |
| 827 | + | // It keeps the stopped record hidden until all sources and metadata cross their | |
| 828 | + | // deletion boundaries. | |
| 829 | + | func (d *Daemon) retryStoppedPurge(name string) error { | |
| 830 | + | d.mu.Lock() | |
| 831 | + | stopped, ok := d.stopped[name] | |
| 832 | + | if !ok { | |
| 833 | + | d.mu.Unlock() | |
| 834 | + | return nil | |
| 835 | + | } | |
| 836 | + | // Without a repository there is no restore/import source to fence, so the | |
| 837 | + | // stopped record retains the metadata-only deletion behavior. | |
| 838 | + | if d.snapshotRepository == nil { | |
| 839 | + | d.mu.Unlock() | |
| 840 | + | if err := d.persist.Delete(name); err != nil { | |
| 841 | + | return err | |
| 842 | + | } | |
| 843 | + | d.mu.Lock() | |
| 844 | + | delete(d.stopped, name) | |
| 845 | + | d.mu.Unlock() | |
| 846 | + | return nil | |
| 847 | + | } | |
| 848 | + | stopped.purging = true | |
| 849 | + | d.stopped[name] = stopped | |
| 850 | + | d.mu.Unlock() | |
| 851 | + | ||
| 852 | + | if err := d.beginSnapshotPurge(name); err != nil { | |
| 853 | + | return err | |
| 854 | + | } | |
| 855 | + | if err := d.finishSnapshotPurge(name); err != nil { | |
| 856 | + | return err | |
| 857 | + | } | |
| 858 | + | d.mu.Lock() | |
| 859 | + | if current, ok := d.stopped[name]; ok && current.purging { | |
| 860 | + | delete(d.stopped, name) | |
| 861 | + | } | |
| 862 | + | d.mu.Unlock() | |
| 863 | + | return nil | |
| 864 | + | } | |
| 865 | + | ||
| 720 | 866 | func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |
| 867 | + | return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil) | |
| 868 | + | } | |
| 869 | + | ||
| 870 | + | // killSessionWithSnapshotDeadline shares Serve's shutdown budget with its | |
| 871 | + | // terminal checkpoint and coordinator join. A timed-out repository call keeps | |
| 872 | + | // only immutable capture state; it never observes closed worker channels or | |
| 873 | + | // session-owned cache that teardown has released. | |
| 874 | + | func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline) error { | |
| 721 | 875 | ringing := sess.anyAttention() | |
| 722 | 876 | sess.mu.Lock() | |
| 723 | 877 | isEphemeral := sess.ephemeral | |
| 878 | + | name := sess.name | |
| 724 | 879 | sess.mu.Unlock() | |
| 880 | + | // Commit the tombstone before changing live identity or metadata. A | |
| 881 | + | // repository delete must never race an older publication that could recreate | |
| 882 | + | // the just-quarantined name. Do not hold daemon or session locks while | |
| 883 | + | // tombstoning or joining: a repository implementation may block on I/O. | |
| 884 | + | if purge && !isEphemeral { | |
| 885 | + | if err := d.beginSnapshotPurge(name); err != nil { | |
| 886 | + | return err | |
| 887 | + | } | |
| 888 | + | if d.snapshotRepository != nil { | |
| 889 | + | <-quarantineSnapshotCoordinator(sess) | |
| 890 | + | } | |
| 891 | + | } | |
| 725 | 892 | if !purge && !isEphemeral && d.persistEnabled { | |
| 726 | 893 | d.refreshSessionCwd(sess) | |
| 727 | 894 | } | |
| 728 | - | var snapshotDeleteErr, terminalSnapshotErr error | |
| 729 | - | if d.snapsEnabled && !isEphemeral { | |
| 730 | - | if purge { | |
| 895 | + | var terminalSnapshotErr error | |
| 896 | + | retainSnapshotRetry := false | |
| 897 | + | if d.snapsEnabled && !isEphemeral && !purge { | |
| 898 | + | markSnapshotDirty(sess) | |
| 899 | + | sess.snapshotMu.Lock() | |
| 900 | + | terminalGeneration := sess.snapshotGeneration | |
| 901 | + | sess.snapshotMu.Unlock() | |
| 902 | + | if !d.scheduleFinalSnapshot(sess) || !d.waitForSnapshotGenerationWithDeadline(sess, terminalGeneration, deadline) { | |
| 731 | 903 | sess.mu.Lock() | |
| 732 | 904 | name := sess.name | |
| 733 | 905 | sess.mu.Unlock() | |
| 734 | - | if err := d.snaps.Delete(name); err != nil { | |
| 735 | - | snapshotDeleteErr = err | |
| 736 | - | d.log.Warn("deleting session snapshot failed", "err", err, "session", name) | |
| 906 | + | terminalSnapshotErr = fmt.Errorf("retain final snapshot for session %q: snapshot worker unavailable, saturated, or timed out", name) | |
| 907 | + | if reason != ports.ReasonServerShutdown { | |
| 908 | + | return terminalSnapshotErr | |
| 737 | 909 | } | |
| 910 | + | // Preserve any already-queued routine capture until the blocked | |
| 911 | + | // worker can safely discard it. This retains the dirty generation | |
| 912 | + | // and forced retry intent without adding another queue entry. | |
| 913 | + | retainSnapshotRetry = true | |
| 914 | + | d.persistShutdownSnapshotFailure(name, terminalSnapshotErr) | |
| 915 | + | } | |
| 916 | + | } | |
| 917 | + | if !isEphemeral { | |
| 918 | + | // Joining first preserves byte ownership for a publication that is still | |
| 919 | + | // encoding while this session is being torn down. Once Serve's shared | |
| 920 | + | // budget expires, the worker is cancelled by its one final join instead; | |
| 921 | + | // keep the cache reachable because an uncooperative writer may still be | |
| 922 | + | // encoding immutable state. | |
| 923 | + | var quarantineDone <-chan struct{} | |
| 924 | + | if retainSnapshotRetry { | |
| 925 | + | quarantineDone = quarantineSnapshotCoordinatorRetainingQueuedCapture(sess) | |
| 738 | 926 | } else { | |
| 739 | - | markSnapshotDirty(sess) | |
| 740 | - | if !d.scheduleFinalSnapshot(sess) { | |
| 741 | - | sess.mu.Lock() | |
| 742 | - | name := sess.name | |
| 743 | - | sess.mu.Unlock() | |
| 744 | - | terminalSnapshotErr = fmt.Errorf("retain final snapshot for session %q: snapshot worker unavailable or saturated", name) | |
| 745 | - | if reason != ports.ReasonServerShutdown { | |
| 746 | - | return terminalSnapshotErr | |
| 747 | - | } | |
| 748 | - | d.persistShutdownSnapshotFailure(name, terminalSnapshotErr) | |
| 927 | + | quarantineDone = quarantineSnapshotCoordinator(sess) | |
| 928 | + | } | |
| 929 | + | joined := true | |
| 930 | + | if deadline != nil { | |
| 931 | + | select { | |
| 932 | + | case <-quarantineDone: | |
| 933 | + | case <-deadline.Done(): | |
| 934 | + | joined = false | |
| 749 | 935 | } | |
| 936 | + | } else { | |
| 937 | + | <-quarantineDone | |
| 938 | + | } | |
| 939 | + | if joined { | |
| 940 | + | sess.snapshotMu.Lock() | |
| 941 | + | sess.snapshotChunkCache = nil | |
| 942 | + | sess.snapshotMu.Unlock() | |
| 750 | 943 | } | |
| 751 | 944 | } | |
| 752 | 945 | d.mu.Lock() | |
| @@ -778,25 +971,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 778 | 971 | d.mu.Unlock() | |
| 779 | 972 | d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason) | |
| 780 | 973 | ||
| 781 | - | purgeErr := snapshotDeleteErr | |
| 782 | - | if !ephemeral && purge { | |
| 783 | - | if err := d.persist.Delete(stoppedName); err != nil { | |
| 784 | - | purgeErr = err | |
| 785 | - | d.log.Warn("deleting persisted session failed", "err", err, "session", stoppedName) | |
| 786 | - | d.mu.Lock() | |
| 787 | - | if stopped, ok := d.stopped[stoppedName]; ok && stopped.purging { | |
| 788 | - | stopped.purging = false | |
| 789 | - | d.stopped[stoppedName] = stopped | |
| 790 | - | } | |
| 791 | - | d.mu.Unlock() | |
| 792 | - | } else { | |
| 793 | - | d.mu.Lock() | |
| 794 | - | if stopped, ok := d.stopped[stoppedName]; ok && stopped.purging { | |
| 795 | - | delete(d.stopped, stoppedName) | |
| 796 | - | } | |
| 797 | - | d.mu.Unlock() | |
| 798 | - | } | |
| 799 | - | } | |
| 974 | + | var purgeErr error | |
| 800 | 975 | ||
| 801 | 976 | // A global route that selected this attachment must publish before terminal | |
| 802 | 977 | // teardown makes it stale. | |
| @@ -840,6 +1015,22 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 840 | 1015 | d.log.Warn("removing clipboard temp file failed", "err", err, "path", path) | |
| 841 | 1016 | } | |
| 842 | 1017 | } | |
| 1018 | + | // The coordinator and all panes are now stopped, so no producer can publish | |
| 1019 | + | // another generation after this destructive source deletion. Keep the | |
| 1020 | + | // tombstone and hidden stopped record when any source or metadata operation | |
| 1021 | + | // fails; retryStoppedPurge (including a repeated live kill) resumes safely. | |
| 1022 | + | if !ephemeral && purge { | |
| 1023 | + | if err := d.finishSnapshotPurge(stoppedName); err != nil { | |
| 1024 | + | purgeErr = errors.Join(purgeErr, err) | |
| 1025 | + | d.log.Warn("finishing session snapshot purge failed", "err", err, "session", stoppedName) | |
| 1026 | + | } else { | |
| 1027 | + | d.mu.Lock() | |
| 1028 | + | if stopped, ok := d.stopped[stoppedName]; ok && stopped.purging { | |
| 1029 | + | delete(d.stopped, stoppedName) | |
| 1030 | + | } | |
| 1031 | + | d.mu.Unlock() | |
| 1032 | + | } | |
| 1033 | + | } | |
| 843 | 1034 | if empty { | |
| 844 | 1035 | d.doneOnce.Do(func() { close(d.done) }) | |
| 845 | 1036 | } else if ringing { | |
A internal/usecase/daemon/session_purge_control_test.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/mock" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestHandleKillStoppedUsesDurableBothSourcePurge(t *testing.T) { | |
| 15 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 16 | + | repository := &retryablePurgeRepository{legacyErr: errors.New("legacy delete failed")} | |
| 17 | + | WithSnapshotRepository(repository, repository)(d) | |
| 18 | + | d.stopped["work"] = stoppedSession{name: "work"} | |
| 19 | + | ||
| 20 | + | transport := portsmocks.NewMockTransport(t) | |
| 21 | + | transport.EXPECT().Send(mock.Anything).Return(nil).Once() | |
| 22 | + | transport.EXPECT().Close().Return(nil).Once() | |
| 23 | + | d.handleKill(transport, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |
| 24 | + | ||
| 25 | + | require.Equal(t, []string{"tombstone", "incremental", "legacy"}, repository.calls) | |
| 26 | + | require.True(t, repository.tombstoned["work"]) | |
| 27 | + | require.True(t, d.stopped["work"].purging) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestStoppedPurgeMetadataFailureRemainsFencedForRetry(t *testing.T) { | |
| 31 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 32 | + | repository := &retryablePurgeRepository{} | |
| 33 | + | WithSnapshotRepository(repository, repository)(d) | |
| 34 | + | store, state := newMockStore(t) | |
| 35 | + | WithStore(store)(d) | |
| 36 | + | metadataErr := errors.New("metadata delete failed") | |
| 37 | + | state.mu.Lock() | |
| 38 | + | state.deleteErr = func(string) error { return metadataErr } | |
| 39 | + | state.mu.Unlock() | |
| 40 | + | d.stopped["work"] = stoppedSession{name: "work"} | |
| 41 | + | ||
| 42 | + | require.ErrorIs(t, d.retryStoppedPurge("work"), metadataErr) | |
| 43 | + | require.True(t, repository.tombstoned["work"]) | |
| 44 | + | require.Equal(t, []string{"tombstone", "incremental", "legacy"}, repository.calls) | |
| 45 | + | ||
| 46 | + | state.mu.Lock() | |
| 47 | + | state.deleteErr = nil | |
| 48 | + | state.mu.Unlock() | |
| 49 | + | require.NoError(t, d.retryStoppedPurge("work")) | |
| 50 | + | require.Equal(t, []string{"tombstone", "incremental", "legacy", "tombstone", "incremental", "legacy", "clear tombstone"}, repository.calls) | |
| 51 | + | } | |
A internal/usecase/daemon/session_purge_retry_test.go +97 -0
| @@ -0,0 +1,97 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type retryablePurgeRepository struct { | |
| 16 | + | incrementalErr error | |
| 17 | + | legacyErr error | |
| 18 | + | tombstoned map[string]bool | |
| 19 | + | calls []string | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (r *retryablePurgeRepository) Publish(context.Context, ports.SnapshotPublication) error { | |
| 23 | + | return nil | |
| 24 | + | } | |
| 25 | + | func (r *retryablePurgeRepository) List(context.Context) ([]string, error) { return nil, nil } | |
| 26 | + | func (r *retryablePurgeRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) { | |
| 27 | + | return ports.SnapshotGeneration{}, errors.New("unused") | |
| 28 | + | } | |
| 29 | + | func (r *retryablePurgeRepository) Maintain(context.Context) error { return nil } | |
| 30 | + | func (r *retryablePurgeRepository) Delete(_ context.Context, name string) error { | |
| 31 | + | r.calls = append(r.calls, "incremental") | |
| 32 | + | return r.incrementalErr | |
| 33 | + | } | |
| 34 | + | func (r *retryablePurgeRepository) LoadLegacy(context.Context) ([]ports.LegacySnapshot, error) { | |
| 35 | + | return nil, nil | |
| 36 | + | } | |
| 37 | + | func (r *retryablePurgeRepository) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error { | |
| 38 | + | return r.DeleteLegacy(ctx, blob.Name) | |
| 39 | + | } | |
| 40 | + | func (r *retryablePurgeRepository) DeleteLegacy(_ context.Context, name string) error { | |
| 41 | + | r.calls = append(r.calls, "legacy") | |
| 42 | + | return r.legacyErr | |
| 43 | + | } | |
| 44 | + | func (r *retryablePurgeRepository) Tombstone(_ context.Context, name string) error { | |
| 45 | + | if r.tombstoned == nil { | |
| 46 | + | r.tombstoned = make(map[string]bool) | |
| 47 | + | } | |
| 48 | + | r.calls = append(r.calls, "tombstone") | |
| 49 | + | r.tombstoned[name] = true | |
| 50 | + | return nil | |
| 51 | + | } | |
| 52 | + | func (r *retryablePurgeRepository) DeleteTombstone(_ context.Context, name string) error { | |
| 53 | + | r.calls = append(r.calls, "clear tombstone") | |
| 54 | + | delete(r.tombstoned, name) | |
| 55 | + | return nil | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestLivePurgeRetainsTombstoneAcrossPartialSourceDeletion(t *testing.T) { | |
| 59 | + | for _, tt := range []struct { | |
| 60 | + | name string | |
| 61 | + | incremental error | |
| 62 | + | legacy error | |
| 63 | + | }{ | |
| 64 | + | {name: "incremental failure", incremental: errors.New("incremental failed")}, | |
| 65 | + | {name: "legacy failure", legacy: errors.New("legacy failed")}, | |
| 66 | + | } { | |
| 67 | + | t.Run(tt.name, func(t *testing.T) { | |
| 68 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 69 | + | repository := &retryablePurgeRepository{incrementalErr: tt.incremental, legacyErr: tt.legacy} | |
| 70 | + | WithSnapshotRepository(repository, repository)(d) | |
| 71 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 72 | + | pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 73 | + | require.True(t, ok, "snapshot test pane must use MockPTY") | |
| 74 | + | pty.EXPECT().Close().Return(nil).Once() | |
| 75 | + | d.sessions = map[domain.SessionID]*session{sess.id: sess} | |
| 76 | + | ||
| 77 | + | require.Error(t, d.killSession(sess, ports.ReasonSessionKilled, true)) | |
| 78 | + | require.True(t, repository.tombstoned["work"], "partial purge must fence startup restore/import") | |
| 79 | + | require.Equal(t, []string{"tombstone", "incremental", "legacy"}, repository.calls) | |
| 80 | + | d.mu.Lock() | |
| 81 | + | stopped, ok := d.stopped["work"] | |
| 82 | + | d.mu.Unlock() | |
| 83 | + | require.True(t, ok) | |
| 84 | + | require.True(t, stopped.purging) | |
| 85 | + | ||
| 86 | + | repository.incrementalErr = nil | |
| 87 | + | repository.legacyErr = nil | |
| 88 | + | require.NoError(t, d.retryStoppedPurge("work")) | |
| 89 | + | require.False(t, repository.tombstoned["work"]) | |
| 90 | + | require.Equal(t, []string{"tombstone", "incremental", "legacy", "tombstone", "incremental", "legacy", "clear tombstone"}, repository.calls) | |
| 91 | + | d.mu.Lock() | |
| 92 | + | _, ok = d.stopped["work"] | |
| 93 | + | d.mu.Unlock() | |
| 94 | + | require.False(t, ok, "metadata is removed only after both source deletes and tombstone clear") | |
| 95 | + | }) | |
| 96 | + | } | |
| 97 | + | } | |
A internal/usecase/daemon/session_rename_purge_test.go +45 -0
| @@ -0,0 +1,45 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestRenamePurgesOldIncrementalAndLegacyBeforeCommittingNewIdentity(t *testing.T) { | |
| 14 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 15 | + | repository := &retryablePurgeRepository{} | |
| 16 | + | WithSnapshotRepository(repository, repository)(d) | |
| 17 | + | store, state := newMockStore(t) | |
| 18 | + | WithStore(store)(d) | |
| 19 | + | sess := newSnapshotTestSession(t, "old", false, "/work") | |
| 20 | + | d.sessions = map[domain.SessionID]*session{sess.id: sess} | |
| 21 | + | require.NoError(t, d.persist.Save(sess.persistRecordLocked(1))) | |
| 22 | + | ||
| 23 | + | require.NoError(t, d.renameSession(sess, "new")) | |
| 24 | + | require.Equal(t, []string{"tombstone", "incremental", "legacy", "clear tombstone"}, repository.calls) | |
| 25 | + | require.False(t, state.has("old")) | |
| 26 | + | require.True(t, state.has("new")) | |
| 27 | + | require.False(t, repository.tombstoned["old"]) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestRenameLegacyFailureLeavesOldIdentityFenced(t *testing.T) { | |
| 31 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 32 | + | repository := &retryablePurgeRepository{legacyErr: errors.New("legacy delete failed")} | |
| 33 | + | WithSnapshotRepository(repository, repository)(d) | |
| 34 | + | store, state := newMockStore(t) | |
| 35 | + | WithStore(store)(d) | |
| 36 | + | sess := newSnapshotTestSession(t, "old", false, "/work") | |
| 37 | + | d.sessions = map[domain.SessionID]*session{sess.id: sess} | |
| 38 | + | require.NoError(t, d.persist.Save(sess.persistRecordLocked(1))) | |
| 39 | + | ||
| 40 | + | require.Error(t, d.renameSession(sess, "new")) | |
| 41 | + | require.Equal(t, []string{"tombstone", "incremental", "legacy"}, repository.calls) | |
| 42 | + | require.True(t, repository.tombstoned["old"]) | |
| 43 | + | require.True(t, state.has("old")) | |
| 44 | + | require.False(t, state.has("new")) | |
| 45 | + | } | |
M internal/usecase/daemon/session_test.go +23 -9
| @@ -674,12 +674,14 @@ func TestServeReturnsDespiteWedgedClientOnShutdown(t *testing.T) { | |||
| 674 | 674 | } | |
| 675 | 675 | ||
| 676 | 676 | type mockStoreState struct { | |
| 677 | - | mu sync.Mutex | |
| 678 | - | data map[string][]byte | |
| 679 | - | sets int | |
| 680 | - | dels []string | |
| 681 | - | syncs int | |
| 682 | - | closed bool | |
| 677 | + | mu sync.Mutex | |
| 678 | + | data map[string][]byte | |
| 679 | + | sets int | |
| 680 | + | dels []string | |
| 681 | + | deleteErr func(string) error | |
| 682 | + | deleteDone chan struct{} | |
| 683 | + | syncs int | |
| 684 | + | closed bool | |
| 683 | 685 | } | |
| 684 | 686 | ||
| 685 | 687 | func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) { | |
| @@ -701,9 +703,21 @@ func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) { | |||
| 701 | 703 | }).Maybe() | |
| 702 | 704 | store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error { | |
| 703 | 705 | state.mu.Lock() | |
| 704 | - | defer state.mu.Unlock() | |
| 705 | - | state.dels = append(state.dels, string(k)) | |
| 706 | - | delete(state.data, string(k)) | |
| 706 | + | key := string(k) | |
| 707 | + | state.dels = append(state.dels, key) | |
| 708 | + | if state.deleteErr != nil { | |
| 709 | + | if err := state.deleteErr(key); err != nil { | |
| 710 | + | state.mu.Unlock() | |
| 711 | + | return err | |
| 712 | + | } | |
| 713 | + | } | |
| 714 | + | delete(state.data, key) | |
| 715 | + | deleteDone := state.deleteDone | |
| 716 | + | state.deleteDone = nil | |
| 717 | + | state.mu.Unlock() | |
| 718 | + | if deleteDone != nil { | |
| 719 | + | close(deleteDone) | |
| 720 | + | } | |
| 707 | 721 | return nil | |
| 708 | 722 | }).Maybe() | |
| 709 | 723 | store.EXPECT().Range(mock.Anything).Run(func(fn func(k, v []byte) bool) { | |
M internal/usecase/daemon/snapshot.go +55 -780
| @@ -2,12 +2,11 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | - | "math" | |
| 7 | 7 | "sync" | |
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | - | "github.com/bnema/vev/internal/domain" | |
| 11 | 10 | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 11 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 13 | 12 | "github.com/bnema/vev/pkg/vt" | |
| @@ -22,14 +21,29 @@ const snapshotQueueCapacity = 1 | |||
| 22 | 21 | // (and left dirty) rather than silently dropping a persistence acknowledgement. | |
| 23 | 22 | const snapshotFinalQueueCapacity = 32 | |
| 24 | 23 | ||
| 24 | + | // snapshotAttemptKind distinguishes rate-limited routine publications from | |
| 25 | + | // mandatory terminal checkpoints. Forced attempts never consume the routine | |
| 26 | + | // completion window. | |
| 27 | + | type snapshotAttemptKind uint8 | |
| 28 | + | ||
| 29 | + | const ( | |
| 30 | + | snapshotAttemptRoutine snapshotAttemptKind = iota | |
| 31 | + | snapshotAttemptForced | |
| 32 | + | ) | |
| 33 | + | ||
| 25 | 34 | type snapshotCapture struct { | |
| 26 | - | session *session | |
| 27 | - | generation uint64 | |
| 28 | - | name string | |
| 29 | - | createdAt uint64 | |
| 30 | - | active uint16 | |
| 31 | - | tabs []snapshotCaptureTab | |
| 32 | - | finishOnce sync.Once | |
| 35 | + | session *session | |
| 36 | + | attemptKind snapshotAttemptKind | |
| 37 | + | generation uint64 // repository publication generation | |
| 38 | + | mutationRevision uint64 | |
| 39 | + | name string | |
| 40 | + | createdAt uint64 | |
| 41 | + | active uint16 | |
| 42 | + | tabs []snapshotCaptureTab | |
| 43 | + | publicationContext context.Context | |
| 44 | + | sealedRefs map[*vt.HistoryChunk]snapcodec.ObjectRef // set by the single encoder worker | |
| 45 | + | coordinatorDiscarded bool // guarded by session.snapshotMu | |
| 46 | + | finishOnce sync.Once | |
| 33 | 47 | } | |
| 34 | 48 | ||
| 35 | 49 | type snapshotCaptureTab struct { | |
| @@ -43,779 +57,40 @@ type snapshotCaptureTab struct { | |||
| 43 | 57 | } | |
| 44 | 58 | ||
| 45 | 59 | type snapshotCapturePane struct { | |
| 46 | - | id layout.PaneID | |
| 47 | - | stableID string | |
| 48 | - | cwd string | |
| 49 | - | history vt.HistoryView | |
| 50 | - | visible []byte | |
| 51 | - | visibleErr error | |
| 52 | - | process *snapcodec.Process | |
| 60 | + | id layout.PaneID | |
| 61 | + | stableID string | |
| 62 | + | cwd string | |
| 63 | + | // Sealed identities and the copied mutable tail are retained separately, so | |
| 64 | + | // capture never rotates a live mutable tail merely to persist it. | |
| 65 | + | sealed vt.HistorySnapshotView | |
| 66 | + | tail vt.HistoryView | |
| 67 | + | // visible is an owned copy taken while pane.mu is held. It must be | |
| 68 | + | // marshaled only after that lock has been released. | |
| 69 | + | visible vt.PrimaryVisibleSnapshot | |
| 70 | + | process *snapcodec.Process | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // snapshotFailureSignature classifies a persistence failure without retaining | |
| 74 | + | // error text. Error strings often contain object content or filesystem paths, | |
| 75 | + | // neither of which is stable enough for deduplication or suitable for a global | |
| 76 | + | // notice history. | |
| 77 | + | func snapshotFailureSignature(phase string, err error) string { | |
| 78 | + | if err == nil { | |
| 79 | + | return phase + ":none" | |
| 80 | + | } | |
| 81 | + | switch { | |
| 82 | + | case errors.Is(err, context.Canceled): | |
| 83 | + | return phase + ":canceled" | |
| 84 | + | case errors.Is(err, context.DeadlineExceeded): | |
| 85 | + | return phase + ":deadline" | |
| 86 | + | default: | |
| 87 | + | return fmt.Sprintf("%s:%T", phase, err) | |
| 88 | + | } | |
| 53 | 89 | } | |
| 54 | 90 | ||
| 55 | 91 | const ( | |
| 56 | - | snapshotInterval = 30 * time.Second | |
| 92 | + | // Routine checkpoints are rate limited from completion; forced teardown | |
| 93 | + | // checkpoints use the same bounded worker but never consume this window. | |
| 94 | + | snapshotInterval = 2 * time.Minute | |
| 57 | 95 | snapshotFinalFlushTimeout = time.Second | |
| 58 | 96 | ) | |
| 59 | - | ||
| 60 | - | func (d *Daemon) closeRestoreDone() { | |
| 61 | - | d.restoreOnce.Do(func() { close(d.restoreDone) }) | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | func (d *Daemon) restoreSnapshots(ctx context.Context) { | |
| 65 | - | if d == nil { | |
| 66 | - | return | |
| 67 | - | } | |
| 68 | - | defer d.closeRestoreDone() | |
| 69 | - | if ns := d.noticeStore; ns != nil { | |
| 70 | - | claimed, err := ns.Claim() | |
| 71 | - | if err != nil { | |
| 72 | - | d.log.Warn("claiming pending notices failed", "err", err) | |
| 73 | - | } else { | |
| 74 | - | for _, n := range claimed { | |
| 75 | - | d.notices.record(n) | |
| 76 | - | d.notices.queueGlobal(n) | |
| 77 | - | } | |
| 78 | - | // Do not discard the claim until every notice has been recorded and | |
| 79 | - | // queued. An Ack failure is safe: the claim is replayed at startup. | |
| 80 | - | if err := ns.Ack(); err != nil { | |
| 81 | - | d.log.Warn("acknowledging pending notices failed", "err", err) | |
| 82 | - | } | |
| 83 | - | } | |
| 84 | - | } | |
| 85 | - | if !d.snapsEnabled || d.snaps == nil { | |
| 86 | - | return | |
| 87 | - | } | |
| 88 | - | blobs, err := d.snaps.Load() | |
| 89 | - | if err != nil { | |
| 90 | - | d.log.Warn("loading session snapshots failed", "err", err) | |
| 91 | - | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, | |
| 92 | - | "couldn't load saved sessions after restart", err) | |
| 93 | - | return | |
| 94 | - | } | |
| 95 | - | for _, blob := range blobs { | |
| 96 | - | select { | |
| 97 | - | case <-ctx.Done(): | |
| 98 | - | return | |
| 99 | - | default: | |
| 100 | - | } | |
| 101 | - | snap, err := snapcodec.Unmarshal(blob.Data) | |
| 102 | - | if err != nil { | |
| 103 | - | d.log.Warn("decoding session snapshot failed", "err", err, "session", blob.Name) | |
| 104 | - | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, | |
| 105 | - | "couldn't restore session "+blob.Name+" after restart", err) | |
| 106 | - | continue | |
| 107 | - | } | |
| 108 | - | if err := d.restoreSession(ctx, snap); err != nil { | |
| 109 | - | d.log.Warn("restoring session snapshot failed", "err", err, "session", snap.Name) | |
| 110 | - | if ctx.Err() == nil { | |
| 111 | - | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, | |
| 112 | - | "couldn't restore session "+snap.Name+" after restart", err) | |
| 113 | - | } | |
| 114 | - | } | |
| 115 | - | } | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error { | |
| 119 | - | if err := ctx.Err(); err != nil { | |
| 120 | - | return err | |
| 121 | - | } | |
| 122 | - | if len(snap.Tabs) == 0 { | |
| 123 | - | if snap.Active != 0 { | |
| 124 | - | return fmt.Errorf("snapshot: active tab out of range") | |
| 125 | - | } | |
| 126 | - | } else if int(snap.Active) >= len(snap.Tabs) { | |
| 127 | - | return fmt.Errorf("snapshot: active tab out of range") | |
| 128 | - | } | |
| 129 | - | if snap.Name == "" { | |
| 130 | - | return fmt.Errorf("snapshot: empty session name") | |
| 131 | - | } | |
| 132 | - | if snap.CreatedAt > math.MaxInt64 { | |
| 133 | - | return fmt.Errorf("snapshot: created_at overflows int64") | |
| 134 | - | } | |
| 135 | - | d.mu.Lock() | |
| 136 | - | if d.closing || d.findByNameLocked(snap.Name) != nil { | |
| 137 | - | d.mu.Unlock() | |
| 138 | - | return nil | |
| 139 | - | } | |
| 140 | - | d.mu.Unlock() | |
| 141 | - | ||
| 142 | - | sctx, cancel := context.WithCancel(d.serveCtx) | |
| 143 | - | opened := make([]*tab, 0, len(snap.Tabs)) | |
| 144 | - | // Snapshot restore runs before any client Hello is available. Start panes | |
| 145 | - | // from the daemon environment captured at startup; the next attach replaces | |
| 146 | - | // this snapshot for future panes from the client's capability. | |
| 147 | - | restoreTerm := terminalEnv{} | |
| 148 | - | restoreEnv := copyEnvironment(d.baseEnv) | |
| 149 | - | closeOpened := func() { | |
| 150 | - | for _, tb := range opened { | |
| 151 | - | tb.closeAllPanes() | |
| 152 | - | } | |
| 153 | - | cancel() | |
| 154 | - | } | |
| 155 | - | allowlist := d.restoreProcessAllowlistSnapshot() | |
| 156 | - | d.mu.Lock() | |
| 157 | - | persisted := d.stopped[snap.Name] | |
| 158 | - | d.mu.Unlock() | |
| 159 | - | for tabIndex, tabSnap := range snap.Tabs { | |
| 160 | - | if err := ctx.Err(); err != nil { | |
| 161 | - | closeOpened() | |
| 162 | - | return err | |
| 163 | - | } | |
| 164 | - | tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)} | |
| 165 | - | if !tbSize.Valid() { | |
| 166 | - | closeOpened() | |
| 167 | - | return fmt.Errorf("snapshot: invalid tab size") | |
| 168 | - | } | |
| 169 | - | placements, ok := layout.Solve(tabSnap.Tree.Root, domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows}) | |
| 170 | - | if !ok { | |
| 171 | - | closeOpened() | |
| 172 | - | return fmt.Errorf("snapshot: unsolvable layout") | |
| 173 | - | } | |
| 174 | - | placementByPane := make(map[layout.PaneID]domain.Rect, len(placements)) | |
| 175 | - | for _, pl := range placements { | |
| 176 | - | placementByPane[pl.ID] = pl.Content | |
| 177 | - | } | |
| 178 | - | tabStableID := tabSnap.StableID | |
| 179 | - | if tabStableID == "" { | |
| 180 | - | var err error | |
| 181 | - | tabStableID, err = newStableID("t") | |
| 182 | - | if err != nil { | |
| 183 | - | closeOpened() | |
| 184 | - | return fmt.Errorf("snapshot: generating tab identity: %w", err) | |
| 185 | - | } | |
| 186 | - | } | |
| 187 | - | tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize} | |
| 188 | - | if tabIndex < len(persisted.tabNames) { | |
| 189 | - | tb.name = persisted.tabNames[tabIndex] | |
| 190 | - | } | |
| 191 | - | if tb.nextPaneID <= 0 { | |
| 192 | - | tb.nextPaneID = 1 | |
| 193 | - | } | |
| 194 | - | tb.ctx, tb.cancel = context.WithCancel(sctx) | |
| 195 | - | opened = append(opened, tb) | |
| 196 | - | for _, paneSnap := range tabSnap.Panes { | |
| 197 | - | restoreCommand := "" | |
| 198 | - | if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore { | |
| 199 | - | restoreCommand = decision.Command | |
| 200 | - | } | |
| 201 | - | if err := ctx.Err(); err != nil { | |
| 202 | - | closeOpened() | |
| 203 | - | return err | |
| 204 | - | } | |
| 205 | - | contentRect, ok := placementByPane[paneSnap.ID] | |
| 206 | - | if !ok { | |
| 207 | - | closeOpened() | |
| 208 | - | return fmt.Errorf("snapshot: missing pane placement") | |
| 209 | - | } | |
| 210 | - | contentSize := restorePTYSize(contentRect, tbSize) | |
| 211 | - | paneStableID := paneSnap.StableID | |
| 212 | - | if paneStableID == "" { | |
| 213 | - | var err error | |
| 214 | - | paneStableID, err = newStableID("p") | |
| 215 | - | if err != nil { | |
| 216 | - | closeOpened() | |
| 217 | - | return fmt.Errorf("snapshot: generating pane identity: %w", err) | |
| 218 | - | } | |
| 219 | - | } | |
| 220 | - | command, args := d.ptyCommand(restoreEnv) | |
| 221 | - | pty, err := d.ptys.Open(ctx, command, args, childEnvFrom(restoreEnv, snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize) | |
| 222 | - | if err != nil { | |
| 223 | - | closeOpened() | |
| 224 | - | return err | |
| 225 | - | } | |
| 226 | - | p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize) | |
| 227 | - | p.ctx, p.cancel = context.WithCancel(tb.ctx) | |
| 228 | - | p.rect = contentRect | |
| 229 | - | if err := restorePaneTerminal(p, paneSnap); err != nil { | |
| 230 | - | // p is not in tb.panes yet, so closeOpened cannot reach it. | |
| 231 | - | p.cancel() | |
| 232 | - | _ = pty.Close() | |
| 233 | - | closeOpened() | |
| 234 | - | return err | |
| 235 | - | } | |
| 236 | - | if restoreCommand != "" { | |
| 237 | - | if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil { | |
| 238 | - | d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID) | |
| 239 | - | d.NotifyGlobal(domain.NoticeWarn, domain.NoticeAutoResume, | |
| 240 | - | "couldn't restore the running program in session "+snap.Name, err) | |
| 241 | - | } | |
| 242 | - | } | |
| 243 | - | tb.panes[paneSnap.ID] = p | |
| 244 | - | } | |
| 245 | - | } | |
| 246 | - | if len(opened) == 0 { | |
| 247 | - | cancel() | |
| 248 | - | return nil | |
| 249 | - | } | |
| 250 | - | createdAt := int64(snap.CreatedAt) | |
| 251 | - | sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, env: restoreEnv, createdAt: createdAt} | |
| 252 | - | sess.snapEligible.Store(true) | |
| 253 | - | if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 { | |
| 254 | - | sess.cwd = snap.Tabs[0].Panes[0].Cwd | |
| 255 | - | } | |
| 256 | - | d.mu.Lock() | |
| 257 | - | if stopped, ok := d.stopped[snap.Name]; ok { | |
| 258 | - | sess.mruAt.Store(stopped.lastUsedSeq) | |
| 259 | - | } | |
| 260 | - | d.mu.Unlock() | |
| 261 | - | record := sess.persistRecordLocked(createdAt) | |
| 262 | - | if err := d.persist.Save(record); err != nil { | |
| 263 | - | closeOpened() | |
| 264 | - | return err | |
| 265 | - | } | |
| 266 | - | d.mu.Lock() | |
| 267 | - | if d.closing || d.findByNameLocked(snap.Name) != nil || ctx.Err() != nil { | |
| 268 | - | d.mu.Unlock() | |
| 269 | - | closeOpened() | |
| 270 | - | return nil | |
| 271 | - | } | |
| 272 | - | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |
| 273 | - | d.nextID++ | |
| 274 | - | sess.id = id | |
| 275 | - | delete(d.stopped, snap.Name) | |
| 276 | - | d.sessions[id] = sess | |
| 277 | - | d.mu.Unlock() | |
| 278 | - | sess.snapDirty.Store(false) | |
| 279 | - | for _, tb := range opened { | |
| 280 | - | d.startTabGoroutines(sess, tb) | |
| 281 | - | } | |
| 282 | - | return nil | |
| 283 | - | } | |
| 284 | - | ||
| 285 | - | func markSnapshotDirty(sess *session) { | |
| 286 | - | if sess == nil || !sess.snapEligible.Load() { | |
| 287 | - | return | |
| 288 | - | } | |
| 289 | - | sess.snapshotMu.Lock() | |
| 290 | - | sess.snapshotGeneration++ | |
| 291 | - | sess.snapDirty.Store(true) | |
| 292 | - | sess.signalSnapshotChangedLocked() | |
| 293 | - | sess.snapshotMu.Unlock() | |
| 294 | - | } | |
| 295 | - | ||
| 296 | - | // snapshotChangeLocked returns a channel closed by the next snapshot state | |
| 297 | - | // transition. snapshotMu must be held. | |
| 298 | - | func (sess *session) snapshotChangeLocked() chan struct{} { | |
| 299 | - | if sess.snapshotChanged == nil { | |
| 300 | - | sess.snapshotChanged = make(chan struct{}) | |
| 301 | - | } | |
| 302 | - | return sess.snapshotChanged | |
| 303 | - | } | |
| 304 | - | ||
| 305 | - | // signalSnapshotChangedLocked wakes waiters after a snapshot state transition. | |
| 306 | - | // snapshotMu must be held. | |
| 307 | - | func (sess *session) signalSnapshotChangedLocked() { | |
| 308 | - | close(sess.snapshotChangeLocked()) | |
| 309 | - | sess.snapshotChanged = make(chan struct{}) | |
| 310 | - | } | |
| 311 | - | ||
| 312 | - | func restorePaneTerminal(p *pane, snap snapcodec.Pane) error { | |
| 313 | - | if len(snap.Tail) == 0 { | |
| 314 | - | return fmt.Errorf("snapshot history: missing tail blob") | |
| 315 | - | } | |
| 316 | - | if len(snap.Visible) == 0 { | |
| 317 | - | return fmt.Errorf("snapshot visible: missing visible blob") | |
| 318 | - | } | |
| 319 | - | p.mu.Lock() | |
| 320 | - | defer p.mu.Unlock() | |
| 321 | - | screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, snap.Tail) | |
| 322 | - | if err != nil { | |
| 323 | - | return fmt.Errorf("snapshot history: %w", err) | |
| 324 | - | } | |
| 325 | - | if err := screen.RestorePrimaryVisible(snap.Visible); err != nil { | |
| 326 | - | return fmt.Errorf("snapshot visible: %w", err) | |
| 327 | - | } | |
| 328 | - | p.screen = screen | |
| 329 | - | p.history = screen.History() | |
| 330 | - | return nil | |
| 331 | - | } | |
| 332 | - | ||
| 333 | - | func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size { | |
| 334 | - | cols := contentRect.Width | |
| 335 | - | if cols <= 0 { | |
| 336 | - | cols = tabSize.Cols | |
| 337 | - | } | |
| 338 | - | if cols < layout.MinPaneCols { | |
| 339 | - | cols = layout.MinPaneCols | |
| 340 | - | } | |
| 341 | - | rows := contentRect.Height | |
| 342 | - | if rows <= 0 { | |
| 343 | - | rows = min(max(tabSize.Rows, layout.MinPaneRows), 24) | |
| 344 | - | } | |
| 345 | - | if rows < layout.MinPaneRows { | |
| 346 | - | rows = layout.MinPaneRows | |
| 347 | - | } | |
| 348 | - | return domain.Size{Cols: cols, Rows: rows} | |
| 349 | - | } | |
| 350 | - | ||
| 351 | - | // scheduleSnapshot captures a session only once while an immutable capture is | |
| 352 | - | // queued or being written. It never waits for the bounded worker queue. | |
| 353 | - | func (d *Daemon) scheduleSnapshot(sess *session) bool { | |
| 354 | - | return d.scheduleSnapshotWithFinalFallback(sess, false) | |
| 355 | - | } | |
| 356 | - | ||
| 357 | - | // scheduleFinalSnapshot captures the terminal state even when an older capture | |
| 358 | - | // is pending. A removed session cannot be retried by snapshotSaver, so the | |
| 359 | - | // worker drains its retained terminal state before it stops. | |
| 360 | - | func (d *Daemon) scheduleFinalSnapshot(sess *session) bool { | |
| 361 | - | return d.scheduleSnapshotWithFinalFallback(sess, true) | |
| 362 | - | } | |
| 363 | - | ||
| 364 | - | func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool { | |
| 365 | - | if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil || !sess.snapEligible.Load() { | |
| 366 | - | return true | |
| 367 | - | } | |
| 368 | - | sess.snapshotMu.Lock() | |
| 369 | - | if !sess.snapDirty.Load() || (!final && sess.snapshotPending) { | |
| 370 | - | sess.snapshotMu.Unlock() | |
| 371 | - | return true | |
| 372 | - | } | |
| 373 | - | generation := sess.snapshotGeneration | |
| 374 | - | sess.snapshotPendingCaptures++ | |
| 375 | - | sess.snapshotPending = true | |
| 376 | - | sess.signalSnapshotChangedLocked() | |
| 377 | - | sess.snapshotMu.Unlock() | |
| 378 | - | ||
| 379 | - | capture, ok := d.captureSnapshotState(sess, generation) | |
| 380 | - | if !ok { | |
| 381 | - | d.finishSnapshotCapture(capture, false) | |
| 382 | - | return false | |
| 383 | - | } | |
| 384 | - | if d.enqueueSnapshotCapture(capture) || (final && d.enqueueFinalSnapshotCapture(capture)) { | |
| 385 | - | return true | |
| 386 | - | } | |
| 387 | - | // Coalesce under saturation or shutdown: the latest state stays dirty and | |
| 388 | - | // will be captured on a later tick once an active worker has room. | |
| 389 | - | d.finishSnapshotCapture(capture, false) | |
| 390 | - | return false | |
| 391 | - | } | |
| 392 | - | ||
| 393 | - | // enqueueSnapshotCapture serializes worker shutdown with the non-blocking | |
| 394 | - | // queue send. The queue is deliberately never closed: producers can race | |
| 395 | - | // shutdown without risking a send-on-closed panic. | |
| 396 | - | func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool { | |
| 397 | - | d.snapshotWorkerMu.Lock() | |
| 398 | - | defer d.snapshotWorkerMu.Unlock() | |
| 399 | - | if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil { | |
| 400 | - | return false | |
| 401 | - | } | |
| 402 | - | select { | |
| 403 | - | case d.snapshotJobs <- capture: | |
| 404 | - | return true | |
| 405 | - | default: | |
| 406 | - | return false | |
| 407 | - | } | |
| 408 | - | } | |
| 409 | - | ||
| 410 | - | // enqueueFinalSnapshotCapture hands terminal captures to the existing worker | |
| 411 | - | // without making session teardown wait behind its normal bounded queue. A | |
| 412 | - | // blocked worker retains at most one terminal capture per session, replacing | |
| 413 | - | // stale state with the newest immutable capture. | |
| 414 | - | func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool { | |
| 415 | - | if capture == nil || capture.session == nil { | |
| 416 | - | return false | |
| 417 | - | } | |
| 418 | - | d.snapshotWorkerMu.Lock() | |
| 419 | - | if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil { | |
| 420 | - | d.snapshotWorkerMu.Unlock() | |
| 421 | - | return false | |
| 422 | - | } | |
| 423 | - | if d.snapshotFinalJobs == nil { | |
| 424 | - | d.snapshotFinalJobs = make(map[*session]*snapshotCapture) | |
| 425 | - | } | |
| 426 | - | replaced, exists := d.snapshotFinalJobs[capture.session] | |
| 427 | - | if !exists && len(d.snapshotFinalJobs) >= snapshotFinalQueueCapacity { | |
| 428 | - | d.snapshotWorkerMu.Unlock() | |
| 429 | - | d.log.Warn("terminal snapshot retention saturated; capture rejected", "session", capture.name, "capacity", snapshotFinalQueueCapacity) | |
| 430 | - | return false | |
| 431 | - | } | |
| 432 | - | // Finish the stale capture before publishing its replacement. Otherwise a | |
| 433 | - | // fast worker could persist the replacement and then have this stale failure | |
| 434 | - | // mark the session dirty again. | |
| 435 | - | d.finishSnapshotCapture(replaced, false) | |
| 436 | - | if !exists { | |
| 437 | - | d.snapshotFinalOrder = append(d.snapshotFinalOrder, capture.session) | |
| 438 | - | } | |
| 439 | - | d.snapshotFinalJobs[capture.session] = capture | |
| 440 | - | select { | |
| 441 | - | case d.snapshotWorkerFinalWake <- struct{}{}: | |
| 442 | - | default: | |
| 443 | - | } | |
| 444 | - | d.snapshotWorkerMu.Unlock() | |
| 445 | - | return true | |
| 446 | - | } | |
| 447 | - | ||
| 448 | - | // captureSession rotates history tails and clones visible frames while holding | |
| 449 | - | // each pane lock. The returned capture contains only immutable state; encoding | |
| 450 | - | // and persistence are deliberately deferred to snapshotEncodeWorker. | |
| 451 | - | func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) { | |
| 452 | - | sess.mu.Lock() | |
| 453 | - | capture := &snapshotCapture{ | |
| 454 | - | session: sess, | |
| 455 | - | generation: generation, | |
| 456 | - | name: sess.name, | |
| 457 | - | createdAt: uint64(sess.createdAt), | |
| 458 | - | active: uint16(max(sess.active, 0)), | |
| 459 | - | } | |
| 460 | - | ephemeral := sess.ephemeral | |
| 461 | - | fallbackCwd := sess.cwd | |
| 462 | - | tabs := append([]*tab(nil), sess.tabs...) | |
| 463 | - | sess.mu.Unlock() | |
| 464 | - | if ephemeral || capture.name == "" { | |
| 465 | - | return capture, false | |
| 466 | - | } | |
| 467 | - | ||
| 468 | - | capture.tabs = make([]snapshotCaptureTab, 0, len(tabs)) | |
| 469 | - | for _, tb := range tabs { | |
| 470 | - | tb.mu.Lock() | |
| 471 | - | tabCapture := snapshotCaptureTab{ | |
| 472 | - | stableID: tb.stableID, | |
| 473 | - | cols: uint16(max(tb.size.Cols, 0)), | |
| 474 | - | rows: uint16(max(tb.size.Rows, 0)), | |
| 475 | - | nextPaneID: uint64(max(tb.nextPaneID, 0)), | |
| 476 | - | tree: tb.tree.Clone(), | |
| 477 | - | } | |
| 478 | - | if tb.tree != nil { | |
| 479 | - | tabCapture.focus = tb.tree.Focus | |
| 480 | - | } | |
| 481 | - | panes := make([]*pane, 0, len(tb.panes)) | |
| 482 | - | for _, p := range tb.panes { | |
| 483 | - | panes = append(panes, p) | |
| 484 | - | } | |
| 485 | - | tb.mu.Unlock() | |
| 486 | - | ||
| 487 | - | tabCapture.panes = make([]snapshotCapturePane, 0, len(panes)) | |
| 488 | - | for _, p := range panes { | |
| 489 | - | p.mu.Lock() | |
| 490 | - | pty := p.pty | |
| 491 | - | pid := 0 | |
| 492 | - | if pty != nil { | |
| 493 | - | pid = pty.Pid() | |
| 494 | - | } | |
| 495 | - | visible, visibleErr := p.screen.MarshalPrimaryVisible() | |
| 496 | - | paneCapture := snapshotCapturePane{ | |
| 497 | - | id: p.id, | |
| 498 | - | stableID: p.stableID, | |
| 499 | - | history: p.history.SealAndView(), | |
| 500 | - | visible: visible, | |
| 501 | - | visibleErr: visibleErr, | |
| 502 | - | } | |
| 503 | - | p.mu.Unlock() | |
| 504 | - | paneCapture.cwd = fallbackCwd | |
| 505 | - | if d.procCwd != nil && pid > 0 { | |
| 506 | - | if cwd, err := d.procCwd(pid); err == nil && cwd != "" { | |
| 507 | - | paneCapture.cwd = cwd | |
| 508 | - | } | |
| 509 | - | } | |
| 510 | - | paneCapture.process = d.capturePaneProcess(pty, pid) | |
| 511 | - | tabCapture.panes = append(tabCapture.panes, paneCapture) | |
| 512 | - | } | |
| 513 | - | capture.tabs = append(capture.tabs, tabCapture) | |
| 514 | - | } | |
| 515 | - | return capture, true | |
| 516 | - | } | |
| 517 | - | ||
| 518 | - | // captureSession remains the synchronous producer-facing trigger for callers | |
| 519 | - | // such as teardown and benchmarks; the actual encoding and Write stay async. | |
| 520 | - | func (d *Daemon) captureSession(sess *session) bool { | |
| 521 | - | markSnapshotDirty(sess) | |
| 522 | - | return d.scheduleSnapshot(sess) | |
| 523 | - | } | |
| 524 | - | ||
| 525 | - | func (d *Daemon) encodeSnapshotCapture(capture *snapshotCapture) ([]byte, error) { | |
| 526 | - | snap := snapcodec.Session{Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.Tab, 0, len(capture.tabs))} | |
| 527 | - | for _, tabCapture := range capture.tabs { | |
| 528 | - | tabSnap := snapcodec.Tab{ | |
| 529 | - | StableID: tabCapture.stableID, Cols: tabCapture.cols, Rows: tabCapture.rows, | |
| 530 | - | NextPaneID: tabCapture.nextPaneID, Focus: tabCapture.focus, Tree: tabCapture.tree, | |
| 531 | - | Panes: make([]snapcodec.Pane, 0, len(tabCapture.panes)), | |
| 532 | - | } | |
| 533 | - | paneIDs := make(map[layout.PaneID]struct{}, len(tabCapture.panes)) | |
| 534 | - | for _, paneCapture := range tabCapture.panes { | |
| 535 | - | sealed, tail, historyErr := vt.MarshalSealedHistory(paneCapture.history) | |
| 536 | - | if historyErr != nil || paneCapture.visibleErr != nil { | |
| 537 | - | return nil, fmt.Errorf("terminal snapshot pane %s: history=%w visible=%w", paneCapture.id, historyErr, paneCapture.visibleErr) | |
| 538 | - | } | |
| 539 | - | tabSnap.Panes = append(tabSnap.Panes, snapcodec.Pane{ID: paneCapture.id, StableID: paneCapture.stableID, Cwd: paneCapture.cwd, SealedChunks: sealed, Tail: tail, Visible: paneCapture.visible, Process: paneCapture.process}) | |
| 540 | - | paneIDs[paneCapture.id] = struct{}{} | |
| 541 | - | } | |
| 542 | - | if tabSnap.Tree != nil { | |
| 543 | - | pruneTreeToPanes(tabSnap.Tree.Root, paneIDs) | |
| 544 | - | } | |
| 545 | - | snap.Tabs = append(snap.Tabs, tabSnap) | |
| 546 | - | } | |
| 547 | - | return d.snapshotMarshal(snap) | |
| 548 | - | } | |
| 549 | - | ||
| 550 | - | func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool) { | |
| 551 | - | if capture == nil { | |
| 552 | - | return | |
| 553 | - | } | |
| 554 | - | capture.finishOnce.Do(func() { | |
| 555 | - | capture.session.snapshotMu.Lock() | |
| 556 | - | if capture.session.snapshotPendingCaptures > 0 { | |
| 557 | - | capture.session.snapshotPendingCaptures-- | |
| 558 | - | } | |
| 559 | - | capture.session.snapshotPending = capture.session.snapshotPendingCaptures > 0 | |
| 560 | - | if succeeded && capture.session.snapshotGeneration == capture.generation { | |
| 561 | - | capture.session.snapDirty.Store(false) | |
| 562 | - | } else if !succeeded { | |
| 563 | - | capture.session.snapDirty.Store(true) | |
| 564 | - | } | |
| 565 | - | capture.session.signalSnapshotChangedLocked() | |
| 566 | - | capture.session.snapshotMu.Unlock() | |
| 567 | - | }) | |
| 568 | - | } | |
| 569 | - | ||
| 570 | - | func (d *Daemon) startSnapshotEncodeWorker() { | |
| 571 | - | if d == nil { | |
| 572 | - | return | |
| 573 | - | } | |
| 574 | - | d.snapshotWorkerMu.Lock() | |
| 575 | - | if d.snapshotWorkerCancel != nil { | |
| 576 | - | d.snapshotWorkerMu.Unlock() | |
| 577 | - | return | |
| 578 | - | } | |
| 579 | - | // Shutdown owns the worker lifetime so it can flush captures after Serve | |
| 580 | - | // cancels its parent context. The worker is always stopped explicitly. | |
| 581 | - | workerCtx, cancel := context.WithCancel(context.Background()) | |
| 582 | - | d.snapshotWorkerID++ | |
| 583 | - | workerID := d.snapshotWorkerID | |
| 584 | - | d.snapshotWorkerCtx = workerCtx | |
| 585 | - | d.snapshotWorkerCancel = cancel | |
| 586 | - | d.snapshotWorkerDone = make(chan struct{}) | |
| 587 | - | d.snapshotWorkerFlush = make(chan struct{}) | |
| 588 | - | d.snapshotWorkerFinalWake = make(chan struct{}, 1) | |
| 589 | - | d.snapshotFinalJobs = make(map[*session]*snapshotCapture) | |
| 590 | - | d.snapshotFinalOrder = nil | |
| 591 | - | d.snapshotWorkerClosing = false | |
| 592 | - | d.snapshotWorkerInFlight = nil | |
| 593 | - | done := d.snapshotWorkerDone | |
| 594 | - | flush := d.snapshotWorkerFlush | |
| 595 | - | finalWake := d.snapshotWorkerFinalWake | |
| 596 | - | d.snapshotWorkerMu.Unlock() | |
| 597 | - | go func() { | |
| 598 | - | defer close(done) | |
| 599 | - | write := func(capture *snapshotCapture) bool { | |
| 600 | - | if workerCtx.Err() != nil || !d.setSnapshotWorkerInFlight(workerID, capture) { | |
| 601 | - | d.finishSnapshotCapture(capture, false) | |
| 602 | - | return false | |
| 603 | - | } | |
| 604 | - | data, err := d.encodeSnapshotCapture(capture) | |
| 605 | - | if err == nil && workerCtx.Err() == nil { | |
| 606 | - | err = d.snaps.Write(capture.name, data) | |
| 607 | - | } | |
| 608 | - | d.clearSnapshotWorkerInFlight(workerID, capture) | |
| 609 | - | if err != nil && workerCtx.Err() == nil { | |
| 610 | - | d.log.Warn("writing session snapshot failed", "err", err, "session", capture.name) | |
| 611 | - | // Global, not session-scoped: by now the session may already be | |
| 612 | - | // torn down, so a session notice would be dead-on-arrival. No lock | |
| 613 | - | // is held here (clearSnapshotWorkerInFlight released its own). | |
| 614 | - | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, | |
| 615 | - | "couldn't save session "+capture.name+"; recent state may be lost on restart", err) | |
| 616 | - | } | |
| 617 | - | d.finishSnapshotCapture(capture, err == nil && workerCtx.Err() == nil) | |
| 618 | - | return workerCtx.Err() == nil | |
| 619 | - | } | |
| 620 | - | drainFinal := func() bool { | |
| 621 | - | for { | |
| 622 | - | capture := d.takeFinalSnapshotCapture() | |
| 623 | - | if capture == nil { | |
| 624 | - | return true | |
| 625 | - | } | |
| 626 | - | if !write(capture) { | |
| 627 | - | return false | |
| 628 | - | } | |
| 629 | - | } | |
| 630 | - | } | |
| 631 | - | flushQueued := func() { | |
| 632 | - | for { | |
| 633 | - | select { | |
| 634 | - | case capture := <-d.snapshotJobs: | |
| 635 | - | if !write(capture) { | |
| 636 | - | return | |
| 637 | - | } | |
| 638 | - | default: | |
| 639 | - | _ = drainFinal() | |
| 640 | - | return | |
| 641 | - | } | |
| 642 | - | } | |
| 643 | - | } | |
| 644 | - | for { | |
| 645 | - | select { | |
| 646 | - | case <-workerCtx.Done(): | |
| 647 | - | return | |
| 648 | - | case <-flush: | |
| 649 | - | flushQueued() | |
| 650 | - | return | |
| 651 | - | case <-finalWake: | |
| 652 | - | if !drainFinal() { | |
| 653 | - | return | |
| 654 | - | } | |
| 655 | - | case capture := <-d.snapshotJobs: | |
| 656 | - | if !write(capture) { | |
| 657 | - | return | |
| 658 | - | } | |
| 659 | - | } | |
| 660 | - | } | |
| 661 | - | }() | |
| 662 | - | } | |
| 663 | - | ||
| 664 | - | func (d *Daemon) takeFinalSnapshotCapture() *snapshotCapture { | |
| 665 | - | d.snapshotWorkerMu.Lock() | |
| 666 | - | defer d.snapshotWorkerMu.Unlock() | |
| 667 | - | for len(d.snapshotFinalOrder) > 0 { | |
| 668 | - | sess := d.snapshotFinalOrder[0] | |
| 669 | - | d.snapshotFinalOrder[0] = nil | |
| 670 | - | d.snapshotFinalOrder = d.snapshotFinalOrder[1:] | |
| 671 | - | capture := d.snapshotFinalJobs[sess] | |
| 672 | - | delete(d.snapshotFinalJobs, sess) | |
| 673 | - | if capture != nil { | |
| 674 | - | return capture | |
| 675 | - | } | |
| 676 | - | } | |
| 677 | - | return nil | |
| 678 | - | } | |
| 679 | - | ||
| 680 | - | func (d *Daemon) setSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) bool { | |
| 681 | - | d.snapshotWorkerMu.Lock() | |
| 682 | - | defer d.snapshotWorkerMu.Unlock() | |
| 683 | - | if d.snapshotWorkerID != workerID || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil { | |
| 684 | - | return false | |
| 685 | - | } | |
| 686 | - | d.snapshotWorkerInFlight = capture | |
| 687 | - | return true | |
| 688 | - | } | |
| 689 | - | ||
| 690 | - | func (d *Daemon) clearSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) { | |
| 691 | - | d.snapshotWorkerMu.Lock() | |
| 692 | - | defer d.snapshotWorkerMu.Unlock() | |
| 693 | - | if d.snapshotWorkerID == workerID && d.snapshotWorkerInFlight == capture { | |
| 694 | - | d.snapshotWorkerInFlight = nil | |
| 695 | - | } | |
| 696 | - | } | |
| 697 | - | ||
| 698 | - | func (d *Daemon) stopSnapshotEncodeWorker() { | |
| 699 | - | if d == nil { | |
| 700 | - | return | |
| 701 | - | } | |
| 702 | - | d.snapshotWorkerMu.Lock() | |
| 703 | - | cancel := d.snapshotWorkerCancel | |
| 704 | - | if cancel == nil || d.snapshotWorkerClosing { | |
| 705 | - | d.snapshotWorkerMu.Unlock() | |
| 706 | - | return | |
| 707 | - | } | |
| 708 | - | // Stop accepting producers, then let the single worker drain its bounded | |
| 709 | - | // queue. A non-context-aware store may still block indefinitely, so the | |
| 710 | - | // injected clock bounds how long teardown waits before detaching it. | |
| 711 | - | d.snapshotWorkerClosing = true | |
| 712 | - | flush := d.snapshotWorkerFlush | |
| 713 | - | done := d.snapshotWorkerDone | |
| 714 | - | close(flush) | |
| 715 | - | timer := d.clock.NewTimer(snapshotFinalFlushTimeout) | |
| 716 | - | d.snapshotWorkerMu.Unlock() | |
| 717 | - | ||
| 718 | - | select { | |
| 719 | - | case <-done: | |
| 720 | - | timer.Stop() | |
| 721 | - | d.finishStoppedSnapshotWorker(false) | |
| 722 | - | case <-timer.C(): | |
| 723 | - | cancel() | |
| 724 | - | d.finishStoppedSnapshotWorker(true) | |
| 725 | - | } | |
| 726 | - | } | |
| 727 | - | ||
| 728 | - | func (d *Daemon) finishStoppedSnapshotWorker(abandoned bool) { | |
| 729 | - | d.snapshotWorkerMu.Lock() | |
| 730 | - | inFlight := d.snapshotWorkerInFlight | |
| 731 | - | d.snapshotWorkerCtx = nil | |
| 732 | - | d.snapshotWorkerCancel = nil | |
| 733 | - | d.snapshotWorkerDone = nil | |
| 734 | - | d.snapshotWorkerFlush = nil | |
| 735 | - | d.snapshotWorkerFinalWake = nil | |
| 736 | - | d.snapshotWorkerClosing = false | |
| 737 | - | d.snapshotWorkerInFlight = nil | |
| 738 | - | queued := make([]*snapshotCapture, 0, len(d.snapshotJobs)+len(d.snapshotFinalJobs)) | |
| 739 | - | for _, capture := range d.snapshotFinalJobs { | |
| 740 | - | queued = append(queued, capture) | |
| 741 | - | } | |
| 742 | - | d.snapshotFinalJobs = nil | |
| 743 | - | d.snapshotFinalOrder = nil | |
| 744 | - | for { | |
| 745 | - | select { | |
| 746 | - | case capture := <-d.snapshotJobs: | |
| 747 | - | queued = append(queued, capture) | |
| 748 | - | default: | |
| 749 | - | d.snapshotWorkerMu.Unlock() | |
| 750 | - | if abandoned { | |
| 751 | - | d.finishSnapshotCapture(inFlight, false) | |
| 752 | - | } | |
| 753 | - | for _, capture := range queued { | |
| 754 | - | d.finishSnapshotCapture(capture, false) | |
| 755 | - | } | |
| 756 | - | return | |
| 757 | - | } | |
| 758 | - | } | |
| 759 | - | } | |
| 760 | - | ||
| 761 | - | func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process { | |
| 762 | - | if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil { | |
| 763 | - | return nil | |
| 764 | - | } | |
| 765 | - | pgid, err := pty.ForegroundPgid() | |
| 766 | - | if err != nil || pgid <= 0 || pgid == shellPid { | |
| 767 | - | return nil | |
| 768 | - | } | |
| 769 | - | argv, err := d.procGroupArgv(pgid, shellPid) | |
| 770 | - | if err != nil || len(argv) == 0 || argv[0] == "" { | |
| 771 | - | return nil | |
| 772 | - | } | |
| 773 | - | strategy := detectProcessStrategy(argv) | |
| 774 | - | return &snapcodec.Process{ | |
| 775 | - | Argv: append([]string(nil), argv...), | |
| 776 | - | Strategy: strategy, | |
| 777 | - | Opts: snapcodec.ProcessOpts{ | |
| 778 | - | AgentSessionID: extractAgentSessionID(strategy, argv), | |
| 779 | - | }, | |
| 780 | - | } | |
| 781 | - | } | |
| 782 | - | ||
| 783 | - | func (d *Daemon) snapshotSaver(ctx context.Context) { | |
| 784 | - | t := d.clock.NewTimer(snapshotInterval) | |
| 785 | - | defer t.Stop() | |
| 786 | - | for { | |
| 787 | - | select { | |
| 788 | - | case <-ctx.Done(): | |
| 789 | - | return | |
| 790 | - | case <-t.C(): | |
| 791 | - | } | |
| 792 | - | ||
| 793 | - | d.mu.Lock() | |
| 794 | - | sessions := d.sessionsSnapshotLocked() | |
| 795 | - | d.mu.Unlock() | |
| 796 | - | for _, sess := range sessions { | |
| 797 | - | if sess.snapEligible.Load() && sess.snapDirty.Load() { | |
| 798 | - | d.scheduleSnapshot(sess) | |
| 799 | - | } | |
| 800 | - | } | |
| 801 | - | t.Reset(snapshotInterval) | |
| 802 | - | } | |
| 803 | - | } | |
| 804 | - | ||
| 805 | - | func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool { | |
| 806 | - | if n == nil { | |
| 807 | - | return false | |
| 808 | - | } | |
| 809 | - | if n.Kind == layout.Leaf { | |
| 810 | - | _, ok := panes[n.Leaf] | |
| 811 | - | return ok | |
| 812 | - | } | |
| 813 | - | kept := n.Children[:0] | |
| 814 | - | for _, child := range n.Children { | |
| 815 | - | if pruneTreeToPanes(child, panes) { | |
| 816 | - | kept = append(kept, child) | |
| 817 | - | } | |
| 818 | - | } | |
| 819 | - | n.Children = kept | |
| 820 | - | return len(n.Children) > 0 | |
| 821 | - | } | |
M internal/usecase/daemon/snapshot_acceptance_test.go +59 -226
| @@ -1,249 +1,82 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/stretchr/testify/require" | |
| 7 | 8 | ||
| 8 | - | "github.com/bnema/vev/internal/domain" | |
| 9 | - | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 10 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 11 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 13 | - | "github.com/bnema/vev/internal/usecase/visualsearch" | |
| 14 | 9 | "github.com/bnema/vev/pkg/renderer" | |
| 15 | 10 | "github.com/bnema/vev/pkg/vt" | |
| 16 | 11 | ) | |
| 17 | 12 | ||
| 18 | - | func TestSnapshotCaptureMarshalUnmarshalRestorePreservesTopologyAndTerminalState(t *testing.T) { | |
| 19 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |
| 20 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 21 | - | WithSnapshotStore(store)(d) | |
| 22 | - | startSnapshotEncodeWorker(t, d) | |
| 23 | - | ||
| 24 | - | sess := newSnapshotTestSession(t, "acceptance", false, "/work") | |
| 25 | - | first := sess.tabs[0] | |
| 26 | - | first.size = domain.Size{Cols: 80, Rows: 24} | |
| 27 | - | first.stableID = "tab-one" | |
| 28 | - | first.nextPaneID = 3 | |
| 29 | - | first.panes["pane-1"].stableID = "pane-one" | |
| 30 | - | secondPane := newPaneWithStableID("pane-2", "pane-two", snapshotAcceptancePTY(t), domain.Size{Cols: 40, Rows: 24}) | |
| 31 | - | secondPane.history.Append([]renderer.Cell{{Rune: 't'}, {Rune: 'w'}, {Rune: 'o'}}) | |
| 32 | - | secondPane.screen.Write([]byte("two")) | |
| 33 | - | first.panes["pane-2"] = secondPane | |
| 34 | - | first.tree = &layout.Tree{Focus: "pane-2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |
| 35 | - | layout.NewLeaf("pane-1"), | |
| 36 | - | layout.NewLeaf("pane-2"), | |
| 37 | - | }}} | |
| 38 | - | ||
| 39 | - | second := newTab(snapshotAcceptancePTY(t), domain.Size{Cols: 90, Rows: 30}) | |
| 40 | - | second.stableID = "tab-two" | |
| 41 | - | second.nextPaneID = 2 | |
| 42 | - | second.panes["pane-1"].stableID = "pane-three" | |
| 43 | - | second.panes["pane-1"].history.Append([]renderer.Cell{{Rune: 't'}, {Rune: 'h'}, {Rune: 'r'}, {Rune: 'e'}, {Rune: 'e'}}) | |
| 44 | - | second.panes["pane-1"].screen.Write([]byte("three")) | |
| 45 | - | sess.tabs = []*tab{first, second} | |
| 46 | - | sess.active = 1 | |
| 47 | - | ||
| 48 | - | require.True(t, d.captureSession(sess)) | |
| 49 | - | encoded := <-store.writes | |
| 50 | - | captured, err := snapcodec.Unmarshal(encoded) | |
| 13 | + | func TestSnapshotChunkCacheIsScopedToNamedSession(t *testing.T) { | |
| 14 | + | d := New(nil, nil, nil) | |
| 15 | + | a := newSnapshotTestSession(t, "a", false, "/a") | |
| 16 | + | b := newSnapshotTestSession(t, "b", false, "/b") | |
| 17 | + | a.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit) | |
| 18 | + | b.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit) | |
| 19 | + | ||
| 20 | + | // Eleven maximum-size chunks exceed the per-session 16 MiB cache budget. | |
| 21 | + | // The cache keeps its warmed working set rather than admitting every | |
| 22 | + | // oldest-to-newest scan miss. | |
| 23 | + | aHistory := snapshotCacheHistory(t, 11, 'a') | |
| 24 | + | aCapture := snapshotCacheCapture(a, aHistory, 1) | |
| 25 | + | aFirst, err := d.incrementalPublication(aCapture) | |
| 51 | 26 | require.NoError(t, err) | |
| 52 | - | assertCapturedSessionTopology(t, captured) | |
| 53 | - | ||
| 54 | - | restoreFactory := &restorePTYFactory{} | |
| 55 | - | restoredDaemon := newTestDaemon(t, restoreFactory, stubClock{}) | |
| 56 | - | require.NoError(t, restoredDaemon.restoreSession(t.Context(), captured)) | |
| 57 | - | restoredDaemon.mu.Lock() | |
| 58 | - | restored := restoredDaemon.findByNameLocked("acceptance") | |
| 59 | - | restoredDaemon.mu.Unlock() | |
| 60 | - | require.NotNil(t, restored) | |
| 61 | - | require.Equal(t, 1, restored.active) | |
| 62 | - | require.Len(t, restored.tabs, 2) | |
| 63 | - | ||
| 64 | - | for _, tt := range []struct { | |
| 65 | - | name string | |
| 66 | - | tab int | |
| 67 | - | pane layout.PaneID | |
| 68 | - | wantRow string | |
| 69 | - | wantFocus layout.PaneID | |
| 70 | - | }{ | |
| 71 | - | {name: "first tab focused second pane", tab: 0, pane: "pane-2", wantRow: "two", wantFocus: "pane-2"}, | |
| 72 | - | {name: "second tab focused only pane", tab: 1, pane: "pane-1", wantRow: "three", wantFocus: "pane-1"}, | |
| 73 | - | } { | |
| 74 | - | t.Run(tt.name, func(t *testing.T) { | |
| 75 | - | tab := restored.tabs[tt.tab] | |
| 76 | - | tab.mu.Lock() | |
| 77 | - | defer tab.mu.Unlock() | |
| 78 | - | require.Equal(t, tt.wantFocus, tab.tree.Focus) | |
| 79 | - | pane := tab.panes[tt.pane] | |
| 80 | - | require.NotNil(t, pane) | |
| 81 | - | pane.mu.Lock() | |
| 82 | - | defer pane.mu.Unlock() | |
| 83 | - | require.Equal(t, tt.wantRow, rowText(pane.history.View().Row(0))) | |
| 84 | - | }) | |
| 85 | - | } | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | func TestSnapshotCaptureRetainsImmutableValuesAfterTailMutationAndEviction(t *testing.T) { | |
| 89 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |
| 90 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 91 | - | WithSnapshotStore(store)(d) | |
| 92 | - | startSnapshotEncodeWorker(t, d) | |
| 93 | - | ||
| 94 | - | entered := make(chan struct{}) | |
| 95 | - | release := make(chan struct{}) | |
| 96 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 97 | - | close(entered) | |
| 98 | - | <-release | |
| 99 | - | return snapcodec.Marshal(s) | |
| 100 | - | } | |
| 101 | - | sess := newSnapshotTestSession(t, "frozen", false, "/work") | |
| 102 | - | pane := sess.tabs[0].panes["pane-1"] | |
| 103 | - | pane.screen = vt.NewScreenWithHistory(8, 3, vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |
| 104 | - | pane.history = pane.screen.History() | |
| 105 | - | pane.history.Append([]renderer.Cell{{Rune: 'o'}, {Rune: 'l'}, {Rune: 'd'}}) | |
| 106 | - | pane.screen.Write([]byte("before")) | |
| 107 | - | ||
| 108 | - | require.True(t, d.captureSession(sess)) | |
| 109 | - | <-entered | |
| 110 | - | pane.mu.Lock() | |
| 111 | - | pane.history.Append([]renderer.Cell{{Rune: 'n'}, {Rune: 'e'}, {Rune: 'w'}}) | |
| 112 | - | pane.history.Append([]renderer.Cell{{Rune: 'e'}, {Rune: 'v'}, {Rune: 'i'}, {Rune: 'c'}, {Rune: 't'}}) | |
| 113 | - | pane.screen.Write([]byte("\rafter")) | |
| 114 | - | pane.mu.Unlock() | |
| 115 | - | close(release) | |
| 116 | - | ||
| 117 | - | captured, err := snapcodec.Unmarshal(<-store.writes) | |
| 27 | + | require.NotEmpty(t, aFirst.Objects) | |
| 28 | + | require.LessOrEqual(t, a.snapshotChunkCache.used, a.snapshotChunkCache.limit) | |
| 29 | + | require.LessOrEqual(t, a.snapshotChunkCache.limit, snapshotChunkCacheLimit) | |
| 30 | + | require.Contains(t, a.snapshotChunkCache.byPtr, aHistory.Chunk(0), "session A should retain its warmed entry") | |
| 31 | + | ||
| 32 | + | bHistory := snapshotCacheHistory(t, 1, 'b') | |
| 33 | + | bCapture := snapshotCacheCapture(b, bHistory, 1) | |
| 34 | + | _, err = d.incrementalPublication(bCapture) | |
| 118 | 35 | require.NoError(t, err) | |
| 119 | - | restoredHistory, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}, captured.Tabs[0].Panes[0].SealedChunks, captured.Tabs[0].Panes[0].Tail) | |
| 120 | - | require.NoError(t, err) | |
| 121 | - | require.Equal(t, "old", rowText(restoredHistory.View().Row(0))) | |
| 122 | - | frame, err := vt.UnmarshalVisible(captured.Tabs[0].Panes[0].Visible) | |
| 123 | - | require.NoError(t, err) | |
| 124 | - | require.Contains(t, rowText(frame.Row(0)), "before") | |
| 125 | - | } | |
| 36 | + | require.Contains(t, b.snapshotChunkCache.byPtr, bHistory.Chunk(0)) | |
| 126 | 37 | ||
| 127 | - | func TestDaemonHistoryEvictionLeavesCopyAndSearchSnapshotsUsable(t *testing.T) { | |
| 128 | - | sess := newSnapshotTestSession(t, "copy-lifetime", false, "/work") | |
| 129 | - | pane := sess.tabs[0].panes["pane-1"] | |
| 130 | - | pane.screen = vt.NewScreenWithHistory(8, 1, vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |
| 131 | - | pane.history = pane.screen.History() | |
| 132 | - | for _, text := range []string{"first", "second"} { | |
| 133 | - | pane.history.Append(cells(text)) | |
| 134 | - | } | |
| 135 | - | pane.screen.Write([]byte("screen")) | |
| 136 | - | ||
| 137 | - | document := scopy.NewSnapshot(pane.history, pane.screen.Frame) | |
| 138 | - | for _, text := range []string{"third", "fourth"} { | |
| 139 | - | pane.history.Append(cells(text)) | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | for _, tt := range []struct { | |
| 143 | - | name string | |
| 144 | - | rows func() [][]renderer.Cell | |
| 145 | - | want []string | |
| 146 | - | }{ | |
| 147 | - | {name: "bounded live history is oldest first after eviction", rows: func() [][]renderer.Cell { | |
| 148 | - | view := pane.history.View() | |
| 149 | - | rows := make([][]renderer.Cell, view.Len()) | |
| 150 | - | for i := range rows { | |
| 151 | - | rows[i] = view.Row(i) | |
| 152 | - | } | |
| 153 | - | return rows | |
| 154 | - | }, want: []string{"third", "fourth"}}, | |
| 155 | - | {name: "captured document retains evicted rows", rows: func() [][]renderer.Cell { | |
| 156 | - | rows := make([][]renderer.Cell, document.Len()-document.Height) | |
| 157 | - | for i := range rows { | |
| 158 | - | rows[i] = document.Row(i) | |
| 159 | - | } | |
| 160 | - | return rows | |
| 161 | - | }, want: []string{"first", "second"}}, | |
| 162 | - | } { | |
| 163 | - | t.Run(tt.name, func(t *testing.T) { | |
| 164 | - | rows := tt.rows() | |
| 165 | - | got := make([]string, len(rows)) | |
| 166 | - | for i, row := range rows { | |
| 167 | - | got[i] = rowText(row) | |
| 168 | - | } | |
| 169 | - | require.Equal(t, tt.want, got) | |
| 170 | - | }) | |
| 171 | - | } | |
| 172 | - | ||
| 173 | - | search := visualsearch.New(document) | |
| 174 | - | for _, r := range "first" { | |
| 175 | - | search.Insert(r) | |
| 176 | - | } | |
| 177 | - | match, ok := search.Selected() | |
| 178 | - | require.True(t, ok) | |
| 179 | - | require.Equal(t, "first", match.Text) | |
| 180 | - | } | |
| 38 | + | inFlightBytes := append([]byte(nil), aFirst.Objects[0].Data...) | |
| 39 | + | aSecond := snapshotCacheCapture(a, snapshotCacheHistory(t, 1, 'z'), 2) | |
| 40 | + | _, err = d.incrementalPublication(aSecond) | |
| 41 | + | require.NoError(t, err) | |
| 42 | + | require.True(t, bytes.Equal(inFlightBytes, aFirst.Objects[0].Data), "cache pruning must not mutate queued publication bytes") | |
| 43 | + | require.LessOrEqual(t, a.snapshotChunkCache.used, snapshotChunkCacheLimit) | |
| 181 | 44 | ||
| 182 | - | func snapshotAcceptancePTY(t *testing.T) *portsmocks.MockPTY { | |
| 183 | - | t.Helper() | |
| 184 | - | pty := portsmocks.NewMockPTY(t) | |
| 185 | - | pty.EXPECT().Pid().Return(0).Maybe() | |
| 186 | - | pty.EXPECT().ForegroundPgid().Return(0, nil).Maybe() | |
| 187 | - | return pty | |
| 45 | + | _, err = d.incrementalPublication(snapshotCacheCapture(b, bHistory, 2)) | |
| 46 | + | require.NoError(t, err) | |
| 47 | + | require.Contains(t, b.snapshotChunkCache.byPtr, bHistory.Chunk(0), "session A eviction must not affect session B") | |
| 48 | + | require.LessOrEqual(t, b.snapshotChunkCache.used, snapshotChunkCacheLimit) | |
| 188 | 49 | } | |
| 189 | 50 | ||
| 190 | - | func assertCapturedSessionTopology(t *testing.T, got snapcodec.Session) { | |
| 51 | + | func snapshotCacheHistory(t *testing.T, chunks int, first rune) vt.HistorySnapshotView { | |
| 191 | 52 | t.Helper() | |
| 192 | - | require.Equal(t, "acceptance", got.Name) | |
| 193 | - | require.Equal(t, uint16(1), got.Active) | |
| 194 | - | require.Len(t, got.Tabs, 2) | |
| 195 | - | for _, tt := range []struct { | |
| 196 | - | name string | |
| 197 | - | tab int | |
| 198 | - | stableID string | |
| 199 | - | focus layout.PaneID | |
| 200 | - | nextPaneID uint64 | |
| 201 | - | paneIDs []layout.PaneID | |
| 202 | - | wantSplit bool | |
| 203 | - | }{ | |
| 204 | - | {name: "first split tab", tab: 0, stableID: "tab-one", focus: "pane-2", nextPaneID: 3, paneIDs: []layout.PaneID{"pane-1", "pane-2"}, wantSplit: true}, | |
| 205 | - | {name: "second leaf tab", tab: 1, stableID: "tab-two", focus: "pane-1", nextPaneID: 2, paneIDs: []layout.PaneID{"pane-1"}}, | |
| 206 | - | } { | |
| 207 | - | t.Run(tt.name, func(t *testing.T) { | |
| 208 | - | tab := got.Tabs[tt.tab] | |
| 209 | - | require.Equal(t, tt.stableID, tab.StableID) | |
| 210 | - | require.Equal(t, tt.focus, tab.Focus) | |
| 211 | - | require.Equal(t, tt.nextPaneID, tab.NextPaneID) | |
| 212 | - | require.NotNil(t, tab.Tree) | |
| 213 | - | require.Equal(t, tt.focus, tab.Tree.Focus) | |
| 214 | - | if tt.wantSplit { | |
| 215 | - | require.Equal(t, layout.Split, tab.Tree.Root.Kind) | |
| 216 | - | } | |
| 217 | - | ids := make(map[layout.PaneID]struct{}, len(tab.Panes)) | |
| 218 | - | for _, pane := range tab.Panes { | |
| 219 | - | ids[pane.ID] = struct{}{} | |
| 220 | - | } | |
| 221 | - | for _, id := range tt.paneIDs { | |
| 222 | - | _, ok := ids[id] | |
| 223 | - | require.True(t, ok, "missing pane %q", id) | |
| 224 | - | } | |
| 225 | - | _, focused := ids[tab.Focus] | |
| 226 | - | require.True(t, focused, "focus %q is dangling", tab.Focus) | |
| 227 | - | assertTreeReferencesCapturedPanes(t, tab.Tree.Root, ids) | |
| 228 | - | }) | |
| 53 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: chunks * 256, ChunkRows: 256}) | |
| 54 | + | for row := 0; row < chunks*256; row++ { | |
| 55 | + | cells := make([]renderer.Cell, 160) | |
| 56 | + | for col := range cells { | |
| 57 | + | cells[col] = renderer.Cell{Rune: first + rune((row+col)%26)} | |
| 58 | + | } | |
| 59 | + | require.NoError(t, history.Append(cells)) | |
| 229 | 60 | } | |
| 61 | + | return history.SnapshotView() | |
| 230 | 62 | } | |
| 231 | 63 | ||
| 232 | - | func assertTreeReferencesCapturedPanes(t *testing.T, node *layout.Node, panes map[layout.PaneID]struct{}) { | |
| 233 | - | t.Helper() | |
| 234 | - | if node == nil { | |
| 235 | - | return | |
| 236 | - | } | |
| 237 | - | if node.Kind == layout.Leaf { | |
| 238 | - | _, ok := panes[node.Leaf] | |
| 239 | - | require.True(t, ok, "tree leaf %q is dangling", node.Leaf) | |
| 240 | - | return | |
| 241 | - | } | |
| 242 | - | if node.Kind == layout.Stack && node.Expanded != "" { | |
| 243 | - | _, ok := panes[node.Expanded] | |
| 244 | - | require.True(t, ok, "stack expanded pane %q is dangling", node.Expanded) | |
| 245 | - | } | |
| 246 | - | for _, child := range node.Children { | |
| 247 | - | assertTreeReferencesCapturedPanes(t, child, panes) | |
| 64 | + | func snapshotCacheCapture(sess *session, history vt.HistorySnapshotView, generation uint64) *snapshotCapture { | |
| 65 | + | return &snapshotCapture{ | |
| 66 | + | session: sess, | |
| 67 | + | name: sess.name, | |
| 68 | + | generation: generation, | |
| 69 | + | tabs: []snapshotCaptureTab{{ | |
| 70 | + | stableID: "tab", | |
| 71 | + | cols: 1, | |
| 72 | + | rows: 1, | |
| 73 | + | panes: []snapshotCapturePane{{ | |
| 74 | + | id: "pane", | |
| 75 | + | stableID: "pane", | |
| 76 | + | sealed: history, | |
| 77 | + | tail: history.Tail(), | |
| 78 | + | visible: vt.NewScreen(1, 1).PrimaryVisibleSnapshot(), | |
| 79 | + | }}, | |
| 80 | + | }}, | |
| 248 | 81 | } | |
| 249 | 82 | } | |
A internal/usecase/daemon/snapshot_capture.go +101 -0
| @@ -0,0 +1,101 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 4 | + | ||
| 5 | + | // captureSession rotates history tails and clones visible frames while holding | |
| 6 | + | // each pane lock. The returned capture contains only immutable state; encoding | |
| 7 | + | // and persistence are deliberately deferred to snapshotEncodeWorker. | |
| 8 | + | func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) { | |
| 9 | + | sess.mu.Lock() | |
| 10 | + | capture := &snapshotCapture{ | |
| 11 | + | session: sess, | |
| 12 | + | generation: generation, | |
| 13 | + | name: sess.name, | |
| 14 | + | createdAt: uint64(sess.createdAt), | |
| 15 | + | active: uint16(max(sess.active, 0)), | |
| 16 | + | } | |
| 17 | + | ephemeral := sess.ephemeral | |
| 18 | + | fallbackCwd := sess.cwd | |
| 19 | + | tabs := append([]*tab(nil), sess.tabs...) | |
| 20 | + | sess.mu.Unlock() | |
| 21 | + | if ephemeral || capture.name == "" { | |
| 22 | + | return capture, false | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | capture.tabs = make([]snapshotCaptureTab, 0, len(tabs)) | |
| 26 | + | for _, tb := range tabs { | |
| 27 | + | tb.mu.Lock() | |
| 28 | + | tabCapture := snapshotCaptureTab{ | |
| 29 | + | stableID: tb.stableID, | |
| 30 | + | cols: uint16(max(tb.size.Cols, 0)), | |
| 31 | + | rows: uint16(max(tb.size.Rows, 0)), | |
| 32 | + | nextPaneID: uint64(max(tb.nextPaneID, 0)), | |
| 33 | + | tree: tb.tree.Clone(), | |
| 34 | + | } | |
| 35 | + | if tb.tree != nil { | |
| 36 | + | tabCapture.focus = tb.tree.Focus | |
| 37 | + | } | |
| 38 | + | panes := make([]*pane, 0, len(tb.panes)) | |
| 39 | + | for _, p := range tb.panes { | |
| 40 | + | panes = append(panes, p) | |
| 41 | + | } | |
| 42 | + | tb.mu.Unlock() | |
| 43 | + | ||
| 44 | + | tabCapture.panes = make([]snapshotCapturePane, 0, len(panes)) | |
| 45 | + | for _, p := range panes { | |
| 46 | + | p.mu.Lock() | |
| 47 | + | pty := p.pty | |
| 48 | + | pid := 0 | |
| 49 | + | if pty != nil { | |
| 50 | + | pid = pty.Pid() | |
| 51 | + | } | |
| 52 | + | paneCapture := snapshotCapturePane{ | |
| 53 | + | id: p.id, | |
| 54 | + | stableID: p.stableID, | |
| 55 | + | visible: p.screen.PrimaryVisibleSnapshot(), | |
| 56 | + | } | |
| 57 | + | paneCapture.sealed = p.history.SnapshotView() | |
| 58 | + | paneCapture.tail = paneCapture.sealed.Tail() | |
| 59 | + | p.mu.Unlock() | |
| 60 | + | paneCapture.cwd = fallbackCwd | |
| 61 | + | if d.procCwd != nil && pid > 0 { | |
| 62 | + | if cwd, err := d.procCwd(pid); err == nil && cwd != "" { | |
| 63 | + | paneCapture.cwd = cwd | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | paneCapture.process = d.capturePaneProcess(pty, pid) | |
| 67 | + | tabCapture.panes = append(tabCapture.panes, paneCapture) | |
| 68 | + | } | |
| 69 | + | capture.tabs = append(capture.tabs, tabCapture) | |
| 70 | + | } | |
| 71 | + | return capture, true | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // captureSession remains the synchronous producer-facing trigger for callers | |
| 75 | + | // such as teardown and benchmarks; the actual encoding and Write stay async. | |
| 76 | + | func (d *Daemon) captureSession(sess *session) bool { | |
| 77 | + | markSnapshotDirty(sess) | |
| 78 | + | return d.scheduleSnapshot(sess) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process { | |
| 82 | + | if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil { | |
| 83 | + | return nil | |
| 84 | + | } | |
| 85 | + | pgid, err := pty.ForegroundPgid() | |
| 86 | + | if err != nil || pgid <= 0 || pgid == shellPid { | |
| 87 | + | return nil | |
| 88 | + | } | |
| 89 | + | argv, err := d.procGroupArgv(pgid, shellPid) | |
| 90 | + | if err != nil || len(argv) == 0 || argv[0] == "" { | |
| 91 | + | return nil | |
| 92 | + | } | |
| 93 | + | strategy := detectProcessStrategy(argv) | |
| 94 | + | return &snapcodec.Process{ | |
| 95 | + | Argv: append([]string(nil), argv...), | |
| 96 | + | Strategy: strategy, | |
| 97 | + | Opts: snapcodec.ProcessOpts{ | |
| 98 | + | AgentSessionID: extractAgentSessionID(strategy, argv), | |
| 99 | + | }, | |
| 100 | + | } | |
| 101 | + | } | |
A internal/usecase/daemon/snapshot_coordinator.go +279 -0
| @@ -0,0 +1,279 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // snapshotCoordinatorQuarantine identifies one stop request. A later teardown | |
| 9 | + | // can supersede it, in which case only that later owner may resume publication. | |
| 10 | + | type snapshotCoordinatorQuarantine struct { | |
| 11 | + | done <-chan struct{} | |
| 12 | + | epoch uint64 | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // quarantineSnapshotCoordinator prevents a deleted session from starting any | |
| 16 | + | // further publication. The returned join channel is closed after an already | |
| 17 | + | // started repository call returns; callers must wait without daemon or session | |
| 18 | + | // locks before deleting repository state. | |
| 19 | + | func quarantineSnapshotCoordinator(sess *session) <-chan struct{} { | |
| 20 | + | return quarantineSnapshotCoordinatorWithEpoch(sess).done | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // quarantineSnapshotCoordinatorRetainingQueuedCapture is used after a forced | |
| 24 | + | // shutdown checkpoint times out. Its queued capture is logical retry state, not | |
| 25 | + | // a repository operation: startSnapshotPublication rejects it while | |
| 26 | + | // quarantined, and the worker eventually discards it after its blocked call | |
| 27 | + | // returns. | |
| 28 | + | func quarantineSnapshotCoordinatorRetainingQueuedCapture(sess *session) <-chan struct{} { | |
| 29 | + | return quarantineSnapshotCoordinatorWithOptions(sess, true).done | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func quarantineSnapshotCoordinatorWithEpoch(sess *session) snapshotCoordinatorQuarantine { | |
| 33 | + | return quarantineSnapshotCoordinatorWithOptions(sess, false) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func quarantineSnapshotCoordinatorWithOptions(sess *session, retainQueuedCapture bool) snapshotCoordinatorQuarantine { | |
| 37 | + | if sess == nil { | |
| 38 | + | done := make(chan struct{}) | |
| 39 | + | close(done) | |
| 40 | + | return snapshotCoordinatorQuarantine{done: done} | |
| 41 | + | } | |
| 42 | + | sess.snapshotMu.Lock() | |
| 43 | + | sess.snapshotQuarantineEpoch++ | |
| 44 | + | epoch := sess.snapshotQuarantineEpoch | |
| 45 | + | sess.snapshotQuarantined = true | |
| 46 | + | sess.snapEligible.Store(false) | |
| 47 | + | if sess.snapshotPublicationCancel != nil { | |
| 48 | + | sess.snapshotPublicationCancel() | |
| 49 | + | } | |
| 50 | + | if sess.snapshotQueuedCapture != nil && !retainQueuedCapture { | |
| 51 | + | // A rename or purge must discard a capture under the old identity. The | |
| 52 | + | // global queue may still contain it, but startSnapshotPublication | |
| 53 | + | // compares identity and will discard it even if this session is renamed. | |
| 54 | + | sess.snapshotQueuedCapture.coordinatorDiscarded = true | |
| 55 | + | sess.snapshotQueuedCapture = nil | |
| 56 | + | if sess.snapshotPendingCaptures > 0 { | |
| 57 | + | sess.snapshotPendingCaptures-- | |
| 58 | + | } | |
| 59 | + | sess.snapshotPending = sess.snapshotPendingCaptures > 0 || sess.snapshotInFlightCapture != nil | |
| 60 | + | } | |
| 61 | + | done := sess.snapshotPublicationDone | |
| 62 | + | if done == nil { | |
| 63 | + | done = make(chan struct{}) | |
| 64 | + | close(done) | |
| 65 | + | } | |
| 66 | + | sess.signalSnapshotChangedLocked() | |
| 67 | + | sess.snapshotMu.Unlock() | |
| 68 | + | return snapshotCoordinatorQuarantine{done: done, epoch: epoch} | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // resumeSnapshotCoordinatorForNewIdentity starts a fresh repository lineage | |
| 72 | + | // only after rename committed its new in-memory name. The epoch prevents a | |
| 73 | + | // concurrent teardown from being undone by a late rename completion. | |
| 74 | + | func resumeSnapshotCoordinatorForNewIdentity(sess *session, quarantine snapshotCoordinatorQuarantine) bool { | |
| 75 | + | if sess == nil { | |
| 76 | + | return false | |
| 77 | + | } | |
| 78 | + | sess.snapshotMu.Lock() | |
| 79 | + | if !sess.snapshotQuarantined || sess.snapshotQuarantineEpoch != quarantine.epoch { | |
| 80 | + | sess.snapshotMu.Unlock() | |
| 81 | + | return false | |
| 82 | + | } | |
| 83 | + | if sess.snapshotPublicationCancel != nil { | |
| 84 | + | sess.snapshotPublicationCancel() | |
| 85 | + | } | |
| 86 | + | sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background()) | |
| 87 | + | sess.snapshotQuarantined = false | |
| 88 | + | // A repository name owns its own generation stream. Reset all scheduler | |
| 89 | + | // state while eligibility remains false, then atomically make generation 1 | |
| 90 | + | // dirty and wake the scheduler. | |
| 91 | + | sess.snapshotGeneration = 1 | |
| 92 | + | sess.snapshotPublishedGeneration = 0 | |
| 93 | + | sess.snapshotPublishedMutationRevision = 0 | |
| 94 | + | sess.snapshotForcedGeneration = 0 | |
| 95 | + | sess.snapshotNextEligibleAt = time.Time{} | |
| 96 | + | sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit) | |
| 97 | + | sess.snapDirty.Store(true) | |
| 98 | + | sess.snapEligible.Store(true) | |
| 99 | + | sess.signalSnapshotChangedLocked() | |
| 100 | + | wake := sess.snapshotWake | |
| 101 | + | sess.snapshotMu.Unlock() | |
| 102 | + | if wake != nil { | |
| 103 | + | select { | |
| 104 | + | case wake <- struct{}{}: | |
| 105 | + | default: | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | return true | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func markSnapshotDirty(sess *session) { | |
| 112 | + | if sess == nil || !sess.snapEligible.Load() { | |
| 113 | + | return | |
| 114 | + | } | |
| 115 | + | sess.snapshotMu.Lock() | |
| 116 | + | sess.snapshotGeneration++ | |
| 117 | + | sess.snapDirty.Store(true) | |
| 118 | + | sess.signalSnapshotChangedLocked() | |
| 119 | + | wake := sess.snapshotWake | |
| 120 | + | sess.snapshotMu.Unlock() | |
| 121 | + | if wake != nil { | |
| 122 | + | select { | |
| 123 | + | case wake <- struct{}{}: | |
| 124 | + | default: | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // snapshotChangeLocked returns a channel closed by the next snapshot state | |
| 130 | + | // transition. snapshotMu must be held. | |
| 131 | + | func (sess *session) snapshotChangeLocked() chan struct{} { | |
| 132 | + | if sess.snapshotChanged == nil { | |
| 133 | + | sess.snapshotChanged = make(chan struct{}) | |
| 134 | + | } | |
| 135 | + | return sess.snapshotChanged | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // signalSnapshotChangedLocked wakes waiters after a snapshot state transition. | |
| 139 | + | // snapshotMu must be held. | |
| 140 | + | func (sess *session) signalSnapshotChangedLocked() { | |
| 141 | + | close(sess.snapshotChangeLocked()) | |
| 142 | + | sess.snapshotChanged = make(chan struct{}) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // waitForSnapshotGeneration waits only for the bounded terminal checkpoint | |
| 146 | + | // deadline. It observes successful publication of the requested mutation | |
| 147 | + | // revision, rather than dirty state, so a concurrent later mutation cannot | |
| 148 | + | // turn a completed forced checkpoint into a false timeout. | |
| 149 | + | func (d *Daemon) waitForSnapshotGeneration(sess *session, generation uint64) bool { | |
| 150 | + | if d == nil || sess == nil || generation == 0 { | |
| 151 | + | return true | |
| 152 | + | } | |
| 153 | + | deadline := newSnapshotShutdownDeadline(d.clock) | |
| 154 | + | defer deadline.stop() | |
| 155 | + | return d.waitForSnapshotGenerationWithDeadline(sess, generation, deadline) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // waitForSnapshotGenerationWithDeadline waits for a forced checkpoint without | |
| 159 | + | // allocating another shutdown interval. Serve shares one deadline across every | |
| 160 | + | // session and the subsequent worker drain. | |
| 161 | + | func (d *Daemon) waitForSnapshotGenerationWithDeadline(sess *session, generation uint64, deadline *snapshotShutdownDeadline) bool { | |
| 162 | + | if d == nil || sess == nil || generation == 0 { | |
| 163 | + | return true | |
| 164 | + | } | |
| 165 | + | if deadline == nil { | |
| 166 | + | return d.waitForSnapshotGeneration(sess, generation) | |
| 167 | + | } | |
| 168 | + | for { | |
| 169 | + | sess.snapshotMu.Lock() | |
| 170 | + | published := sess.snapshotPublishedMutationRevision >= generation | |
| 171 | + | changed := sess.snapshotChangeLocked() | |
| 172 | + | sess.snapshotMu.Unlock() | |
| 173 | + | if published { | |
| 174 | + | return true | |
| 175 | + | } | |
| 176 | + | select { | |
| 177 | + | case <-changed: | |
| 178 | + | case <-deadline.Done(): | |
| 179 | + | return false | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // startSnapshotPublication moves a retained capture from the producer queue to | |
| 185 | + | // the repository worker. Quarantine and this transition share snapshotMu, so a | |
| 186 | + | // repository call cannot begin after destructive teardown has quarantined the | |
| 187 | + | // session. | |
| 188 | + | func startSnapshotPublication(capture *snapshotCapture) bool { | |
| 189 | + | if capture == nil || capture.session == nil { | |
| 190 | + | return false | |
| 191 | + | } | |
| 192 | + | sess := capture.session | |
| 193 | + | sess.snapshotMu.Lock() | |
| 194 | + | defer sess.snapshotMu.Unlock() | |
| 195 | + | if sess.snapshotQuarantined || sess.snapshotQueuedCapture != capture { | |
| 196 | + | return false | |
| 197 | + | } | |
| 198 | + | sess.snapshotQueuedCapture = nil | |
| 199 | + | sess.snapshotInFlightCapture = capture | |
| 200 | + | sess.snapshotPublicationDone = make(chan struct{}) | |
| 201 | + | sess.snapshotPending = true | |
| 202 | + | sess.signalSnapshotChangedLocked() | |
| 203 | + | return true | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool) { | |
| 207 | + | if capture == nil { | |
| 208 | + | return | |
| 209 | + | } | |
| 210 | + | capture.finishOnce.Do(func() { | |
| 211 | + | var shouldScheduleForcedSuccessor bool | |
| 212 | + | capture.session.snapshotMu.Lock() | |
| 213 | + | if capture.session.snapshotPendingCaptures > 0 { | |
| 214 | + | capture.session.snapshotPendingCaptures-- | |
| 215 | + | } | |
| 216 | + | if capture.session.snapshotQueuedCapture == capture { | |
| 217 | + | capture.session.snapshotQueuedCapture = nil | |
| 218 | + | } | |
| 219 | + | if capture.session.snapshotInFlightCapture == capture { | |
| 220 | + | capture.session.snapshotInFlightCapture = nil | |
| 221 | + | if capture.session.snapshotPublicationDone != nil { | |
| 222 | + | close(capture.session.snapshotPublicationDone) | |
| 223 | + | capture.session.snapshotPublicationDone = nil | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | capture.session.snapshotPending = capture.session.snapshotPendingCaptures > 0 || | |
| 227 | + | capture.session.snapshotQueuedCapture != nil || capture.session.snapshotInFlightCapture != nil | |
| 228 | + | if !capture.coordinatorDiscarded && capture.attemptKind == snapshotAttemptRoutine && d.snapshotRepository != nil { | |
| 229 | + | // The interval is measured from completion, including a failed | |
| 230 | + | // attempt. A forced teardown checkpoint deliberately leaves it alone. | |
| 231 | + | capture.session.snapshotNextEligibleAt = d.clock.Now().Add(snapshotInterval) | |
| 232 | + | } | |
| 233 | + | mutationRevision := capture.mutationRevision | |
| 234 | + | // Older unit fixtures created captures before mutation revisions were | |
| 235 | + | // recorded explicitly. Production captures always set it at scheduling; | |
| 236 | + | // retaining this fallback keeps those fixtures representative. | |
| 237 | + | if mutationRevision == 0 { | |
| 238 | + | mutationRevision = capture.generation | |
| 239 | + | } | |
| 240 | + | if succeeded { | |
| 241 | + | capture.session.snapshotPublishedGeneration = max(capture.session.snapshotPublishedGeneration, capture.generation) | |
| 242 | + | capture.session.snapshotPublishedMutationRevision = max(capture.session.snapshotPublishedMutationRevision, mutationRevision) | |
| 243 | + | } | |
| 244 | + | if succeeded && capture.session.snapshotGeneration == mutationRevision { | |
| 245 | + | capture.session.snapDirty.Store(false) | |
| 246 | + | } else if !succeeded { | |
| 247 | + | capture.session.snapDirty.Store(true) | |
| 248 | + | } | |
| 249 | + | if succeeded && capture.session.snapshotForcedGeneration != 0 && | |
| 250 | + | mutationRevision >= capture.session.snapshotForcedGeneration { | |
| 251 | + | capture.session.snapshotForcedGeneration = 0 | |
| 252 | + | } | |
| 253 | + | // A failed forced publication remains dirty for ordinary retry rather | |
| 254 | + | // than recursively creating forced jobs. A later forced request records | |
| 255 | + | // a newer intent and can create its one bounded successor. | |
| 256 | + | shouldScheduleForcedSuccessor = capture.session.snapshotForcedGeneration != 0 && | |
| 257 | + | mutationRevision < capture.session.snapshotForcedGeneration && | |
| 258 | + | capture.session.snapshotQueuedCapture == nil && capture.session.snapshotInFlightCapture == nil && | |
| 259 | + | (succeeded || capture.attemptKind != snapshotAttemptForced) | |
| 260 | + | capture.session.signalSnapshotChangedLocked() | |
| 261 | + | wake := capture.session.snapshotWake | |
| 262 | + | capture.session.snapshotMu.Unlock() | |
| 263 | + | if wake != nil { | |
| 264 | + | select { | |
| 265 | + | case wake <- struct{}{}: | |
| 266 | + | default: | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | if shouldScheduleForcedSuccessor { | |
| 270 | + | // This runs after publication completion and outside snapshotMu. It | |
| 271 | + | // bypasses routine eligibility while preserving the single queued and | |
| 272 | + | // single in-flight capture bound. | |
| 273 | + | _ = d.scheduleFinalSnapshot(capture.session) | |
| 274 | + | } | |
| 275 | + | if succeeded { | |
| 276 | + | d.clearSnapshotFailure() | |
| 277 | + | } | |
| 278 | + | }) | |
| 279 | + | } | |
A internal/usecase/daemon/snapshot_failure_notice_test.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestSnapshotFailureNoticeDeduplicatesStableCauseAndClearsOnSuccess(t *testing.T) { | |
| 11 | + | d, _, client, _ := newNoticeFixture(t, newNoticeClock()) | |
| 12 | + | capture := &snapshotCapture{session: newSnapshotTestSession(t, "failed", false, "/work"), name: "failed"} | |
| 13 | + | ||
| 14 | + | d.reportSnapshotFailure(capture, "publish", errors.New("first transient failure")) | |
| 15 | + | d.reportSnapshotFailure(capture, "publish", errors.New("same class, changed details")) | |
| 16 | + | ||
| 17 | + | history := d.notices.history() | |
| 18 | + | require.Len(t, history, 1) | |
| 19 | + | require.EqualValues(t, 2, history[0].Count) | |
| 20 | + | require.Len(t, awaitToastCount(t, client, 1), 1) | |
| 21 | + | ||
| 22 | + | d.finishSnapshotCapture(capture, true) | |
| 23 | + | d.reportSnapshotFailure(capture, "publish", errors.New("failure after success")) | |
| 24 | + | require.Len(t, awaitToastCount(t, client, 1), 1) | |
| 25 | + | require.Len(t, d.notices.history(), 1) | |
| 26 | + | require.EqualValues(t, 3, d.notices.history()[0].Count) | |
| 27 | + | } | |
A internal/usecase/daemon/snapshot_failure_test.go +22 -0
| @@ -0,0 +1,22 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "os" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestSnapshotFailureSignatureIsStableAndNilSafe(t *testing.T) { | |
| 12 | + | t.Parallel() | |
| 13 | + | ||
| 14 | + | first := &os.PathError{Op: "write", Path: "/private/first", Err: os.ErrPermission} | |
| 15 | + | second := &os.PathError{Op: "write", Path: "/volatile/second", Err: os.ErrPermission} | |
| 16 | + | ||
| 17 | + | require.Equal(t, snapshotFailureSignature("publish", first), snapshotFailureSignature("publish", second)) | |
| 18 | + | require.NotContains(t, snapshotFailureSignature("publish", first), "/private/first") | |
| 19 | + | require.NotContains(t, snapshotFailureSignature("publish", nil), "%!w(<nil>)") | |
| 20 | + | require.NotEqual(t, snapshotFailureSignature("publish", first), snapshotFailureSignature("encode", first)) | |
| 21 | + | require.NotEqual(t, snapshotFailureSignature("publish", first), snapshotFailureSignature("publish", errors.New("other"))) | |
| 22 | + | } | |
A internal/usecase/daemon/snapshot_generation_test.go +80 -0
| @@ -0,0 +1,80 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "sync/atomic" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestSnapshotMultipleMutationsPublishNextRepositoryGeneration(t *testing.T) { | |
| 18 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 19 | + | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 20 | + | WithSnapshotRepository(repository, nil)(d) | |
| 21 | + | startSnapshotEncodeWorker(t, d) | |
| 22 | + | ||
| 23 | + | publications := make(chan ports.SnapshotPublication, 1) | |
| 24 | + | repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error { | |
| 25 | + | publications <- publication | |
| 26 | + | return nil | |
| 27 | + | }).Once() | |
| 28 | + | ||
| 29 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 30 | + | markSnapshotDirty(sess) | |
| 31 | + | markSnapshotDirty(sess) | |
| 32 | + | markSnapshotDirty(sess) | |
| 33 | + | require.True(t, d.scheduleSnapshot(sess)) | |
| 34 | + | ||
| 35 | + | select { | |
| 36 | + | case publication := <-publications: | |
| 37 | + | require.Equal(t, uint64(1), publication.Generation) | |
| 38 | + | case <-time.After(testWaitTimeout): | |
| 39 | + | t.Fatal("snapshot was not published") | |
| 40 | + | } | |
| 41 | + | awaitSnapshotClean(t, sess) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestSnapshotRetryKeepsRepositoryGeneration(t *testing.T) { | |
| 45 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 46 | + | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 47 | + | WithSnapshotRepository(repository, nil)(d) | |
| 48 | + | startSnapshotEncodeWorker(t, d) | |
| 49 | + | ||
| 50 | + | publications := make(chan ports.SnapshotPublication, 2) | |
| 51 | + | var attempts atomic.Int32 | |
| 52 | + | repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error { | |
| 53 | + | publications <- publication | |
| 54 | + | if attempts.Add(1) == 1 { | |
| 55 | + | return errors.New("temporary repository failure") | |
| 56 | + | } | |
| 57 | + | return nil | |
| 58 | + | }).Twice() | |
| 59 | + | ||
| 60 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 61 | + | markSnapshotDirty(sess) | |
| 62 | + | require.True(t, d.scheduleSnapshot(sess)) | |
| 63 | + | select { | |
| 64 | + | case publication := <-publications: | |
| 65 | + | require.Equal(t, uint64(1), publication.Generation) | |
| 66 | + | case <-time.After(testWaitTimeout): | |
| 67 | + | t.Fatal("first snapshot was not published") | |
| 68 | + | } | |
| 69 | + | awaitSnapshotIdle(t, sess) | |
| 70 | + | require.True(t, sess.snapDirty.Load()) | |
| 71 | + | ||
| 72 | + | require.True(t, d.scheduleFinalSnapshot(sess)) | |
| 73 | + | select { | |
| 74 | + | case publication := <-publications: | |
| 75 | + | require.Equal(t, uint64(1), publication.Generation) | |
| 76 | + | case <-time.After(testWaitTimeout): | |
| 77 | + | t.Fatal("retry snapshot was not published") | |
| 78 | + | } | |
| 79 | + | awaitSnapshotClean(t, sess) | |
| 80 | + | } | |
A internal/usecase/daemon/snapshot_import_test.go +215 -0
| @@ -0,0 +1,215 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/base64" | |
| 6 | + | "errors" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | type snapshotAcceptanceLegacySource struct { | |
| 18 | + | blobs []ports.LegacySnapshot | |
| 19 | + | loadCalls int | |
| 20 | + | deleteCalls []string | |
| 21 | + | deleteErr error | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (s *snapshotAcceptanceLegacySource) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, error) { | |
| 25 | + | if err := ctx.Err(); err != nil { | |
| 26 | + | return nil, err | |
| 27 | + | } | |
| 28 | + | s.loadCalls++ | |
| 29 | + | out := make([]ports.LegacySnapshot, len(s.blobs)) | |
| 30 | + | for i, blob := range s.blobs { | |
| 31 | + | out[i] = ports.LegacySnapshot{Name: blob.Name, Data: append([]byte(nil), blob.Data...)} | |
| 32 | + | } | |
| 33 | + | return out, nil | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (s *snapshotAcceptanceLegacySource) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error { | |
| 37 | + | return s.DeleteLegacy(ctx, blob.Name) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (s *snapshotAcceptanceLegacySource) DeleteLegacy(ctx context.Context, name string) error { | |
| 41 | + | if err := ctx.Err(); err != nil { | |
| 42 | + | return err | |
| 43 | + | } | |
| 44 | + | s.deleteCalls = append(s.deleteCalls, name) | |
| 45 | + | if s.deleteErr != nil { | |
| 46 | + | return s.deleteErr | |
| 47 | + | } | |
| 48 | + | for i, blob := range s.blobs { | |
| 49 | + | if blob.Name == name { | |
| 50 | + | s.blobs = append(s.blobs[:i], s.blobs[i+1:]...) | |
| 51 | + | break | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | return nil | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func legacyAcceptanceSnapshot(t *testing.T) snapcodec.Session { | |
| 58 | + | t.Helper() | |
| 59 | + | data, err := os.ReadFile(filepath.Join("testdata", "legacy-v3-empty.b64")) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | encoded, err := base64.StdEncoding.DecodeString(string(data)) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | snapshot, err := snapcodec.Unmarshal(encoded) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | return snapshot | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func legacyAcceptanceBlob(t *testing.T, snapshot snapcodec.Session) ports.LegacySnapshot { | |
| 69 | + | t.Helper() | |
| 70 | + | fixture := legacyAcceptanceSnapshot(t) | |
| 71 | + | require.Equal(t, fixture, snapshot, "legacy fixture must be the imported session") | |
| 72 | + | data, err := os.ReadFile(filepath.Join("testdata", "legacy-v3-empty.b64")) | |
| 73 | + | require.NoError(t, err) | |
| 74 | + | encoded, err := base64.StdEncoding.DecodeString(string(data)) | |
| 75 | + | require.NoError(t, err) | |
| 76 | + | return ports.LegacySnapshot{Name: snapshot.Name, Data: encoded} | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestLegacyPublicationMarshalObjectErrorsIdentifyPaneAndStage(t *testing.T) { | |
| 80 | + | for _, tt := range []struct { | |
| 81 | + | name string | |
| 82 | + | mutate func(*snapcodec.Pane) | |
| 83 | + | stage string | |
| 84 | + | }{ | |
| 85 | + | {name: "sealed chunk", mutate: func(pane *snapcodec.Pane) { pane.SealedChunks = [][]byte{nil} }, stage: "sealed chunk 0"}, | |
| 86 | + | {name: "tail", mutate: func(pane *snapcodec.Pane) { pane.Tail = nil }, stage: "tail"}, | |
| 87 | + | {name: "visible", mutate: func(pane *snapcodec.Pane) { pane.Visible = nil }, stage: "visible"}, | |
| 88 | + | } { | |
| 89 | + | t.Run(tt.name, func(t *testing.T) { | |
| 90 | + | pane := snapcodec.Pane{ID: "pane-1", StableID: "pane-stable", Tail: []byte("tail"), Visible: []byte("visible")} | |
| 91 | + | tt.mutate(&pane) | |
| 92 | + | snapshot := snapcodec.Session{Name: "legacy", Tabs: []snapcodec.Tab{{StableID: "tab-1", Panes: []snapcodec.Pane{pane}}}} | |
| 93 | + | ||
| 94 | + | _, err := legacyPublication(snapshot) | |
| 95 | + | require.Error(t, err) | |
| 96 | + | require.ErrorContains(t, err, `tab "tab-1"`) | |
| 97 | + | require.ErrorContains(t, err, `pane "pane-1"`) | |
| 98 | + | require.ErrorContains(t, err, tt.stage) | |
| 99 | + | require.ErrorContains(t, err, "marshal object") | |
| 100 | + | }) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing.T) { | |
| 105 | + | snapshot := legacyAcceptanceSnapshot(t) | |
| 106 | + | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)} | |
| 107 | + | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}} | |
| 108 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 109 | + | WithSnapshotRepository(repository, legacy)(d) | |
| 110 | + | expected, verifyErr := legacyPublication(snapshot) | |
| 111 | + | require.NoError(t, verifyErr) | |
| 112 | + | _, verifyErr = snapcodec.Unmarshal(legacy.blobs[0].Data) | |
| 113 | + | require.NoError(t, verifyErr) | |
| 114 | + | require.NoError(t, verifyLegacyImportGeneration(acceptanceGeneration(t, snapshot, 1), expected)) | |
| 115 | + | ||
| 116 | + | d.importLegacySnapshots(context.Background()) | |
| 117 | + | ||
| 118 | + | require.Len(t, repository.publishes, 1) | |
| 119 | + | publication := repository.publishes[0] | |
| 120 | + | require.Equal(t, snapshot.Name, publication.Name) | |
| 121 | + | require.EqualValues(t, 1, publication.Generation) | |
| 122 | + | manifest, err := snapcodec.UnmarshalManifest(publication.Manifest) | |
| 123 | + | require.NoError(t, err) | |
| 124 | + | require.Equal(t, uint64(1), manifest.Generation) | |
| 125 | + | require.Empty(t, manifest.Tabs) | |
| 126 | + | for _, object := range publication.Objects { | |
| 127 | + | kind, _, err := snapcodec.UnmarshalObject(object.Data) | |
| 128 | + | require.NoError(t, err) | |
| 129 | + | require.Contains(t, []snapcodec.ObjectKind{snapcodec.HistoryChunk, snapcodec.HistoryTail, snapcodec.Visible}, kind) | |
| 130 | + | require.Equal(t, object.Digest, snapcodec.ManifestDigest(object.Data)) | |
| 131 | + | } | |
| 132 | + | require.Equal(t, []string{snapshot.Name}, legacy.deleteCalls) | |
| 133 | + | require.Empty(t, legacy.blobs) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestImportLegacyExistingIncrementalSkipsLegacy(t *testing.T) { | |
| 137 | + | snapshot := legacyAcceptanceSnapshot(t) | |
| 138 | + | repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: make(map[string]ports.SnapshotGeneration)} | |
| 139 | + | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}} | |
| 140 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 141 | + | WithSnapshotRepository(repository, legacy)(d) | |
| 142 | + | ||
| 143 | + | d.importLegacySnapshots(context.Background()) | |
| 144 | + | ||
| 145 | + | require.Empty(t, repository.publishes) | |
| 146 | + | require.Empty(t, legacy.deleteCalls) | |
| 147 | + | require.Len(t, legacy.blobs, 1) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) { | |
| 151 | + | snapshot := legacyAcceptanceSnapshot(t) | |
| 152 | + | for _, test := range []struct { | |
| 153 | + | name string | |
| 154 | + | publish error | |
| 155 | + | load error | |
| 156 | + | mutate func(ports.SnapshotGeneration, ports.SnapshotPublication) ports.SnapshotGeneration | |
| 157 | + | }{ | |
| 158 | + | {"publish failure", errors.New("publish failed"), nil, nil}, | |
| 159 | + | {"reload failure", nil, errors.New("reload failed"), nil}, | |
| 160 | + | {"manifest metadata mismatch", nil, nil, func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration { | |
| 161 | + | g.Generation++ | |
| 162 | + | return g | |
| 163 | + | }}, | |
| 164 | + | {"manifest bytes mismatch", nil, nil, func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration { | |
| 165 | + | g.Manifest[len(g.Manifest)-1] ^= 1 | |
| 166 | + | return g | |
| 167 | + | }}, | |
| 168 | + | } { | |
| 169 | + | t.Run(test.name, func(t *testing.T) { | |
| 170 | + | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration), publishErr: test.publish, loadErr: test.load, loadMutate: test.mutate} | |
| 171 | + | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}} | |
| 172 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 173 | + | WithSnapshotRepository(repository, legacy)(d) | |
| 174 | + | d.importLegacySnapshots(context.Background()) | |
| 175 | + | require.Empty(t, legacy.deleteCalls) | |
| 176 | + | require.Len(t, legacy.blobs, 1) | |
| 177 | + | }) | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestImportLegacyDeleteFailureRetriesWithoutRepublishing(t *testing.T) { | |
| 182 | + | snapshot := legacyAcceptanceSnapshot(t) | |
| 183 | + | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)} | |
| 184 | + | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}, deleteErr: errors.New("sync failed")} | |
| 185 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 186 | + | WithSnapshotRepository(repository, legacy)(d) | |
| 187 | + | ||
| 188 | + | d.importLegacySnapshots(context.Background()) | |
| 189 | + | legacy.deleteErr = nil | |
| 190 | + | d.importLegacySnapshots(context.Background()) | |
| 191 | + | ||
| 192 | + | require.Len(t, repository.publishes, 1, "a retry must only retry source deletion") | |
| 193 | + | require.Equal(t, []string{snapshot.Name, snapshot.Name}, legacy.deleteCalls) | |
| 194 | + | require.Empty(t, legacy.blobs) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestImportLegacyCancellationAndPerSessionContinuation(t *testing.T) { | |
| 198 | + | bad := legacyAcceptanceSnapshot(t) | |
| 199 | + | good := legacyAcceptanceSnapshot(t) | |
| 200 | + | badBlob := legacyAcceptanceBlob(t, bad) | |
| 201 | + | badBlob.Data = []byte("not-a-v3-snapshot") | |
| 202 | + | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)} | |
| 203 | + | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{badBlob, legacyAcceptanceBlob(t, good)}} | |
| 204 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 205 | + | WithSnapshotRepository(repository, legacy)(d) | |
| 206 | + | ||
| 207 | + | d.importLegacySnapshots(context.Background()) | |
| 208 | + | require.Len(t, repository.publishes, 1, "a corrupt session must not block later sessions") | |
| 209 | + | require.Equal(t, []string{good.Name}, legacy.deleteCalls) | |
| 210 | + | ||
| 211 | + | cancelled, cancel := context.WithCancel(context.Background()) | |
| 212 | + | cancel() | |
| 213 | + | d.importLegacySnapshots(cancelled) | |
| 214 | + | require.Equal(t, 1, legacy.loadCalls, "a canceled import must not touch the legacy source") | |
| 215 | + | } | |
A internal/usecase/daemon/snapshot_incremental.go +248 -0
| @@ -0,0 +1,248 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 8 | + | "github.com/bnema/vev/pkg/vt" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // snapshotChunkCacheLimit is deliberately small: history chunks remain owned | |
| 12 | + | // by VT and each named-session cache only retains their encoded VEVO form. | |
| 13 | + | const snapshotChunkCacheLimit = 16 << 20 | |
| 14 | + | ||
| 15 | + | type snapshotChunkCacheEntry struct { | |
| 16 | + | object ports.SnapshotObject | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // snapshotChunkCache is keyed by immutable HistoryChunk identity. Its owner | |
| 20 | + | // must hold the named session's snapshotMu for every operation. Encoded bytes | |
| 21 | + | // remain cache-owned; publications receive a copy only until a successful | |
| 22 | + | // repository publication has made the immutable object available by reference. | |
| 23 | + | type snapshotChunkCache struct { | |
| 24 | + | limit int | |
| 25 | + | used int | |
| 26 | + | byPtr map[*vt.HistoryChunk]snapshotChunkCacheEntry | |
| 27 | + | persisted map[*vt.HistoryChunk]snapcodec.ObjectRef | |
| 28 | + | // marshalChunk makes cache admission measurable without coupling the cache | |
| 29 | + | // policy test to VT's wire encoding implementation. | |
| 30 | + | marshalChunk func(*vt.HistoryChunk) ([]byte, error) | |
| 31 | + | copyObject func(ports.SnapshotObject) ports.SnapshotObject | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func newSnapshotChunkCache(limit int) *snapshotChunkCache { | |
| 35 | + | return &snapshotChunkCache{ | |
| 36 | + | limit: limit, | |
| 37 | + | byPtr: make(map[*vt.HistoryChunk]snapshotChunkCacheEntry), | |
| 38 | + | persisted: make(map[*vt.HistoryChunk]snapcodec.ObjectRef), | |
| 39 | + | marshalChunk: vt.MarshalHistoryChunk, | |
| 40 | + | copyObject: copySnapshotObject, | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // objectLocked returns a complete manifest reference and, when the repository | |
| 45 | + | // does not already retain it, an independently owned object to supply. Caller | |
| 46 | + | // holds the owning session's snapshotMu. | |
| 47 | + | func (c *snapshotChunkCache) objectLocked(chunk *vt.HistoryChunk) (snapcodec.ObjectRef, *ports.SnapshotObject, error) { | |
| 48 | + | if chunk == nil { | |
| 49 | + | return snapcodec.ObjectRef{}, nil, fmt.Errorf("snapshot: nil history chunk") | |
| 50 | + | } | |
| 51 | + | if ref, ok := c.persisted[chunk]; ok { | |
| 52 | + | return ref, nil, nil | |
| 53 | + | } | |
| 54 | + | if entry, ok := c.byPtr[chunk]; ok { | |
| 55 | + | object := c.copyObject(entry.object) | |
| 56 | + | return objectRef(snapcodec.HistoryChunk, object), &object, nil | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | payload, err := c.marshalChunk(chunk) | |
| 60 | + | if err != nil { | |
| 61 | + | return snapcodec.ObjectRef{}, nil, err | |
| 62 | + | } | |
| 63 | + | object, err := snapcodec.MarshalObject(snapcodec.HistoryChunk, payload) | |
| 64 | + | if err != nil { | |
| 65 | + | return snapcodec.ObjectRef{}, nil, err | |
| 66 | + | } | |
| 67 | + | ref := objectRef(snapcodec.HistoryChunk, object) | |
| 68 | + | ||
| 69 | + | // An oldest-to-newest snapshot scan can exceed the cache. LRU would admit | |
| 70 | + | // every early miss and evict the later working set, causing the next scan to | |
| 71 | + | // encode every immutable chunk again. Once full, retain the warmed set and | |
| 72 | + | // return later misses uncached. Pruning makes space when a retained chunk is | |
| 73 | + | // no longer reachable by a queued or in-flight capture. | |
| 74 | + | if len(object.Data) > c.limit || c.used > c.limit-len(object.Data) { | |
| 75 | + | return ref, &object, nil | |
| 76 | + | } | |
| 77 | + | stored := c.copyObject(object) | |
| 78 | + | c.byPtr[chunk] = snapshotChunkCacheEntry{object: stored} | |
| 79 | + | c.used += len(stored.Data) | |
| 80 | + | return ref, &object, nil | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // pruneLocked releases cached encodings which no currently queued or in-flight | |
| 84 | + | // capture can use. Caller holds the owning session's snapshotMu. | |
| 85 | + | func (c *snapshotChunkCache) pruneLocked(referenced map[*vt.HistoryChunk]struct{}) { | |
| 86 | + | for chunk, entry := range c.byPtr { | |
| 87 | + | if _, keep := referenced[chunk]; !keep { | |
| 88 | + | delete(c.byPtr, chunk) | |
| 89 | + | c.used -= len(entry.object.Data) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | for chunk := range c.persisted { | |
| 93 | + | if _, keep := referenced[chunk]; !keep { | |
| 94 | + | delete(c.persisted, chunk) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func copySnapshotObject(object ports.SnapshotObject) ports.SnapshotObject { | |
| 100 | + | object.Data = append([]byte(nil), object.Data...) | |
| 101 | + | return object | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // incrementalPublication converts one immutable capture into complete VEVO | |
| 105 | + | // objects plus its VEVM manifest. Encoding runs after pane locks are released. | |
| 106 | + | // Sealed history uses this capture's named-session pointer identity cache; | |
| 107 | + | // tails and visible frames are intentionally not cached because they are copied | |
| 108 | + | // mutable state. | |
| 109 | + | func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.SnapshotPublication, error) { | |
| 110 | + | if capture == nil || capture.session == nil || capture.name == "" { | |
| 111 | + | return ports.SnapshotPublication{}, fmt.Errorf("snapshot: empty capture") | |
| 112 | + | } | |
| 113 | + | if err := prepareSnapshotChunkCache(capture); err != nil { | |
| 114 | + | return ports.SnapshotPublication{}, err | |
| 115 | + | } | |
| 116 | + | manifest := snapcodec.Manifest{Generation: capture.generation, Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.ManifestTab, 0, len(capture.tabs))} | |
| 117 | + | objects := make([]ports.SnapshotObject, 0) | |
| 118 | + | capture.sealedRefs = make(map[*vt.HistoryChunk]snapcodec.ObjectRef) | |
| 119 | + | for _, tab := range capture.tabs { | |
| 120 | + | outTab := snapcodec.ManifestTab{StableID: tab.stableID, Cols: tab.cols, Rows: tab.rows, NextPaneID: tab.nextPaneID, Focus: tab.focus, Tree: tab.tree, Panes: make([]snapcodec.ManifestPane, 0, len(tab.panes))} | |
| 121 | + | for _, pane := range tab.panes { | |
| 122 | + | // visible was copied under pane.mu. Marshal it here in the worker, | |
| 123 | + | // after all pane and session locks have been released. | |
| 124 | + | visible, err := pane.visible.Marshal() | |
| 125 | + | if err != nil { | |
| 126 | + | return ports.SnapshotPublication{}, fmt.Errorf("snapshot visible: %w", err) | |
| 127 | + | } | |
| 128 | + | outPane := snapcodec.ManifestPane{ID: pane.id, StableID: pane.stableID, Cwd: pane.cwd, Process: pane.process} | |
| 129 | + | for i := 0; i < pane.sealed.ChunkCount(); i++ { | |
| 130 | + | ref, object, err := snapshotChunkObject(capture, pane.sealed.Chunk(i)) | |
| 131 | + | if err != nil { | |
| 132 | + | return ports.SnapshotPublication{}, fmt.Errorf("snapshot history chunk: %w", err) | |
| 133 | + | } | |
| 134 | + | outPane.Sealed = append(outPane.Sealed, ref) | |
| 135 | + | capture.sealedRefs[pane.sealed.Chunk(i)] = ref | |
| 136 | + | if object != nil { | |
| 137 | + | objects = append(objects, *object) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | tail, err := marshalSnapshotTail(pane.tail) | |
| 141 | + | if err != nil { | |
| 142 | + | return ports.SnapshotPublication{}, err | |
| 143 | + | } | |
| 144 | + | tailObject, err := snapcodec.MarshalObject(snapcodec.HistoryTail, tail) | |
| 145 | + | if err != nil { | |
| 146 | + | return ports.SnapshotPublication{}, err | |
| 147 | + | } | |
| 148 | + | visibleObject, err := snapcodec.MarshalObject(snapcodec.Visible, visible) | |
| 149 | + | if err != nil { | |
| 150 | + | return ports.SnapshotPublication{}, err | |
| 151 | + | } | |
| 152 | + | outPane.Tail = objectRef(snapcodec.HistoryTail, tailObject) | |
| 153 | + | outPane.Visible = objectRef(snapcodec.Visible, visibleObject) | |
| 154 | + | objects = append(objects, tailObject, visibleObject) | |
| 155 | + | outTab.Panes = append(outTab.Panes, outPane) | |
| 156 | + | } | |
| 157 | + | manifest.Tabs = append(manifest.Tabs, outTab) | |
| 158 | + | } | |
| 159 | + | encoded, err := snapcodec.MarshalManifest(manifest) | |
| 160 | + | if err != nil { | |
| 161 | + | return ports.SnapshotPublication{}, err | |
| 162 | + | } | |
| 163 | + | return ports.SnapshotPublication{Name: capture.name, Generation: capture.generation, Manifest: encoded, Objects: objects}, nil | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // marshalSnapshotTail selects the canonical empty-tail encoding before any | |
| 167 | + | // general history encoding. An empty tail is represented by its dedicated | |
| 168 | + | // canonical blob; a non-empty copied tail retains its rows. | |
| 169 | + | func marshalSnapshotTail(tail vt.HistoryView) ([]byte, error) { | |
| 170 | + | if tail.Len() == 0 { | |
| 171 | + | return vt.MarshalEmptyHistoryTail() | |
| 172 | + | } | |
| 173 | + | return vt.MarshalHistory(tail) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func prepareSnapshotChunkCache(capture *snapshotCapture) error { | |
| 177 | + | sess := capture.session | |
| 178 | + | sess.snapshotMu.Lock() | |
| 179 | + | defer sess.snapshotMu.Unlock() | |
| 180 | + | if sess.snapshotChunkCache == nil { | |
| 181 | + | if sess.name == "" || sess.ephemeral { | |
| 182 | + | return fmt.Errorf("snapshot: chunk cache unavailable") | |
| 183 | + | } | |
| 184 | + | sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit) | |
| 185 | + | } | |
| 186 | + | pruneSnapshotChunkCacheLocked(sess, capture) | |
| 187 | + | return nil | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func snapshotChunkObject(capture *snapshotCapture, chunk *vt.HistoryChunk) (snapcodec.ObjectRef, *ports.SnapshotObject, error) { | |
| 191 | + | sess := capture.session | |
| 192 | + | sess.snapshotMu.Lock() | |
| 193 | + | defer sess.snapshotMu.Unlock() | |
| 194 | + | return sess.snapshotChunkCache.objectLocked(chunk) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // markSnapshotCaptureObjectsPublished records that the immutable sealed | |
| 198 | + | // history referenced by capture is available in this session's repository | |
| 199 | + | // lineage. It is called only after Publish succeeds. | |
| 200 | + | func markSnapshotCaptureObjectsPublished(capture *snapshotCapture) { | |
| 201 | + | if capture == nil || capture.session == nil { | |
| 202 | + | return | |
| 203 | + | } | |
| 204 | + | sess := capture.session | |
| 205 | + | sess.snapshotMu.Lock() | |
| 206 | + | defer sess.snapshotMu.Unlock() | |
| 207 | + | cache := sess.snapshotChunkCache | |
| 208 | + | if cache == nil { | |
| 209 | + | return | |
| 210 | + | } | |
| 211 | + | for chunk, ref := range capture.sealedRefs { | |
| 212 | + | if chunk != nil { | |
| 213 | + | cache.persisted[chunk] = ref | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // pruneSnapshotChunkCacheLocked retains only chunks referenced by captures that | |
| 219 | + | // can still be published. Caller holds sess.snapshotMu. | |
| 220 | + | func pruneSnapshotChunkCacheLocked(sess *session, capture *snapshotCapture) { | |
| 221 | + | if sess.snapshotChunkCache == nil { | |
| 222 | + | return | |
| 223 | + | } | |
| 224 | + | referenced := make(map[*vt.HistoryChunk]struct{}) | |
| 225 | + | collectSnapshotCaptureChunks(referenced, capture) | |
| 226 | + | collectSnapshotCaptureChunks(referenced, sess.snapshotQueuedCapture) | |
| 227 | + | collectSnapshotCaptureChunks(referenced, sess.snapshotInFlightCapture) | |
| 228 | + | sess.snapshotChunkCache.pruneLocked(referenced) | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func collectSnapshotCaptureChunks(referenced map[*vt.HistoryChunk]struct{}, capture *snapshotCapture) { | |
| 232 | + | if capture == nil { | |
| 233 | + | return | |
| 234 | + | } | |
| 235 | + | for _, tab := range capture.tabs { | |
| 236 | + | for _, pane := range tab.panes { | |
| 237 | + | for i := 0; i < pane.sealed.ChunkCount(); i++ { | |
| 238 | + | if chunk := pane.sealed.Chunk(i); chunk != nil { | |
| 239 | + | referenced[chunk] = struct{}{} | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func objectRef(kind snapcodec.ObjectKind, object ports.SnapshotObject) snapcodec.ObjectRef { | |
| 247 | + | return snapcodec.ObjectRef{Kind: kind, Digest: object.Digest, Size: uint32(len(object.Data))} | |
| 248 | + | } | |
A internal/usecase/daemon/snapshot_incremental_test.go +143 -0
| @@ -0,0 +1,143 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 10 | + | "github.com/bnema/vev/pkg/renderer" | |
| 11 | + | "github.com/bnema/vev/pkg/vt" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestMarshalSnapshotTailSelectsCanonicalEncoder(t *testing.T) { | |
| 15 | + | for _, tt := range []struct { | |
| 16 | + | name string | |
| 17 | + | fill bool | |
| 18 | + | }{ | |
| 19 | + | {name: "empty tail"}, | |
| 20 | + | {name: "non-empty tail", fill: true}, | |
| 21 | + | } { | |
| 22 | + | t.Run(tt.name, func(t *testing.T) { | |
| 23 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 2}) | |
| 24 | + | if tt.fill { | |
| 25 | + | require.NoError(t, history.Append([]renderer.Cell{{Rune: 'x'}})) | |
| 26 | + | } | |
| 27 | + | tail := history.SnapshotView().Tail() | |
| 28 | + | ||
| 29 | + | got, err := marshalSnapshotTail(tail) | |
| 30 | + | require.NoError(t, err) | |
| 31 | + | if tt.fill { | |
| 32 | + | want, err := vt.MarshalHistory(tail) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | require.Equal(t, want, got) | |
| 35 | + | return | |
| 36 | + | } | |
| 37 | + | want, err := vt.MarshalEmptyHistoryTail() | |
| 38 | + | require.NoError(t, err) | |
| 39 | + | require.Equal(t, want, got) | |
| 40 | + | }) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestIncrementalPublicationEncodesAfterPaneUnlock(t *testing.T) { | |
| 45 | + | d := New(nil, nil, nil) | |
| 46 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 47 | + | capture, ok := d.captureSnapshotState(sess, 1) | |
| 48 | + | if !ok { | |
| 49 | + | t.Fatal("capture rejected named session") | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | pane := sess.tabs[0].panes["pane-1"] | |
| 53 | + | pane.mu.Lock() | |
| 54 | + | done := make(chan error, 1) | |
| 55 | + | go func() { | |
| 56 | + | _, err := d.incrementalPublication(capture) | |
| 57 | + | done <- err | |
| 58 | + | }() | |
| 59 | + | select { | |
| 60 | + | case err := <-done: | |
| 61 | + | if err != nil { | |
| 62 | + | t.Fatalf("encode capture: %v", err) | |
| 63 | + | } | |
| 64 | + | case <-time.After(testWaitTimeout): | |
| 65 | + | pane.mu.Unlock() | |
| 66 | + | t.Fatal("visible encoding waited for pane lock") | |
| 67 | + | } | |
| 68 | + | pane.mu.Unlock() | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) { | |
| 72 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 6, ChunkRows: 1}) | |
| 73 | + | for i := range 6 { | |
| 74 | + | require.NoError(t, history.Append([]renderer.Cell{{Rune: rune('a' + i)}})) | |
| 75 | + | } | |
| 76 | + | view := history.SnapshotView() | |
| 77 | + | require.Equal(t, 6, view.ChunkCount()) | |
| 78 | + | ||
| 79 | + | firstPayload, err := vt.MarshalHistoryChunk(view.Chunk(0)) | |
| 80 | + | require.NoError(t, err) | |
| 81 | + | firstObject, err := snapcodec.MarshalObject(snapcodec.HistoryChunk, firstPayload) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | cache := newSnapshotChunkCache(2 * len(firstObject.Data)) | |
| 84 | + | encodes := make(map[*vt.HistoryChunk]int) | |
| 85 | + | cache.marshalChunk = func(chunk *vt.HistoryChunk) ([]byte, error) { | |
| 86 | + | encodes[chunk]++ | |
| 87 | + | return vt.MarshalHistoryChunk(chunk) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | for pass := range 3 { | |
| 91 | + | for i := range view.ChunkCount() { | |
| 92 | + | _, _, err := cache.objectLocked(view.Chunk(i)) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | } | |
| 95 | + | require.LessOrEqual(t, cache.used, cache.limit) | |
| 96 | + | if pass == 0 { | |
| 97 | + | require.Len(t, cache.byPtr, 2) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // The cache admits the first bounded working set then rejects later scan | |
| 102 | + | // misses. Consequently, each warmed retained chunk is encoded only once | |
| 103 | + | // even though the history is repeatedly scanned oldest-to-newest. | |
| 104 | + | require.Equal(t, 1, encodes[view.Chunk(0)]) | |
| 105 | + | require.Equal(t, 1, encodes[view.Chunk(1)]) | |
| 106 | + | require.Equal(t, 3, encodes[view.Chunk(2)]) | |
| 107 | + | require.Equal(t, 3, encodes[view.Chunk(5)]) | |
| 108 | + | ||
| 109 | + | isolated := newSnapshotChunkCache(cache.limit) | |
| 110 | + | isolatedEncodes := 0 | |
| 111 | + | isolated.marshalChunk = func(chunk *vt.HistoryChunk) ([]byte, error) { | |
| 112 | + | isolatedEncodes++ | |
| 113 | + | return vt.MarshalHistoryChunk(chunk) | |
| 114 | + | } | |
| 115 | + | _, _, err = isolated.objectLocked(view.Chunk(0)) | |
| 116 | + | require.NoError(t, err) | |
| 117 | + | require.Equal(t, 1, isolatedEncodes, "a session cache must not share encoded history with another session") | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) { | |
| 121 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |
| 122 | + | require.NoError(t, history.Append([]renderer.Cell{renderer.BlankCell()})) | |
| 123 | + | view := history.SnapshotView() | |
| 124 | + | visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot() | |
| 125 | + | d := New(nil, nil, nil) | |
| 126 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 127 | + | capture := &snapshotCapture{session: sess, name: "work", generation: 1, tabs: []snapshotCaptureTab{{stableID: "t", cols: 1, rows: 1, panes: []snapshotCapturePane{{id: "p", stableID: "p", sealed: view, tail: view.Tail(), visible: visible}}}}} | |
| 128 | + | first, err := d.incrementalPublication(capture) | |
| 129 | + | if err != nil { | |
| 130 | + | t.Fatal(err) | |
| 131 | + | } | |
| 132 | + | capture.generation = 2 | |
| 133 | + | second, err := d.incrementalPublication(capture) | |
| 134 | + | if err != nil { | |
| 135 | + | t.Fatal(err) | |
| 136 | + | } | |
| 137 | + | if len(first.Objects) == 0 || len(second.Objects) == 0 { | |
| 138 | + | t.Fatal("publication omitted required objects") | |
| 139 | + | } | |
| 140 | + | if got, want := sess.snapshotChunkCache.used, sess.snapshotChunkCache.limit; got > want { | |
| 141 | + | t.Fatalf("cache bytes = %d, limit = %d", got, want) | |
| 142 | + | } | |
| 143 | + | } | |
A internal/usecase/daemon/snapshot_legacy_import.go +44 -0
| @@ -0,0 +1,44 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func legacyPublication(snapshot snapcodec.Session) (ports.SnapshotPublication, error) { | |
| 11 | + | manifest := snapcodec.Manifest{Generation: 1, Name: snapshot.Name, CreatedAt: snapshot.CreatedAt, Active: snapshot.Active, Tabs: make([]snapcodec.ManifestTab, 0, len(snapshot.Tabs))} | |
| 12 | + | objects := make([]ports.SnapshotObject, 0) | |
| 13 | + | for _, tab := range snapshot.Tabs { | |
| 14 | + | outTab := snapcodec.ManifestTab{StableID: tab.StableID, Cols: tab.Cols, Rows: tab.Rows, NextPaneID: tab.NextPaneID, Focus: tab.Focus, Tree: tab.Tree, Panes: make([]snapcodec.ManifestPane, 0, len(tab.Panes))} | |
| 15 | + | for _, pane := range tab.Panes { | |
| 16 | + | outPane := snapcodec.ManifestPane{ID: pane.ID, StableID: pane.StableID, Cwd: pane.Cwd, Process: pane.Process} | |
| 17 | + | for i, payload := range pane.SealedChunks { | |
| 18 | + | object, err := snapcodec.MarshalObject(snapcodec.HistoryChunk, payload) | |
| 19 | + | if err != nil { | |
| 20 | + | return ports.SnapshotPublication{}, fmt.Errorf("snapshot legacy import: tab %q pane %q sealed chunk %d: marshal object: %w", tab.StableID, pane.ID, i, err) | |
| 21 | + | } | |
| 22 | + | outPane.Sealed = append(outPane.Sealed, objectRef(snapcodec.HistoryChunk, object)) | |
| 23 | + | objects = append(objects, object) | |
| 24 | + | } | |
| 25 | + | tail, err := snapcodec.MarshalObject(snapcodec.HistoryTail, pane.Tail) | |
| 26 | + | if err != nil { | |
| 27 | + | return ports.SnapshotPublication{}, fmt.Errorf("snapshot legacy import: tab %q pane %q tail: marshal object: %w", tab.StableID, pane.ID, err) | |
| 28 | + | } | |
| 29 | + | visible, err := snapcodec.MarshalObject(snapcodec.Visible, pane.Visible) | |
| 30 | + | if err != nil { | |
| 31 | + | return ports.SnapshotPublication{}, fmt.Errorf("snapshot legacy import: tab %q pane %q visible: marshal object: %w", tab.StableID, pane.ID, err) | |
| 32 | + | } | |
| 33 | + | outPane.Tail, outPane.Visible = objectRef(snapcodec.HistoryTail, tail), objectRef(snapcodec.Visible, visible) | |
| 34 | + | objects = append(objects, tail, visible) | |
| 35 | + | outTab.Panes = append(outTab.Panes, outPane) | |
| 36 | + | } | |
| 37 | + | manifest.Tabs = append(manifest.Tabs, outTab) | |
| 38 | + | } | |
| 39 | + | encoded, err := snapcodec.MarshalManifest(manifest) | |
| 40 | + | if err != nil { | |
| 41 | + | return ports.SnapshotPublication{}, fmt.Errorf("snapshot: marshal legacy import manifest: %w", err) | |
| 42 | + | } | |
| 43 | + | return ports.SnapshotPublication{Name: snapshot.Name, Generation: 1, Manifest: encoded, Objects: objects}, nil | |
| 44 | + | } | |
D internal/usecase/daemon/snapshot_notice_test.go +0 -121
| @@ -1,121 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "fmt" | |
| 6 | - | "sync/atomic" | |
| 7 | - | "testing" | |
| 8 | - | ||
| 9 | - | "github.com/stretchr/testify/mock" | |
| 10 | - | "github.com/stretchr/testify/require" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/vev/internal/domain" | |
| 13 | - | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 14 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | // TestSnapshotWriteFailureNotifiesGlobally proves a failed snapshot write surfaces | |
| 18 | - | // as a GLOBAL notice: the failing session is never registered (it stands in for a | |
| 19 | - | // session already torn down by the time its async write fails), yet a client | |
| 20 | - | // attached to a DIFFERENT, surviving session still receives the toast. This is the | |
| 21 | - | // whole point of routing through NotifyGlobal instead of a session-scoped notice. | |
| 22 | - | func TestSnapshotWriteFailureNotifiesGlobally(t *testing.T) { | |
| 23 | - | // newNoticeFixture registers a survivor session "work"/"manual" owned by ac. | |
| 24 | - | d, _, survivorClient, _ := newNoticeFixture(t, newNoticeClock()) | |
| 25 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 26 | - | WithSnapshotStore(store)(d) | |
| 27 | - | d.procCwd = func(int) (string, error) { return "/pane", nil } | |
| 28 | - | startSnapshotEncodeWorker(t, d) | |
| 29 | - | ||
| 30 | - | // The failing session is deliberately NOT added to d.sessions. | |
| 31 | - | failing := newSnapshotTestSession(t, "failing", false, "/fallback") | |
| 32 | - | ||
| 33 | - | store.EXPECT().Write("failing", mock.Anything).RunAndReturn(func(string, []byte) error { | |
| 34 | - | return errors.New("disk full") | |
| 35 | - | }).Once() | |
| 36 | - | ||
| 37 | - | require.True(t, d.captureSession(failing)) | |
| 38 | - | ||
| 39 | - | // The write fails on the worker goroutine; awaitToastCount is the bounded, | |
| 40 | - | // poll-only completion signal. No require runs on the worker goroutine. | |
| 41 | - | toasts := awaitToastCount(t, survivorClient, 1) | |
| 42 | - | require.Equal(t, domain.NoticeSnapshotWrite, toasts[0].Code) | |
| 43 | - | require.Equal(t, "couldn't save session failing; recent state may be lost on restart", toasts[0].Message) | |
| 44 | - | require.Equal(t, domain.NoticeError, toasts[0].Severity) | |
| 45 | - | require.Equal(t, domain.SessionID(""), toasts[0].SessionID, | |
| 46 | - | "snapshot write failures are global, never scoped to the (possibly gone) failing session") | |
| 47 | - | require.Contains(t, toasts[0].Details, "disk full") | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | // TestCloseLastTabSaturatedSnapshotSurfacesNotice proves that when closing the | |
| 51 | - | // last tab of a named session is refused because the snapshot worker cannot | |
| 52 | - | // retain the final state, the session survives for a retry and the still | |
| 53 | - | // attached client is told why, instead of the close silently no-oping. | |
| 54 | - | // | |
| 55 | - | // The snapshot worker is saturated deterministically with the same fixture as | |
| 56 | - | // the kill-session ordering tests: one capture blocked in marshal, one in the | |
| 57 | - | // bounded queue, and the terminal-retention fallback filled to capacity. No | |
| 58 | - | // assertion runs on the worker goroutine; the marshal gate is released only on | |
| 59 | - | // cleanup, after every assertion has run on the test goroutine. | |
| 60 | - | func TestCloseLastTabSaturatedSnapshotSurfacesNotice(t *testing.T) { | |
| 61 | - | store := discardSnapshotStore{} | |
| 62 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 63 | - | WithSnapshotStore(store)(d) | |
| 64 | - | startSnapshotEncodeWorker(t, d) | |
| 65 | - | ||
| 66 | - | entered := make(chan struct{}) | |
| 67 | - | release := make(chan struct{}) | |
| 68 | - | // Release the sole blocked marshal on the test goroutine before the worker | |
| 69 | - | // is stopped in cleanup, so teardown never waits on the gate. | |
| 70 | - | defer close(release) | |
| 71 | - | var calls atomic.Int32 | |
| 72 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 73 | - | if calls.Add(1) == 1 { | |
| 74 | - | close(entered) | |
| 75 | - | <-release | |
| 76 | - | } | |
| 77 | - | return snapcodec.Marshal(s) | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | active := newSnapshotTestSession(t, "active", false, "/work") | |
| 81 | - | queued := newSnapshotTestSession(t, "queued", false, "/work") | |
| 82 | - | markSnapshotDirty(active) | |
| 83 | - | markSnapshotDirty(queued) | |
| 84 | - | require.True(t, d.scheduleSnapshot(active)) | |
| 85 | - | <-entered // the worker is now parked inside marshal for active | |
| 86 | - | require.True(t, d.scheduleSnapshot(queued), "one capture waits in the bounded queue") | |
| 87 | - | for i := range snapshotFinalQueueCapacity { | |
| 88 | - | s := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work") | |
| 89 | - | markSnapshotDirty(s) | |
| 90 | - | require.True(t, d.scheduleFinalSnapshot(s), "fill the terminal-retention fallback to capacity") | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | // A single-tab named session whose client is still attached. | |
| 94 | - | target := newSnapshotTestSession(t, "target", false, "/work") | |
| 95 | - | tr, _ := newCapturingTransport(t) | |
| 96 | - | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 97 | - | ac.initOverlays() | |
| 98 | - | ac.setSession(target) | |
| 99 | - | target.client = ac | |
| 100 | - | d.mu.Lock() | |
| 101 | - | d.sessions[target.id] = target | |
| 102 | - | d.mu.Unlock() | |
| 103 | - | require.Len(t, target.tabs, 1) | |
| 104 | - | ||
| 105 | - | d.closeTab(target, target.tabs[0], false) | |
| 106 | - | ||
| 107 | - | // The refused close retains the session for a retry. | |
| 108 | - | d.mu.Lock() | |
| 109 | - | require.Same(t, target, d.sessions[target.id], "a refused close of the last tab must retain the session") | |
| 110 | - | d.mu.Unlock() | |
| 111 | - | ||
| 112 | - | // The refusal is recorded in history and shown on the still-attached client. | |
| 113 | - | hist := d.notices.history() | |
| 114 | - | require.NotEmpty(t, hist, "the refused close must be recorded as a notice") | |
| 115 | - | require.Equal(t, domain.NoticeSnapshotSaturated, hist[0].Code) | |
| 116 | - | require.Equal(t, target.id, hist[0].SessionID) | |
| 117 | - | ||
| 118 | - | toasts := awaitToastCount(t, ac, 1) | |
| 119 | - | require.Equal(t, domain.NoticeSnapshotSaturated, toasts[0].Code) | |
| 120 | - | require.Equal(t, "couldn't close tab: session state not yet saved; try again", toasts[0].Message) | |
| 121 | - | } | |
A internal/usecase/daemon/snapshot_queue.go +144 -0
| @@ -0,0 +1,144 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import "context" | |
| 4 | + | ||
| 5 | + | func (d *Daemon) scheduleSnapshot(sess *session) bool { | |
| 6 | + | return d.scheduleSnapshotWithFinalFallback(sess, false) | |
| 7 | + | } | |
| 8 | + | ||
| 9 | + | // scheduleFinalSnapshot captures the terminal state even when an older capture | |
| 10 | + | // is pending. A removed session cannot be retried by the repository scheduler, so the | |
| 11 | + | // worker drains its retained terminal state before it stops. | |
| 12 | + | func (d *Daemon) scheduleFinalSnapshot(sess *session) bool { | |
| 13 | + | return d.scheduleSnapshotWithFinalFallback(sess, true) | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool { | |
| 17 | + | if d == nil || sess == nil || !d.snapsEnabled || d.snapshotRepository == nil || !sess.snapEligible.Load() { | |
| 18 | + | return true | |
| 19 | + | } | |
| 20 | + | kind := snapshotAttemptRoutine | |
| 21 | + | if final { | |
| 22 | + | kind = snapshotAttemptForced | |
| 23 | + | } | |
| 24 | + | sess.snapshotMu.Lock() | |
| 25 | + | if sess.snapshotQuarantined || !sess.snapDirty.Load() { | |
| 26 | + | available := !sess.snapshotQuarantined | |
| 27 | + | sess.snapshotMu.Unlock() | |
| 28 | + | return available | |
| 29 | + | } | |
| 30 | + | if final && sess.snapshotGeneration > sess.snapshotForcedGeneration { | |
| 31 | + | // Do not let an older queued or in-flight routine publication satisfy a | |
| 32 | + | // terminal checkpoint. The intent is retained until a publication at | |
| 33 | + | // this generation (or newer) succeeds. | |
| 34 | + | sess.snapshotForcedGeneration = sess.snapshotGeneration | |
| 35 | + | } | |
| 36 | + | if sess.snapshotPending || | |
| 37 | + | (!final && !sess.snapshotNextEligibleAt.IsZero() && d.clock.Now().Before(sess.snapshotNextEligibleAt)) { | |
| 38 | + | sess.snapshotMu.Unlock() | |
| 39 | + | return true | |
| 40 | + | } | |
| 41 | + | if sess.snapshotPublicationContext == nil { | |
| 42 | + | sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background()) | |
| 43 | + | } | |
| 44 | + | // A capture's repository generation advances only from the last successful | |
| 45 | + | // repository publication. Mutations are independently versioned, so several | |
| 46 | + | // changes coalesced before this checkpoint still publish the immediate next | |
| 47 | + | // repository generation and a failed attempt retries that same generation. | |
| 48 | + | generation := sess.snapshotPublishedGeneration + 1 | |
| 49 | + | mutationRevision := sess.snapshotGeneration | |
| 50 | + | // A restored or test-created dirty session can predate mutation revision | |
| 51 | + | // tracking. Normalize that state into the first revision before capture. | |
| 52 | + | if mutationRevision == 0 { | |
| 53 | + | mutationRevision = 1 | |
| 54 | + | sess.snapshotGeneration = mutationRevision | |
| 55 | + | } | |
| 56 | + | publicationContext := sess.snapshotPublicationContext | |
| 57 | + | sess.snapshotPendingCaptures++ | |
| 58 | + | sess.snapshotPending = true | |
| 59 | + | sess.signalSnapshotChangedLocked() | |
| 60 | + | sess.snapshotMu.Unlock() | |
| 61 | + | ||
| 62 | + | capture, ok := d.captureSnapshotState(sess, generation) | |
| 63 | + | if capture != nil { | |
| 64 | + | capture.mutationRevision = mutationRevision | |
| 65 | + | capture.attemptKind = kind | |
| 66 | + | capture.publicationContext = publicationContext | |
| 67 | + | } | |
| 68 | + | if !ok { | |
| 69 | + | d.finishSnapshotCapture(capture, false) | |
| 70 | + | return false | |
| 71 | + | } | |
| 72 | + | sess.snapshotMu.Lock() | |
| 73 | + | quarantined := sess.snapshotQuarantined | |
| 74 | + | if !quarantined { | |
| 75 | + | sess.snapshotQueuedCapture = capture | |
| 76 | + | } | |
| 77 | + | sess.snapshotMu.Unlock() | |
| 78 | + | if quarantined { | |
| 79 | + | d.finishSnapshotCapture(capture, false) | |
| 80 | + | return false | |
| 81 | + | } | |
| 82 | + | if d.enqueueSnapshotCapture(capture) || (final && d.enqueueFinalSnapshotCapture(capture)) { | |
| 83 | + | return true | |
| 84 | + | } | |
| 85 | + | // Coalesce under saturation or shutdown: the latest state stays dirty and | |
| 86 | + | // will be captured on a later tick once an active worker has room. | |
| 87 | + | d.finishSnapshotCapture(capture, false) | |
| 88 | + | return false | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // enqueueSnapshotCapture serializes worker shutdown with the non-blocking | |
| 92 | + | // queue send. The queue is deliberately never closed: producers can race | |
| 93 | + | // shutdown without risking a send-on-closed panic. | |
| 94 | + | func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool { | |
| 95 | + | d.snapshotWorkerMu.Lock() | |
| 96 | + | defer d.snapshotWorkerMu.Unlock() | |
| 97 | + | if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil { | |
| 98 | + | return false | |
| 99 | + | } | |
| 100 | + | select { | |
| 101 | + | case d.snapshotJobs <- capture: | |
| 102 | + | return true | |
| 103 | + | default: | |
| 104 | + | return false | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // enqueueFinalSnapshotCapture hands terminal captures to the existing worker | |
| 109 | + | // without making session teardown wait behind its normal bounded queue. A | |
| 110 | + | // blocked worker retains at most one terminal capture per session, replacing | |
| 111 | + | // stale state with the newest immutable capture. | |
| 112 | + | func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool { | |
| 113 | + | if capture == nil || capture.session == nil { | |
| 114 | + | return false | |
| 115 | + | } | |
| 116 | + | d.snapshotWorkerMu.Lock() | |
| 117 | + | if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil { | |
| 118 | + | d.snapshotWorkerMu.Unlock() | |
| 119 | + | return false | |
| 120 | + | } | |
| 121 | + | if d.snapshotFinalJobs == nil { | |
| 122 | + | d.snapshotFinalJobs = make(map[*session]*snapshotCapture) | |
| 123 | + | } | |
| 124 | + | replaced, exists := d.snapshotFinalJobs[capture.session] | |
| 125 | + | if !exists && len(d.snapshotFinalJobs) >= snapshotFinalQueueCapacity { | |
| 126 | + | d.snapshotWorkerMu.Unlock() | |
| 127 | + | d.log.Warn("terminal snapshot retention saturated; capture rejected", "session", capture.name, "capacity", snapshotFinalQueueCapacity) | |
| 128 | + | return false | |
| 129 | + | } | |
| 130 | + | // Finish the stale capture before publishing its replacement. Otherwise a | |
| 131 | + | // fast worker could persist the replacement and then have this stale failure | |
| 132 | + | // mark the session dirty again. | |
| 133 | + | d.finishSnapshotCapture(replaced, false) | |
| 134 | + | if !exists { | |
| 135 | + | d.snapshotFinalOrder = append(d.snapshotFinalOrder, capture.session) | |
| 136 | + | } | |
| 137 | + | d.snapshotFinalJobs[capture.session] = capture | |
| 138 | + | select { | |
| 139 | + | case d.snapshotWorkerFinalWake <- struct{}{}: | |
| 140 | + | default: | |
| 141 | + | } | |
| 142 | + | d.snapshotWorkerMu.Unlock() | |
| 143 | + | return true | |
| 144 | + | } | |
A internal/usecase/daemon/snapshot_restore.go +312 -0
| @@ -0,0 +1,312 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "math" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 11 | + | "github.com/bnema/vev/pkg/vt" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func (d *Daemon) closeRestoreDone() { | |
| 15 | + | d.restoreOnce.Do(func() { close(d.restoreDone) }) | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func (d *Daemon) restoreSnapshots(ctx context.Context) { | |
| 19 | + | if d == nil { | |
| 20 | + | return | |
| 21 | + | } | |
| 22 | + | defer d.closeRestoreDone() | |
| 23 | + | if ns := d.noticeStore; ns != nil { | |
| 24 | + | claimed, err := ns.Claim() | |
| 25 | + | if err != nil { | |
| 26 | + | d.log.Warn("claiming pending notices failed", "err", err) | |
| 27 | + | } else { | |
| 28 | + | for _, n := range claimed { | |
| 29 | + | d.notices.record(n) | |
| 30 | + | d.notices.queueGlobal(n) | |
| 31 | + | } | |
| 32 | + | // Do not discard the claim until every notice has been recorded and | |
| 33 | + | // queued. An Ack failure is safe: the claim is replayed at startup. | |
| 34 | + | if err := ns.Ack(); err != nil { | |
| 35 | + | d.log.Warn("acknowledging pending notices failed", "err", err) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | if !d.snapsEnabled { | |
| 40 | + | return | |
| 41 | + | } | |
| 42 | + | if d.snapshotRepository != nil { | |
| 43 | + | d.restoreIncrementalSnapshots(ctx) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func validateRestoreSessionSnapshot(snap snapcodec.Session) error { | |
| 49 | + | if len(snap.Tabs) == 0 { | |
| 50 | + | if snap.Active != 0 { | |
| 51 | + | return fmt.Errorf("snapshot: active tab out of range") | |
| 52 | + | } | |
| 53 | + | } else if int(snap.Active) >= len(snap.Tabs) { | |
| 54 | + | return fmt.Errorf("snapshot: active tab out of range") | |
| 55 | + | } | |
| 56 | + | if snap.Name == "" { | |
| 57 | + | return fmt.Errorf("snapshot: empty session name") | |
| 58 | + | } | |
| 59 | + | if snap.CreatedAt > math.MaxInt64 { | |
| 60 | + | return fmt.Errorf("snapshot: created_at overflows int64") | |
| 61 | + | } | |
| 62 | + | return nil | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // restoreSession owns the restored tabs until registration succeeds. Every | |
| 66 | + | // unsuccessful path closes their PTYs and cancels their shared session context. | |
| 67 | + | func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session, repositoryGeneration uint64) error { | |
| 68 | + | if err := ctx.Err(); err != nil { | |
| 69 | + | return err | |
| 70 | + | } | |
| 71 | + | if err := validateRestoreSessionSnapshot(snap); err != nil { | |
| 72 | + | return err | |
| 73 | + | } | |
| 74 | + | if d.restoredSessionAlreadyExists(snap.Name) { | |
| 75 | + | return nil | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | parent := d.serveCtx | |
| 79 | + | if parent == nil { | |
| 80 | + | parent = context.Background() | |
| 81 | + | } | |
| 82 | + | sctx, cancel := context.WithCancel(parent) | |
| 83 | + | opened, err := d.restoreSnapshotTabs(ctx, sctx, snap) | |
| 84 | + | if err != nil { | |
| 85 | + | cancel() | |
| 86 | + | return err | |
| 87 | + | } | |
| 88 | + | if len(opened) == 0 { | |
| 89 | + | cancel() | |
| 90 | + | return nil | |
| 91 | + | } | |
| 92 | + | ownsOpened := true | |
| 93 | + | defer func() { | |
| 94 | + | if ownsOpened { | |
| 95 | + | closeRestoredTabs(opened) | |
| 96 | + | cancel() | |
| 97 | + | } | |
| 98 | + | }() | |
| 99 | + | ||
| 100 | + | sess := d.newRestoredSession(snap, sctx, cancel, opened) | |
| 101 | + | // The loaded manifest is the repository head for this name. Future dirty | |
| 102 | + | // checkpoints must continue from it rather than reuse generation one. | |
| 103 | + | sess.snapshotPublishedGeneration = repositoryGeneration | |
| 104 | + | registered, err := d.persistAndRegisterRestoredSession(ctx, sess) | |
| 105 | + | if err != nil || !registered { | |
| 106 | + | return err | |
| 107 | + | } | |
| 108 | + | ownsOpened = false | |
| 109 | + | for _, tb := range opened { | |
| 110 | + | d.startTabGoroutines(sess, tb) | |
| 111 | + | } | |
| 112 | + | return nil | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (d *Daemon) restoredSessionAlreadyExists(name string) bool { | |
| 116 | + | d.mu.Lock() | |
| 117 | + | defer d.mu.Unlock() | |
| 118 | + | return d.closing || d.findByNameLocked(name) != nil | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func (d *Daemon) restoreSnapshotTabs(ctx, sctx context.Context, snap snapcodec.Session) ([]*tab, error) { | |
| 122 | + | // Restore runs before client Hello. Future panes therefore inherit the | |
| 123 | + | // daemon's startup environment until an attach supplies terminal capability. | |
| 124 | + | restoreTerm := terminalEnv{} | |
| 125 | + | restoreEnv := copyEnvironment(d.baseEnv) | |
| 126 | + | allowlist := d.restoreProcessAllowlistSnapshot() | |
| 127 | + | stoppedTabNames := d.restoredSessionTabNames(snap.Name) | |
| 128 | + | opened := make([]*tab, 0, len(snap.Tabs)) | |
| 129 | + | for index, tabSnap := range snap.Tabs { | |
| 130 | + | if err := ctx.Err(); err != nil { | |
| 131 | + | closeRestoredTabs(opened) | |
| 132 | + | return nil, err | |
| 133 | + | } | |
| 134 | + | tabName := "" | |
| 135 | + | if index < len(stoppedTabNames) { | |
| 136 | + | tabName = stoppedTabNames[index] | |
| 137 | + | } | |
| 138 | + | tb, err := d.restoreSnapshotTab(ctx, sctx, snap.Name, tabName, tabSnap, restoreEnv, restoreTerm, allowlist) | |
| 139 | + | if err != nil { | |
| 140 | + | closeRestoredTabs(opened) | |
| 141 | + | return nil, err | |
| 142 | + | } | |
| 143 | + | opened = append(opened, tb) | |
| 144 | + | } | |
| 145 | + | return opened, nil | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // closeRestoredTabs releases every PTY and pane context still owned by a | |
| 149 | + | // failed restore. Registered sessions transfer this ownership to the daemon. | |
| 150 | + | func closeRestoredTabs(tabs []*tab) { | |
| 151 | + | for _, tb := range tabs { | |
| 152 | + | tb.closeAllPanes() | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func (d *Daemon) restoredSessionTabNames(name string) []string { | |
| 157 | + | d.mu.Lock() | |
| 158 | + | defer d.mu.Unlock() | |
| 159 | + | return append([]string(nil), d.stopped[name].tabNames...) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func (d *Daemon) restoreSnapshotTab(ctx, sctx context.Context, sessionName, tabName string, tabSnap snapcodec.Tab, restoreEnv []string, restoreTerm terminalEnv, allowlist map[string]struct{}) (*tab, error) { | |
| 163 | + | tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)} | |
| 164 | + | if !tbSize.Valid() { | |
| 165 | + | return nil, fmt.Errorf("snapshot: invalid tab size") | |
| 166 | + | } | |
| 167 | + | placements, ok := layout.Solve(tabSnap.Tree.Root, domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows}) | |
| 168 | + | if !ok { | |
| 169 | + | return nil, fmt.Errorf("snapshot: unsolvable layout") | |
| 170 | + | } | |
| 171 | + | placementByPane := make(map[layout.PaneID]domain.Rect, len(placements)) | |
| 172 | + | for _, placement := range placements { | |
| 173 | + | placementByPane[placement.ID] = placement.Content | |
| 174 | + | } | |
| 175 | + | tabStableID := tabSnap.StableID | |
| 176 | + | if tabStableID == "" { | |
| 177 | + | var err error | |
| 178 | + | tabStableID, err = newStableID("t") | |
| 179 | + | if err != nil { | |
| 180 | + | return nil, fmt.Errorf("snapshot: generating tab identity: %w", err) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | tb := &tab{stableID: tabStableID, name: tabName, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize} | |
| 184 | + | if tb.nextPaneID <= 0 { | |
| 185 | + | tb.nextPaneID = 1 | |
| 186 | + | } | |
| 187 | + | tb.ctx, tb.cancel = context.WithCancel(sctx) | |
| 188 | + | for _, paneSnap := range tabSnap.Panes { | |
| 189 | + | if err := ctx.Err(); err != nil { | |
| 190 | + | tb.closeAllPanes() | |
| 191 | + | return nil, err | |
| 192 | + | } | |
| 193 | + | contentRect, ok := placementByPane[paneSnap.ID] | |
| 194 | + | if !ok { | |
| 195 | + | tb.closeAllPanes() | |
| 196 | + | return nil, fmt.Errorf("snapshot: missing pane placement") | |
| 197 | + | } | |
| 198 | + | p, err := d.restoreSnapshotPane(ctx, sessionName, tabStableID, paneSnap, contentRect, tbSize, restoreEnv, restoreTerm, allowlist, tb.ctx) | |
| 199 | + | if err != nil { | |
| 200 | + | tb.closeAllPanes() | |
| 201 | + | return nil, err | |
| 202 | + | } | |
| 203 | + | tb.panes[paneSnap.ID] = p | |
| 204 | + | } | |
| 205 | + | return tb, nil | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStableID string, paneSnap snapcodec.Pane, contentRect domain.Rect, tabSize domain.Size, restoreEnv []string, restoreTerm terminalEnv, allowlist map[string]struct{}, tabCtx context.Context) (*pane, error) { | |
| 209 | + | paneStableID := paneSnap.StableID | |
| 210 | + | if paneStableID == "" { | |
| 211 | + | var err error | |
| 212 | + | paneStableID, err = newStableID("p") | |
| 213 | + | if err != nil { | |
| 214 | + | return nil, fmt.Errorf("snapshot: generating pane identity: %w", err) | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | command, args := d.ptyCommand(restoreEnv) | |
| 218 | + | pty, err := d.ptys.Open(ctx, command, args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, restorePTYSize(contentRect, tabSize)) | |
| 219 | + | if err != nil { | |
| 220 | + | return nil, err | |
| 221 | + | } | |
| 222 | + | p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, restorePTYSize(contentRect, tabSize)) | |
| 223 | + | p.ctx, p.cancel = context.WithCancel(tabCtx) | |
| 224 | + | p.rect = contentRect | |
| 225 | + | if err := restorePaneTerminal(p, paneSnap); err != nil { | |
| 226 | + | p.cancel() | |
| 227 | + | _ = pty.Close() | |
| 228 | + | return nil, err | |
| 229 | + | } | |
| 230 | + | if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore { | |
| 231 | + | if _, err := pty.Write([]byte(decision.Command + "\n")); err != nil { | |
| 232 | + | d.log.Warn("writing snapshot restore command failed", "err", err, "session", sessionName, "pane", paneSnap.ID) | |
| 233 | + | d.NotifyGlobal(domain.NoticeWarn, domain.NoticeAutoResume, "couldn't restore the running program in session "+sessionName, err) | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | return p, nil | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session { | |
| 240 | + | sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: terminalEnv{}, env: copyEnvironment(d.baseEnv), createdAt: int64(snap.CreatedAt), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)} | |
| 241 | + | sess.snapEligible.Store(true) | |
| 242 | + | if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 { | |
| 243 | + | sess.cwd = snap.Tabs[0].Panes[0].Cwd | |
| 244 | + | } | |
| 245 | + | d.mu.Lock() | |
| 246 | + | if stopped, ok := d.stopped[snap.Name]; ok { | |
| 247 | + | sess.mruAt.Store(stopped.lastUsedSeq) | |
| 248 | + | } | |
| 249 | + | d.mu.Unlock() | |
| 250 | + | return sess | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | // persistAndRegisterRestoredSession atomically transfers a successfully | |
| 254 | + | // persisted session to the daemon. The caller retains cleanup on false/error. | |
| 255 | + | func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *session) (bool, error) { | |
| 256 | + | if err := d.persist.Save(sess.persistRecordLocked(sess.createdAt)); err != nil { | |
| 257 | + | return false, err | |
| 258 | + | } | |
| 259 | + | d.mu.Lock() | |
| 260 | + | defer d.mu.Unlock() | |
| 261 | + | if d.closing || d.findByNameLocked(sess.name) != nil || ctx.Err() != nil { | |
| 262 | + | return false, nil | |
| 263 | + | } | |
| 264 | + | sess.id = domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |
| 265 | + | d.nextID++ | |
| 266 | + | delete(d.stopped, sess.name) | |
| 267 | + | d.sessions[sess.id] = sess | |
| 268 | + | sess.snapDirty.Store(false) | |
| 269 | + | return true, nil | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func restorePaneTerminal(p *pane, snap snapcodec.Pane) error { | |
| 273 | + | if len(snap.Tail) == 0 { | |
| 274 | + | return fmt.Errorf("snapshot history: missing tail blob") | |
| 275 | + | } | |
| 276 | + | if len(snap.Visible) == 0 { | |
| 277 | + | return fmt.Errorf("snapshot visible: missing visible blob") | |
| 278 | + | } | |
| 279 | + | p.mu.Lock() | |
| 280 | + | defer p.mu.Unlock() | |
| 281 | + | screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells}, snap.SealedChunks, snap.Tail) | |
| 282 | + | if err != nil { | |
| 283 | + | return fmt.Errorf("snapshot history: %w", err) | |
| 284 | + | } | |
| 285 | + | if err := screen.RestorePrimaryVisible(snap.Visible); err != nil { | |
| 286 | + | return fmt.Errorf("snapshot visible: %w", err) | |
| 287 | + | } | |
| 288 | + | p.screen = screen | |
| 289 | + | p.history = screen.History() | |
| 290 | + | return nil | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size { | |
| 294 | + | cols := contentRect.Width | |
| 295 | + | if cols <= 0 { | |
| 296 | + | cols = tabSize.Cols | |
| 297 | + | } | |
| 298 | + | if cols < layout.MinPaneCols { | |
| 299 | + | cols = layout.MinPaneCols | |
| 300 | + | } | |
| 301 | + | rows := contentRect.Height | |
| 302 | + | if rows <= 0 { | |
| 303 | + | rows = min(max(tabSize.Rows, layout.MinPaneRows), 24) | |
| 304 | + | } | |
| 305 | + | if rows < layout.MinPaneRows { | |
| 306 | + | rows = layout.MinPaneRows | |
| 307 | + | } | |
| 308 | + | return domain.Size{Cols: cols, Rows: rows} | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | // scheduleSnapshot captures a session only once while an immutable capture is | |
| 312 | + | // queued or being written. It never waits for the bounded worker queue. | |
A internal/usecase/daemon/snapshot_restore_incremental.go +215 -0
| @@ -0,0 +1,215 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "crypto/sha256" | |
| 7 | + | "fmt" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // restoreIncrementalSnapshots imports v3 only before listing repository | |
| 15 | + | // sessions, then restores each independently. A corrupt session therefore | |
| 16 | + | // cannot prevent an unrelated saved session from starting. | |
| 17 | + | func (d *Daemon) restoreIncrementalSnapshots(ctx context.Context) { | |
| 18 | + | d.importLegacySnapshots(ctx) | |
| 19 | + | names, err := d.snapshotRepository.List(ctx) | |
| 20 | + | if err != nil { | |
| 21 | + | d.log.Warn("listing session snapshots failed", "err", err) | |
| 22 | + | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, "couldn't load saved sessions after restart", err) | |
| 23 | + | return | |
| 24 | + | } | |
| 25 | + | for _, name := range names { | |
| 26 | + | if err := ctx.Err(); err != nil { | |
| 27 | + | return | |
| 28 | + | } | |
| 29 | + | generation, err := d.snapshotRepository.Load(ctx, name) | |
| 30 | + | if err != nil { | |
| 31 | + | d.snapshotRestoreFailure(name, err) | |
| 32 | + | continue | |
| 33 | + | } | |
| 34 | + | if generation.Fallback { | |
| 35 | + | d.NotifyGlobal(domain.NoticeWarn, domain.NoticeSnapshotRestore, "restored an older saved checkpoint for session "+name, nil) | |
| 36 | + | } | |
| 37 | + | snapshot, err := sessionFromGeneration(generation) | |
| 38 | + | if err != nil { | |
| 39 | + | d.snapshotRestoreFailure(name, err) | |
| 40 | + | continue | |
| 41 | + | } | |
| 42 | + | if err := d.restoreSession(ctx, snapshot, generation.Generation); err != nil { | |
| 43 | + | if ctx.Err() == nil { | |
| 44 | + | d.snapshotRestoreFailure(name, err) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (d *Daemon) snapshotRestoreFailure(name string, err error) { | |
| 51 | + | d.log.Warn("restoring session snapshot failed", "err", err, "session", name) | |
| 52 | + | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, "couldn't restore session "+name+" after restart", err) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func sessionFromGeneration(generation ports.SnapshotGeneration) (snapcodec.Session, error) { | |
| 56 | + | manifest, err := snapcodec.UnmarshalManifest(generation.Manifest) | |
| 57 | + | if err != nil { | |
| 58 | + | return snapcodec.Session{}, err | |
| 59 | + | } | |
| 60 | + | if manifest.Name != generation.Name || manifest.Generation != generation.Generation { | |
| 61 | + | return snapcodec.Session{}, fmt.Errorf("snapshot: generation identity mismatch") | |
| 62 | + | } | |
| 63 | + | result := snapcodec.Session{Name: manifest.Name, CreatedAt: manifest.CreatedAt, Active: manifest.Active, Tabs: make([]snapcodec.Tab, 0, len(manifest.Tabs))} | |
| 64 | + | for _, tab := range manifest.Tabs { | |
| 65 | + | outTab := snapcodec.Tab{StableID: tab.StableID, Cols: tab.Cols, Rows: tab.Rows, NextPaneID: tab.NextPaneID, Focus: tab.Focus, Tree: tab.Tree, Panes: make([]snapcodec.Pane, 0, len(tab.Panes))} | |
| 66 | + | for _, pane := range tab.Panes { | |
| 67 | + | outPane := snapcodec.Pane{ID: pane.ID, StableID: pane.StableID, Cwd: pane.Cwd, Process: pane.Process} | |
| 68 | + | for _, ref := range pane.Sealed { | |
| 69 | + | data, err := generationObject(generation, ref, snapcodec.HistoryChunk) | |
| 70 | + | if err != nil { | |
| 71 | + | return snapcodec.Session{}, err | |
| 72 | + | } | |
| 73 | + | outPane.SealedChunks = append(outPane.SealedChunks, data) | |
| 74 | + | } | |
| 75 | + | if outPane.Tail, err = generationObject(generation, pane.Tail, snapcodec.HistoryTail); err != nil { | |
| 76 | + | return snapcodec.Session{}, err | |
| 77 | + | } | |
| 78 | + | if outPane.Visible, err = generationObject(generation, pane.Visible, snapcodec.Visible); err != nil { | |
| 79 | + | return snapcodec.Session{}, err | |
| 80 | + | } | |
| 81 | + | outTab.Panes = append(outTab.Panes, outPane) | |
| 82 | + | } | |
| 83 | + | result.Tabs = append(result.Tabs, outTab) | |
| 84 | + | } | |
| 85 | + | return result, nil | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func generationObject(generation ports.SnapshotGeneration, ref snapcodec.ObjectRef, kind snapcodec.ObjectKind) ([]byte, error) { | |
| 89 | + | if ref.Kind != kind { | |
| 90 | + | return nil, fmt.Errorf("snapshot: object kind mismatch") | |
| 91 | + | } | |
| 92 | + | data, ok := generation.Objects[ref.Digest] | |
| 93 | + | if !ok || uint32(len(data)) != ref.Size || sha256.Sum256(data) != ref.Digest { | |
| 94 | + | return nil, fmt.Errorf("snapshot: missing object") | |
| 95 | + | } | |
| 96 | + | gotKind, payload, err := snapcodec.UnmarshalObject(data) | |
| 97 | + | if err != nil || gotKind != kind { | |
| 98 | + | return nil, fmt.Errorf("snapshot: invalid object") | |
| 99 | + | } | |
| 100 | + | return append([]byte(nil), payload...), nil | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // importLegacySnapshots is intentionally best effort per session. It deletes | |
| 104 | + | // a legacy blob only after publication and a full repository reload verify the | |
| 105 | + | // exact converted session, leaving failures retryable on the next startup. | |
| 106 | + | func (d *Daemon) importLegacySnapshots(ctx context.Context) { | |
| 107 | + | if d.legacySnapshots == nil || d.snapshotRepository == nil || ctx.Err() != nil { | |
| 108 | + | return | |
| 109 | + | } | |
| 110 | + | existing, err := d.snapshotRepository.List(ctx) | |
| 111 | + | if err != nil { | |
| 112 | + | d.log.Warn("listing incremental snapshots before import failed", "err", err) | |
| 113 | + | return | |
| 114 | + | } | |
| 115 | + | present := make(map[string]struct{}, len(existing)) | |
| 116 | + | for _, name := range existing { | |
| 117 | + | present[name] = struct{}{} | |
| 118 | + | } | |
| 119 | + | legacy, err := d.legacySnapshots.LoadLegacy(ctx) | |
| 120 | + | if err != nil { | |
| 121 | + | d.log.Warn("loading legacy snapshots failed", "err", err) | |
| 122 | + | return | |
| 123 | + | } | |
| 124 | + | for _, blob := range legacy { | |
| 125 | + | if ctx.Err() != nil { | |
| 126 | + | return | |
| 127 | + | } | |
| 128 | + | if _, ok := present[blob.Name]; ok { | |
| 129 | + | d.retryLegacyDelete(ctx, blob) | |
| 130 | + | continue | |
| 131 | + | } | |
| 132 | + | snapshot, err := snapcodec.Unmarshal(blob.Data) | |
| 133 | + | if err != nil || snapshot.Name != blob.Name { | |
| 134 | + | d.log.Warn("importing legacy snapshot failed", "session", blob.Name, "err", err) | |
| 135 | + | continue | |
| 136 | + | } | |
| 137 | + | publication, err := legacyPublication(snapshot) | |
| 138 | + | if err == nil { | |
| 139 | + | err = d.snapshotRepository.Publish(ctx, publication) | |
| 140 | + | } | |
| 141 | + | if err == nil { | |
| 142 | + | var generation ports.SnapshotGeneration | |
| 143 | + | generation, err = d.snapshotRepository.Load(ctx, snapshot.Name) | |
| 144 | + | if err == nil { | |
| 145 | + | err = verifyLegacyImportGeneration(generation, publication) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | if err != nil { | |
| 149 | + | d.log.Warn("importing legacy snapshot failed", "session", blob.Name, "err", err) | |
| 150 | + | continue | |
| 151 | + | } | |
| 152 | + | d.rememberLegacyDelete(blob) | |
| 153 | + | if err := d.legacySnapshots.DeleteVerifiedLegacy(ctx, blob); err != nil { | |
| 154 | + | d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err) | |
| 155 | + | continue | |
| 156 | + | } | |
| 157 | + | d.forgetLegacyDelete(blob.Name) | |
| 158 | + | present[blob.Name] = struct{}{} | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func verifyLegacyImportGeneration(generation ports.SnapshotGeneration, publication ports.SnapshotPublication) error { | |
| 163 | + | if generation.Name != publication.Name || generation.Generation != publication.Generation || !bytes.Equal(generation.Manifest, publication.Manifest) { | |
| 164 | + | return fmt.Errorf("snapshot: legacy import verification mismatch") | |
| 165 | + | } | |
| 166 | + | expected := make(map[ports.SnapshotDigest][]byte, len(publication.Objects)) | |
| 167 | + | for _, object := range publication.Objects { | |
| 168 | + | if sha256.Sum256(object.Data) != object.Digest { | |
| 169 | + | return fmt.Errorf("snapshot: legacy import publication digest mismatch") | |
| 170 | + | } | |
| 171 | + | expected[object.Digest] = object.Data | |
| 172 | + | } | |
| 173 | + | if len(generation.Objects) != len(expected) { | |
| 174 | + | return fmt.Errorf("snapshot: legacy import verification mismatch") | |
| 175 | + | } | |
| 176 | + | for digest, expectedData := range expected { | |
| 177 | + | actual, ok := generation.Objects[digest] | |
| 178 | + | if !ok || !bytes.Equal(actual, expectedData) || sha256.Sum256(actual) != digest { | |
| 179 | + | return fmt.Errorf("snapshot: legacy import verification mismatch") | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | return nil | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func (d *Daemon) rememberLegacyDelete(blob ports.LegacySnapshot) { | |
| 186 | + | d.legacyImportMu.Lock() | |
| 187 | + | if d.legacyImportPending == nil { | |
| 188 | + | d.legacyImportPending = make(map[string][]byte) | |
| 189 | + | } | |
| 190 | + | d.legacyImportPending[blob.Name] = append([]byte(nil), blob.Data...) | |
| 191 | + | d.legacyImportMu.Unlock() | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func (d *Daemon) forgetLegacyDelete(name string) { | |
| 195 | + | d.legacyImportMu.Lock() | |
| 196 | + | delete(d.legacyImportPending, name) | |
| 197 | + | d.legacyImportMu.Unlock() | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // retryLegacyDelete only acts on a blob which this daemon has already | |
| 201 | + | // published and exactly reloaded. An unrelated pre-existing generation must | |
| 202 | + | // continue to leave its legacy counterpart untouched. | |
| 203 | + | func (d *Daemon) retryLegacyDelete(ctx context.Context, blob ports.LegacySnapshot) { | |
| 204 | + | d.legacyImportMu.Lock() | |
| 205 | + | pending, ok := d.legacyImportPending[blob.Name] | |
| 206 | + | d.legacyImportMu.Unlock() | |
| 207 | + | if !ok || !bytes.Equal(pending, blob.Data) { | |
| 208 | + | return | |
| 209 | + | } | |
| 210 | + | if err := d.legacySnapshots.DeleteVerifiedLegacy(ctx, blob); err != nil { | |
| 211 | + | d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err) | |
| 212 | + | return | |
| 213 | + | } | |
| 214 | + | d.forgetLegacyDelete(blob.Name) | |
| 215 | + | } | |
M internal/usecase/daemon/snapshot_restore_test.go +244 -730
| @@ -2,794 +2,308 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "encoding/binary" | |
| 6 | 5 | "errors" | |
| 7 | - | "io" | |
| 8 | 6 | "math" | |
| 9 | 7 | "sync" | |
| 10 | 8 | "testing" | |
| 11 | - | "time" | |
| 12 | 9 | ||
| 13 | 10 | "github.com/stretchr/testify/require" | |
| 14 | 11 | ||
| 15 | 12 | "github.com/bnema/vev/internal/domain" | |
| 16 | 13 | "github.com/bnema/vev/internal/ports" | |
| 17 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 18 | 14 | "github.com/bnema/vev/internal/usecase/layout" | |
| 19 | 15 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 20 | 16 | "github.com/bnema/vev/pkg/renderer" | |
| 21 | 17 | "github.com/bnema/vev/pkg/vt" | |
| 22 | 18 | ) | |
| 23 | 19 | ||
| 24 | - | func terminalPane(t *testing.T, id, stableID, cwd string, historyRows, visibleRows [][]renderer.Cell) snapcodec.Pane { | |
| 25 | - | t.Helper() | |
| 26 | - | h := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows}) | |
| 27 | - | for _, row := range historyRows { | |
| 28 | - | h.Append(row) | |
| 29 | - | } | |
| 30 | - | sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView()) | |
| 31 | - | require.NoError(t, err) | |
| 32 | - | frame := renderer.NewFrame(40, 24) | |
| 33 | - | for y, row := range visibleRows { | |
| 34 | - | copy(frame.Row(y), row) | |
| 35 | - | } | |
| 36 | - | visible, err := vt.MarshalVisible(frame) | |
| 37 | - | require.NoError(t, err) | |
| 38 | - | return snapcodec.Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible} | |
| 20 | + | type snapshotAcceptanceRepository struct { | |
| 21 | + | mu sync.Mutex | |
| 22 | + | names []string | |
| 23 | + | generations map[string]ports.SnapshotGeneration | |
| 24 | + | publishErr error | |
| 25 | + | listErr error | |
| 26 | + | loadErr error | |
| 27 | + | publishes []ports.SnapshotPublication | |
| 28 | + | loadMutate func(ports.SnapshotGeneration, ports.SnapshotPublication) ports.SnapshotGeneration | |
| 39 | 29 | } | |
| 40 | 30 | ||
| 41 | - | func TestRestorePaneTerminalRejectsMissingOrMalformedCanonicalBlobs(t *testing.T) { | |
| 42 | - | valid := terminalPane(t, "pane", "p", "/work", nil, nil) | |
| 43 | - | tests := []struct { | |
| 44 | - | name string | |
| 45 | - | mutate func(*snapcodec.Pane) | |
| 46 | - | }{ | |
| 47 | - | {name: "missing tail", mutate: func(p *snapcodec.Pane) { p.Tail = nil }}, | |
| 48 | - | {name: "missing visible", mutate: func(p *snapcodec.Pane) { p.Visible = nil }}, | |
| 49 | - | {name: "malformed tail", mutate: func(p *snapcodec.Pane) { p.Tail = []byte("bad") }}, | |
| 50 | - | {name: "malformed visible", mutate: func(p *snapcodec.Pane) { p.Visible = []byte("bad") }}, | |
| 31 | + | func (r *snapshotAcceptanceRepository) Publish(ctx context.Context, p ports.SnapshotPublication) error { | |
| 32 | + | if err := ctx.Err(); err != nil { | |
| 33 | + | return err | |
| 51 | 34 | } | |
| 52 | - | for _, tt := range tests { | |
| 53 | - | t.Run(tt.name, func(t *testing.T) { | |
| 54 | - | snap := valid | |
| 55 | - | tt.mutate(&snap) | |
| 56 | - | p := newPane("pane", newRestorePTY(), domain.Size{Cols: 40, Rows: 24}) | |
| 57 | - | require.Error(t, restorePaneTerminal(p, snap)) | |
| 58 | - | }) | |
| 35 | + | r.mu.Lock() | |
| 36 | + | defer r.mu.Unlock() | |
| 37 | + | r.publishes = append(r.publishes, p) | |
| 38 | + | if r.publishErr != nil { | |
| 39 | + | return r.publishErr | |
| 59 | 40 | } | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | func TestRestoreSnapshotsNilDaemonDoesNotPanic(t *testing.T) { | |
| 63 | - | var d *Daemon | |
| 64 | - | require.NotPanics(t, func() { d.restoreSnapshots(t.Context()) }) | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | func TestRestoreSessionRejectsInvalidActiveTab(t *testing.T) { | |
| 68 | - | factory := &restorePTYFactory{} | |
| 69 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 70 | - | ||
| 71 | - | err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad-active", Active: 1, Tabs: []snapcodec.Tab{{ | |
| 72 | - | Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")}, | |
| 73 | - | }}}) | |
| 74 | - | ||
| 75 | - | require.Error(t, err) | |
| 76 | - | require.Empty(t, factory.opens) | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | func TestRestoreSessionClosesOpenedPTYWhenTerminalRestoreFails(t *testing.T) { | |
| 80 | - | factory := &restorePTYFactory{} | |
| 81 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 82 | - | pane := emptyTerminalPane(t, "pane-1", "/tmp") | |
| 83 | - | pane.Tail = []byte("malformed") | |
| 84 | - | ||
| 85 | - | err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad", Tabs: []snapcodec.Tab{{ | |
| 86 | - | Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane}, | |
| 87 | - | }}}) | |
| 88 | - | ||
| 89 | - | require.Error(t, err) | |
| 90 | - | require.Len(t, factory.opens, 1) | |
| 91 | - | require.Equal(t, 1, factory.opens[0].pty.closeCount()) | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | func TestRestoreSessionUsesDaemonFallbackEnvironmentAndShellBeforeAttach(t *testing.T) { | |
| 95 | - | factory := &restorePTYFactory{} | |
| 96 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 97 | - | d.baseEnv = []string{"ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "TERM=stale", "RAW"} | |
| 98 | - | ||
| 99 | - | require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "work", Tabs: []snapcodec.Tab{{ | |
| 100 | - | StableID: "tab", Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{terminalPane(t, "pane-1", "pane", "/tmp", nil, nil)}, | |
| 101 | - | }}})) | |
| 102 | - | initialOpens := factory.snapshotOpens() | |
| 103 | - | require.Len(t, initialOpens, 1) | |
| 104 | - | require.Equal(t, "/usr/local/bin/daemon-shell", initialOpens[0].command) | |
| 105 | - | require.Equal(t, []string{ | |
| 106 | - | "ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "RAW", | |
| 107 | - | "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=tab,pane=pane", | |
| 108 | - | }, initialOpens[0].env) | |
| 109 | - | ||
| 110 | - | d.baseEnv[0] = "ORDINARY=changed" | |
| 111 | - | d.mu.Lock() | |
| 112 | - | restored := d.findByNameLocked("work") | |
| 113 | - | d.mu.Unlock() | |
| 114 | - | require.NotNil(t, restored) | |
| 115 | - | restored.mu.Lock() | |
| 116 | - | require.Equal(t, []string{"ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "TERM=stale", "RAW"}, restored.env) | |
| 117 | - | restored.mu.Unlock() | |
| 118 | - | ||
| 119 | - | tr, _ := newCapturingTransport(t) | |
| 120 | - | _, ac, err := d.route(ports.Hello{ | |
| 121 | - | Version: ports.ProtocolVersion, | |
| 122 | - | Intent: ports.IntentAttach, | |
| 123 | - | Name: "work", | |
| 124 | - | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 125 | - | Env: []string{"ORDINARY=attached", "SHELL=/bin/attached-shell"}, | |
| 126 | - | }, tr) | |
| 127 | - | require.NoError(t, err) | |
| 128 | - | require.NoError(t, d.createTab(restored, ac.size)) | |
| 129 | - | ||
| 130 | - | restored.mu.Lock() | |
| 131 | - | attachedTab := restored.tabs[1] | |
| 132 | - | restored.mu.Unlock() | |
| 133 | - | attachedTab.mu.Lock() | |
| 134 | - | attachedPane := attachedTab.panes["pane-1"] | |
| 135 | - | attachedTabStableID := attachedTab.stableID | |
| 136 | - | attachedPaneStableID := attachedPane.stableID | |
| 137 | - | attachedTab.mu.Unlock() | |
| 138 | - | expectedVEV := "VEV=session=work,tab=" + attachedTabStableID + ",pane=" + attachedPaneStableID | |
| 139 | - | var attachedOpens []restorePTYOpen | |
| 140 | - | for _, open := range factory.snapshotOpens() { | |
| 141 | - | if containsEnv(open.env, expectedVEV) { | |
| 142 | - | attachedOpens = append(attachedOpens, open) | |
| 143 | - | } | |
| 41 | + | objects := make(map[ports.SnapshotDigest][]byte, len(p.Objects)) | |
| 42 | + | for _, object := range p.Objects { | |
| 43 | + | objects[object.Digest] = append([]byte(nil), object.Data...) | |
| 144 | 44 | } | |
| 145 | - | require.Len(t, attachedOpens, 1) | |
| 146 | - | require.Equal(t, "/bin/attached-shell", attachedOpens[0].command) | |
| 147 | - | require.Equal(t, []string{ | |
| 148 | - | "ORDINARY=attached", "SHELL=/bin/attached-shell", | |
| 149 | - | "TERM=xterm-256color", "TERM_PROGRAM=vev", expectedVEV, | |
| 150 | - | }, attachedOpens[0].env) | |
| 151 | - | require.NoError(t, d.killSession(restored, ports.ReasonSessionKilled, false)) | |
| 152 | - | d.sessWg.Wait() | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |
| 156 | - | store := &restoreSnapshotStore{} | |
| 157 | - | store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 158 | - | Name: "work", | |
| 159 | - | CreatedAt: 99, | |
| 160 | - | Active: 0, | |
| 161 | - | Tabs: []snapcodec.Tab{{ | |
| 162 | - | StableID: "t_saved", | |
| 163 | - | Cols: 80, | |
| 164 | - | Rows: 24, | |
| 165 | - | NextPaneID: 3, | |
| 166 | - | Focus: "pane-2", | |
| 167 | - | Tree: &layout.Tree{Focus: "pane-2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |
| 168 | - | layout.NewLeaf("pane-1"), | |
| 169 | - | layout.NewLeaf("pane-2"), | |
| 170 | - | }}}, | |
| 171 | - | Panes: []snapcodec.Pane{ | |
| 172 | - | terminalPane(t, "pane-1", "p_saved_1", "/one", [][]renderer.Cell{cells("old1")}, [][]renderer.Cell{cells("vis1")}), | |
| 173 | - | terminalPane(t, "pane-2", "p_saved_2", "/two", [][]renderer.Cell{cells("old2")}, [][]renderer.Cell{cells("vis2")}), | |
| 174 | - | }, | |
| 175 | - | }}, | |
| 176 | - | })}} | |
| 177 | - | factory := &restorePTYFactory{} | |
| 178 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 179 | - | persistStore, persistState := newMockStore(t) | |
| 180 | - | WithStore(persistStore)(d) | |
| 181 | - | WithSnapshotStore(store)(d) | |
| 182 | - | d.stopped["work"] = stoppedSession{name: "work", createdAt: 99, tabNames: []string{"logs"}} | |
| 183 | - | ||
| 184 | - | d.restoreSnapshots(context.Background()) | |
| 185 | - | ||
| 186 | - | require.Len(t, factory.opens, 2) | |
| 187 | - | require.Equal(t, "/one", factory.opens[0].dir) | |
| 188 | - | require.Equal(t, "/two", factory.opens[1].dir) | |
| 189 | - | require.Equal(t, domain.Size{Cols: 40, Rows: 24}, factory.opens[0].size) | |
| 190 | - | ||
| 191 | - | d.mu.Lock() | |
| 192 | - | restored := d.findByNameLocked("work") | |
| 193 | - | d.mu.Unlock() | |
| 194 | - | require.NotNil(t, restored) | |
| 195 | - | require.False(t, restored.snapDirty.Load()) | |
| 196 | - | require.Equal(t, domain.SessionID("sess-0"), restored.id) | |
| 197 | - | require.Equal(t, int64(99), restored.createdAt) | |
| 198 | - | require.Equal(t, "/one", restored.cwd) | |
| 199 | - | ||
| 200 | - | restored.mu.Lock() | |
| 201 | - | require.Len(t, restored.tabs, 1) | |
| 202 | - | tb := restored.tabs[0] | |
| 203 | - | restored.mu.Unlock() | |
| 204 | - | require.Equal(t, "logs", tb.name) | |
| 205 | - | require.Equal(t, []string{"logs"}, persistState.record(t, "work").TabNames) | |
| 206 | - | tb.mu.Lock() | |
| 207 | - | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| 208 | - | require.Equal(t, 3, tb.nextPaneID) | |
| 209 | - | p := tb.panes["pane-2"] | |
| 210 | - | tb.mu.Unlock() | |
| 211 | - | p.mu.Lock() | |
| 212 | - | require.Same(t, p.screen.History(), p.history) | |
| 213 | - | require.Equal(t, "old2", rowText(p.history.View().Row(0))) | |
| 214 | - | require.Equal(t, 1, p.history.Len()) | |
| 215 | - | require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4])) | |
| 216 | - | copySnap := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 217 | - | require.Equal(t, "old2", rowText(copySnap.Row(0)[:4])) | |
| 218 | - | require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4])) | |
| 219 | - | p.mu.Unlock() | |
| 220 | - | ||
| 221 | - | tb.mu.Lock() | |
| 222 | - | tabStableID := tb.stableID | |
| 223 | - | pane1StableID := tb.panes["pane-1"].stableID | |
| 224 | - | pane2StableID := tb.panes["pane-2"].stableID | |
| 225 | - | tb.mu.Unlock() | |
| 226 | - | require.Equal(t, "t_saved", tabStableID) | |
| 227 | - | require.Equal(t, "p_saved_1", pane1StableID) | |
| 228 | - | require.Equal(t, "p_saved_2", pane2StableID) | |
| 229 | - | require.Contains(t, factory.opens[0].env, "TERM=xterm-256color") | |
| 230 | - | require.NotContains(t, factory.opens[0].env, "TERM=xterm-direct") | |
| 231 | - | require.NotContains(t, factory.opens[0].env, "COLORTERM=truecolor") | |
| 232 | - | require.Contains(t, factory.opens[0].env, "TERM_PROGRAM=vev") | |
| 233 | - | require.Contains(t, factory.opens[0].env, "VEV=session=work,tab="+tabStableID+",pane="+pane1StableID) | |
| 234 | - | require.Contains(t, factory.opens[1].env, "TERM=xterm-256color") | |
| 235 | - | require.NotContains(t, factory.opens[1].env, "TERM=xterm-direct") | |
| 236 | - | require.NotContains(t, factory.opens[1].env, "COLORTERM=truecolor") | |
| 237 | - | require.Contains(t, factory.opens[1].env, "TERM_PROGRAM=vev") | |
| 238 | - | require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID) | |
| 239 | - | } | |
| 240 | - | ||
| 241 | - | func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) { | |
| 242 | - | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 243 | - | Name: "work", Active: 0, Tabs: []snapcodec.Tab{ | |
| 244 | - | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/one")}}, | |
| 245 | - | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}}, | |
| 246 | - | }, | |
| 247 | - | })}}} | |
| 248 | - | factory := &restorePTYFactory{} | |
| 249 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 250 | - | d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Command: "btop", Width: 80, Height: 80}}) | |
| 251 | - | WithSnapshotStore(store)(d) | |
| 252 | - | d.restoreSnapshots(context.Background()) | |
| 253 | - | // Only the persisted normal panes may be restored. Floating runtimes are | |
| 254 | - | // deliberately excluded and must wait for a Phase-5 activation path. | |
| 255 | - | require.Len(t, factory.opens, 2) | |
| 256 | - | d.mu.Lock() | |
| 257 | - | restored := d.findByNameLocked("work") | |
| 258 | - | d.mu.Unlock() | |
| 259 | - | require.NotNil(t, restored) | |
| 260 | - | restored.mu.Lock() | |
| 261 | - | tabs := append([]*tab(nil), restored.tabs...) | |
| 262 | - | restored.mu.Unlock() | |
| 263 | - | require.Len(t, tabs, 2) | |
| 264 | - | for _, tb := range tabs { | |
| 265 | - | tb.mu.Lock() | |
| 266 | - | require.Equal(t, floatingUninitialized, tb.floating.state) | |
| 267 | - | require.Nil(t, tb.floating.pane) | |
| 268 | - | tb.mu.Unlock() | |
| 45 | + | r.generations[p.Name] = ports.SnapshotGeneration{Name: p.Name, Generation: p.Generation, Manifest: append([]byte(nil), p.Manifest...), Objects: objects} | |
| 46 | + | found := false | |
| 47 | + | for _, name := range r.names { | |
| 48 | + | found = found || name == p.Name | |
| 269 | 49 | } | |
| 50 | + | if !found { | |
| 51 | + | r.names = append(r.names, p.Name) | |
| 52 | + | } | |
| 53 | + | return nil | |
| 270 | 54 | } | |
| 271 | 55 | ||
| 272 | - | func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore { | |
| 273 | - | t.Helper() | |
| 274 | - | pane := emptyTerminalPane(t, "pane-1", "/tmp") | |
| 275 | - | pane.Process = proc | |
| 276 | - | return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 277 | - | Name: name, | |
| 278 | - | Tabs: []snapcodec.Tab{{ | |
| 279 | - | Cols: 80, | |
| 280 | - | Rows: 24, | |
| 281 | - | Tree: layout.NewTree("pane-1"), | |
| 282 | - | Panes: []snapcodec.Pane{pane}, | |
| 283 | - | }}, | |
| 284 | - | })}}} | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | func TestRestoreSnapshotsProcessRestoreCommands(t *testing.T) { | |
| 288 | - | lessProc := &snapcodec.Process{Argv: []string{"less", "README.md"}, Strategy: processStrategyGeneric} | |
| 289 | - | agentProc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}} | |
| 290 | - | controlAgentProc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc\nmalicious"}} | |
| 291 | - | ||
| 292 | - | tests := []struct { | |
| 293 | - | name string | |
| 294 | - | store *restoreSnapshotStore | |
| 295 | - | allow []string | |
| 296 | - | wantWrites [][]string | |
| 297 | - | }{ | |
| 298 | - | { | |
| 299 | - | name: "allowlisted generic command", | |
| 300 | - | store: processRestoreTestStore(t, "allowlisted", lessProc), | |
| 301 | - | allow: []string{"less"}, | |
| 302 | - | wantWrites: [][]string{{"less README.md\n"}}, | |
| 303 | - | }, | |
| 304 | - | { | |
| 305 | - | name: "denied generic command", | |
| 306 | - | store: processRestoreTestStore(t, "denied", lessProc), | |
| 307 | - | allow: []string{"vim"}, | |
| 308 | - | wantWrites: [][]string{{}}, | |
| 309 | - | }, | |
| 310 | - | { | |
| 311 | - | name: "pane scoped command", | |
| 312 | - | store: &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "scoped", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 313 | - | Name: "scoped", | |
| 314 | - | Tabs: []snapcodec.Tab{ | |
| 315 | - | { | |
| 316 | - | Cols: 80, | |
| 317 | - | Rows: 24, | |
| 318 | - | Tree: layout.NewTree("pane-1"), | |
| 319 | - | Panes: func() []snapcodec.Pane { | |
| 320 | - | pane := emptyTerminalPane(t, "pane-1", "/one") | |
| 321 | - | pane.Process = lessProc | |
| 322 | - | return []snapcodec.Pane{pane} | |
| 323 | - | }(), | |
| 324 | - | }, | |
| 325 | - | { | |
| 326 | - | Cols: 80, | |
| 327 | - | Rows: 24, | |
| 328 | - | Tree: layout.NewTree("pane-1"), | |
| 329 | - | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}, | |
| 330 | - | }, | |
| 331 | - | }, | |
| 332 | - | })}}}, | |
| 333 | - | allow: []string{"less"}, | |
| 334 | - | wantWrites: [][]string{{"less README.md\n"}, {}}, | |
| 335 | - | }, | |
| 336 | - | { | |
| 337 | - | name: "agent ID command", | |
| 338 | - | store: processRestoreTestStore(t, "agent", agentProc), | |
| 339 | - | allow: []string{"pi"}, | |
| 340 | - | wantWrites: [][]string{{"pi --resume abc123\n"}}, | |
| 341 | - | }, | |
| 342 | - | { | |
| 343 | - | name: "agent ID with control byte rejected", | |
| 344 | - | store: processRestoreTestStore(t, "agent-control", controlAgentProc), | |
| 345 | - | allow: []string{"pi"}, | |
| 346 | - | wantWrites: [][]string{{}}, | |
| 347 | - | }, | |
| 56 | + | func (r *snapshotAcceptanceRepository) List(ctx context.Context) ([]string, error) { | |
| 57 | + | if err := ctx.Err(); err != nil { | |
| 58 | + | return nil, err | |
| 348 | 59 | } | |
| 349 | - | ||
| 350 | - | for _, tt := range tests { | |
| 351 | - | t.Run(tt.name, func(t *testing.T) { | |
| 352 | - | factory := &restorePTYFactory{} | |
| 353 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 354 | - | WithSnapshotStore(tt.store)(d) | |
| 355 | - | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: tt.allow}}) | |
| 356 | - | ||
| 357 | - | d.restoreSnapshots(context.Background()) | |
| 358 | - | ||
| 359 | - | require.Len(t, factory.opens, len(tt.wantWrites)) | |
| 360 | - | for i, want := range tt.wantWrites { | |
| 361 | - | if len(want) == 0 { | |
| 362 | - | require.Empty(t, factory.opens[i].pty.writes) | |
| 363 | - | continue | |
| 364 | - | } | |
| 365 | - | require.Equal(t, want, factory.opens[i].pty.writes) | |
| 366 | - | } | |
| 367 | - | }) | |
| 60 | + | r.mu.Lock() | |
| 61 | + | defer r.mu.Unlock() | |
| 62 | + | if r.listErr != nil { | |
| 63 | + | return nil, r.listErr | |
| 368 | 64 | } | |
| 65 | + | return append([]string(nil), r.names...), nil | |
| 369 | 66 | } | |
| 370 | 67 | ||
| 371 | - | func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) { | |
| 372 | - | proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}} | |
| 373 | - | factory := &restorePTYFactory{writeErr: errors.New("boom")} | |
| 374 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 375 | - | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}}) | |
| 376 | - | ||
| 377 | - | pane := emptyTerminalPane(t, "pane-1", "/tmp") | |
| 378 | - | pane.Process = proc | |
| 379 | - | require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane}}}})) | |
| 380 | - | require.Len(t, factory.opens, 1) | |
| 381 | - | require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes) | |
| 382 | - | } | |
| 383 | - | ||
| 384 | - | func TestRestoreSessionPassesRestoreContextToPTYOpen(t *testing.T) { | |
| 385 | - | factory := &restorePTYFactory{} | |
| 386 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 387 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 388 | - | defer cancel() | |
| 389 | - | ||
| 390 | - | require.NoError(t, d.restoreSession(ctx, snapcodec.Session{Name: "restore-context", Tabs: []snapcodec.Tab{{ | |
| 391 | - | Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")}, | |
| 392 | - | }}})) | |
| 393 | - | require.Len(t, factory.opens, 1) | |
| 394 | - | require.Same(t, ctx, factory.opens[0].ctx) | |
| 395 | - | } | |
| 396 | - | ||
| 397 | - | func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) { | |
| 398 | - | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 399 | - | Name: "stacked", | |
| 400 | - | Tabs: []snapcodec.Tab{{ | |
| 401 | - | Cols: 20, | |
| 402 | - | Rows: 4, | |
| 403 | - | Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{ | |
| 404 | - | layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"), | |
| 405 | - | }}}, | |
| 406 | - | Panes: []snapcodec.Pane{ | |
| 407 | - | emptyTerminalPane(t, "a", "/a"), | |
| 408 | - | emptyTerminalPane(t, "b", "/b"), | |
| 409 | - | emptyTerminalPane(t, "c", "/c"), | |
| 410 | - | }, | |
| 411 | - | }}, | |
| 412 | - | })}}} | |
| 413 | - | factory := &restorePTYFactory{} | |
| 414 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 415 | - | WithSnapshotStore(store)(d) | |
| 416 | - | ||
| 417 | - | d.restoreSnapshots(context.Background()) | |
| 418 | - | ||
| 419 | - | require.Len(t, factory.opens, 3) | |
| 420 | - | for _, open := range factory.opens { | |
| 421 | - | require.True(t, open.size.Valid(), "open size for %s was %#v", open.dir, open.size) | |
| 422 | - | require.GreaterOrEqual(t, open.size.Cols, layout.MinPaneCols) | |
| 423 | - | require.GreaterOrEqual(t, open.size.Rows, layout.MinPaneRows) | |
| 68 | + | func (r *snapshotAcceptanceRepository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) { | |
| 69 | + | if err := ctx.Err(); err != nil { | |
| 70 | + | return ports.SnapshotGeneration{}, err | |
| 424 | 71 | } | |
| 425 | - | d.mu.Lock() | |
| 426 | - | restored := d.findByNameLocked("stacked") | |
| 427 | - | d.mu.Unlock() | |
| 428 | - | require.NotNil(t, restored) | |
| 429 | - | restored.mu.Lock() | |
| 430 | - | tb := restored.tabs[0] | |
| 431 | - | restored.mu.Unlock() | |
| 432 | - | tb.mu.Lock() | |
| 433 | - | require.Equal(t, layout.Stack, tb.tree.Root.Kind) | |
| 434 | - | require.Equal(t, layout.PaneID("b"), tb.tree.Root.Expanded) | |
| 435 | - | require.Len(t, tb.panes, 3) | |
| 436 | - | tb.mu.Unlock() | |
| 437 | - | } | |
| 438 | - | ||
| 439 | - | func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) { | |
| 440 | - | valid := mustSnapshotBytes(t, snapcodec.Session{ | |
| 441 | - | Name: "live", | |
| 442 | - | CreatedAt: 1, | |
| 443 | - | Tabs: []snapcodec.Tab{{ | |
| 444 | - | Cols: 80, | |
| 445 | - | Rows: 24, | |
| 446 | - | Tree: layout.NewTree("pane-1"), | |
| 447 | - | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/ok")}, | |
| 448 | - | }}, | |
| 449 | - | }) | |
| 450 | - | badVersion := append([]byte(nil), valid...) | |
| 451 | - | binary.BigEndian.PutUint16(badVersion[4:6], 1) | |
| 452 | - | tests := []struct { | |
| 453 | - | name string | |
| 454 | - | blobs []ports.SnapshotBlob | |
| 455 | - | liveName string | |
| 456 | - | wantOpen int | |
| 457 | - | }{ | |
| 458 | - | {name: "empty store"}, | |
| 459 | - | {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}}, | |
| 460 | - | {name: "old v1 blob", blobs: []ports.SnapshotBlob{{Name: "old", Data: badVersion}}}, | |
| 461 | - | {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"}, | |
| 72 | + | r.mu.Lock() | |
| 73 | + | defer r.mu.Unlock() | |
| 74 | + | if r.loadErr != nil { | |
| 75 | + | return ports.SnapshotGeneration{}, r.loadErr | |
| 462 | 76 | } | |
| 463 | - | for _, tt := range tests { | |
| 464 | - | t.Run(tt.name, func(t *testing.T) { | |
| 465 | - | factory := &restorePTYFactory{} | |
| 466 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 467 | - | WithSnapshotStore(&restoreSnapshotStore{blobs: tt.blobs})(d) | |
| 468 | - | if tt.liveName != "" { | |
| 469 | - | d.sessions["existing"] = &session{id: "existing", name: tt.liveName} | |
| 470 | - | } | |
| 471 | - | ||
| 472 | - | require.NotPanics(t, func() { d.restoreSnapshots(context.Background()) }) | |
| 473 | - | require.Len(t, factory.opens, tt.wantOpen) | |
| 474 | - | }) | |
| 77 | + | generation, ok := r.generations[name] | |
| 78 | + | if !ok { | |
| 79 | + | return ports.SnapshotGeneration{}, errors.New("missing generation") | |
| 475 | 80 | } | |
| 81 | + | generation = cloneAcceptanceGeneration(generation) | |
| 82 | + | if r.loadMutate != nil && len(r.publishes) > 0 { | |
| 83 | + | generation = r.loadMutate(generation, r.publishes[len(r.publishes)-1]) | |
| 84 | + | } | |
| 85 | + | return cloneAcceptanceGeneration(generation), nil | |
| 476 | 86 | } | |
| 477 | 87 | ||
| 478 | - | func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) { | |
| 479 | - | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "overflow", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 480 | - | Name: "overflow", | |
| 481 | - | CreatedAt: uint64(math.MaxInt64) + 1, | |
| 482 | - | Tabs: []snapcodec.Tab{{ | |
| 483 | - | Cols: 80, | |
| 484 | - | Rows: 24, | |
| 485 | - | Tree: layout.NewTree("pane-1"), | |
| 486 | - | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/bad")}, | |
| 487 | - | }}, | |
| 488 | - | })}}} | |
| 489 | - | factory := &restorePTYFactory{} | |
| 490 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 491 | - | WithSnapshotStore(store)(d) | |
| 492 | - | ||
| 493 | - | d.restoreSnapshots(context.Background()) | |
| 494 | - | ||
| 495 | - | records, err := d.persist.LoadAll() | |
| 496 | - | require.NoError(t, err) | |
| 497 | - | require.Empty(t, records) | |
| 498 | - | require.Len(t, factory.opens, 0) | |
| 499 | - | d.mu.Lock() | |
| 500 | - | restored := d.findByNameLocked("overflow") | |
| 501 | - | d.mu.Unlock() | |
| 502 | - | require.Nil(t, restored) | |
| 503 | - | } | |
| 504 | - | ||
| 505 | - | // TestRestoreSnapshotsNotifiesGlobalOnUnrestorableSnapshots proves each | |
| 506 | - | // unrestorable snapshot is recorded to history individually, while the | |
| 507 | - | // pending-global queue dedups them by code into a single toast with the | |
| 508 | - | // combined Count. Both blobs decode fine (valid snapcodec bytes, Marshal | |
| 509 | - | // itself validates Active/tab range) but fail in restoreSession itself: an | |
| 510 | - | // empty embedded session name is rejected deterministically, before any lock | |
| 511 | - | // is taken or PTY is opened. | |
| 512 | - | func TestRestoreSnapshotsNotifiesGlobalOnUnrestorableSnapshots(t *testing.T) { | |
| 513 | - | badSnap := mustSnapshotBytes(t, snapcodec.Session{Name: ""}) | |
| 514 | - | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{ | |
| 515 | - | {Name: "bad-1", Data: badSnap}, | |
| 516 | - | {Name: "bad-2", Data: badSnap}, | |
| 517 | - | }} | |
| 518 | - | factory := &restorePTYFactory{} | |
| 519 | - | d := newTestDaemon(t, factory, newNoticeClock()) | |
| 520 | - | WithSnapshotStore(store)(d) | |
| 521 | - | ||
| 522 | - | // Mirrors real startup (daemon.go's Serve): restoreSnapshots runs off the | |
| 523 | - | // caller goroutine and signals completion by closing d.restoreDone. | |
| 524 | - | d.sessWg.Go(func() { d.restoreSnapshots(d.serveCtx) }) | |
| 88 | + | func (*snapshotAcceptanceRepository) Delete(context.Context, string) error { return nil } | |
| 89 | + | func (*snapshotAcceptanceRepository) Tombstone(context.Context, string) error { return nil } | |
| 90 | + | func (*snapshotAcceptanceRepository) DeleteTombstone(context.Context, string) error { return nil } | |
| 91 | + | func (*snapshotAcceptanceRepository) Maintain(context.Context) error { return nil } | |
| 525 | 92 | ||
| 526 | - | select { | |
| 527 | - | case <-d.restoreDone: | |
| 528 | - | case <-time.After(2 * time.Second): | |
| 529 | - | t.Fatal("restoreSnapshots did not close restoreDone in time") | |
| 93 | + | func TestSnapshotAcceptanceRepositoryLoadMutationDoesNotChangeStoredGeneration(t *testing.T) { | |
| 94 | + | digest := ports.SnapshotDigest{1} | |
| 95 | + | stored := ports.SnapshotGeneration{ | |
| 96 | + | Name: "work", | |
| 97 | + | Manifest: []byte("manifest"), | |
| 98 | + | Objects: map[ports.SnapshotDigest][]byte{digest: []byte("object")}, | |
| 530 | 99 | } | |
| 531 | - | ||
| 532 | - | require.Empty(t, factory.opens, "both snapshots must fail before any PTY is opened") | |
| 533 | - | require.Len(t, d.notices.history(), 2, "each failure is recorded individually") | |
| 534 | - | for _, n := range d.notices.history() { | |
| 535 | - | require.Equal(t, domain.NoticeSnapshotRestore, n.Code) | |
| 536 | - | require.Equal(t, domain.NoticeError, n.Severity) | |
| 100 | + | repository := &snapshotAcceptanceRepository{ | |
| 101 | + | generations: map[string]ports.SnapshotGeneration{"work": stored}, | |
| 102 | + | publishes: []ports.SnapshotPublication{{Name: "work"}}, | |
| 103 | + | loadMutate: func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration { | |
| 104 | + | g.Manifest[0] = 'M' | |
| 105 | + | g.Objects[digest][0] = 'O' | |
| 106 | + | delete(g.Objects, digest) | |
| 107 | + | return g | |
| 108 | + | }, | |
| 537 | 109 | } | |
| 538 | 110 | ||
| 539 | - | tr, _ := newCapturingTransport(t) | |
| 540 | - | sess, ac, err := d.route(ports.Hello{ | |
| 541 | - | Version: ports.ProtocolVersion, | |
| 542 | - | Intent: ports.IntentEphemeral, | |
| 543 | - | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 544 | - | }, tr) | |
| 111 | + | _, err := repository.Load(context.Background(), "work") | |
| 545 | 112 | require.NoError(t, err) | |
| 546 | - | d.firstPaint(sess, ac, ac.size) | |
| 547 | - | ||
| 548 | - | toasts := awaitToastCount(t, ac, 1) | |
| 549 | - | require.Equal(t, domain.NoticeSnapshotRestore, toasts[0].Code) | |
| 550 | - | require.Equal(t, 2, toasts[0].Count, "two same-code failures dedup into one toast counted twice") | |
| 551 | - | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | |
| 113 | + | require.Equal(t, []byte("manifest"), repository.generations["work"].Manifest) | |
| 114 | + | require.Equal(t, []byte("object"), repository.generations["work"].Objects[digest]) | |
| 552 | 115 | } | |
| 553 | 116 | ||
| 554 | - | func TestRestoreSnapshotsDoesNotNotifyForCancelledRestore(t *testing.T) { | |
| 555 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 556 | - | factory := &restorePTYFactory{onOpen: cancel} | |
| 557 | - | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "cancelled", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 558 | - | Name: "cancelled", | |
| 559 | - | Tabs: []snapcodec.Tab{{ | |
| 560 | - | Cols: 80, Rows: 24, | |
| 561 | - | Tree: &layout.Tree{Focus: "one", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |
| 562 | - | layout.NewLeaf("one"), layout.NewLeaf("two"), | |
| 563 | - | }}}, | |
| 564 | - | Panes: []snapcodec.Pane{ | |
| 565 | - | emptyTerminalPane(t, "one", "/one"), | |
| 566 | - | emptyTerminalPane(t, "two", "/two"), | |
| 567 | - | }, | |
| 568 | - | }}, | |
| 569 | - | })}}} | |
| 570 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 571 | - | WithSnapshotStore(store)(d) | |
| 572 | - | ||
| 573 | - | d.restoreSnapshots(ctx) | |
| 574 | - | ||
| 575 | - | require.Len(t, factory.snapshotOpens(), 1, "cancellation stops restore before the next pane") | |
| 576 | - | require.Empty(t, d.notices.history(), "shutdown cancellation is not a user-visible restore failure") | |
| 577 | - | } | |
| 578 | - | ||
| 579 | - | func TestRestoreSnapshotsNotifiesGlobalOnResumeCommandWriteFailure(t *testing.T) { | |
| 580 | - | proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}} | |
| 581 | - | store := processRestoreTestStore(t, "agent-session", proc) | |
| 582 | - | factory := &restorePTYFactory{writeErr: errors.New("boom")} | |
| 583 | - | d := newTestDaemon(t, factory, newNoticeClock()) | |
| 584 | - | WithSnapshotStore(store)(d) | |
| 585 | - | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}}) | |
| 586 | - | ||
| 587 | - | // Mirrors real startup (daemon.go's Serve): restoreSnapshots runs off the | |
| 588 | - | // caller goroutine and signals completion by closing d.restoreDone. | |
| 589 | - | d.sessWg.Go(func() { d.restoreSnapshots(d.serveCtx) }) | |
| 590 | - | ||
| 591 | - | select { | |
| 592 | - | case <-d.restoreDone: | |
| 593 | - | case <-time.After(2 * time.Second): | |
| 594 | - | t.Fatal("restoreSnapshots did not close restoreDone in time") | |
| 117 | + | func cloneAcceptanceGeneration(g ports.SnapshotGeneration) ports.SnapshotGeneration { | |
| 118 | + | out := g | |
| 119 | + | out.Manifest = append([]byte(nil), g.Manifest...) | |
| 120 | + | out.Objects = make(map[ports.SnapshotDigest][]byte, len(g.Objects)) | |
| 121 | + | for digest, data := range g.Objects { | |
| 122 | + | out.Objects[digest] = append([]byte(nil), data...) | |
| 595 | 123 | } | |
| 596 | - | ||
| 597 | - | require.Len(t, factory.opens, 1, "the pane still opens even though the resume write fails") | |
| 598 | - | require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes) | |
| 599 | - | ||
| 600 | - | history := d.notices.history() | |
| 601 | - | require.Len(t, history, 1) | |
| 602 | - | require.Equal(t, domain.NoticeAutoResume, history[0].Code) | |
| 603 | - | require.Equal(t, domain.NoticeWarn, history[0].Severity) | |
| 604 | - | require.Equal(t, "couldn't restore the running program in session agent-session", history[0].Message) | |
| 605 | - | ||
| 606 | - | tr, _ := newCapturingTransport(t) | |
| 607 | - | sess, ac, err := d.route(ports.Hello{ | |
| 608 | - | Version: ports.ProtocolVersion, | |
| 609 | - | Intent: ports.IntentEphemeral, | |
| 610 | - | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 611 | - | }, tr) | |
| 612 | - | require.NoError(t, err) | |
| 613 | - | d.firstPaint(sess, ac, ac.size) | |
| 614 | - | ||
| 615 | - | toasts := awaitToastCount(t, ac, 1) | |
| 616 | - | require.Equal(t, domain.NoticeAutoResume, toasts[0].Code) | |
| 617 | - | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | |
| 124 | + | return out | |
| 618 | 125 | } | |
| 619 | 126 | ||
| 620 | - | func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) { | |
| 621 | - | releaseLoad := make(chan struct{}) | |
| 622 | - | loaded := make(chan struct{}) | |
| 623 | - | store := &restoreSnapshotStore{ | |
| 624 | - | loadFn: func() ([]ports.SnapshotBlob, error) { | |
| 625 | - | close(loaded) | |
| 626 | - | <-releaseLoad | |
| 627 | - | return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 628 | - | Name: "work", | |
| 629 | - | Tabs: []snapcodec.Tab{{ | |
| 630 | - | Cols: 80, | |
| 631 | - | Rows: 24, | |
| 632 | - | Tree: layout.NewTree("pane-1"), | |
| 633 | - | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/restored")}, | |
| 634 | - | }}, | |
| 635 | - | })}}, nil | |
| 636 | - | }, | |
| 127 | + | func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session { | |
| 128 | + | t.Helper() | |
| 129 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 8 * 293, ChunkRows: 2}) | |
| 130 | + | for _, ch := range []rune{'a', 'b', 'c', 'd', 'e'} { | |
| 131 | + | row := make([]renderer.Cell, 293) | |
| 132 | + | for i := range row { | |
| 133 | + | row[i] = renderer.Cell{Rune: ch} | |
| 134 | + | } | |
| 135 | + | require.NoError(t, history.Append(row)) | |
| 637 | 136 | } | |
| 638 | - | factory := &restorePTYFactory{} | |
| 639 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 640 | - | WithSnapshotStore(store)(d) | |
| 641 | - | ||
| 642 | - | go d.restoreSnapshots(context.Background()) | |
| 643 | - | <-loaded | |
| 644 | - | ||
| 645 | - | routed := make(chan *session, 1) | |
| 646 | - | go func() { | |
| 647 | - | tr, _ := newCapturingTransport(t) | |
| 648 | - | sess, _, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 26}}, tr) | |
| 137 | + | view := history.SnapshotView() | |
| 138 | + | sealed := make([][]byte, view.ChunkCount()) | |
| 139 | + | for i := range sealed { | |
| 140 | + | var err error | |
| 141 | + | sealed[i], err = vt.MarshalHistoryChunk(view.Chunk(i)) | |
| 649 | 142 | require.NoError(t, err) | |
| 650 | - | routed <- sess | |
| 651 | - | }() | |
| 652 | - | ||
| 653 | - | select { | |
| 654 | - | case <-routed: | |
| 655 | - | t.Fatal("named attach routed before restore barrier closed") | |
| 656 | - | case <-time.After(25 * time.Millisecond): | |
| 657 | - | } | |
| 658 | - | close(releaseLoad) | |
| 659 | - | select { | |
| 660 | - | case sess := <-routed: | |
| 661 | - | require.Equal(t, "work", sess.name) | |
| 662 | - | require.Len(t, factory.opens, 1) | |
| 663 | - | require.Equal(t, "/restored", factory.opens[0].dir) | |
| 664 | - | case <-time.After(time.Second): | |
| 665 | - | t.Fatal("timed out waiting for named attach after restore") | |
| 666 | 143 | } | |
| 667 | - | } | |
| 668 | - | ||
| 669 | - | func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) { | |
| 670 | - | d := newTestDaemon(t, &restorePTYFactory{}, stubClock{}) | |
| 671 | - | WithSnapshotStore(&restoreSnapshotStore{})(d) | |
| 672 | - | d.serveCancel() | |
| 673 | - | ||
| 674 | - | tr, _ := newCapturingTransport(t) | |
| 675 | - | sess, ac, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}}, tr) | |
| 676 | - | ||
| 677 | - | require.Nil(t, sess) | |
| 678 | - | require.Nil(t, ac) | |
| 679 | - | var perr *protoErr | |
| 680 | - | require.ErrorAs(t, err, &perr) | |
| 681 | - | require.Equal(t, ports.ErrServerShutdown, perr.code) | |
| 682 | - | } | |
| 683 | - | ||
| 684 | - | func emptyTerminalPane(t *testing.T, id, cwd string) snapcodec.Pane { | |
| 685 | - | t.Helper() | |
| 686 | - | return terminalPane(t, id, "", cwd, nil, nil) | |
| 687 | - | } | |
| 144 | + | tail, err := vt.MarshalHistoryTail(view) | |
| 145 | + | require.NoError(t, err) | |
| 688 | 146 | ||
| 689 | - | func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte { | |
| 690 | - | t.Helper() | |
| 691 | - | b, err := snapcodec.Marshal(s) | |
| 147 | + | screen := vt.NewScreen(293, 4) | |
| 148 | + | screen.Write([]byte("primary-visible")) | |
| 149 | + | screen.Write([]byte("\x1b[?1049h")) | |
| 150 | + | screen.Write([]byte("alternate-active")) | |
| 151 | + | visible, err := screen.PrimaryVisibleSnapshot().Marshal() | |
| 692 | 152 | require.NoError(t, err) | |
| 693 | - | return b | |
| 694 | - | } | |
| 695 | 153 | ||
| 696 | - | func cells(s string) []renderer.Cell { | |
| 697 | - | out := make([]renderer.Cell, 0, len(s)) | |
| 698 | - | for _, r := range s { | |
| 699 | - | out = append(out, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |
| 154 | + | paneID := layout.PaneID("pane-7") | |
| 155 | + | return snapcodec.Session{ | |
| 156 | + | Name: name, CreatedAt: 77, Active: 0, | |
| 157 | + | Tabs: []snapcodec.Tab{{ | |
| 158 | + | StableID: "tab-stable", Cols: 293, Rows: 4, NextPaneID: 8, Focus: paneID, | |
| 159 | + | Tree: &layout.Tree{Root: layout.NewLeaf(paneID), Focus: paneID}, | |
| 160 | + | Panes: []snapcodec.Pane{{ | |
| 161 | + | ID: paneID, StableID: "pane-stable", Cwd: "/snapshot/cwd", SealedChunks: sealed, Tail: tail, Visible: visible, | |
| 162 | + | Process: &snapcodec.Process{Argv: []string{"sleep", "99"}, Strategy: "argv"}, | |
| 163 | + | }}, | |
| 164 | + | }}, | |
| 700 | 165 | } | |
| 701 | - | return out | |
| 702 | - | } | |
| 703 | - | ||
| 704 | - | type restoreSnapshotStore struct { | |
| 705 | - | blobs []ports.SnapshotBlob | |
| 706 | - | loadFn func() ([]ports.SnapshotBlob, error) | |
| 707 | 166 | } | |
| 708 | 167 | ||
| 709 | - | func (s *restoreSnapshotStore) Write(string, []byte) error { return nil } | |
| 710 | - | func (s *restoreSnapshotStore) Delete(string) error { return nil } | |
| 711 | - | func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) { | |
| 712 | - | if s.loadFn != nil { | |
| 713 | - | return s.loadFn() | |
| 168 | + | func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation uint64) ports.SnapshotGeneration { | |
| 169 | + | t.Helper() | |
| 170 | + | publication, err := legacyPublication(snapshot) | |
| 171 | + | require.NoError(t, err) | |
| 172 | + | manifest, err := snapcodec.UnmarshalManifest(publication.Manifest) | |
| 173 | + | require.NoError(t, err) | |
| 174 | + | manifest.Generation = generation | |
| 175 | + | encoded, err := snapcodec.MarshalManifest(manifest) | |
| 176 | + | require.NoError(t, err) | |
| 177 | + | objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects)) | |
| 178 | + | for _, object := range publication.Objects { | |
| 179 | + | objects[object.Digest] = append([]byte(nil), object.Data...) | |
| 714 | 180 | } | |
| 715 | - | return s.blobs, nil | |
| 716 | - | } | |
| 717 | - | ||
| 718 | - | type restorePTYFactory struct { | |
| 719 | - | mu sync.Mutex | |
| 720 | - | opens []restorePTYOpen | |
| 721 | - | writeErr error | |
| 722 | - | onOpen func() | |
| 723 | - | } | |
| 724 | - | ||
| 725 | - | type restorePTYOpen struct { | |
| 726 | - | ctx context.Context | |
| 727 | - | command string | |
| 728 | - | dir string | |
| 729 | - | size domain.Size | |
| 730 | - | env []string | |
| 731 | - | pty *restorePTY | |
| 181 | + | return ports.SnapshotGeneration{Name: snapshot.Name, Generation: generation, Manifest: encoded, Objects: objects} | |
| 732 | 182 | } | |
| 733 | 183 | ||
| 734 | - | func (f *restorePTYFactory) Open(ctx context.Context, command string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) { | |
| 735 | - | f.mu.Lock() | |
| 736 | - | pty := newRestorePTY() | |
| 737 | - | pty.writeErr = f.writeErr | |
| 738 | - | f.opens = append(f.opens, restorePTYOpen{ctx: ctx, command: command, dir: dir, size: sz, env: append([]string(nil), env...), pty: pty}) | |
| 739 | - | onOpen := f.onOpen | |
| 740 | - | f.mu.Unlock() | |
| 741 | - | if onOpen != nil { | |
| 742 | - | onOpen() | |
| 184 | + | func TestValidateRestoreSessionSnapshot(t *testing.T) { | |
| 185 | + | valid := restoreAcceptanceSession(t, "restored") | |
| 186 | + | for _, test := range []struct { | |
| 187 | + | name string | |
| 188 | + | snap snapcodec.Session | |
| 189 | + | }{ | |
| 190 | + | {"valid", valid}, | |
| 191 | + | {"empty name", func() snapcodec.Session { snap := valid; snap.Name = ""; return snap }()}, | |
| 192 | + | {"active without tabs", func() snapcodec.Session { snap := valid; snap.Tabs = nil; snap.Active = 1; return snap }()}, | |
| 193 | + | {"active beyond tabs", func() snapcodec.Session { snap := valid; snap.Active = 1; return snap }()}, | |
| 194 | + | {"created at overflow", func() snapcodec.Session { snap := valid; snap.CreatedAt = math.MaxInt64 + 1; return snap }()}, | |
| 195 | + | } { | |
| 196 | + | t.Run(test.name, func(t *testing.T) { | |
| 197 | + | err := validateRestoreSessionSnapshot(test.snap) | |
| 198 | + | if test.name == "valid" { | |
| 199 | + | require.NoError(t, err) | |
| 200 | + | return | |
| 201 | + | } | |
| 202 | + | require.Error(t, err) | |
| 203 | + | }) | |
| 743 | 204 | } | |
| 744 | - | return pty, nil | |
| 745 | 205 | } | |
| 746 | 206 | ||
| 747 | - | func (f *restorePTYFactory) snapshotOpens() []restorePTYOpen { | |
| 748 | - | f.mu.Lock() | |
| 749 | - | defer f.mu.Unlock() | |
| 750 | - | opens := make([]restorePTYOpen, len(f.opens)) | |
| 751 | - | copy(opens, f.opens) | |
| 752 | - | for i := range opens { | |
| 753 | - | opens[i].env = append([]string(nil), opens[i].env...) | |
| 754 | - | } | |
| 755 | - | return opens | |
| 756 | - | } | |
| 207 | + | func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |
| 208 | + | snapshot := restoreAcceptanceSession(t, "restored") | |
| 209 | + | generation := acceptanceGeneration(t, snapshot, 9) | |
| 210 | + | repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}} | |
| 211 | + | pty, release := newBlockingPTY(t) | |
| 212 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 213 | + | store, _ := newMockStore(t) | |
| 214 | + | WithStore(store)(d) | |
| 215 | + | WithSnapshotRepository(repository, nil)(d) | |
| 216 | + | t.Cleanup(func() { release(); d.sessWg.Wait() }) | |
| 757 | 217 | ||
| 758 | - | type restorePTY struct { | |
| 759 | - | mu sync.Mutex | |
| 760 | - | writes []string | |
| 761 | - | closes int | |
| 762 | - | writeErr error | |
| 763 | - | done chan struct{} | |
| 764 | - | once sync.Once | |
| 765 | - | } | |
| 218 | + | d.restoreIncrementalSnapshots(context.Background()) | |
| 766 | 219 | ||
| 767 | - | func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} } | |
| 768 | - | func (p *restorePTY) Read([]byte) (int, error) { | |
| 769 | - | <-p.done | |
| 770 | - | return 0, io.EOF | |
| 771 | - | } | |
| 772 | - | func (p *restorePTY) Write(b []byte) (int, error) { | |
| 773 | - | p.mu.Lock() | |
| 774 | - | defer p.mu.Unlock() | |
| 775 | - | p.writes = append(p.writes, string(b)) | |
| 776 | - | if p.writeErr != nil { | |
| 777 | - | return 0, p.writeErr | |
| 220 | + | d.mu.Lock() | |
| 221 | + | restored := d.findByNameLocked(snapshot.Name) | |
| 222 | + | d.mu.Unlock() | |
| 223 | + | require.NotNil(t, restored) | |
| 224 | + | require.False(t, restored.snapDirty.Load(), "a loaded generation must begin clean") | |
| 225 | + | require.Equal(t, uint64(9), restored.snapshotPublishedGeneration, "the loaded manifest is the repository generation head") | |
| 226 | + | require.Equal(t, 0, restored.active) | |
| 227 | + | require.Equal(t, "/snapshot/cwd", restored.cwd) | |
| 228 | + | require.Len(t, restored.tabs, 1) | |
| 229 | + | tab := restored.tabs[0] | |
| 230 | + | require.Equal(t, 293, tab.size.Cols) | |
| 231 | + | require.Equal(t, layout.PaneID("pane-7"), tab.tree.Focus) | |
| 232 | + | pane := tab.panes[layout.PaneID("pane-7")] | |
| 233 | + | pane.mu.Lock() | |
| 234 | + | require.Equal(t, "primary-visible", string([]rune{pane.screen.Frame.Row(0)[0].Rune, pane.screen.Frame.Row(0)[1].Rune, pane.screen.Frame.Row(0)[2].Rune, pane.screen.Frame.Row(0)[3].Rune, pane.screen.Frame.Row(0)[4].Rune, pane.screen.Frame.Row(0)[5].Rune, pane.screen.Frame.Row(0)[6].Rune, pane.screen.Frame.Row(0)[7].Rune, pane.screen.Frame.Row(0)[8].Rune, pane.screen.Frame.Row(0)[9].Rune, pane.screen.Frame.Row(0)[10].Rune, pane.screen.Frame.Row(0)[11].Rune, pane.screen.Frame.Row(0)[12].Rune, pane.screen.Frame.Row(0)[13].Rune, pane.screen.Frame.Row(0)[14].Rune})) | |
| 235 | + | require.Equal(t, 5, pane.history.Len()) | |
| 236 | + | require.Equal(t, 5*293, pane.history.Cells()) | |
| 237 | + | require.Equal(t, defaultScrollbackRows, pane.history.Cap()) | |
| 238 | + | require.Equal(t, defaultScrollbackCells, pane.history.CellCap()) | |
| 239 | + | pane.mu.Unlock() | |
| 240 | + | ||
| 241 | + | startSnapshotEncodeWorker(t, d) | |
| 242 | + | markSnapshotDirty(restored) | |
| 243 | + | require.True(t, d.scheduleSnapshot(restored)) | |
| 244 | + | awaitSnapshotClean(t, restored) | |
| 245 | + | require.Len(t, repository.publishes, 1) | |
| 246 | + | require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream") | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) { | |
| 250 | + | snapshot := restoreAcceptanceSession(t, "fallback") | |
| 251 | + | valid := acceptanceGeneration(t, snapshot, 3) | |
| 252 | + | valid.Fallback = true | |
| 253 | + | pty, release := newBlockingPTY(t) | |
| 254 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 255 | + | store, _ := newMockStore(t) | |
| 256 | + | WithStore(store)(d) | |
| 257 | + | repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: valid}} | |
| 258 | + | WithSnapshotRepository(repository, nil)(d) | |
| 259 | + | t.Cleanup(func() { release(); d.sessWg.Wait() }) | |
| 260 | + | d.restoreIncrementalSnapshots(context.Background()) | |
| 261 | + | require.Len(t, d.notices.history(), 1) | |
| 262 | + | require.Equal(t, domain.NoticeWarn, d.notices.history()[0].Severity) | |
| 263 | + | ||
| 264 | + | for _, mutate := range []struct { | |
| 265 | + | name string | |
| 266 | + | fn func(*ports.SnapshotGeneration) | |
| 267 | + | }{ | |
| 268 | + | {"missing", func(g *ports.SnapshotGeneration) { | |
| 269 | + | for digest := range g.Objects { | |
| 270 | + | delete(g.Objects, digest) | |
| 271 | + | break | |
| 272 | + | } | |
| 273 | + | }}, | |
| 274 | + | {"wrong kind", func(g *ports.SnapshotGeneration) { | |
| 275 | + | for digest, data := range g.Objects { | |
| 276 | + | kind, payload, err := snapcodec.UnmarshalObject(data) | |
| 277 | + | require.NoError(t, err) | |
| 278 | + | if kind == snapcodec.HistoryTail { | |
| 279 | + | replacement, err := snapcodec.MarshalObject(snapcodec.Visible, payload) | |
| 280 | + | require.NoError(t, err) | |
| 281 | + | delete(g.Objects, digest) | |
| 282 | + | g.Objects[digest] = replacement.Data | |
| 283 | + | break | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | }}, | |
| 287 | + | {"wrong digest", func(g *ports.SnapshotGeneration) { | |
| 288 | + | for digest, data := range g.Objects { | |
| 289 | + | kind, payload, err := snapcodec.UnmarshalObject(data) | |
| 290 | + | require.NoError(t, err) | |
| 291 | + | payload = append([]byte(nil), payload...) | |
| 292 | + | payload[len(payload)-1] ^= 1 | |
| 293 | + | replacement, err := snapcodec.MarshalObject(kind, payload) | |
| 294 | + | require.NoError(t, err) | |
| 295 | + | require.Equal(t, len(data), len(replacement.Data)) | |
| 296 | + | g.Objects[digest] = replacement.Data | |
| 297 | + | break | |
| 298 | + | } | |
| 299 | + | }}, | |
| 300 | + | } { | |
| 301 | + | t.Run(mutate.name, func(t *testing.T) { | |
| 302 | + | generation := cloneAcceptanceGeneration(valid) | |
| 303 | + | generation.Fallback = false | |
| 304 | + | mutate.fn(&generation) | |
| 305 | + | _, err := sessionFromGeneration(generation) | |
| 306 | + | require.Error(t, err) | |
| 307 | + | }) | |
| 778 | 308 | } | |
| 779 | - | return len(b), nil | |
| 780 | - | } | |
| 781 | - | func (p *restorePTY) Close() error { | |
| 782 | - | p.mu.Lock() | |
| 783 | - | p.closes++ | |
| 784 | - | p.mu.Unlock() | |
| 785 | - | p.once.Do(func() { close(p.done) }) | |
| 786 | - | return nil | |
| 787 | - | } | |
| 788 | - | func (p *restorePTY) closeCount() int { | |
| 789 | - | p.mu.Lock() | |
| 790 | - | defer p.mu.Unlock() | |
| 791 | - | return p.closes | |
| 792 | 309 | } | |
| 793 | - | func (p *restorePTY) Resize(domain.Size) error { return nil } | |
| 794 | - | func (p *restorePTY) Pid() int { return 0 } | |
| 795 | - | func (p *restorePTY) ForegroundPgid() (int, error) { return 0, nil } | |
A internal/usecase/daemon/snapshot_scheduler.go +68 -0
| @@ -0,0 +1,68 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func (d *Daemon) snapshotRepositorySaver(ctx context.Context) { | |
| 9 | + | if d == nil { | |
| 10 | + | return | |
| 11 | + | } | |
| 12 | + | timer := d.clock.NewTimer(24 * time.Hour) | |
| 13 | + | defer timer.Stop() | |
| 14 | + | for { | |
| 15 | + | delay := d.scheduleEligibleRepositorySnapshots() | |
| 16 | + | timer.Reset(delay) | |
| 17 | + | select { | |
| 18 | + | case <-ctx.Done(): | |
| 19 | + | return | |
| 20 | + | case <-d.snapshotWake: | |
| 21 | + | case <-timer.C(): | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (d *Daemon) scheduleEligibleRepositorySnapshots() time.Duration { | |
| 27 | + | d.mu.Lock() | |
| 28 | + | sessions := d.sessionsSnapshotLocked() | |
| 29 | + | d.mu.Unlock() | |
| 30 | + | now := d.clock.Now() | |
| 31 | + | for _, sess := range sessions { | |
| 32 | + | if !sess.snapEligible.Load() || !sess.snapDirty.Load() { | |
| 33 | + | continue | |
| 34 | + | } | |
| 35 | + | sess.snapshotMu.Lock() | |
| 36 | + | due := !sess.snapshotPending && (sess.snapshotNextEligibleAt.IsZero() || !now.Before(sess.snapshotNextEligibleAt)) | |
| 37 | + | sess.snapshotMu.Unlock() | |
| 38 | + | if due { | |
| 39 | + | d.scheduleSnapshot(sess) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // Scheduling can synchronously fail or mark a capture pending, so inspect | |
| 44 | + | // the final state to select the actual earliest timer deadline. | |
| 45 | + | var next time.Time | |
| 46 | + | for _, sess := range sessions { | |
| 47 | + | if !sess.snapEligible.Load() || !sess.snapDirty.Load() { | |
| 48 | + | continue | |
| 49 | + | } | |
| 50 | + | sess.snapshotMu.Lock() | |
| 51 | + | eligibleAt := sess.snapshotNextEligibleAt | |
| 52 | + | pending := sess.snapshotPending | |
| 53 | + | sess.snapshotMu.Unlock() | |
| 54 | + | if pending || eligibleAt.IsZero() { | |
| 55 | + | continue | |
| 56 | + | } | |
| 57 | + | if next.IsZero() || eligibleAt.Before(next) { | |
| 58 | + | next = eligibleAt | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | if next.IsZero() { | |
| 62 | + | return 24 * time.Hour | |
| 63 | + | } | |
| 64 | + | if delay := next.Sub(now); delay > 0 { | |
| 65 | + | return delay | |
| 66 | + | } | |
| 67 | + | return 0 | |
| 68 | + | } | |
A internal/usecase/daemon/snapshot_scheduler_test.go +981 -0
| @@ -0,0 +1,981 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "sync" | |
| 8 | + | "sync/atomic" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/vev/internal/domain" | |
| 16 | + | "github.com/bnema/vev/internal/ports" | |
| 17 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func TestSnapshotCoordinatorQuarantineCancelsPublicationBeforeDelete(t *testing.T) { | |
| 21 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 22 | + | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 23 | + | WithSnapshotRepository(repository, nil)(d) | |
| 24 | + | startSnapshotEncodeWorker(t, d) | |
| 25 | + | ||
| 26 | + | started := make(chan struct{}) | |
| 27 | + | repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.SnapshotPublication) error { | |
| 28 | + | close(started) | |
| 29 | + | <-ctx.Done() | |
| 30 | + | return ctx.Err() | |
| 31 | + | }).Once() | |
| 32 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 33 | + | markSnapshotDirty(sess) | |
| 34 | + | require.True(t, d.scheduleSnapshot(sess)) | |
| 35 | + | select { | |
| 36 | + | case <-started: | |
| 37 | + | case <-time.After(testWaitTimeout): | |
| 38 | + | t.Fatal("publication did not start") | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | done := quarantineSnapshotCoordinator(sess) | |
| 42 | + | select { | |
| 43 | + | case <-done: | |
| 44 | + | case <-time.After(testWaitTimeout): | |
| 45 | + | t.Fatal("quarantine did not join canceled publication") | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestSnapshotCoordinatorQuarantineJoinsInFlightPublication(t *testing.T) { | |
| 50 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 51 | + | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 52 | + | WithSnapshotRepository(repository, nil)(d) | |
| 53 | + | startSnapshotEncodeWorker(t, d) | |
| 54 | + | ||
| 55 | + | started := make(chan struct{}) | |
| 56 | + | release := make(chan struct{}) | |
| 57 | + | repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.SnapshotPublication) error { | |
| 58 | + | close(started) | |
| 59 | + | <-release // deliberately ignore cancellation | |
| 60 | + | return nil | |
| 61 | + | }).Once() | |
| 62 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 63 | + | markSnapshotDirty(sess) | |
| 64 | + | require.True(t, d.scheduleSnapshot(sess)) | |
| 65 | + | select { | |
| 66 | + | case <-started: | |
| 67 | + | case <-time.After(testWaitTimeout): | |
| 68 | + | t.Fatal("publication did not start") | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | done := quarantineSnapshotCoordinator(sess) | |
| 72 | + | select { | |
| 73 | + | case <-done: | |
| 74 | + | t.Fatal("quarantine joined before publication returned") | |
| 75 | + | default: | |
| 76 | + | } | |
| 77 | + | close(release) | |
| 78 | + | select { | |
| 79 | + | case <-done: | |
| 80 | + | case <-time.After(testWaitTimeout): | |
| 81 | + | t.Fatal("quarantine did not join publication") | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestRenameKeepsSnapshotCoordinatorQuarantinedUntilNewIdentityCommits(t *testing.T) { | |
| 86 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 87 | + | store, state := newMockStore(t) | |
| 88 | + | WithStore(store)(d) | |
| 89 | + | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 90 | + | WithSnapshotRepository(repository, nil)(d) | |
| 91 | + | startSnapshotEncodeWorker(t, d) | |
| 92 | + | ||
| 93 | + | deleteEntered := make(chan struct{}) | |
| 94 | + | allowDelete := make(chan struct{}) | |
| 95 | + | deleteCompleted := make(chan struct{}) | |
| 96 | + | state.deleteDone = deleteCompleted | |
| 97 | + | published := make(chan ports.SnapshotPublication, 2) | |
| 98 | + | repository.EXPECT().Tombstone(mock.Anything, "work").Return(nil).Once() | |
| 99 | + | repository.EXPECT().Delete(mock.Anything, "work").RunAndReturn(func(context.Context, string) error { | |
| 100 | + | close(deleteEntered) | |
| 101 | + | <-allowDelete | |
| 102 | + | return nil | |
| 103 | + | }).Once() | |
| 104 | + | repository.EXPECT().DeleteTombstone(mock.Anything, "work").Return(nil).Once() | |
| 105 | + | repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error { | |
| 106 | + | published <- publication | |
| 107 | + | return nil | |
| 108 | + | }).Once() | |
| 109 | + | ||
| 110 | + | pty, release := newBlockingPTY(t) | |
| 111 | + | defer release() | |
| 112 | + | d.ptys = newFactory(t, pty) | |
| 113 | + | sess, err := d.createSessionLocked("work", false, "/work", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv) | |
| 114 | + | require.NoError(t, err) | |
| 115 | + | markSnapshotDirty(sess) | |
| 116 | + | ||
| 117 | + | renameDone := make(chan error, 1) | |
| 118 | + | go func() { renameDone <- d.renameSession(sess, "renamed") }() | |
| 119 | + | select { | |
| 120 | + | case <-deleteEntered: | |
| 121 | + | case <-time.After(testWaitTimeout): | |
| 122 | + | t.Fatal("rename did not begin deleting the old snapshot identity") | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // Hold the daemon identity commit after Delete has returned. Scheduler work | |
| 126 | + | // during this window must not capture or publish the old name. | |
| 127 | + | d.mu.Lock() | |
| 128 | + | close(allowDelete) | |
| 129 | + | select { | |
| 130 | + | case <-deleteCompleted: | |
| 131 | + | case <-time.After(testWaitTimeout): | |
| 132 | + | t.Fatal("old snapshot identity was not deleted") | |
| 133 | + | } | |
| 134 | + | require.False(t, state.has("work")) | |
| 135 | + | sess.snapshotMu.Lock() | |
| 136 | + | quarantined := sess.snapshotQuarantined | |
| 137 | + | sess.snapshotMu.Unlock() | |
| 138 | + | require.True(t, quarantined, "old Delete returned before the coordinator resumed") | |
| 139 | + | ||
| 140 | + | schedulerCtx, cancelScheduler := context.WithCancel(context.Background()) | |
| 141 | + | defer cancelScheduler() | |
| 142 | + | go d.snapshotRepositorySaver(schedulerCtx) | |
| 143 | + | select { | |
| 144 | + | case d.snapshotWake <- struct{}{}: | |
| 145 | + | default: | |
| 146 | + | } | |
| 147 | + | require.True(t, d.scheduleSnapshot(sess), "old work may be coalesced while the coordinator is quarantined") | |
| 148 | + | d.mu.Unlock() | |
| 149 | + | ||
| 150 | + | require.NoError(t, <-renameDone) | |
| 151 | + | select { | |
| 152 | + | case publication := <-published: | |
| 153 | + | require.Equal(t, "renamed", publication.Name) | |
| 154 | + | require.Equal(t, uint64(1), publication.Generation) | |
| 155 | + | case <-time.After(testWaitTimeout): | |
| 156 | + | t.Fatal("new identity was not published") | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func TestRenameRollbackAfterOldDeleteLeavesOldCoordinatorStopped(t *testing.T) { | |
| 161 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 162 | + | store, state := newMockStore(t) | |
| 163 | + | WithStore(store)(d) | |
| 164 | + | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 165 | + | WithSnapshotRepository(repository, nil)(d) | |
| 166 | + | startSnapshotEncodeWorker(t, d) | |
| 167 | + | ||
| 168 | + | deleteOldRecordErr := errors.New("old record delete failed") | |
| 169 | + | state.mu.Lock() | |
| 170 | + | state.deleteErr = func(key string) error { | |
| 171 | + | if key == "work" { | |
| 172 | + | return deleteOldRecordErr | |
| 173 | + | } | |
| 174 | + | return nil | |
| 175 | + | } | |
| 176 | + | state.mu.Unlock() | |
| 177 | + | repository.EXPECT().Tombstone(mock.Anything, "work").Return(nil).Once() | |
| 178 | + | repository.EXPECT().Delete(mock.Anything, "work").Return(nil).Once() | |
| 179 | + | // No Publish expectation is installed: any publication is an unexpected mock | |
| 180 | + | // call. The saver handshake below joins the scheduler before this test ends. | |
| 181 | + | ||
| 182 | + | pty, release := newBlockingPTY(t) | |
| 183 | + | defer release() | |
| 184 | + | d.ptys = newFactory(t, pty) | |
| 185 | + | sess, err := d.createSessionLocked("work", false, "/work", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv) | |
| 186 | + | require.NoError(t, err) | |
| 187 | + | markSnapshotDirty(sess) | |
| 188 | + | ||
| 189 | + | require.ErrorIs(t, d.renameSession(sess, "renamed"), deleteOldRecordErr) | |
| 190 | + | sess.mu.Lock() | |
| 191 | + | require.Equal(t, "work", sess.name) | |
| 192 | + | require.False(t, sess.ephemeral) | |
| 193 | + | require.False(t, sess.renameInProgress) | |
| 194 | + | sess.mu.Unlock() | |
| 195 | + | require.True(t, state.has("work")) | |
| 196 | + | require.False(t, state.has("renamed")) | |
| 197 | + | sess.snapshotMu.Lock() | |
| 198 | + | quarantined := sess.snapshotQuarantined | |
| 199 | + | sess.snapshotMu.Unlock() | |
| 200 | + | require.True(t, quarantined) | |
| 201 | + | ||
| 202 | + | // Drain stale producer notifications, then use the timer-reset handshake to | |
| 203 | + | // prove the saver consumed this wake and completed its scheduling pass. Stop | |
| 204 | + | // and join it before asserting: no publisher can start after this point. | |
| 205 | + | for { | |
| 206 | + | select { | |
| 207 | + | case <-d.snapshotWake: | |
| 208 | + | default: | |
| 209 | + | goto wakeDrained | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | wakeDrained: | |
| 214 | + | clock := newDeterministicSnapshotClock(time.Unix(100, 0)) | |
| 215 | + | d.clock = clock | |
| 216 | + | schedulerCtx, cancelScheduler := context.WithCancel(context.Background()) | |
| 217 | + | schedulerDone := make(chan struct{}) | |
| 218 | + | go func() { | |
| 219 | + | d.snapshotRepositorySaver(schedulerCtx) | |
| 220 | + | close(schedulerDone) | |
| 221 | + | }() | |
| 222 | + | timer := clock.timer() | |
| 223 | + | require.Equal(t, 24*time.Hour, <-timer.resets) | |
| 224 | + | select { | |
| 225 | + | case d.snapshotWake <- struct{}{}: | |
| 226 | + | default: | |
| 227 | + | t.Fatal("snapshot wake unexpectedly remained full") | |
| 228 | + | } | |
| 229 | + | require.Equal(t, 24*time.Hour, <-timer.resets, "scheduler did not process the rollback wake") | |
| 230 | + | cancelScheduler() | |
| 231 | + | select { | |
| 232 | + | case <-schedulerDone: | |
| 233 | + | case <-time.After(testWaitTimeout): | |
| 234 | + | t.Fatal("snapshot scheduler did not stop") | |
| 235 | + | } | |
| 236 | + | // The deterministic scheduler clock has no independently firing deadline; | |
| 237 | + | // restore the daemon's normal test clock before its session cleanup runs. | |
| 238 | + | d.clock = stubClock{} | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func TestForcedSnapshotSchedulesSuccessorAfterRoutineInFlight(t *testing.T) { | |
| 242 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 243 | + | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 244 | + | WithSnapshotRepository(repository, nil)(d) | |
| 245 | + | startSnapshotEncodeWorker(t, d) | |
| 246 | + | ||
| 247 | + | started := make(chan struct{}) | |
| 248 | + | release := make(chan struct{}) | |
| 249 | + | publications := make(chan ports.SnapshotPublication, 2) | |
| 250 | + | repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error { | |
| 251 | + | publications <- publication | |
| 252 | + | if publication.Generation == 1 { | |
| 253 | + | close(started) | |
| 254 | + | <-release | |
| 255 | + | } | |
| 256 | + | return nil | |
| 257 | + | }).Twice() | |
| 258 | + | ||
| 259 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 260 | + | markSnapshotDirty(sess) | |
| 261 | + | require.True(t, d.scheduleSnapshot(sess)) | |
| 262 | + | select { | |
| 263 | + | case <-started: | |
| 264 | + | case <-time.After(testWaitTimeout): | |
| 265 | + | t.Fatal("routine publication did not start") | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // The terminal state changed after the routine capture, so its publication | |
| 269 | + | // cannot satisfy the forced checkpoint. | |
| 270 | + | sess.tabs[0].panes["pane-1"].screen.Write([]byte(" terminal")) | |
| 271 | + | markSnapshotDirty(sess) | |
| 272 | + | require.True(t, d.scheduleFinalSnapshot(sess)) | |
| 273 | + | close(release) | |
| 274 | + | ||
| 275 | + | awaitSnapshotClean(t, sess) | |
| 276 | + | require.Equal(t, uint64(1), (<-publications).Generation) | |
| 277 | + | require.Equal(t, uint64(2), (<-publications).Generation) | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | func TestForcedSnapshotSchedulesSuccessorForRoutineCaptureQueuedBehindWorker(t *testing.T) { | |
| 281 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 282 | + | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 283 | + | WithSnapshotRepository(repository, nil)(d) | |
| 284 | + | startSnapshotEncodeWorker(t, d) | |
| 285 | + | ||
| 286 | + | blockerStarted := make(chan struct{}) | |
| 287 | + | releaseBlocker := make(chan struct{}) | |
| 288 | + | workPublications := make(chan ports.SnapshotPublication, 2) | |
| 289 | + | repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error { | |
| 290 | + | if publication.Name == "blocker" { | |
| 291 | + | close(blockerStarted) | |
| 292 | + | <-releaseBlocker | |
| 293 | + | return nil | |
| 294 | + | } | |
| 295 | + | workPublications <- publication | |
| 296 | + | return nil | |
| 297 | + | }).Times(3) | |
| 298 | + | ||
| 299 | + | blocker := newSnapshotTestSession(t, "blocker", false, "/work") | |
| 300 | + | markSnapshotDirty(blocker) | |
| 301 | + | require.True(t, d.scheduleSnapshot(blocker)) | |
| 302 | + | select { | |
| 303 | + | case <-blockerStarted: | |
| 304 | + | case <-time.After(testWaitTimeout): | |
| 305 | + | t.Fatal("blocking publication did not start") | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 309 | + | markSnapshotDirty(sess) | |
| 310 | + | require.True(t, d.scheduleSnapshot(sess), "routine capture should occupy the bounded queue") | |
| 311 | + | sess.tabs[0].panes["pane-1"].screen.Write([]byte(" terminal")) | |
| 312 | + | markSnapshotDirty(sess) | |
| 313 | + | require.True(t, d.scheduleFinalSnapshot(sess)) | |
| 314 | + | close(releaseBlocker) | |
| 315 | + | ||
| 316 | + | awaitSnapshotClean(t, sess) | |
| 317 | + | require.Equal(t, uint64(1), (<-workPublications).Generation) | |
| 318 | + | require.Equal(t, uint64(2), (<-workPublications).Generation) | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func TestMultipleForcedSnapshotsCoalesceToOneSuccessor(t *testing.T) { | |
| 322 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 323 | + | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 324 | + | WithSnapshotRepository(repository, nil)(d) | |
| 325 | + | startSnapshotEncodeWorker(t, d) | |
| 326 | + | ||
| 327 | + | started := make(chan struct{}) | |
| 328 | + | release := make(chan struct{}) | |
| 329 | + | publications := make(chan ports.SnapshotPublication, 2) | |
| 330 | + | repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error { | |
| 331 | + | publications <- publication | |
| 332 | + | if publication.Generation == 1 { | |
| 333 | + | close(started) | |
| 334 | + | <-release | |
| 335 | + | } | |
| 336 | + | return nil | |
| 337 | + | }).Twice() | |
| 338 | + | ||
| 339 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 340 | + | markSnapshotDirty(sess) | |
| 341 | + | require.True(t, d.scheduleSnapshot(sess)) | |
| 342 | + | select { | |
| 343 | + | case <-started: | |
| 344 | + | case <-time.After(testWaitTimeout): | |
| 345 | + | t.Fatal("routine publication did not start") | |
| 346 | + | } | |
| 347 | + | markSnapshotDirty(sess) | |
| 348 | + | require.True(t, d.scheduleFinalSnapshot(sess)) | |
| 349 | + | markSnapshotDirty(sess) | |
| 350 | + | require.True(t, d.scheduleFinalSnapshot(sess)) | |
| 351 | + | close(release) | |
| 352 | + | ||
| 353 | + | awaitSnapshotClean(t, sess) | |
| 354 | + | require.Equal(t, uint64(1), (<-publications).Generation) | |
| 355 | + | require.Equal(t, uint64(2), (<-publications).Generation) | |
| 356 | + | sess.snapshotMu.Lock() | |
| 357 | + | require.False(t, sess.snapshotPending) | |
| 358 | + | require.Nil(t, sess.snapshotQueuedCapture) | |
| 359 | + | require.Nil(t, sess.snapshotInFlightCapture) | |
| 360 | + | sess.snapshotMu.Unlock() | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | func TestForcedSnapshotShutdownTimeoutRetainsRetryableStateAndNotice(t *testing.T) { | |
| 364 | + | clock := newSnapshotDeadlineClock() | |
| 365 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |
| 366 | + | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 367 | + | WithSnapshotRepository(repository, nil)(d) | |
| 368 | + | notices := portsmocks.NewMockNoticeStore(t) | |
| 369 | + | WithNoticeStore(notices)(d) | |
| 370 | + | startSnapshotEncodeWorker(t, d) | |
| 371 | + | ||
| 372 | + | blockerStarted := make(chan struct{}) | |
| 373 | + | releaseBlocker := make(chan struct{}) | |
| 374 | + | var releaseBlockerOnce sync.Once | |
| 375 | + | release := func() { releaseBlockerOnce.Do(func() { close(releaseBlocker) }) } | |
| 376 | + | // Register this after newTestDaemon's worker cleanup so the uncooperative | |
| 377 | + | // repository call is always released before cleanup asks the worker to join. | |
| 378 | + | t.Cleanup(release) | |
| 379 | + | repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error { | |
| 380 | + | require.Equal(t, "blocker", publication.Name) | |
| 381 | + | close(blockerStarted) | |
| 382 | + | <-releaseBlocker | |
| 383 | + | return nil | |
| 384 | + | }).Once() | |
| 385 | + | notices.EXPECT().Append(mock.MatchedBy(func(notification domain.Notification) bool { | |
| 386 | + | return notification.Code == domain.NoticeSnapshotWrite && notification.Severity == domain.NoticeError | |
| 387 | + | })).Return(nil).Once() | |
| 388 | + | ||
| 389 | + | blocker := newSnapshotTestSession(t, "blocker", false, "/work") | |
| 390 | + | markSnapshotDirty(blocker) | |
| 391 | + | require.True(t, d.scheduleSnapshot(blocker)) | |
| 392 | + | select { | |
| 393 | + | case <-blockerStarted: | |
| 394 | + | case <-time.After(testWaitTimeout): | |
| 395 | + | t.Fatal("blocking publication did not start") | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 399 | + | pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 400 | + | require.True(t, ok, "snapshot test pane must use MockPTY") | |
| 401 | + | pty.EXPECT().Close().Return(nil).Maybe() | |
| 402 | + | d.sessions[sess.id] = sess | |
| 403 | + | markSnapshotDirty(sess) | |
| 404 | + | require.True(t, d.scheduleSnapshot(sess), "routine capture should occupy the bounded queue") | |
| 405 | + | ||
| 406 | + | killed := make(chan error, 1) | |
| 407 | + | go func() { killed <- d.killSession(sess, ports.ReasonServerShutdown, false) }() | |
| 408 | + | timer := clock.nextTimer(t) | |
| 409 | + | timer.fire() | |
| 410 | + | require.Error(t, <-killed) | |
| 411 | + | ||
| 412 | + | func() { | |
| 413 | + | sess.snapshotMu.Lock() | |
| 414 | + | defer sess.snapshotMu.Unlock() | |
| 415 | + | require.True(t, sess.snapDirty.Load()) | |
| 416 | + | require.Greater(t, sess.snapshotForcedGeneration, sess.snapshotPublishedGeneration) | |
| 417 | + | require.True(t, sess.snapshotPending) | |
| 418 | + | require.Equal(t, uint(1), sess.snapshotPendingCaptures) | |
| 419 | + | require.NotNil(t, sess.snapshotQueuedCapture, "the one routine capture remains retryable") | |
| 420 | + | require.Nil(t, sess.snapshotInFlightCapture) | |
| 421 | + | }() | |
| 422 | + | d.snapshotWorkerMu.Lock() | |
| 423 | + | require.LessOrEqual(t, len(d.snapshotJobs), snapshotQueueCapacity) | |
| 424 | + | d.snapshotWorkerMu.Unlock() | |
| 425 | + | ||
| 426 | + | // The retained capture is not published after quarantine, but releasing the | |
| 427 | + | // unrelated blocked call lets the worker discard it and lets cleanup join. | |
| 428 | + | release() | |
| 429 | + | for { | |
| 430 | + | sess.snapshotMu.Lock() | |
| 431 | + | complete := !sess.snapshotPending && sess.snapshotQueuedCapture == nil | |
| 432 | + | changed := sess.snapshotChangeLocked() | |
| 433 | + | sess.snapshotMu.Unlock() | |
| 434 | + | if complete { | |
| 435 | + | break | |
| 436 | + | } | |
| 437 | + | select { | |
| 438 | + | case <-changed: | |
| 439 | + | case <-time.After(testWaitTimeout): | |
| 440 | + | t.Fatal("quarantined capture was not discarded") | |
| 441 | + | } | |
| 442 | + | } | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | func TestServeShutdownCheckpointsBeforeStoppingSnapshotWorker(t *testing.T) { | |
| 446 | + | p, releasePTY := newBlockingPTY(t) | |
| 447 | + | defer releasePTY() | |
| 448 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 449 | + | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 450 | + | WithSnapshotRepository(repository, nil)(d) | |
| 451 | + | repository.EXPECT().List(mock.Anything).Return(nil, nil).Maybe() | |
| 452 | + | ||
| 453 | + | published := make(chan ports.SnapshotPublication, 2) | |
| 454 | + | allowTerminalPublication := make(chan struct{}) | |
| 455 | + | repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error { | |
| 456 | + | published <- publication | |
| 457 | + | if publication.Generation == 2 { | |
| 458 | + | <-allowTerminalPublication | |
| 459 | + | } | |
| 460 | + | return nil | |
| 461 | + | }).Twice() | |
| 462 | + | ||
| 463 | + | tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24})) | |
| 464 | + | l := serveSnapshotListener(t, tr) | |
| 465 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 466 | + | defer cancel() | |
| 467 | + | served := make(chan error, 1) | |
| 468 | + | go func() { served <- d.Serve(ctx, l) }() | |
| 469 | + | ||
| 470 | + | awaitFrame(t, sends, ports.MsgWelcome) | |
| 471 | + | markSnapshotDirty(firstSession(d)) | |
| 472 | + | d.snapshotWake <- struct{}{} | |
| 473 | + | first := <-published | |
| 474 | + | require.Equal(t, uint64(1), first.Generation) | |
| 475 | + | ||
| 476 | + | cancel() | |
| 477 | + | terminal := <-published | |
| 478 | + | require.Equal(t, uint64(2), terminal.Generation, "shutdown must publish a forced terminal checkpoint") | |
| 479 | + | close(allowTerminalPublication) | |
| 480 | + | ||
| 481 | + | select { | |
| 482 | + | case err := <-served: | |
| 483 | + | require.NoError(t, err) | |
| 484 | + | case <-time.After(testWaitTimeout): | |
| 485 | + | t.Fatal("Serve did not return after the terminal checkpoint completed") | |
| 486 | + | } | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | func TestServeShutdownDeadlineDoesNotWaitTwiceForUncooperativeSnapshotRepository(t *testing.T) { | |
| 490 | + | p, releasePTY := newBlockingPTY(t) | |
| 491 | + | defer releasePTY() | |
| 492 | + | clock := newServeShutdownClock() | |
| 493 | + | d := newTestDaemon(t, newFactory(t, p), clock) | |
| 494 | + | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 495 | + | WithSnapshotRepository(repository, nil)(d) | |
| 496 | + | repository.EXPECT().List(mock.Anything).Return(nil, nil).Maybe() | |
| 497 | + | noticeStore := portsmocks.NewMockNoticeStore(t) | |
| 498 | + | WithNoticeStore(noticeStore)(d) | |
| 499 | + | noticeStore.EXPECT().Claim().Return(nil, nil).Maybe() | |
| 500 | + | noticeStore.EXPECT().Ack().Return(nil).Maybe() | |
| 501 | + | noticeStore.EXPECT().Append(mock.MatchedBy(func(n domain.Notification) bool { | |
| 502 | + | return n.Code == domain.NoticeSnapshotWrite && n.Severity == domain.NoticeError | |
| 503 | + | })).Return(nil).Once() | |
| 504 | + | ||
| 505 | + | firstPublicationCompleted := make(chan struct{}) | |
| 506 | + | terminalPublicationStarted := make(chan struct{}) | |
| 507 | + | releaseTerminalPublication := make(chan struct{}) | |
| 508 | + | var lastValid atomic.Uint64 | |
| 509 | + | repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error { | |
| 510 | + | if publication.Generation == 1 { | |
| 511 | + | lastValid.Store(publication.Generation) | |
| 512 | + | close(firstPublicationCompleted) | |
| 513 | + | return nil | |
| 514 | + | } | |
| 515 | + | close(terminalPublicationStarted) | |
| 516 | + | <-releaseTerminalPublication // Deliberately ignores the worker cancellation context. | |
| 517 | + | return nil | |
| 518 | + | }).Twice() | |
| 519 | + | ||
| 520 | + | tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24})) | |
| 521 | + | l := serveSnapshotListener(t, tr) | |
| 522 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 523 | + | defer cancel() | |
| 524 | + | served := make(chan error, 1) | |
| 525 | + | go func() { served <- d.Serve(ctx, l) }() | |
| 526 | + | ||
| 527 | + | awaitFrame(t, sends, ports.MsgWelcome) | |
| 528 | + | markSnapshotDirty(firstSession(d)) | |
| 529 | + | d.snapshotWake <- struct{}{} | |
| 530 | + | select { | |
| 531 | + | case <-firstPublicationCompleted: | |
| 532 | + | case <-time.After(testWaitTimeout): | |
| 533 | + | t.Fatal("initial checkpoint did not complete") | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | cancel() | |
| 537 | + | select { | |
| 538 | + | case <-terminalPublicationStarted: | |
| 539 | + | case <-time.After(testWaitTimeout): | |
| 540 | + | t.Fatal("shutdown terminal checkpoint did not reach the worker") | |
| 541 | + | } | |
| 542 | + | d.snapshotWorkerMu.Lock() | |
| 543 | + | workerDone := d.snapshotWorkerDone | |
| 544 | + | d.snapshotWorkerMu.Unlock() | |
| 545 | + | require.NotNil(t, workerDone) | |
| 546 | + | defer func() { | |
| 547 | + | close(releaseTerminalPublication) | |
| 548 | + | select { | |
| 549 | + | case <-workerDone: | |
| 550 | + | case <-time.After(testWaitTimeout): | |
| 551 | + | t.Error("snapshot worker leaked after its repository call was released") | |
| 552 | + | } | |
| 553 | + | }() | |
| 554 | + | clock.nextFinalTimer(t).fire() | |
| 555 | + | ||
| 556 | + | select { | |
| 557 | + | case err := <-served: | |
| 558 | + | require.NoError(t, err) | |
| 559 | + | case <-time.After(testWaitTimeout): | |
| 560 | + | t.Fatal("Serve waited beyond the terminal checkpoint deadline") | |
| 561 | + | } | |
| 562 | + | require.LessOrEqual(t, clock.finalBudget.Load(), int64(snapshotFinalFlushTimeout), "shutdown must consume at most one snapshot deadline") | |
| 563 | + | require.Equal(t, uint64(1), lastValid.Load(), "the last valid checkpoint remains available after the terminal attempt times out") | |
| 564 | + | } | |
| 565 | + | ||
| 566 | + | type serveShutdownClock struct { | |
| 567 | + | finalTimers chan *snapshotDeadlineTimer | |
| 568 | + | finalBudget atomic.Int64 | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | func newServeShutdownClock() *serveShutdownClock { | |
| 572 | + | return &serveShutdownClock{finalTimers: make(chan *snapshotDeadlineTimer, 2)} | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | func (*serveShutdownClock) Now() time.Time { return time.Unix(100, 0) } | |
| 576 | + | func (c *serveShutdownClock) NewTimer(delay time.Duration) ports.Timer { | |
| 577 | + | if delay != snapshotFinalFlushTimeout { | |
| 578 | + | return stubTimer{} | |
| 579 | + | } | |
| 580 | + | timer := &snapshotDeadlineTimer{ch: make(chan time.Time, 1), onFire: func() { | |
| 581 | + | c.finalBudget.Add(int64(delay)) | |
| 582 | + | }} | |
| 583 | + | c.finalTimers <- timer | |
| 584 | + | return timer | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | func (c *serveShutdownClock) nextFinalTimer(t *testing.T) *snapshotDeadlineTimer { | |
| 588 | + | t.Helper() | |
| 589 | + | select { | |
| 590 | + | case timer := <-c.finalTimers: | |
| 591 | + | return timer | |
| 592 | + | case <-time.After(testWaitTimeout): | |
| 593 | + | t.Fatal("shutdown checkpoint deadline timer was not created") | |
| 594 | + | return nil | |
| 595 | + | } | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | func serveSnapshotListener(t *testing.T, tr ports.Transport) *portsmocks.MockListener { | |
| 599 | + | t.Helper() | |
| 600 | + | l := portsmocks.NewMockListener(t) | |
| 601 | + | connections := make(chan ports.Transport, 1) | |
| 602 | + | connections <- tr | |
| 603 | + | closed := make(chan struct{}) | |
| 604 | + | var once sync.Once | |
| 605 | + | l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) { | |
| 606 | + | select { | |
| 607 | + | case conn := <-connections: | |
| 608 | + | return conn, nil | |
| 609 | + | case <-closed: | |
| 610 | + | return nil, io.EOF | |
| 611 | + | } | |
| 612 | + | }).Maybe() | |
| 613 | + | l.EXPECT().Close().RunAndReturn(func() error { | |
| 614 | + | once.Do(func() { close(closed) }) | |
| 615 | + | return nil | |
| 616 | + | }).Maybe() | |
| 617 | + | l.EXPECT().Addr().Return("mock").Maybe() | |
| 618 | + | return l | |
| 619 | + | } | |
| 620 | + | ||
| 621 | + | func TestRoutineSnapshotEligibilityStartsAtCompletionAndForcedDoesNotMoveIt(t *testing.T) { | |
| 622 | + | clock := &snapshotSchedulerClock{now: time.Unix(100, 0)} | |
| 623 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |
| 624 | + | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 625 | + | WithSnapshotRepository(repository, nil)(d) | |
| 626 | + | startSnapshotEncodeWorker(t, d) | |
| 627 | + | ||
| 628 | + | var publishes atomic.Int32 | |
| 629 | + | repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ ports.SnapshotPublication) error { | |
| 630 | + | publishes.Add(1) | |
| 631 | + | return nil | |
| 632 | + | }).Times(2) | |
| 633 | + | ||
| 634 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 635 | + | markSnapshotDirty(sess) | |
| 636 | + | require.True(t, d.scheduleSnapshot(sess)) | |
| 637 | + | awaitSnapshotClean(t, sess) | |
| 638 | + | ||
| 639 | + | sess.snapshotMu.Lock() | |
| 640 | + | eligibleAt := sess.snapshotNextEligibleAt | |
| 641 | + | sess.snapshotMu.Unlock() | |
| 642 | + | require.Equal(t, time.Unix(100, 0).Add(snapshotInterval), eligibleAt) | |
| 643 | + | ||
| 644 | + | markSnapshotDirty(sess) | |
| 645 | + | require.True(t, d.scheduleSnapshot(sess)) | |
| 646 | + | require.Equal(t, int32(1), publishes.Load(), "rate-limited routine work must not publish") | |
| 647 | + | ||
| 648 | + | require.True(t, d.scheduleFinalSnapshot(sess)) | |
| 649 | + | awaitSnapshotClean(t, sess) | |
| 650 | + | sess.snapshotMu.Lock() | |
| 651 | + | require.Equal(t, eligibleAt, sess.snapshotNextEligibleAt) | |
| 652 | + | sess.snapshotMu.Unlock() | |
| 653 | + | } | |
| 654 | + | ||
| 655 | + | type snapshotSchedulerClock struct { | |
| 656 | + | mu sync.Mutex | |
| 657 | + | now time.Time | |
| 658 | + | } | |
| 659 | + | ||
| 660 | + | func (c *snapshotSchedulerClock) Now() time.Time { | |
| 661 | + | c.mu.Lock() | |
| 662 | + | defer c.mu.Unlock() | |
| 663 | + | return c.now | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | func (*snapshotSchedulerClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} } | |
| 667 | + | ||
| 668 | + | type snapshotDeadlineClock struct { | |
| 669 | + | timers chan *snapshotDeadlineTimer | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | func newSnapshotDeadlineClock() *snapshotDeadlineClock { | |
| 673 | + | return &snapshotDeadlineClock{timers: make(chan *snapshotDeadlineTimer, 4)} | |
| 674 | + | } | |
| 675 | + | ||
| 676 | + | func (*snapshotDeadlineClock) Now() time.Time { return time.Unix(100, 0) } | |
| 677 | + | func (c *snapshotDeadlineClock) NewTimer(time.Duration) ports.Timer { | |
| 678 | + | timer := &snapshotDeadlineTimer{ch: make(chan time.Time, 1)} | |
| 679 | + | c.timers <- timer | |
| 680 | + | return timer | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | func (c *snapshotDeadlineClock) nextTimer(t *testing.T) *snapshotDeadlineTimer { | |
| 684 | + | t.Helper() | |
| 685 | + | select { | |
| 686 | + | case timer := <-c.timers: | |
| 687 | + | return timer | |
| 688 | + | case <-time.After(testWaitTimeout): | |
| 689 | + | t.Fatal("snapshot deadline timer was not created") | |
| 690 | + | return nil | |
| 691 | + | } | |
| 692 | + | } | |
| 693 | + | ||
| 694 | + | type snapshotDeadlineTimer struct { | |
| 695 | + | ch chan time.Time | |
| 696 | + | onFire func() | |
| 697 | + | } | |
| 698 | + | ||
| 699 | + | func (t *snapshotDeadlineTimer) C() <-chan time.Time { return t.ch } | |
| 700 | + | func (*snapshotDeadlineTimer) Reset(time.Duration) bool { return true } | |
| 701 | + | func (*snapshotDeadlineTimer) Stop() bool { return true } | |
| 702 | + | func (t *snapshotDeadlineTimer) fire() { | |
| 703 | + | if t.onFire != nil { | |
| 704 | + | t.onFire() | |
| 705 | + | } | |
| 706 | + | t.ch <- time.Unix(101, 0) | |
| 707 | + | } | |
| 708 | + | ||
| 709 | + | // deterministicSnapshotClock records timer resets and advances only when a | |
| 710 | + | // test explicitly fires its timer. It deliberately has no wall-clock behavior. | |
| 711 | + | type deterministicSnapshotClock struct { | |
| 712 | + | mu sync.Mutex | |
| 713 | + | now time.Time | |
| 714 | + | timers chan *deterministicSnapshotTimer | |
| 715 | + | } | |
| 716 | + | ||
| 717 | + | func newDeterministicSnapshotClock(now time.Time) *deterministicSnapshotClock { | |
| 718 | + | return &deterministicSnapshotClock{now: now, timers: make(chan *deterministicSnapshotTimer, 1)} | |
| 719 | + | } | |
| 720 | + | ||
| 721 | + | func (c *deterministicSnapshotClock) Now() time.Time { | |
| 722 | + | c.mu.Lock() | |
| 723 | + | defer c.mu.Unlock() | |
| 724 | + | return c.now | |
| 725 | + | } | |
| 726 | + | ||
| 727 | + | func (c *deterministicSnapshotClock) advance(now time.Time) { | |
| 728 | + | c.mu.Lock() | |
| 729 | + | c.now = now | |
| 730 | + | c.mu.Unlock() | |
| 731 | + | } | |
| 732 | + | ||
| 733 | + | func (c *deterministicSnapshotClock) NewTimer(time.Duration) ports.Timer { | |
| 734 | + | timer := &deterministicSnapshotTimer{clock: c, ch: make(chan time.Time, 1), resets: make(chan time.Duration, 16)} | |
| 735 | + | c.timers <- timer | |
| 736 | + | return timer | |
| 737 | + | } | |
| 738 | + | ||
| 739 | + | func (c *deterministicSnapshotClock) timer() *deterministicSnapshotTimer { return <-c.timers } | |
| 740 | + | ||
| 741 | + | type deterministicSnapshotTimer struct { | |
| 742 | + | clock *deterministicSnapshotClock | |
| 743 | + | ch chan time.Time | |
| 744 | + | resets chan time.Duration | |
| 745 | + | } | |
| 746 | + | ||
| 747 | + | func (t *deterministicSnapshotTimer) C() <-chan time.Time { return t.ch } | |
| 748 | + | func (t *deterministicSnapshotTimer) Reset(delay time.Duration) bool { | |
| 749 | + | t.resets <- delay | |
| 750 | + | return true | |
| 751 | + | } | |
| 752 | + | func (*deterministicSnapshotTimer) Stop() bool { return true } | |
| 753 | + | func (t *deterministicSnapshotTimer) fire() { t.ch <- t.clock.Now() } | |
| 754 | + | ||
| 755 | + | func awaitSnapshotCleanSignal(sess *session) { | |
| 756 | + | for { | |
| 757 | + | sess.snapshotMu.Lock() | |
| 758 | + | clean := !sess.snapDirty.Load() | |
| 759 | + | changed := sess.snapshotChangeLocked() | |
| 760 | + | sess.snapshotMu.Unlock() | |
| 761 | + | if clean { | |
| 762 | + | return | |
| 763 | + | } | |
| 764 | + | <-changed | |
| 765 | + | } | |
| 766 | + | } | |
| 767 | + | ||
| 768 | + | func TestSnapshotCompletionSchedulesRoutineRetriesFromCompletion(t *testing.T) { | |
| 769 | + | now := time.Unix(100, 0) | |
| 770 | + | clock := newDeterministicSnapshotClock(now) | |
| 771 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |
| 772 | + | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 773 | + | WithSnapshotRepository(repository, nil)(d) | |
| 774 | + | ||
| 775 | + | for _, tt := range []struct { | |
| 776 | + | name string | |
| 777 | + | kind snapshotAttemptKind | |
| 778 | + | succeeded bool | |
| 779 | + | existingEligible time.Time | |
| 780 | + | wantDirty bool | |
| 781 | + | wantEligible time.Time | |
| 782 | + | }{ | |
| 783 | + | {name: "routine success", kind: snapshotAttemptRoutine, succeeded: true, wantEligible: now.Add(snapshotInterval)}, | |
| 784 | + | {name: "routine failure", kind: snapshotAttemptRoutine, wantDirty: true, wantEligible: now.Add(snapshotInterval)}, | |
| 785 | + | {name: "forced completion preserves routine eligibility", kind: snapshotAttemptForced, succeeded: true, existingEligible: now.Add(time.Minute), wantEligible: now.Add(time.Minute)}, | |
| 786 | + | } { | |
| 787 | + | t.Run(tt.name, func(t *testing.T) { | |
| 788 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 789 | + | sess.snapshotGeneration = 1 | |
| 790 | + | sess.snapshotNextEligibleAt = tt.existingEligible | |
| 791 | + | sess.snapDirty.Store(true) | |
| 792 | + | d.finishSnapshotCapture(&snapshotCapture{session: sess, generation: 1, attemptKind: tt.kind}, tt.succeeded) | |
| 793 | + | ||
| 794 | + | require.Equal(t, tt.wantDirty, sess.snapDirty.Load()) | |
| 795 | + | sess.snapshotMu.Lock() | |
| 796 | + | require.Equal(t, tt.wantEligible, sess.snapshotNextEligibleAt) | |
| 797 | + | sess.snapshotMu.Unlock() | |
| 798 | + | }) | |
| 799 | + | } | |
| 800 | + | } | |
| 801 | + | ||
| 802 | + | func TestSnapshotSchedulerImmediateEligibilityAndStaleCapturesRemainDirty(t *testing.T) { | |
| 803 | + | now := time.Unix(100, 0) | |
| 804 | + | clock := newDeterministicSnapshotClock(now) | |
| 805 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |
| 806 | + | WithSnapshotRepository(portsmocks.NewMockSnapshotRepository(t), nil)(d) | |
| 807 | + | ||
| 808 | + | t.Run("no prior attempt is immediately eligible", func(t *testing.T) { | |
| 809 | + | published := make(chan ports.SnapshotPublication, 1) | |
| 810 | + | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 811 | + | WithSnapshotRepository(repository, nil)(d) | |
| 812 | + | startSnapshotEncodeWorker(t, d) | |
| 813 | + | repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error { | |
| 814 | + | published <- publication | |
| 815 | + | return nil | |
| 816 | + | }).Once() | |
| 817 | + | ||
| 818 | + | sess := newSnapshotTestSession(t, "immediate", false, "/work") | |
| 819 | + | sess.snapshotWake = d.snapshotWake | |
| 820 | + | sess.snapDirty.Store(true) | |
| 821 | + | d.sessions[sess.id] = sess | |
| 822 | + | ||
| 823 | + | d.scheduleEligibleRepositorySnapshots() | |
| 824 | + | select { | |
| 825 | + | case publication := <-published: | |
| 826 | + | require.Equal(t, "immediate", publication.Name) | |
| 827 | + | case <-time.After(testWaitTimeout): | |
| 828 | + | t.Fatal("immediately eligible snapshot was not published") | |
| 829 | + | } | |
| 830 | + | awaitSnapshotClean(t, sess) | |
| 831 | + | }) | |
| 832 | + | ||
| 833 | + | t.Run("queued and in-flight stale successes cannot clear newer generation", func(t *testing.T) { | |
| 834 | + | for _, state := range []struct { | |
| 835 | + | name string | |
| 836 | + | set func(*session, *snapshotCapture) | |
| 837 | + | }{ | |
| 838 | + | {name: "queued", set: func(sess *session, capture *snapshotCapture) { sess.snapshotQueuedCapture = capture }}, | |
| 839 | + | {name: "in flight", set: func(sess *session, capture *snapshotCapture) { sess.snapshotInFlightCapture = capture }}, | |
| 840 | + | } { | |
| 841 | + | t.Run(state.name, func(t *testing.T) { | |
| 842 | + | sess := newSnapshotTestSession(t, state.name, false, "/work") | |
| 843 | + | sess.snapshotGeneration = 1 | |
| 844 | + | sess.snapDirty.Store(true) | |
| 845 | + | capture := &snapshotCapture{session: sess, generation: 1, attemptKind: snapshotAttemptRoutine} | |
| 846 | + | sess.snapshotPendingCaptures = 1 | |
| 847 | + | sess.snapshotPending = true | |
| 848 | + | state.set(sess, capture) | |
| 849 | + | ||
| 850 | + | markSnapshotDirty(sess) | |
| 851 | + | d.finishSnapshotCapture(capture, true) | |
| 852 | + | ||
| 853 | + | require.True(t, sess.snapDirty.Load()) | |
| 854 | + | require.Equal(t, uint64(1), sess.snapshotPublishedGeneration) | |
| 855 | + | require.Equal(t, now.Add(snapshotInterval), sess.snapshotNextEligibleAt) | |
| 856 | + | }) | |
| 857 | + | } | |
| 858 | + | }) | |
| 859 | + | } | |
| 860 | + | ||
| 861 | + | func TestSnapshotWorkerQueueIsBoundedAndForcedCapturesCoalesce(t *testing.T) { | |
| 862 | + | now := time.Unix(100, 0) | |
| 863 | + | clock := newDeterministicSnapshotClock(now) | |
| 864 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |
| 865 | + | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 866 | + | WithSnapshotRepository(repository, nil)(d) | |
| 867 | + | startSnapshotEncodeWorker(t, d) | |
| 868 | + | ||
| 869 | + | started := make(chan struct{}) | |
| 870 | + | release := make(chan struct{}) | |
| 871 | + | publications := make(chan ports.SnapshotPublication, 3) | |
| 872 | + | var blockFirst sync.Once | |
| 873 | + | repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error { | |
| 874 | + | publications <- publication | |
| 875 | + | if publication.Name == "in-flight" { | |
| 876 | + | blockFirst.Do(func() { | |
| 877 | + | close(started) | |
| 878 | + | <-release | |
| 879 | + | }) | |
| 880 | + | } | |
| 881 | + | return nil | |
| 882 | + | }).Times(3) | |
| 883 | + | ||
| 884 | + | inFlight := newSnapshotTestSession(t, "in-flight", false, "/work") | |
| 885 | + | markSnapshotDirty(inFlight) | |
| 886 | + | require.True(t, d.scheduleSnapshot(inFlight)) | |
| 887 | + | <-started | |
| 888 | + | ||
| 889 | + | queued := newSnapshotTestSession(t, "queued", false, "/work") | |
| 890 | + | markSnapshotDirty(queued) | |
| 891 | + | require.True(t, d.scheduleSnapshot(queued)) | |
| 892 | + | ||
| 893 | + | d.snapshotWorkerMu.Lock() | |
| 894 | + | require.NotNil(t, d.snapshotWorkerInFlight) | |
| 895 | + | require.Len(t, d.snapshotJobs, snapshotQueueCapacity) | |
| 896 | + | d.snapshotWorkerMu.Unlock() | |
| 897 | + | ||
| 898 | + | saturated := newSnapshotTestSession(t, "saturated", false, "/work") | |
| 899 | + | markSnapshotDirty(saturated) | |
| 900 | + | require.False(t, d.scheduleSnapshot(saturated), "a saturated queue must not block the producer") | |
| 901 | + | require.True(t, saturated.snapDirty.Load(), "the rejected capture remains retryable") | |
| 902 | + | saturated.snapshotMu.Lock() | |
| 903 | + | require.Equal(t, now.Add(snapshotInterval), saturated.snapshotNextEligibleAt) | |
| 904 | + | saturated.snapshotMu.Unlock() | |
| 905 | + | ||
| 906 | + | // A forced request bypasses the routine rate limit and repeated requests | |
| 907 | + | // coalesce to one successor after the in-flight capture completes. | |
| 908 | + | markSnapshotDirty(inFlight) | |
| 909 | + | require.True(t, d.scheduleFinalSnapshot(inFlight)) | |
| 910 | + | markSnapshotDirty(inFlight) | |
| 911 | + | require.True(t, d.scheduleFinalSnapshot(inFlight)) | |
| 912 | + | close(release) | |
| 913 | + | ||
| 914 | + | gotGenerations := []uint64{(<-publications).Generation, (<-publications).Generation, (<-publications).Generation} | |
| 915 | + | require.ElementsMatch(t, []uint64{1, 1, 2}, gotGenerations, "one forced successor bypasses the routine limit while queued work remains bounded") | |
| 916 | + | awaitSnapshotCleanSignal(inFlight) | |
| 917 | + | } | |
| 918 | + | ||
| 919 | + | func TestSnapshotRepositorySaverUsesEarliestDeadlineAndRecomputes(t *testing.T) { | |
| 920 | + | now := time.Unix(100, 0) | |
| 921 | + | clock := newDeterministicSnapshotClock(now) | |
| 922 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |
| 923 | + | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 924 | + | WithSnapshotRepository(repository, nil)(d) | |
| 925 | + | startSnapshotEncodeWorker(t, d) | |
| 926 | + | ||
| 927 | + | published := make(chan ports.SnapshotPublication, 1) | |
| 928 | + | repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error { | |
| 929 | + | published <- publication | |
| 930 | + | return nil | |
| 931 | + | }).Once() | |
| 932 | + | ||
| 933 | + | later := newSnapshotTestSession(t, "later", false, "/work") | |
| 934 | + | later.snapshotWake = d.snapshotWake | |
| 935 | + | later.snapDirty.Store(true) | |
| 936 | + | later.snapshotNextEligibleAt = now.Add(10 * time.Minute) | |
| 937 | + | d.sessions[later.id] = later | |
| 938 | + | ||
| 939 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 940 | + | done := make(chan struct{}) | |
| 941 | + | go func() { | |
| 942 | + | d.snapshotRepositorySaver(ctx) | |
| 943 | + | close(done) | |
| 944 | + | }() | |
| 945 | + | timer := clock.timer() | |
| 946 | + | require.Equal(t, 10*time.Minute, <-timer.resets) | |
| 947 | + | ||
| 948 | + | earlier := newSnapshotTestSession(t, "earlier", false, "/work") | |
| 949 | + | earlier.snapshotWake = d.snapshotWake | |
| 950 | + | earlier.snapDirty.Store(true) | |
| 951 | + | earlier.snapshotNextEligibleAt = now.Add(5 * time.Minute) | |
| 952 | + | d.sessions[earlier.id] = earlier | |
| 953 | + | d.snapshotWake <- struct{}{} | |
| 954 | + | require.Equal(t, 5*time.Minute, <-timer.resets) | |
| 955 | + | ||
| 956 | + | clock.advance(now.Add(5 * time.Minute)) | |
| 957 | + | timer.fire() | |
| 958 | + | // The saver first recomputes immediately after queueing the due capture. | |
| 959 | + | require.Equal(t, 5*time.Minute, <-timer.resets) | |
| 960 | + | publication := <-published | |
| 961 | + | require.Equal(t, "earlier", publication.Name, "timer fires exactly at the earliest eligibility") | |
| 962 | + | for { | |
| 963 | + | earlier.snapshotMu.Lock() | |
| 964 | + | clean := !earlier.snapDirty.Load() | |
| 965 | + | changed := earlier.snapshotChangeLocked() | |
| 966 | + | earlier.snapshotMu.Unlock() | |
| 967 | + | if clean { | |
| 968 | + | break | |
| 969 | + | } | |
| 970 | + | <-changed | |
| 971 | + | } | |
| 972 | + | // The later dirty session is still the next deadline after completion. | |
| 973 | + | require.Equal(t, 5*time.Minute, <-timer.resets) | |
| 974 | + | ||
| 975 | + | delete(d.sessions, later.id) | |
| 976 | + | d.snapshotWake <- struct{}{} | |
| 977 | + | require.Equal(t, 24*time.Hour, <-timer.resets) | |
| 978 | + | ||
| 979 | + | cancel() | |
| 980 | + | <-done | |
| 981 | + | } | |
M internal/usecase/daemon/snapshot_test.go +11 -1143
| @@ -3,10 +3,6 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | - | "fmt" | |
| 7 | - | "io" | |
| 8 | - | "sync" | |
| 9 | - | "sync/atomic" | |
| 10 | 6 | "testing" | |
| 11 | 7 | "time" | |
| 12 | 8 | ||
| @@ -16,1012 +12,26 @@ import ( | |||
| 16 | 12 | "github.com/bnema/vev/internal/domain" | |
| 17 | 13 | "github.com/bnema/vev/internal/ports" | |
| 18 | 14 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 19 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 20 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 21 | 15 | "github.com/bnema/vev/pkg/renderer" | |
| 22 | - | "github.com/bnema/vev/pkg/vt" | |
| 23 | 16 | ) | |
| 24 | 17 | ||
| 25 | - | func TestSnapshotEncodeWorkerDefersMarshalUntilAfterCapture(t *testing.T) { | |
| 26 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |
| 18 | + | // TestSnapshotWorkerPublishesContentAddressedCapture verifies that all new | |
| 19 | + | // checkpoints use the repository publication contract. | |
| 20 | + | func TestSnapshotWorkerPublishesContentAddressedCapture(t *testing.T) { | |
| 27 | 21 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 28 | - | WithSnapshotStore(store)(d) | |
| 29 | - | t.Cleanup(d.stopSnapshotEncodeWorker) | |
| 30 | - | d.startSnapshotEncodeWorker() | |
| 31 | - | ||
| 32 | - | entered := make(chan struct{}) | |
| 33 | - | release := make(chan struct{}) | |
| 34 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 35 | - | close(entered) | |
| 36 | - | <-release | |
| 37 | - | return snapcodec.Marshal(s) | |
| 38 | - | } | |
| 39 | - | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 40 | - | markSnapshotDirty(sess) | |
| 41 | - | require.True(t, d.scheduleSnapshot(sess)) | |
| 42 | - | <-entered | |
| 43 | - | ||
| 44 | - | paneLocked := make(chan struct{}) | |
| 45 | - | releasePane := make(chan struct{}) | |
| 46 | - | go func() { | |
| 47 | - | p := sess.tabs[0].panes["pane-1"] | |
| 48 | - | p.mu.Lock() | |
| 49 | - | close(paneLocked) | |
| 50 | - | <-releasePane | |
| 51 | - | p.mu.Unlock() | |
| 52 | - | }() | |
| 53 | - | <-paneLocked | |
| 54 | - | close(releasePane) | |
| 55 | - | close(release) | |
| 56 | - | <-store.writes | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | func TestSnapshotEncodeWorkerDoesNotBlockPTYOrCoordinatorRender(t *testing.T) { | |
| 60 | - | exerciseBlockedSnapshotWorker(t, true) | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | func TestSnapshotStoreWorkerDoesNotBlockPTYOrCoordinatorRender(t *testing.T) { | |
| 64 | - | exerciseBlockedSnapshotWorker(t, false) | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | func exerciseBlockedSnapshotWorker(t *testing.T, blockEncode bool) { | |
| 68 | - | t.Helper() | |
| 69 | - | store := newGatedSnapshotStore() | |
| 70 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 71 | - | WithSnapshotStore(store)(d) | |
| 72 | - | d.startSnapshotEncodeWorker() | |
| 73 | - | t.Cleanup(func() { | |
| 74 | - | store.unblock() | |
| 75 | - | d.stopSnapshotEncodeWorker() | |
| 76 | - | }) | |
| 77 | - | if blockEncode { | |
| 78 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 79 | - | store.enter() | |
| 80 | - | <-store.release | |
| 81 | - | return snapcodec.Marshal(s) | |
| 82 | - | } | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | sess := newSnapshotTestSession(t, "live", false, "/live") | |
| 86 | - | d.sessions[sess.id] = sess | |
| 87 | - | sess.tabs[0].size = domain.Size{Cols: 80, Rows: 24} | |
| 88 | - | p := sess.tabs[0].panes["pane-1"] | |
| 89 | - | tr, sends, _ := newConn(t, ports.Frame{}) | |
| 90 | - | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 8, Rows: 3}} | |
| 91 | - | ac.setSession(sess) | |
| 92 | - | sess.mu.Lock() | |
| 93 | - | sess.client = ac | |
| 94 | - | sess.mu.Unlock() | |
| 95 | - | rendered := make(chan struct{}, 1) | |
| 96 | - | rc := newRenderCoordinator(renderCoordinatorOptions{ | |
| 97 | - | clock: stubClock{}, | |
| 98 | - | onInvalidate: func(renderInvalidation) { | |
| 99 | - | d.paint(sess, ac, true, nil) | |
| 100 | - | rendered <- struct{}{} | |
| 101 | - | }, | |
| 102 | - | }) | |
| 103 | - | sess.installRenderCoordinator(rc) | |
| 104 | - | rc.attach(ac) | |
| 105 | - | require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac))) | |
| 106 | - | t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() }) | |
| 107 | - | ||
| 108 | - | markSnapshotDirty(sess) | |
| 109 | - | require.True(t, d.scheduleSnapshot(sess)) | |
| 110 | - | <-store.entered | |
| 111 | - | ||
| 112 | - | paneLocked := make(chan struct{}) | |
| 113 | - | releasePane := make(chan struct{}) | |
| 114 | - | go func() { | |
| 115 | - | p.mu.Lock() | |
| 116 | - | close(paneLocked) | |
| 117 | - | <-releasePane | |
| 118 | - | p.mu.Unlock() | |
| 119 | - | }() | |
| 120 | - | <-paneLocked | |
| 121 | - | close(releasePane) | |
| 122 | - | ||
| 123 | - | reader := portsmocks.NewMockPTY(t) | |
| 124 | - | var reads atomic.Int32 | |
| 125 | - | reader.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |
| 126 | - | if reads.Add(1) == 1 { | |
| 127 | - | return copy(buf, []byte("ok")), nil | |
| 128 | - | } | |
| 129 | - | return 0, io.EOF | |
| 130 | - | }).Twice() | |
| 131 | - | reader.EXPECT().Write(mock.Anything).Return(0, nil).Maybe() | |
| 132 | - | p.mu.Lock() | |
| 133 | - | p.pty = reader | |
| 134 | - | p.onExit = func() {} | |
| 135 | - | p.mu.Unlock() | |
| 136 | - | d.sessWg.Add(1) | |
| 137 | - | go d.ptyReader(sess, sess.tabs[0], p) | |
| 138 | - | d.sessWg.Wait() | |
| 139 | - | <-rendered | |
| 140 | - | _ = awaitFrame(t, sends, ports.MsgOutput) | |
| 141 | - | p.mu.Lock() | |
| 142 | - | require.Contains(t, screenLineText(p.screen, 0), "ok") | |
| 143 | - | p.mu.Unlock() | |
| 144 | - | ||
| 145 | - | store.unblock() | |
| 146 | - | awaitSnapshotIdle(t, sess) | |
| 147 | - | require.True(t, sess.snapDirty.Load(), "PTY output published while blocked must remain retryable") | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | func TestFinalSnapshotFlushPersistsNewestNamedSessionBeforeWorkerTeardown(t *testing.T) { | |
| 151 | - | clock := newFinalFlushClock() | |
| 152 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |
| 153 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |
| 154 | - | WithSnapshotStore(store)(d) | |
| 155 | - | d.startSnapshotEncodeWorker() | |
| 156 | - | ||
| 157 | - | entered := make(chan struct{}) | |
| 158 | - | release := make(chan struct{}) | |
| 159 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 160 | - | close(entered) | |
| 161 | - | <-release | |
| 162 | - | return snapcodec.Marshal(s) | |
| 163 | - | } | |
| 164 | - | sess := newSnapshotTestSession(t, "final", false, "/work") | |
| 165 | - | pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 166 | - | require.True(t, ok) | |
| 167 | - | pty.EXPECT().Close().Return(nil).Once() | |
| 168 | - | sess.tabs[0].panes["pane-1"].screen.Write([]byte("\rnewest")) | |
| 169 | - | d.sessions[sess.id] = sess | |
| 170 | - | ||
| 171 | - | require.NoError(t, d.killSession(sess, 0, false)) | |
| 172 | - | <-entered | |
| 173 | - | stopped := make(chan struct{}) | |
| 174 | - | go func() { | |
| 175 | - | d.stopSnapshotEncodeWorker() | |
| 176 | - | close(stopped) | |
| 177 | - | }() | |
| 178 | - | close(release) | |
| 179 | - | <-stopped | |
| 180 | - | awaitSnapshotIdle(t, sess) | |
| 181 | - | ||
| 182 | - | select { | |
| 183 | - | case data := <-store.writes: | |
| 184 | - | snap, err := snapcodec.Unmarshal(data) | |
| 185 | - | require.NoError(t, err) | |
| 186 | - | frame, err := vt.UnmarshalVisible(snap.Tabs[0].Panes[0].Visible) | |
| 187 | - | require.NoError(t, err) | |
| 188 | - | require.Contains(t, rowText(frame.Row(0)), "newest") | |
| 189 | - | default: | |
| 190 | - | t.Fatal("final snapshot was dropped during worker teardown") | |
| 191 | - | } | |
| 192 | - | } | |
| 193 | - | ||
| 194 | - | func TestFinalSnapshotSupersedesPendingCaptureBeforeWorkerTeardown(t *testing.T) { | |
| 195 | - | clock := newFinalFlushClock() | |
| 196 | - | store := &channelSnapshotStore{writes: make(chan []byte, 2)} | |
| 197 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |
| 198 | - | WithSnapshotStore(store)(d) | |
| 199 | - | d.startSnapshotEncodeWorker() | |
| 200 | - | ||
| 201 | - | entered := make(chan struct{}) | |
| 202 | - | release := make(chan struct{}) | |
| 203 | - | var calls atomic.Int32 | |
| 204 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 205 | - | if calls.Add(1) == 1 { | |
| 206 | - | close(entered) | |
| 207 | - | <-release | |
| 208 | - | } | |
| 209 | - | return snapcodec.Marshal(s) | |
| 210 | - | } | |
| 211 | - | sess := newSnapshotTestSession(t, "pending-final", false, "/work") | |
| 212 | - | markSnapshotDirty(sess) | |
| 213 | - | require.True(t, d.scheduleSnapshot(sess)) | |
| 214 | - | <-entered | |
| 215 | - | ||
| 216 | - | pane := sess.tabs[0].panes["pane-1"] | |
| 217 | - | pane.mu.Lock() | |
| 218 | - | pane.screen.Write([]byte("\rterminal")) | |
| 219 | - | pane.mu.Unlock() | |
| 220 | - | markSnapshotDirty(sess) | |
| 221 | - | require.True(t, d.scheduleFinalSnapshot(sess)) | |
| 222 | - | ||
| 223 | - | stopped := make(chan struct{}) | |
| 224 | - | go func() { | |
| 225 | - | d.stopSnapshotEncodeWorker() | |
| 226 | - | close(stopped) | |
| 227 | - | }() | |
| 228 | - | close(release) | |
| 229 | - | <-stopped | |
| 230 | - | ||
| 231 | - | require.Len(t, store.writes, 2, "teardown must flush the newer terminal capture") | |
| 232 | - | var latest snapcodec.Session | |
| 233 | - | for range 2 { | |
| 234 | - | snap, err := snapcodec.Unmarshal(<-store.writes) | |
| 235 | - | require.NoError(t, err) | |
| 236 | - | latest = snap | |
| 237 | - | } | |
| 238 | - | frame, err := vt.UnmarshalVisible(latest.Tabs[0].Panes[0].Visible) | |
| 239 | - | require.NoError(t, err) | |
| 240 | - | require.Contains(t, rowText(frame.Row(0)), "terminal") | |
| 241 | - | } | |
| 242 | - | ||
| 243 | - | func TestFinalSnapshotFallbackCoalescesRepeatedTerminalCaptures(t *testing.T) { | |
| 244 | - | store := discardSnapshotStore{} | |
| 245 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 246 | - | WithSnapshotStore(store)(d) | |
| 247 | - | d.startSnapshotEncodeWorker() | |
| 248 | - | ||
| 249 | - | entered := make(chan struct{}) | |
| 250 | - | release := make(chan struct{}) | |
| 251 | - | var releaseOnce sync.Once | |
| 252 | - | releaseWorker := func() { releaseOnce.Do(func() { close(release) }) } | |
| 253 | - | defer func() { | |
| 254 | - | releaseWorker() | |
| 255 | - | d.stopSnapshotEncodeWorker() | |
| 256 | - | }() | |
| 257 | - | var calls atomic.Int32 | |
| 258 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 259 | - | if calls.Add(1) == 1 { | |
| 260 | - | close(entered) | |
| 261 | - | <-release | |
| 262 | - | } | |
| 263 | - | return snapcodec.Marshal(s) | |
| 264 | - | } | |
| 265 | - | sess := newSnapshotTestSession(t, "coalesced-final", false, "/work") | |
| 266 | - | markSnapshotDirty(sess) | |
| 267 | - | require.True(t, d.scheduleSnapshot(sess)) | |
| 268 | - | <-entered | |
| 269 | - | ||
| 270 | - | for generation := uint64(1); generation <= 32; generation++ { | |
| 271 | - | capture, ok := d.captureSnapshotState(sess, generation) | |
| 272 | - | require.True(t, ok) | |
| 273 | - | require.True(t, d.enqueueFinalSnapshotCapture(capture)) | |
| 274 | - | } | |
| 275 | - | ||
| 276 | - | d.snapshotWorkerMu.Lock() | |
| 277 | - | captures := make([]*snapshotCapture, 0, len(d.snapshotFinalJobs)) | |
| 278 | - | for _, capture := range d.snapshotFinalJobs { | |
| 279 | - | captures = append(captures, capture) | |
| 280 | - | } | |
| 281 | - | d.snapshotWorkerMu.Unlock() | |
| 282 | - | require.Len(t, captures, 1, "blocked workers retain only the latest terminal capture per session") | |
| 283 | - | require.Equal(t, uint64(32), captures[0].generation) | |
| 284 | - | releaseWorker() | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | func TestFinalSnapshotFallbackBoundsDistinctTerminalSessions(t *testing.T) { | |
| 288 | - | store := discardSnapshotStore{} | |
| 289 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 290 | - | WithSnapshotStore(store)(d) | |
| 291 | - | d.startSnapshotEncodeWorker() | |
| 292 | - | ||
| 293 | - | entered := make(chan struct{}) | |
| 294 | - | release := make(chan struct{}) | |
| 295 | - | var calls atomic.Int32 | |
| 296 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 297 | - | if calls.Add(1) == 1 { | |
| 298 | - | close(entered) | |
| 299 | - | <-release | |
| 300 | - | } | |
| 301 | - | return snapcodec.Marshal(s) | |
| 302 | - | } | |
| 303 | - | defer func() { | |
| 304 | - | close(release) | |
| 305 | - | d.stopSnapshotEncodeWorker() | |
| 306 | - | }() | |
| 307 | - | ||
| 308 | - | active := newSnapshotTestSession(t, "active", false, "/work") | |
| 309 | - | queued := newSnapshotTestSession(t, "queued", false, "/work") | |
| 310 | - | markSnapshotDirty(active) | |
| 311 | - | markSnapshotDirty(queued) | |
| 312 | - | require.True(t, d.scheduleSnapshot(active)) | |
| 313 | - | <-entered | |
| 314 | - | require.True(t, d.scheduleSnapshot(queued), "fill the regular bounded queue before terminal retirement") | |
| 315 | - | ||
| 316 | - | var superseded *session | |
| 317 | - | for i := range snapshotFinalQueueCapacity { | |
| 318 | - | sess := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work") | |
| 319 | - | markSnapshotDirty(sess) | |
| 320 | - | require.True(t, d.scheduleFinalSnapshot(sess)) | |
| 321 | - | if i == 0 { | |
| 322 | - | superseded = sess | |
| 323 | - | } | |
| 324 | - | } | |
| 325 | - | markSnapshotDirty(superseded) | |
| 326 | - | require.True(t, d.scheduleFinalSnapshot(superseded), "a newer generation for a retained session must supersede at capacity") | |
| 327 | - | ||
| 328 | - | rejected := newSnapshotTestSession(t, "terminal-rejected", false, "/work") | |
| 329 | - | d.mu.Lock() | |
| 330 | - | d.sessions[rejected.id] = rejected | |
| 331 | - | d.mu.Unlock() | |
| 332 | - | markSnapshotDirty(rejected) | |
| 333 | - | err := d.killSession(rejected, ports.ReasonSessionKilled, false) | |
| 334 | - | require.ErrorContains(t, err, "snapshot worker unavailable or saturated") | |
| 335 | - | d.mu.Lock() | |
| 336 | - | require.Same(t, rejected, d.sessions[rejected.id], "rejected terminal state must remain registered for retry") | |
| 337 | - | d.mu.Unlock() | |
| 338 | - | ||
| 339 | - | d.snapshotWorkerMu.Lock() | |
| 340 | - | retained := len(d.snapshotFinalJobs) | |
| 341 | - | owners := len(d.snapshotFinalOrder) | |
| 342 | - | supersedingCapture := d.snapshotFinalJobs[superseded] | |
| 343 | - | d.snapshotWorkerMu.Unlock() | |
| 344 | - | require.Equal(t, snapshotFinalQueueCapacity, retained, "distinct terminal captures must have a global bound") | |
| 345 | - | require.Equal(t, snapshotFinalQueueCapacity, owners, "retention order must not retain extra session owners") | |
| 346 | - | require.NotNil(t, supersedingCapture) | |
| 347 | - | require.Equal(t, uint64(2), supersedingCapture.generation, "retained session must keep its newest terminal generation") | |
| 348 | - | require.NotNil(t, rejected) | |
| 349 | - | require.True(t, rejected.snapEligible.Load()) | |
| 350 | - | require.True(t, rejected.snapDirty.Load(), "a rejected terminal capture must remain retryable") | |
| 351 | - | awaitSnapshotIdle(t, rejected) | |
| 352 | - | } | |
| 353 | - | ||
| 354 | - | func TestStopSnapshotEncodeWorkerDoesNotWaitForUncancellableWrite(t *testing.T) { | |
| 355 | - | store := newNeverReturningSnapshotStore() | |
| 356 | - | clock := newFinalFlushClock() | |
| 357 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |
| 358 | - | WithSnapshotStore(store)(d) | |
| 359 | - | d.startSnapshotEncodeWorker() | |
| 360 | - | ||
| 361 | - | sess := newSnapshotTestSession(t, "stuck-write", false, "/work") | |
| 362 | - | require.True(t, d.captureSession(sess)) | |
| 363 | - | <-store.entered | |
| 364 | - | ||
| 365 | - | stopped := make(chan struct{}) | |
| 366 | - | go func() { | |
| 367 | - | d.stopSnapshotEncodeWorker() | |
| 368 | - | close(stopped) | |
| 369 | - | }() | |
| 370 | - | timer := <-clock.timers | |
| 371 | - | timer.ch <- time.Time{} | |
| 372 | - | <-stopped | |
| 373 | - | awaitSnapshotIdle(t, sess) | |
| 374 | - | require.True(t, sess.snapDirty.Load(), "an abandoned write must remain retryable") | |
| 375 | - | require.NotPanics(t, func() { require.False(t, d.scheduleSnapshot(sess)) }) | |
| 376 | - | } | |
| 377 | - | ||
| 378 | - | func TestRejectedSnapshotCaptureLeavesSessionRetryable(t *testing.T) { | |
| 379 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 380 | - | WithSnapshotStore(&channelSnapshotStore{writes: make(chan []byte, 1)})(d) | |
| 381 | - | sess := newSnapshotTestSession(t, "rejected", false, "/work") | |
| 382 | - | sess.name = "" | |
| 383 | - | sess.snapEligible.Store(true) | |
| 384 | - | ||
| 385 | - | markSnapshotDirty(sess) | |
| 386 | - | require.False(t, d.scheduleSnapshot(sess)) | |
| 387 | - | awaitSnapshotIdle(t, sess) | |
| 388 | - | require.True(t, sess.snapDirty.Load()) | |
| 389 | - | } | |
| 390 | - | ||
| 391 | - | func TestStoppedSnapshotWorkerLeavesCaptureRetryable(t *testing.T) { | |
| 392 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |
| 393 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 394 | - | WithSnapshotStore(store)(d) | |
| 395 | - | d.startSnapshotEncodeWorker() | |
| 396 | - | d.stopSnapshotEncodeWorker() | |
| 397 | - | ||
| 398 | - | sess := newSnapshotTestSession(t, "stopped", false, "/work") | |
| 399 | - | markSnapshotDirty(sess) | |
| 400 | - | require.False(t, d.scheduleSnapshot(sess), "a stopped worker must not accept a capture") | |
| 401 | - | awaitSnapshotIdle(t, sess) | |
| 402 | - | require.True(t, sess.snapDirty.Load(), "an unsubmitted capture must remain retryable") | |
| 403 | - | } | |
| 404 | - | ||
| 405 | - | func TestSnapshotQueueSaturationLeavesCaptureRetryable(t *testing.T) { | |
| 406 | - | store := &channelSnapshotStore{writes: make(chan []byte, 3)} | |
| 407 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 408 | - | WithSnapshotStore(store)(d) | |
| 409 | - | startSnapshotEncodeWorker(t, d) | |
| 410 | - | entered := make(chan struct{}) | |
| 411 | - | release := make(chan struct{}) | |
| 412 | - | var calls atomic.Int32 | |
| 413 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 414 | - | if calls.Add(1) == 1 { | |
| 415 | - | close(entered) | |
| 416 | - | <-release | |
| 417 | - | } | |
| 418 | - | return snapcodec.Marshal(s) | |
| 419 | - | } | |
| 420 | - | first := newSnapshotTestSession(t, "first", false, "/work") | |
| 421 | - | second := newSnapshotTestSession(t, "second", false, "/work") | |
| 422 | - | third := newSnapshotTestSession(t, "third", false, "/work") | |
| 423 | - | markSnapshotDirty(first) | |
| 424 | - | markSnapshotDirty(second) | |
| 425 | - | markSnapshotDirty(third) | |
| 426 | - | require.True(t, d.scheduleSnapshot(first)) | |
| 427 | - | <-entered | |
| 428 | - | require.True(t, d.scheduleSnapshot(second), "one capture may wait in the bounded queue") | |
| 429 | - | require.False(t, d.scheduleSnapshot(third), "full queue must not block the producer") | |
| 430 | - | require.True(t, third.snapDirty.Load()) | |
| 431 | - | ||
| 432 | - | close(release) | |
| 433 | - | awaitSnapshotClean(t, first) | |
| 434 | - | awaitSnapshotClean(t, second) | |
| 435 | - | require.True(t, d.scheduleSnapshot(third)) | |
| 436 | - | <-store.writes | |
| 437 | - | awaitSnapshotClean(t, third) | |
| 438 | - | } | |
| 439 | - | ||
| 440 | - | func TestNamedFinalSnapshotSurvivesSaturatedQueue(t *testing.T) { | |
| 441 | - | for _, tc := range []struct { | |
| 442 | - | name string | |
| 443 | - | stop func(t *testing.T, d *Daemon, sess *session) | |
| 444 | - | }{ | |
| 445 | - | { | |
| 446 | - | name: "kill session", | |
| 447 | - | stop: func(t *testing.T, d *Daemon, sess *session) { | |
| 448 | - | t.Helper() | |
| 449 | - | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 450 | - | }, | |
| 451 | - | }, | |
| 452 | - | { | |
| 453 | - | name: "shutdown all", | |
| 454 | - | stop: func(t *testing.T, d *Daemon, _ *session) { | |
| 455 | - | t.Helper() | |
| 456 | - | d.shutdownAll(ports.ReasonServerShutdown) | |
| 457 | - | }, | |
| 458 | - | }, | |
| 459 | - | } { | |
| 460 | - | t.Run(tc.name, func(t *testing.T) { | |
| 461 | - | store := &channelSnapshotStore{writes: make(chan []byte, 3)} | |
| 462 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 463 | - | WithSnapshotStore(store)(d) | |
| 464 | - | startSnapshotEncodeWorker(t, d) | |
| 465 | - | ||
| 466 | - | entered := make(chan struct{}) | |
| 467 | - | release := make(chan struct{}) | |
| 468 | - | var calls atomic.Int32 | |
| 469 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 470 | - | if calls.Add(1) == 1 { | |
| 471 | - | close(entered) | |
| 472 | - | <-release | |
| 473 | - | } | |
| 474 | - | return snapcodec.Marshal(s) | |
| 475 | - | } | |
| 476 | - | first := newSnapshotTestSession(t, "first", false, "/work") | |
| 477 | - | second := newSnapshotTestSession(t, "second", false, "/work") | |
| 478 | - | final := newSnapshotTestSession(t, "final", false, "/work") | |
| 479 | - | finalPTY, ok := final.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 480 | - | require.True(t, ok) | |
| 481 | - | finalPTY.EXPECT().Close().Return(nil).Once() | |
| 482 | - | markSnapshotDirty(first) | |
| 483 | - | markSnapshotDirty(second) | |
| 484 | - | require.True(t, d.scheduleSnapshot(first)) | |
| 485 | - | <-entered | |
| 486 | - | require.True(t, d.scheduleSnapshot(second)) | |
| 487 | - | d.sessions[final.id] = final | |
| 488 | - | ||
| 489 | - | tc.stop(t, d, final) | |
| 490 | - | require.NotContains(t, d.sessions, final.id, "final capture must survive session removal") | |
| 491 | - | close(release) | |
| 492 | - | ||
| 493 | - | written := make(map[string]bool, 3) | |
| 494 | - | deadline := time.NewTimer(time.Second) | |
| 495 | - | defer deadline.Stop() | |
| 496 | - | for len(written) < 3 { | |
| 497 | - | select { | |
| 498 | - | case data := <-store.writes: | |
| 499 | - | snap, err := snapcodec.Unmarshal(data) | |
| 500 | - | require.NoError(t, err) | |
| 501 | - | written[snap.Name] = true | |
| 502 | - | case <-deadline.C: | |
| 503 | - | t.Fatalf("final snapshot was dropped: wrote %v", written) | |
| 504 | - | } | |
| 505 | - | } | |
| 506 | - | require.True(t, written[final.name]) | |
| 507 | - | }) | |
| 508 | - | } | |
| 509 | - | } | |
| 510 | - | ||
| 511 | - | func TestSnapshotCompletionSignalsWaiters(t *testing.T) { | |
| 512 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |
| 513 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 514 | - | WithSnapshotStore(store)(d) | |
| 515 | - | startSnapshotEncodeWorker(t, d) | |
| 516 | - | ||
| 517 | - | entered := make(chan struct{}) | |
| 518 | - | release := make(chan struct{}) | |
| 519 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 520 | - | close(entered) | |
| 521 | - | <-release | |
| 522 | - | return snapcodec.Marshal(s) | |
| 523 | - | } | |
| 524 | - | sess := newSnapshotTestSession(t, "completion", false, "/work") | |
| 525 | - | require.True(t, d.captureSession(sess)) | |
| 526 | - | <-entered | |
| 527 | - | ||
| 528 | - | sess.snapshotMu.Lock() | |
| 529 | - | changed := sess.snapshotChangeLocked() | |
| 530 | - | sess.snapshotMu.Unlock() | |
| 531 | - | close(release) | |
| 532 | - | <-changed | |
| 533 | - | awaitSnapshotClean(t, sess) | |
| 534 | - | } | |
| 535 | - | ||
| 536 | - | func TestSnapshotSuccessDoesNotClearNewerGeneration(t *testing.T) { | |
| 537 | - | store := &channelSnapshotStore{writes: make(chan []byte, 2)} | |
| 538 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 539 | - | WithSnapshotStore(store)(d) | |
| 22 | + | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 23 | + | WithSnapshotRepository(repository, nil)(d) | |
| 540 | 24 | startSnapshotEncodeWorker(t, d) | |
| 541 | - | entered := make(chan struct{}) | |
| 542 | - | release := make(chan struct{}) | |
| 543 | - | var calls atomic.Int32 | |
| 544 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 545 | - | if calls.Add(1) == 1 { | |
| 546 | - | close(entered) | |
| 547 | - | <-release | |
| 548 | - | } | |
| 549 | - | return snapcodec.Marshal(s) | |
| 550 | - | } | |
| 551 | - | sess := newSnapshotTestSession(t, "generation", false, "/work") | |
| 552 | 25 | ||
| 553 | - | markSnapshotDirty(sess) | |
| 554 | - | require.True(t, d.scheduleSnapshot(sess)) | |
| 555 | - | <-entered | |
| 556 | - | markSnapshotDirty(sess) | |
| 557 | - | close(release) | |
| 558 | - | <-store.writes | |
| 559 | - | awaitSnapshotIdle(t, sess) | |
| 560 | - | require.True(t, sess.snapDirty.Load(), "a stale successful capture must not clear newer state") | |
| 561 | - | ||
| 562 | - | require.True(t, d.scheduleSnapshot(sess)) | |
| 563 | - | <-store.writes | |
| 564 | - | awaitSnapshotClean(t, sess) | |
| 565 | - | } | |
| 566 | - | ||
| 567 | - | func TestSnapshotEncodeFailureLeavesSessionDirtyAndRetries(t *testing.T) { | |
| 568 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |
| 569 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 570 | - | WithSnapshotStore(store)(d) | |
| 571 | - | startSnapshotEncodeWorker(t, d) | |
| 572 | - | sess := newSnapshotTestSession(t, "retry-encode", false, "/work") | |
| 573 | - | failed := make(chan struct{}, 1) | |
| 574 | - | var calls atomic.Int32 | |
| 575 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 576 | - | if calls.Add(1) == 1 { | |
| 577 | - | failed <- struct{}{} | |
| 578 | - | return nil, errors.New("encode failed") | |
| 579 | - | } | |
| 580 | - | return snapcodec.Marshal(s) | |
| 581 | - | } | |
| 582 | - | ||
| 583 | - | require.True(t, d.captureSession(sess)) | |
| 584 | - | <-failed | |
| 585 | - | awaitSnapshotIdle(t, sess) | |
| 586 | - | require.True(t, sess.snapDirty.Load()) | |
| 587 | - | require.True(t, d.scheduleSnapshot(sess)) | |
| 588 | - | <-store.writes | |
| 589 | - | awaitSnapshotClean(t, sess) | |
| 590 | - | } | |
| 591 | - | ||
| 592 | - | func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) { | |
| 593 | - | clock := newManualSnapshotClock() | |
| 594 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 595 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |
| 596 | - | WithSnapshotStore(store)(d) | |
| 597 | - | d.procCwd = func(int) (string, error) { return "/pane", nil } | |
| 598 | - | startSnapshotEncodeWorker(t, d) | |
| 599 | - | ||
| 600 | - | dirty := newSnapshotTestSession(t, "dirty", false, "/fallback") | |
| 601 | - | clean := newSnapshotTestSession(t, "clean", false, "/fallback") | |
| 602 | - | ephemeral := newSnapshotTestSession(t, "0", true, "/fallback") | |
| 603 | - | dirty.snapDirty.Store(true) | |
| 604 | - | ephemeral.snapDirty.Store(true) | |
| 605 | - | d.sessions[dirty.id] = dirty | |
| 606 | - | d.sessions[clean.id] = clean | |
| 607 | - | d.sessions[ephemeral.id] = ephemeral | |
| 608 | - | ||
| 609 | - | wrote := make(chan struct{}, 1) | |
| 610 | - | store.EXPECT().Write("dirty", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 611 | - | snap, err := snapcodec.Unmarshal(data) | |
| 612 | - | require.NoError(t, err) | |
| 613 | - | require.Equal(t, "dirty", snap.Name) | |
| 614 | - | wrote <- struct{}{} | |
| 615 | - | return nil | |
| 616 | - | }).Once() | |
| 617 | - | ||
| 618 | - | ctx, cancel := context.WithCancel(t.Context()) | |
| 619 | - | defer cancel() | |
| 620 | - | go d.snapshotSaver(ctx) | |
| 621 | - | clock.fireNext(t) | |
| 622 | - | select { | |
| 623 | - | case <-wrote: | |
| 624 | - | case <-time.After(time.Second): | |
| 625 | - | t.Fatal("timed out waiting for dirty snapshot write") | |
| 626 | - | } | |
| 627 | - | awaitSnapshotClean(t, dirty) | |
| 628 | - | ||
| 629 | - | clock.fireNext(t) | |
| 630 | - | select { | |
| 631 | - | case <-wrote: | |
| 632 | - | t.Fatal("clean second interval wrote unexpectedly") | |
| 633 | - | case <-time.After(25 * time.Millisecond): | |
| 634 | - | } | |
| 635 | - | } | |
| 636 | - | ||
| 637 | - | func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) { | |
| 638 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 639 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 640 | - | WithSnapshotStore(store)(d) | |
| 641 | - | startSnapshotEncodeWorker(t, d) | |
| 642 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 643 | - | tb := sess.tabs[0] | |
| 644 | - | floatingPTY, _ := newBlockingPTY(t) | |
| 645 | - | floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8}) | |
| 646 | - | tb.mu.Lock() | |
| 647 | - | generation := tb.beginFloatingWarmLocked(false) | |
| 648 | - | require.True(t, tb.installFloatingLocked(floating, generation)) | |
| 649 | - | tb.mu.Unlock() | |
| 650 | - | ||
| 651 | - | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 652 | - | snap, err := snapcodec.Unmarshal(data) | |
| 653 | - | require.NoError(t, err) | |
| 654 | - | require.Len(t, snap.Tabs, 1) | |
| 655 | - | require.Len(t, snap.Tabs[0].Panes, 1, "floating runtime must not be persisted") | |
| 656 | - | require.Equal(t, layout.PaneID("pane-1"), snap.Tabs[0].Panes[0].ID) | |
| 657 | - | return nil | |
| 658 | - | }).Once() | |
| 659 | - | require.True(t, d.captureSession(sess)) | |
| 660 | - | awaitSnapshotClean(t, sess) | |
| 661 | - | } | |
| 662 | - | ||
| 663 | - | func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) { | |
| 664 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 665 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 666 | - | WithSnapshotStore(store)(d) | |
| 667 | - | startSnapshotEncodeWorker(t, d) | |
| 668 | - | d.procGroupArgv = func(pgid int, shellPid int) ([]string, error) { | |
| 669 | - | require.Equal(t, 4321, pgid) | |
| 670 | - | require.Equal(t, 1234, shellPid) | |
| 671 | - | return []string{"claude", "--session-id", "agent-123"}, nil | |
| 672 | - | } | |
| 673 | - | ||
| 674 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 675 | - | pty := snapshotProcessPTY(t, 4321) | |
| 676 | - | sess.tabs[0].panes["pane-1"].pty = pty | |
| 677 | - | ||
| 678 | - | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 679 | - | snap, err := snapcodec.Unmarshal(data) | |
| 680 | - | require.NoError(t, err) | |
| 681 | - | proc := snap.Tabs[0].Panes[0].Process | |
| 682 | - | require.NotNil(t, proc) | |
| 683 | - | require.Equal(t, []string{"claude", "--session-id", "agent-123"}, proc.Argv) | |
| 684 | - | require.Equal(t, processStrategyClaude, proc.Strategy) | |
| 685 | - | require.Equal(t, "agent-123", proc.Opts.AgentSessionID) | |
| 686 | - | return nil | |
| 687 | - | }).Once() | |
| 688 | - | ||
| 689 | - | require.True(t, d.captureSession(sess)) | |
| 690 | - | awaitSnapshotClean(t, sess) | |
| 691 | - | } | |
| 692 | - | ||
| 693 | - | func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) { | |
| 694 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 695 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 696 | - | WithSnapshotStore(store)(d) | |
| 697 | - | startSnapshotEncodeWorker(t, d) | |
| 698 | - | d.procGroupArgv = func(int, int) ([]string, error) { return nil, errors.New("inspect failed") } | |
| 699 | - | ||
| 700 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 701 | - | pty := snapshotProcessPTY(t, 4321) | |
| 702 | - | sess.tabs[0].panes["pane-1"].pty = pty | |
| 703 | - | ||
| 704 | - | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 705 | - | snap, err := snapcodec.Unmarshal(data) | |
| 706 | - | require.NoError(t, err) | |
| 707 | - | require.Nil(t, snap.Tabs[0].Panes[0].Process) | |
| 708 | - | return nil | |
| 709 | - | }).Once() | |
| 710 | - | ||
| 711 | - | require.True(t, d.captureSession(sess)) | |
| 712 | - | awaitSnapshotClean(t, sess) | |
| 713 | - | } | |
| 714 | - | ||
| 715 | - | func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) { | |
| 716 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 717 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 718 | - | WithSnapshotStore(store)(d) | |
| 719 | - | startSnapshotEncodeWorker(t, d) | |
| 720 | - | d.procGroupArgv = func(int, int) ([]string, error) { | |
| 721 | - | t.Fatal("bare shell should not inspect argv") | |
| 722 | - | return nil, nil | |
| 723 | - | } | |
| 724 | - | ||
| 725 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 726 | - | pty := snapshotProcessPTY(t, 1234) | |
| 727 | - | sess.tabs[0].panes["pane-1"].pty = pty | |
| 728 | - | ||
| 729 | - | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 730 | - | snap, err := snapcodec.Unmarshal(data) | |
| 731 | - | require.NoError(t, err) | |
| 732 | - | require.Nil(t, snap.Tabs[0].Panes[0].Process) | |
| 733 | - | return nil | |
| 734 | - | }).Once() | |
| 26 | + | repository.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(p ports.SnapshotPublication) bool { | |
| 27 | + | return p.Name == "work" && p.Generation == 1 && len(p.Manifest) > 0 && len(p.Objects) > 0 | |
| 28 | + | })).Return(nil).Once() | |
| 735 | 29 | ||
| 30 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 736 | 31 | require.True(t, d.captureSession(sess)) | |
| 737 | 32 | awaitSnapshotClean(t, sess) | |
| 738 | 33 | } | |
| 739 | 34 | ||
| 740 | - | func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) { | |
| 741 | - | store := newGatedSnapshotStore() | |
| 742 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 743 | - | WithSnapshotStore(store)(d) | |
| 744 | - | d.startSnapshotEncodeWorker() | |
| 745 | - | t.Cleanup(func() { | |
| 746 | - | store.unblock() | |
| 747 | - | d.stopSnapshotEncodeWorker() | |
| 748 | - | }) | |
| 749 | - | d.procCwd = func(int) (string, error) { return "/live", nil } | |
| 750 | - | ||
| 751 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 752 | - | d.sessions[sess.id] = sess | |
| 753 | - | ||
| 754 | - | var closed atomic.Bool | |
| 755 | - | mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 756 | - | require.True(t, ok) | |
| 757 | - | mockPTY.EXPECT().Close().RunAndReturn(func() error { | |
| 758 | - | closed.Store(true) | |
| 759 | - | return nil | |
| 760 | - | }).Once() | |
| 761 | - | ||
| 762 | - | require.NoError(t, d.killSession(sess, 0, false)) | |
| 763 | - | // closeAllPanes runs synchronously inside killSession, which has already | |
| 764 | - | // returned here. The store's write gate is still held below, so | |
| 765 | - | // persistence cannot have completed yet: this proves pane close did not | |
| 766 | - | // wait on it, with zero timing dependence on the encode worker. | |
| 767 | - | require.True(t, closed.Load(), "snapshot persistence must not delay pane close") | |
| 768 | - | ||
| 769 | - | store.unblock() | |
| 770 | - | awaitSnapshotClean(t, sess) | |
| 771 | - | ||
| 772 | - | writes, name, data := store.recorded() | |
| 773 | - | require.Equal(t, 1, writes) | |
| 774 | - | require.Equal(t, "work", name) | |
| 775 | - | snap, err := snapcodec.Unmarshal(data) | |
| 776 | - | require.NoError(t, err) | |
| 777 | - | require.Equal(t, "work", snap.Name) | |
| 778 | - | require.Equal(t, "/live", snap.Tabs[0].Panes[0].Cwd) | |
| 779 | - | } | |
| 780 | - | ||
| 781 | - | func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) { | |
| 782 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 783 | - | p := portsmocks.NewMockPTY(t) | |
| 784 | - | chunks := [][]byte{[]byte("\x1b[?2026hhello\x1b[?2026l")} | |
| 785 | - | p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |
| 786 | - | if len(chunks) == 0 { | |
| 787 | - | return 0, io.EOF | |
| 788 | - | } | |
| 789 | - | n := copy(buf, chunks[0]) | |
| 790 | - | chunks = chunks[1:] | |
| 791 | - | return n, nil | |
| 792 | - | }) | |
| 793 | - | p.EXPECT().Close().Return(nil).Maybe() | |
| 794 | - | ||
| 795 | - | sctx, cancel := context.WithCancel(context.Background()) | |
| 796 | - | defer cancel() | |
| 797 | - | tb := newTestTabWithContext(p, sctx, cancel) | |
| 798 | - | sess := &session{id: "sync", name: "sync", tabs: []*tab{tb}, ctx: sctx, cancel: cancel} | |
| 799 | - | sess.snapEligible.Store(true) | |
| 800 | - | d.sessions[sess.id] = sess | |
| 801 | - | d.sessWg.Add(1) | |
| 802 | - | d.ptyReader(sess, tb, tb.focusedPane()) | |
| 803 | - | ||
| 804 | - | require.True(t, sess.snapDirty.Load()) | |
| 805 | - | } | |
| 806 | - | ||
| 807 | - | func TestPTYReaderSameReadSynchronizedOutputUsesUrgentCoordinatorDeadline(t *testing.T) { | |
| 808 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 809 | - | p := chunkReadPTY(t, []byte("\x1b[?2026hcomplete batch\x1b[?2026l")) | |
| 810 | - | sctx, cancel := context.WithCancel(context.Background()) | |
| 811 | - | defer cancel() | |
| 812 | - | tb := newTestTabWithContext(p, sctx, cancel) | |
| 813 | - | sess := &session{id: "sync-urgent", name: "sync-urgent", tabs: []*tab{tb}, client: &attachedClient{}, ctx: sctx, cancel: cancel} | |
| 814 | - | invs := make(chan renderInvalidation, 1) | |
| 815 | - | clock := newCoordinatorMockClock(t, 2) | |
| 816 | - | sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{ | |
| 817 | - | clock: clock.clock, | |
| 818 | - | onInvalidate: func(inv renderInvalidation) { invs <- inv }, | |
| 819 | - | })) | |
| 820 | - | d.sessions[sess.id] = sess | |
| 821 | - | d.sessWg.Add(1) | |
| 822 | - | d.ptyReader(sess, tb, tb.focusedPane()) | |
| 823 | - | ||
| 824 | - | inv := awaitInvalidation(t, invs) | |
| 825 | - | require.Equal(t, invalidateUrgent, inv.class, "a complete same-read synchronized batch must flush urgently") | |
| 826 | - | timer := awaitCoordinatorScheduledTimer(t, clock) | |
| 827 | - | require.Equal(t, urgentRenderDeadline, timer.duration) | |
| 828 | - | } | |
| 829 | - | ||
| 830 | - | func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) { | |
| 831 | - | clock := newManualSnapshotClock() | |
| 832 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 833 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |
| 834 | - | WithSnapshotStore(store)(d) | |
| 835 | - | startSnapshotEncodeWorker(t, d) | |
| 836 | - | ||
| 837 | - | sess := newSnapshotTestSession(t, "retry", false, "/fallback") | |
| 838 | - | sess.snapDirty.Store(true) | |
| 839 | - | d.sessions[sess.id] = sess | |
| 840 | - | ||
| 841 | - | failed := make(chan struct{}, 1) | |
| 842 | - | store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error { | |
| 843 | - | failed <- struct{}{} | |
| 844 | - | return errors.New("boom") | |
| 845 | - | }).Once() | |
| 846 | - | wrote := make(chan struct{}, 1) | |
| 847 | - | store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error { | |
| 848 | - | wrote <- struct{}{} | |
| 849 | - | return nil | |
| 850 | - | }).Once() | |
| 851 | - | ||
| 852 | - | ctx, cancel := context.WithCancel(t.Context()) | |
| 853 | - | defer cancel() | |
| 854 | - | go d.snapshotSaver(ctx) | |
| 855 | - | clock.fireNext(t) | |
| 856 | - | <-failed | |
| 857 | - | awaitSnapshotIdle(t, sess) | |
| 858 | - | require.True(t, sess.snapDirty.Load()) | |
| 859 | - | clock.fireNext(t) | |
| 860 | - | select { | |
| 861 | - | case <-wrote: | |
| 862 | - | case <-time.After(time.Second): | |
| 863 | - | t.Fatal("timed out waiting for retry snapshot write") | |
| 864 | - | } | |
| 865 | - | awaitSnapshotClean(t, sess) | |
| 866 | - | require.False(t, sess.snapDirty.Load()) | |
| 867 | - | } | |
| 868 | - | ||
| 869 | - | func TestKillSessionPurgeDeletesSnapshot(t *testing.T) { | |
| 870 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 871 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 872 | - | WithSnapshotStore(store)(d) | |
| 873 | - | ||
| 874 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 875 | - | d.sessions[sess.id] = sess | |
| 876 | - | ||
| 877 | - | store.EXPECT().Delete("work").Return(nil).Once() | |
| 878 | - | mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 879 | - | require.True(t, ok) | |
| 880 | - | mockPTY.EXPECT().Close().Return(nil).Once() | |
| 881 | - | ||
| 882 | - | require.NoError(t, d.killSession(sess, 0, true)) | |
| 883 | - | } | |
| 884 | - | ||
| 885 | - | func TestKillSessionPurgeReturnsSnapshotDeleteError(t *testing.T) { | |
| 886 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 887 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 888 | - | WithSnapshotStore(store)(d) | |
| 889 | - | ||
| 890 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 891 | - | d.sessions[sess.id] = sess | |
| 892 | - | ||
| 893 | - | store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once() | |
| 894 | - | mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 895 | - | require.True(t, ok) | |
| 896 | - | mockPTY.EXPECT().Close().Return(nil).Once() | |
| 897 | - | ||
| 898 | - | err := d.killSession(sess, 0, true) | |
| 899 | - | require.ErrorContains(t, err, "delete failed") | |
| 900 | - | require.NotContains(t, d.sessions, sess.id) | |
| 901 | - | require.NotContains(t, d.stopped, "work") | |
| 902 | - | } | |
| 903 | - | ||
| 904 | - | func TestRefreshSessionCwdMarksSnapshotDirty(t *testing.T) { | |
| 905 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 906 | - | d.procCwd = func(int) (string, error) { return "/new", nil } | |
| 907 | - | sess := newSnapshotTestSession(t, "work", false, "/old") | |
| 908 | - | d.sessions[sess.id] = sess | |
| 909 | - | ||
| 910 | - | d.refreshSessionCwd(sess) | |
| 911 | - | require.Equal(t, "/new", sess.cwd) | |
| 912 | - | require.True(t, sess.snapDirty.Load()) | |
| 913 | - | } | |
| 914 | - | ||
| 915 | - | func TestStoppedSessionKillPurgesSnapshot(t *testing.T) { | |
| 916 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 917 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 918 | - | WithSnapshotStore(store)(d) | |
| 919 | - | d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1} | |
| 920 | - | ||
| 921 | - | store.EXPECT().Delete("work").Return(nil).Once() | |
| 922 | - | tr, _, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |
| 923 | - | ||
| 924 | - | d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |
| 925 | - | ||
| 926 | - | require.NotContains(t, d.stopped, "work") | |
| 927 | - | } | |
| 928 | - | ||
| 929 | - | func TestStoppedSessionKillSnapshotDeleteFailureKeepsMetadata(t *testing.T) { | |
| 930 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 931 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 932 | - | WithSnapshotStore(store)(d) | |
| 933 | - | d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1} | |
| 934 | - | ||
| 935 | - | store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once() | |
| 936 | - | tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |
| 937 | - | ||
| 938 | - | d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |
| 939 | - | ||
| 940 | - | require.Contains(t, d.stopped, "work") | |
| 941 | - | frame := awaitFrame(t, sends, ports.MsgError) | |
| 942 | - | msg, err := ports.UnmarshalErrorMsg(frame.Payload) | |
| 943 | - | require.NoError(t, err) | |
| 944 | - | require.Equal(t, ports.ErrInternal, msg.Code) | |
| 945 | - | } | |
| 946 | - | ||
| 947 | - | func TestRenameSessionDeletesOldSnapshotAndMarksDirty(t *testing.T) { | |
| 948 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 949 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 950 | - | WithSnapshotStore(store)(d) | |
| 951 | - | sess := newSnapshotTestSession(t, "old", false, "/fallback") | |
| 952 | - | d.sessions[sess.id] = sess | |
| 953 | - | ||
| 954 | - | store.EXPECT().Delete("old").Return(nil).Once() | |
| 955 | - | require.NoError(t, d.renameSession(sess, "new")) | |
| 956 | - | ||
| 957 | - | require.True(t, sess.snapDirty.Load()) | |
| 958 | - | require.Nil(t, d.findByNameLocked("old")) | |
| 959 | - | } | |
| 960 | - | ||
| 961 | - | func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) { | |
| 962 | - | clock := newManualSnapshotClock() | |
| 963 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 964 | - | factory := portsmocks.NewMockPTYFactory(t) | |
| 965 | - | d := newTestDaemon(t, factory, clock) | |
| 966 | - | WithSnapshotStore(store)(d) | |
| 967 | - | startSnapshotEncodeWorker(t, d) | |
| 968 | - | ||
| 969 | - | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 970 | - | tctx, tcancel := context.WithCancel(context.Background()) | |
| 971 | - | t.Cleanup(tcancel) | |
| 972 | - | sess.ctx = tctx | |
| 973 | - | sess.cancel = tcancel | |
| 974 | - | sess.tabs[0].ctx = tctx | |
| 975 | - | sess.tabs[0].cancel = tcancel | |
| 976 | - | for _, p := range sess.tabs[0].panes { | |
| 977 | - | p.ctx = tctx | |
| 978 | - | p.cancel = tcancel | |
| 979 | - | } | |
| 980 | - | sess.tabs[0].size = domain.Size{Cols: 41, Rows: 10} | |
| 981 | - | d.sessions[sess.id] = sess | |
| 982 | - | newPTY := portsmocks.NewMockPTY(t) | |
| 983 | - | newPTY.EXPECT().Pid().Return(2345).Maybe() | |
| 984 | - | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |
| 985 | - | newPTY.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe() | |
| 986 | - | newPTY.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |
| 987 | - | newPTY.EXPECT().ForegroundPgid().Return(0, nil).Maybe() | |
| 988 | - | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once() | |
| 989 | - | ||
| 990 | - | require.NoError(t, d.splitPane(sess, nil, layout.Right)) | |
| 991 | - | require.True(t, sess.snapDirty.Load()) | |
| 992 | - | ||
| 993 | - | wrote := make(chan struct{}, 1) | |
| 994 | - | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 995 | - | snap, err := snapcodec.Unmarshal(data) | |
| 996 | - | require.NoError(t, err) | |
| 997 | - | require.Len(t, snap.Tabs, 1) | |
| 998 | - | require.Len(t, snap.Tabs[0].Panes, 2) | |
| 999 | - | wrote <- struct{}{} | |
| 1000 | - | return nil | |
| 1001 | - | }).Once() | |
| 1002 | - | ||
| 1003 | - | ctx, cancel := context.WithCancel(t.Context()) | |
| 1004 | - | defer cancel() | |
| 1005 | - | go d.snapshotSaver(ctx) | |
| 1006 | - | clock.fireNext(t) | |
| 1007 | - | select { | |
| 1008 | - | case <-wrote: | |
| 1009 | - | case <-time.After(time.Second): | |
| 1010 | - | t.Fatal("timed out waiting for layout-only dirty snapshot write") | |
| 1011 | - | } | |
| 1012 | - | } | |
| 1013 | - | ||
| 1014 | - | func snapshotProcessPTY(t *testing.T, pgid int) *portsmocks.MockPTY { | |
| 1015 | - | t.Helper() | |
| 1016 | - | pty := portsmocks.NewMockPTY(t) | |
| 1017 | - | pty.EXPECT().Pid().Return(1234).Maybe() | |
| 1018 | - | pty.EXPECT().Read(mock.Anything).Return(0, errors.New("unused")).Maybe() | |
| 1019 | - | pty.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe() | |
| 1020 | - | pty.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |
| 1021 | - | pty.EXPECT().ForegroundPgid().Return(pgid, nil).Once() | |
| 1022 | - | return pty | |
| 1023 | - | } | |
| 1024 | - | ||
| 1025 | 35 | func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session { | |
| 1026 | 36 | t.Helper() | |
| 1027 | 37 | pty := portsmocks.NewMockPTY(t) | |
| @@ -1034,18 +44,8 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin | |||
| 1034 | 44 | tb := newTab(pty, domain.Size{Cols: 8, Rows: 3}) | |
| 1035 | 45 | p := tb.panes["pane-1"] | |
| 1036 | 46 | p.screen.Write([]byte("hello")) | |
| 1037 | - | p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}) | |
| 1038 | - | sess := &session{ | |
| 1039 | - | id: domain.SessionID("sess-" + name), | |
| 1040 | - | name: name, | |
| 1041 | - | ephemeral: ephemeral, | |
| 1042 | - | ctx: context.Background(), | |
| 1043 | - | cancel: func() {}, | |
| 1044 | - | tabs: []*tab{tb}, | |
| 1045 | - | active: 0, | |
| 1046 | - | cwd: cwd, | |
| 1047 | - | createdAt: 42, | |
| 1048 | - | } | |
| 47 | + | require.NoError(t, p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})) | |
| 48 | + | sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42} | |
| 1049 | 49 | sess.snapEligible.Store(!ephemeral && name != "") | |
| 1050 | 50 | return sess | |
| 1051 | 51 | } | |
| @@ -1095,135 +95,3 @@ func startSnapshotEncodeWorker(t *testing.T, d *Daemon) { | |||
| 1095 | 95 | d.startSnapshotEncodeWorker() | |
| 1096 | 96 | t.Cleanup(d.stopSnapshotEncodeWorker) | |
| 1097 | 97 | } | |
| 1098 | - | ||
| 1099 | - | type discardSnapshotStore struct{} | |
| 1100 | - | ||
| 1101 | - | func (discardSnapshotStore) Write(string, []byte) error { return nil } | |
| 1102 | - | func (discardSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |
| 1103 | - | func (discardSnapshotStore) Delete(string) error { return nil } | |
| 1104 | - | ||
| 1105 | - | type gatedSnapshotStore struct { | |
| 1106 | - | entered chan struct{} | |
| 1107 | - | release chan struct{} | |
| 1108 | - | once sync.Once | |
| 1109 | - | releaseOnce sync.Once | |
| 1110 | - | ||
| 1111 | - | mu sync.Mutex | |
| 1112 | - | writes int | |
| 1113 | - | name string | |
| 1114 | - | data []byte | |
| 1115 | - | } | |
| 1116 | - | ||
| 1117 | - | func newGatedSnapshotStore() *gatedSnapshotStore { | |
| 1118 | - | return &gatedSnapshotStore{entered: make(chan struct{}), release: make(chan struct{})} | |
| 1119 | - | } | |
| 1120 | - | ||
| 1121 | - | func (s *gatedSnapshotStore) enter() { s.once.Do(func() { close(s.entered) }) } | |
| 1122 | - | func (s *gatedSnapshotStore) unblock() { s.releaseOnce.Do(func() { close(s.release) }) } | |
| 1123 | - | ||
| 1124 | - | func (s *gatedSnapshotStore) Write(name string, data []byte) error { | |
| 1125 | - | s.enter() | |
| 1126 | - | <-s.release | |
| 1127 | - | s.mu.Lock() | |
| 1128 | - | s.writes++ | |
| 1129 | - | s.name = name | |
| 1130 | - | s.data = append([]byte(nil), data...) | |
| 1131 | - | s.mu.Unlock() | |
| 1132 | - | return nil | |
| 1133 | - | } | |
| 1134 | - | func (*gatedSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |
| 1135 | - | func (*gatedSnapshotStore) Delete(string) error { return nil } | |
| 1136 | - | ||
| 1137 | - | // recorded returns the write count and the most recently written name/data, | |
| 1138 | - | // captured under the store's own lock so it is safe to call from the test | |
| 1139 | - | // goroutine after the write has happened-before via awaitSnapshotClean. | |
| 1140 | - | func (s *gatedSnapshotStore) recorded() (int, string, []byte) { | |
| 1141 | - | s.mu.Lock() | |
| 1142 | - | defer s.mu.Unlock() | |
| 1143 | - | return s.writes, s.name, s.data | |
| 1144 | - | } | |
| 1145 | - | ||
| 1146 | - | type neverReturningSnapshotStore struct { | |
| 1147 | - | entered chan struct{} | |
| 1148 | - | once sync.Once | |
| 1149 | - | block chan struct{} | |
| 1150 | - | } | |
| 1151 | - | ||
| 1152 | - | func newNeverReturningSnapshotStore() *neverReturningSnapshotStore { | |
| 1153 | - | return &neverReturningSnapshotStore{entered: make(chan struct{}), block: make(chan struct{})} | |
| 1154 | - | } | |
| 1155 | - | ||
| 1156 | - | func (s *neverReturningSnapshotStore) Write(string, []byte) error { | |
| 1157 | - | s.once.Do(func() { close(s.entered) }) | |
| 1158 | - | <-s.block | |
| 1159 | - | return nil | |
| 1160 | - | } | |
| 1161 | - | func (*neverReturningSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |
| 1162 | - | func (*neverReturningSnapshotStore) Delete(string) error { return nil } | |
| 1163 | - | ||
| 1164 | - | type channelSnapshotStore struct { | |
| 1165 | - | writes chan []byte | |
| 1166 | - | } | |
| 1167 | - | ||
| 1168 | - | func (s *channelSnapshotStore) Write(_ string, data []byte) error { | |
| 1169 | - | s.writes <- append([]byte(nil), data...) | |
| 1170 | - | return nil | |
| 1171 | - | } | |
| 1172 | - | func (*channelSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |
| 1173 | - | func (*channelSnapshotStore) Delete(string) error { return nil } | |
| 1174 | - | ||
| 1175 | - | type manualSnapshotClock struct { | |
| 1176 | - | mu sync.Mutex | |
| 1177 | - | timers []*manualSnapshotTimer | |
| 1178 | - | } | |
| 1179 | - | ||
| 1180 | - | func newManualSnapshotClock() *manualSnapshotClock { return &manualSnapshotClock{} } | |
| 1181 | - | ||
| 1182 | - | func (c *manualSnapshotClock) Now() time.Time { return time.Unix(0, 0) } | |
| 1183 | - | ||
| 1184 | - | func (c *manualSnapshotClock) NewTimer(time.Duration) ports.Timer { | |
| 1185 | - | t := &manualSnapshotTimer{ch: make(chan time.Time, 1)} | |
| 1186 | - | c.mu.Lock() | |
| 1187 | - | c.timers = append(c.timers, t) | |
| 1188 | - | c.mu.Unlock() | |
| 1189 | - | return t | |
| 1190 | - | } | |
| 1191 | - | ||
| 1192 | - | func (c *manualSnapshotClock) fireNext(t *testing.T) { | |
| 1193 | - | t.Helper() | |
| 1194 | - | deadline := time.After(time.Second) | |
| 1195 | - | for { | |
| 1196 | - | c.mu.Lock() | |
| 1197 | - | if len(c.timers) > 0 { | |
| 1198 | - | timer := c.timers[0] | |
| 1199 | - | c.mu.Unlock() | |
| 1200 | - | timer.ch <- time.Unix(0, 0) | |
| 1201 | - | return | |
| 1202 | - | } | |
| 1203 | - | c.mu.Unlock() | |
| 1204 | - | select { | |
| 1205 | - | case <-deadline: | |
| 1206 | - | t.Fatal("timed out waiting for snapshot timer") | |
| 1207 | - | case <-time.After(time.Millisecond): | |
| 1208 | - | } | |
| 1209 | - | } | |
| 1210 | - | } | |
| 1211 | - | ||
| 1212 | - | type finalFlushClock struct{ timers chan *manualSnapshotTimer } | |
| 1213 | - | ||
| 1214 | - | func newFinalFlushClock() *finalFlushClock { | |
| 1215 | - | return &finalFlushClock{timers: make(chan *manualSnapshotTimer, 1)} | |
| 1216 | - | } | |
| 1217 | - | ||
| 1218 | - | func (c *finalFlushClock) Now() time.Time { return time.Unix(0, 0) } | |
| 1219 | - | func (c *finalFlushClock) NewTimer(time.Duration) ports.Timer { | |
| 1220 | - | t := &manualSnapshotTimer{ch: make(chan time.Time, 1)} | |
| 1221 | - | c.timers <- t | |
| 1222 | - | return t | |
| 1223 | - | } | |
| 1224 | - | ||
| 1225 | - | type manualSnapshotTimer struct{ ch chan time.Time } | |
| 1226 | - | ||
| 1227 | - | func (t *manualSnapshotTimer) C() <-chan time.Time { return t.ch } | |
| 1228 | - | func (t *manualSnapshotTimer) Reset(time.Duration) bool { return true } | |
| 1229 | - | func (t *manualSnapshotTimer) Stop() bool { return true } | |
A internal/usecase/daemon/snapshot_worker.go +260 -0
| @@ -0,0 +1,260 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func (d *Daemon) reportSnapshotFailure(capture *snapshotCapture, phase string, cause error) { | |
| 10 | + | if d == nil || capture == nil || capture.session == nil || cause == nil { | |
| 11 | + | return | |
| 12 | + | } | |
| 13 | + | signature := snapshotFailureSignature(phase, cause) | |
| 14 | + | d.snapshotNoticeMu.Lock() | |
| 15 | + | changed := d.snapshotActiveFailureSignature != signature | |
| 16 | + | d.snapshotActiveFailureSignature = signature | |
| 17 | + | d.snapshotNoticeMu.Unlock() | |
| 18 | + | ||
| 19 | + | n := domain.Notification{ | |
| 20 | + | Code: domain.NoticeSnapshotWrite, | |
| 21 | + | Severity: domain.NoticeError, | |
| 22 | + | Message: "couldn't save session state; recent state may be lost on restart", | |
| 23 | + | // The history is intentionally diagnostic only at the error-class level. | |
| 24 | + | // Raw causes can include user paths and terminal content. | |
| 25 | + | Details: signature, | |
| 26 | + | Time: d.clock.Now(), | |
| 27 | + | } | |
| 28 | + | n, _ = d.notices.recordSnapshotFailure(n) | |
| 29 | + | d.log.Warn("writing session snapshot failed", "phase", phase, "class", signature, "session", capture.name) | |
| 30 | + | if changed { | |
| 31 | + | d.deliverGlobal(n) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (d *Daemon) clearSnapshotFailure() { | |
| 36 | + | if d == nil { | |
| 37 | + | return | |
| 38 | + | } | |
| 39 | + | d.snapshotNoticeMu.Lock() | |
| 40 | + | d.snapshotActiveFailureSignature = "" | |
| 41 | + | d.snapshotNoticeMu.Unlock() | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (d *Daemon) startSnapshotEncodeWorker() { | |
| 45 | + | if d == nil { | |
| 46 | + | return | |
| 47 | + | } | |
| 48 | + | d.snapshotWorkerMu.Lock() | |
| 49 | + | if d.snapshotWorkerCancel != nil { | |
| 50 | + | d.snapshotWorkerMu.Unlock() | |
| 51 | + | return | |
| 52 | + | } | |
| 53 | + | // Shutdown owns the worker lifetime so it can flush captures after Serve | |
| 54 | + | // cancels its parent context. The worker is always stopped explicitly. | |
| 55 | + | workerCtx, cancel := context.WithCancel(context.Background()) | |
| 56 | + | d.snapshotWorkerID++ | |
| 57 | + | workerID := d.snapshotWorkerID | |
| 58 | + | d.snapshotWorkerCtx = workerCtx | |
| 59 | + | d.snapshotWorkerCancel = cancel | |
| 60 | + | d.snapshotWorkerDone = make(chan struct{}) | |
| 61 | + | d.snapshotWorkerFlush = make(chan struct{}) | |
| 62 | + | d.snapshotWorkerFinalWake = make(chan struct{}, 1) | |
| 63 | + | d.snapshotFinalJobs = make(map[*session]*snapshotCapture) | |
| 64 | + | d.snapshotFinalOrder = nil | |
| 65 | + | d.snapshotWorkerClosing = false | |
| 66 | + | d.snapshotWorkerInFlight = nil | |
| 67 | + | done := d.snapshotWorkerDone | |
| 68 | + | flush := d.snapshotWorkerFlush | |
| 69 | + | finalWake := d.snapshotWorkerFinalWake | |
| 70 | + | d.snapshotWorkerMu.Unlock() | |
| 71 | + | go d.runSnapshotEncodeWorker(workerCtx, workerID, done, flush, finalWake) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // runSnapshotEncodeWorker owns the worker state machine. Each event either | |
| 75 | + | // continues processing, drains the terminal queues for shutdown, or stops; | |
| 76 | + | // publication details are kept out of the select loop. | |
| 77 | + | func (d *Daemon) runSnapshotEncodeWorker(workerCtx context.Context, workerID uint64, done chan<- struct{}, flush, finalWake <-chan struct{}) { | |
| 78 | + | defer close(done) | |
| 79 | + | for { | |
| 80 | + | select { | |
| 81 | + | case <-workerCtx.Done(): | |
| 82 | + | return | |
| 83 | + | case <-flush: | |
| 84 | + | d.flushSnapshotCaptures(workerCtx, workerID) | |
| 85 | + | return | |
| 86 | + | case <-finalWake: | |
| 87 | + | if !d.drainFinalSnapshotCaptures(workerCtx, workerID) { | |
| 88 | + | return | |
| 89 | + | } | |
| 90 | + | case capture := <-d.snapshotJobs: | |
| 91 | + | if !d.publishSnapshotCapture(workerCtx, workerID, capture) { | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (d *Daemon) flushSnapshotCaptures(workerCtx context.Context, workerID uint64) { | |
| 99 | + | for { | |
| 100 | + | select { | |
| 101 | + | case capture := <-d.snapshotJobs: | |
| 102 | + | if !d.publishSnapshotCapture(workerCtx, workerID, capture) { | |
| 103 | + | return | |
| 104 | + | } | |
| 105 | + | default: | |
| 106 | + | _ = d.drainFinalSnapshotCaptures(workerCtx, workerID) | |
| 107 | + | return | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (d *Daemon) drainFinalSnapshotCaptures(workerCtx context.Context, workerID uint64) bool { | |
| 113 | + | for capture := d.takeFinalSnapshotCapture(); capture != nil; capture = d.takeFinalSnapshotCapture() { | |
| 114 | + | if !d.publishSnapshotCapture(workerCtx, workerID, capture) { | |
| 115 | + | return false | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | return true | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func (d *Daemon) publishSnapshotCapture(workerCtx context.Context, workerID uint64, capture *snapshotCapture) bool { | |
| 122 | + | if workerCtx.Err() != nil || !startSnapshotPublication(capture) || !d.setSnapshotWorkerInFlight(workerID, capture) { | |
| 123 | + | d.finishSnapshotCapture(capture, false) | |
| 124 | + | return workerCtx.Err() == nil | |
| 125 | + | } | |
| 126 | + | publication, err := d.incrementalPublication(capture) | |
| 127 | + | publicationContext := capture.publicationContext | |
| 128 | + | if publicationContext == nil { | |
| 129 | + | publicationContext = workerCtx | |
| 130 | + | } | |
| 131 | + | if err == nil && workerCtx.Err() == nil { | |
| 132 | + | if err = publicationContext.Err(); err == nil { | |
| 133 | + | err = d.snapshotRepository.Publish(publicationContext, publication) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | d.clearSnapshotWorkerInFlight(workerID, capture) | |
| 137 | + | if err == nil && workerCtx.Err() == nil { | |
| 138 | + | markSnapshotCaptureObjectsPublished(capture) | |
| 139 | + | } | |
| 140 | + | if err != nil && workerCtx.Err() == nil && publicationContext.Err() == nil { | |
| 141 | + | // Global, not session-scoped: by now the session may already be torn | |
| 142 | + | // down, so a session notice would be dead-on-arrival. No lock is held | |
| 143 | + | // here (clearSnapshotWorkerInFlight released its own). | |
| 144 | + | d.reportSnapshotFailure(capture, "publish", err) | |
| 145 | + | } | |
| 146 | + | d.finishSnapshotCapture(capture, err == nil && workerCtx.Err() == nil) | |
| 147 | + | return workerCtx.Err() == nil | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func (d *Daemon) takeFinalSnapshotCapture() *snapshotCapture { | |
| 151 | + | d.snapshotWorkerMu.Lock() | |
| 152 | + | defer d.snapshotWorkerMu.Unlock() | |
| 153 | + | for len(d.snapshotFinalOrder) > 0 { | |
| 154 | + | sess := d.snapshotFinalOrder[0] | |
| 155 | + | d.snapshotFinalOrder[0] = nil | |
| 156 | + | d.snapshotFinalOrder = d.snapshotFinalOrder[1:] | |
| 157 | + | capture := d.snapshotFinalJobs[sess] | |
| 158 | + | delete(d.snapshotFinalJobs, sess) | |
| 159 | + | if capture != nil { | |
| 160 | + | return capture | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | return nil | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func (d *Daemon) setSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) bool { | |
| 167 | + | d.snapshotWorkerMu.Lock() | |
| 168 | + | defer d.snapshotWorkerMu.Unlock() | |
| 169 | + | if d.snapshotWorkerID != workerID || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil { | |
| 170 | + | return false | |
| 171 | + | } | |
| 172 | + | d.snapshotWorkerInFlight = capture | |
| 173 | + | return true | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func (d *Daemon) clearSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) { | |
| 177 | + | d.snapshotWorkerMu.Lock() | |
| 178 | + | defer d.snapshotWorkerMu.Unlock() | |
| 179 | + | if d.snapshotWorkerID == workerID && d.snapshotWorkerInFlight == capture { | |
| 180 | + | d.snapshotWorkerInFlight = nil | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func (d *Daemon) stopSnapshotEncodeWorker() { | |
| 185 | + | if d == nil { | |
| 186 | + | return | |
| 187 | + | } | |
| 188 | + | deadline := newSnapshotShutdownDeadline(d.clock) | |
| 189 | + | defer deadline.stop() | |
| 190 | + | d.stopSnapshotEncodeWorkerWithDeadline(deadline) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // stopSnapshotEncodeWorkerWithDeadline performs the one deadline-aware join | |
| 194 | + | // for a Serve shutdown. Queues are never closed, and detached writers only | |
| 195 | + | // retain immutable captures plus contexts/channels that remain live, so an | |
| 196 | + | // uncooperative repository call cannot race teardown or block process exit. | |
| 197 | + | func (d *Daemon) stopSnapshotEncodeWorkerWithDeadline(deadline *snapshotShutdownDeadline) { | |
| 198 | + | if d == nil { | |
| 199 | + | return | |
| 200 | + | } | |
| 201 | + | d.snapshotWorkerMu.Lock() | |
| 202 | + | cancel := d.snapshotWorkerCancel | |
| 203 | + | if cancel == nil || d.snapshotWorkerClosing { | |
| 204 | + | d.snapshotWorkerMu.Unlock() | |
| 205 | + | return | |
| 206 | + | } | |
| 207 | + | // Stop accepting producers, then let the single worker drain its bounded | |
| 208 | + | // queue. A non-context-aware store may still block indefinitely, so the | |
| 209 | + | // shared shutdown deadline bounds the one and only join. | |
| 210 | + | d.snapshotWorkerClosing = true | |
| 211 | + | flush := d.snapshotWorkerFlush | |
| 212 | + | done := d.snapshotWorkerDone | |
| 213 | + | close(flush) | |
| 214 | + | d.snapshotWorkerMu.Unlock() | |
| 215 | + | ||
| 216 | + | if deadline == nil { | |
| 217 | + | deadline = newSnapshotShutdownDeadline(d.clock) | |
| 218 | + | defer deadline.stop() | |
| 219 | + | } | |
| 220 | + | select { | |
| 221 | + | case <-done: | |
| 222 | + | d.finishStoppedSnapshotWorker(false) | |
| 223 | + | case <-deadline.Done(): | |
| 224 | + | cancel() | |
| 225 | + | d.finishStoppedSnapshotWorker(true) | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func (d *Daemon) finishStoppedSnapshotWorker(abandoned bool) { | |
| 230 | + | d.snapshotWorkerMu.Lock() | |
| 231 | + | inFlight := d.snapshotWorkerInFlight | |
| 232 | + | d.snapshotWorkerCtx = nil | |
| 233 | + | d.snapshotWorkerCancel = nil | |
| 234 | + | d.snapshotWorkerDone = nil | |
| 235 | + | d.snapshotWorkerFlush = nil | |
| 236 | + | d.snapshotWorkerFinalWake = nil | |
| 237 | + | d.snapshotWorkerClosing = false | |
| 238 | + | d.snapshotWorkerInFlight = nil | |
| 239 | + | queued := make([]*snapshotCapture, 0, len(d.snapshotJobs)+len(d.snapshotFinalJobs)) | |
| 240 | + | for _, capture := range d.snapshotFinalJobs { | |
| 241 | + | queued = append(queued, capture) | |
| 242 | + | } | |
| 243 | + | d.snapshotFinalJobs = nil | |
| 244 | + | d.snapshotFinalOrder = nil | |
| 245 | + | for { | |
| 246 | + | select { | |
| 247 | + | case capture := <-d.snapshotJobs: | |
| 248 | + | queued = append(queued, capture) | |
| 249 | + | default: | |
| 250 | + | d.snapshotWorkerMu.Unlock() | |
| 251 | + | if abandoned { | |
| 252 | + | d.finishSnapshotCapture(inFlight, false) | |
| 253 | + | } | |
| 254 | + | for _, capture := range queued { | |
| 255 | + | d.finishSnapshotCapture(capture, false) | |
| 256 | + | } | |
| 257 | + | return | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | } | |
A internal/usecase/daemon/testdata/legacy-v3-empty.b64 +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | VkVWUwADAAAAAAAc4u6s7AAObGVnYWN5LWZpeHR1cmUAAAAAAAAAAAAAAAA= | |
D internal/usecase/snapshot/benchmark_test.go +0 -122
| @@ -1,122 +0,0 @@ | |||
| 1 | - | package snapshot | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "testing" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 8 | - | "github.com/bnema/vev/pkg/vt" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | var ( | |
| 12 | - | benchmarkV3BytesSink []byte | |
| 13 | - | benchmarkV3SessionSink Session | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | func BenchmarkSnapshotV3Marshal10KRows(b *testing.B) { | |
| 17 | - | session, encoded := benchmarkV3Session(b) | |
| 18 | - | b.ReportAllocs() | |
| 19 | - | b.SetBytes(int64(len(encoded))) | |
| 20 | - | b.ResetTimer() | |
| 21 | - | for b.Loop() { | |
| 22 | - | var err error | |
| 23 | - | benchmarkV3BytesSink, err = Marshal(session) | |
| 24 | - | if err != nil { | |
| 25 | - | b.Fatal(err) | |
| 26 | - | } | |
| 27 | - | } | |
| 28 | - | b.StopTimer() | |
| 29 | - | if _, err := Unmarshal(benchmarkV3BytesSink); err != nil { | |
| 30 | - | b.Fatalf("marshal produced an invalid v3 snapshot: %v", err) | |
| 31 | - | } | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | func BenchmarkSnapshotV3Unmarshal10KRows(b *testing.B) { | |
| 35 | - | _, encoded := benchmarkV3Session(b) | |
| 36 | - | b.ReportAllocs() | |
| 37 | - | b.SetBytes(int64(len(encoded))) | |
| 38 | - | b.ResetTimer() | |
| 39 | - | for b.Loop() { | |
| 40 | - | var err error | |
| 41 | - | benchmarkV3SessionSink, err = Unmarshal(encoded) | |
| 42 | - | if err != nil { | |
| 43 | - | b.Fatal(err) | |
| 44 | - | } | |
| 45 | - | } | |
| 46 | - | b.StopTimer() | |
| 47 | - | if len(benchmarkV3SessionSink.Tabs) != 1 || len(benchmarkV3SessionSink.Tabs[0].Panes) != 1 || len(benchmarkV3SessionSink.Tabs[0].Panes[0].SealedChunks) != 40 { | |
| 48 | - | b.Fatal("unmarshal lost canonical 10k-row snapshot data") | |
| 49 | - | } | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | func benchmarkV3Session(t testing.TB) (Session, []byte) { | |
| 53 | - | t.Helper() | |
| 54 | - | const ( | |
| 55 | - | cols = 120 | |
| 56 | - | rows = 40 | |
| 57 | - | historyRows = 10_000 | |
| 58 | - | chunkRows = 256 | |
| 59 | - | ) | |
| 60 | - | history := vt.NewHistory(vt.HistoryConfig{MaxRows: historyRows, ChunkRows: chunkRows}) | |
| 61 | - | for row := range historyRows { | |
| 62 | - | cells := make([]renderer.Cell, cols) | |
| 63 | - | for col := range cells { | |
| 64 | - | cells[col] = renderer.Cell{Rune: rune('a' + (row+col)%26)} | |
| 65 | - | } | |
| 66 | - | history.Append(cells) | |
| 67 | - | } | |
| 68 | - | sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView()) | |
| 69 | - | if err != nil { | |
| 70 | - | t.Fatal(err) | |
| 71 | - | } | |
| 72 | - | visibleFrame := renderer.NewFrame(cols, rows) | |
| 73 | - | for y := range rows { | |
| 74 | - | for x := range cols { | |
| 75 | - | visibleFrame.Set(x, y, renderer.Cell{Rune: rune('A' + (x+y)%26)}) | |
| 76 | - | } | |
| 77 | - | } | |
| 78 | - | visible, err := vt.MarshalVisible(visibleFrame) | |
| 79 | - | if err != nil { | |
| 80 | - | t.Fatal(err) | |
| 81 | - | } | |
| 82 | - | session := Session{ | |
| 83 | - | Name: "benchmark", | |
| 84 | - | CreatedAt: 1, | |
| 85 | - | Tabs: []Tab{{ | |
| 86 | - | StableID: "tab-1", | |
| 87 | - | Cols: cols, | |
| 88 | - | Rows: rows, | |
| 89 | - | NextPaneID: 2, | |
| 90 | - | Focus: "pane-1", | |
| 91 | - | Tree: layout.NewTree("pane-1"), | |
| 92 | - | Panes: []Pane{{ | |
| 93 | - | ID: "pane-1", | |
| 94 | - | StableID: "pane-1", | |
| 95 | - | Cwd: "/benchmark", | |
| 96 | - | SealedChunks: sealed, | |
| 97 | - | Tail: tail, | |
| 98 | - | Visible: visible, | |
| 99 | - | }}, | |
| 100 | - | }}, | |
| 101 | - | } | |
| 102 | - | encoded, err := Marshal(session) | |
| 103 | - | if err != nil { | |
| 104 | - | t.Fatal(err) | |
| 105 | - | } | |
| 106 | - | decoded, err := Unmarshal(encoded) | |
| 107 | - | if err != nil { | |
| 108 | - | t.Fatal(err) | |
| 109 | - | } | |
| 110 | - | paneCount := 0 | |
| 111 | - | decodedChunks := 0 | |
| 112 | - | if len(decoded.Tabs) == 1 { | |
| 113 | - | paneCount = len(decoded.Tabs[0].Panes) | |
| 114 | - | if paneCount == 1 { | |
| 115 | - | decodedChunks = len(decoded.Tabs[0].Panes[0].SealedChunks) | |
| 116 | - | } | |
| 117 | - | } | |
| 118 | - | if len(sealed) != (historyRows+chunkRows-1)/chunkRows || paneCount != 1 || decodedChunks != len(sealed) { | |
| 119 | - | t.Fatalf("invalid v3 benchmark fixture: chunks=%d tabs=%d panes=%d decoded chunks=%d", len(sealed), len(decoded.Tabs), paneCount, decodedChunks) | |
| 120 | - | } | |
| 121 | - | return session, encoded | |
| 122 | - | } | |
M internal/usecase/snapshot/codec.go +63 -165
| @@ -24,7 +24,7 @@ const ( | |||
| 24 | 24 | magic = "VEVS" | |
| 25 | 25 | version = uint16(3) | |
| 26 | 26 | maxDecodedBodySize = 256 << 20 | |
| 27 | - | maxSnapshotBlobs = 1 << 16 | |
| 27 | + | maxSnapshotObjects = 1 << 16 | |
| 28 | 28 | maxSnapshotBytes = 256 << 20 | |
| 29 | 29 | maxSnapshotRows = 1 << 20 | |
| 30 | 30 | maxSnapshotCells = 16 << 20 | |
| @@ -34,24 +34,6 @@ const ( | |||
| 34 | 34 | maxSnapshotDecodedAllocation = 256 << 20 | |
| 35 | 35 | ) | |
| 36 | 36 | ||
| 37 | - | // Marshal encodes the strict v3, flags-zero durable envelope. | |
| 38 | - | func Marshal(s Session) ([]byte, error) { | |
| 39 | - | var w payloadWriter | |
| 40 | - | if err := writeSession(&w, s); err != nil { | |
| 41 | - | return nil, err | |
| 42 | - | } | |
| 43 | - | if len(w.b) > maxDecodedBodySize || len(w.b) > math.MaxUint32 { | |
| 44 | - | return nil, fmt.Errorf("%w: body too large", ErrInvalidData) | |
| 45 | - | } | |
| 46 | - | out := make([]byte, 16+len(w.b)) | |
| 47 | - | copy(out[:4], magic) | |
| 48 | - | binary.BigEndian.PutUint16(out[4:6], version) | |
| 49 | - | binary.BigEndian.PutUint32(out[8:12], uint32(len(w.b))) | |
| 50 | - | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(w.b)) | |
| 51 | - | copy(out[16:], w.b) | |
| 52 | - | return out, nil | |
| 53 | - | } | |
| 54 | - | ||
| 55 | 37 | // Unmarshal validates the complete manifest and all VT blobs without | |
| 56 | 38 | // allocations first, then decodes the already preflighted data. | |
| 57 | 39 | func Unmarshal(b []byte) (Session, error) { | |
| @@ -146,6 +128,61 @@ func (w *payloadWriter) putStrings(ss []string) error { | |||
| 146 | 128 | return nil | |
| 147 | 129 | } | |
| 148 | 130 | ||
| 131 | + | func writeNode(w *payloadWriter, n *layout.Node) error { | |
| 132 | + | if n == nil { | |
| 133 | + | w.putUint8(manifestNodeNil) | |
| 134 | + | return nil | |
| 135 | + | } | |
| 136 | + | switch n.Kind { | |
| 137 | + | case layout.Leaf: | |
| 138 | + | w.putUint8(manifestNodeLeaf) | |
| 139 | + | return w.putString(string(n.Leaf)) | |
| 140 | + | case layout.Split: | |
| 141 | + | w.putUint8(manifestNodeSplit) | |
| 142 | + | w.putUint8(uint8(n.Dir)) | |
| 143 | + | return writeChildren(w, n.Children) | |
| 144 | + | case layout.Stack: | |
| 145 | + | w.putUint8(manifestNodeStack) | |
| 146 | + | if err := w.putString(string(n.Expanded)); err != nil { | |
| 147 | + | return err | |
| 148 | + | } | |
| 149 | + | return writeChildren(w, n.Children) | |
| 150 | + | default: | |
| 151 | + | return fmt.Errorf("%w: unknown node kind", ErrInvalidData) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func writeChildren(w *payloadWriter, children []*layout.Node) error { | |
| 156 | + | if len(children) > math.MaxUint16 { | |
| 157 | + | return fmt.Errorf("%w: too many children", ErrInvalidData) | |
| 158 | + | } | |
| 159 | + | w.putUint16(uint16(len(children))) | |
| 160 | + | for _, c := range children { | |
| 161 | + | if err := writeNode(w, c); err != nil { | |
| 162 | + | return err | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | return nil | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func writeProcess(w *payloadWriter, p *Process) error { | |
| 169 | + | if p == nil { | |
| 170 | + | w.putUint8(processAbsent) | |
| 171 | + | return nil | |
| 172 | + | } | |
| 173 | + | if len(p.Argv) == 0 { | |
| 174 | + | return fmt.Errorf("%w: process argv empty", ErrInvalidData) | |
| 175 | + | } | |
| 176 | + | w.putUint8(processPresent) | |
| 177 | + | if err := w.putStrings(p.Argv); err != nil { | |
| 178 | + | return err | |
| 179 | + | } | |
| 180 | + | if err := w.putString(p.Strategy); err != nil { | |
| 181 | + | return err | |
| 182 | + | } | |
| 183 | + | return w.putString(p.Opts.AgentSessionID) | |
| 184 | + | } | |
| 185 | + | ||
| 149 | 186 | type payloadReader struct{ b []byte } | |
| 150 | 187 | ||
| 151 | 188 | func (r *payloadReader) getUint8() (uint8, error) { | |
| @@ -226,27 +263,6 @@ func (r *payloadReader) done() error { | |||
| 226 | 263 | return nil | |
| 227 | 264 | } | |
| 228 | 265 | ||
| 229 | - | func writeSession(w *payloadWriter, s Session) error { | |
| 230 | - | if len(s.Tabs) > math.MaxUint16 { | |
| 231 | - | return fmt.Errorf("%w: too many tabs", ErrInvalidData) | |
| 232 | - | } | |
| 233 | - | if err := validateActive(s.Active, len(s.Tabs)); err != nil { | |
| 234 | - | return err | |
| 235 | - | } | |
| 236 | - | if err := w.putString(s.Name); err != nil { | |
| 237 | - | return err | |
| 238 | - | } | |
| 239 | - | w.putUint64(s.CreatedAt) | |
| 240 | - | w.putUint16(s.Active) | |
| 241 | - | w.putUint16(uint16(len(s.Tabs))) | |
| 242 | - | for _, t := range s.Tabs { | |
| 243 | - | if err := writeTab(w, t); err != nil { | |
| 244 | - | return err | |
| 245 | - | } | |
| 246 | - | } | |
| 247 | - | return nil | |
| 248 | - | } | |
| 249 | - | ||
| 250 | 266 | func readSession(r *payloadReader) (Session, error) { | |
| 251 | 267 | name, err := r.getString() | |
| 252 | 268 | if err != nil { | |
| @@ -278,35 +294,6 @@ func readSession(r *payloadReader) (Session, error) { | |||
| 278 | 294 | return s, nil | |
| 279 | 295 | } | |
| 280 | 296 | ||
| 281 | - | func writeTab(w *payloadWriter, t Tab) error { | |
| 282 | - | if err := w.putString(t.StableID); err != nil { | |
| 283 | - | return err | |
| 284 | - | } | |
| 285 | - | w.putUint16(t.Cols) | |
| 286 | - | w.putUint16(t.Rows) | |
| 287 | - | w.putUint64(t.NextPaneID) | |
| 288 | - | if err := w.putString(string(t.Focus)); err != nil { | |
| 289 | - | return err | |
| 290 | - | } | |
| 291 | - | var root *layout.Node | |
| 292 | - | if t.Tree != nil { | |
| 293 | - | root = t.Tree.Root | |
| 294 | - | } | |
| 295 | - | if err := writeNode(w, root); err != nil { | |
| 296 | - | return err | |
| 297 | - | } | |
| 298 | - | if len(t.Panes) > math.MaxUint16 { | |
| 299 | - | return fmt.Errorf("%w: too many panes", ErrInvalidData) | |
| 300 | - | } | |
| 301 | - | w.putUint16(uint16(len(t.Panes))) | |
| 302 | - | for _, p := range t.Panes { | |
| 303 | - | if err := writePane(w, p); err != nil { | |
| 304 | - | return err | |
| 305 | - | } | |
| 306 | - | } | |
| 307 | - | return nil | |
| 308 | - | } | |
| 309 | - | ||
| 310 | 297 | func readTab(r *payloadReader) (Tab, error) { | |
| 311 | 298 | stableID, err := r.getString() | |
| 312 | 299 | if err != nil { | |
| @@ -347,56 +334,19 @@ func readTab(r *payloadReader) (Tab, error) { | |||
| 347 | 334 | return t, nil | |
| 348 | 335 | } | |
| 349 | 336 | ||
| 350 | - | func writeNode(w *payloadWriter, n *layout.Node) error { | |
| 351 | - | if n == nil { | |
| 352 | - | w.putUint8(3) | |
| 353 | - | return nil | |
| 354 | - | } | |
| 355 | - | switch n.Kind { | |
| 356 | - | case layout.Leaf: | |
| 357 | - | w.putUint8(0) | |
| 358 | - | return w.putString(string(n.Leaf)) | |
| 359 | - | case layout.Split: | |
| 360 | - | w.putUint8(1) | |
| 361 | - | w.putUint8(uint8(n.Dir)) | |
| 362 | - | return writeChildren(w, n.Children) | |
| 363 | - | case layout.Stack: | |
| 364 | - | w.putUint8(2) | |
| 365 | - | if err := w.putString(string(n.Expanded)); err != nil { | |
| 366 | - | return err | |
| 367 | - | } | |
| 368 | - | return writeChildren(w, n.Children) | |
| 369 | - | default: | |
| 370 | - | return fmt.Errorf("%w: unknown node kind", ErrInvalidData) | |
| 371 | - | } | |
| 372 | - | } | |
| 373 | - | ||
| 374 | - | func writeChildren(w *payloadWriter, children []*layout.Node) error { | |
| 375 | - | if len(children) > math.MaxUint16 { | |
| 376 | - | return fmt.Errorf("%w: too many children", ErrInvalidData) | |
| 377 | - | } | |
| 378 | - | w.putUint16(uint16(len(children))) | |
| 379 | - | for _, c := range children { | |
| 380 | - | if err := writeNode(w, c); err != nil { | |
| 381 | - | return err | |
| 382 | - | } | |
| 383 | - | } | |
| 384 | - | return nil | |
| 385 | - | } | |
| 386 | - | ||
| 387 | 337 | func readNode(r *payloadReader) (*layout.Node, error) { | |
| 388 | 338 | kind, err := r.getUint8() | |
| 389 | 339 | if err != nil { | |
| 390 | 340 | return nil, err | |
| 391 | 341 | } | |
| 392 | 342 | switch kind { | |
| 393 | - | case 0: | |
| 343 | + | case manifestNodeLeaf: | |
| 394 | 344 | leaf, err := r.getString() | |
| 395 | 345 | if err != nil { | |
| 396 | 346 | return nil, err | |
| 397 | 347 | } | |
| 398 | 348 | return layout.NewLeaf(layout.PaneID(leaf)), nil | |
| 399 | - | case 1: | |
| 349 | + | case manifestNodeSplit: | |
| 400 | 350 | dir, err := r.getUint8() | |
| 401 | 351 | if err != nil { | |
| 402 | 352 | return nil, err | |
| @@ -406,7 +356,7 @@ func readNode(r *payloadReader) (*layout.Node, error) { | |||
| 406 | 356 | return nil, err | |
| 407 | 357 | } | |
| 408 | 358 | return &layout.Node{Kind: layout.Split, Dir: layout.SplitDir(dir), Children: children}, nil | |
| 409 | - | case 2: | |
| 359 | + | case manifestNodeStack: | |
| 410 | 360 | exp, err := r.getString() | |
| 411 | 361 | if err != nil { | |
| 412 | 362 | return nil, err | |
| @@ -416,7 +366,7 @@ func readNode(r *payloadReader) (*layout.Node, error) { | |||
| 416 | 366 | return nil, err | |
| 417 | 367 | } | |
| 418 | 368 | return &layout.Node{Kind: layout.Stack, Expanded: layout.PaneID(exp), Children: children}, nil | |
| 419 | - | case 3: | |
| 369 | + | case manifestNodeNil: | |
| 420 | 370 | return nil, nil | |
| 421 | 371 | default: | |
| 422 | 372 | return nil, fmt.Errorf("%w: unknown node kind", ErrInvalidData) | |
| @@ -439,40 +389,6 @@ func readChildren(r *payloadReader) ([]*layout.Node, error) { | |||
| 439 | 389 | return out, nil | |
| 440 | 390 | } | |
| 441 | 391 | ||
| 442 | - | func writePane(w *payloadWriter, p Pane) error { | |
| 443 | - | if err := w.putString(string(p.ID)); err != nil { | |
| 444 | - | return err | |
| 445 | - | } | |
| 446 | - | if err := w.putString(p.StableID); err != nil { | |
| 447 | - | return err | |
| 448 | - | } | |
| 449 | - | if err := w.putString(p.Cwd); err != nil { | |
| 450 | - | return err | |
| 451 | - | } | |
| 452 | - | if len(p.SealedChunks) > maxSnapshotBlobs { | |
| 453 | - | return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData) | |
| 454 | - | } | |
| 455 | - | w.putUint32(uint32(len(p.SealedChunks))) | |
| 456 | - | for _, blob := range p.SealedChunks { | |
| 457 | - | if len(blob) == 0 || len(blob) > math.MaxUint32 { | |
| 458 | - | return fmt.Errorf("%w: sealed chunk length", ErrInvalidData) | |
| 459 | - | } | |
| 460 | - | w.putUint32(uint32(len(blob))) | |
| 461 | - | w.b = append(w.b, blob...) | |
| 462 | - | } | |
| 463 | - | if len(p.Tail) == 0 || len(p.Tail) > math.MaxUint32 { | |
| 464 | - | return fmt.Errorf("%w: tail length", ErrInvalidData) | |
| 465 | - | } | |
| 466 | - | w.putUint32(uint32(len(p.Tail))) | |
| 467 | - | w.b = append(w.b, p.Tail...) | |
| 468 | - | if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 { | |
| 469 | - | return fmt.Errorf("%w: visible length", ErrInvalidData) | |
| 470 | - | } | |
| 471 | - | w.putUint32(uint32(len(p.Visible))) | |
| 472 | - | w.b = append(w.b, p.Visible...) | |
| 473 | - | return writeProcess(w, p.Process) | |
| 474 | - | } | |
| 475 | - | ||
| 476 | 392 | func readPane(r *payloadReader) (Pane, error) { | |
| 477 | 393 | id, err := r.getString() | |
| 478 | 394 | if err != nil { | |
| @@ -490,7 +406,7 @@ func readPane(r *payloadReader) (Pane, error) { | |||
| 490 | 406 | if err != nil { | |
| 491 | 407 | return Pane{}, err | |
| 492 | 408 | } | |
| 493 | - | if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 { | |
| 409 | + | if n > maxSnapshotObjects || uint64(n) > uint64(len(r.b))/4 { | |
| 494 | 410 | return Pane{}, fmt.Errorf("%w: sealed chunks", ErrInvalidData) | |
| 495 | 411 | } | |
| 496 | 412 | sealed := make([][]byte, n) | |
| @@ -514,33 +430,15 @@ func readPane(r *payloadReader) (Pane, error) { | |||
| 514 | 430 | return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil | |
| 515 | 431 | } | |
| 516 | 432 | ||
| 517 | - | func writeProcess(w *payloadWriter, p *Process) error { | |
| 518 | - | if p == nil { | |
| 519 | - | w.putUint8(0) | |
| 520 | - | return nil | |
| 521 | - | } | |
| 522 | - | if len(p.Argv) == 0 { | |
| 523 | - | return fmt.Errorf("%w: process argv empty", ErrInvalidData) | |
| 524 | - | } | |
| 525 | - | w.putUint8(1) | |
| 526 | - | if err := w.putStrings(p.Argv); err != nil { | |
| 527 | - | return err | |
| 528 | - | } | |
| 529 | - | if err := w.putString(p.Strategy); err != nil { | |
| 530 | - | return err | |
| 531 | - | } | |
| 532 | - | return w.putString(p.Opts.AgentSessionID) | |
| 533 | - | } | |
| 534 | - | ||
| 535 | 433 | func readProcess(r *payloadReader) (*Process, error) { | |
| 536 | 434 | present, err := r.getUint8() | |
| 537 | 435 | if err != nil { | |
| 538 | 436 | return nil, err | |
| 539 | 437 | } | |
| 540 | - | if present == 0 { | |
| 438 | + | if present == processAbsent { | |
| 541 | 439 | return nil, nil | |
| 542 | 440 | } | |
| 543 | - | if present != 1 { | |
| 441 | + | if present != processPresent { | |
| 544 | 442 | return nil, fmt.Errorf("%w: process presence", ErrInvalidData) | |
| 545 | 443 | } | |
| 546 | 444 | argv, err := r.getStrings() | |
A internal/usecase/snapshot/codec_encoder_test.go +113 -0
| @@ -0,0 +1,113 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | "fmt" | |
| 6 | + | "hash/crc32" | |
| 7 | + | "math" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // marshalTest is a test-only v3 encoder. Production retains only the decoder | |
| 13 | + | // required by the one-time legacy import. | |
| 14 | + | func marshalTest(s Session) ([]byte, error) { | |
| 15 | + | var w payloadWriter | |
| 16 | + | if err := writeSession(&w, s); err != nil { | |
| 17 | + | return nil, err | |
| 18 | + | } | |
| 19 | + | if len(w.b) > maxDecodedBodySize || len(w.b) > math.MaxUint32 { | |
| 20 | + | return nil, fmt.Errorf("%w: body too large", ErrInvalidData) | |
| 21 | + | } | |
| 22 | + | out := make([]byte, 16+len(w.b)) | |
| 23 | + | copy(out[:4], magic) | |
| 24 | + | binary.BigEndian.PutUint16(out[4:6], version) | |
| 25 | + | binary.BigEndian.PutUint32(out[8:12], uint32(len(w.b))) | |
| 26 | + | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(w.b)) | |
| 27 | + | copy(out[16:], w.b) | |
| 28 | + | return out, nil | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func writeSession(w *payloadWriter, s Session) error { | |
| 32 | + | if len(s.Tabs) > math.MaxUint16 { | |
| 33 | + | return fmt.Errorf("%w: too many tabs", ErrInvalidData) | |
| 34 | + | } | |
| 35 | + | if err := validateActive(s.Active, len(s.Tabs)); err != nil { | |
| 36 | + | return err | |
| 37 | + | } | |
| 38 | + | if err := w.putString(s.Name); err != nil { | |
| 39 | + | return err | |
| 40 | + | } | |
| 41 | + | w.putUint64(s.CreatedAt) | |
| 42 | + | w.putUint16(s.Active) | |
| 43 | + | w.putUint16(uint16(len(s.Tabs))) | |
| 44 | + | for _, t := range s.Tabs { | |
| 45 | + | if err := writeTab(w, t); err != nil { | |
| 46 | + | return err | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | return nil | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func writeTab(w *payloadWriter, t Tab) error { | |
| 53 | + | if err := w.putString(t.StableID); err != nil { | |
| 54 | + | return err | |
| 55 | + | } | |
| 56 | + | w.putUint16(t.Cols) | |
| 57 | + | w.putUint16(t.Rows) | |
| 58 | + | w.putUint64(t.NextPaneID) | |
| 59 | + | if err := w.putString(string(t.Focus)); err != nil { | |
| 60 | + | return err | |
| 61 | + | } | |
| 62 | + | var root *layout.Node | |
| 63 | + | if t.Tree != nil { | |
| 64 | + | root = t.Tree.Root | |
| 65 | + | } | |
| 66 | + | if err := writeNode(w, root); err != nil { | |
| 67 | + | return err | |
| 68 | + | } | |
| 69 | + | if len(t.Panes) > math.MaxUint16 { | |
| 70 | + | return fmt.Errorf("%w: too many panes", ErrInvalidData) | |
| 71 | + | } | |
| 72 | + | w.putUint16(uint16(len(t.Panes))) | |
| 73 | + | for _, p := range t.Panes { | |
| 74 | + | if err := writePane(w, p); err != nil { | |
| 75 | + | return err | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | return nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func writePane(w *payloadWriter, p Pane) error { | |
| 82 | + | if err := w.putString(string(p.ID)); err != nil { | |
| 83 | + | return err | |
| 84 | + | } | |
| 85 | + | if err := w.putString(p.StableID); err != nil { | |
| 86 | + | return err | |
| 87 | + | } | |
| 88 | + | if err := w.putString(p.Cwd); err != nil { | |
| 89 | + | return err | |
| 90 | + | } | |
| 91 | + | if len(p.SealedChunks) > maxSnapshotObjects { | |
| 92 | + | return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData) | |
| 93 | + | } | |
| 94 | + | w.putUint32(uint32(len(p.SealedChunks))) | |
| 95 | + | for _, blob := range p.SealedChunks { | |
| 96 | + | if len(blob) == 0 || len(blob) > math.MaxUint32 { | |
| 97 | + | return fmt.Errorf("%w: sealed chunk length", ErrInvalidData) | |
| 98 | + | } | |
| 99 | + | w.putUint32(uint32(len(blob))) | |
| 100 | + | w.b = append(w.b, blob...) | |
| 101 | + | } | |
| 102 | + | if len(p.Tail) == 0 || len(p.Tail) > math.MaxUint32 { | |
| 103 | + | return fmt.Errorf("%w: tail length", ErrInvalidData) | |
| 104 | + | } | |
| 105 | + | w.putUint32(uint32(len(p.Tail))) | |
| 106 | + | w.b = append(w.b, p.Tail...) | |
| 107 | + | if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 { | |
| 108 | + | return fmt.Errorf("%w: visible length", ErrInvalidData) | |
| 109 | + | } | |
| 110 | + | w.putUint32(uint32(len(p.Visible))) | |
| 111 | + | w.b = append(w.b, p.Visible...) | |
| 112 | + | return writeProcess(w, p.Process) | |
| 113 | + | } | |
M internal/usecase/snapshot/codec_test.go +40 -19
| @@ -32,7 +32,7 @@ func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) { | |||
| 32 | 32 | {ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, | |
| 33 | 33 | {ID: "p2", SealedChunks: sealed2, Tail: tail2, Visible: visible2}, | |
| 34 | 34 | }}}} | |
| 35 | - | encoded, err := Marshal(want) | |
| 35 | + | encoded, err := marshalTest(want) | |
| 36 | 36 | if err != nil { | |
| 37 | 37 | t.Fatal(err) | |
| 38 | 38 | } | |
| @@ -86,13 +86,13 @@ func TestActiveTabReferenceMustBeExact(t *testing.T) { | |||
| 86 | 86 | {name: "nonempty tabs active past end", s: Session{Active: 1, Tabs: []Tab{validTab}}}, | |
| 87 | 87 | } { | |
| 88 | 88 | t.Run("marshal "+tc.name, func(t *testing.T) { | |
| 89 | - | _, err := Marshal(tc.s) | |
| 89 | + | _, err := marshalTest(tc.s) | |
| 90 | 90 | if tc.valid { | |
| 91 | 91 | requireNoError(t, err) | |
| 92 | 92 | return | |
| 93 | 93 | } | |
| 94 | 94 | if !errors.Is(err, ErrInvalidData) { | |
| 95 | - | t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData) | |
| 95 | + | t.Fatalf("marshalTest() error = %v, want %v", err, ErrInvalidData) | |
| 96 | 96 | } | |
| 97 | 97 | }) | |
| 98 | 98 | } | |
| @@ -153,7 +153,7 @@ func TestUnmarshalRejectsHostileV3ManifestDeclarationsBeforeAllocation(t *testin | |||
| 153 | 153 | func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) { | |
| 154 | 154 | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}}) | |
| 155 | 155 | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 156 | - | encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) | |
| 156 | + | encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) | |
| 157 | 157 | if err != nil { | |
| 158 | 158 | t.Fatal(err) | |
| 159 | 159 | } | |
| @@ -174,7 +174,7 @@ func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) { | |||
| 174 | 174 | {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, {ID: "p", Tail: tail, Visible: visible}}}, | |
| 175 | 175 | } { | |
| 176 | 176 | t.Run(tc.name, func(t *testing.T) { | |
| 177 | - | data, err := Marshal(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}}) | |
| 177 | + | data, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}}) | |
| 178 | 178 | requireNoError(t, err) | |
| 179 | 179 | reject(t, data, ErrInvalidData) | |
| 180 | 180 | }) | |
| @@ -184,7 +184,7 @@ func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) { | |||
| 184 | 184 | func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) { | |
| 185 | 185 | sealed, tail := historyBlobs(t, nil) | |
| 186 | 186 | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 187 | - | encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) | |
| 187 | + | encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) | |
| 188 | 188 | if err != nil { | |
| 189 | 189 | t.Fatal(err) | |
| 190 | 190 | } | |
| @@ -193,8 +193,8 @@ func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) { | |||
| 193 | 193 | } | |
| 194 | 194 | } | |
| 195 | 195 | ||
| 196 | - | func TestUnmarshalRejectsNonCanonicalBlobRolesDuringPreflight(t *testing.T) { | |
| 197 | - | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}}) | |
| 196 | + | func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |
| 197 | + | sealed, emptyTail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}}) | |
| 198 | 198 | if len(sealed) == 0 { | |
| 199 | 199 | t.Fatal("expected a sealed chunk") | |
| 200 | 200 | } | |
| @@ -204,16 +204,37 @@ func TestUnmarshalRejectsNonCanonicalBlobRolesDuringPreflight(t *testing.T) { | |||
| 204 | 204 | multiChunk = append(multiChunk, sealed[0][9:]...) | |
| 205 | 205 | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 206 | 206 | emptyVisible := visibleBlob(t, nil) | |
| 207 | + | ||
| 208 | + | // Incremental captures retain one copied mutable tail chunk instead of | |
| 209 | + | // rotating it into the sealed history set. That one chunk is canonical and | |
| 210 | + | // must remain a valid v3 import payload. | |
| 211 | + | mutableHistory := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |
| 212 | + | for _, row := range [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}} { | |
| 213 | + | requireNoError(t, mutableHistory.Append(row)) | |
| 214 | + | } | |
| 215 | + | mutableTail, err := vt.MarshalHistoryTail(mutableHistory.SnapshotView()) | |
| 216 | + | requireNoError(t, err) | |
| 217 | + | fullHistory, err := vt.MarshalHistory(mutableHistory.SealAndView()) | |
| 218 | + | requireNoError(t, err) | |
| 219 | + | valid, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: sealed[:1], Tail: mutableTail, Visible: visible}}}}}) | |
| 220 | + | requireNoError(t, err) | |
| 221 | + | roundTrip, err := Unmarshal(valid) | |
| 222 | + | requireNoError(t, err) | |
| 223 | + | if got := roundTrip.Tabs[0].Panes[0].Tail; !bytes.Equal(got, mutableTail) { | |
| 224 | + | t.Fatal("mutable tail did not round trip") | |
| 225 | + | } | |
| 226 | + | ||
| 207 | 227 | for _, tc := range []struct { | |
| 208 | 228 | name string | |
| 209 | 229 | tab Tab | |
| 210 | 230 | }{ | |
| 211 | - | {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: tail, Visible: visible}}}}, | |
| 212 | - | {name: "nonempty tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: sealed[0], Visible: visible}}}}, | |
| 213 | - | {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: tail, Visible: emptyVisible}}}}, | |
| 231 | + | {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: emptyTail, Visible: visible}}}}, | |
| 232 | + | {name: "tail has multiple chunks", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: multiChunk, Visible: visible}}}}, | |
| 233 | + | {name: "noncanonical full history tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: fullHistory, Visible: visible}}}}, | |
| 234 | + | {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: emptyTail, Visible: emptyVisible}}}}, | |
| 214 | 235 | } { | |
| 215 | 236 | t.Run(tc.name, func(t *testing.T) { | |
| 216 | - | data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}}) | |
| 237 | + | data, err := marshalTest(Session{Name: "s", Tabs: []Tab{tc.tab}}) | |
| 217 | 238 | requireNoError(t, err) | |
| 218 | 239 | reject(t, data, ErrInvalidData) | |
| 219 | 240 | }) | |
| @@ -238,7 +259,7 @@ func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) { | |||
| 238 | 259 | {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}}, | |
| 239 | 260 | } { | |
| 240 | 261 | t.Run(tc.name, func(t *testing.T) { | |
| 241 | - | data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}}) | |
| 262 | + | data, err := marshalTest(Session{Name: "s", Tabs: []Tab{tc.tab}}) | |
| 242 | 263 | requireNoError(t, err) | |
| 243 | 264 | got, err := Unmarshal(data) | |
| 244 | 265 | requireNoError(t, err) | |
| @@ -265,7 +286,7 @@ func TestRoundTripSessionMetadataAndProcess(t *testing.T) { | |||
| 265 | 286 | }, | |
| 266 | 287 | {StableID: "t2", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", SealedChunks: sealed, Tail: tail, Visible: visible}}}, | |
| 267 | 288 | }} | |
| 268 | - | data, err := Marshal(want) | |
| 289 | + | data, err := marshalTest(want) | |
| 269 | 290 | requireNoError(t, err) | |
| 270 | 291 | got, err := Unmarshal(data) | |
| 271 | 292 | requireNoError(t, err) | |
| @@ -279,11 +300,11 @@ func TestRoundTripSessionMetadataAndProcess(t *testing.T) { | |||
| 279 | 300 | } | |
| 280 | 301 | ||
| 281 | 302 | func TestMarshalRejectsProcessWithoutArgv(t *testing.T) { | |
| 282 | - | _, err := Marshal(Session{Name: "s", Tabs: []Tab{ | |
| 303 | + | _, err := marshalTest(Session{Name: "s", Tabs: []Tab{ | |
| 283 | 304 | {Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}}, | |
| 284 | 305 | }}) | |
| 285 | 306 | if !errors.Is(err, ErrInvalidData) { | |
| 286 | - | t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData) | |
| 307 | + | t.Fatalf("marshalTest() error = %v, want %v", err, ErrInvalidData) | |
| 287 | 308 | } | |
| 288 | 309 | } | |
| 289 | 310 | ||
| @@ -322,7 +343,7 @@ func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) { | |||
| 322 | 343 | func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) { | |
| 323 | 344 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256}) | |
| 324 | 345 | for range 256 { | |
| 325 | - | history.Append([]renderer.Cell{{Rune: 'x'}}) | |
| 346 | + | requireNoError(t, history.Append([]renderer.Cell{{Rune: 'x'}})) | |
| 326 | 347 | } | |
| 327 | 348 | sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView()) | |
| 328 | 349 | requireNoError(t, err) | |
| @@ -333,7 +354,7 @@ func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) { | |||
| 333 | 354 | for i := range tabs { | |
| 334 | 355 | tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Visible: visible}}} | |
| 335 | 356 | } | |
| 336 | - | data, err := Marshal(Session{Name: "over-budget", Tabs: tabs}) | |
| 357 | + | data, err := marshalTest(Session{Name: "over-budget", Tabs: tabs}) | |
| 337 | 358 | requireNoError(t, err) | |
| 338 | 359 | ||
| 339 | 360 | allocs := testing.AllocsPerRun(3, func() { | |
| @@ -385,7 +406,7 @@ func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) { | |||
| 385 | 406 | t.Helper() | |
| 386 | 407 | h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2}) | |
| 387 | 408 | for _, row := range rows { | |
| 388 | - | h.Append(row) | |
| 409 | + | requireNoError(t, h.Append(row)) | |
| 389 | 410 | } | |
| 390 | 411 | sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView()) | |
| 391 | 412 | if err != nil { | |
A internal/usecase/snapshot/head_test.go +44 -0
| @@ -0,0 +1,44 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestHeadCodecIsDeterministicAndStrict(t *testing.T) { | |
| 9 | + | head := Head{Generation: 7, ManifestDigest: SnapshotDigest{1}} | |
| 10 | + | encoded, err := MarshalHead(head) | |
| 11 | + | if err != nil { | |
| 12 | + | t.Fatal(err) | |
| 13 | + | } | |
| 14 | + | encodedAgain, err := MarshalHead(head) | |
| 15 | + | if err != nil { | |
| 16 | + | t.Fatal(err) | |
| 17 | + | } | |
| 18 | + | if string(encoded) != string(encodedAgain) { | |
| 19 | + | t.Fatal("head encoding is not deterministic") | |
| 20 | + | } | |
| 21 | + | got, err := UnmarshalHead(encoded) | |
| 22 | + | if err != nil || got != head { | |
| 23 | + | t.Fatalf("head round trip = %#v, %v", got, err) | |
| 24 | + | } | |
| 25 | + | for n := range len(encoded) { | |
| 26 | + | if _, err := UnmarshalHead(encoded[:n]); err == nil { | |
| 27 | + | t.Fatalf("prefix %d accepted", n) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | if _, err := UnmarshalHead(append(encoded, 0)); !errors.Is(err, ErrTrailingBytes) { | |
| 31 | + | t.Fatalf("trailing data error = %v", err) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestHeadCodecRejectsInvalidDeclarations(t *testing.T) { | |
| 36 | + | for _, head := range []Head{ | |
| 37 | + | {ManifestDigest: SnapshotDigest{1}}, | |
| 38 | + | {Generation: 1}, | |
| 39 | + | } { | |
| 40 | + | if _, err := MarshalHead(head); !errors.Is(err, ErrInvalidData) { | |
| 41 | + | t.Fatalf("MarshalHead(%#v) error = %v, want invalid data", head, err) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | } | |
A internal/usecase/snapshot/manifest.go +515 -0
| @@ -0,0 +1,515 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/sha256" | |
| 5 | + | "encoding/binary" | |
| 6 | + | "fmt" | |
| 7 | + | "hash/crc32" | |
| 8 | + | "math" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | const ( | |
| 15 | + | manifestMagic = "VEVM" | |
| 16 | + | headMagic = "VEVH" | |
| 17 | + | objectMagic = "VEVO" | |
| 18 | + | ManifestVersion = uint16(1) | |
| 19 | + | manifestHeaderSize = 16 | |
| 20 | + | objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length | |
| 21 | + | minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 1 | |
| 22 | + | objectRefDigestSize = sha256.Size | |
| 23 | + | objectRefSizeFieldSize = 4 | |
| 24 | + | ||
| 25 | + | manifestNodeLeaf uint8 = 0 | |
| 26 | + | manifestNodeSplit uint8 = 1 | |
| 27 | + | manifestNodeStack uint8 = 2 | |
| 28 | + | manifestNodeNil uint8 = 3 | |
| 29 | + | ||
| 30 | + | processAbsent uint8 = 0 | |
| 31 | + | processPresent uint8 = 1 | |
| 32 | + | // maxDecodedBodySize includes the object kind and payload-length prefix. | |
| 33 | + | maxObjectEnvelopeSize = manifestHeaderSize + maxDecodedBodySize | |
| 34 | + | maxObjectPayloadSize = maxDecodedBodySize - objectEnvelopeBodyPrefixSize | |
| 35 | + | ) | |
| 36 | + | ||
| 37 | + | // SnapshotDigest is the shared content-address type used by the repository. | |
| 38 | + | type SnapshotDigest = ports.SnapshotDigest | |
| 39 | + | ||
| 40 | + | func ManifestDigest(encoded []byte) SnapshotDigest { return sha256.Sum256(encoded) } | |
| 41 | + | ||
| 42 | + | type ObjectKind uint8 | |
| 43 | + | ||
| 44 | + | const ( | |
| 45 | + | HistoryChunk ObjectKind = iota + 1 | |
| 46 | + | HistoryTail | |
| 47 | + | Visible | |
| 48 | + | ) | |
| 49 | + | ||
| 50 | + | type ObjectRef struct { | |
| 51 | + | Kind ObjectKind | |
| 52 | + | Digest SnapshotDigest | |
| 53 | + | Size uint32 | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // Head is the repository-owned generation pointer. It is deliberately not a | |
| 57 | + | // VEVM payload; adapters produce and atomically manage HEAD later. | |
| 58 | + | type Head struct { | |
| 59 | + | Generation uint64 | |
| 60 | + | ManifestDigest SnapshotDigest | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // MarshalHead encodes the deterministic, strict VEVH generation pointer. | |
| 64 | + | func MarshalHead(h Head) ([]byte, error) { | |
| 65 | + | if h.Generation == 0 || isZeroDigestBytes(h.ManifestDigest[:]) { | |
| 66 | + | return nil, fmt.Errorf("%w: head", ErrInvalidData) | |
| 67 | + | } | |
| 68 | + | var w payloadWriter | |
| 69 | + | w.putUint64(h.Generation) | |
| 70 | + | w.b = append(w.b, h.ManifestDigest[:]...) | |
| 71 | + | return marshalManifestEnvelope(headMagic, w.b) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // UnmarshalHead decodes a complete VEVH generation pointer. | |
| 75 | + | func UnmarshalHead(encoded []byte) (Head, error) { | |
| 76 | + | body, err := unmarshalManifestEnvelope(encoded, headMagic) | |
| 77 | + | if err != nil { | |
| 78 | + | return Head{}, err | |
| 79 | + | } | |
| 80 | + | if len(body) < 8+sha256.Size { | |
| 81 | + | return Head{}, ErrShortPayload | |
| 82 | + | } | |
| 83 | + | if len(body) != 8+sha256.Size { | |
| 84 | + | return Head{}, ErrTrailingBytes | |
| 85 | + | } | |
| 86 | + | head := Head{Generation: binary.BigEndian.Uint64(body[:8])} | |
| 87 | + | copy(head.ManifestDigest[:], body[8:]) | |
| 88 | + | if head.Generation == 0 || isZeroDigestBytes(head.ManifestDigest[:]) { | |
| 89 | + | return Head{}, fmt.Errorf("%w: head", ErrInvalidData) | |
| 90 | + | } | |
| 91 | + | return head, nil | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // Manifest is the complete VEVM payload carried directly in a publication. | |
| 95 | + | type Manifest struct { | |
| 96 | + | Generation uint64 | |
| 97 | + | Name string | |
| 98 | + | CreatedAt uint64 | |
| 99 | + | Active uint16 | |
| 100 | + | Tabs []ManifestTab | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | type ManifestTab struct { | |
| 104 | + | StableID string | |
| 105 | + | Cols uint16 | |
| 106 | + | Rows uint16 | |
| 107 | + | NextPaneID uint64 | |
| 108 | + | Focus layout.PaneID | |
| 109 | + | Tree *layout.Tree | |
| 110 | + | Panes []ManifestPane | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | type ManifestPane struct { | |
| 114 | + | ID layout.PaneID | |
| 115 | + | StableID string | |
| 116 | + | Cwd string | |
| 117 | + | Sealed []ObjectRef | |
| 118 | + | Tail ObjectRef | |
| 119 | + | Visible ObjectRef | |
| 120 | + | Process *Process | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func MarshalManifest(m Manifest) ([]byte, error) { | |
| 124 | + | if err := validateManifest(m); err != nil { | |
| 125 | + | return nil, err | |
| 126 | + | } | |
| 127 | + | var w payloadWriter | |
| 128 | + | w.putUint64(m.Generation) | |
| 129 | + | if err := w.putString(m.Name); err != nil { | |
| 130 | + | return nil, err | |
| 131 | + | } | |
| 132 | + | w.putUint64(m.CreatedAt) | |
| 133 | + | w.putUint16(m.Active) | |
| 134 | + | if len(m.Tabs) > math.MaxUint16 { | |
| 135 | + | return nil, fmt.Errorf("%w: too many tabs", ErrInvalidData) | |
| 136 | + | } | |
| 137 | + | w.putUint16(uint16(len(m.Tabs))) | |
| 138 | + | for _, tab := range m.Tabs { | |
| 139 | + | if err := writeManifestTab(&w, tab); err != nil { | |
| 140 | + | return nil, err | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | return marshalManifestEnvelope(manifestMagic, w.b) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func UnmarshalManifest(encoded []byte) (Manifest, error) { | |
| 147 | + | body, err := unmarshalManifestEnvelope(encoded, manifestMagic) | |
| 148 | + | if err != nil { | |
| 149 | + | return Manifest{}, err | |
| 150 | + | } | |
| 151 | + | if err := preflightManifest(body); err != nil { | |
| 152 | + | return Manifest{}, err | |
| 153 | + | } | |
| 154 | + | return decodeManifest(body) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // decodeManifest decodes an already preflighted payload. Keeping this separate | |
| 158 | + | // allows the preflight grammar to be differentially tested against decoding. | |
| 159 | + | func decodeManifest(body []byte) (Manifest, error) { | |
| 160 | + | r := payloadReader{b: body} | |
| 161 | + | generation, err := r.getUint64() | |
| 162 | + | if err != nil { | |
| 163 | + | return Manifest{}, err | |
| 164 | + | } | |
| 165 | + | name, err := r.getString() | |
| 166 | + | if err != nil { | |
| 167 | + | return Manifest{}, err | |
| 168 | + | } | |
| 169 | + | createdAt, err := r.getUint64() | |
| 170 | + | if err != nil { | |
| 171 | + | return Manifest{}, err | |
| 172 | + | } | |
| 173 | + | active, err := r.getUint16() | |
| 174 | + | if err != nil { | |
| 175 | + | return Manifest{}, err | |
| 176 | + | } | |
| 177 | + | n, err := r.getUint16() | |
| 178 | + | if err != nil { | |
| 179 | + | return Manifest{}, err | |
| 180 | + | } | |
| 181 | + | m := Manifest{Generation: generation, Name: name, CreatedAt: createdAt, Active: active, Tabs: make([]ManifestTab, n)} | |
| 182 | + | for i := range m.Tabs { | |
| 183 | + | if m.Tabs[i], err = readManifestTab(&r); err != nil { | |
| 184 | + | return Manifest{}, err | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | if err := r.done(); err != nil { | |
| 188 | + | return Manifest{}, err | |
| 189 | + | } | |
| 190 | + | if err := validateManifest(m); err != nil { | |
| 191 | + | return Manifest{}, err | |
| 192 | + | } | |
| 193 | + | return m, nil | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // MarshalObject frames payload as VEVO and returns the bytes together with the | |
| 197 | + | // SHA-256 address of those complete bytes. | |
| 198 | + | func MarshalObject(kind ObjectKind, payload []byte) (ports.SnapshotObject, error) { | |
| 199 | + | if !validObjectKind(kind) || len(payload) == 0 || len(payload) > maxObjectPayloadSize { | |
| 200 | + | return ports.SnapshotObject{}, fmt.Errorf("%w: object", ErrInvalidData) | |
| 201 | + | } | |
| 202 | + | var w payloadWriter | |
| 203 | + | w.putUint8(uint8(kind)) | |
| 204 | + | w.putUint32(uint32(len(payload))) | |
| 205 | + | w.b = append(w.b, payload...) | |
| 206 | + | data, err := marshalManifestEnvelope(objectMagic, w.b) | |
| 207 | + | if err != nil { | |
| 208 | + | return ports.SnapshotObject{}, err | |
| 209 | + | } | |
| 210 | + | return ports.SnapshotObject{Digest: sha256.Sum256(data), Data: data}, nil | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func UnmarshalObject(encoded []byte) (ObjectKind, []byte, error) { | |
| 214 | + | kind, payload, err := PreflightObject(encoded) | |
| 215 | + | if err != nil { | |
| 216 | + | return 0, nil, err | |
| 217 | + | } | |
| 218 | + | return kind, append([]byte(nil), payload...), nil | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // PreflightObject verifies VEVO framing and declared payload size without | |
| 222 | + | // copying; the returned payload aliases encoded and is only valid while it is. | |
| 223 | + | func PreflightObject(encoded []byte) (ObjectKind, []byte, error) { | |
| 224 | + | body, err := unmarshalManifestEnvelope(encoded, objectMagic) | |
| 225 | + | if err != nil { | |
| 226 | + | return 0, nil, err | |
| 227 | + | } | |
| 228 | + | if len(body) < 5 { | |
| 229 | + | return 0, nil, ErrShortPayload | |
| 230 | + | } | |
| 231 | + | kind := ObjectKind(body[0]) | |
| 232 | + | if !validObjectKind(kind) { | |
| 233 | + | return 0, nil, fmt.Errorf("%w: object kind", ErrInvalidData) | |
| 234 | + | } | |
| 235 | + | n := binary.BigEndian.Uint32(body[1:5]) | |
| 236 | + | if n == 0 || n > maxObjectPayloadSize { | |
| 237 | + | return 0, nil, fmt.Errorf("%w: object length", ErrInvalidData) | |
| 238 | + | } | |
| 239 | + | if uint64(n) > uint64(len(body)-5) { | |
| 240 | + | return 0, nil, ErrShortPayload | |
| 241 | + | } | |
| 242 | + | if uint64(n) != uint64(len(body)-5) { | |
| 243 | + | return 0, nil, ErrTrailingBytes | |
| 244 | + | } | |
| 245 | + | return kind, body[5:], nil | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func marshalManifestEnvelope(magic string, body []byte) ([]byte, error) { | |
| 249 | + | if len(body) > maxDecodedBodySize || len(body) > math.MaxUint32 { | |
| 250 | + | return nil, fmt.Errorf("%w: body too large", ErrInvalidData) | |
| 251 | + | } | |
| 252 | + | out := make([]byte, manifestHeaderSize+len(body)) | |
| 253 | + | copy(out[:4], magic) | |
| 254 | + | binary.BigEndian.PutUint16(out[4:6], ManifestVersion) | |
| 255 | + | binary.BigEndian.PutUint32(out[8:12], uint32(len(body))) | |
| 256 | + | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body)) | |
| 257 | + | copy(out[16:], body) | |
| 258 | + | return out, nil | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func unmarshalManifestEnvelope(encoded []byte, expectedMagic string) ([]byte, error) { | |
| 262 | + | if len(encoded) < manifestHeaderSize { | |
| 263 | + | return nil, ErrShortPayload | |
| 264 | + | } | |
| 265 | + | if string(encoded[:4]) != expectedMagic { | |
| 266 | + | return nil, ErrBadMagic | |
| 267 | + | } | |
| 268 | + | if binary.BigEndian.Uint16(encoded[4:6]) != ManifestVersion { | |
| 269 | + | return nil, ErrBadVersion | |
| 270 | + | } | |
| 271 | + | if binary.BigEndian.Uint16(encoded[6:8]) != 0 { | |
| 272 | + | return nil, fmt.Errorf("%w: flags", ErrInvalidData) | |
| 273 | + | } | |
| 274 | + | n := binary.BigEndian.Uint32(encoded[8:12]) | |
| 275 | + | if n > maxDecodedBodySize { | |
| 276 | + | return nil, fmt.Errorf("%w: body too large", ErrInvalidData) | |
| 277 | + | } | |
| 278 | + | if uint64(len(encoded)) < uint64(manifestHeaderSize)+uint64(n) { | |
| 279 | + | return nil, ErrShortPayload | |
| 280 | + | } | |
| 281 | + | if uint64(len(encoded)) != uint64(manifestHeaderSize)+uint64(n) { | |
| 282 | + | return nil, ErrTrailingBytes | |
| 283 | + | } | |
| 284 | + | body := encoded[manifestHeaderSize:] | |
| 285 | + | if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(encoded[12:16]) { | |
| 286 | + | return nil, ErrBadCRC | |
| 287 | + | } | |
| 288 | + | return body, nil | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | func writeManifestTab(w *payloadWriter, tab ManifestTab) error { | |
| 292 | + | if err := w.putString(tab.StableID); err != nil { | |
| 293 | + | return err | |
| 294 | + | } | |
| 295 | + | w.putUint16(tab.Cols) | |
| 296 | + | w.putUint16(tab.Rows) | |
| 297 | + | w.putUint64(tab.NextPaneID) | |
| 298 | + | if err := w.putString(string(tab.Focus)); err != nil { | |
| 299 | + | return err | |
| 300 | + | } | |
| 301 | + | var root *layout.Node | |
| 302 | + | if tab.Tree != nil { | |
| 303 | + | root = tab.Tree.Root | |
| 304 | + | } | |
| 305 | + | if err := writeNode(w, root); err != nil { | |
| 306 | + | return err | |
| 307 | + | } | |
| 308 | + | if len(tab.Panes) > math.MaxUint16 { | |
| 309 | + | return fmt.Errorf("%w: too many panes", ErrInvalidData) | |
| 310 | + | } | |
| 311 | + | w.putUint16(uint16(len(tab.Panes))) | |
| 312 | + | for _, pane := range tab.Panes { | |
| 313 | + | if err := writeManifestPane(w, pane); err != nil { | |
| 314 | + | return err | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | return nil | |
| 318 | + | } | |
| 319 | + | func writeManifestPane(w *payloadWriter, pane ManifestPane) error { | |
| 320 | + | if err := w.putString(string(pane.ID)); err != nil { | |
| 321 | + | return err | |
| 322 | + | } | |
| 323 | + | if err := w.putString(pane.StableID); err != nil { | |
| 324 | + | return err | |
| 325 | + | } | |
| 326 | + | if err := w.putString(pane.Cwd); err != nil { | |
| 327 | + | return err | |
| 328 | + | } | |
| 329 | + | if len(pane.Sealed) > math.MaxUint16 { | |
| 330 | + | return fmt.Errorf("%w: too many sealed references", ErrInvalidData) | |
| 331 | + | } | |
| 332 | + | w.putUint16(uint16(len(pane.Sealed))) | |
| 333 | + | for _, ref := range pane.Sealed { | |
| 334 | + | writeObjectRef(w, ref) | |
| 335 | + | } | |
| 336 | + | writeObjectRef(w, pane.Tail) | |
| 337 | + | writeObjectRef(w, pane.Visible) | |
| 338 | + | return writeProcess(w, pane.Process) | |
| 339 | + | } | |
| 340 | + | func writeObjectRef(w *payloadWriter, ref ObjectRef) { | |
| 341 | + | w.putUint8(uint8(ref.Kind)) | |
| 342 | + | w.b = append(w.b, ref.Digest[:]...) | |
| 343 | + | w.putUint32(ref.Size) | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | func readManifestTab(r *payloadReader) (ManifestTab, error) { | |
| 347 | + | stableID, err := r.getString() | |
| 348 | + | if err != nil { | |
| 349 | + | return ManifestTab{}, err | |
| 350 | + | } | |
| 351 | + | cols, err := r.getUint16() | |
| 352 | + | if err != nil { | |
| 353 | + | return ManifestTab{}, err | |
| 354 | + | } | |
| 355 | + | rows, err := r.getUint16() | |
| 356 | + | if err != nil { | |
| 357 | + | return ManifestTab{}, err | |
| 358 | + | } | |
| 359 | + | next, err := r.getUint64() | |
| 360 | + | if err != nil { | |
| 361 | + | return ManifestTab{}, err | |
| 362 | + | } | |
| 363 | + | focus, err := r.getString() | |
| 364 | + | if err != nil { | |
| 365 | + | return ManifestTab{}, err | |
| 366 | + | } | |
| 367 | + | root, err := readNode(r) | |
| 368 | + | if err != nil { | |
| 369 | + | return ManifestTab{}, err | |
| 370 | + | } | |
| 371 | + | n, err := r.getUint16() | |
| 372 | + | if err != nil { | |
| 373 | + | return ManifestTab{}, err | |
| 374 | + | } | |
| 375 | + | tab := ManifestTab{StableID: stableID, Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]ManifestPane, n)} | |
| 376 | + | for i := range tab.Panes { | |
| 377 | + | if tab.Panes[i], err = readManifestPane(r); err != nil { | |
| 378 | + | return ManifestTab{}, err | |
| 379 | + | } | |
| 380 | + | } | |
| 381 | + | return tab, nil | |
| 382 | + | } | |
| 383 | + | func readManifestPane(r *payloadReader) (ManifestPane, error) { | |
| 384 | + | id, err := r.getString() | |
| 385 | + | if err != nil { | |
| 386 | + | return ManifestPane{}, err | |
| 387 | + | } | |
| 388 | + | stableID, err := r.getString() | |
| 389 | + | if err != nil { | |
| 390 | + | return ManifestPane{}, err | |
| 391 | + | } | |
| 392 | + | cwd, err := r.getString() | |
| 393 | + | if err != nil { | |
| 394 | + | return ManifestPane{}, err | |
| 395 | + | } | |
| 396 | + | n, err := r.getUint16() | |
| 397 | + | if err != nil { | |
| 398 | + | return ManifestPane{}, err | |
| 399 | + | } | |
| 400 | + | pane := ManifestPane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, Sealed: make([]ObjectRef, n)} | |
| 401 | + | for i := range pane.Sealed { | |
| 402 | + | if pane.Sealed[i], err = readObjectRef(r); err != nil { | |
| 403 | + | return ManifestPane{}, err | |
| 404 | + | } | |
| 405 | + | } | |
| 406 | + | if pane.Tail, err = readObjectRef(r); err != nil { | |
| 407 | + | return ManifestPane{}, err | |
| 408 | + | } | |
| 409 | + | if pane.Visible, err = readObjectRef(r); err != nil { | |
| 410 | + | return ManifestPane{}, err | |
| 411 | + | } | |
| 412 | + | pane.Process, err = readProcess(r) | |
| 413 | + | return pane, err | |
| 414 | + | } | |
| 415 | + | func readObjectRef(r *payloadReader) (ObjectRef, error) { | |
| 416 | + | kind, digestBytes, size, err := parseObjectRef(r) | |
| 417 | + | if err != nil { | |
| 418 | + | return ObjectRef{}, err | |
| 419 | + | } | |
| 420 | + | var digest SnapshotDigest | |
| 421 | + | copy(digest[:], digestBytes) | |
| 422 | + | return ObjectRef{Kind: kind, Digest: digest, Size: size}, nil | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | // parseObjectRef advances r over an encoded object reference without copying | |
| 426 | + | // its digest. Callers that retain the digest must copy it before reusing input. | |
| 427 | + | func parseObjectRef(r *payloadReader) (ObjectKind, []byte, uint32, error) { | |
| 428 | + | kind, err := r.getUint8() | |
| 429 | + | if err != nil { | |
| 430 | + | return 0, nil, 0, err | |
| 431 | + | } | |
| 432 | + | if len(r.b) < objectRefDigestSize+objectRefSizeFieldSize { | |
| 433 | + | return 0, nil, 0, ErrShortPayload | |
| 434 | + | } | |
| 435 | + | digest := r.b[:objectRefDigestSize] | |
| 436 | + | r.b = r.b[objectRefDigestSize:] | |
| 437 | + | size, err := r.getUint32() | |
| 438 | + | if err != nil { | |
| 439 | + | return 0, nil, 0, err | |
| 440 | + | } | |
| 441 | + | return ObjectKind(kind), digest, size, nil | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | func validateManifest(m Manifest) error { | |
| 445 | + | if m.Name == "" { | |
| 446 | + | return fmt.Errorf("%w: manifest name", ErrInvalidData) | |
| 447 | + | } | |
| 448 | + | if err := validateActive(m.Active, len(m.Tabs)); err != nil { | |
| 449 | + | return err | |
| 450 | + | } | |
| 451 | + | for _, tab := range m.Tabs { | |
| 452 | + | if !safeDimensions(tab.Cols, tab.Rows) { | |
| 453 | + | return fmt.Errorf("%w: dimensions", ErrInvalidData) | |
| 454 | + | } | |
| 455 | + | ids := make(map[layout.PaneID]struct{}, len(tab.Panes)) | |
| 456 | + | for _, pane := range tab.Panes { | |
| 457 | + | if pane.ID == "" { | |
| 458 | + | return fmt.Errorf("%w: empty pane ID", ErrInvalidData) | |
| 459 | + | } | |
| 460 | + | if _, exists := ids[pane.ID]; exists { | |
| 461 | + | return fmt.Errorf("%w: duplicate pane ID", ErrInvalidData) | |
| 462 | + | } | |
| 463 | + | if err := validatePaneRefs(pane); err != nil { | |
| 464 | + | return err | |
| 465 | + | } | |
| 466 | + | ids[pane.ID] = struct{}{} | |
| 467 | + | } | |
| 468 | + | if _, ok := ids[tab.Focus]; tab.Focus != "" && !ok { | |
| 469 | + | return ErrUnknownPane | |
| 470 | + | } | |
| 471 | + | if err := validateTree(tab.Tree, ids); err != nil { | |
| 472 | + | return err | |
| 473 | + | } | |
| 474 | + | } | |
| 475 | + | return nil | |
| 476 | + | } | |
| 477 | + | func safeDimensions(cols, rows uint16) bool { | |
| 478 | + | return cols != 0 && rows != 0 && uint64(cols)*uint64(rows) <= maxSnapshotCells | |
| 479 | + | } | |
| 480 | + | func validatePaneRefs(p ManifestPane) error { | |
| 481 | + | for _, ref := range p.Sealed { | |
| 482 | + | if err := validateObjectRef(ref, HistoryChunk); err != nil { | |
| 483 | + | return err | |
| 484 | + | } | |
| 485 | + | } | |
| 486 | + | if err := validateObjectRef(p.Tail, HistoryTail); err != nil { | |
| 487 | + | return err | |
| 488 | + | } | |
| 489 | + | return validateObjectRef(p.Visible, Visible) | |
| 490 | + | } | |
| 491 | + | func validateObjectRef(ref ObjectRef, want ObjectKind) error { | |
| 492 | + | return validateObjectRefFields(ref.Kind, ref.Digest[:], ref.Size, want) | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | func validateObjectRefFields(kind ObjectKind, digest []byte, size uint32, want ObjectKind) error { | |
| 496 | + | if kind != want || !validObjectEnvelopeSize(size) || isZeroDigestBytes(digest) { | |
| 497 | + | return fmt.Errorf("%w: object reference", ErrInvalidData) | |
| 498 | + | } | |
| 499 | + | return nil | |
| 500 | + | } | |
| 501 | + | func validObjectEnvelopeSize(size uint32) bool { | |
| 502 | + | return size >= minObjectEnvelopeSize && size <= maxObjectEnvelopeSize | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | func validObjectKind(kind ObjectKind) bool { | |
| 506 | + | return kind == HistoryChunk || kind == HistoryTail || kind == Visible | |
| 507 | + | } | |
| 508 | + | func isZeroDigestBytes(d []byte) bool { | |
| 509 | + | for _, b := range d { | |
| 510 | + | if b != 0 { | |
| 511 | + | return false | |
| 512 | + | } | |
| 513 | + | } | |
| 514 | + | return true | |
| 515 | + | } | |
A internal/usecase/snapshot/manifest_preflight.go +271 -0
| @@ -0,0 +1,271 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // preflightManifest rejects hostile declarations before UnmarshalManifest | |
| 10 | + | // allocates tab, pane, string, or reference slices. | |
| 11 | + | func preflightManifest(body []byte) error { | |
| 12 | + | r := payloadReader{b: body} | |
| 13 | + | budget := manifestPreflightBudget{} | |
| 14 | + | if _, err := r.getUint64(); err != nil { | |
| 15 | + | return err | |
| 16 | + | } | |
| 17 | + | name, err := preflightManifestStringBytes(&r, &budget) | |
| 18 | + | if err != nil { | |
| 19 | + | return err | |
| 20 | + | } | |
| 21 | + | if len(name) == 0 { | |
| 22 | + | return fmt.Errorf("%w: manifest name", ErrInvalidData) | |
| 23 | + | } | |
| 24 | + | if _, err := r.getUint64(); err != nil { | |
| 25 | + | return err | |
| 26 | + | } | |
| 27 | + | active, err := r.getUint16() | |
| 28 | + | if err != nil { | |
| 29 | + | return err | |
| 30 | + | } | |
| 31 | + | tabs, err := r.getUint16() | |
| 32 | + | if err != nil { | |
| 33 | + | return err | |
| 34 | + | } | |
| 35 | + | if err := validateActive(active, int(tabs)); err != nil { | |
| 36 | + | return err | |
| 37 | + | } | |
| 38 | + | if uint64(tabs) > uint64(len(r.b))/2 || !budget.add(uint64(tabs)*128) { | |
| 39 | + | return fmt.Errorf("%w: tab allocation", ErrInvalidData) | |
| 40 | + | } | |
| 41 | + | for range tabs { | |
| 42 | + | if err := preflightManifestTab(&r, &budget); err != nil { | |
| 43 | + | return err | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | return r.done() | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | type manifestPreflightBudget struct{ allocation uint64 } | |
| 50 | + | ||
| 51 | + | func (b *manifestPreflightBudget) add(n uint64) bool { | |
| 52 | + | if n > maxSnapshotDecodedAllocation-b.allocation { | |
| 53 | + | return false | |
| 54 | + | } | |
| 55 | + | b.allocation += n | |
| 56 | + | return true | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func preflightManifestStringBytes(r *payloadReader, budget *manifestPreflightBudget) ([]byte, error) { | |
| 60 | + | n, err := r.getUint16() | |
| 61 | + | if err != nil { | |
| 62 | + | return nil, err | |
| 63 | + | } | |
| 64 | + | if uint64(n) > uint64(len(r.b)) { | |
| 65 | + | return nil, ErrShortPayload | |
| 66 | + | } | |
| 67 | + | if !budget.add(uint64(n) + 16) { | |
| 68 | + | return nil, fmt.Errorf("%w: string allocation", ErrInvalidData) | |
| 69 | + | } | |
| 70 | + | b := r.b[:n] | |
| 71 | + | r.b = r.b[n:] | |
| 72 | + | return b, nil | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // skipManifestString accounts for the decoded string without materializing it. | |
| 76 | + | func skipManifestString(r *payloadReader, budget *manifestPreflightBudget) error { | |
| 77 | + | _, err := preflightManifestStringBytes(r, budget) | |
| 78 | + | return err | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func preflightManifestString(r *payloadReader, budget *manifestPreflightBudget) (string, error) { | |
| 82 | + | b, err := preflightManifestStringBytes(r, budget) | |
| 83 | + | if err != nil { | |
| 84 | + | return "", err | |
| 85 | + | } | |
| 86 | + | return string(b), nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func preflightManifestTab(r *payloadReader, budget *manifestPreflightBudget) error { | |
| 90 | + | if err := skipManifestString(r, budget); err != nil { | |
| 91 | + | return err | |
| 92 | + | } | |
| 93 | + | cols, err := r.getUint16() | |
| 94 | + | if err != nil { | |
| 95 | + | return err | |
| 96 | + | } | |
| 97 | + | rows, err := r.getUint16() | |
| 98 | + | if err != nil { | |
| 99 | + | return err | |
| 100 | + | } | |
| 101 | + | if !safeDimensions(cols, rows) { | |
| 102 | + | return fmt.Errorf("%w: dimensions", ErrInvalidData) | |
| 103 | + | } | |
| 104 | + | if _, err := r.getUint64(); err != nil { | |
| 105 | + | return err | |
| 106 | + | } | |
| 107 | + | focus, err := preflightManifestString(r, budget) | |
| 108 | + | if err != nil { | |
| 109 | + | return err | |
| 110 | + | } | |
| 111 | + | references := make([]layout.PaneID, 0, 8) | |
| 112 | + | if err := preflightManifestNode(r, 0, true, budget, &references); err != nil { | |
| 113 | + | return err | |
| 114 | + | } | |
| 115 | + | panes, err := r.getUint16() | |
| 116 | + | if err != nil { | |
| 117 | + | return err | |
| 118 | + | } | |
| 119 | + | if uint64(panes) > uint64(len(r.b))/2 || !budget.add(uint64(panes)*256) { | |
| 120 | + | return fmt.Errorf("%w: pane allocation", ErrInvalidData) | |
| 121 | + | } | |
| 122 | + | ids := make(map[layout.PaneID]struct{}, panes) | |
| 123 | + | for range panes { | |
| 124 | + | id, err := preflightManifestPane(r, budget) | |
| 125 | + | if err != nil { | |
| 126 | + | return err | |
| 127 | + | } | |
| 128 | + | if id == "" { | |
| 129 | + | return fmt.Errorf("%w: empty pane ID", ErrInvalidData) | |
| 130 | + | } | |
| 131 | + | if _, duplicate := ids[id]; duplicate { | |
| 132 | + | return fmt.Errorf("%w: duplicate pane ID", ErrInvalidData) | |
| 133 | + | } | |
| 134 | + | ids[id] = struct{}{} | |
| 135 | + | } | |
| 136 | + | if focus != "" { | |
| 137 | + | references = append(references, layout.PaneID(focus)) | |
| 138 | + | } | |
| 139 | + | for _, id := range references { | |
| 140 | + | if _, ok := ids[id]; !ok { | |
| 141 | + | return ErrUnknownPane | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | return nil | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func preflightManifestNode(r *payloadReader, depth int, root bool, budget *manifestPreflightBudget, refs *[]layout.PaneID) error { | |
| 148 | + | if depth > 64 || !budget.add(128) { | |
| 149 | + | return fmt.Errorf("%w: tree resource declaration", ErrInvalidData) | |
| 150 | + | } | |
| 151 | + | kind, err := r.getUint8() | |
| 152 | + | if err != nil { | |
| 153 | + | return err | |
| 154 | + | } | |
| 155 | + | switch kind { | |
| 156 | + | case manifestNodeLeaf: | |
| 157 | + | leaf, err := preflightManifestString(r, budget) | |
| 158 | + | if err != nil { | |
| 159 | + | return err | |
| 160 | + | } | |
| 161 | + | *refs = append(*refs, layout.PaneID(leaf)) | |
| 162 | + | return nil | |
| 163 | + | case manifestNodeSplit: | |
| 164 | + | dir, err := r.getUint8() | |
| 165 | + | if err != nil { | |
| 166 | + | return err | |
| 167 | + | } | |
| 168 | + | if layout.SplitDir(dir) != layout.Horizontal && layout.SplitDir(dir) != layout.Vertical { | |
| 169 | + | return fmt.Errorf("%w: split dir", ErrInvalidData) | |
| 170 | + | } | |
| 171 | + | case manifestNodeStack: | |
| 172 | + | expanded, err := preflightManifestString(r, budget) | |
| 173 | + | if err != nil { | |
| 174 | + | return err | |
| 175 | + | } | |
| 176 | + | if expanded != "" { | |
| 177 | + | *refs = append(*refs, layout.PaneID(expanded)) | |
| 178 | + | } | |
| 179 | + | case manifestNodeNil: | |
| 180 | + | if !root { | |
| 181 | + | return fmt.Errorf("%w: nil child node", ErrInvalidData) | |
| 182 | + | } | |
| 183 | + | return nil | |
| 184 | + | default: | |
| 185 | + | return fmt.Errorf("%w: node kind", ErrInvalidData) | |
| 186 | + | } | |
| 187 | + | n, err := r.getUint16() | |
| 188 | + | if err != nil { | |
| 189 | + | return err | |
| 190 | + | } | |
| 191 | + | if uint64(n) > uint64(len(r.b)) { | |
| 192 | + | return ErrShortPayload | |
| 193 | + | } | |
| 194 | + | for range n { | |
| 195 | + | if err := preflightManifestNode(r, depth+1, false, budget, refs); err != nil { | |
| 196 | + | return err | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | return nil | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func preflightManifestPane(r *payloadReader, budget *manifestPreflightBudget) (layout.PaneID, error) { | |
| 203 | + | id, err := preflightManifestString(r, budget) | |
| 204 | + | if err != nil { | |
| 205 | + | return "", err | |
| 206 | + | } | |
| 207 | + | if err := skipManifestString(r, budget); err != nil { | |
| 208 | + | return "", err | |
| 209 | + | } | |
| 210 | + | if err := skipManifestString(r, budget); err != nil { | |
| 211 | + | return "", err | |
| 212 | + | } | |
| 213 | + | sealed, err := r.getUint16() | |
| 214 | + | if err != nil { | |
| 215 | + | return "", err | |
| 216 | + | } | |
| 217 | + | if uint64(sealed) > uint64(len(r.b))/(objectRefDigestSize+objectRefSizeFieldSize+1) || !budget.add(uint64(sealed)*48) { | |
| 218 | + | return "", fmt.Errorf("%w: sealed allocation", ErrInvalidData) | |
| 219 | + | } | |
| 220 | + | for range sealed { | |
| 221 | + | if err := preflightObjectRef(r, HistoryChunk); err != nil { | |
| 222 | + | return "", err | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | if err := preflightObjectRef(r, HistoryTail); err != nil { | |
| 226 | + | return "", err | |
| 227 | + | } | |
| 228 | + | if err := preflightObjectRef(r, Visible); err != nil { | |
| 229 | + | return "", err | |
| 230 | + | } | |
| 231 | + | if err := preflightManifestProcess(r, budget); err != nil { | |
| 232 | + | return "", err | |
| 233 | + | } | |
| 234 | + | return layout.PaneID(id), nil | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func preflightObjectRef(r *payloadReader, want ObjectKind) error { | |
| 238 | + | kind, digest, size, err := parseObjectRef(r) | |
| 239 | + | if err != nil { | |
| 240 | + | return err | |
| 241 | + | } | |
| 242 | + | return validateObjectRefFields(kind, digest, size, want) | |
| 243 | + | } | |
| 244 | + | func preflightManifestProcess(r *payloadReader, budget *manifestPreflightBudget) error { | |
| 245 | + | present, err := r.getUint8() | |
| 246 | + | if err != nil { | |
| 247 | + | return err | |
| 248 | + | } | |
| 249 | + | if present == processAbsent { | |
| 250 | + | return nil | |
| 251 | + | } | |
| 252 | + | if present != processPresent { | |
| 253 | + | return fmt.Errorf("%w: process presence", ErrInvalidData) | |
| 254 | + | } | |
| 255 | + | n, err := r.getUint16() | |
| 256 | + | if err != nil { | |
| 257 | + | return err | |
| 258 | + | } | |
| 259 | + | if n == 0 || !budget.add(uint64(n)*16) { | |
| 260 | + | return fmt.Errorf("%w: process argv", ErrInvalidData) | |
| 261 | + | } | |
| 262 | + | for range n { | |
| 263 | + | if err := skipManifestString(r, budget); err != nil { | |
| 264 | + | return err | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | if err := skipManifestString(r, budget); err != nil { | |
| 268 | + | return err | |
| 269 | + | } | |
| 270 | + | return skipManifestString(r, budget) | |
| 271 | + | } | |
A internal/usecase/snapshot/manifest_preflight_test.go +173 -0
| @@ -0,0 +1,173 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | "errors" | |
| 6 | + | "hash/crc32" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestManifestGrammarRoundTrip(t *testing.T) { | |
| 13 | + | for _, tc := range []struct { | |
| 14 | + | name string | |
| 15 | + | tree *layout.Tree | |
| 16 | + | proc *Process | |
| 17 | + | }{ | |
| 18 | + | {"split without process", &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("one"), layout.NewLeaf("two")}}}, nil}, | |
| 19 | + | {"stack with process argv", &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Expanded: "one", Children: []*layout.Node{layout.NewLeaf("one"), layout.NewLeaf("two")}}}, &Process{Argv: []string{"sh", "-c", "echo"}, Strategy: "restore", Opts: ProcessOpts{AgentSessionID: "agent"}}}, | |
| 20 | + | } { | |
| 21 | + | t.Run(tc.name, func(t *testing.T) { | |
| 22 | + | encoded, err := MarshalManifest(testManifest(tc.tree, tc.proc)) | |
| 23 | + | if err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | got, err := UnmarshalManifest(encoded) | |
| 27 | + | if err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | if got.Tabs[0].Tree.Root.Kind != tc.tree.Root.Kind || (got.Tabs[0].Panes[0].Process == nil) != (tc.proc == nil) { | |
| 31 | + | t.Fatalf("decoded tree/process = %#v/%#v", got.Tabs[0].Tree, got.Tabs[0].Panes[0].Process) | |
| 32 | + | } | |
| 33 | + | }) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestManifestEnvelopePreflightParity(t *testing.T) { | |
| 38 | + | encoded, err := MarshalManifest(testManifest(layout.NewTree("one"), nil)) | |
| 39 | + | if err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | mutations := []struct { | |
| 43 | + | name string | |
| 44 | + | edit func([]byte) []byte | |
| 45 | + | }{ | |
| 46 | + | {"valid", func(b []byte) []byte { return b }}, | |
| 47 | + | {"magic", func(b []byte) []byte { b[0] ^= 1; return b }}, | |
| 48 | + | {"version", func(b []byte) []byte { binary.BigEndian.PutUint16(b[4:6], ManifestVersion+1); return b }}, | |
| 49 | + | {"flags", func(b []byte) []byte { b[7] = 1; return b }}, | |
| 50 | + | {"body length", func(b []byte) []byte { binary.BigEndian.PutUint32(b[8:12], uint32(len(b))); return b }}, | |
| 51 | + | {"crc", func(b []byte) []byte { b[12] ^= 1; return b }}, | |
| 52 | + | {"trailing", func(b []byte) []byte { return append(b, 0) }}, | |
| 53 | + | {"body name", func(b []byte) []byte { b[26] ^= 1; binary.BigEndian.PutUint32(b[12:16], checksum(b[16:])); return b }}, | |
| 54 | + | {"body generation", func(b []byte) []byte { b[16] ^= 1; binary.BigEndian.PutUint32(b[12:16], checksum(b[16:])); return b }}, | |
| 55 | + | {"body node kind", func(b []byte) []byte { b[65] = 99; binary.BigEndian.PutUint32(b[12:16], checksum(b[16:])); return b }}, | |
| 56 | + | } | |
| 57 | + | for _, tc := range mutations { | |
| 58 | + | t.Run(tc.name, func(t *testing.T) { | |
| 59 | + | candidate := tc.edit(append([]byte(nil), encoded...)) | |
| 60 | + | _, decodeErr := UnmarshalManifest(candidate) | |
| 61 | + | preflightErr := preflightManifestEnvelope(candidate) | |
| 62 | + | uncheckedErr := decodeManifestEnvelope(candidate) | |
| 63 | + | if (decodeErr == nil) != (preflightErr == nil) || (preflightErr == nil) != (uncheckedErr == nil) { | |
| 64 | + | t.Fatalf("preflight error = %v, unchecked decode error = %v, decode error = %v", preflightErr, uncheckedErr, decodeErr) | |
| 65 | + | } | |
| 66 | + | }) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestManifestObjectRefKinds(t *testing.T) { | |
| 71 | + | for _, tc := range []struct { | |
| 72 | + | name string | |
| 73 | + | got ObjectKind | |
| 74 | + | want ObjectKind | |
| 75 | + | valid bool | |
| 76 | + | }{ | |
| 77 | + | {"chunk", HistoryChunk, HistoryChunk, true}, | |
| 78 | + | {"tail", HistoryTail, HistoryTail, true}, | |
| 79 | + | {"visible", Visible, Visible, true}, | |
| 80 | + | {"invalid", ObjectKind(99), Visible, false}, | |
| 81 | + | {"wrong role", HistoryTail, Visible, false}, | |
| 82 | + | } { | |
| 83 | + | t.Run(tc.name, func(t *testing.T) { | |
| 84 | + | var w payloadWriter | |
| 85 | + | writeObjectRef(&w, ObjectRef{Kind: tc.got, Digest: SnapshotDigest{1}, Size: minObjectEnvelopeSize}) | |
| 86 | + | err := preflightObjectRef(&payloadReader{b: w.b}, tc.want) | |
| 87 | + | if (err == nil) != tc.valid { | |
| 88 | + | t.Fatalf("preflightObjectRef() error = %v, valid=%v", err, tc.valid) | |
| 89 | + | } | |
| 90 | + | }) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestManifestPreflightHostileDeclarations(t *testing.T) { | |
| 95 | + | t.Run("tab count", func(t *testing.T) { | |
| 96 | + | var w payloadWriter | |
| 97 | + | w.putUint64(1) | |
| 98 | + | if err := w.putString("name"); err != nil { | |
| 99 | + | t.Fatalf("putString(name): %v", err) | |
| 100 | + | } | |
| 101 | + | w.putUint64(1) | |
| 102 | + | w.putUint16(0) | |
| 103 | + | w.putUint16(^uint16(0)) | |
| 104 | + | if err := preflightManifest(w.b); !errors.Is(err, ErrInvalidData) { | |
| 105 | + | t.Fatalf("preflightManifest() error = %v", err) | |
| 106 | + | } | |
| 107 | + | }) | |
| 108 | + | for _, tc := range []struct { | |
| 109 | + | name string | |
| 110 | + | fn func(*payloadReader, *manifestPreflightBudget) error | |
| 111 | + | body []byte | |
| 112 | + | }{ | |
| 113 | + | {"string length", func(r *payloadReader, b *manifestPreflightBudget) error { return skipManifestString(r, b) }, []byte{0, 1}}, | |
| 114 | + | {"tree children", func(r *payloadReader, b *manifestPreflightBudget) error { | |
| 115 | + | return preflightManifestNode(r, 0, true, b, nil) | |
| 116 | + | }, []byte{manifestNodeSplit, byte(layout.Horizontal), 0xff, 0xff}}, | |
| 117 | + | {"nil child", func(r *payloadReader, b *manifestPreflightBudget) error { | |
| 118 | + | return preflightManifestNode(r, 1, false, b, nil) | |
| 119 | + | }, []byte{manifestNodeNil}}, | |
| 120 | + | {"process declaration", func(r *payloadReader, b *manifestPreflightBudget) error { return preflightManifestProcess(r, b) }, []byte{processPresent, 0, 1, 0, 2, 'x'}}, | |
| 121 | + | } { | |
| 122 | + | t.Run(tc.name, func(t *testing.T) { | |
| 123 | + | err := tc.fn(&payloadReader{b: tc.body}, &manifestPreflightBudget{}) | |
| 124 | + | if err == nil { | |
| 125 | + | t.Fatal("hostile declaration accepted") | |
| 126 | + | } | |
| 127 | + | }) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func TestSkipManifestStringDoesNotAllocate(t *testing.T) { | |
| 132 | + | raw := make([]byte, 2+1024) | |
| 133 | + | binary.BigEndian.PutUint16(raw, 1024) | |
| 134 | + | if allocations := testing.AllocsPerRun(100, func() { | |
| 135 | + | r := payloadReader{b: raw} | |
| 136 | + | if err := skipManifestString(&r, &manifestPreflightBudget{}); err != nil { | |
| 137 | + | t.Fatal(err) | |
| 138 | + | } | |
| 139 | + | }); allocations != 0 { | |
| 140 | + | t.Fatalf("skipManifestString allocations = %v, want 0", allocations) | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func preflightManifestEnvelope(encoded []byte) error { | |
| 145 | + | body, err := unmarshalManifestEnvelope(encoded, manifestMagic) | |
| 146 | + | if err != nil { | |
| 147 | + | return err | |
| 148 | + | } | |
| 149 | + | return preflightManifest(body) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func decodeManifestEnvelope(encoded []byte) error { | |
| 153 | + | body, err := unmarshalManifestEnvelope(encoded, manifestMagic) | |
| 154 | + | if err != nil { | |
| 155 | + | return err | |
| 156 | + | } | |
| 157 | + | _, err = decodeManifest(body) | |
| 158 | + | return err | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func checksum(body []byte) uint32 { | |
| 162 | + | return crc32.ChecksumIEEE(body) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func testManifest(tree *layout.Tree, process *Process) Manifest { | |
| 166 | + | return Manifest{Name: "named", Tabs: []ManifestTab{{ | |
| 167 | + | StableID: "tab", Cols: 80, Rows: 24, Focus: "one", Tree: tree, | |
| 168 | + | Panes: []ManifestPane{ | |
| 169 | + | {ID: "one", StableID: "one", Cwd: "/one", Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: SnapshotDigest{1}, Size: minObjectEnvelopeSize}}, Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{3}, Size: minObjectEnvelopeSize}, Process: process}, | |
| 170 | + | {ID: "two", StableID: "two", Cwd: "/two", Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{4}, Size: minObjectEnvelopeSize}, Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{5}, Size: minObjectEnvelopeSize}}, | |
| 171 | + | }, | |
| 172 | + | }}} | |
| 173 | + | } | |
A internal/usecase/snapshot/manifest_test.go +183 -0
| @@ -0,0 +1,183 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "crypto/sha256" | |
| 6 | + | "errors" | |
| 7 | + | "math" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestManifestCodecUsesGenerationAndOrderedTypedReferences(t *testing.T) { | |
| 15 | + | chunk, err := MarshalObject(HistoryChunk, []byte("history")) | |
| 16 | + | if err != nil { | |
| 17 | + | t.Fatal(err) | |
| 18 | + | } | |
| 19 | + | tail, err := MarshalObject(HistoryTail, []byte("tail")) | |
| 20 | + | if err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | visible, err := MarshalObject(Visible, []byte("visible")) | |
| 24 | + | if err != nil { | |
| 25 | + | t.Fatal(err) | |
| 26 | + | } | |
| 27 | + | manifest := Manifest{Generation: 7, Name: "named", CreatedAt: 9, Active: 0, Tabs: []ManifestTab{{ | |
| 28 | + | StableID: "tab", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "pane", Tree: layout.NewTree("pane"), | |
| 29 | + | Panes: []ManifestPane{{ID: "pane", StableID: "pane-stable", Cwd: "/tmp", | |
| 30 | + | Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: chunk.Digest, Size: uint32(len(chunk.Data))}}, | |
| 31 | + | Tail: ObjectRef{Kind: HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, | |
| 32 | + | Visible: ObjectRef{Kind: Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}, | |
| 33 | + | }}, | |
| 34 | + | }}} | |
| 35 | + | encoded, err := MarshalManifest(manifest) | |
| 36 | + | if err != nil { | |
| 37 | + | t.Fatal(err) | |
| 38 | + | } | |
| 39 | + | got, err := UnmarshalManifest(encoded) | |
| 40 | + | if err != nil { | |
| 41 | + | t.Fatal(err) | |
| 42 | + | } | |
| 43 | + | if got.Generation != manifest.Generation || got.Tabs[0].Panes[0].Tail != manifest.Tabs[0].Panes[0].Tail { | |
| 44 | + | t.Fatalf("round trip = %#v", got) | |
| 45 | + | } | |
| 46 | + | for n := range len(encoded) { | |
| 47 | + | if _, err := UnmarshalManifest(encoded[:n]); err == nil { | |
| 48 | + | t.Fatalf("prefix %d accepted", n) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | if _, err := UnmarshalManifest(append(encoded, 0)); !errors.Is(err, ErrTrailingBytes) { | |
| 52 | + | t.Fatalf("trailing data error = %v", err) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | head := Head{Generation: 7, ManifestDigest: sha256.Sum256(encoded)} | |
| 56 | + | if head.Generation != manifest.Generation || head.ManifestDigest != ManifestDigest(encoded) { | |
| 57 | + | t.Fatalf("head = %#v", head) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestObjectCodecReturnsContentAddressedObjectAndPayload(t *testing.T) { | |
| 62 | + | object, err := MarshalObject(Visible, []byte("canonical-vt")) | |
| 63 | + | if err != nil { | |
| 64 | + | t.Fatal(err) | |
| 65 | + | } | |
| 66 | + | if object.Digest != sha256.Sum256(object.Data) { | |
| 67 | + | t.Fatalf("digest does not address VEVO bytes") | |
| 68 | + | } | |
| 69 | + | kind, payload, err := UnmarshalObject(object.Data) | |
| 70 | + | if err != nil || kind != Visible || !bytes.Equal(payload, []byte("canonical-vt")) { | |
| 71 | + | t.Fatalf("round trip = %v %q %v", kind, payload, err) | |
| 72 | + | } | |
| 73 | + | kind, payload, err = PreflightObject(object.Data) | |
| 74 | + | if err != nil || kind != Visible || !bytes.Equal(payload, []byte("canonical-vt")) { | |
| 75 | + | t.Fatalf("preflight = %v %q %v", kind, payload, err) | |
| 76 | + | } | |
| 77 | + | for n := range len(object.Data) { | |
| 78 | + | if _, _, err := UnmarshalObject(object.Data[:n]); err == nil { | |
| 79 | + | t.Fatalf("prefix %d accepted", n) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | if _, _, err := UnmarshalObject(append(object.Data, 0)); !errors.Is(err, ErrTrailingBytes) { | |
| 83 | + | t.Fatalf("trailing data error = %v", err) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestManifestDimensionBoundaries(t *testing.T) { | |
| 88 | + | ref := ObjectRef{Kind: HistoryTail, Digest: ports.SnapshotDigest{1}, Size: minObjectEnvelopeSize} | |
| 89 | + | visible := ObjectRef{Kind: Visible, Digest: ports.SnapshotDigest{2}, Size: minObjectEnvelopeSize} | |
| 90 | + | for _, tc := range []struct { | |
| 91 | + | name string | |
| 92 | + | cols, rows uint16 | |
| 93 | + | wantErr bool | |
| 94 | + | }{ | |
| 95 | + | {"max cols safe rows", math.MaxUint16, 1, false}, | |
| 96 | + | {"max rows safe cols", 1, math.MaxUint16, false}, | |
| 97 | + | {"hostile product", math.MaxUint16, math.MaxUint16, true}, | |
| 98 | + | } { | |
| 99 | + | t.Run(tc.name, func(t *testing.T) { | |
| 100 | + | m := Manifest{Name: "safe", Tabs: []ManifestTab{{Cols: tc.cols, Rows: tc.rows, Panes: []ManifestPane{{ID: "p", Tail: ref, Visible: visible}}}}} | |
| 101 | + | _, err := MarshalManifest(m) | |
| 102 | + | if (err != nil) != tc.wantErr { | |
| 103 | + | t.Fatalf("MarshalManifest() error = %v, want error=%v", err, tc.wantErr) | |
| 104 | + | } | |
| 105 | + | }) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestManifestRejectsImpossibleObjectEnvelopeSize(t *testing.T) { | |
| 110 | + | manifest := Manifest{Name: "named", Tabs: []ManifestTab{{Cols: 1, Rows: 1, Panes: []ManifestPane{{ | |
| 111 | + | ID: "pane", | |
| 112 | + | Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: 1}, | |
| 113 | + | Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, | |
| 114 | + | }}}}} | |
| 115 | + | if _, err := MarshalManifest(manifest); !errors.Is(err, ErrInvalidData) { | |
| 116 | + | t.Fatalf("MarshalManifest() error = %v, want invalid data", err) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestObjectRefEnvelopeSizeBoundaries(t *testing.T) { | |
| 121 | + | const ( | |
| 122 | + | minimum = manifestHeaderSize + 1 + 4 + 1 | |
| 123 | + | maximum = manifestHeaderSize + maxDecodedBodySize | |
| 124 | + | ) | |
| 125 | + | if minObjectEnvelopeSize != minimum || maxObjectEnvelopeSize != maximum { | |
| 126 | + | t.Fatalf("object envelope range = [%d, %d], want [%d, %d]", minObjectEnvelopeSize, maxObjectEnvelopeSize, minimum, maximum) | |
| 127 | + | } | |
| 128 | + | object, err := MarshalObject(HistoryTail, []byte{0}) | |
| 129 | + | if err != nil { | |
| 130 | + | t.Fatalf("MarshalObject() error = %v", err) | |
| 131 | + | } | |
| 132 | + | if len(object.Data) != minimum { | |
| 133 | + | t.Fatalf("MarshalObject() size = %d, want %d", len(object.Data), minimum) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | for _, tc := range []struct { | |
| 137 | + | name string | |
| 138 | + | size uint32 | |
| 139 | + | valid bool | |
| 140 | + | }{ | |
| 141 | + | {name: "one below minimum", size: minimum - 1}, | |
| 142 | + | {name: "minimum", size: minimum, valid: true}, | |
| 143 | + | {name: "maximum declaration", size: maximum, valid: true}, | |
| 144 | + | {name: "one above maximum", size: maximum + 1}, | |
| 145 | + | } { | |
| 146 | + | t.Run(tc.name, func(t *testing.T) { | |
| 147 | + | manifest := manifestWithTailSize(tc.size) | |
| 148 | + | encoded, err := MarshalManifest(manifest) | |
| 149 | + | if tc.valid { | |
| 150 | + | if err != nil { | |
| 151 | + | t.Fatalf("MarshalManifest() error = %v", err) | |
| 152 | + | } | |
| 153 | + | if _, err := UnmarshalManifest(encoded); err != nil { | |
| 154 | + | t.Fatalf("UnmarshalManifest() error = %v", err) | |
| 155 | + | } | |
| 156 | + | } else if !errors.Is(err, ErrInvalidData) { | |
| 157 | + | t.Fatalf("MarshalManifest() error = %v, want invalid data", err) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | var w payloadWriter | |
| 161 | + | writeObjectRef(&w, ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: tc.size}) | |
| 162 | + | err = preflightObjectRef(&payloadReader{b: w.b}, HistoryTail) | |
| 163 | + | if tc.valid && err != nil { | |
| 164 | + | t.Fatalf("preflightObjectRef() error = %v", err) | |
| 165 | + | } | |
| 166 | + | if !tc.valid && !errors.Is(err, ErrInvalidData) { | |
| 167 | + | t.Fatalf("preflightObjectRef() error = %v, want invalid data", err) | |
| 168 | + | } | |
| 169 | + | }) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func manifestWithTailSize(size uint32) Manifest { | |
| 174 | + | return Manifest{Name: "named", Tabs: []ManifestTab{{ | |
| 175 | + | Cols: 1, | |
| 176 | + | Rows: 1, | |
| 177 | + | Panes: []ManifestPane{{ | |
| 178 | + | ID: "pane", | |
| 179 | + | Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: size}, | |
| 180 | + | Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, | |
| 181 | + | }}, | |
| 182 | + | }}} | |
| 183 | + | } | |
M internal/usecase/snapshot/preflight.go +6 -3
| @@ -198,7 +198,7 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin | |||
| 198 | 198 | if err != nil { | |
| 199 | 199 | return err | |
| 200 | 200 | } | |
| 201 | - | if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 { | |
| 201 | + | if n > maxSnapshotObjects || uint64(n) > uint64(len(r.b))/4 { | |
| 202 | 202 | return fmt.Errorf("%w: sealed chunks", ErrInvalidData) | |
| 203 | 203 | } | |
| 204 | 204 | for range n { | |
| @@ -214,7 +214,10 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin | |||
| 214 | 214 | if err != nil { | |
| 215 | 215 | return err | |
| 216 | 216 | } | |
| 217 | - | if tail.Chunks != 0 || tail.Rows != 0 || tail.Cells != 0 { | |
| 217 | + | // A capture keeps the live mutable tail separate from immutable sealed | |
| 218 | + | // chunks. It may therefore contain one partial chunk (or the canonical | |
| 219 | + | // empty tail), but never a sequence of sealed chunks. | |
| 220 | + | if tail.Chunks > 1 { | |
| 218 | 221 | return fmt.Errorf("%w: tail blob role", ErrInvalidData) | |
| 219 | 222 | } | |
| 220 | 223 | visible, err := preflightBlob(r, totals, blobs, false, budget) | |
| @@ -235,7 +238,7 @@ func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, hist | |||
| 235 | 238 | if n == 0 || uint64(n) > uint64(len(r.b)) || uint64(n) > maxSnapshotBytes { | |
| 236 | 239 | return vt.DecodeStats{}, fmt.Errorf("%w: VT blob length", ErrInvalidData) | |
| 237 | 240 | } | |
| 238 | - | if *blobs >= maxSnapshotBlobs { | |
| 241 | + | if *blobs >= maxSnapshotObjects { | |
| 239 | 242 | return vt.DecodeStats{}, fmt.Errorf("%w: too many VT blobs", ErrInvalidData) | |
| 240 | 243 | } | |
| 241 | 244 | blob := r.b[:n] | |
M internal/usecase/snapshot/preflight_acceptance_test.go +1 -1
| @@ -33,7 +33,7 @@ func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T | |||
| 33 | 33 | }, | |
| 34 | 34 | } { | |
| 35 | 35 | t.Run(tt.name, func(t *testing.T) { | |
| 36 | - | encoded, err := Marshal(Session{Name: "hostile", Tabs: []Tab{tt.tab}}) | |
| 36 | + | encoded, err := marshalTest(Session{Name: "hostile", Tabs: []Tab{tt.tab}}) | |
| 37 | 37 | if err != nil { | |
| 38 | 38 | t.Fatalf("Marshal: %v", err) | |
| 39 | 39 | } | |
M pkg/vt/benchmark_test.go +25 -5
| @@ -8,9 +8,10 @@ import ( | |||
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | var ( | |
| 11 | - | benchmarkHistoryViewSink HistoryView | |
| 12 | - | benchmarkHistoryBlobSink []byte | |
| 13 | - | benchmarkHistoryBlobsSink [][]byte | |
| 11 | + | benchmarkHistoryViewSink HistoryView | |
| 12 | + | benchmarkHistorySnapshotSink HistorySnapshotView | |
| 13 | + | benchmarkHistoryBlobSink []byte | |
| 14 | + | benchmarkHistoryBlobsSink [][]byte | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | // TestHistoryViewPartialTailAllocationBounded guards the capture property used | |
| @@ -75,6 +76,25 @@ func BenchmarkHistoryView10KRowsPartialTail(b *testing.B) { | |||
| 75 | 76 | b.ReportMetric(float64(10_000%256), "tailrows/view") | |
| 76 | 77 | } | |
| 77 | 78 | ||
| 79 | + | func BenchmarkHistorySnapshotView10KRowsPartialTail(b *testing.B) { | |
| 80 | + | history := benchmarkHistory(b, 10_000, 120, 256) | |
| 81 | + | if got, want := len(history.tail), 16; got != want { | |
| 82 | + | b.Fatalf("partial tail rows = %d, want %d", got, want) | |
| 83 | + | } | |
| 84 | + | sealed := history.View().Chunk(0) | |
| 85 | + | ||
| 86 | + | b.ReportAllocs() | |
| 87 | + | b.ResetTimer() | |
| 88 | + | for b.Loop() { | |
| 89 | + | benchmarkHistorySnapshotSink = history.SnapshotView() | |
| 90 | + | } | |
| 91 | + | b.StopTimer() | |
| 92 | + | if got := benchmarkHistorySnapshotSink; got.Chunk(0) != sealed || got.Tail().Len() != 16 { | |
| 93 | + | b.Fatal("snapshot did not preserve chunk identity and tail rows") | |
| 94 | + | } | |
| 95 | + | b.ReportMetric(float64(benchmarkHistorySnapshotSink.ChunkCount()), "sealedchunks/view") | |
| 96 | + | } | |
| 97 | + | ||
| 78 | 98 | func BenchmarkMarshalHistoryChunk256x120(b *testing.B) { | |
| 79 | 99 | history := benchmarkHistory(b, 256, 120, 256) | |
| 80 | 100 | chunk := history.SealAndView().Chunk(0) | |
| @@ -155,7 +175,7 @@ func benchmarkHistory(t testing.TB, rows, width, chunkRows int) *History { | |||
| 155 | 175 | for col := range cells { | |
| 156 | 176 | cells[col] = renderer.Cell{Rune: rune('a' + (row+col)%26)} | |
| 157 | 177 | } | |
| 158 | - | history.Append(cells) | |
| 178 | + | requireHistoryAppend(t, history, cells) | |
| 159 | 179 | } | |
| 160 | 180 | return history | |
| 161 | 181 | } | |
| @@ -173,7 +193,7 @@ func benchmarkScreenResizeReflow(b *testing.B, historyRows int) { | |||
| 173 | 193 | s := NewScreenWithHistory(120, 40, HistoryConfig{MaxRows: historyRows + 500}) | |
| 174 | 194 | if historyRows > 0 { | |
| 175 | 195 | for range historyRows { | |
| 176 | - | s.history.Append(make([]renderer.Cell, 120)) | |
| 196 | + | requireHistoryAppend(b, s.history, make([]renderer.Cell, 120)) | |
| 177 | 197 | } | |
| 178 | 198 | } | |
| 179 | 199 | s.Write(bytes.Repeat([]byte("x"), 120*39)) | |
M pkg/vt/history.go +165 -17
| @@ -1,10 +1,20 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "math" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // ErrHistoryRowTooWide is returned when a row cannot fit within the configured | |
| 11 | + | // cell budget. The history is not modified when this error is returned. | |
| 12 | + | var ErrHistoryRowTooWide = errors.New("history row exceeds cell capacity") | |
| 4 | 13 | ||
| 5 | 14 | // HistoryConfig controls the bounded terminal history retained by a Screen. | |
| 6 | 15 | type HistoryConfig struct { | |
| 7 | 16 | MaxRows int | |
| 17 | + | MaxCells int | |
| 8 | 18 | ChunkRows int | |
| 9 | 19 | } | |
| 10 | 20 | ||
| @@ -13,10 +23,12 @@ type HistoryConfig struct { | |||
| 13 | 23 | // appends. | |
| 14 | 24 | type History struct { | |
| 15 | 25 | maxRows int | |
| 26 | + | maxCells int | |
| 16 | 27 | chunkRows int | |
| 17 | 28 | chunks []*HistoryChunk | |
| 18 | 29 | tail [][]renderer.Cell | |
| 19 | 30 | rows int | |
| 31 | + | cells int | |
| 20 | 32 | } | |
| 21 | 33 | ||
| 22 | 34 | // HistoryChunk is an immutable group of history rows. Its identity is stable | |
| @@ -30,6 +42,17 @@ type HistoryChunk struct { | |||
| 30 | 42 | type HistoryView struct { | |
| 31 | 43 | chunks []*HistoryChunk | |
| 32 | 44 | rows int | |
| 45 | + | cells int | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // HistorySnapshotView captures sealed history chunks and the mutable tail | |
| 49 | + | // independently. Sealed chunks are shared by identity; Tail is owned by the | |
| 50 | + | // view and can be serialized without rotating the live tail into a chunk. | |
| 51 | + | type HistorySnapshotView struct { | |
| 52 | + | chunks []*HistoryChunk | |
| 53 | + | tail [][]renderer.Cell | |
| 54 | + | rows int | |
| 55 | + | cells int | |
| 33 | 56 | } | |
| 34 | 57 | ||
| 35 | 58 | func NewHistory(config HistoryConfig) *History { | |
| @@ -42,20 +65,39 @@ func NewHistory(config HistoryConfig) *History { | |||
| 42 | 65 | } | |
| 43 | 66 | chunkRows = min(chunkRows, 256) | |
| 44 | 67 | chunkRows = min(chunkRows, config.MaxRows) | |
| 45 | - | return &History{maxRows: config.MaxRows, chunkRows: chunkRows} | |
| 68 | + | maxCells := config.MaxCells | |
| 69 | + | if maxCells <= 0 { | |
| 70 | + | maxCells = defaultHistoryMaxCells(config.MaxRows) | |
| 71 | + | } | |
| 72 | + | return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows} | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func defaultHistoryMaxCells(maxRows int) int { | |
| 76 | + | if maxRows > math.MaxInt/160 { | |
| 77 | + | return math.MaxInt | |
| 78 | + | } | |
| 79 | + | return maxRows * 160 | |
| 46 | 80 | } | |
| 47 | 81 | ||
| 48 | 82 | // Append records a copy of row. Once a chunk is full it is sealed forever. | |
| 49 | - | func (h *History) Append(row []renderer.Cell) { | |
| 83 | + | // Rows wider than the total cell capacity are rejected without mutation. | |
| 84 | + | func (h *History) Append(row []renderer.Cell) error { | |
| 50 | 85 | if h == nil || h.maxRows == 0 { | |
| 51 | - | return | |
| 86 | + | return nil | |
| 52 | 87 | } | |
| 88 | + | if len(row) > h.maxCells { | |
| 89 | + | return ErrHistoryRowTooWide | |
| 90 | + | } | |
| 91 | + | // Make room before adding so Cells cannot overflow even when the default | |
| 92 | + | // capacity is MaxInt. | |
| 93 | + | h.evictFor(len(row)) | |
| 53 | 94 | h.tail = append(h.tail, append([]renderer.Cell(nil), row...)) | |
| 54 | 95 | h.rows++ | |
| 96 | + | h.cells += len(row) | |
| 55 | 97 | if len(h.tail) == h.chunkRows { | |
| 56 | 98 | h.sealTail() | |
| 57 | 99 | } | |
| 58 | - | h.evict() | |
| 100 | + | return nil | |
| 59 | 101 | } | |
| 60 | 102 | ||
| 61 | 103 | func (h *History) sealTail() { | |
| @@ -66,13 +108,53 @@ func (h *History) sealTail() { | |||
| 66 | 108 | h.tail = nil | |
| 67 | 109 | } | |
| 68 | 110 | ||
| 111 | + | // normalizeTail seals complete chunks so the mutable tail remains shorter than | |
| 112 | + | // chunkRows. Restored snapshots may have been written with a different chunk | |
| 113 | + | // size than the current history configuration. | |
| 114 | + | func (h *History) normalizeTail() { | |
| 115 | + | for h.chunkRows > 0 && len(h.tail) >= h.chunkRows { | |
| 116 | + | h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail[:h.chunkRows]}) | |
| 117 | + | h.tail = h.tail[h.chunkRows:] | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 69 | 121 | func (h *History) evict() { | |
| 70 | - | for h.rows > h.maxRows && len(h.chunks) > 0 { | |
| 71 | - | evicted := h.chunks[0] | |
| 72 | - | h.rows -= len(evicted.rows) | |
| 73 | - | copy(h.chunks, h.chunks[1:]) | |
| 74 | - | h.chunks[len(h.chunks)-1] = nil | |
| 75 | - | h.chunks = h.chunks[:len(h.chunks)-1] | |
| 122 | + | h.evictUntil(0, 0) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // evictFor discards oldest rows until another row using cellCount cells can fit. | |
| 126 | + | func (h *History) evictFor(cellCount int) { | |
| 127 | + | h.evictUntil(1, cellCount) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // evictUntil makes room for rowCount rows and cellCount cells without | |
| 131 | + | // overflowing either accounting total. | |
| 132 | + | func (h *History) evictUntil(rowCount, cellCount int) { | |
| 133 | + | for h.rows > h.maxRows-rowCount || h.cells > h.maxCells-cellCount { | |
| 134 | + | if len(h.chunks) > 0 { | |
| 135 | + | chunk := h.chunks[0] | |
| 136 | + | row := chunk.rows[0] | |
| 137 | + | h.rows-- | |
| 138 | + | h.cells -= len(row) | |
| 139 | + | if len(chunk.rows) == 1 { | |
| 140 | + | copy(h.chunks, h.chunks[1:]) | |
| 141 | + | h.chunks[len(h.chunks)-1] = nil | |
| 142 | + | h.chunks = h.chunks[:len(h.chunks)-1] | |
| 143 | + | } else { | |
| 144 | + | // Preserve cell storage while replacing only the chunk wrapper: a | |
| 145 | + | // retained view may still refer to the original immutable chunk. | |
| 146 | + | h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:]} | |
| 147 | + | } | |
| 148 | + | continue | |
| 149 | + | } | |
| 150 | + | if len(h.tail) == 0 { | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | row := h.tail[0] | |
| 154 | + | h.rows-- | |
| 155 | + | h.cells -= len(row) | |
| 156 | + | h.tail[0] = nil | |
| 157 | + | h.tail = h.tail[1:] | |
| 76 | 158 | } | |
| 77 | 159 | } | |
| 78 | 160 | ||
| @@ -93,13 +175,34 @@ func (h *History) View() HistoryView { | |||
| 93 | 175 | } | |
| 94 | 176 | chunks := append([]*HistoryChunk(nil), h.chunks...) | |
| 95 | 177 | if len(h.tail) > 0 { | |
| 96 | - | rows := make([][]renderer.Cell, len(h.tail)) | |
| 97 | - | for i, row := range h.tail { | |
| 98 | - | rows[i] = append([]renderer.Cell(nil), row...) | |
| 99 | - | } | |
| 100 | - | chunks = append(chunks, &HistoryChunk{rows: rows}) | |
| 178 | + | chunks = append(chunks, &HistoryChunk{rows: cloneHistoryRows(h.tail)}) | |
| 179 | + | } | |
| 180 | + | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells} | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // SnapshotView captures history for persistence without sealing the mutable | |
| 184 | + | // tail. Sealed chunks are shared by identity and the tail is deeply copied. | |
| 185 | + | func (h *History) SnapshotView() HistorySnapshotView { | |
| 186 | + | if h == nil || h.rows == 0 { | |
| 187 | + | return HistorySnapshotView{} | |
| 188 | + | } | |
| 189 | + | return HistorySnapshotView{ | |
| 190 | + | chunks: append([]*HistoryChunk(nil), h.chunks...), | |
| 191 | + | tail: cloneHistoryRows(h.tail), | |
| 192 | + | rows: h.rows, | |
| 193 | + | cells: h.cells, | |
| 101 | 194 | } | |
| 102 | - | return HistoryView{chunks: chunks, rows: h.rows} | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func cloneHistoryRows(rows [][]renderer.Cell) [][]renderer.Cell { | |
| 198 | + | if len(rows) == 0 { | |
| 199 | + | return nil | |
| 200 | + | } | |
| 201 | + | copyRows := make([][]renderer.Cell, len(rows)) | |
| 202 | + | for i, row := range rows { | |
| 203 | + | copyRows[i] = append([]renderer.Cell(nil), row...) | |
| 204 | + | } | |
| 205 | + | return copyRows | |
| 103 | 206 | } | |
| 104 | 207 | ||
| 105 | 208 | // Len returns the currently retained history row count. | |
| @@ -110,6 +213,14 @@ func (h *History) Len() int { | |||
| 110 | 213 | return h.rows | |
| 111 | 214 | } | |
| 112 | 215 | ||
| 216 | + | // Cells returns the currently retained history cell count. | |
| 217 | + | func (h *History) Cells() int { | |
| 218 | + | if h == nil { | |
| 219 | + | return 0 | |
| 220 | + | } | |
| 221 | + | return h.cells | |
| 222 | + | } | |
| 223 | + | ||
| 113 | 224 | // Cap returns the configured bounded row capacity. | |
| 114 | 225 | func (h *History) Cap() int { | |
| 115 | 226 | if h == nil { | |
| @@ -118,7 +229,16 @@ func (h *History) Cap() int { | |||
| 118 | 229 | return h.maxRows | |
| 119 | 230 | } | |
| 120 | 231 | ||
| 232 | + | // CellCap returns the configured bounded cell capacity. | |
| 233 | + | func (h *History) CellCap() int { | |
| 234 | + | if h == nil { | |
| 235 | + | return 0 | |
| 236 | + | } | |
| 237 | + | return h.maxCells | |
| 238 | + | } | |
| 239 | + | ||
| 121 | 240 | func (v HistoryView) Len() int { return v.rows } | |
| 241 | + | func (v HistoryView) Cells() int { return v.cells } | |
| 122 | 242 | func (v HistoryView) ChunkCount() int { return len(v.chunks) } | |
| 123 | 243 | ||
| 124 | 244 | // Chunk returns the immutable chunk at i, or nil when i is out of range. | |
| @@ -161,3 +281,31 @@ func (v HistoryView) BorrowedRow(i int) []renderer.Cell { | |||
| 161 | 281 | } | |
| 162 | 282 | return nil | |
| 163 | 283 | } | |
| 284 | + | ||
| 285 | + | func (v HistorySnapshotView) Len() int { return v.rows } | |
| 286 | + | func (v HistorySnapshotView) Cells() int { return v.cells } | |
| 287 | + | func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) } | |
| 288 | + | ||
| 289 | + | // Chunk returns a sealed immutable chunk at i, or nil when i is out of range. | |
| 290 | + | func (v HistorySnapshotView) Chunk(i int) *HistoryChunk { | |
| 291 | + | if i < 0 || i >= len(v.chunks) { | |
| 292 | + | return nil | |
| 293 | + | } | |
| 294 | + | return v.chunks[i] | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // Tail returns an immutable view of the copied mutable tail. | |
| 298 | + | func (v HistorySnapshotView) Tail() HistoryView { | |
| 299 | + | if len(v.tail) == 0 { | |
| 300 | + | return HistoryView{} | |
| 301 | + | } | |
| 302 | + | return HistoryView{chunks: []*HistoryChunk{{rows: v.tail}}, rows: len(v.tail), cells: historyRowsCells(v.tail)} | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func historyRowsCells(rows [][]renderer.Cell) int { | |
| 306 | + | cells := 0 | |
| 307 | + | for _, row := range rows { | |
| 308 | + | cells += len(row) | |
| 309 | + | } | |
| 310 | + | return cells | |
| 311 | + | } | |
M pkg/vt/history_acceptance_test.go +1 -1
| @@ -62,7 +62,7 @@ func TestSnapshotCodecsPreserveEveryTerminalCellAcrossSealedTailAndVisible(t *te | |||
| 62 | 62 | makeRow('D', rgb), makeRow('E', indexed), makeRow('F', rgb), makeRow('G', indexed), | |
| 63 | 63 | } | |
| 64 | 64 | for _, row := range want { | |
| 65 | - | history.Append(row) | |
| 65 | + | requireHistoryAppend(t, history, row) | |
| 66 | 66 | } | |
| 67 | 67 | if got, wantChunks := history.View().ChunkCount(), 4; got != wantChunks { | |
| 68 | 68 | t.Fatalf("chunk count = %d, want three sealed chunks plus copied tail %d", got, wantChunks) | |
M pkg/vt/history_codec.go +6 -6
| @@ -46,7 +46,7 @@ func MarshalHistory(view HistoryView) ([]byte, error) { | |||
| 46 | 46 | for _, row := range chunk.rows { | |
| 47 | 47 | cellCount := uint64(len(row)) | |
| 48 | 48 | cellBytes, ok := historyCellByteCount(cellCount) | |
| 49 | - | if cellCount > maxHistoryRowCells || !ok || | |
| 49 | + | if !ok || | |
| 50 | 50 | !addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) || | |
| 51 | 51 | !addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) || | |
| 52 | 52 | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | |
| @@ -193,11 +193,11 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 193 | 193 | for range rowCount { | |
| 194 | 194 | cellCount, ok := p.uint32() | |
| 195 | 195 | cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount)) | |
| 196 | - | if !ok || uint64(cellCount) > maxHistoryRowCells || !validCellBytes || | |
| 197 | - | uint64(cellCount) > uint64(len(p.data))/historyCellBytes || | |
| 196 | + | if !ok || !validCellBytes || | |
| 198 | 197 | !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) || | |
| 199 | 198 | !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) || | |
| 200 | - | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | |
| 199 | + | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) || | |
| 200 | + | uint64(cellCount) > uint64(len(p.data))/historyCellBytes { | |
| 201 | 201 | return HistoryView{}, historyDecodeStats{}, false | |
| 202 | 202 | } | |
| 203 | 203 | ||
| @@ -205,7 +205,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 205 | 205 | if populate { | |
| 206 | 206 | row = make([]renderer.Cell, cellCount) | |
| 207 | 207 | } | |
| 208 | - | for i := uint32(0); i < cellCount; i++ { | |
| 208 | + | for i := range cellCount { | |
| 209 | 209 | cell, ok := p.cell() | |
| 210 | 210 | if !ok || !validHistoryCell(cell) { | |
| 211 | 211 | return HistoryView{}, historyDecodeStats{}, false | |
| @@ -225,7 +225,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 225 | 225 | if len(p.data) != 0 { | |
| 226 | 226 | return HistoryView{}, historyDecodeStats{}, false | |
| 227 | 227 | } | |
| 228 | - | return HistoryView{chunks: chunks, rows: int(stats.rows)}, stats, true | |
| 228 | + | return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells)}, stats, true | |
| 229 | 229 | } | |
| 230 | 230 | ||
| 231 | 231 | func historyCellByteCount(cellCount uint64) (uint64, bool) { | |
M pkg/vt/history_codec_test.go +54 -12
| @@ -2,6 +2,8 @@ package vt | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/binary" | |
| 5 | + | "fmt" | |
| 6 | + | "math" | |
| 5 | 7 | "testing" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -55,7 +57,7 @@ func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) { | |||
| 55 | 57 | t.Run(tt.name, func(t *testing.T) { | |
| 56 | 58 | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |
| 57 | 59 | for _, row := range tt.rows { | |
| 58 | - | history.Append(row) | |
| 60 | + | requireHistoryAppend(t, history, row) | |
| 59 | 61 | } | |
| 60 | 62 | ||
| 61 | 63 | encoded, err := MarshalHistory(history.View()) | |
| @@ -73,8 +75,8 @@ func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) { | |||
| 73 | 75 | ||
| 74 | 76 | func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) { | |
| 75 | 77 | history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 2}) | |
| 76 | - | history.Append(historyRow("AAAA")) | |
| 77 | - | history.Append(historyRow("BBBB")) | |
| 78 | + | requireHistoryAppend(t, history, historyRow("AAAA")) | |
| 79 | + | requireHistoryAppend(t, history, historyRow("BBBB")) | |
| 78 | 80 | encoded, err := MarshalHistory(history.View()) | |
| 79 | 81 | if err != nil { | |
| 80 | 82 | t.Fatalf("marshal history: %v", err) | |
| @@ -123,10 +125,7 @@ func TestChunkCodecSupportsRepresentativeHistory(t *testing.T) { | |||
| 123 | 125 | } | |
| 124 | 126 | ||
| 125 | 127 | func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) { | |
| 126 | - | const ( | |
| 127 | - | supportedRows = 12_000 | |
| 128 | - | supportedWidth = 160 | |
| 129 | - | ) | |
| 128 | + | const supportedRows = 12_000 | |
| 130 | 129 | tests := []struct { | |
| 131 | 130 | name string | |
| 132 | 131 | view HistoryView | |
| @@ -137,11 +136,6 @@ func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) { | |||
| 137 | 136 | view: historyViewWithDimensions(supportedRows+1, 0), | |
| 138 | 137 | data: historyPayloadWithDimensions(supportedRows+1, 0), | |
| 139 | 138 | }, | |
| 140 | - | { | |
| 141 | - | name: "too many cells in a row", | |
| 142 | - | view: historyViewWithDimensions(1, supportedWidth+1), | |
| 143 | - | data: historyPayloadWithDimensions(1, supportedWidth+1), | |
| 144 | - | }, | |
| 145 | 139 | } | |
| 146 | 140 | ||
| 147 | 141 | for _, tt := range tests { | |
| @@ -156,6 +150,41 @@ func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) { | |||
| 156 | 150 | } | |
| 157 | 151 | } | |
| 158 | 152 | ||
| 153 | + | func TestChunkCodecRejectsAggregateCellDeclarationBeforePayloadAllocation(t *testing.T) { | |
| 154 | + | data := aggregateCellLimitDeclaration() | |
| 155 | + | if len(data) >= 1024 { | |
| 156 | + | t.Fatalf("aggregate declaration length = %d, want compact fixture", len(data)) | |
| 157 | + | } | |
| 158 | + | if _, err := PreflightHistoryBlob(data); err == nil { | |
| 159 | + | t.Fatal("preflight accepted aggregate cell declaration") | |
| 160 | + | } | |
| 161 | + | if _, err := UnmarshalHistory(data); err == nil { | |
| 162 | + | t.Fatal("unmarshal accepted aggregate cell declaration") | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestChunkCodecAcceptsWideRowsWithinAggregateBudget(t *testing.T) { | |
| 167 | + | for _, width := range []int{161, 293, math.MaxUint16} { | |
| 168 | + | t.Run(fmt.Sprintf("width_%d", width), func(t *testing.T) { | |
| 169 | + | view := historyViewWithDimensions(1, width) | |
| 170 | + | encoded, err := MarshalHistory(view) | |
| 171 | + | if err != nil { | |
| 172 | + | t.Fatalf("marshal width %d: %v", width, err) | |
| 173 | + | } | |
| 174 | + | decoded, err := UnmarshalHistory(encoded) | |
| 175 | + | if err != nil { | |
| 176 | + | t.Fatalf("unmarshal width %d: %v", width, err) | |
| 177 | + | } | |
| 178 | + | if got := len(decoded.Row(0)); got != width { | |
| 179 | + | t.Fatalf("decoded width = %d, want %d", got, width) | |
| 180 | + | } | |
| 181 | + | if stats, err := PreflightHistoryBlob(encoded); err != nil || stats.Cells != uint64(width) { | |
| 182 | + | t.Fatalf("preflight stats = %+v, err = %v", stats, err) | |
| 183 | + | } | |
| 184 | + | }) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 159 | 188 | func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) { | |
| 160 | 189 | tests := []struct { | |
| 161 | 190 | name string | |
| @@ -213,6 +242,18 @@ func historyPayloadWithDimensions(rowCount, width int) []byte { | |||
| 213 | 242 | return data | |
| 214 | 243 | } | |
| 215 | 244 | ||
| 245 | + | // aggregateCellLimitDeclaration declares one row, which is within the row | |
| 246 | + | // budget, but its cells exceed the aggregate budget. It deliberately omits | |
| 247 | + | // cell payload bytes: the aggregate limit must reject it before allocation. | |
| 248 | + | func aggregateCellLimitDeclaration() []byte { | |
| 249 | + | data := make([]byte, 9, 21) | |
| 250 | + | copy(data, historyMagic) | |
| 251 | + | data[4] = historyVersion | |
| 252 | + | binary.BigEndian.PutUint32(data[5:], 1) | |
| 253 | + | data = binary.BigEndian.AppendUint32(data, 1) | |
| 254 | + | return binary.BigEndian.AppendUint32(data, uint32(maxHistoryCells+1)) | |
| 255 | + | } | |
| 256 | + | ||
| 216 | 257 | func hostileHistoryDeclarations(chunkCount, rowCount int) []byte { | |
| 217 | 258 | data := make([]byte, 9, 9+chunkCount*(4+rowCount*4)) | |
| 218 | 259 | copy(data, historyMagic) | |
| @@ -248,6 +289,7 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) { | |||
| 248 | 289 | {name: "invalid rune", data: invalidRune}, | |
| 249 | 290 | {name: "invalid underline style", data: invalidUnderlineStyle}, | |
| 250 | 291 | {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)}, | |
| 292 | + | {name: "aggregate cell budget", data: aggregateCellLimitDeclaration()}, | |
| 251 | 293 | {name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0}}, | |
| 252 | 294 | } | |
| 253 | 295 | for _, tt := range tests { | |
M pkg/vt/history_test.go +107 -9
| @@ -1,11 +1,20 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 5 | + | "math" | |
| 4 | 6 | "testing" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/bnema/vev/pkg/renderer" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 11 | + | func requireHistoryAppend(t testing.TB, history *History, row []renderer.Cell) { | |
| 12 | + | t.Helper() | |
| 13 | + | if err := history.Append(row); err != nil { | |
| 14 | + | t.Fatalf("append history row: %v", err) | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | ||
| 9 | 18 | func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) { | |
| 10 | 19 | tests := []struct { | |
| 11 | 20 | name string | |
| @@ -22,11 +31,11 @@ func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) { | |||
| 22 | 31 | want: []string{"cccc", "dddd", "eeee", "ffff"}, | |
| 23 | 32 | }, | |
| 24 | 33 | { | |
| 25 | - | name: "nonmultiple capacity evicts a whole oldest immutable chunk", | |
| 34 | + | name: "nonmultiple capacity evicts exactly the oldest row", | |
| 26 | 35 | maxRows: 5, | |
| 27 | 36 | chunkRows: 2, | |
| 28 | 37 | rows: []string{"0000", "1111", "2222", "3333", "4444", "5555"}, | |
| 29 | - | want: []string{"2222", "3333", "4444", "5555"}, | |
| 38 | + | want: []string{"1111", "2222", "3333", "4444", "5555"}, | |
| 30 | 39 | }, | |
| 31 | 40 | { | |
| 32 | 41 | name: "rows preserve append order across chunk boundaries", | |
| @@ -41,7 +50,7 @@ func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) { | |||
| 41 | 50 | t.Run(tt.name, func(t *testing.T) { | |
| 42 | 51 | history := NewHistory(HistoryConfig{MaxRows: tt.maxRows, ChunkRows: tt.chunkRows}) | |
| 43 | 52 | for _, text := range tt.rows { | |
| 44 | - | history.Append(historyRow(text)) | |
| 53 | + | requireHistoryAppend(t, history, historyRow(text)) | |
| 45 | 54 | } | |
| 46 | 55 | ||
| 47 | 56 | view := history.View() | |
| @@ -70,15 +79,15 @@ func TestHistoryTailRotationAndStableViews(t *testing.T) { | |||
| 70 | 79 | t.Run(tt.name, func(t *testing.T) { | |
| 71 | 80 | history := NewHistory(HistoryConfig{MaxRows: 6, ChunkRows: 2}) | |
| 72 | 81 | first := historyRow("AAAA") | |
| 73 | - | history.Append(first) | |
| 74 | - | history.Append(historyRow("BBBB")) | |
| 82 | + | requireHistoryAppend(t, history, first) | |
| 83 | + | requireHistoryAppend(t, history, historyRow("BBBB")) | |
| 75 | 84 | view := history.View() | |
| 76 | 85 | if got, want := view.ChunkCount(), 1; got != want { | |
| 77 | 86 | t.Fatalf("sealed chunk count = %d, want %d", got, want) | |
| 78 | 87 | } | |
| 79 | 88 | ||
| 80 | 89 | first[0].Rune = 'X' | |
| 81 | - | history.Append(historyRow("CCCC")) | |
| 90 | + | requireHistoryAppend(t, history, historyRow("CCCC")) | |
| 82 | 91 | ||
| 83 | 92 | if got, want := historyViewTexts(view), []string{"AAAA", "BBBB"}; !equalStrings(got, want) { | |
| 84 | 93 | t.Fatalf("stable view rows = %#v, want %#v", got, want) | |
| @@ -100,11 +109,11 @@ func TestHistoryReusesSealedChunkIdentityAcrossViews(t *testing.T) { | |||
| 100 | 109 | for _, tt := range tests { | |
| 101 | 110 | t.Run(tt.name, func(t *testing.T) { | |
| 102 | 111 | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |
| 103 | - | history.Append(historyRow("AAAA")) | |
| 104 | - | history.Append(historyRow("BBBB")) | |
| 112 | + | requireHistoryAppend(t, history, historyRow("AAAA")) | |
| 113 | + | requireHistoryAppend(t, history, historyRow("BBBB")) | |
| 105 | 114 | before := history.View() | |
| 106 | 115 | ||
| 107 | - | history.Append(historyRow("CCCC")) | |
| 116 | + | requireHistoryAppend(t, history, historyRow("CCCC")) | |
| 108 | 117 | after := history.View() | |
| 109 | 118 | ||
| 110 | 119 | if before.ChunkCount() == 0 || after.ChunkCount() == 0 { | |
| @@ -192,6 +201,95 @@ func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) { | |||
| 192 | 201 | } | |
| 193 | 202 | } | |
| 194 | 203 | ||
| 204 | + | func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) { | |
| 205 | + | history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2}) | |
| 206 | + | for _, text := range []string{"aa", "bbb", "c", "dd"} { | |
| 207 | + | if err := history.Append(historyRow(text)); err != nil { | |
| 208 | + | t.Fatalf("append %q: %v", text, err) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | view := history.View() | |
| 213 | + | if got, want := historyViewTexts(view), []string{"c", "dd"}; !equalStrings(got, want) { | |
| 214 | + | t.Fatalf("retained rows = %#v, want %#v", got, want) | |
| 215 | + | } | |
| 216 | + | if got, want := history.Len(), 2; got != want { | |
| 217 | + | t.Fatalf("retained rows = %d, want %d", got, want) | |
| 218 | + | } | |
| 219 | + | if got, want := history.Cells(), 3; got != want { | |
| 220 | + | t.Fatalf("retained cells = %d, want %d", got, want) | |
| 221 | + | } | |
| 222 | + | if got, want := view.Cells(), 3; got != want { | |
| 223 | + | t.Fatalf("view cells = %d, want %d", got, want) | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) { | |
| 228 | + | tests := []struct { | |
| 229 | + | name string | |
| 230 | + | history *History | |
| 231 | + | }{ | |
| 232 | + | {name: "nil history"}, | |
| 233 | + | {name: "zero-value history", history: &History{}}, | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | for _, tt := range tests { | |
| 237 | + | t.Run(tt.name, func(t *testing.T) { | |
| 238 | + | if err := tt.history.Append(historyRow("row")); err != nil { | |
| 239 | + | t.Fatalf("append error = %v, want nil", err) | |
| 240 | + | } | |
| 241 | + | if got := tt.history.Len(); got != 0 { | |
| 242 | + | t.Fatalf("retained rows = %d, want 0", got) | |
| 243 | + | } | |
| 244 | + | }) | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) { | |
| 249 | + | history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2}) | |
| 250 | + | if err := history.Append(historyRow("ok")); err != nil { | |
| 251 | + | t.Fatalf("append retained row: %v", err) | |
| 252 | + | } | |
| 253 | + | before := history.View() | |
| 254 | + | ||
| 255 | + | err := history.Append(historyRow("wide")) | |
| 256 | + | if !errors.Is(err, ErrHistoryRowTooWide) { | |
| 257 | + | t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err) | |
| 258 | + | } | |
| 259 | + | if got, want := historyViewTexts(history.View()), historyViewTexts(before); !equalStrings(got, want) { | |
| 260 | + | t.Fatalf("history mutated after rejected append: got %#v, want %#v", got, want) | |
| 261 | + | } | |
| 262 | + | if got, want := history.Cells(), 2; got != want { | |
| 263 | + | t.Fatalf("retained cells after rejected append = %d, want %d", got, want) | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T) { | |
| 268 | + | screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2, MaxCells: 3}) | |
| 269 | + | var events []string | |
| 270 | + | screen.OnLineEvicted = func(row []renderer.Cell) { events = append(events, rowText(row)) } | |
| 271 | + | copy(screen.Frame.Row(0), historyRow("AAAA")) | |
| 272 | + | copy(screen.Frame.Row(1), historyRow("BBBB")) | |
| 273 | + | screen.scrollUpRegion(0, 1, 1) | |
| 274 | + | ||
| 275 | + | if got := screen.History().Len(); got != 0 { | |
| 276 | + | t.Fatalf("oversized scrollback rows = %d, want 0", got) | |
| 277 | + | } | |
| 278 | + | if got, want := events, []string{"AAAA"}; !equalStrings(got, want) { | |
| 279 | + | t.Fatalf("scroll callbacks = %#v, want %#v", got, want) | |
| 280 | + | } | |
| 281 | + | if got := lineText(screen, 0); got != "BBBB" { | |
| 282 | + | t.Fatalf("screen did not scroll after history drop: row 0 = %q", got) | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) { | |
| 287 | + | history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1}) | |
| 288 | + | if got, want := history.CellCap(), math.MaxInt; got != want { | |
| 289 | + | t.Fatalf("default cell cap = %d, want %d", got, want) | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | ||
| 195 | 293 | func historyRow(text string) []renderer.Cell { | |
| 196 | 294 | row := make([]renderer.Cell, len(text)) | |
| 197 | 295 | for i, r := range text { | |
A pkg/vt/primary_visible_snapshot_test.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestPrimaryVisibleSnapshotOwnsSavedPrimaryAcrossLiveMutations(t *testing.T) { | |
| 10 | + | s := NewScreen(8, 2) | |
| 11 | + | s.Write([]byte("primary")) | |
| 12 | + | s.Write([]byte("\x1b[?1049h")) | |
| 13 | + | ||
| 14 | + | captured := s.PrimaryVisibleSnapshot() | |
| 15 | + | mutated := make(chan struct{}) | |
| 16 | + | go func() { | |
| 17 | + | s.Write([]byte("alternate")) | |
| 18 | + | s.Resize(3, 3) | |
| 19 | + | s.Write([]byte("\x1b[?1049lmutated")) | |
| 20 | + | close(mutated) | |
| 21 | + | }() | |
| 22 | + | ||
| 23 | + | blob, err := captured.Marshal() | |
| 24 | + | <-mutated | |
| 25 | + | require.NoError(t, err) | |
| 26 | + | restored := NewScreen(1, 1) | |
| 27 | + | require.NoError(t, restored.RestorePrimaryVisible(blob)) | |
| 28 | + | require.Equal(t, "primary ", rowString(restored.Frame.Row(0))) | |
| 29 | + | } | |
M pkg/vt/screen_scroll.go +11 -2
| @@ -1,6 +1,10 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | func (s *Screen) index() { | |
| 6 | 10 | if s.Frame.Height == 0 { | |
| @@ -88,7 +92,12 @@ func (s *Screen) emitLineEvicted(top, n int) { | |||
| 88 | 92 | ||
| 89 | 93 | func (s *Screen) recordEvicted(row []renderer.Cell) { | |
| 90 | 94 | if s.history != nil { | |
| 91 | - | s.history.Append(row) | |
| 95 | + | err := s.history.Append(row) | |
| 96 | + | if err != nil && !errors.Is(err, ErrHistoryRowTooWide) { | |
| 97 | + | panic(err) | |
| 98 | + | } | |
| 99 | + | // A nil error records the row; ErrHistoryRowTooWide explicitly leaves it | |
| 100 | + | // unrecorded. Both cases continue to the eviction observer below. | |
| 92 | 101 | } | |
| 93 | 102 | if s.OnLineEvicted != nil { | |
| 94 | 103 | s.OnLineEvicted(append([]renderer.Cell(nil), row...)) | |
M pkg/vt/snapshot.go +46 -5
| @@ -54,6 +54,12 @@ func MarshalHistoryChunk(chunk *HistoryChunk) ([]byte, error) { | |||
| 54 | 54 | // MarshalEmptyHistoryTail returns the mandatory canonical empty tail blob. | |
| 55 | 55 | func MarshalEmptyHistoryTail() ([]byte, error) { return MarshalHistory(HistoryView{}) } | |
| 56 | 56 | ||
| 57 | + | // MarshalHistoryTail encodes the copied mutable tail of a snapshot view as one | |
| 58 | + | // canonical history blob. It does not seal or otherwise mutate live history. | |
| 59 | + | func MarshalHistoryTail(view HistorySnapshotView) ([]byte, error) { | |
| 60 | + | return MarshalHistory(view.Tail()) | |
| 61 | + | } | |
| 62 | + | ||
| 57 | 63 | // MarshalSealedHistory serializes a SealAndView result as oldest-first, | |
| 58 | 64 | // self-contained sealed blobs plus a mandatory empty canonical tail blob. | |
| 59 | 65 | func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) { | |
| @@ -84,18 +90,23 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist | |||
| 84 | 90 | if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 { | |
| 85 | 91 | return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory) | |
| 86 | 92 | } | |
| 93 | + | h.evictUntil(len(view.chunks[0].rows), view.Cells()) | |
| 87 | 94 | h.chunks = append(h.chunks, view.chunks[0]) | |
| 88 | 95 | h.rows += len(view.chunks[0].rows) | |
| 96 | + | h.cells += view.Cells() | |
| 89 | 97 | } | |
| 90 | 98 | view, err := UnmarshalHistory(tail) | |
| 91 | 99 | if err != nil || len(view.chunks) > 1 { | |
| 92 | 100 | return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory) | |
| 93 | 101 | } | |
| 94 | 102 | if len(view.chunks) == 1 { | |
| 103 | + | h.evictUntil(len(view.chunks[0].rows), view.Cells()) | |
| 95 | 104 | h.tail = view.chunks[0].rows | |
| 96 | 105 | h.rows += len(h.tail) | |
| 106 | + | h.cells += view.Cells() | |
| 97 | 107 | } | |
| 98 | 108 | h.evict() | |
| 109 | + | h.normalizeTail() | |
| 99 | 110 | return h, nil | |
| 100 | 111 | } | |
| 101 | 112 | ||
| @@ -130,17 +141,47 @@ func MarshalVisible(frame renderer.Frame) ([]byte, error) { | |||
| 130 | 141 | return marshalVisible(frame, boundaries) | |
| 131 | 142 | } | |
| 132 | 143 | ||
| 133 | - | // MarshalPrimaryVisible preserves the primary buffer's logical-line boundaries | |
| 134 | - | // alongside its cells so a restored viewport can subsequently reflow. | |
| 135 | - | func (s *Screen) MarshalPrimaryVisible() ([]byte, error) { | |
| 144 | + | // PrimaryVisibleSnapshot is an owned copy of the primary buffer's visible | |
| 145 | + | // state. Capture it while the screen owner is synchronized, then Marshal it | |
| 146 | + | // after releasing that lock. | |
| 147 | + | type PrimaryVisibleSnapshot struct { | |
| 148 | + | frame renderer.Frame | |
| 149 | + | boundaries []lineBoundary | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // PrimaryVisibleSnapshot copies the saved primary frame and its logical-line | |
| 153 | + | // boundaries. When the alternate screen is active, the saved primary buffer is | |
| 154 | + | // copied rather than the currently displayed alternate buffer. | |
| 155 | + | func (s *Screen) PrimaryVisibleSnapshot() PrimaryVisibleSnapshot { | |
| 136 | 156 | b := s.buffer | |
| 137 | 157 | if s.alternate != nil { | |
| 138 | 158 | b = s.alternate.buffer | |
| 139 | 159 | } | |
| 140 | 160 | if b == nil { | |
| 141 | - | return MarshalVisible(s.PrimaryVisibleFrame()) | |
| 161 | + | return PrimaryVisibleSnapshot{frame: s.PrimaryVisibleFrame()} | |
| 162 | + | } | |
| 163 | + | return PrimaryVisibleSnapshot{ | |
| 164 | + | frame: b.frame.Clone(), | |
| 165 | + | boundaries: append([]lineBoundary(nil), b.boundaries...), | |
| 142 | 166 | } | |
| 143 | - | return marshalVisible(b.frame, b.boundaries) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // Marshal encodes an owned primary visible snapshot without accessing live VT | |
| 170 | + | // state. | |
| 171 | + | func (v PrimaryVisibleSnapshot) Marshal() ([]byte, error) { | |
| 172 | + | if len(v.boundaries) == 0 && v.frame.Height > 0 { | |
| 173 | + | return MarshalVisible(v.frame) | |
| 174 | + | } | |
| 175 | + | return marshalVisible(v.frame, v.boundaries) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // MarshalPrimaryVisible preserves the primary buffer's logical-line boundaries | |
| 179 | + | // alongside its cells so a restored viewport can subsequently reflow. | |
| 180 | + | // | |
| 181 | + | // Deprecated: callers holding a pane lock should use PrimaryVisibleSnapshot | |
| 182 | + | // and marshal the result after unlocking. | |
| 183 | + | func (s *Screen) MarshalPrimaryVisible() ([]byte, error) { | |
| 184 | + | return s.PrimaryVisibleSnapshot().Marshal() | |
| 144 | 185 | } | |
| 145 | 186 | ||
| 146 | 187 | func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, error) { | |
M pkg/vt/snapshot_test.go +79 -0
| @@ -4,9 +4,88 @@ import ( | |||
| 4 | 4 | "strings" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 7 | 8 | "github.com/stretchr/testify/require" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 11 | + | func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) { | |
| 12 | + | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |
| 13 | + | if err := history.Append(historyRow("AAAA")); err != nil { | |
| 14 | + | t.Fatal(err) | |
| 15 | + | } | |
| 16 | + | if err := history.Append(historyRow("BBBB")); err != nil { | |
| 17 | + | t.Fatal(err) | |
| 18 | + | } | |
| 19 | + | if err := history.Append(historyRow("CCCC")); err != nil { | |
| 20 | + | t.Fatal(err) | |
| 21 | + | } | |
| 22 | + | sealed := history.View().Chunk(0) | |
| 23 | + | ||
| 24 | + | view := history.SnapshotView() | |
| 25 | + | if got, want := view.ChunkCount(), 1; got != want { | |
| 26 | + | t.Fatalf("sealed chunks = %d, want %d", got, want) | |
| 27 | + | } | |
| 28 | + | if view.Chunk(0) != sealed { | |
| 29 | + | t.Fatal("snapshot copied an immutable sealed chunk") | |
| 30 | + | } | |
| 31 | + | if got, want := len(history.tail), 1; got != want { | |
| 32 | + | t.Fatalf("live tail rows = %d, want %d; snapshot sealed it", got, want) | |
| 33 | + | } | |
| 34 | + | history.tail[0][0].Rune = 'X' | |
| 35 | + | if got, want := rowText(view.Tail().Row(0)), "CCCC"; got != want { | |
| 36 | + | t.Fatalf("snapshot tail = %q, want %q", got, want) | |
| 37 | + | } | |
| 38 | + | if got, want := view.Len(), 3; got != want { | |
| 39 | + | t.Fatalf("snapshot rows = %d, want %d", got, want) | |
| 40 | + | } | |
| 41 | + | if got, want := view.Cells(), 12; got != want { | |
| 42 | + | t.Fatalf("snapshot cells = %d, want %d", got, want) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | tail, err := MarshalHistoryTail(view) | |
| 46 | + | require.NoError(t, err) | |
| 47 | + | decoded, err := UnmarshalHistory(tail) | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | require.Equal(t, []string{"CCCC"}, historyViewTexts(decoded)) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) { | |
| 53 | + | fullTail, err := MarshalHistory(HistoryView{ | |
| 54 | + | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("AAAA"), historyRow("BBBB")}}}, | |
| 55 | + | rows: 2, | |
| 56 | + | }) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | ||
| 59 | + | restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, ChunkRows: 2}, nil, fullTail) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | require.NoError(t, restored.Append(historyRow("CCCC"))) | |
| 62 | + | ||
| 63 | + | require.Equal(t, []string{"AAAA", "BBBB", "CCCC"}, historyViewTexts(restored.View())) | |
| 64 | + | require.Len(t, restored.chunks, 1, "a full restored tail must become sealed") | |
| 65 | + | require.Len(t, restored.tail, 1, "the appended row must be the mutable tail") | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing.T) { | |
| 69 | + | history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 10, ChunkRows: 2}) | |
| 70 | + | for _, text := range []string{"a", "b", "cc"} { | |
| 71 | + | require.NoError(t, history.Append(historyRow(text))) | |
| 72 | + | } | |
| 73 | + | view := history.SnapshotView() | |
| 74 | + | sealed := make([][]byte, view.ChunkCount()) | |
| 75 | + | for i := range sealed { | |
| 76 | + | var err error | |
| 77 | + | sealed[i], err = MarshalHistoryChunk(view.Chunk(i)) | |
| 78 | + | require.NoError(t, err) | |
| 79 | + | } | |
| 80 | + | tail, err := MarshalHistoryTail(view) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | ||
| 83 | + | restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, MaxCells: 2, ChunkRows: 2}, sealed, tail) | |
| 84 | + | require.NoError(t, err) | |
| 85 | + | require.Equal(t, []string{"cc"}, historyViewTexts(restored.View())) | |
| 86 | + | require.Equal(t, 2, restored.Cells()) | |
| 87 | + | } | |
| 88 | + | ||
| 10 | 89 | func TestPrimaryVisibleSnapshotRetainsReflowBoundaries(t *testing.T) { | |
| 11 | 90 | s := NewScreen(5, 3) | |
| 12 | 91 | s.Write([]byte("abcdefgh")) | |