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 | go 1.26 | 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 | require ( | 10 | require ( | ||
| 8 | github.com/davecgh/go-spew v1.1.1 // indirect | 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 | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | 450 | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | ||
| 451 | daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New())) | 451 | daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New())) | ||
| 452 | daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome)) | 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 | daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticefile.New(platform.StateDir()))) | 455 | daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticefile.New(platform.StateDir()))) | ||
| 455 | storePath := persist.StorePath(platform.StateDir()) | 456 | storePath := persist.StorePath(platform.StateDir()) | ||
| 456 | var storeErr error | 457 | var storeErr error | ||
| @@ -1185,6 +1186,27 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) { | |||||
| 1185 | _ = tw.Flush() | 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 | // runKill asks the daemon to terminate a named session, every session, or the daemon. | 1210 | // runKill asks the daemon to terminate a named session, every session, or the daemon. | ||
| 1189 | func runKill(ctx context.Context, name string, all, daemon bool) error { | 1211 | func runKill(ctx context.Context, name string, all, daemon bool) error { | ||
| 1190 | transport, err := realDial(ctx, ipc.SocketDir()) | 1212 | transport, err := realDial(ctx, ipc.SocketDir()) | ||
| @@ -1214,17 +1236,34 @@ func runKill(ctx context.Context, name string, all, daemon bool) error { | |||||
| 1214 | if openErr != nil { | 1236 | if openErr != nil { | ||
| 1215 | return fmt.Errorf("vev: opening stored sessions: %w", openErr) | 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 | if deleteErr := p.Delete(name); deleteErr != nil { | 1256 | if deleteErr := p.Delete(name); deleteErr != nil { | ||
| 1218 | _ = p.Close() | 1257 | _ = p.Close() | ||
| 1219 | return fmt.Errorf("vev: deleting stored session: %w", deleteErr) | 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 | if closeErr := p.Close(); closeErr != nil { | 1264 | if closeErr := p.Close(); closeErr != nil { | ||
| 1222 | return fmt.Errorf("vev: closing stored sessions: %w", closeErr) | 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 | printKillSuccess(name, all, daemon) | 1267 | printKillSuccess(name, all, daemon) | ||
| 1229 | return nil | 1268 | return nil | ||
| 1230 | } | 1269 | } | ||
M internal/app/run_test.go +5 -8
| @@ -352,10 +352,7 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) { | |||||
| 352 | if err := p.Close(); err != nil { | 352 | if err := p.Close(); err != nil { | ||
| 353 | t.Fatalf("Close error = %v", err) | 353 | t.Fatalf("Close error = %v", err) | ||
| 354 | } | 354 | } | ||
| 355 | - | snapshots := snapshot.NewStore(filepath.Join(stateRoot, "vev", "snapshots")) | 355 | + | snapshots := snapshot.NewRepository(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 | - | } | |||
| 359 | 356 | ||||
| 360 | got := captureStdout(t, func() { | 357 | got := captureStdout(t, func() { | ||
| 361 | if err := runKill(context.Background(), "stored", false, false); err != nil { | 358 | if err := runKill(context.Background(), "stored", false, false); err != nil { | ||
| @@ -372,12 +369,12 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) { | |||||
| 372 | if len(records) != 0 { | 369 | if len(records) != 0 { | ||
| 373 | t.Fatalf("records after kill = %#v, want none", records) | 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 | if err != nil { | 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 { | 376 | + | if len(names) != 0 { |
| 380 | - | t.Fatalf("snapshots after kill = %#v, want none", blobs) | 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 | package portsmocks | 5 | package portsmocks | ||
| 6 | 6 | ||||
| 7 | import ( | 7 | import ( | ||
| 8 | + | "context" | |||
| 9 | + | ||||
| 8 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 9 | mock "github.com/stretchr/testify/mock" | 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 | return _c | 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 | // The first argument is typically a *testing.T value. | 340 | // The first argument is typically a *testing.T value. | ||
| 339 | - | func NewMockSnapshotStore(t interface { | 341 | + | func NewMockSnapshotRepository(t interface { |
| 340 | mock.TestingT | 342 | mock.TestingT | ||
| 341 | Cleanup(func()) | 343 | Cleanup(func()) | ||
| 342 | - | }) *MockSnapshotStore { | 344 | + | }) *MockSnapshotRepository { |
| 343 | - | mock := &MockSnapshotStore{} | 345 | + | mock := &MockSnapshotRepository{} |
| 344 | mock.Mock.Test(t) | 346 | mock.Mock.Test(t) | ||
| 345 | 347 | ||||
| 346 | t.Cleanup(func() { mock.AssertExpectations(t) }) | 348 | t.Cleanup(func() { mock.AssertExpectations(t) }) | ||
| @@ -348,163 +350,350 @@ func NewMockSnapshotStore(t interface { | |||||
| 348 | return mock | 350 | return mock | ||
| 349 | } | 351 | } | ||
| 350 | 352 | ||||
| 351 | - | // MockSnapshotStore is an autogenerated mock type for the SnapshotStore type | 353 | + | // MockSnapshotRepository is an autogenerated mock type for the SnapshotRepository type |
| 352 | - | type MockSnapshotStore struct { | 354 | + | type MockSnapshotRepository struct { |
| 353 | mock.Mock | 355 | mock.Mock | ||
| 354 | } | 356 | } | ||
| 355 | 357 | ||||
| 356 | - | type MockSnapshotStore_Expecter struct { | 358 | + | type MockSnapshotRepository_Expecter struct { |
| 357 | mock *mock.Mock | 359 | mock *mock.Mock | ||
| 358 | } | 360 | } | ||
| 359 | 361 | ||||
| 360 | - | func (_m *MockSnapshotStore) EXPECT() *MockSnapshotStore_Expecter { | 362 | + | func (_m *MockSnapshotRepository) EXPECT() *MockSnapshotRepository_Expecter { |
| 361 | - | return &MockSnapshotStore_Expecter{mock: &_m.Mock} | 363 | + | return &MockSnapshotRepository_Expecter{mock: &_m.Mock} |
| 362 | } | 364 | } | ||
| 363 | 365 | ||||
| 364 | - | // Delete provides a mock function for the type MockSnapshotStore | 366 | + | // Delete provides a mock function for the type MockSnapshotRepository |
| 365 | - | func (_mock *MockSnapshotStore) Delete(name string) error { | 367 | + | func (_mock *MockSnapshotRepository) Delete(context1 context.Context, s string) error { |
| 366 | - | ret := _mock.Called(name) | 368 | + | ret := _mock.Called(context1, s) |
| 367 | 369 | ||||
| 368 | if len(ret) == 0 { | 370 | if len(ret) == 0 { | ||
| 369 | panic("no return value specified for Delete") | 371 | panic("no return value specified for Delete") | ||
| 370 | } | 372 | } | ||
| 371 | 373 | ||||
| 372 | var r0 error | 374 | var r0 error | ||
| 373 | - | if returnFunc, ok := ret.Get(0).(func(string) error); ok { | 375 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { |
| 374 | - | r0 = returnFunc(name) | 376 | + | r0 = returnFunc(context1, s) |
| 375 | } else { | 377 | } else { | ||
| 376 | r0 = ret.Error(0) | 378 | r0 = ret.Error(0) | ||
| 377 | } | 379 | } | ||
| 378 | return r0 | 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' | 383 | + | // MockSnapshotRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' |
| 382 | - | type MockSnapshotStore_Delete_Call struct { | 384 | + | type MockSnapshotRepository_Delete_Call struct { |
| 383 | *mock.Call | 385 | *mock.Call | ||
| 384 | } | 386 | } | ||
| 385 | 387 | ||||
| 386 | // Delete is a helper method to define mock.On call | 388 | // Delete is a helper method to define mock.On call | ||
| 387 | - | // - name string | 389 | + | // - context1 context.Context |
| 388 | - | func (_e *MockSnapshotStore_Expecter) Delete(name any) *MockSnapshotStore_Delete_Call { | 390 | + | // - s string |
| 389 | - | return &MockSnapshotStore_Delete_Call{Call: _e.mock.On("Delete", name)} | 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 | _c.Call.Run(func(args mock.Arguments) { | 396 | _c.Call.Run(func(args mock.Arguments) { | ||
| 394 | - | var arg0 string | 397 | + | var arg0 context.Context |
| 395 | if args[0] != nil { | 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 | run( | 405 | run( | ||
| 399 | arg0, | 406 | arg0, | ||
| 407 | + | arg1, | |||
| 400 | ) | 408 | ) | ||
| 401 | }) | 409 | }) | ||
| 402 | return _c | 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 | _c.Call.Return(err) | 414 | _c.Call.Return(err) | ||
| 407 | return _c | 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 | _c.Call.Return(run) | 419 | _c.Call.Return(run) | ||
| 412 | return _c | 420 | return _c | ||
| 413 | } | 421 | } | ||
| 414 | 422 | ||||
| 415 | - | // Load provides a mock function for the type MockSnapshotStore | 423 | + | // DeleteTombstone provides a mock function for the type MockSnapshotRepository |
| 416 | - | func (_mock *MockSnapshotStore) Load() ([]ports.SnapshotBlob, error) { | 424 | + | func (_mock *MockSnapshotRepository) DeleteTombstone(context1 context.Context, s string) error { |
| 417 | - | ret := _mock.Called() | 425 | + | ret := _mock.Called(context1, s) |
| 418 | 426 | ||||
| 419 | if len(ret) == 0 { | 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 | var r1 error | 489 | var r1 error | ||
| 425 | - | if returnFunc, ok := ret.Get(0).(func() ([]ports.SnapshotBlob, error)); ok { | 490 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok { |
| 426 | - | return returnFunc() | 491 | + | return returnFunc(context1) |
| 427 | } | 492 | } | ||
| 428 | - | if returnFunc, ok := ret.Get(0).(func() []ports.SnapshotBlob); ok { | 493 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []string); ok { |
| 429 | - | r0 = returnFunc() | 494 | + | r0 = returnFunc(context1) |
| 430 | } else { | 495 | } else { | ||
| 431 | if ret.Get(0) != nil { | 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 { | 500 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { |
| 436 | - | r1 = returnFunc() | 501 | + | r1 = returnFunc(context1) |
| 437 | } else { | 502 | } else { | ||
| 438 | r1 = ret.Error(1) | 503 | r1 = ret.Error(1) | ||
| 439 | } | 504 | } | ||
| 440 | return r0, r1 | 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' | 508 | + | // MockSnapshotRepository_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' |
| 444 | - | type MockSnapshotStore_Load_Call struct { | 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 | *mock.Call | 570 | *mock.Call | ||
| 446 | } | 571 | } | ||
| 447 | 572 | ||||
| 448 | // Load is a helper method to define mock.On call | 573 | // Load is a helper method to define mock.On call | ||
| 449 | - | func (_e *MockSnapshotStore_Expecter) Load() *MockSnapshotStore_Load_Call { | 574 | + | // - context1 context.Context |
| 450 | - | return &MockSnapshotStore_Load_Call{Call: _e.mock.On("Load")} | 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 | _c.Call.Run(func(args mock.Arguments) { | 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 | return _c | 595 | return _c | ||
| 458 | } | 596 | } | ||
| 459 | 597 | ||||
| 460 | - | func (_c *MockSnapshotStore_Load_Call) Return(snapshotBlobs []ports.SnapshotBlob, err error) *MockSnapshotStore_Load_Call { | 598 | + | func (_c *MockSnapshotRepository_Load_Call) Return(snapshotGeneration ports.SnapshotGeneration, err error) *MockSnapshotRepository_Load_Call { |
| 461 | - | _c.Call.Return(snapshotBlobs, err) | 599 | + | _c.Call.Return(snapshotGeneration, err) |
| 462 | return _c | 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 | _c.Call.Return(run) | 604 | _c.Call.Return(run) | ||
| 467 | return _c | 605 | return _c | ||
| 468 | } | 606 | } | ||
| 469 | 607 | ||||
| 470 | - | // Write provides a mock function for the type MockSnapshotStore | 608 | + | // Maintain provides a mock function for the type MockSnapshotRepository |
| 471 | - | func (_mock *MockSnapshotStore) Write(name string, data []byte) error { | 609 | + | func (_mock *MockSnapshotRepository) Maintain(context1 context.Context) error { |
| 472 | - | ret := _mock.Called(name, data) | 610 | + | ret := _mock.Called(context1) |
| 473 | 611 | ||||
| 474 | if len(ret) == 0 { | 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 | var r0 error | 616 | var r0 error | ||
| 479 | - | if returnFunc, ok := ret.Get(0).(func(string, []byte) error); ok { | 617 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { |
| 480 | - | r0 = returnFunc(name, data) | 618 | + | r0 = returnFunc(context1) |
| 481 | } else { | 619 | } else { | ||
| 482 | r0 = ret.Error(0) | 620 | r0 = ret.Error(0) | ||
| 483 | } | 621 | } | ||
| 484 | return r0 | 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' | 625 | + | // MockSnapshotRepository_Maintain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Maintain' |
| 488 | - | type MockSnapshotStore_Write_Call struct { | 626 | + | type MockSnapshotRepository_Maintain_Call struct { |
| 489 | *mock.Call | 627 | *mock.Call | ||
| 490 | } | 628 | } | ||
| 491 | 629 | ||||
| 492 | - | // Write is a helper method to define mock.On call | 630 | + | // Maintain is a helper method to define mock.On call |
| 493 | - | // - name string | 631 | + | // - context1 context.Context |
| 494 | - | // - data []byte | 632 | + | func (_e *MockSnapshotRepository_Expecter) Maintain(context1 any) *MockSnapshotRepository_Maintain_Call { |
| 495 | - | func (_e *MockSnapshotStore_Expecter) Write(name any, data any) *MockSnapshotStore_Write_Call { | 633 | + | return &MockSnapshotRepository_Maintain_Call{Call: _e.mock.On("Maintain", context1)} |
| 496 | - | return &MockSnapshotStore_Write_Call{Call: _e.mock.On("Write", name, data)} | |||
| 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 | _c.Call.Run(func(args mock.Arguments) { | 637 | _c.Call.Run(func(args mock.Arguments) { | ||
| 501 | - | var arg0 string | 638 | + | var arg0 context.Context |
| 502 | if args[0] != nil { | 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 | if args[1] != nil { | 695 | if args[1] != nil { | ||
| 507 | - | arg1 = args[1].([]byte) | 696 | + | arg1 = args[1].(ports.SnapshotPublication) |
| 508 | } | 697 | } | ||
| 509 | run( | 698 | run( | ||
| 510 | arg0, | 699 | arg0, | ||
| @@ -514,12 +703,272 @@ func (_c *MockSnapshotStore_Write_Call) Run(run func(name string, data []byte)) | |||||
| 514 | return _c | 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 | _c.Call.Return(err) | 707 | _c.Call.Return(err) | ||
| 519 | return _c | 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 | _c.Call.Return(run) | 972 | _c.Call.Return(run) | ||
| 524 | return _c | 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 | package ports | 1 | package ports | ||
| 2 | 2 | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/sha256" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 3 | // Store is a small byte-key/value persistence port. | 8 | // Store is a small byte-key/value persistence port. | ||
| 4 | // | 9 | // | ||
| 5 | // Implementations may buffer writes; Sync is the durability barrier. | 10 | // Implementations may buffer writes; Sync is the durability barrier. | ||
| @@ -17,15 +22,63 @@ type Store interface { | |||||
| 17 | Close() error | 22 | Close() error | ||
| 18 | } | 23 | } | ||
| 19 | 24 | ||||
| 20 | - | // SnapshotBlob is a durable named session snapshot payload. | 25 | + | // SnapshotDigest is the SHA-256 content address of a complete snapshot object. |
| 21 | - | type SnapshotBlob struct { | 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 | Name string | 70 | Name string | ||
| 23 | Data []byte | 71 | Data []byte | ||
| 24 | } | 72 | } | ||
| 25 | 73 | ||||
| 26 | - | // SnapshotStore persists encoded session snapshots by name. | 74 | + | // LegacySnapshotSource exposes the v3 bridge separately from the new write |
| 27 | - | type SnapshotStore interface { | 75 | + | // contract. |
| 28 | - | Write(name string, data []byte) error | 76 | + | type LegacySnapshotSource interface { |
| 29 | - | Load() ([]SnapshotBlob, error) | 77 | + | LoadLegacy(context.Context) ([]LegacySnapshot, error) |
| 30 | - | Delete(name string) 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 | import ( | 3 | import ( | ||
| 4 | "encoding/base64" | 4 | "encoding/base64" | ||
| 5 | + | "math" | |||
| 5 | "slices" | 6 | "slices" | ||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "unicode" | 8 | "unicode" | ||
| @@ -27,12 +28,31 @@ func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot { | |||||
| 27 | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | 28 | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | ||
| 28 | } | 29 | } | ||
| 29 | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | 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 | for _, row := range rows { | 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 | return Snapshot{history: history.SealAndView(), Width: width, Height: height} | 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 | func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height } | 56 | func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height } | ||
| 37 | func (s Snapshot) Row(i int) []renderer.Cell { | 57 | func (s Snapshot) Row(i int) []renderer.Cell { | ||
| 38 | if i < 0 { | 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 | return NewMode(NewDocument(NewSnapshotFromRows(rows, 16, height), domain.DefaultWordSeparators)) | 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 | func TestCopyModeKeyboardSelection(t *testing.T) { | 45 | func TestCopyModeKeyboardSelection(t *testing.T) { | ||
| 31 | m := modeFor([]string{"alpha", "xy", "bravo", "charlie"}, 2) | 46 | m := modeFor([]string{"alpha", "xy", "bravo", "charlie"}, 2) | ||
| 32 | require.Equal(t, Pos{Row: 3}, m.Cursor()) | 47 | require.Equal(t, Pos{Row: 3}, m.Cursor()) | ||
| @@ -175,9 +190,9 @@ func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) { | |||||
| 175 | const rows = 10_000 | 190 | const rows = 10_000 | ||
| 176 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256}) | 191 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256}) | ||
| 177 | for range rows { | 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 | snapshot := NewSnapshot(history, renderer.NewFrame(16, 1)) | 196 | snapshot := NewSnapshot(history, renderer.NewFrame(16, 1)) | ||
| 182 | view := history.SealAndView() | 197 | view := history.SealAndView() | ||
| 183 | require.Same(t, view.Chunk(0), snapshot.history.Chunk(0)) | 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 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 301 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 302 | pane := sess.tabs[0].focusedPane() | 302 | pane := sess.tabs[0].focusedPane() | ||
| 303 | installTestHistory(pane, vt.HistoryConfig{MaxRows: 4}) | 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 | copy(pane.screen.Frame.Row(0), testRow("live alpha")) | 305 | copy(pane.screen.Frame.Row(0), testRow("live alpha")) | ||
| 306 | 306 | ||||
| 307 | d.enterCopyMode(sess, ac) | 307 | d.enterCopyMode(sess, ac) | ||
| @@ -330,8 +330,8 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |||||
| 330 | p, _ := newBlockingPTYWithWrites(t, writes) | 330 | p, _ := newBlockingPTYWithWrites(t, writes) | ||
| 331 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 331 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 332 | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) | 332 | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) | ||
| 333 | - | sess.tabs[0].focusedPane().history.Append(testRow("old1 ")) | 333 | + | require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old1 "))) |
| 334 | - | sess.tabs[0].focusedPane().history.Append(testRow("old2 ")) | 334 | + | require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old2 "))) |
| 335 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | 335 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | ||
| 336 | 336 | ||||
| 337 | d.enterCopyMode(sess, ac) | 337 | d.enterCopyMode(sess, ac) | ||
| @@ -505,8 +505,8 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||||
| 505 | p, _ := newBlockingPTY(t) | 505 | p, _ := newBlockingPTY(t) | ||
| 506 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 506 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 507 | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) | 507 | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) | ||
| 508 | - | sess.tabs[0].focusedPane().history.Append(testRow("old1 ")) | 508 | + | require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old1 "))) |
| 509 | - | sess.tabs[0].focusedPane().history.Append(testRow("old2 ")) | 509 | + | require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old2 "))) |
| 510 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | 510 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | ||
| 511 | 511 | ||||
| 512 | d.enterCopyMode(sess, ac) | 512 | d.enterCopyMode(sess, ac) | ||
| @@ -739,11 +739,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) { | |||||
| 739 | var want *pane | 739 | var want *pane | ||
| 740 | if tc.useFloating { | 740 | if tc.useFloating { | ||
| 741 | want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | 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 | want.screen.Write([]byte("flt-live")) | 743 | want.screen.Write([]byte("flt-live")) | ||
| 744 | } else { | 744 | } else { | ||
| 745 | want = main | 745 | want = main | ||
| 746 | - | want.history.Append(testRow("one-old")) | 746 | + | require.NoError(t, want.history.Append(testRow("one-old"))) |
| 747 | want.screen.Write([]byte("one-live")) | 747 | want.screen.Write([]byte("one-live")) | ||
| 748 | } | 748 | } | ||
| 749 | 749 | ||||
| @@ -780,7 +780,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||||
| 780 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | 780 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | ||
| 781 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | 781 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | ||
| 782 | for i := range 30 { | 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 | fp.screen.Write([]byte("live")) | 785 | fp.screen.Write([]byte("live")) | ||
| 786 | fp.mu.Lock() | 786 | fp.mu.Lock() | ||
| @@ -806,7 +806,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) { | |||||
| 806 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | 806 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | ||
| 807 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | 807 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | ||
| 808 | for i := range 5 { | 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 | fp.screen.Write([]byte("live")) | 811 | fp.screen.Write([]byte("live")) | ||
| 812 | 812 | ||||
M internal/usecase/daemon/daemon.go +156 -96
| @@ -7,7 +7,6 @@ import ( | |||||
| 7 | "log/slog" | 7 | "log/slog" | ||
| 8 | "math" | 8 | "math" | ||
| 9 | "os" | 9 | "os" | ||
| 10 | - | "slices" | |||
| 11 | "sort" | 10 | "sort" | ||
| 12 | "sync" | 11 | "sync" | ||
| 13 | "sync/atomic" | 12 | "sync/atomic" | ||
| @@ -17,7 +16,6 @@ import ( | |||||
| 17 | "github.com/bnema/vev/internal/persist" | 16 | "github.com/bnema/vev/internal/persist" | ||
| 18 | "github.com/bnema/vev/internal/ports" | 17 | "github.com/bnema/vev/internal/ports" | ||
| 19 | "github.com/bnema/vev/internal/usecase/keys" | 18 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 20 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 21 | ) | 19 | ) | ||
| 22 | 20 | ||||
| 23 | // Scheduler debounce bounds. Idle updates use the minimum for low latency; | 21 | // Scheduler debounce bounds. Idle updates use the minimum for low latency; | ||
| @@ -32,8 +30,13 @@ const debounceInterval = minDebounceInterval | |||||
| 32 | // ptyReadBufSize is the PTY reader's read buffer. | 30 | // ptyReadBufSize is the PTY reader's read buffer. | ||
| 33 | const ptyReadBufSize = 32 * 1024 | 31 | const ptyReadBufSize = 32 * 1024 | ||
| 34 | 32 | ||||
| 35 | - | // defaultScrollbackRows is the per-tab retained history size. | 33 | + | // New and restored panes are bounded by both history rows and cells. The |
| 36 | - | const defaultScrollbackRows = 10_000 | 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 | // detachNotifyTimeout bounds the best-effort Detached notification on the | 41 | // detachNotifyTimeout bounds the best-effort Detached notification on the | ||
| 39 | // detach/kill/shutdown paths: if a wedged client (full kernel send buffer) | 42 | // detach/kill/shutdown paths: if a wedged client (full kernel send buffer) | ||
| @@ -101,22 +104,32 @@ type Daemon struct { | |||||
| 101 | beforeCopyModeRevalidate func() | 104 | beforeCopyModeRevalidate func() | ||
| 102 | // beforeCopyMouseMap is a deterministic seam after an immutable copy-input | 105 | // beforeCopyMouseMap is a deterministic seam after an immutable copy-input | ||
| 103 | // snapshot is captured and before its mapped position is applied. | 106 | // snapshot is captured and before its mapped position is applied. | ||
| 104 | - | beforeCopyMouseMap func() | 107 | + | beforeCopyMouseMap func() |
| 105 | - | ptys ports.PTYFactory | 108 | + | ptys ports.PTYFactory |
| 106 | - | clock ports.Clock | 109 | + | clock ports.Clock |
| 107 | - | log *slog.Logger | 110 | + | log *slog.Logger |
| 108 | - | runtimeObserver ports.RuntimeObserver | 111 | + | runtimeObserver ports.RuntimeObserver |
| 109 | - | baseEnv []string | 112 | + | baseEnv []string |
| 110 | - | shell string | 113 | + | shell string |
| 111 | - | shellArgs []string | 114 | + | shellArgs []string |
| 112 | - | shellOverride bool | 115 | + | shellOverride bool |
| 113 | - | persist *persist.Persister | 116 | + | persist *persist.Persister |
| 114 | - | persistEnabled bool | 117 | + | persistEnabled bool |
| 115 | - | snaps ports.SnapshotStore | 118 | + | // snapshotRepository is the sole checkpoint contract. legacySnapshots is |
| 116 | - | snapsEnabled bool | 119 | + | // read-only migration input and is never used for new writes. |
| 117 | - | noticeStore ports.NoticeStore | 120 | + | snapshotRepository ports.SnapshotRepository |
| 118 | - | snapshotMarshal func(snapcodec.Session) ([]byte, error) | 121 | + | legacySnapshots ports.LegacySnapshotSource |
| 119 | - | snapshotJobs chan *snapshotCapture | 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 | snapshotWorkerMu sync.Mutex | 133 | snapshotWorkerMu sync.Mutex | ||
| 121 | snapshotWorkerID uint64 | 134 | snapshotWorkerID uint64 | ||
| 122 | snapshotWorkerCtx context.Context | 135 | snapshotWorkerCtx context.Context | ||
| @@ -127,28 +140,33 @@ type Daemon struct { | |||||
| 127 | // snapshotFinalJobs coalesces terminal captures by session when the bounded | 140 | // snapshotFinalJobs coalesces terminal captures by session when the bounded | ||
| 128 | // regular queue is full. It retains at most snapshotFinalQueueCapacity named | 141 | // regular queue is full. It retains at most snapshotFinalQueueCapacity named | ||
| 129 | // sessions, each with only its newest terminal state while the worker blocks. | 142 | // sessions, each with only its newest terminal state while the worker blocks. | ||
| 130 | - | snapshotFinalJobs map[*session]*snapshotCapture | 143 | + | snapshotFinalJobs map[*session]*snapshotCapture |
| 131 | - | snapshotFinalOrder []*session | 144 | + | snapshotFinalOrder []*session |
| 132 | - | snapshotWorkerClosing bool | 145 | + | snapshotWorkerClosing bool |
| 133 | - | snapshotWorkerInFlight *snapshotCapture | 146 | + | snapshotWorkerInFlight *snapshotCapture |
| 134 | - | restoreDone chan struct{} | 147 | + | // snapshotNoticeMu guards the active global persistence failure signature. |
| 135 | - | restoreOnce sync.Once | 148 | + | // It is separate from snapshotWorkerMu so notice routing cannot block a |
| 136 | - | procCwd func(int) (string, error) | 149 | + | // producer or a repository worker. |
| 137 | - | procComm func(int) (string, error) | 150 | + | snapshotNoticeMu sync.Mutex |
| 138 | - | procArgv func(int) ([]string, error) | 151 | + | snapshotActiveFailureSignature string |
| 139 | - | procGroupArgv func(int, int) ([]string, error) | 152 | + | restoreDone chan struct{} |
| 140 | - | dirOrHome func(string) string | 153 | + | restoreOnce sync.Once |
| 141 | - | bindings atomic.Pointer[keys.Bindings] | 154 | + | procCwd func(int) (string, error) |
| 142 | - | codeOverrides atomic.Pointer[map[string]string] | 155 | + | procComm func(int) (string, error) |
| 143 | - | restoreProcessAllowlist atomic.Pointer[map[string]struct{}] | 156 | + | procArgv func(int) ([]string, error) |
| 144 | - | floatingConfig atomic.Pointer[domain.FloatingConfig] | 157 | + | procGroupArgv func(int, int) ([]string, error) |
| 145 | - | copyConfig atomic.Pointer[domain.CopyConfig] | 158 | + | dirOrHome func(string) string |
| 146 | - | paletteConfig atomic.Pointer[domain.PaletteConfig] | 159 | + | bindings atomic.Pointer[keys.Bindings] |
| 147 | - | tabsConfig atomic.Pointer[domain.TabsConfig] | 160 | + | codeOverrides atomic.Pointer[map[string]string] |
| 148 | - | themeConfig atomic.Pointer[themeConfigSnapshot] | 161 | + | restoreProcessAllowlist atomic.Pointer[map[string]struct{}] |
| 149 | - | barScripts *barScriptState | 162 | + | floatingConfig atomic.Pointer[domain.FloatingConfig] |
| 150 | - | notices *noticeCenter | 163 | + | copyConfig atomic.Pointer[domain.CopyConfig] |
| 151 | - | resumeParkGrace time.Duration | 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 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | 170 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | ||
| 153 | // (see clipboard.go); empty means use os.TempDir(). | 171 | // (see clipboard.go); empty means use os.TempDir(). | ||
| 154 | tempDir string | 172 | tempDir string | ||
| @@ -194,15 +212,6 @@ type stoppedSession struct { | |||||
| 194 | purging bool | 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 | type Option func(*Daemon) | 215 | type Option func(*Daemon) | ||
| 207 | 216 | ||||
| 208 | // WithRuntimeObserver accepts only a composition-root serialized observer. | 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 | 244 | + | // WithSnapshotRepository enables content-addressed incremental snapshots. The |
| 236 | - | // the daemon in no-op snapshot mode. | 245 | + | // legacy source is read-only migration input and is never used for new writes. |
| 237 | - | func WithSnapshotStore(store ports.SnapshotStore) Option { | 246 | + | func WithSnapshotRepository(repository ports.SnapshotRepository, legacy ports.LegacySnapshotSource) Option { |
| 238 | return func(d *Daemon) { | 247 | return func(d *Daemon) { | ||
| 239 | - | d.snaps = store | 248 | + | d.snapshotRepository = repository |
| 240 | - | d.snapsEnabled = store != nil | 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 | shell = "/bin/sh" | 353 | shell = "/bin/sh" | ||
| 344 | } | 354 | } | ||
| 345 | d := &Daemon{ | 355 | d := &Daemon{ | ||
| 346 | - | sessions: make(map[domain.SessionID]*session), | 356 | + | sessions: make(map[domain.SessionID]*session), |
| 347 | - | stopped: make(map[string]stoppedSession), | 357 | + | stopped: make(map[string]stoppedSession), |
| 348 | - | parked: make(map[uint64]*parkedAttachment), | 358 | + | parked: make(map[uint64]*parkedAttachment), |
| 349 | - | ptys: ptys, | 359 | + | ptys: ptys, |
| 350 | - | clock: clock, | 360 | + | clock: clock, |
| 351 | - | log: log, | 361 | + | log: log, |
| 352 | - | baseEnv: os.Environ(), | 362 | + | baseEnv: os.Environ(), |
| 353 | - | shell: shell, | 363 | + | shell: shell, |
| 354 | - | persist: persist.New(nil), | 364 | + | persist: persist.New(nil), |
| 355 | - | dirOrHome: dirOrHome, | 365 | + | dirOrHome: dirOrHome, |
| 356 | - | done: make(chan struct{}), | 366 | + | done: make(chan struct{}), |
| 357 | - | restoreDone: make(chan struct{}), | 367 | + | restoreDone: make(chan struct{}), |
| 358 | - | animWake: make(chan struct{}, 1), | 368 | + | animWake: make(chan struct{}, 1), |
| 359 | - | snapshotMarshal: snapcodec.Marshal, | 369 | + | legacyImportPending: make(map[string][]byte), |
| 360 | - | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), | 370 | + | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), |
| 361 | - | notices: newNoticeCenter(), | 371 | + | snapshotWake: make(chan struct{}, 1), |
| 362 | - | resumeParkGrace: defaultResumeParkGrace, | 372 | + | notices: newNoticeCenter(), |
| 373 | + | resumeParkGrace: defaultResumeParkGrace, | |||
| 363 | barScripts: &barScriptState{ | 374 | barScripts: &barScriptState{ | ||
| 364 | cfg: barConfigFromDomain(domain.Defaults().Bar), | 375 | cfg: barConfigFromDomain(domain.Defaults().Bar), | ||
| 365 | outputs: make(map[domain.SessionID]barScriptOutputs), | 376 | outputs: make(map[domain.SessionID]barScriptOutputs), | ||
| @@ -443,7 +454,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||||
| 443 | if d.snapsEnabled { | 454 | if d.snapsEnabled { | ||
| 444 | d.startSnapshotEncodeWorker() | 455 | d.startSnapshotEncodeWorker() | ||
| 445 | d.sessWg.Go(func() { | 456 | d.sessWg.Go(func() { | ||
| 446 | - | d.snapshotSaver(d.serveCtx) | 457 | + | d.snapshotRepositorySaver(d.serveCtx) |
| 447 | }) | 458 | }) | ||
| 448 | d.sessWg.Go(func() { | 459 | d.sessWg.Go(func() { | ||
| 449 | d.restoreSnapshots(d.serveCtx) | 460 | d.restoreSnapshots(d.serveCtx) | ||
| @@ -486,7 +497,15 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||||
| 486 | // force-close. Finally drain the conn handlers, run one defensive sweep in | 497 | // force-close. Finally drain the conn handlers, run one defensive sweep in | ||
| 487 | // case any ordering ever leaves a session behind, and join the session | 498 | // case any ordering ever leaves a session behind, and join the session | ||
| 488 | // goroutines (readers unblock via pty.Close, coordinators via ctx cancel). | 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 | d.waitNotifies() | 509 | d.waitNotifies() | ||
| 491 | d.hardCancel() | 510 | d.hardCancel() | ||
| 492 | d.serveCancel() | 511 | d.serveCancel() | ||
| @@ -495,7 +514,10 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||||
| 495 | // registry snapshot has been removed. | 514 | // registry snapshot has been removed. | ||
| 496 | d.connWg.Wait() | 515 | d.connWg.Wait() | ||
| 497 | d.attachmentCleanupWg.Wait() | 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 | d.shutdownAll(ports.ReasonServerShutdown) | 521 | d.shutdownAll(ports.ReasonServerShutdown) | ||
| 500 | d.sessWg.Wait() | 522 | d.sessWg.Wait() | ||
| 501 | d.waitNotifies() | 523 | d.waitNotifies() | ||
| @@ -509,7 +531,11 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||||
| 509 | // closing under the same lock as the snapshot guarantees no session can be | 531 | // closing under the same lock as the snapshot guarantees no session can be | ||
| 510 | // inserted after the snapshot: route rejects once closing is set, and both run | 532 | // inserted after the snapshot: route rejects once closing is set, and both run | ||
| 511 | // under d.mu. killSession (which relocks) runs after the lock is released. | 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 | d.mu.Lock() | 539 | d.mu.Lock() | ||
| 514 | d.closing = true | 540 | d.closing = true | ||
| 515 | for token, parked := range d.parked { | 541 | for token, parked := range d.parked { | ||
| @@ -521,13 +547,57 @@ func (d *Daemon) shutdownAll(reason uint8) { | |||||
| 521 | d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot)) | 547 | d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot)) | ||
| 522 | if empty { | 548 | if empty { | ||
| 523 | d.doneOnce.Do(func() { close(d.done) }) | 549 | d.doneOnce.Do(func() { close(d.done) }) | ||
| 524 | - | return | 550 | + | return false |
| 525 | } | 551 | } | ||
| 526 | for _, s := range snapshot { | 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 | d.log.Error("closing session with unpersisted terminal state", "err", err) | 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 | func (d *Daemon) sessionsSnapshotLocked() []*session { | 603 | func (d *Daemon) sessionsSnapshotLocked() []*session { | ||
| @@ -620,25 +690,15 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |||||
| 620 | d.mu.Lock() | 690 | d.mu.Lock() | ||
| 621 | target := d.findByNameLocked(k.Name) | 691 | target := d.findByNameLocked(k.Name) | ||
| 622 | if target == nil { | 692 | if target == nil { | ||
| 623 | - | if stopped, ok := d.stopped[k.Name]; ok { | 693 | + | if _, ok := d.stopped[k.Name]; ok { |
| 624 | d.mu.Unlock() | 694 | d.mu.Unlock() | ||
| 625 | - | if d.snapsEnabled && d.snaps != nil { | 695 | + | // Stopped sessions follow the same tombstone-backed transaction as |
| 626 | - | if err := d.snaps.Delete(k.Name); err != nil { | 696 | + | // live and offline purges. In particular, a retained legacy source |
| 627 | - | d.log.Warn("deleting stopped session snapshot failed", "err", err, "session", k.Name) | 697 | + | // must never outlive metadata after an interrupted control kill. |
| 628 | - | _ = tr.Send(frameError(ports.ErrInternal, "deleting stopped session snapshot failed")) | 698 | + | if err := d.retryStoppedPurge(k.Name); err != nil { |
| 629 | - | return | 699 | + | d.log.Warn("deleting stopped session failed", "err", err, "session", k.Name) |
| 630 | - | } | 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 | return | 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 | // Output evicts five lines after Press; the Document remains immutable. | 1238 | // Output evicts five lines after Press; the Document remains immutable. | ||
| 1239 | for range 5 { | 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 | // Motion maps the next endpoint through the press-owned Document. | 1243 | // Motion maps the next endpoint through the press-owned Document. | ||
| @@ -1764,7 +1764,7 @@ func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T) | |||||
| 1764 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 1764 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 1765 | pane := sess.activeTab().focusedPane() | 1765 | pane := sess.activeTab().focusedPane() | ||
| 1766 | for range 8 { | 1766 | for range 8 { | ||
| 1767 | - | pane.history.Append(testRow("history")) | 1767 | + | require.NoError(t, pane.history.Append(testRow("history"))) |
| 1768 | } | 1768 | } | ||
| 1769 | d.enterCopyMode(sess, ac) | 1769 | d.enterCopyMode(sess, ac) | ||
| 1770 | mustOutputData(t, sends) | 1770 | mustOutputData(t, sends) | ||
M internal/usecase/daemon/notice_persist_test.go +0 -90
| @@ -1,17 +1,13 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "fmt" | |||
| 5 | - | "sync/atomic" | |||
| 6 | "testing" | 4 | "testing" | ||
| 7 | 5 | ||||
| 8 | - | "github.com/stretchr/testify/mock" | |||
| 9 | "github.com/stretchr/testify/require" | 6 | "github.com/stretchr/testify/require" | ||
| 10 | 7 | ||||
| 11 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 12 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| 13 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 10 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 14 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 15 | ) | 11 | ) | ||
| 16 | 12 | ||||
| 17 | // TestKillSessionServerShutdownSaturatedAppendsNotice proves that when the | 13 | // TestKillSessionServerShutdownSaturatedAppendsNotice proves that when the | ||
| @@ -19,92 +15,6 @@ import ( | |||||
| 19 | // no way to retain the session's terminal state and no attached client to | 15 | // no way to retain the session's terminal state and no attached client to | ||
| 20 | // toast, because the daemon is dying — the failure is handed to the | 16 | // toast, because the daemon is dying — the failure is handed to the | ||
| 21 | // NoticeStore instead of being silently lost. | 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 | // TestStartupClaimRecordsAndToastsPendingNotices proves notices persisted by | 18 | // TestStartupClaimRecordsAndToastsPendingNotices proves notices persisted by | ||
| 109 | // a previous daemon are recorded to history and toasted to the first client | 19 | // a previous daemon are recorded to history and toasted to the first client | ||
| 110 | // that attaches after this daemon starts before their claim is acknowledged. | 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 | return n | 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 | func (nc *noticeCenter) history() []domain.Notification { | 89 | func (nc *noticeCenter) history() []domain.Notification { | ||
| 65 | nc.mu.Lock() | 90 | nc.mu.Lock() | ||
| 66 | defer nc.mu.Unlock() | 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 | ac := &attachedClient{} | 199 | ac := &attachedClient{} | ||
| 200 | ac.initOverlays() | 200 | ac.initOverlays() | ||
| 201 | history := newTestHistory(1) | 201 | history := newTestHistory(1) | ||
| 202 | - | history.Append(testRow("before")) | 202 | + | require.NoError(t, history.Append(testRow("before"))) |
| 203 | document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1)), domain.DefaultWordSeparators) | 203 | document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1)), domain.DefaultWordSeparators) | ||
| 204 | 204 | ||||
| 205 | ac.overlays.copyMu.Lock() | 205 | ac.overlays.copyMu.Lock() | ||
| @@ -207,7 +207,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) { | |||||
| 207 | ac.overlays.copyDocument = document | 207 | ac.overlays.copyDocument = document | ||
| 208 | ac.overlays.copyMu.Unlock() | 208 | ac.overlays.copyMu.Unlock() | ||
| 209 | 209 | ||||
| 210 | - | history.Append(testRow("after")) | 210 | + | require.NoError(t, history.Append(testRow("after"))) |
| 211 | renderSnap := ac.overlays.SnapshotForRender() | 211 | renderSnap := ac.overlays.SnapshotForRender() | ||
| 212 | defer renderSnap.Unlock() | 212 | defer renderSnap.Unlock() | ||
| 213 | require.NotNil(t, renderSnap.copyMode) | 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 | func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane { | 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 | return &pane{ | 51 | return &pane{ | ||
| 52 | id: id, | 52 | id: id, | ||
| 53 | stableID: stableID, | 53 | stableID: stableID, | ||
M internal/usecase/daemon/performance_benchmark_test.go +387 -77
| @@ -1,9 +1,11 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "fmt" | 5 | "fmt" | ||
| 5 | "io" | 6 | "io" | ||
| 6 | "sync" | 7 | "sync" | ||
| 8 | + | "sync/atomic" | |||
| 7 | "testing" | 9 | "testing" | ||
| 8 | "time" | 10 | "time" | ||
| 9 | 11 | ||||
| @@ -13,18 +15,138 @@ import ( | |||||
| 13 | "github.com/bnema/vev/internal/ports" | 15 | "github.com/bnema/vev/internal/ports" | ||
| 14 | "github.com/bnema/vev/internal/usecase/layout" | 16 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 15 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 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) { | 21 | + | func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) { |
| 19 | - | store := &countingSnapshotStore{} | 22 | + | repository := &countingSnapshotRepository{} |
| 20 | payload := []byte{0xff, 0x00, 0xfe} | 23 | payload := []byte{0xff, 0x00, 0xfe} | ||
| 24 | + | object := []byte{0x01, 0x02} | |||
| 21 | 25 | ||||
| 22 | - | require.NoError(t, store.Write("session", payload)) | 26 | + | require.NoError(t, repository.Publish(context.Background(), ports.SnapshotPublication{Name: "session", Manifest: payload, Objects: []ports.SnapshotObject{{Data: object}}})) |
| 23 | - | require.Equal(t, countingSnapshotMetrics{writes: 1, bytes: uint64(len(payload))}, store.metrics()) | 27 | + | require.Equal(t, countingSnapshotMetrics{writes: 1, manifestBytes: uint64(len(payload)), objectBytes: uint64(len(object)), suppliedObjectBytes: uint64(len(object)), headBytes: snapshotHeadBytes}, repository.metrics()) |
| 24 | - | require.Equal(t, payload, store.lastPayload()) | 28 | + | require.Equal(t, payload, repository.lastPayload()) |
| 25 | 29 | ||||
| 26 | payload[0] = 0x01 | 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 | func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) { | 152 | func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) { | ||
| @@ -63,7 +185,8 @@ func TestPerformanceFixtureCounters(t *testing.T) { | |||||
| 63 | fixture.captureSnapshot() | 185 | fixture.captureSnapshot() | ||
| 64 | metrics = fixture.metrics() | 186 | metrics = fixture.metrics() | ||
| 65 | require.Equal(t, uint64(1), metrics.snapshotWrites) | 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 | fixture.enterCopySearch() | 191 | fixture.enterCopySearch() | ||
| 69 | require.Positive(t, fixture.searchMatches()) | 192 | require.Positive(t, fixture.searchMatches()) | ||
| @@ -157,13 +280,13 @@ func TestPerformanceFixtureSnapshotCaptureRetainsSealedHistory(t *testing.T) { | |||||
| 157 | require.True(t, ok) | 280 | require.True(t, ok) | ||
| 158 | require.Len(t, first.tabs, 1) | 281 | require.Len(t, first.tabs, 1) | ||
| 159 | require.Len(t, first.tabs[0].panes, 2) | 282 | require.Len(t, first.tabs[0].panes, 2) | ||
| 160 | - | require.Equal(t, 10_000, first.tabs[0].panes[0].history.Len()) | 283 | + | require.Equal(t, 10_000, first.tabs[0].panes[0].sealed.Len()) |
| 161 | - | require.Equal(t, 40, first.tabs[0].panes[0].history.ChunkCount()) | 284 | + | require.Positive(t, first.tabs[0].panes[0].sealed.ChunkCount()) |
| 162 | 285 | ||||
| 163 | second, ok := fixture.d.captureSnapshotState(fixture.sess, 2) | 286 | second, ok := fixture.d.captureSnapshotState(fixture.sess, 2) | ||
| 164 | require.True(t, ok) | 287 | require.True(t, ok) | ||
| 165 | secondPane := capturePaneByID(second, first.tabs[0].panes[0].id) | 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 | t.Fatal("unchanged sealed history chunk was not reused across captures") | 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 | benchmarkDaemonSnapshotCapture(b) | 339 | benchmarkDaemonSnapshotCapture(b) | ||
| 217 | } | 340 | } | ||
| 218 | 341 | ||||
| 219 | - | // BenchmarkDaemonHistorySnapshotEncode measures v3 encoding from an already | 342 | + | // BenchmarkDaemonIncrementalSnapshotRepository measures the complete |
| 220 | - | // immutable capture, without queueing or persistence. | 343 | + | // content-addressed publication path against a filesystem-equivalent byte |
| 221 | - | func BenchmarkDaemonHistorySnapshotEncode(b *testing.B) { | 344 | + | // counter. Each scenario reports persisted VEVO, VEVM, and VEVH bytes, plus |
| 222 | - | benchmarkDaemonSnapshotEncode(b) | 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 | // BenchmarkDaemonHistorySnapshotRestore measures terminal-state restoration | 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 | func BenchmarkDaemonHistorySnapshotRestore(b *testing.B) { | 356 | func BenchmarkDaemonHistorySnapshotRestore(b *testing.B) { | ||
| 228 | benchmarkDaemonSnapshotRestore(b) | 357 | benchmarkDaemonSnapshotRestore(b) | ||
| 229 | } | 358 | } | ||
| @@ -326,7 +455,7 @@ func benchmarkDaemonSnapshotCapture(b *testing.B) { | |||||
| 326 | b.Fatal("invalid snapshot capture fixture") | 455 | b.Fatal("invalid snapshot capture fixture") | ||
| 327 | } | 456 | } | ||
| 328 | firstPane := first.tabs[0].panes[0] | 457 | firstPane := first.tabs[0].panes[0] | ||
| 329 | - | firstChunk := firstPane.history.Chunk(0) | 458 | + | firstChunk := firstPane.sealed.Chunk(0) |
| 330 | captures := 0 | 459 | captures := 0 | ||
| 331 | var last *snapshotCapture | 460 | var last *snapshotCapture | ||
| 332 | b.ReportAllocs() | 461 | b.ReportAllocs() | ||
| @@ -340,7 +469,7 @@ func benchmarkDaemonSnapshotCapture(b *testing.B) { | |||||
| 340 | } | 469 | } | ||
| 341 | b.StopTimer() | 470 | b.StopTimer() | ||
| 342 | lastPane := capturePaneByID(last, firstPane.id) | 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 | b.Fatal("snapshot capture did not preserve immutable sealed history") | 473 | b.Fatal("snapshot capture did not preserve immutable sealed history") | ||
| 345 | } | 474 | } | ||
| 346 | b.ReportMetric(float64(capturePaneCount(last)), "capturepanes/op") | 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) { | 480 | + | type snapshotBenchmarkScenario struct { |
| 352 | - | for _, topology := range daemonSnapshotTopologies { | 481 | + | name string |
| 353 | - | b.Run(topology.name, func(b *testing.B) { | 482 | + | size domain.Size |
| 354 | - | fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000}) | 483 | + | historyRows int |
| 355 | - | if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) { | 484 | + | baseline bool |
| 356 | - | b.Fatal("invalid daemon snapshot encode fixture") | 485 | + | mutate func(*performanceFixture, int) |
| 357 | - | } | 486 | + | unchanged bool |
| 358 | - | capture, ok := fixture.d.captureSnapshotState(fixture.sess, 1) | 487 | + | cellLimited bool |
| 359 | - | if !ok || !validBenchmarkCapture(capture) { | 488 | + | } |
| 360 | - | b.Fatal("invalid snapshot encode fixture") | 489 | + | |
| 361 | - | } | 490 | + | var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{ |
| 362 | - | encoded, err := fixture.d.encodeSnapshotCapture(capture) | 491 | + | {name: "initial-10k-x-120", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000}, |
| 363 | - | if err != nil { | 492 | + | {name: "unchanged", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, unchanged: true}, |
| 364 | - | b.Fatal(err) | 493 | + | {name: "visible-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkVisible}, |
| 365 | - | } | 494 | + | // Leave one row below the retention cap so the mutation changes only the |
| 366 | - | if _, err := snapcodec.Unmarshal(encoded); err != nil { | 495 | + | // mutable tail and cannot evict a sealed chunk. |
| 367 | - | b.Fatalf("invalid v3 snapshot encode fixture: %v", err) | 496 | + | {name: "tail-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 9_999, baseline: true, mutate: mutateBenchmarkTail}, |
| 368 | - | } | 497 | + | {name: "new-sealed-chunk", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkSealedChunk}, |
| 369 | - | var last []byte | 498 | + | {name: "cell-limited-293-columns", size: domain.Size{Cols: 293, Rows: 40}, historyRows: 10_000, cellLimited: true}, |
| 370 | - | b.ReportAllocs() | 499 | + | } |
| 371 | - | b.SetBytes(int64(len(encoded))) | 500 | + | |
| 372 | - | b.ResetTimer() | 501 | + | func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBenchmarkScenario) { |
| 373 | - | for range b.N { | 502 | + | var ( |
| 374 | - | last, err = fixture.d.encodeSnapshotCapture(capture) | 503 | + | metrics countingSnapshotMetrics |
| 375 | - | if err != nil { | 504 | + | peakCacheBytes int |
| 376 | - | b.Fatal(err) | 505 | + | ) |
| 377 | - | } | 506 | + | b.ReportAllocs() |
| 378 | - | } | 507 | + | b.ResetTimer() |
| 379 | - | b.StopTimer() | 508 | + | for operation := 0; b.Loop(); operation++ { |
| 380 | - | if _, err := snapcodec.Unmarshal(last); err != nil { | 509 | + | // A new fixture is deliberately outside the timed region. It gives every |
| 381 | - | b.Fatalf("snapshot encoder produced invalid v3 data: %v", err) | 510 | + | // iteration the same filesystem state: initial has no generation, |
| 382 | - | } | 511 | + | // unchanged/visible/tail/sealed begin after one persisted generation. |
| 383 | - | b.ReportMetric(float64(len(last)), "snapshotbytes/op") | 512 | + | // Reusing one fixture would eventually turn a tail into a sealed chunk |
| 384 | - | }) | 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 | func benchmarkDaemonSnapshotRestore(b *testing.B) { | 635 | func benchmarkDaemonSnapshotRestore(b *testing.B) { | ||
| @@ -394,17 +641,17 @@ func benchmarkDaemonSnapshotRestore(b *testing.B) { | |||||
| 394 | if !ok || !validBenchmarkCapture(capture) { | 641 | if !ok || !validBenchmarkCapture(capture) { | ||
| 395 | b.Fatal("invalid snapshot restore fixture") | 642 | b.Fatal("invalid snapshot restore fixture") | ||
| 396 | } | 643 | } | ||
| 397 | - | encoded, err := fixture.d.encodeSnapshotCapture(capture) | 644 | + | publication, err := fixture.d.incrementalPublication(capture) |
| 398 | if err != nil { | 645 | if err != nil { | ||
| 399 | b.Fatal(err) | 646 | b.Fatal(err) | ||
| 400 | } | 647 | } | ||
| 401 | - | snapshot, err := snapcodec.Unmarshal(encoded) | 648 | + | snapshot, err := sessionFromGeneration(snapshotGeneration(publication)) |
| 402 | if err != nil || len(snapshot.Tabs) != 1 || len(snapshot.Tabs[0].Panes) == 0 { | 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 | paneSnapshot := snapshot.Tabs[0].Panes[0] | 652 | paneSnapshot := snapshot.Tabs[0].Panes[0] | ||
| 406 | b.ReportAllocs() | 653 | b.ReportAllocs() | ||
| 407 | - | b.SetBytes(int64(len(encoded))) | 654 | + | b.SetBytes(int64(snapshotPublicationBytes(publication))) |
| 408 | b.ResetTimer() | 655 | b.ResetTimer() | ||
| 409 | for range b.N { | 656 | for range b.N { | ||
| 410 | if err := restorePaneTerminal(fixture.activePane, paneSnapshot); err != nil { | 657 | if err := restorePaneTerminal(fixture.activePane, paneSnapshot); err != nil { | ||
| @@ -422,7 +669,27 @@ func benchmarkDaemonSnapshotRestore(b *testing.B) { | |||||
| 422 | } | 669 | } | ||
| 423 | 670 | ||||
| 424 | func validBenchmarkCapture(capture *snapshotCapture) bool { | 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 | func capturePaneCount(capture *snapshotCapture) int { | 695 | func capturePaneCount(capture *snapshotCapture) int { | ||
| @@ -473,7 +740,8 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations | |||||
| 473 | b.ReportMetric(float64(metrics.outputBytes)/perOperation, "outputbytes/op") | 740 | b.ReportMetric(float64(metrics.outputBytes)/perOperation, "outputbytes/op") | ||
| 474 | b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op") | 741 | b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op") | ||
| 475 | b.ReportMetric(float64(metrics.snapshotWrites)/perOperation, "snapshotwrites/op") | 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 | b.ReportMetric(float64(metrics.coordinatorInvalidations)/perOperation, "coordinatorinvalidations/op") | 745 | b.ReportMetric(float64(metrics.coordinatorInvalidations)/perOperation, "coordinatorinvalidations/op") | ||
| 478 | b.ReportMetric(float64(metrics.coordinatorWakes)/perOperation, "coordinatorwakes/op") | 746 | b.ReportMetric(float64(metrics.coordinatorWakes)/perOperation, "coordinatorwakes/op") | ||
| 479 | b.ReportMetric(float64(metrics.coordinatorCoalesced)/perOperation, "coordinatorcoalesced/op") | 747 | b.ReportMetric(float64(metrics.coordinatorCoalesced)/perOperation, "coordinatorcoalesced/op") | ||
| @@ -587,7 +855,8 @@ type performanceMetrics struct { | |||||
| 587 | outputBytes uint64 | 855 | outputBytes uint64 | ||
| 588 | outputPayloadBytes uint64 | 856 | outputPayloadBytes uint64 | ||
| 589 | snapshotWrites uint64 | 857 | snapshotWrites uint64 | ||
| 590 | - | snapshotBytes uint64 | 858 | + | snapshotManifestBytes uint64 |
| 859 | + | snapshotObjectBytes uint64 | |||
| 591 | coordinatorInvalidations uint64 | 860 | coordinatorInvalidations uint64 | ||
| 592 | coordinatorWakes uint64 | 861 | coordinatorWakes uint64 | ||
| 593 | coordinatorCoalesced uint64 | 862 | coordinatorCoalesced uint64 | ||
| @@ -674,7 +943,7 @@ type performanceFixture struct { | |||||
| 674 | sess *session | 943 | sess *session | ||
| 675 | ac *attachedClient | 944 | ac *attachedClient | ||
| 676 | output *countingOutputTransport | 945 | output *countingOutputTransport | ||
| 677 | - | snaps *countingSnapshotStore | 946 | + | snapshots *countingSnapshotRepository |
| 678 | activePane *pane | 947 | activePane *pane | ||
| 679 | liveWrites [][]byte | 948 | liveWrites [][]byte | ||
| 680 | paints int | 949 | paints int | ||
| @@ -727,13 +996,13 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||||
| 727 | sess: sess, | 996 | sess: sess, | ||
| 728 | ac: ac, | 997 | ac: ac, | ||
| 729 | output: output, | 998 | output: output, | ||
| 730 | - | snaps: &countingSnapshotStore{}, | 999 | + | snapshots: &countingSnapshotRepository{}, |
| 731 | pty: pty, | 1000 | pty: pty, | ||
| 732 | clock: clock, | 1001 | clock: clock, | ||
| 733 | liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")}, | 1002 | liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")}, | ||
| 734 | resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size}, | 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 | d.startSnapshotEncodeWorker() | 1006 | d.startSnapshotEncodeWorker() | ||
| 738 | t.Cleanup(d.stopSnapshotEncodeWorker) | 1007 | t.Cleanup(d.stopSnapshotEncodeWorker) | ||
| 739 | for tabIndex, tb := range sess.tabs { | 1008 | for tabIndex, tb := range sess.tabs { | ||
| @@ -778,7 +1047,7 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR | |||||
| 778 | for _, p := range tb.panes { | 1047 | for _, p := range tb.panes { | ||
| 779 | p.mu.Lock() | 1048 | p.mu.Lock() | ||
| 780 | width := p.screen.Frame.Width | 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 | p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n")) | 1051 | p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n")) | ||
| 783 | } | 1052 | } | ||
| 784 | p.mu.Unlock() | 1053 | p.mu.Unlock() | ||
| @@ -927,7 +1196,7 @@ func (f *performanceFixture) resized() bool { | |||||
| 927 | 1196 | ||||
| 928 | func (f *performanceFixture) resetMetrics() { | 1197 | func (f *performanceFixture) resetMetrics() { | ||
| 929 | f.output.reset() | 1198 | f.output.reset() | ||
| 930 | - | f.snaps.reset() | 1199 | + | f.snapshots.reset() |
| 931 | f.renderCaptures = 0 | 1200 | f.renderCaptures = 0 | ||
| 932 | f.renderCompositions = 0 | 1201 | f.renderCompositions = 0 | ||
| 933 | f.renderEmissions = 0 | 1202 | f.renderEmissions = 0 | ||
| @@ -943,10 +1212,10 @@ func (f *performanceFixture) resetMetrics() { | |||||
| 943 | 1212 | ||||
| 944 | func (f *performanceFixture) metrics() performanceMetrics { | 1213 | func (f *performanceFixture) metrics() performanceMetrics { | ||
| 945 | output := f.output.metrics() | 1214 | output := f.output.metrics() | ||
| 946 | - | snapshots := f.snaps.metrics() | 1215 | + | snapshots := f.snapshots.metrics() |
| 947 | metrics := performanceMetrics{ | 1216 | metrics := performanceMetrics{ | ||
| 948 | outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes, | 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 | renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions, | 1219 | renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions, | ||
| 951 | resizeRequests: f.resizeRequests, resizeCommits: f.resizeCommits, resizeCommitNanos: f.resizeCommitNanos, | 1220 | resizeRequests: f.resizeRequests, resizeCommits: f.resizeCommits, resizeCommitNanos: f.resizeCommitNanos, | ||
| 952 | ptyFailures: f.ptyFailures, ptyRetries: f.ptyRetries, | 1221 | ptyFailures: f.ptyFailures, ptyRetries: f.ptyRetries, | ||
| @@ -1010,35 +1279,76 @@ func (t *countingOutputTransport) lastPayload() []byte { | |||||
| 1010 | return t.last | 1279 | return t.last | ||
| 1011 | } | 1280 | } | ||
| 1012 | 1281 | ||||
| 1013 | - | type countingSnapshotMetrics struct{ writes, bytes uint64 } | 1282 | + | // snapshotHeadBytes is the on-disk VEVH header (magic, generation, and |
| 1014 | - | type countingSnapshotStore struct { | 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 | mu sync.Mutex | 1290 | mu sync.Mutex | ||
| 1016 | countingSnapshotMetrics | 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 | s.mu.Lock() | 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 | s.writes++ | 1311 | s.writes++ | ||
| 1023 | - | s.bytes += uint64(len(data)) | 1312 | + | s.manifestBytes += uint64(len(publication.Manifest)) |
| 1024 | - | s.last = data | 1313 | + | s.headBytes += snapshotHeadBytes |
| 1025 | - | s.mu.Unlock() | 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 | return nil | 1330 | return nil | ||
| 1027 | } | 1331 | } | ||
| 1028 | - | func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | 1332 | + | func (*countingSnapshotRepository) List(context.Context) ([]string, error) { return nil, nil } |
| 1029 | - | func (*countingSnapshotStore) Delete(string) error { return nil } | 1333 | + | func (*countingSnapshotRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) { |
| 1030 | - | func (s *countingSnapshotStore) reset() { | 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 | s.mu.Lock() | 1341 | s.mu.Lock() | ||
| 1032 | s.countingSnapshotMetrics = countingSnapshotMetrics{} | 1342 | s.countingSnapshotMetrics = countingSnapshotMetrics{} | ||
| 1033 | s.last = nil | 1343 | s.last = nil | ||
| 1034 | s.mu.Unlock() | 1344 | s.mu.Unlock() | ||
| 1035 | } | 1345 | } | ||
| 1036 | - | func (s *countingSnapshotStore) metrics() countingSnapshotMetrics { | 1346 | + | func (s *countingSnapshotRepository) metrics() countingSnapshotMetrics { |
| 1037 | s.mu.Lock() | 1347 | s.mu.Lock() | ||
| 1038 | defer s.mu.Unlock() | 1348 | defer s.mu.Unlock() | ||
| 1039 | return s.countingSnapshotMetrics | 1349 | return s.countingSnapshotMetrics | ||
| 1040 | } | 1350 | } | ||
| 1041 | - | func (s *countingSnapshotStore) lastPayload() []byte { | 1351 | + | func (s *countingSnapshotRepository) lastPayload() []byte { |
| 1042 | s.mu.Lock() | 1352 | s.mu.Lock() | ||
| 1043 | defer s.mu.Unlock() | 1353 | defer s.mu.Unlock() | ||
| 1044 | return s.last | 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 | func (d *Daemon) killPickerTarget(target picker.Target) error { | 548 | func (d *Daemon) killPickerTarget(target picker.Target) error { | ||
| 549 | if target.Stopped { | 549 | if target.Stopped { | ||
| 550 | - | d.mu.Lock() | 550 | + | if err := d.retryStoppedPurge(target.Name); err != nil { |
| 551 | - | stopped, ok := d.stopped[target.Name] | 551 | + | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name) |
| 552 | - | if ok && !stopped.purging { | 552 | + | return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err) |
| 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) | |||
| 559 | } | 553 | } | ||
| 560 | - | d.mu.Unlock() | |||
| 561 | return nil | 554 | return nil | ||
| 562 | } | 555 | } | ||
| 563 | d.mu.Lock() | 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 | func (failingDeleteStore) Sync() error { return nil } | 35 | func (failingDeleteStore) Sync() error { return nil } | ||
| 36 | func (failingDeleteStore) Close() error { return nil } | 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 } | 40 | + | func (refusingSnapshotDeleteRepository) Publish(context.Context, ports.SnapshotPublication) error { |
| 41 | - | func (refusingSnapshotDeleteStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | 41 | + | return nil |
| 42 | - | func (s refusingSnapshotDeleteStore) Delete(string) error { return s.err } | 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 | func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab { | 54 | func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab { | ||
| 45 | tb := newTab(p, domain.Size{Cols: 80, Rows: 23}) | 55 | tb := newTab(p, domain.Size{Cols: 80, Rows: 23}) | ||
| @@ -796,7 +806,7 @@ func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testin | |||||
| 796 | } | 806 | } | ||
| 797 | 807 | ||||
| 798 | func TestPickerPreviewContainsOnlyVisibleFrameRowsWithLargeScrollback(t *testing.T) { | 808 | func TestPickerPreviewContainsOnlyVisibleFrameRowsWithLargeScrollback(t *testing.T) { | ||
| 799 | - | withScrollback := newPickerPreviewTabWithHistoryRows(10_000) | 809 | + | withScrollback := newPickerPreviewTabWithHistoryRows(t, 10_000) |
| 800 | require.Equal(t, 10_000, withScrollback.focusedPane().screen.History().Len()) | 810 | require.Equal(t, 10_000, withScrollback.focusedPane().screen.History().Len()) | ||
| 801 | 811 | ||||
| 802 | preview := snapshotPickerPreview(withScrollback) | 812 | preview := snapshotPickerPreview(withScrollback) | ||
| @@ -820,11 +830,12 @@ func TestPickerPreviewContainsOnlyVisibleFrameRowsWithLargeScrollback(t *testing | |||||
| 820 | require.NotContains(t, got, "history-only-marker") | 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 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | 835 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | ||
| 825 | p := tb.focusedPane() | 836 | p := tb.focusedPane() | ||
| 826 | for range historyRows { | 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 | p.screen.Write([]byte("NOW")) | 840 | p.screen.Write([]byte("NOW")) | ||
| 830 | return tb | 841 | return tb | ||
| @@ -997,7 +1008,7 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t | |||||
| 997 | d, from, ac, sends, releases := newRecentNavigationTestSessions(t) | 1008 | d, from, ac, sends, releases := newRecentNavigationTestSessions(t) | ||
| 998 | defer releaseAll(releases) | 1009 | defer releaseAll(releases) | ||
| 999 | cause := errors.New("snapshot delete refused") | 1010 | cause := errors.New("snapshot delete refused") | ||
| 1000 | - | WithSnapshotStore(refusingSnapshotDeleteStore{err: cause})(d) | 1011 | + | WithSnapshotRepository(refusingSnapshotDeleteRepository{err: cause}, nil)(d) |
| 1001 | target := d.sessions[domain.SessionID("recent")] | 1012 | target := d.sessions[domain.SessionID("recent")] | ||
| 1002 | 1013 | ||||
| 1003 | d.enterPicker(from, ac) | 1014 | d.enterPicker(from, ac) | ||
M internal/usecase/daemon/render_test.go +1 -1
| @@ -1289,7 +1289,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) { | |||||
| 1289 | defer releaseFloating() | 1289 | defer releaseFloating() | ||
| 1290 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | 1290 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | ||
| 1291 | fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3}) | 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 | fp.screen.Write([]byte("flt-live")) | 1293 | fp.screen.Write([]byte("flt-live")) | ||
| 1294 | installTestFloating(sess.activeTab(), fp, true) | 1294 | installTestFloating(sess.activeTab(), fp, true) | ||
| 1295 | 1295 | ||||
M internal/usecase/daemon/session.go +268 -77
| @@ -43,6 +43,7 @@ type session struct { | |||||
| 43 | client *attachedClient | 43 | client *attachedClient | ||
| 44 | clipboardQueue []clipboardForward | 44 | clipboardQueue []clipboardForward | ||
| 45 | clipboardWorkerRunning bool | 45 | clipboardWorkerRunning bool | ||
| 46 | + | renameInProgress bool | |||
| 46 | cwd string | 47 | cwd string | ||
| 47 | terminal terminalEnv | 48 | terminal terminalEnv | ||
| 48 | // env is the authoritative immutable environment snapshot for future PTY children. | 49 | // env is the authoritative immutable environment snapshot for future PTY children. | ||
| @@ -52,14 +53,37 @@ type session struct { | |||||
| 52 | mruAt atomic.Uint64 | 53 | mruAt atomic.Uint64 | ||
| 53 | snapDirty atomic.Bool | 54 | snapDirty atomic.Bool | ||
| 54 | snapEligible atomic.Bool | 55 | snapEligible atomic.Bool | ||
| 55 | - | // snapshotMu serializes the dirty generation with worker completion. It is | 56 | + | // snapshotMu serializes mutation revisions and repository publication |
| 56 | - | // intentionally independent from mu: persistence never holds session state | 57 | + | // generations with worker completion. It is intentionally independent from |
| 57 | - | // locks while encoding or writing. | 58 | + | // mu: persistence never holds session state locks while encoding or writing. |
| 58 | - | snapshotMu sync.Mutex | 59 | + | snapshotMu sync.Mutex |
| 59 | - | snapshotGeneration uint64 | 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 | snapshotPending bool | 68 | snapshotPending bool | ||
| 61 | snapshotPendingCaptures uint | 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 | // syncGen makes synchronized-output watchdog generations unique across all | 87 | // syncGen makes synchronized-output watchdog generations unique across all | ||
| 64 | // panes in this session. | 88 | // panes in this session. | ||
| 65 | syncGen atomic.Uint64 | 89 | syncGen atomic.Uint64 | ||
| @@ -193,16 +217,20 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||||
| 193 | lastUsedSeq = stopped.lastUsedSeq | 217 | lastUsedSeq = stopped.lastUsedSeq | ||
| 194 | } | 218 | } | ||
| 195 | sess := &session{ | 219 | sess := &session{ | ||
| 196 | - | id: id, | 220 | + | id: id, |
| 197 | - | name: name, | 221 | + | name: name, |
| 198 | - | ephemeral: ephemeral, | 222 | + | ephemeral: ephemeral, |
| 199 | - | ctx: sctx, | 223 | + | ctx: sctx, |
| 200 | - | cancel: cancel, | 224 | + | cancel: cancel, |
| 201 | - | tabs: tabs, | 225 | + | tabs: tabs, |
| 202 | - | cwd: cwd, | 226 | + | cwd: cwd, |
| 203 | - | terminal: term, | 227 | + | terminal: term, |
| 204 | - | env: env, | 228 | + | env: env, |
| 205 | - | createdAt: createdAt, | 229 | + | createdAt: createdAt, |
| 230 | + | snapshotWake: d.snapshotWake, | |||
| 231 | + | } | |||
| 232 | + | if !ephemeral && name != "" { | |||
| 233 | + | sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit) | |||
| 206 | } | 234 | } | ||
| 207 | if lastUsedSeq > 0 { | 235 | if lastUsedSeq > 0 { | ||
| 208 | sess.mruAt.Store(lastUsedSeq) | 236 | sess.mruAt.Store(lastUsedSeq) | ||
| @@ -521,15 +549,27 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||||
| 521 | if err := domain.ValidateSessionName(name); err != nil { | 549 | if err := domain.ValidateSessionName(name); err != nil { | ||
| 522 | return err | 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 | d.mu.Lock() | 557 | d.mu.Lock() | ||
| 525 | - | defer d.mu.Unlock() | |||
| 526 | if taken := d.findByNameLocked(name); taken != nil && taken != sess { | 558 | if taken := d.findByNameLocked(name); taken != nil && taken != sess { | ||
| 559 | + | d.mu.Unlock() | |||
| 527 | return errSessionNameInUse | 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 | if stopped, ok := d.stopped[name]; ok && stopped.name != sess.name { | 568 | if stopped, ok := d.stopped[name]; ok && stopped.name != sess.name { | ||
| 569 | + | sess.mu.Unlock() | |||
| 570 | + | d.mu.Unlock() | |||
| 530 | return errSessionNameInUse | 571 | return errSessionNameInUse | ||
| 531 | } | 572 | } | ||
| 532 | - | sess.mu.Lock() | |||
| 533 | oldName := sess.name | 573 | oldName := sess.name | ||
| 534 | wasEphemeral := sess.ephemeral | 574 | wasEphemeral := sess.ephemeral | ||
| 535 | createdAt := sess.createdAt | 575 | createdAt := sess.createdAt | ||
| @@ -538,45 +578,79 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||||
| 538 | createdAt, err = d.allocateLifecycleCreatedAtLocked() | 578 | createdAt, err = d.allocateLifecycleCreatedAtLocked() | ||
| 539 | if err != nil { | 579 | if err != nil { | ||
| 540 | sess.mu.Unlock() | 580 | sess.mu.Unlock() | ||
| 581 | + | d.mu.Unlock() | |||
| 541 | return err | 582 | return err | ||
| 542 | } | 583 | } | ||
| 543 | } | 584 | } | ||
| 544 | lastUsedSeq := sess.mruAt.Load() | 585 | lastUsedSeq := sess.mruAt.Load() | ||
| 545 | - | if wasEphemeral || oldName != name { | 586 | + | record := sess.persistRecordLocked(d.nowUnixNano()) |
| 546 | - | record := sess.persistRecordLocked(d.nowUnixNano()) | 587 | + | record.Name = name |
| 547 | - | record.Name = name | 588 | + | record.CreatedAt = createdAt |
| 548 | - | record.CreatedAt = createdAt | 589 | + | record.LastUsedSeq = lastUsedSeq |
| 549 | - | record.LastUsedSeq = lastUsedSeq | 590 | + | sess.renameInProgress = true |
| 550 | - | if err := d.persist.Save(record); err != nil { | 591 | + | sess.mu.Unlock() |
| 551 | - | sess.mu.Unlock() | 592 | + | d.mu.Unlock() |
| 552 | - | return err | 593 | + | |
| 553 | - | } | 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 | if !wasEphemeral && oldName != name { | 601 | if !wasEphemeral && oldName != name { | ||
| 556 | - | if d.snapsEnabled && d.snaps != nil { | 602 | + | quarantine = quarantineSnapshotCoordinatorWithEpoch(sess) |
| 557 | - | if err := d.snaps.Delete(oldName); err != nil { | 603 | + | <-quarantine.done |
| 558 | - | if cleanupErr := d.persist.Delete(name); cleanupErr != nil { | 604 | + | if err := d.beginSnapshotPurge(oldName); err != nil { |
| 559 | - | d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name) | 605 | + | return rollback(err) |
| 560 | - | } | |||
| 561 | - | sess.mu.Unlock() | |||
| 562 | - | return err | |||
| 563 | - | } | |||
| 564 | } | 606 | } | ||
| 565 | - | if err := d.persist.Delete(oldName); err != nil { | 607 | + | // finishSnapshotPurge preserves the tombstone through both source |
| 566 | - | if cleanupErr := d.persist.Delete(name); cleanupErr != nil { | 608 | + | // deletions and the old metadata delete, including a retained legacy |
| 567 | - | d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name) | 609 | + | // import whose verified source delete is still pending. |
| 568 | - | } | 610 | + | if err := d.finishSnapshotPurge(oldName); err != nil { |
| 569 | - | sess.mu.Unlock() | 611 | + | return rollback(err) |
| 570 | - | return 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 | delete(d.stopped, oldName) | 627 | delete(d.stopped, oldName) | ||
| 574 | delete(d.stopped, name) | 628 | delete(d.stopped, name) | ||
| 575 | sess.name = name | 629 | sess.name = name | ||
| 576 | sess.createdAt = createdAt | 630 | sess.createdAt = createdAt | ||
| 577 | sess.ephemeral = false | 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 | sess.mu.Unlock() | 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 | markSnapshotDirty(sess) | 654 | markSnapshotDirty(sess) | ||
| 581 | return nil | 655 | return nil | ||
| 582 | } | 656 | } | ||
| @@ -717,36 +791,155 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||||
| 717 | // ptyReader drains child output into the VT screen and pokes the dirty channel | 791 | // ptyReader drains child output into the VT screen and pokes the dirty channel | ||
| 718 | // (non-blocking: a full channel already means a render is pending). On any read | 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 | func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | 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 | ringing := sess.anyAttention() | 875 | ringing := sess.anyAttention() | ||
| 722 | sess.mu.Lock() | 876 | sess.mu.Lock() | ||
| 723 | isEphemeral := sess.ephemeral | 877 | isEphemeral := sess.ephemeral | ||
| 878 | + | name := sess.name | |||
| 724 | sess.mu.Unlock() | 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 | if !purge && !isEphemeral && d.persistEnabled { | 892 | if !purge && !isEphemeral && d.persistEnabled { | ||
| 726 | d.refreshSessionCwd(sess) | 893 | d.refreshSessionCwd(sess) | ||
| 727 | } | 894 | } | ||
| 728 | - | var snapshotDeleteErr, terminalSnapshotErr error | 895 | + | var terminalSnapshotErr error |
| 729 | - | if d.snapsEnabled && !isEphemeral { | 896 | + | retainSnapshotRetry := false |
| 730 | - | if purge { | 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 | sess.mu.Lock() | 903 | sess.mu.Lock() | ||
| 732 | name := sess.name | 904 | name := sess.name | ||
| 733 | sess.mu.Unlock() | 905 | sess.mu.Unlock() | ||
| 734 | - | if err := d.snaps.Delete(name); err != nil { | 906 | + | terminalSnapshotErr = fmt.Errorf("retain final snapshot for session %q: snapshot worker unavailable, saturated, or timed out", name) |
| 735 | - | snapshotDeleteErr = err | 907 | + | if reason != ports.ReasonServerShutdown { |
| 736 | - | d.log.Warn("deleting session snapshot failed", "err", err, "session", name) | 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 | } else { | 926 | } else { | ||
| 739 | - | markSnapshotDirty(sess) | 927 | + | quarantineDone = quarantineSnapshotCoordinator(sess) |
| 740 | - | if !d.scheduleFinalSnapshot(sess) { | 928 | + | } |
| 741 | - | sess.mu.Lock() | 929 | + | joined := true |
| 742 | - | name := sess.name | 930 | + | if deadline != nil { |
| 743 | - | sess.mu.Unlock() | 931 | + | select { |
| 744 | - | terminalSnapshotErr = fmt.Errorf("retain final snapshot for session %q: snapshot worker unavailable or saturated", name) | 932 | + | case <-quarantineDone: |
| 745 | - | if reason != ports.ReasonServerShutdown { | 933 | + | case <-deadline.Done(): |
| 746 | - | return terminalSnapshotErr | 934 | + | joined = false |
| 747 | - | } | |||
| 748 | - | d.persistShutdownSnapshotFailure(name, terminalSnapshotErr) | |||
| 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 | d.mu.Lock() | 945 | d.mu.Lock() | ||
| @@ -778,25 +971,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 778 | d.mu.Unlock() | 971 | d.mu.Unlock() | ||
| 779 | d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason) | 972 | d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason) | ||
| 780 | 973 | ||||
| 781 | - | purgeErr := snapshotDeleteErr | 974 | + | var purgeErr error |
| 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 | - | } | |||
| 800 | 975 | ||||
| 801 | // A global route that selected this attachment must publish before terminal | 976 | // A global route that selected this attachment must publish before terminal | ||
| 802 | // teardown makes it stale. | 977 | // teardown makes it stale. | ||
| @@ -840,6 +1015,22 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 840 | d.log.Warn("removing clipboard temp file failed", "err", err, "path", path) | 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 | if empty { | 1034 | if empty { | ||
| 844 | d.doneOnce.Do(func() { close(d.done) }) | 1035 | d.doneOnce.Do(func() { close(d.done) }) | ||
| 845 | } else if ringing { | 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 | type mockStoreState struct { | 676 | type mockStoreState struct { | ||
| 677 | - | mu sync.Mutex | 677 | + | mu sync.Mutex |
| 678 | - | data map[string][]byte | 678 | + | data map[string][]byte |
| 679 | - | sets int | 679 | + | sets int |
| 680 | - | dels []string | 680 | + | dels []string |
| 681 | - | syncs int | 681 | + | deleteErr func(string) error |
| 682 | - | closed bool | 682 | + | deleteDone chan struct{} |
| 683 | + | syncs int | |||
| 684 | + | closed bool | |||
| 683 | } | 685 | } | ||
| 684 | 686 | ||||
| 685 | func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) { | 687 | func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) { | ||
| @@ -701,9 +703,21 @@ func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) { | |||||
| 701 | }).Maybe() | 703 | }).Maybe() | ||
| 702 | store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error { | 704 | store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error { | ||
| 703 | state.mu.Lock() | 705 | state.mu.Lock() | ||
| 704 | - | defer state.mu.Unlock() | 706 | + | key := string(k) |
| 705 | - | state.dels = append(state.dels, string(k)) | 707 | + | state.dels = append(state.dels, key) |
| 706 | - | delete(state.data, string(k)) | 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 | return nil | 721 | return nil | ||
| 708 | }).Maybe() | 722 | }).Maybe() | ||
| 709 | store.EXPECT().Range(mock.Anything).Run(func(fn func(k, v []byte) bool) { | 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 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | - | "math" | |||
| 7 | "sync" | 7 | "sync" | ||
| 8 | "time" | 8 | "time" | ||
| 9 | 9 | ||||
| 10 | - | "github.com/bnema/vev/internal/domain" | |||
| 11 | "github.com/bnema/vev/internal/usecase/layout" | 10 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 12 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 11 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| 13 | "github.com/bnema/vev/pkg/vt" | 12 | "github.com/bnema/vev/pkg/vt" | ||
| @@ -22,14 +21,29 @@ const snapshotQueueCapacity = 1 | |||||
| 22 | // (and left dirty) rather than silently dropping a persistence acknowledgement. | 21 | // (and left dirty) rather than silently dropping a persistence acknowledgement. | ||
| 23 | const snapshotFinalQueueCapacity = 32 | 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 | type snapshotCapture struct { | 34 | type snapshotCapture struct { | ||
| 26 | - | session *session | 35 | + | session *session |
| 27 | - | generation uint64 | 36 | + | attemptKind snapshotAttemptKind |
| 28 | - | name string | 37 | + | generation uint64 // repository publication generation |
| 29 | - | createdAt uint64 | 38 | + | mutationRevision uint64 |
| 30 | - | active uint16 | 39 | + | name string |
| 31 | - | tabs []snapshotCaptureTab | 40 | + | createdAt uint64 |
| 32 | - | finishOnce sync.Once | 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 | type snapshotCaptureTab struct { | 49 | type snapshotCaptureTab struct { | ||
| @@ -43,779 +57,40 @@ type snapshotCaptureTab struct { | |||||
| 43 | } | 57 | } | ||
| 44 | 58 | ||||
| 45 | type snapshotCapturePane struct { | 59 | type snapshotCapturePane struct { | ||
| 46 | - | id layout.PaneID | 60 | + | id layout.PaneID |
| 47 | - | stableID string | 61 | + | stableID string |
| 48 | - | cwd string | 62 | + | cwd string |
| 49 | - | history vt.HistoryView | 63 | + | // Sealed identities and the copied mutable tail are retained separately, so |
| 50 | - | visible []byte | 64 | + | // capture never rotates a live mutable tail merely to persist it. |
| 51 | - | visibleErr error | 65 | + | sealed vt.HistorySnapshotView |
| 52 | - | process *snapcodec.Process | 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 | const ( | 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 | snapshotFinalFlushTimeout = time.Second | 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 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 6 | "github.com/stretchr/testify/require" | 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 | "github.com/bnema/vev/pkg/renderer" | 9 | "github.com/bnema/vev/pkg/renderer" | ||
| 15 | "github.com/bnema/vev/pkg/vt" | 10 | "github.com/bnema/vev/pkg/vt" | ||
| 16 | ) | 11 | ) | ||
| 17 | 12 | ||||
| 18 | - | func TestSnapshotCaptureMarshalUnmarshalRestorePreservesTopologyAndTerminalState(t *testing.T) { | 13 | + | func TestSnapshotChunkCacheIsScopedToNamedSession(t *testing.T) { |
| 19 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | 14 | + | d := New(nil, nil, nil) |
| 20 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 15 | + | a := newSnapshotTestSession(t, "a", false, "/a") |
| 21 | - | WithSnapshotStore(store)(d) | 16 | + | b := newSnapshotTestSession(t, "b", false, "/b") |
| 22 | - | startSnapshotEncodeWorker(t, d) | 17 | + | a.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit) |
| 23 | - | 18 | + | b.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit) | |
| 24 | - | sess := newSnapshotTestSession(t, "acceptance", false, "/work") | 19 | + | |
| 25 | - | first := sess.tabs[0] | 20 | + | // Eleven maximum-size chunks exceed the per-session 16 MiB cache budget. |
| 26 | - | first.size = domain.Size{Cols: 80, Rows: 24} | 21 | + | // The cache keeps its warmed working set rather than admitting every |
| 27 | - | first.stableID = "tab-one" | 22 | + | // oldest-to-newest scan miss. |
| 28 | - | first.nextPaneID = 3 | 23 | + | aHistory := snapshotCacheHistory(t, 11, 'a') |
| 29 | - | first.panes["pane-1"].stableID = "pane-one" | 24 | + | aCapture := snapshotCacheCapture(a, aHistory, 1) |
| 30 | - | secondPane := newPaneWithStableID("pane-2", "pane-two", snapshotAcceptancePTY(t), domain.Size{Cols: 40, Rows: 24}) | 25 | + | aFirst, err := d.incrementalPublication(aCapture) |
| 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) | |||
| 51 | require.NoError(t, err) | 26 | require.NoError(t, err) | ||
| 52 | - | assertCapturedSessionTopology(t, captured) | 27 | + | require.NotEmpty(t, aFirst.Objects) |
| 53 | - | 28 | + | require.LessOrEqual(t, a.snapshotChunkCache.used, a.snapshotChunkCache.limit) | |
| 54 | - | restoreFactory := &restorePTYFactory{} | 29 | + | require.LessOrEqual(t, a.snapshotChunkCache.limit, snapshotChunkCacheLimit) |
| 55 | - | restoredDaemon := newTestDaemon(t, restoreFactory, stubClock{}) | 30 | + | require.Contains(t, a.snapshotChunkCache.byPtr, aHistory.Chunk(0), "session A should retain its warmed entry") |
| 56 | - | require.NoError(t, restoredDaemon.restoreSession(t.Context(), captured)) | 31 | + | |
| 57 | - | restoredDaemon.mu.Lock() | 32 | + | bHistory := snapshotCacheHistory(t, 1, 'b') |
| 58 | - | restored := restoredDaemon.findByNameLocked("acceptance") | 33 | + | bCapture := snapshotCacheCapture(b, bHistory, 1) |
| 59 | - | restoredDaemon.mu.Unlock() | 34 | + | _, err = d.incrementalPublication(bCapture) |
| 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) | |||
| 118 | require.NoError(t, err) | 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) | 36 | + | require.Contains(t, b.snapshotChunkCache.byPtr, bHistory.Chunk(0)) |
| 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 | - | } | |||
| 126 | 37 | ||||
| 127 | - | func TestDaemonHistoryEvictionLeavesCopyAndSearchSnapshotsUsable(t *testing.T) { | 38 | + | inFlightBytes := append([]byte(nil), aFirst.Objects[0].Data...) |
| 128 | - | sess := newSnapshotTestSession(t, "copy-lifetime", false, "/work") | 39 | + | aSecond := snapshotCacheCapture(a, snapshotCacheHistory(t, 1, 'z'), 2) |
| 129 | - | pane := sess.tabs[0].panes["pane-1"] | 40 | + | _, err = d.incrementalPublication(aSecond) |
| 130 | - | pane.screen = vt.NewScreenWithHistory(8, 1, vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | 41 | + | require.NoError(t, err) |
| 131 | - | pane.history = pane.screen.History() | 42 | + | require.True(t, bytes.Equal(inFlightBytes, aFirst.Objects[0].Data), "cache pruning must not mutate queued publication bytes") |
| 132 | - | for _, text := range []string{"first", "second"} { | 43 | + | require.LessOrEqual(t, a.snapshotChunkCache.used, snapshotChunkCacheLimit) |
| 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 | - | } | |||
| 181 | 44 | ||||
| 182 | - | func snapshotAcceptancePTY(t *testing.T) *portsmocks.MockPTY { | 45 | + | _, err = d.incrementalPublication(snapshotCacheCapture(b, bHistory, 2)) |
| 183 | - | t.Helper() | 46 | + | require.NoError(t, err) |
| 184 | - | pty := portsmocks.NewMockPTY(t) | 47 | + | require.Contains(t, b.snapshotChunkCache.byPtr, bHistory.Chunk(0), "session A eviction must not affect session B") |
| 185 | - | pty.EXPECT().Pid().Return(0).Maybe() | 48 | + | require.LessOrEqual(t, b.snapshotChunkCache.used, snapshotChunkCacheLimit) |
| 186 | - | pty.EXPECT().ForegroundPgid().Return(0, nil).Maybe() | |||
| 187 | - | return pty | |||
| 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 | t.Helper() | 52 | t.Helper() | ||
| 192 | - | require.Equal(t, "acceptance", got.Name) | 53 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: chunks * 256, ChunkRows: 256}) |
| 193 | - | require.Equal(t, uint16(1), got.Active) | 54 | + | for row := 0; row < chunks*256; row++ { |
| 194 | - | require.Len(t, got.Tabs, 2) | 55 | + | cells := make([]renderer.Cell, 160) |
| 195 | - | for _, tt := range []struct { | 56 | + | for col := range cells { |
| 196 | - | name string | 57 | + | cells[col] = renderer.Cell{Rune: first + rune((row+col)%26)} |
| 197 | - | tab int | 58 | + | } |
| 198 | - | stableID string | 59 | + | require.NoError(t, history.Append(cells)) |
| 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 | - | }) | |||
| 229 | } | 60 | } | ||
| 61 | + | return history.SnapshotView() | |||
| 230 | } | 62 | } | ||
| 231 | 63 | ||||
| 232 | - | func assertTreeReferencesCapturedPanes(t *testing.T, node *layout.Node, panes map[layout.PaneID]struct{}) { | 64 | + | func snapshotCacheCapture(sess *session, history vt.HistorySnapshotView, generation uint64) *snapshotCapture { |
| 233 | - | t.Helper() | 65 | + | return &snapshotCapture{ |
| 234 | - | if node == nil { | 66 | + | session: sess, |
| 235 | - | return | 67 | + | name: sess.name, |
| 236 | - | } | 68 | + | generation: generation, |
| 237 | - | if node.Kind == layout.Leaf { | 69 | + | tabs: []snapshotCaptureTab{{ |
| 238 | - | _, ok := panes[node.Leaf] | 70 | + | stableID: "tab", |
| 239 | - | require.True(t, ok, "tree leaf %q is dangling", node.Leaf) | 71 | + | cols: 1, |
| 240 | - | return | 72 | + | rows: 1, |
| 241 | - | } | 73 | + | panes: []snapshotCapturePane{{ |
| 242 | - | if node.Kind == layout.Stack && node.Expanded != "" { | 74 | + | id: "pane", |
| 243 | - | _, ok := panes[node.Expanded] | 75 | + | stableID: "pane", |
| 244 | - | require.True(t, ok, "stack expanded pane %q is dangling", node.Expanded) | 76 | + | sealed: history, |
| 245 | - | } | 77 | + | tail: history.Tail(), |
| 246 | - | for _, child := range node.Children { | 78 | + | visible: vt.NewScreen(1, 1).PrimaryVisibleSnapshot(), |
| 247 | - | assertTreeReferencesCapturedPanes(t, child, panes) | 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 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "encoding/binary" | |||
| 6 | "errors" | 5 | "errors" | ||
| 7 | - | "io" | |||
| 8 | "math" | 6 | "math" | ||
| 9 | "sync" | 7 | "sync" | ||
| 10 | "testing" | 8 | "testing" | ||
| 11 | - | "time" | |||
| 12 | 9 | ||||
| 13 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 14 | 11 | ||||
| 15 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| 16 | "github.com/bnema/vev/internal/ports" | 13 | "github.com/bnema/vev/internal/ports" | ||
| 17 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 18 | "github.com/bnema/vev/internal/usecase/layout" | 14 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 19 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 15 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| 20 | "github.com/bnema/vev/pkg/renderer" | 16 | "github.com/bnema/vev/pkg/renderer" | ||
| 21 | "github.com/bnema/vev/pkg/vt" | 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 { | 20 | + | type snapshotAcceptanceRepository struct { |
| 25 | - | t.Helper() | 21 | + | mu sync.Mutex |
| 26 | - | h := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows}) | 22 | + | names []string |
| 27 | - | for _, row := range historyRows { | 23 | + | generations map[string]ports.SnapshotGeneration |
| 28 | - | h.Append(row) | 24 | + | publishErr error |
| 29 | - | } | 25 | + | listErr error |
| 30 | - | sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView()) | 26 | + | loadErr error |
| 31 | - | require.NoError(t, err) | 27 | + | publishes []ports.SnapshotPublication |
| 32 | - | frame := renderer.NewFrame(40, 24) | 28 | + | loadMutate func(ports.SnapshotGeneration, ports.SnapshotPublication) ports.SnapshotGeneration |
| 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} | |||
| 39 | } | 29 | } | ||
| 40 | 30 | ||||
| 41 | - | func TestRestorePaneTerminalRejectsMissingOrMalformedCanonicalBlobs(t *testing.T) { | 31 | + | func (r *snapshotAcceptanceRepository) Publish(ctx context.Context, p ports.SnapshotPublication) error { |
| 42 | - | valid := terminalPane(t, "pane", "p", "/work", nil, nil) | 32 | + | if err := ctx.Err(); err != nil { |
| 43 | - | tests := []struct { | 33 | + | return err |
| 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") }}, | |||
| 51 | } | 34 | } | ||
| 52 | - | for _, tt := range tests { | 35 | + | r.mu.Lock() |
| 53 | - | t.Run(tt.name, func(t *testing.T) { | 36 | + | defer r.mu.Unlock() |
| 54 | - | snap := valid | 37 | + | r.publishes = append(r.publishes, p) |
| 55 | - | tt.mutate(&snap) | 38 | + | if r.publishErr != nil { |
| 56 | - | p := newPane("pane", newRestorePTY(), domain.Size{Cols: 40, Rows: 24}) | 39 | + | return r.publishErr |
| 57 | - | require.Error(t, restorePaneTerminal(p, snap)) | |||
| 58 | - | }) | |||
| 59 | } | 40 | } | ||
| 60 | - | } | 41 | + | objects := make(map[ports.SnapshotDigest][]byte, len(p.Objects)) |
| 61 | - | 42 | + | for _, object := range p.Objects { | |
| 62 | - | func TestRestoreSnapshotsNilDaemonDoesNotPanic(t *testing.T) { | 43 | + | objects[object.Digest] = append([]byte(nil), object.Data...) |
| 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 | - | } | |||
| 144 | } | 44 | } | ||
| 145 | - | require.Len(t, attachedOpens, 1) | 45 | + | r.generations[p.Name] = ports.SnapshotGeneration{Name: p.Name, Generation: p.Generation, Manifest: append([]byte(nil), p.Manifest...), Objects: objects} |
| 146 | - | require.Equal(t, "/bin/attached-shell", attachedOpens[0].command) | 46 | + | found := false |
| 147 | - | require.Equal(t, []string{ | 47 | + | for _, name := range r.names { |
| 148 | - | "ORDINARY=attached", "SHELL=/bin/attached-shell", | 48 | + | found = found || name == p.Name |
| 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() | |||
| 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 { | 56 | + | func (r *snapshotAcceptanceRepository) List(ctx context.Context) ([]string, error) { |
| 273 | - | t.Helper() | 57 | + | if err := ctx.Err(); err != nil { |
| 274 | - | pane := emptyTerminalPane(t, "pane-1", "/tmp") | 58 | + | return nil, err |
| 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 | - | }, | |||
| 348 | } | 59 | } | ||
| 349 | - | 60 | + | r.mu.Lock() | |
| 350 | - | for _, tt := range tests { | 61 | + | defer r.mu.Unlock() |
| 351 | - | t.Run(tt.name, func(t *testing.T) { | 62 | + | if r.listErr != nil { |
| 352 | - | factory := &restorePTYFactory{} | 63 | + | return nil, r.listErr |
| 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 | - | }) | |||
| 368 | } | 64 | } | ||
| 65 | + | return append([]string(nil), r.names...), nil | |||
| 369 | } | 66 | } | ||
| 370 | 67 | ||||
| 371 | - | func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) { | 68 | + | func (r *snapshotAcceptanceRepository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) { |
| 372 | - | proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}} | 69 | + | if err := ctx.Err(); err != nil { |
| 373 | - | factory := &restorePTYFactory{writeErr: errors.New("boom")} | 70 | + | return ports.SnapshotGeneration{}, err |
| 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) | |||
| 424 | } | 71 | } | ||
| 425 | - | d.mu.Lock() | 72 | + | r.mu.Lock() |
| 426 | - | restored := d.findByNameLocked("stacked") | 73 | + | defer r.mu.Unlock() |
| 427 | - | d.mu.Unlock() | 74 | + | if r.loadErr != nil { |
| 428 | - | require.NotNil(t, restored) | 75 | + | return ports.SnapshotGeneration{}, r.loadErr |
| 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"}, | |||
| 462 | } | 76 | } | ||
| 463 | - | for _, tt := range tests { | 77 | + | generation, ok := r.generations[name] |
| 464 | - | t.Run(tt.name, func(t *testing.T) { | 78 | + | if !ok { |
| 465 | - | factory := &restorePTYFactory{} | 79 | + | return ports.SnapshotGeneration{}, errors.New("missing generation") |
| 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 | - | }) | |||
| 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) { | 88 | + | func (*snapshotAcceptanceRepository) Delete(context.Context, string) error { return nil } |
| 479 | - | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "overflow", Data: mustSnapshotBytes(t, snapcodec.Session{ | 89 | + | func (*snapshotAcceptanceRepository) Tombstone(context.Context, string) error { return nil } |
| 480 | - | Name: "overflow", | 90 | + | func (*snapshotAcceptanceRepository) DeleteTombstone(context.Context, string) error { return nil } |
| 481 | - | CreatedAt: uint64(math.MaxInt64) + 1, | 91 | + | func (*snapshotAcceptanceRepository) Maintain(context.Context) error { return nil } |
| 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) }) | |||
| 525 | 92 | ||||
| 526 | - | select { | 93 | + | func TestSnapshotAcceptanceRepositoryLoadMutationDoesNotChangeStoredGeneration(t *testing.T) { |
| 527 | - | case <-d.restoreDone: | 94 | + | digest := ports.SnapshotDigest{1} |
| 528 | - | case <-time.After(2 * time.Second): | 95 | + | stored := ports.SnapshotGeneration{ |
| 529 | - | t.Fatal("restoreSnapshots did not close restoreDone in time") | 96 | + | Name: "work", |
| 97 | + | Manifest: []byte("manifest"), | |||
| 98 | + | Objects: map[ports.SnapshotDigest][]byte{digest: []byte("object")}, | |||
| 530 | } | 99 | } | ||
| 531 | - | 100 | + | repository := &snapshotAcceptanceRepository{ | |
| 532 | - | require.Empty(t, factory.opens, "both snapshots must fail before any PTY is opened") | 101 | + | generations: map[string]ports.SnapshotGeneration{"work": stored}, |
| 533 | - | require.Len(t, d.notices.history(), 2, "each failure is recorded individually") | 102 | + | publishes: []ports.SnapshotPublication{{Name: "work"}}, |
| 534 | - | for _, n := range d.notices.history() { | 103 | + | loadMutate: func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration { |
| 535 | - | require.Equal(t, domain.NoticeSnapshotRestore, n.Code) | 104 | + | g.Manifest[0] = 'M' |
| 536 | - | require.Equal(t, domain.NoticeError, n.Severity) | 105 | + | g.Objects[digest][0] = 'O' |
| 106 | + | delete(g.Objects, digest) | |||
| 107 | + | return g | |||
| 108 | + | }, | |||
| 537 | } | 109 | } | ||
| 538 | 110 | ||||
| 539 | - | tr, _ := newCapturingTransport(t) | 111 | + | _, err := repository.Load(context.Background(), "work") |
| 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) | |||
| 545 | require.NoError(t, err) | 112 | require.NoError(t, err) | ||
| 546 | - | d.firstPaint(sess, ac, ac.size) | 113 | + | require.Equal(t, []byte("manifest"), repository.generations["work"].Manifest) |
| 547 | - | 114 | + | require.Equal(t, []byte("object"), repository.generations["work"].Objects[digest]) | |
| 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") | |||
| 552 | } | 115 | } | ||
| 553 | 116 | ||||
| 554 | - | func TestRestoreSnapshotsDoesNotNotifyForCancelledRestore(t *testing.T) { | 117 | + | func cloneAcceptanceGeneration(g ports.SnapshotGeneration) ports.SnapshotGeneration { |
| 555 | - | ctx, cancel := context.WithCancel(context.Background()) | 118 | + | out := g |
| 556 | - | factory := &restorePTYFactory{onOpen: cancel} | 119 | + | out.Manifest = append([]byte(nil), g.Manifest...) |
| 557 | - | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "cancelled", Data: mustSnapshotBytes(t, snapcodec.Session{ | 120 | + | out.Objects = make(map[ports.SnapshotDigest][]byte, len(g.Objects)) |
| 558 | - | Name: "cancelled", | 121 | + | for digest, data := range g.Objects { |
| 559 | - | Tabs: []snapcodec.Tab{{ | 122 | + | out.Objects[digest] = append([]byte(nil), data...) |
| 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") | |||
| 595 | } | 123 | } | ||
| 596 | - | 124 | + | return out | |
| 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") | |||
| 618 | } | 125 | } | ||
| 619 | 126 | ||||
| 620 | - | func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) { | 127 | + | func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session { |
| 621 | - | releaseLoad := make(chan struct{}) | 128 | + | t.Helper() |
| 622 | - | loaded := make(chan struct{}) | 129 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 8 * 293, ChunkRows: 2}) |
| 623 | - | store := &restoreSnapshotStore{ | 130 | + | for _, ch := range []rune{'a', 'b', 'c', 'd', 'e'} { |
| 624 | - | loadFn: func() ([]ports.SnapshotBlob, error) { | 131 | + | row := make([]renderer.Cell, 293) |
| 625 | - | close(loaded) | 132 | + | for i := range row { |
| 626 | - | <-releaseLoad | 133 | + | row[i] = renderer.Cell{Rune: ch} |
| 627 | - | return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | 134 | + | } |
| 628 | - | Name: "work", | 135 | + | require.NoError(t, history.Append(row)) |
| 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 | - | }, | |||
| 637 | } | 136 | } | ||
| 638 | - | factory := &restorePTYFactory{} | 137 | + | view := history.SnapshotView() |
| 639 | - | d := newTestDaemon(t, factory, stubClock{}) | 138 | + | sealed := make([][]byte, view.ChunkCount()) |
| 640 | - | WithSnapshotStore(store)(d) | 139 | + | for i := range sealed { |
| 641 | - | 140 | + | var err error | |
| 642 | - | go d.restoreSnapshots(context.Background()) | 141 | + | sealed[i], err = vt.MarshalHistoryChunk(view.Chunk(i)) |
| 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) | |||
| 649 | require.NoError(t, err) | 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 | - | } | 144 | + | tail, err := vt.MarshalHistoryTail(view) |
| 668 | - | 145 | + | require.NoError(t, err) | |
| 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 | - | } | |||
| 688 | 146 | ||||
| 689 | - | func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte { | 147 | + | screen := vt.NewScreen(293, 4) |
| 690 | - | t.Helper() | 148 | + | screen.Write([]byte("primary-visible")) |
| 691 | - | b, err := snapcodec.Marshal(s) | 149 | + | screen.Write([]byte("\x1b[?1049h")) |
| 150 | + | screen.Write([]byte("alternate-active")) | |||
| 151 | + | visible, err := screen.PrimaryVisibleSnapshot().Marshal() | |||
| 692 | require.NoError(t, err) | 152 | require.NoError(t, err) | ||
| 693 | - | return b | |||
| 694 | - | } | |||
| 695 | 153 | ||||
| 696 | - | func cells(s string) []renderer.Cell { | 154 | + | paneID := layout.PaneID("pane-7") |
| 697 | - | out := make([]renderer.Cell, 0, len(s)) | 155 | + | return snapcodec.Session{ |
| 698 | - | for _, r := range s { | 156 | + | Name: name, CreatedAt: 77, Active: 0, |
| 699 | - | out = append(out, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | 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 } | 168 | + | func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation uint64) ports.SnapshotGeneration { |
| 710 | - | func (s *restoreSnapshotStore) Delete(string) error { return nil } | 169 | + | t.Helper() |
| 711 | - | func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) { | 170 | + | publication, err := legacyPublication(snapshot) |
| 712 | - | if s.loadFn != nil { | 171 | + | require.NoError(t, err) |
| 713 | - | return s.loadFn() | 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 | 181 | + | return ports.SnapshotGeneration{Name: snapshot.Name, Generation: generation, Manifest: encoded, Objects: objects} |
| 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 | |||
| 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) { | 184 | + | func TestValidateRestoreSessionSnapshot(t *testing.T) { |
| 735 | - | f.mu.Lock() | 185 | + | valid := restoreAcceptanceSession(t, "restored") |
| 736 | - | pty := newRestorePTY() | 186 | + | for _, test := range []struct { |
| 737 | - | pty.writeErr = f.writeErr | 187 | + | name string |
| 738 | - | f.opens = append(f.opens, restorePTYOpen{ctx: ctx, command: command, dir: dir, size: sz, env: append([]string(nil), env...), pty: pty}) | 188 | + | snap snapcodec.Session |
| 739 | - | onOpen := f.onOpen | 189 | + | }{ |
| 740 | - | f.mu.Unlock() | 190 | + | {"valid", valid}, |
| 741 | - | if onOpen != nil { | 191 | + | {"empty name", func() snapcodec.Session { snap := valid; snap.Name = ""; return snap }()}, |
| 742 | - | onOpen() | 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 { | 207 | + | func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { |
| 748 | - | f.mu.Lock() | 208 | + | snapshot := restoreAcceptanceSession(t, "restored") |
| 749 | - | defer f.mu.Unlock() | 209 | + | generation := acceptanceGeneration(t, snapshot, 9) |
| 750 | - | opens := make([]restorePTYOpen, len(f.opens)) | 210 | + | repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}} |
| 751 | - | copy(opens, f.opens) | 211 | + | pty, release := newBlockingPTY(t) |
| 752 | - | for i := range opens { | 212 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) |
| 753 | - | opens[i].env = append([]string(nil), opens[i].env...) | 213 | + | store, _ := newMockStore(t) |
| 754 | - | } | 214 | + | WithStore(store)(d) |
| 755 | - | return opens | 215 | + | WithSnapshotRepository(repository, nil)(d) |
| 756 | - | } | 216 | + | t.Cleanup(func() { release(); d.sessWg.Wait() }) |
| 757 | 217 | ||||
| 758 | - | type restorePTY struct { | 218 | + | d.restoreIncrementalSnapshots(context.Background()) |
| 759 | - | mu sync.Mutex | |||
| 760 | - | writes []string | |||
| 761 | - | closes int | |||
| 762 | - | writeErr error | |||
| 763 | - | done chan struct{} | |||
| 764 | - | once sync.Once | |||
| 765 | - | } | |||
| 766 | 219 | ||||
| 767 | - | func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} } | 220 | + | d.mu.Lock() |
| 768 | - | func (p *restorePTY) Read([]byte) (int, error) { | 221 | + | restored := d.findByNameLocked(snapshot.Name) |
| 769 | - | <-p.done | 222 | + | d.mu.Unlock() |
| 770 | - | return 0, io.EOF | 223 | + | require.NotNil(t, restored) |
| 771 | - | } | 224 | + | require.False(t, restored.snapDirty.Load(), "a loaded generation must begin clean") |
| 772 | - | func (p *restorePTY) Write(b []byte) (int, error) { | 225 | + | require.Equal(t, uint64(9), restored.snapshotPublishedGeneration, "the loaded manifest is the repository generation head") |
| 773 | - | p.mu.Lock() | 226 | + | require.Equal(t, 0, restored.active) |
| 774 | - | defer p.mu.Unlock() | 227 | + | require.Equal(t, "/snapshot/cwd", restored.cwd) |
| 775 | - | p.writes = append(p.writes, string(b)) | 228 | + | require.Len(t, restored.tabs, 1) |
| 776 | - | if p.writeErr != nil { | 229 | + | tab := restored.tabs[0] |
| 777 | - | return 0, p.writeErr | 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 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | - | "fmt" | |||
| 7 | - | "io" | |||
| 8 | - | "sync" | |||
| 9 | - | "sync/atomic" | |||
| 10 | "testing" | 6 | "testing" | ||
| 11 | "time" | 7 | "time" | ||
| 12 | 8 | ||||
| @@ -16,1012 +12,26 @@ import ( | |||||
| 16 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| 17 | "github.com/bnema/vev/internal/ports" | 13 | "github.com/bnema/vev/internal/ports" | ||
| 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 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 | "github.com/bnema/vev/pkg/renderer" | 15 | "github.com/bnema/vev/pkg/renderer" | ||
| 22 | - | "github.com/bnema/vev/pkg/vt" | |||
| 23 | ) | 16 | ) | ||
| 24 | 17 | ||||
| 25 | - | func TestSnapshotEncodeWorkerDefersMarshalUntilAfterCapture(t *testing.T) { | 18 | + | // TestSnapshotWorkerPublishesContentAddressedCapture verifies that all new |
| 26 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | 19 | + | // checkpoints use the repository publication contract. |
| 20 | + | func TestSnapshotWorkerPublishesContentAddressedCapture(t *testing.T) { | |||
| 27 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 21 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||
| 28 | - | WithSnapshotStore(store)(d) | 22 | + | repository := portsmocks.NewMockSnapshotRepository(t) |
| 29 | - | t.Cleanup(d.stopSnapshotEncodeWorker) | 23 | + | WithSnapshotRepository(repository, nil)(d) |
| 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) | |||
| 540 | startSnapshotEncodeWorker(t, d) | 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) | 26 | + | repository.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(p ports.SnapshotPublication) bool { |
| 554 | - | require.True(t, d.scheduleSnapshot(sess)) | 27 | + | return p.Name == "work" && p.Generation == 1 && len(p.Manifest) > 0 && len(p.Objects) > 0 |
| 555 | - | <-entered | 28 | + | })).Return(nil).Once() |
| 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() | |||
| 735 | 29 | ||||
| 30 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |||
| 736 | require.True(t, d.captureSession(sess)) | 31 | require.True(t, d.captureSession(sess)) | ||
| 737 | awaitSnapshotClean(t, sess) | 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 | func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session { | 35 | func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session { | ||
| 1026 | t.Helper() | 36 | t.Helper() | ||
| 1027 | pty := portsmocks.NewMockPTY(t) | 37 | pty := portsmocks.NewMockPTY(t) | ||
| @@ -1034,18 +44,8 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin | |||||
| 1034 | tb := newTab(pty, domain.Size{Cols: 8, Rows: 3}) | 44 | tb := newTab(pty, domain.Size{Cols: 8, Rows: 3}) | ||
| 1035 | p := tb.panes["pane-1"] | 45 | p := tb.panes["pane-1"] | ||
| 1036 | p.screen.Write([]byte("hello")) | 46 | p.screen.Write([]byte("hello")) | ||
| 1037 | - | p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}) | 47 | + | require.NoError(t, p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})) |
| 1038 | - | sess := &session{ | 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} |
| 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 | - | } | |||
| 1049 | sess.snapEligible.Store(!ephemeral && name != "") | 49 | sess.snapEligible.Store(!ephemeral && name != "") | ||
| 1050 | return sess | 50 | return sess | ||
| 1051 | } | 51 | } | ||
| @@ -1095,135 +95,3 @@ func startSnapshotEncodeWorker(t *testing.T, d *Daemon) { | |||||
| 1095 | d.startSnapshotEncodeWorker() | 95 | d.startSnapshotEncodeWorker() | ||
| 1096 | t.Cleanup(d.stopSnapshotEncodeWorker) | 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 | magic = "VEVS" | 24 | magic = "VEVS" | ||
| 25 | version = uint16(3) | 25 | version = uint16(3) | ||
| 26 | maxDecodedBodySize = 256 << 20 | 26 | maxDecodedBodySize = 256 << 20 | ||
| 27 | - | maxSnapshotBlobs = 1 << 16 | 27 | + | maxSnapshotObjects = 1 << 16 |
| 28 | maxSnapshotBytes = 256 << 20 | 28 | maxSnapshotBytes = 256 << 20 | ||
| 29 | maxSnapshotRows = 1 << 20 | 29 | maxSnapshotRows = 1 << 20 | ||
| 30 | maxSnapshotCells = 16 << 20 | 30 | maxSnapshotCells = 16 << 20 | ||
| @@ -34,24 +34,6 @@ const ( | |||||
| 34 | maxSnapshotDecodedAllocation = 256 << 20 | 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 | // Unmarshal validates the complete manifest and all VT blobs without | 37 | // Unmarshal validates the complete manifest and all VT blobs without | ||
| 56 | // allocations first, then decodes the already preflighted data. | 38 | // allocations first, then decodes the already preflighted data. | ||
| 57 | func Unmarshal(b []byte) (Session, error) { | 39 | func Unmarshal(b []byte) (Session, error) { | ||
| @@ -146,6 +128,61 @@ func (w *payloadWriter) putStrings(ss []string) error { | |||||
| 146 | return nil | 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 | type payloadReader struct{ b []byte } | 186 | type payloadReader struct{ b []byte } | ||
| 150 | 187 | ||||
| 151 | func (r *payloadReader) getUint8() (uint8, error) { | 188 | func (r *payloadReader) getUint8() (uint8, error) { | ||
| @@ -226,27 +263,6 @@ func (r *payloadReader) done() error { | |||||
| 226 | return nil | 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 | func readSession(r *payloadReader) (Session, error) { | 266 | func readSession(r *payloadReader) (Session, error) { | ||
| 251 | name, err := r.getString() | 267 | name, err := r.getString() | ||
| 252 | if err != nil { | 268 | if err != nil { | ||
| @@ -278,35 +294,6 @@ func readSession(r *payloadReader) (Session, error) { | |||||
| 278 | return s, nil | 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 | func readTab(r *payloadReader) (Tab, error) { | 297 | func readTab(r *payloadReader) (Tab, error) { | ||
| 311 | stableID, err := r.getString() | 298 | stableID, err := r.getString() | ||
| 312 | if err != nil { | 299 | if err != nil { | ||
| @@ -347,56 +334,19 @@ func readTab(r *payloadReader) (Tab, error) { | |||||
| 347 | return t, nil | 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 | func readNode(r *payloadReader) (*layout.Node, error) { | 337 | func readNode(r *payloadReader) (*layout.Node, error) { | ||
| 388 | kind, err := r.getUint8() | 338 | kind, err := r.getUint8() | ||
| 389 | if err != nil { | 339 | if err != nil { | ||
| 390 | return nil, err | 340 | return nil, err | ||
| 391 | } | 341 | } | ||
| 392 | switch kind { | 342 | switch kind { | ||
| 393 | - | case 0: | 343 | + | case manifestNodeLeaf: |
| 394 | leaf, err := r.getString() | 344 | leaf, err := r.getString() | ||
| 395 | if err != nil { | 345 | if err != nil { | ||
| 396 | return nil, err | 346 | return nil, err | ||
| 397 | } | 347 | } | ||
| 398 | return layout.NewLeaf(layout.PaneID(leaf)), nil | 348 | return layout.NewLeaf(layout.PaneID(leaf)), nil | ||
| 399 | - | case 1: | 349 | + | case manifestNodeSplit: |
| 400 | dir, err := r.getUint8() | 350 | dir, err := r.getUint8() | ||
| 401 | if err != nil { | 351 | if err != nil { | ||
| 402 | return nil, err | 352 | return nil, err | ||
| @@ -406,7 +356,7 @@ func readNode(r *payloadReader) (*layout.Node, error) { | |||||
| 406 | return nil, err | 356 | return nil, err | ||
| 407 | } | 357 | } | ||
| 408 | return &layout.Node{Kind: layout.Split, Dir: layout.SplitDir(dir), Children: children}, nil | 358 | return &layout.Node{Kind: layout.Split, Dir: layout.SplitDir(dir), Children: children}, nil | ||
| 409 | - | case 2: | 359 | + | case manifestNodeStack: |
| 410 | exp, err := r.getString() | 360 | exp, err := r.getString() | ||
| 411 | if err != nil { | 361 | if err != nil { | ||
| 412 | return nil, err | 362 | return nil, err | ||
| @@ -416,7 +366,7 @@ func readNode(r *payloadReader) (*layout.Node, error) { | |||||
| 416 | return nil, err | 366 | return nil, err | ||
| 417 | } | 367 | } | ||
| 418 | return &layout.Node{Kind: layout.Stack, Expanded: layout.PaneID(exp), Children: children}, nil | 368 | return &layout.Node{Kind: layout.Stack, Expanded: layout.PaneID(exp), Children: children}, nil | ||
| 419 | - | case 3: | 369 | + | case manifestNodeNil: |
| 420 | return nil, nil | 370 | return nil, nil | ||
| 421 | default: | 371 | default: | ||
| 422 | return nil, fmt.Errorf("%w: unknown node kind", ErrInvalidData) | 372 | return nil, fmt.Errorf("%w: unknown node kind", ErrInvalidData) | ||
| @@ -439,40 +389,6 @@ func readChildren(r *payloadReader) ([]*layout.Node, error) { | |||||
| 439 | return out, nil | 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 | func readPane(r *payloadReader) (Pane, error) { | 392 | func readPane(r *payloadReader) (Pane, error) { | ||
| 477 | id, err := r.getString() | 393 | id, err := r.getString() | ||
| 478 | if err != nil { | 394 | if err != nil { | ||
| @@ -490,7 +406,7 @@ func readPane(r *payloadReader) (Pane, error) { | |||||
| 490 | if err != nil { | 406 | if err != nil { | ||
| 491 | return Pane{}, err | 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 | return Pane{}, fmt.Errorf("%w: sealed chunks", ErrInvalidData) | 410 | return Pane{}, fmt.Errorf("%w: sealed chunks", ErrInvalidData) | ||
| 495 | } | 411 | } | ||
| 496 | sealed := make([][]byte, n) | 412 | sealed := make([][]byte, n) | ||
| @@ -514,33 +430,15 @@ func readPane(r *payloadReader) (Pane, error) { | |||||
| 514 | return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil | 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 | func readProcess(r *payloadReader) (*Process, error) { | 433 | func readProcess(r *payloadReader) (*Process, error) { | ||
| 536 | present, err := r.getUint8() | 434 | present, err := r.getUint8() | ||
| 537 | if err != nil { | 435 | if err != nil { | ||
| 538 | return nil, err | 436 | return nil, err | ||
| 539 | } | 437 | } | ||
| 540 | - | if present == 0 { | 438 | + | if present == processAbsent { |
| 541 | return nil, nil | 439 | return nil, nil | ||
| 542 | } | 440 | } | ||
| 543 | - | if present != 1 { | 441 | + | if present != processPresent { |
| 544 | return nil, fmt.Errorf("%w: process presence", ErrInvalidData) | 442 | return nil, fmt.Errorf("%w: process presence", ErrInvalidData) | ||
| 545 | } | 443 | } | ||
| 546 | argv, err := r.getStrings() | 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 | {ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, | 32 | {ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, | ||
| 33 | {ID: "p2", SealedChunks: sealed2, Tail: tail2, Visible: visible2}, | 33 | {ID: "p2", SealedChunks: sealed2, Tail: tail2, Visible: visible2}, | ||
| 34 | }}}} | 34 | }}}} | ||
| 35 | - | encoded, err := Marshal(want) | 35 | + | encoded, err := marshalTest(want) |
| 36 | if err != nil { | 36 | if err != nil { | ||
| 37 | t.Fatal(err) | 37 | t.Fatal(err) | ||
| 38 | } | 38 | } | ||
| @@ -86,13 +86,13 @@ func TestActiveTabReferenceMustBeExact(t *testing.T) { | |||||
| 86 | {name: "nonempty tabs active past end", s: Session{Active: 1, Tabs: []Tab{validTab}}}, | 86 | {name: "nonempty tabs active past end", s: Session{Active: 1, Tabs: []Tab{validTab}}}, | ||
| 87 | } { | 87 | } { | ||
| 88 | t.Run("marshal "+tc.name, func(t *testing.T) { | 88 | t.Run("marshal "+tc.name, func(t *testing.T) { | ||
| 89 | - | _, err := Marshal(tc.s) | 89 | + | _, err := marshalTest(tc.s) |
| 90 | if tc.valid { | 90 | if tc.valid { | ||
| 91 | requireNoError(t, err) | 91 | requireNoError(t, err) | ||
| 92 | return | 92 | return | ||
| 93 | } | 93 | } | ||
| 94 | if !errors.Is(err, ErrInvalidData) { | 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 | func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) { | 153 | func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) { | ||
| 154 | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}}) | 154 | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}}) | ||
| 155 | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | 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 | if err != nil { | 157 | if err != nil { | ||
| 158 | t.Fatal(err) | 158 | t.Fatal(err) | ||
| 159 | } | 159 | } | ||
| @@ -174,7 +174,7 @@ func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) { | |||||
| 174 | {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, {ID: "p", Tail: tail, Visible: visible}}}, | 174 | {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, {ID: "p", Tail: tail, Visible: visible}}}, | ||
| 175 | } { | 175 | } { | ||
| 176 | t.Run(tc.name, func(t *testing.T) { | 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 | requireNoError(t, err) | 178 | requireNoError(t, err) | ||
| 179 | reject(t, data, ErrInvalidData) | 179 | reject(t, data, ErrInvalidData) | ||
| 180 | }) | 180 | }) | ||
| @@ -184,7 +184,7 @@ func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) { | |||||
| 184 | func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) { | 184 | func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) { | ||
| 185 | sealed, tail := historyBlobs(t, nil) | 185 | sealed, tail := historyBlobs(t, nil) | ||
| 186 | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | 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 | if err != nil { | 188 | if err != nil { | ||
| 189 | t.Fatal(err) | 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) { | 196 | + | func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { |
| 197 | - | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}}) | 197 | + | sealed, emptyTail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}}) |
| 198 | if len(sealed) == 0 { | 198 | if len(sealed) == 0 { | ||
| 199 | t.Fatal("expected a sealed chunk") | 199 | t.Fatal("expected a sealed chunk") | ||
| 200 | } | 200 | } | ||
| @@ -204,16 +204,37 @@ func TestUnmarshalRejectsNonCanonicalBlobRolesDuringPreflight(t *testing.T) { | |||||
| 204 | multiChunk = append(multiChunk, sealed[0][9:]...) | 204 | multiChunk = append(multiChunk, sealed[0][9:]...) | ||
| 205 | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | 205 | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | ||
| 206 | emptyVisible := visibleBlob(t, nil) | 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 | for _, tc := range []struct { | 227 | for _, tc := range []struct { | ||
| 208 | name string | 228 | name string | ||
| 209 | tab Tab | 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}}}}, | 231 | + | {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: emptyTail, Visible: visible}}}}, |
| 212 | - | {name: "nonempty tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: sealed[0], Visible: visible}}}}, | 232 | + | {name: "tail has multiple chunks", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: multiChunk, Visible: visible}}}}, |
| 213 | - | {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: tail, Visible: emptyVisible}}}}, | 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 | t.Run(tc.name, func(t *testing.T) { | 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 | requireNoError(t, err) | 238 | requireNoError(t, err) | ||
| 218 | reject(t, data, ErrInvalidData) | 239 | reject(t, data, ErrInvalidData) | ||
| 219 | }) | 240 | }) | ||
| @@ -238,7 +259,7 @@ func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) { | |||||
| 238 | {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}}, | 259 | {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}}, | ||
| 239 | } { | 260 | } { | ||
| 240 | t.Run(tc.name, func(t *testing.T) { | 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 | requireNoError(t, err) | 263 | requireNoError(t, err) | ||
| 243 | got, err := Unmarshal(data) | 264 | got, err := Unmarshal(data) | ||
| 244 | requireNoError(t, err) | 265 | requireNoError(t, err) | ||
| @@ -265,7 +286,7 @@ func TestRoundTripSessionMetadataAndProcess(t *testing.T) { | |||||
| 265 | }, | 286 | }, | ||
| 266 | {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}}}, | 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 | requireNoError(t, err) | 290 | requireNoError(t, err) | ||
| 270 | got, err := Unmarshal(data) | 291 | got, err := Unmarshal(data) | ||
| 271 | requireNoError(t, err) | 292 | requireNoError(t, err) | ||
| @@ -279,11 +300,11 @@ func TestRoundTripSessionMetadataAndProcess(t *testing.T) { | |||||
| 279 | } | 300 | } | ||
| 280 | 301 | ||||
| 281 | func TestMarshalRejectsProcessWithoutArgv(t *testing.T) { | 302 | func TestMarshalRejectsProcessWithoutArgv(t *testing.T) { | ||
| 282 | - | _, err := Marshal(Session{Name: "s", Tabs: []Tab{ | 303 | + | _, err := marshalTest(Session{Name: "s", Tabs: []Tab{ |
| 283 | {Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}}, | 304 | {Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}}, | ||
| 284 | }}) | 305 | }}) | ||
| 285 | if !errors.Is(err, ErrInvalidData) { | 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 | func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) { | 343 | func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) { | ||
| 323 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256}) | 344 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256}) | ||
| 324 | for range 256 { | 345 | for range 256 { | ||
| 325 | - | history.Append([]renderer.Cell{{Rune: 'x'}}) | 346 | + | requireNoError(t, history.Append([]renderer.Cell{{Rune: 'x'}})) |
| 326 | } | 347 | } | ||
| 327 | sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView()) | 348 | sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView()) | ||
| 328 | requireNoError(t, err) | 349 | requireNoError(t, err) | ||
| @@ -333,7 +354,7 @@ func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) { | |||||
| 333 | for i := range tabs { | 354 | for i := range tabs { | ||
| 334 | tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Visible: visible}}} | 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 | requireNoError(t, err) | 358 | requireNoError(t, err) | ||
| 338 | 359 | ||||
| 339 | allocs := testing.AllocsPerRun(3, func() { | 360 | allocs := testing.AllocsPerRun(3, func() { | ||
| @@ -385,7 +406,7 @@ func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) { | |||||
| 385 | t.Helper() | 406 | t.Helper() | ||
| 386 | h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2}) | 407 | h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2}) | ||
| 387 | for _, row := range rows { | 408 | for _, row := range rows { | ||
| 388 | - | h.Append(row) | 409 | + | requireNoError(t, h.Append(row)) |
| 389 | } | 410 | } | ||
| 390 | sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView()) | 411 | sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView()) | ||
| 391 | if err != nil { | 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 | if err != nil { | 198 | if err != nil { | ||
| 199 | return err | 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 | return fmt.Errorf("%w: sealed chunks", ErrInvalidData) | 202 | return fmt.Errorf("%w: sealed chunks", ErrInvalidData) | ||
| 203 | } | 203 | } | ||
| 204 | for range n { | 204 | for range n { | ||
| @@ -214,7 +214,10 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin | |||||
| 214 | if err != nil { | 214 | if err != nil { | ||
| 215 | return err | 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 | return fmt.Errorf("%w: tail blob role", ErrInvalidData) | 221 | return fmt.Errorf("%w: tail blob role", ErrInvalidData) | ||
| 219 | } | 222 | } | ||
| 220 | visible, err := preflightBlob(r, totals, blobs, false, budget) | 223 | visible, err := preflightBlob(r, totals, blobs, false, budget) | ||
| @@ -235,7 +238,7 @@ func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, hist | |||||
| 235 | if n == 0 || uint64(n) > uint64(len(r.b)) || uint64(n) > maxSnapshotBytes { | 238 | if n == 0 || uint64(n) > uint64(len(r.b)) || uint64(n) > maxSnapshotBytes { | ||
| 236 | return vt.DecodeStats{}, fmt.Errorf("%w: VT blob length", ErrInvalidData) | 239 | return vt.DecodeStats{}, fmt.Errorf("%w: VT blob length", ErrInvalidData) | ||
| 237 | } | 240 | } | ||
| 238 | - | if *blobs >= maxSnapshotBlobs { | 241 | + | if *blobs >= maxSnapshotObjects { |
| 239 | return vt.DecodeStats{}, fmt.Errorf("%w: too many VT blobs", ErrInvalidData) | 242 | return vt.DecodeStats{}, fmt.Errorf("%w: too many VT blobs", ErrInvalidData) | ||
| 240 | } | 243 | } | ||
| 241 | blob := r.b[:n] | 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 | t.Run(tt.name, func(t *testing.T) { | 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 | if err != nil { | 37 | if err != nil { | ||
| 38 | t.Fatalf("Marshal: %v", err) | 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 | var ( | 10 | var ( | ||
| 11 | - | benchmarkHistoryViewSink HistoryView | 11 | + | benchmarkHistoryViewSink HistoryView |
| 12 | - | benchmarkHistoryBlobSink []byte | 12 | + | benchmarkHistorySnapshotSink HistorySnapshotView |
| 13 | - | benchmarkHistoryBlobsSink [][]byte | 13 | + | benchmarkHistoryBlobSink []byte |
| 14 | + | benchmarkHistoryBlobsSink [][]byte | |||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| 16 | // TestHistoryViewPartialTailAllocationBounded guards the capture property used | 17 | // TestHistoryViewPartialTailAllocationBounded guards the capture property used | ||
| @@ -75,6 +76,25 @@ func BenchmarkHistoryView10KRowsPartialTail(b *testing.B) { | |||||
| 75 | b.ReportMetric(float64(10_000%256), "tailrows/view") | 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 | func BenchmarkMarshalHistoryChunk256x120(b *testing.B) { | 98 | func BenchmarkMarshalHistoryChunk256x120(b *testing.B) { | ||
| 79 | history := benchmarkHistory(b, 256, 120, 256) | 99 | history := benchmarkHistory(b, 256, 120, 256) | ||
| 80 | chunk := history.SealAndView().Chunk(0) | 100 | chunk := history.SealAndView().Chunk(0) | ||
| @@ -155,7 +175,7 @@ func benchmarkHistory(t testing.TB, rows, width, chunkRows int) *History { | |||||
| 155 | for col := range cells { | 175 | for col := range cells { | ||
| 156 | cells[col] = renderer.Cell{Rune: rune('a' + (row+col)%26)} | 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 | return history | 180 | return history | ||
| 161 | } | 181 | } | ||
| @@ -173,7 +193,7 @@ func benchmarkScreenResizeReflow(b *testing.B, historyRows int) { | |||||
| 173 | s := NewScreenWithHistory(120, 40, HistoryConfig{MaxRows: historyRows + 500}) | 193 | s := NewScreenWithHistory(120, 40, HistoryConfig{MaxRows: historyRows + 500}) | ||
| 174 | if historyRows > 0 { | 194 | if historyRows > 0 { | ||
| 175 | for range historyRows { | 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 | s.Write(bytes.Repeat([]byte("x"), 120*39)) | 199 | s.Write(bytes.Repeat([]byte("x"), 120*39)) | ||
M pkg/vt/history.go +165 -17
| @@ -1,10 +1,20 @@ | |||||
| 1 | package vt | 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 | // HistoryConfig controls the bounded terminal history retained by a Screen. | 14 | // HistoryConfig controls the bounded terminal history retained by a Screen. | ||
| 6 | type HistoryConfig struct { | 15 | type HistoryConfig struct { | ||
| 7 | MaxRows int | 16 | MaxRows int | ||
| 17 | + | MaxCells int | |||
| 8 | ChunkRows int | 18 | ChunkRows int | ||
| 9 | } | 19 | } | ||
| 10 | 20 | ||||
| @@ -13,10 +23,12 @@ type HistoryConfig struct { | |||||
| 13 | // appends. | 23 | // appends. | ||
| 14 | type History struct { | 24 | type History struct { | ||
| 15 | maxRows int | 25 | maxRows int | ||
| 26 | + | maxCells int | |||
| 16 | chunkRows int | 27 | chunkRows int | ||
| 17 | chunks []*HistoryChunk | 28 | chunks []*HistoryChunk | ||
| 18 | tail [][]renderer.Cell | 29 | tail [][]renderer.Cell | ||
| 19 | rows int | 30 | rows int | ||
| 31 | + | cells int | |||
| 20 | } | 32 | } | ||
| 21 | 33 | ||||
| 22 | // HistoryChunk is an immutable group of history rows. Its identity is stable | 34 | // HistoryChunk is an immutable group of history rows. Its identity is stable | ||
| @@ -30,6 +42,17 @@ type HistoryChunk struct { | |||||
| 30 | type HistoryView struct { | 42 | type HistoryView struct { | ||
| 31 | chunks []*HistoryChunk | 43 | chunks []*HistoryChunk | ||
| 32 | rows int | 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 | func NewHistory(config HistoryConfig) *History { | 58 | func NewHistory(config HistoryConfig) *History { | ||
| @@ -42,20 +65,39 @@ func NewHistory(config HistoryConfig) *History { | |||||
| 42 | } | 65 | } | ||
| 43 | chunkRows = min(chunkRows, 256) | 66 | chunkRows = min(chunkRows, 256) | ||
| 44 | chunkRows = min(chunkRows, config.MaxRows) | 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 | // Append records a copy of row. Once a chunk is full it is sealed forever. | 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 | if h == nil || h.maxRows == 0 { | 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 | h.tail = append(h.tail, append([]renderer.Cell(nil), row...)) | 94 | h.tail = append(h.tail, append([]renderer.Cell(nil), row...)) | ||
| 54 | h.rows++ | 95 | h.rows++ | ||
| 96 | + | h.cells += len(row) | |||
| 55 | if len(h.tail) == h.chunkRows { | 97 | if len(h.tail) == h.chunkRows { | ||
| 56 | h.sealTail() | 98 | h.sealTail() | ||
| 57 | } | 99 | } | ||
| 58 | - | h.evict() | 100 | + | return nil |
| 59 | } | 101 | } | ||
| 60 | 102 | ||||
| 61 | func (h *History) sealTail() { | 103 | func (h *History) sealTail() { | ||
| @@ -66,13 +108,53 @@ func (h *History) sealTail() { | |||||
| 66 | h.tail = nil | 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 | func (h *History) evict() { | 121 | func (h *History) evict() { | ||
| 70 | - | for h.rows > h.maxRows && len(h.chunks) > 0 { | 122 | + | h.evictUntil(0, 0) |
| 71 | - | evicted := h.chunks[0] | 123 | + | } |
| 72 | - | h.rows -= len(evicted.rows) | 124 | + | |
| 73 | - | copy(h.chunks, h.chunks[1:]) | 125 | + | // evictFor discards oldest rows until another row using cellCount cells can fit. |
| 74 | - | h.chunks[len(h.chunks)-1] = nil | 126 | + | func (h *History) evictFor(cellCount int) { |
| 75 | - | h.chunks = h.chunks[:len(h.chunks)-1] | 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 | chunks := append([]*HistoryChunk(nil), h.chunks...) | 176 | chunks := append([]*HistoryChunk(nil), h.chunks...) | ||
| 95 | if len(h.tail) > 0 { | 177 | if len(h.tail) > 0 { | ||
| 96 | - | rows := make([][]renderer.Cell, len(h.tail)) | 178 | + | chunks = append(chunks, &HistoryChunk{rows: cloneHistoryRows(h.tail)}) |
| 97 | - | for i, row := range h.tail { | 179 | + | } |
| 98 | - | rows[i] = append([]renderer.Cell(nil), row...) | 180 | + | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells} |
| 99 | - | } | 181 | + | } |
| 100 | - | chunks = append(chunks, &HistoryChunk{rows: rows}) | 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 | // Len returns the currently retained history row count. | 208 | // Len returns the currently retained history row count. | ||
| @@ -110,6 +213,14 @@ func (h *History) Len() int { | |||||
| 110 | return h.rows | 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 | // Cap returns the configured bounded row capacity. | 224 | // Cap returns the configured bounded row capacity. | ||
| 114 | func (h *History) Cap() int { | 225 | func (h *History) Cap() int { | ||
| 115 | if h == nil { | 226 | if h == nil { | ||
| @@ -118,7 +229,16 @@ func (h *History) Cap() int { | |||||
| 118 | return h.maxRows | 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 | func (v HistoryView) Len() int { return v.rows } | 240 | func (v HistoryView) Len() int { return v.rows } | ||
| 241 | + | func (v HistoryView) Cells() int { return v.cells } | |||
| 122 | func (v HistoryView) ChunkCount() int { return len(v.chunks) } | 242 | func (v HistoryView) ChunkCount() int { return len(v.chunks) } | ||
| 123 | 243 | ||||
| 124 | // Chunk returns the immutable chunk at i, or nil when i is out of range. | 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 | return nil | 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 | makeRow('D', rgb), makeRow('E', indexed), makeRow('F', rgb), makeRow('G', indexed), | 62 | makeRow('D', rgb), makeRow('E', indexed), makeRow('F', rgb), makeRow('G', indexed), | ||
| 63 | } | 63 | } | ||
| 64 | for _, row := range want { | 64 | for _, row := range want { | ||
| 65 | - | history.Append(row) | 65 | + | requireHistoryAppend(t, history, row) |
| 66 | } | 66 | } | ||
| 67 | if got, wantChunks := history.View().ChunkCount(), 4; got != wantChunks { | 67 | if got, wantChunks := history.View().ChunkCount(), 4; got != wantChunks { | ||
| 68 | t.Fatalf("chunk count = %d, want three sealed chunks plus copied tail %d", got, wantChunks) | 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 | for _, row := range chunk.rows { | 46 | for _, row := range chunk.rows { | ||
| 47 | cellCount := uint64(len(row)) | 47 | cellCount := uint64(len(row)) | ||
| 48 | cellBytes, ok := historyCellByteCount(cellCount) | 48 | cellBytes, ok := historyCellByteCount(cellCount) | ||
| 49 | - | if cellCount > maxHistoryRowCells || !ok || | 49 | + | if !ok || |
| 50 | !addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) || | 50 | !addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) || | ||
| 51 | !addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) || | 51 | !addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) || | ||
| 52 | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | 52 | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | ||
| @@ -193,11 +193,11 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||||
| 193 | for range rowCount { | 193 | for range rowCount { | ||
| 194 | cellCount, ok := p.uint32() | 194 | cellCount, ok := p.uint32() | ||
| 195 | cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount)) | 195 | cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount)) | ||
| 196 | - | if !ok || uint64(cellCount) > maxHistoryRowCells || !validCellBytes || | 196 | + | if !ok || !validCellBytes || |
| 197 | - | uint64(cellCount) > uint64(len(p.data))/historyCellBytes || | |||
| 198 | !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) || | 197 | !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) || | ||
| 199 | !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) || | 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 | return HistoryView{}, historyDecodeStats{}, false | 201 | return HistoryView{}, historyDecodeStats{}, false | ||
| 202 | } | 202 | } | ||
| 203 | 203 | ||||
| @@ -205,7 +205,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||||
| 205 | if populate { | 205 | if populate { | ||
| 206 | row = make([]renderer.Cell, cellCount) | 206 | row = make([]renderer.Cell, cellCount) | ||
| 207 | } | 207 | } | ||
| 208 | - | for i := uint32(0); i < cellCount; i++ { | 208 | + | for i := range cellCount { |
| 209 | cell, ok := p.cell() | 209 | cell, ok := p.cell() | ||
| 210 | if !ok || !validHistoryCell(cell) { | 210 | if !ok || !validHistoryCell(cell) { | ||
| 211 | return HistoryView{}, historyDecodeStats{}, false | 211 | return HistoryView{}, historyDecodeStats{}, false | ||
| @@ -225,7 +225,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||||
| 225 | if len(p.data) != 0 { | 225 | if len(p.data) != 0 { | ||
| 226 | return HistoryView{}, historyDecodeStats{}, false | 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 | func historyCellByteCount(cellCount uint64) (uint64, bool) { | 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 | import ( | 3 | import ( | ||
| 4 | "encoding/binary" | 4 | "encoding/binary" | ||
| 5 | + | "fmt" | |||
| 6 | + | "math" | |||
| 5 | "testing" | 7 | "testing" | ||
| 6 | 8 | ||||
| 7 | "github.com/bnema/vev/pkg/renderer" | 9 | "github.com/bnema/vev/pkg/renderer" | ||
| @@ -55,7 +57,7 @@ func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) { | |||||
| 55 | t.Run(tt.name, func(t *testing.T) { | 57 | t.Run(tt.name, func(t *testing.T) { | ||
| 56 | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | 58 | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | ||
| 57 | for _, row := range tt.rows { | 59 | for _, row := range tt.rows { | ||
| 58 | - | history.Append(row) | 60 | + | requireHistoryAppend(t, history, row) |
| 59 | } | 61 | } | ||
| 60 | 62 | ||||
| 61 | encoded, err := MarshalHistory(history.View()) | 63 | encoded, err := MarshalHistory(history.View()) | ||
| @@ -73,8 +75,8 @@ func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) { | |||||
| 73 | 75 | ||||
| 74 | func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) { | 76 | func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) { | ||
| 75 | history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 2}) | 77 | history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 2}) | ||
| 76 | - | history.Append(historyRow("AAAA")) | 78 | + | requireHistoryAppend(t, history, historyRow("AAAA")) |
| 77 | - | history.Append(historyRow("BBBB")) | 79 | + | requireHistoryAppend(t, history, historyRow("BBBB")) |
| 78 | encoded, err := MarshalHistory(history.View()) | 80 | encoded, err := MarshalHistory(history.View()) | ||
| 79 | if err != nil { | 81 | if err != nil { | ||
| 80 | t.Fatalf("marshal history: %v", err) | 82 | t.Fatalf("marshal history: %v", err) | ||
| @@ -123,10 +125,7 @@ func TestChunkCodecSupportsRepresentativeHistory(t *testing.T) { | |||||
| 123 | } | 125 | } | ||
| 124 | 126 | ||||
| 125 | func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) { | 127 | func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) { | ||
| 126 | - | const ( | 128 | + | const supportedRows = 12_000 |
| 127 | - | supportedRows = 12_000 | |||
| 128 | - | supportedWidth = 160 | |||
| 129 | - | ) | |||
| 130 | tests := []struct { | 129 | tests := []struct { | ||
| 131 | name string | 130 | name string | ||
| 132 | view HistoryView | 131 | view HistoryView | ||
| @@ -137,11 +136,6 @@ func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) { | |||||
| 137 | view: historyViewWithDimensions(supportedRows+1, 0), | 136 | view: historyViewWithDimensions(supportedRows+1, 0), | ||
| 138 | data: historyPayloadWithDimensions(supportedRows+1, 0), | 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 | for _, tt := range tests { | 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 | func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) { | 188 | func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) { | ||
| 160 | tests := []struct { | 189 | tests := []struct { | ||
| 161 | name string | 190 | name string | ||
| @@ -213,6 +242,18 @@ func historyPayloadWithDimensions(rowCount, width int) []byte { | |||||
| 213 | return data | 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 | func hostileHistoryDeclarations(chunkCount, rowCount int) []byte { | 257 | func hostileHistoryDeclarations(chunkCount, rowCount int) []byte { | ||
| 217 | data := make([]byte, 9, 9+chunkCount*(4+rowCount*4)) | 258 | data := make([]byte, 9, 9+chunkCount*(4+rowCount*4)) | ||
| 218 | copy(data, historyMagic) | 259 | copy(data, historyMagic) | ||
| @@ -248,6 +289,7 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) { | |||||
| 248 | {name: "invalid rune", data: invalidRune}, | 289 | {name: "invalid rune", data: invalidRune}, | ||
| 249 | {name: "invalid underline style", data: invalidUnderlineStyle}, | 290 | {name: "invalid underline style", data: invalidUnderlineStyle}, | ||
| 250 | {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)}, | 291 | {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)}, | ||
| 292 | + | {name: "aggregate cell budget", data: aggregateCellLimitDeclaration()}, | |||
| 251 | {name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0}}, | 293 | {name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0}}, | ||
| 252 | } | 294 | } | ||
| 253 | for _, tt := range tests { | 295 | for _, tt := range tests { | ||
M pkg/vt/history_test.go +107 -9
| @@ -1,11 +1,20 @@ | |||||
| 1 | package vt | 1 | package vt | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "errors" | |||
| 5 | + | "math" | |||
| 4 | "testing" | 6 | "testing" | ||
| 5 | 7 | ||||
| 6 | "github.com/bnema/vev/pkg/renderer" | 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 | func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) { | 18 | func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) { | ||
| 10 | tests := []struct { | 19 | tests := []struct { | ||
| 11 | name string | 20 | name string | ||
| @@ -22,11 +31,11 @@ func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) { | |||||
| 22 | want: []string{"cccc", "dddd", "eeee", "ffff"}, | 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 | maxRows: 5, | 35 | maxRows: 5, | ||
| 27 | chunkRows: 2, | 36 | chunkRows: 2, | ||
| 28 | rows: []string{"0000", "1111", "2222", "3333", "4444", "5555"}, | 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 | name: "rows preserve append order across chunk boundaries", | 41 | name: "rows preserve append order across chunk boundaries", | ||
| @@ -41,7 +50,7 @@ func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) { | |||||
| 41 | t.Run(tt.name, func(t *testing.T) { | 50 | t.Run(tt.name, func(t *testing.T) { | ||
| 42 | history := NewHistory(HistoryConfig{MaxRows: tt.maxRows, ChunkRows: tt.chunkRows}) | 51 | history := NewHistory(HistoryConfig{MaxRows: tt.maxRows, ChunkRows: tt.chunkRows}) | ||
| 43 | for _, text := range tt.rows { | 52 | for _, text := range tt.rows { | ||
| 44 | - | history.Append(historyRow(text)) | 53 | + | requireHistoryAppend(t, history, historyRow(text)) |
| 45 | } | 54 | } | ||
| 46 | 55 | ||||
| 47 | view := history.View() | 56 | view := history.View() | ||
| @@ -70,15 +79,15 @@ func TestHistoryTailRotationAndStableViews(t *testing.T) { | |||||
| 70 | t.Run(tt.name, func(t *testing.T) { | 79 | t.Run(tt.name, func(t *testing.T) { | ||
| 71 | history := NewHistory(HistoryConfig{MaxRows: 6, ChunkRows: 2}) | 80 | history := NewHistory(HistoryConfig{MaxRows: 6, ChunkRows: 2}) | ||
| 72 | first := historyRow("AAAA") | 81 | first := historyRow("AAAA") | ||
| 73 | - | history.Append(first) | 82 | + | requireHistoryAppend(t, history, first) |
| 74 | - | history.Append(historyRow("BBBB")) | 83 | + | requireHistoryAppend(t, history, historyRow("BBBB")) |
| 75 | view := history.View() | 84 | view := history.View() | ||
| 76 | if got, want := view.ChunkCount(), 1; got != want { | 85 | if got, want := view.ChunkCount(), 1; got != want { | ||
| 77 | t.Fatalf("sealed chunk count = %d, want %d", got, want) | 86 | t.Fatalf("sealed chunk count = %d, want %d", got, want) | ||
| 78 | } | 87 | } | ||
| 79 | 88 | ||||
| 80 | first[0].Rune = 'X' | 89 | first[0].Rune = 'X' | ||
| 81 | - | history.Append(historyRow("CCCC")) | 90 | + | requireHistoryAppend(t, history, historyRow("CCCC")) |
| 82 | 91 | ||||
| 83 | if got, want := historyViewTexts(view), []string{"AAAA", "BBBB"}; !equalStrings(got, want) { | 92 | if got, want := historyViewTexts(view), []string{"AAAA", "BBBB"}; !equalStrings(got, want) { | ||
| 84 | t.Fatalf("stable view rows = %#v, want %#v", got, want) | 93 | t.Fatalf("stable view rows = %#v, want %#v", got, want) | ||
| @@ -100,11 +109,11 @@ func TestHistoryReusesSealedChunkIdentityAcrossViews(t *testing.T) { | |||||
| 100 | for _, tt := range tests { | 109 | for _, tt := range tests { | ||
| 101 | t.Run(tt.name, func(t *testing.T) { | 110 | t.Run(tt.name, func(t *testing.T) { | ||
| 102 | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | 111 | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | ||
| 103 | - | history.Append(historyRow("AAAA")) | 112 | + | requireHistoryAppend(t, history, historyRow("AAAA")) |
| 104 | - | history.Append(historyRow("BBBB")) | 113 | + | requireHistoryAppend(t, history, historyRow("BBBB")) |
| 105 | before := history.View() | 114 | before := history.View() | ||
| 106 | 115 | ||||
| 107 | - | history.Append(historyRow("CCCC")) | 116 | + | requireHistoryAppend(t, history, historyRow("CCCC")) |
| 108 | after := history.View() | 117 | after := history.View() | ||
| 109 | 118 | ||||
| 110 | if before.ChunkCount() == 0 || after.ChunkCount() == 0 { | 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 | func historyRow(text string) []renderer.Cell { | 293 | func historyRow(text string) []renderer.Cell { | ||
| 196 | row := make([]renderer.Cell, len(text)) | 294 | row := make([]renderer.Cell, len(text)) | ||
| 197 | for i, r := range text { | 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 | package vt | 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 | func (s *Screen) index() { | 9 | func (s *Screen) index() { | ||
| 6 | if s.Frame.Height == 0 { | 10 | if s.Frame.Height == 0 { | ||
| @@ -88,7 +92,12 @@ func (s *Screen) emitLineEvicted(top, n int) { | |||||
| 88 | 92 | ||||
| 89 | func (s *Screen) recordEvicted(row []renderer.Cell) { | 93 | func (s *Screen) recordEvicted(row []renderer.Cell) { | ||
| 90 | if s.history != nil { | 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 | if s.OnLineEvicted != nil { | 102 | if s.OnLineEvicted != nil { | ||
| 94 | s.OnLineEvicted(append([]renderer.Cell(nil), row...)) | 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 | // MarshalEmptyHistoryTail returns the mandatory canonical empty tail blob. | 54 | // MarshalEmptyHistoryTail returns the mandatory canonical empty tail blob. | ||
| 55 | func MarshalEmptyHistoryTail() ([]byte, error) { return MarshalHistory(HistoryView{}) } | 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 | // MarshalSealedHistory serializes a SealAndView result as oldest-first, | 63 | // MarshalSealedHistory serializes a SealAndView result as oldest-first, | ||
| 58 | // self-contained sealed blobs plus a mandatory empty canonical tail blob. | 64 | // self-contained sealed blobs plus a mandatory empty canonical tail blob. | ||
| 59 | func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) { | 65 | func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) { | ||
| @@ -84,18 +90,23 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist | |||||
| 84 | if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 { | 90 | if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 { | ||
| 85 | return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory) | 91 | return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory) | ||
| 86 | } | 92 | } | ||
| 93 | + | h.evictUntil(len(view.chunks[0].rows), view.Cells()) | |||
| 87 | h.chunks = append(h.chunks, view.chunks[0]) | 94 | h.chunks = append(h.chunks, view.chunks[0]) | ||
| 88 | h.rows += len(view.chunks[0].rows) | 95 | h.rows += len(view.chunks[0].rows) | ||
| 96 | + | h.cells += view.Cells() | |||
| 89 | } | 97 | } | ||
| 90 | view, err := UnmarshalHistory(tail) | 98 | view, err := UnmarshalHistory(tail) | ||
| 91 | if err != nil || len(view.chunks) > 1 { | 99 | if err != nil || len(view.chunks) > 1 { | ||
| 92 | return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory) | 100 | return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory) | ||
| 93 | } | 101 | } | ||
| 94 | if len(view.chunks) == 1 { | 102 | if len(view.chunks) == 1 { | ||
| 103 | + | h.evictUntil(len(view.chunks[0].rows), view.Cells()) | |||
| 95 | h.tail = view.chunks[0].rows | 104 | h.tail = view.chunks[0].rows | ||
| 96 | h.rows += len(h.tail) | 105 | h.rows += len(h.tail) | ||
| 106 | + | h.cells += view.Cells() | |||
| 97 | } | 107 | } | ||
| 98 | h.evict() | 108 | h.evict() | ||
| 109 | + | h.normalizeTail() | |||
| 99 | return h, nil | 110 | return h, nil | ||
| 100 | } | 111 | } | ||
| 101 | 112 | ||||
| @@ -130,17 +141,47 @@ func MarshalVisible(frame renderer.Frame) ([]byte, error) { | |||||
| 130 | return marshalVisible(frame, boundaries) | 141 | return marshalVisible(frame, boundaries) | ||
| 131 | } | 142 | } | ||
| 132 | 143 | ||||
| 133 | - | // MarshalPrimaryVisible preserves the primary buffer's logical-line boundaries | 144 | + | // PrimaryVisibleSnapshot is an owned copy of the primary buffer's visible |
| 134 | - | // alongside its cells so a restored viewport can subsequently reflow. | 145 | + | // state. Capture it while the screen owner is synchronized, then Marshal it |
| 135 | - | func (s *Screen) MarshalPrimaryVisible() ([]byte, error) { | 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 | b := s.buffer | 156 | b := s.buffer | ||
| 137 | if s.alternate != nil { | 157 | if s.alternate != nil { | ||
| 138 | b = s.alternate.buffer | 158 | b = s.alternate.buffer | ||
| 139 | } | 159 | } | ||
| 140 | if b == nil { | 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 | func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, error) { | 187 | func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, error) { | ||
M pkg/vt/snapshot_test.go +79 -0
| @@ -4,9 +4,88 @@ import ( | |||||
| 4 | "strings" | 4 | "strings" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | "github.com/stretchr/testify/require" | 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 | func TestPrimaryVisibleSnapshotRetainsReflowBoundaries(t *testing.T) { | 89 | func TestPrimaryVisibleSnapshotRetainsReflowBoundaries(t *testing.T) { | ||
| 11 | s := NewScreen(5, 3) | 90 | s := NewScreen(5, 3) | ||
| 12 | s.Write([]byte("abcdefgh")) | 91 | s.Write([]byte("abcdefgh")) | ||