detached bd37e91

test(recovery): complete acceptance verification

commit
bd37e91e19ef78dda8fa50444a9abf30fc851f7b
author
brice · · 2026-07-26 02:57 UTC
parent
e82f1b8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +82 -13
M internal/app/run.go +7 -2
@@ -117,6 +117,11 @@ var (
117117 version = "0.1.0-dev"
118118 commit = "none"
119119 date = "unknown"
120+
121+ openOrMigrate = persist.OpenOrMigrate
122+ listenDaemon = func(dir string, observer ports.SerializedRuntimeObserver) (ports.Listener, error) {
123+ return ipc.Listen(dir, ipc.WithRuntimeObserver(observer))
124+ }
120125 )
121126
122127 // versionLine renders the --version output.
@@ -632,7 +637,7 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
632637 noticeStore := noticefile.New(platform.StateDir())
633638 daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticeStore))
634639 storePath := persist.StorePath(platform.StateDir())
635- opened, err := persist.OpenOrMigrate(ctx, persist.OpenDeps{
640+ opened, err := openOrMigrate(ctx, persist.OpenDeps{
636641 StateDir: platform.StateDir(),
637642 Random: rand.Reader,
638643 SnapshotMigration: snapshotRepository,
@@ -676,7 +681,7 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
676681 // publication. Phase 3 snapshot restoration remains asynchronous in Serve.
677682 d, ln, err := constructDaemonBeforeSocketPublication(
678683 func() *daemon.Daemon { return daemon.New(pty.NewFactory(), clk, log, daemonOpts...) },
679- func() (ports.Listener, error) { return ipc.Listen(ipc.SocketDir(), ipc.WithRuntimeObserver(observer)) },
684+ func() (ports.Listener, error) { return listenDaemon(ipc.SocketDir(), observer) },
680685 )
681686 if err != nil {
682687 closeErr := opened.Catalogue.Close()
@@ -117,6 +117,11 @@ var (
117 version = "0.1.0-dev"117 version = "0.1.0-dev"
118 commit = "none"118 commit = "none"
119 date = "unknown"119 date = "unknown"
120+
121+ openOrMigrate = persist.OpenOrMigrate
122+ listenDaemon = func(dir string, observer ports.SerializedRuntimeObserver) (ports.Listener, error) {
123+ return ipc.Listen(dir, ipc.WithRuntimeObserver(observer))
124+ }
120 )125 )
121 126
122 // versionLine renders the --version output.127 // versionLine renders the --version output.
@@ -632,7 +637,7 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
632 noticeStore := noticefile.New(platform.StateDir())637 noticeStore := noticefile.New(platform.StateDir())
633 daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticeStore))638 daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticeStore))
634 storePath := persist.StorePath(platform.StateDir())639 storePath := persist.StorePath(platform.StateDir())
635- opened, err := persist.OpenOrMigrate(ctx, persist.OpenDeps{640+ opened, err := openOrMigrate(ctx, persist.OpenDeps{
636 StateDir: platform.StateDir(),641 StateDir: platform.StateDir(),
637 Random: rand.Reader,642 Random: rand.Reader,
638 SnapshotMigration: snapshotRepository,643 SnapshotMigration: snapshotRepository,
@@ -676,7 +681,7 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
676 // publication. Phase 3 snapshot restoration remains asynchronous in Serve.681 // publication. Phase 3 snapshot restoration remains asynchronous in Serve.
677 d, ln, err := constructDaemonBeforeSocketPublication(682 d, ln, err := constructDaemonBeforeSocketPublication(
678 func() *daemon.Daemon { return daemon.New(pty.NewFactory(), clk, log, daemonOpts...) },683 func() *daemon.Daemon { return daemon.New(pty.NewFactory(), clk, log, daemonOpts...) },
679- func() (ports.Listener, error) { return ipc.Listen(ipc.SocketDir(), ipc.WithRuntimeObserver(observer)) },684+ func() (ports.Listener, error) { return listenDaemon(ipc.SocketDir(), observer) },
680 )685 )
681 if err != nil {686 if err != nil {
682 closeErr := opened.Catalogue.Close()687 closeErr := opened.Catalogue.Close()
M internal/app/run_test.go +39 -0
@@ -212,6 +212,45 @@ func TestOfflineCommandsPropagateLifecycleReleaseErrors(t *testing.T) {
212212 }
213213 }
214214
215+func TestCatalogueFailureDoesNotListen(t *testing.T) {
216+ originalOpenOrMigrate, originalListenDaemon := openOrMigrate, listenDaemon
217+ t.Cleanup(func() {
218+ openOrMigrate = originalOpenOrMigrate
219+ listenDaemon = originalListenDaemon
220+ })
221+
222+ for _, catalogueErr := range []error{errors.New("catalogue corrupt"), errors.New("catalogue unavailable")} {
223+ t.Run(catalogueErr.Error(), func(t *testing.T) {
224+ runtimeRoot, stateRoot := t.TempDir(), t.TempDir()
225+ runtimeDir := filepath.Join(runtimeRoot, "vev")
226+ stateDir := filepath.Join(stateRoot, "vev")
227+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
228+ t.Setenv("XDG_STATE_HOME", stateRoot)
229+
230+ listenCalls := 0
231+ openOrMigrate = func(_ context.Context, deps persist.OpenDeps) (persist.OpenResult, error) {
232+ require.Equal(t, stateDir, deps.StateDir)
233+ return persist.OpenResult{}, catalogueErr
234+ }
235+ listenDaemon = func(string, ports.SerializedRuntimeObserver) (ports.Listener, error) {
236+ listenCalls++
237+ return nil, errors.New("IPC listen must not run after catalogue failure")
238+ }
239+
240+ err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, func(ctx context.Context) error {
241+ return runDaemonOwnedWithLogger(ctx, slog.New(slog.NewTextHandler(io.Discard, nil)))
242+ })
243+ require.ErrorIs(t, err, catalogueErr)
244+ require.Zero(t, listenCalls)
245+ require.NoFileExists(t, filepath.Join(runtimeDir, "daemon.sock"))
246+
247+ owner, acquireErr := lifecycle.TryAcquire(runtimeDir)
248+ require.NoError(t, acquireErr, "catalogue failure must release lifecycle ownership")
249+ require.NoError(t, owner.Release())
250+ })
251+ }
252+}
253+
215254 func TestCatalogueRegistryConstructionPrecedesSocketPublication(t *testing.T) {
216255 var events []string
217256 _, _, err := constructDaemonBeforeSocketPublication(
@@ -212,6 +212,45 @@ func TestOfflineCommandsPropagateLifecycleReleaseErrors(t *testing.T) {
212 }212 }
213 }213 }
214 214
215+func TestCatalogueFailureDoesNotListen(t *testing.T) {
216+ originalOpenOrMigrate, originalListenDaemon := openOrMigrate, listenDaemon
217+ t.Cleanup(func() {
218+ openOrMigrate = originalOpenOrMigrate
219+ listenDaemon = originalListenDaemon
220+ })
221+
222+ for _, catalogueErr := range []error{errors.New("catalogue corrupt"), errors.New("catalogue unavailable")} {
223+ t.Run(catalogueErr.Error(), func(t *testing.T) {
224+ runtimeRoot, stateRoot := t.TempDir(), t.TempDir()
225+ runtimeDir := filepath.Join(runtimeRoot, "vev")
226+ stateDir := filepath.Join(stateRoot, "vev")
227+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
228+ t.Setenv("XDG_STATE_HOME", stateRoot)
229+
230+ listenCalls := 0
231+ openOrMigrate = func(_ context.Context, deps persist.OpenDeps) (persist.OpenResult, error) {
232+ require.Equal(t, stateDir, deps.StateDir)
233+ return persist.OpenResult{}, catalogueErr
234+ }
235+ listenDaemon = func(string, ports.SerializedRuntimeObserver) (ports.Listener, error) {
236+ listenCalls++
237+ return nil, errors.New("IPC listen must not run after catalogue failure")
238+ }
239+
240+ err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, func(ctx context.Context) error {
241+ return runDaemonOwnedWithLogger(ctx, slog.New(slog.NewTextHandler(io.Discard, nil)))
242+ })
243+ require.ErrorIs(t, err, catalogueErr)
244+ require.Zero(t, listenCalls)
245+ require.NoFileExists(t, filepath.Join(runtimeDir, "daemon.sock"))
246+
247+ owner, acquireErr := lifecycle.TryAcquire(runtimeDir)
248+ require.NoError(t, acquireErr, "catalogue failure must release lifecycle ownership")
249+ require.NoError(t, owner.Release())
250+ })
251+ }
252+}
253+
215 func TestCatalogueRegistryConstructionPrecedesSocketPublication(t *testing.T) {254 func TestCatalogueRegistryConstructionPrecedesSocketPublication(t *testing.T) {
216 var events []string255 var events []string
217 _, _, err := constructDaemonBeforeSocketPublication(256 _, _, err := constructDaemonBeforeSocketPublication(
M internal/persist/migration.go +13 -10
@@ -563,7 +563,7 @@ func appendMigrationString(out []byte, value string) []byte {
563563 out = binary.BigEndian.AppendUint32(out, uint32(len(value)))
564564 return append(out, value...)
565565 }
566-func atomicMigrationWrite(path string, data []byte) error {
566+func atomicMigrationWrite(path string, data []byte) (retErr error) {
567567 dir := filepath.Dir(path)
568568 if err := safedir.EnsurePrivate(dir); err != nil {
569569 return err
@@ -573,18 +573,21 @@ func atomicMigrationWrite(path string, data []byte) error {
573573 return err
574574 }
575575 tmpName := tmp.Name()
576- defer os.Remove(tmpName)
576+ published := false
577+ defer func() {
578+ if !published {
579+ // A failed publication must not leave a replayable partial intent.
580+ retErr = errors.Join(retErr, os.Remove(tmpName))
581+ }
582+ }()
577583 if err := tmp.Chmod(0o600); err != nil {
578- tmp.Close()
579- return err
584+ return errors.Join(err, tmp.Close())
580585 }
581586 if _, err := tmp.Write(data); err != nil {
582- tmp.Close()
583- return err
587+ return errors.Join(err, tmp.Close())
584588 }
585589 if err := tmp.Sync(); err != nil {
586- tmp.Close()
587- return err
590+ return errors.Join(err, tmp.Close())
588591 }
589592 if err := tmp.Close(); err != nil {
590593 return err
@@ -592,6 +595,7 @@ func atomicMigrationWrite(path string, data []byte) error {
592595 if err := os.Rename(tmpName, path); err != nil {
593596 return err
594597 }
598+ published = true
595599 return syncMigrationDir(dir)
596600 }
597601 func syncMigrationDir(dir string) error {
@@ -599,8 +603,7 @@ func syncMigrationDir(dir string) error {
599603 if err != nil {
600604 return err
601605 }
602- defer f.Close()
603- return f.Sync()
606+ return errors.Join(f.Sync(), f.Close())
604607 }
605608 func catalogueRecordsEqual(a, b []domain.CatalogueRecord) bool {
606609 if len(a) != len(b) {
@@ -563,7 +563,7 @@ func appendMigrationString(out []byte, value string) []byte {
563 out = binary.BigEndian.AppendUint32(out, uint32(len(value)))563 out = binary.BigEndian.AppendUint32(out, uint32(len(value)))
564 return append(out, value...)564 return append(out, value...)
565 }565 }
566-func atomicMigrationWrite(path string, data []byte) error {566+func atomicMigrationWrite(path string, data []byte) (retErr error) {
567 dir := filepath.Dir(path)567 dir := filepath.Dir(path)
568 if err := safedir.EnsurePrivate(dir); err != nil {568 if err := safedir.EnsurePrivate(dir); err != nil {
569 return err569 return err
@@ -573,18 +573,21 @@ func atomicMigrationWrite(path string, data []byte) error {
573 return err573 return err
574 }574 }
575 tmpName := tmp.Name()575 tmpName := tmp.Name()
576- defer os.Remove(tmpName)576+ published := false
577+ defer func() {
578+ if !published {
579+ // A failed publication must not leave a replayable partial intent.
580+ retErr = errors.Join(retErr, os.Remove(tmpName))
581+ }
582+ }()
577 if err := tmp.Chmod(0o600); err != nil {583 if err := tmp.Chmod(0o600); err != nil {
578- tmp.Close()584+ return errors.Join(err, tmp.Close())
579- return err
580 }585 }
581 if _, err := tmp.Write(data); err != nil {586 if _, err := tmp.Write(data); err != nil {
582- tmp.Close()587+ return errors.Join(err, tmp.Close())
583- return err
584 }588 }
585 if err := tmp.Sync(); err != nil {589 if err := tmp.Sync(); err != nil {
586- tmp.Close()590+ return errors.Join(err, tmp.Close())
587- return err
588 }591 }
589 if err := tmp.Close(); err != nil {592 if err := tmp.Close(); err != nil {
590 return err593 return err
@@ -592,6 +595,7 @@ func atomicMigrationWrite(path string, data []byte) error {
592 if err := os.Rename(tmpName, path); err != nil {595 if err := os.Rename(tmpName, path); err != nil {
593 return err596 return err
594 }597 }
598+ published = true
595 return syncMigrationDir(dir)599 return syncMigrationDir(dir)
596 }600 }
597 func syncMigrationDir(dir string) error {601 func syncMigrationDir(dir string) error {
@@ -599,8 +603,7 @@ func syncMigrationDir(dir string) error {
599 if err != nil {603 if err != nil {
600 return err604 return err
601 }605 }
602- defer f.Close()606+ return errors.Join(f.Sync(), f.Close())
603- return f.Sync()
604 }607 }
605 func catalogueRecordsEqual(a, b []domain.CatalogueRecord) bool {608 func catalogueRecordsEqual(a, b []domain.CatalogueRecord) bool {
606 if len(a) != len(b) {609 if len(a) != len(b) {
M internal/persist/migration_test.go +15 -0
@@ -278,6 +278,21 @@ func TestOpenOrMigrateLegacyProbe(t *testing.T) {
278278 }
279279 }
280280
281+func TestAtomicMigrationWriteRemovesTempAfterRenameFailure(t *testing.T) {
282+ dir := t.TempDir()
283+ target := filepath.Join(dir, "target")
284+ require.NoError(t, os.Mkdir(target, 0o700))
285+
286+ err := atomicMigrationWrite(target, []byte("intent"))
287+ require.Error(t, err)
288+
289+ entries, readErr := os.ReadDir(dir)
290+ require.NoError(t, readErr)
291+ for _, entry := range entries {
292+ require.NotContains(t, entry.Name(), ".migration-", "failed publication must remove its temporary intent")
293+ }
294+}
295+
281296 type countingReader struct{ next byte }
282297
283298 func (r *countingReader) Read(p []byte) (int, error) {
@@ -278,6 +278,21 @@ func TestOpenOrMigrateLegacyProbe(t *testing.T) {
278 }278 }
279 }279 }
280 280
281+func TestAtomicMigrationWriteRemovesTempAfterRenameFailure(t *testing.T) {
282+ dir := t.TempDir()
283+ target := filepath.Join(dir, "target")
284+ require.NoError(t, os.Mkdir(target, 0o700))
285+
286+ err := atomicMigrationWrite(target, []byte("intent"))
287+ require.Error(t, err)
288+
289+ entries, readErr := os.ReadDir(dir)
290+ require.NoError(t, readErr)
291+ for _, entry := range entries {
292+ require.NotContains(t, entry.Name(), ".migration-", "failed publication must remove its temporary intent")
293+ }
294+}
295+
281 type countingReader struct{ next byte }296 type countingReader struct{ next byte }
282 297
283 func (r *countingReader) Read(p []byte) (int, error) {298 func (r *countingReader) Read(p []byte) (int, error) {
M internal/usecase/daemon/performance_benchmark_test.go +8 -1
@@ -725,7 +725,14 @@ func snapshotGeneration(publication ports.SnapshotPublication) ports.SnapshotGen
725725 for _, object := range publication.Objects {
726726 objects[object.Digest] = object.Data
727727 }
728- return ports.SnapshotGeneration{Name: publication.Name, Generation: publication.Generation, Manifest: publication.Manifest, Objects: objects}
728+ return ports.SnapshotGeneration{
729+ IncarnationID: publication.IncarnationID,
730+ Name: publication.Name,
731+ Generation: publication.Generation,
732+ ParentCheckpoint: publication.ParentCheckpoint,
733+ Manifest: publication.Manifest,
734+ Objects: objects,
735+ }
729736 }
730737
731738 func snapshotObjectBytes(publication ports.SnapshotPublication) uint64 {
@@ -725,7 +725,14 @@ func snapshotGeneration(publication ports.SnapshotPublication) ports.SnapshotGen
725 for _, object := range publication.Objects {725 for _, object := range publication.Objects {
726 objects[object.Digest] = object.Data726 objects[object.Digest] = object.Data
727 }727 }
728- return ports.SnapshotGeneration{Name: publication.Name, Generation: publication.Generation, Manifest: publication.Manifest, Objects: objects}728+ return ports.SnapshotGeneration{
729+ IncarnationID: publication.IncarnationID,
730+ Name: publication.Name,
731+ Generation: publication.Generation,
732+ ParentCheckpoint: publication.ParentCheckpoint,
733+ Manifest: publication.Manifest,
734+ Objects: objects,
735+ }
729 }736 }
730 737
731 func snapshotObjectBytes(publication ports.SnapshotPublication) uint64 {738 func snapshotObjectBytes(publication ports.SnapshotPublication) uint64 {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close