From 87546241f3d974424f90e06621d8da6f89ac678c Mon Sep 17 00:00:00 2001 From: Pablo Ontiveros Date: Wed, 7 Oct 2026 04:34:53 -0600 Subject: [PATCH 1/2] refactor(startup): inject sync service dependencies --- cmd/backscroll/context_startup_test.go | 4 +- cmd/backscroll/empty_pi_replay_test.go | 30 +-- cmd/backscroll/index_policy_test.go | 15 +- cmd/backscroll/origin_replay_test.go | 36 ++- cmd/backscroll/recover.go | 10 +- cmd/backscroll/recover_test.go | 66 +++--- cmd/backscroll/replay_scheduler_test.go | 12 +- cmd/backscroll/startup_coordination.go | 52 ++--- .../startup_coordination_process_test.go | 9 +- cmd/backscroll/startup_coordination_test.go | 49 +++-- cmd/backscroll/startup_policy.go | 10 +- cmd/backscroll/startup_policy_test.go | 43 ++-- cmd/backscroll/startup_prefilter_test.go | 26 +-- cmd/backscroll/sync_helpers.go | 101 +++++---- cmd/backscroll/sync_helpers_context_test.go | 207 ++++++++++++------ 15 files changed, 359 insertions(+), 311 deletions(-) diff --git a/cmd/backscroll/context_startup_test.go b/cmd/backscroll/context_startup_test.go index 58db1aa..5c8a094 100644 --- a/cmd/backscroll/context_startup_test.go +++ b/cmd/backscroll/context_startup_test.go @@ -166,9 +166,9 @@ func TestRootContextBusyFollowerUsesCommittedSnapshot(t *testing.T) { } return originalPrepare(ctx, gotCfg, class) } - coordinator.sync = func(context.Context, *config.Config, io.Writer) error { + coordinator.syncService.open = func(context.Context, string) (*storage.Database, error) { t.Fatal("busy snapshot follower must not synchronize") - return nil + return nil, nil } var stdout, stderr bytes.Buffer diff --git a/cmd/backscroll/empty_pi_replay_test.go b/cmd/backscroll/empty_pi_replay_test.go index 0a72a96..0010381 100644 --- a/cmd/backscroll/empty_pi_replay_test.go +++ b/cmd/backscroll/empty_pi_replay_test.go @@ -57,12 +57,8 @@ func TestMarkedEmptyPiRacyCleanComparesCanonicalContentHash(t *testing.T) { hashes: map[string]string{path: initialHash}, } - oldActiveInputs, oldNewRegistry := maybeAutoSyncActiveInputs, maybeAutoSyncNewRegistry - t.Cleanup(func() { - maybeAutoSyncActiveInputs = oldActiveInputs - maybeAutoSyncNewRegistry = oldNewRegistry - }) - maybeAutoSyncActiveInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + syncService := newStartupSyncService() + syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { return []input_config.InputDefinition{{ ID: "marked-empty-pi", Source: "session", @@ -70,7 +66,7 @@ func TestMarkedEmptyPiRacyCleanComparesCanonicalContentHash(t *testing.T) { Decode: input_config.DecodeConfig{Format: "pi"}, }}, input_config.ModeDeclarative, nil } - maybeAutoSyncNewRegistry = func() *readers.Registry { + syncService.newRegistry = func() *readers.Registry { registry := readers.NewRegistry() registry.Register(reader) return registry @@ -95,7 +91,7 @@ func TestMarkedEmptyPiRacyCleanComparesCanonicalContentHash(t *testing.T) { t.Fatal(err) } - if err := maybeAutoSync(&cfg, &bytes.Buffer{}); err != nil { + if err := syncService.sync(context.Background(), &cfg, &bytes.Buffer{}, startupPhaseTiming{}); err != nil { t.Fatalf("immutable racy-clean sync: %v", err) } if reader.hashCalls != 1 || reader.parseCalls != 0 { @@ -129,7 +125,7 @@ func TestMarkedEmptyPiRacyCleanComparesCanonicalContentHash(t *testing.T) { } reader.hashes[path] = "raw-changed" - if err := maybeAutoSync(&cfg, &bytes.Buffer{}); err != nil { + if err := syncService.sync(context.Background(), &cfg, &bytes.Buffer{}, startupPhaseTiming{}); err != nil { t.Fatalf("changed racy-clean sync: %v", err) } if reader.hashCalls != 2 || reader.parseCalls != 1 { @@ -154,12 +150,8 @@ func TestEmptyPiReplayCapDrainsAndConverges(t *testing.T) { reader := &emptyPiReplayReader{hashes: make(map[string]string)} oldTime := time.Now().Add(-time.Hour) - oldActiveInputs, oldNewRegistry := maybeAutoSyncActiveInputs, maybeAutoSyncNewRegistry - t.Cleanup(func() { - maybeAutoSyncActiveInputs = oldActiveInputs - maybeAutoSyncNewRegistry = oldNewRegistry - }) - maybeAutoSyncActiveInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + syncService := newStartupSyncService() + syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { return []input_config.InputDefinition{{ ID: "empty-pi-replay", Source: "session", @@ -167,7 +159,7 @@ func TestEmptyPiReplayCapDrainsAndConverges(t *testing.T) { Decode: input_config.DecodeConfig{Format: "pi"}, }}, input_config.ModeDeclarative, nil } - maybeAutoSyncNewRegistry = func() *readers.Registry { + syncService.newRegistry = func() *readers.Registry { registry := readers.NewRegistry() registry.Register(reader) return registry @@ -211,7 +203,7 @@ func TestEmptyPiReplayCapDrainsAndConverges(t *testing.T) { t.Fatal(err) } - if err := maybeAutoSync(&cfg, &bytes.Buffer{}); err != nil { + if err := syncService.sync(context.Background(), &cfg, &bytes.Buffer{}, startupPhaseTiming{}); err != nil { t.Fatalf("first replay: %v", err) } if reader.parseCalls != 200 { @@ -237,7 +229,7 @@ func TestEmptyPiReplayCapDrainsAndConverges(t *testing.T) { t.Fatal(err) } - if err := maybeAutoSync(&cfg, &bytes.Buffer{}); err != nil { + if err := syncService.sync(context.Background(), &cfg, &bytes.Buffer{}, startupPhaseTiming{}); err != nil { t.Fatalf("second replay: %v", err) } if reader.parseCalls != 201 { @@ -259,7 +251,7 @@ func TestEmptyPiReplayCapDrainsAndConverges(t *testing.T) { t.Fatal(err) } - if err := maybeAutoSync(&cfg, &bytes.Buffer{}); err != nil { + if err := syncService.sync(context.Background(), &cfg, &bytes.Buffer{}, startupPhaseTiming{}); err != nil { t.Fatalf("convergence replay: %v", err) } if reader.parseCalls != 201 { diff --git a/cmd/backscroll/index_policy_test.go b/cmd/backscroll/index_policy_test.go index 8824652..0609ded 100644 --- a/cmd/backscroll/index_policy_test.go +++ b/cmd/backscroll/index_policy_test.go @@ -363,11 +363,6 @@ func TestAutoSyncFailuresBlockCachedConsumers(t *testing.T) { setup: func(t *testing.T, root string) { writeInputManifest(t, root, "claude", root, []string{"*.jsonl"}, nil) writeFile(t, filepath.Join(root, "session.jsonl"), `{"type":"message","message":{"role":"user","content":"fresh"}}`+"\n") - orig := maybeAutoSyncSyncFiles - maybeAutoSyncSyncFiles = func(context.Context, *storage.Database, []storage.IndexedFile) error { - return fmt.Errorf("injected sync failure") - } - t.Cleanup(func() { maybeAutoSyncSyncFiles = orig }) }, wantError: "sync files", }, @@ -395,8 +390,16 @@ func TestAutoSyncFailuresBlockCachedConsumers(t *testing.T) { setIndexPolicyEnv(t, dbPath, t.TempDir()) tc.setup(t, root) + syncService := newStartupSyncService() + if tc.name == "sync" { + syncService.syncFiles = func(context.Context, *storage.Database, []storage.IndexedFile) error { + return fmt.Errorf("injected sync failure") + } + } var stdout, stderr bytes.Buffer - err := run(&stdout, &stderr, mode.argv) + cmd := buildRootCmdWithCoordinator(&stdout, &stderr, newStartupCoordinatorWithSyncService(syncService)) + cmd.SetArgs(mode.argv) + err := cmd.Execute() if err == nil { t.Fatalf("auto-sync %s failure succeeded; stdout=%q stderr=%q", tc.name, stdout.String(), stderr.String()) } diff --git a/cmd/backscroll/origin_replay_test.go b/cmd/backscroll/origin_replay_test.go index 4acde1a..201d7c1 100644 --- a/cmd/backscroll/origin_replay_test.go +++ b/cmd/backscroll/origin_replay_test.go @@ -92,12 +92,8 @@ func TestOriginParserSyncReplayIsBoundedAndConverges(t *testing.T) { } reader := &originReplayClaudeReader{} - oldActiveInputs, oldNewRegistry := maybeAutoSyncActiveInputs, maybeAutoSyncNewRegistry - t.Cleanup(func() { - maybeAutoSyncActiveInputs = oldActiveInputs - maybeAutoSyncNewRegistry = oldNewRegistry - }) - maybeAutoSyncActiveInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + syncService := newStartupSyncService() + syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { return []input_config.InputDefinition{{ ID: "origin-replay-claude", Source: "session", @@ -109,14 +105,14 @@ func TestOriginParserSyncReplayIsBoundedAndConverges(t *testing.T) { Decode: input_config.DecodeConfig{Format: "claude"}, }}, input_config.ModeDeclarative, nil } - maybeAutoSyncNewRegistry = func() *readers.Registry { + syncService.newRegistry = func() *readers.Registry { registry := readers.NewRegistry() registry.Register(reader) return registry } cfg := config.Config{DatabasePath: filepath.Join(tmp, "index.db")} - if err := maybeAutoSync(&cfg, &bytes.Buffer{}); err != nil { + if err := syncService.sync(context.Background(), &cfg, &bytes.Buffer{}, startupPhaseTiming{}); err != nil { t.Fatalf("initial sync: %v", err) } if reader.parseCalls != liveFiles { @@ -219,7 +215,7 @@ func TestOriginParserSyncReplayIsBoundedAndConverges(t *testing.T) { reader.replayUUIDLessRows = true reader.parsePaths = nil beforeReplay := reader.parseCalls - if err := maybeAutoSync(&cfg, &bytes.Buffer{}); err != nil { + if err := syncService.sync(context.Background(), &cfg, &bytes.Buffer{}, startupPhaseTiming{}); err != nil { t.Fatalf("first origin replay: %v", err) } if got := reader.parseCalls - beforeReplay; got != 200 { @@ -270,7 +266,7 @@ func TestOriginParserSyncReplayIsBoundedAndConverges(t *testing.T) { reader.parsePaths = nil beforeReplay = reader.parseCalls - if err := maybeAutoSync(&cfg, &bytes.Buffer{}); err != nil { + if err := syncService.sync(context.Background(), &cfg, &bytes.Buffer{}, startupPhaseTiming{}); err != nil { t.Fatalf("second origin replay: %v", err) } if got := reader.parseCalls - beforeReplay; got != 1 { @@ -301,7 +297,7 @@ func TestOriginParserSyncReplayIsBoundedAndConverges(t *testing.T) { } beforeReplay = reader.parseCalls - if err := maybeAutoSync(&cfg, &bytes.Buffer{}); err != nil { + if err := syncService.sync(context.Background(), &cfg, &bytes.Buffer{}, startupPhaseTiming{}); err != nil { t.Fatalf("converged origin replay: %v", err) } if got := reader.parseCalls - beforeReplay; got != 0 { @@ -326,12 +322,8 @@ func TestOriginPerennialZeroMessageReplayPreservesHistoryAndConverges(t *testing } reader := &originReplayClaudeReader{} - oldActiveInputs, oldNewRegistry := maybeAutoSyncActiveInputs, maybeAutoSyncNewRegistry - t.Cleanup(func() { - maybeAutoSyncActiveInputs = oldActiveInputs - maybeAutoSyncNewRegistry = oldNewRegistry - }) - maybeAutoSyncActiveInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + syncService := newStartupSyncService() + syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { return []input_config.InputDefinition{{ ID: "origin-zero-claude", Source: "session", @@ -343,14 +335,14 @@ func TestOriginPerennialZeroMessageReplayPreservesHistoryAndConverges(t *testing Decode: input_config.DecodeConfig{Format: "claude"}, }}, input_config.ModeDeclarative, nil } - maybeAutoSyncNewRegistry = func() *readers.Registry { + syncService.newRegistry = func() *readers.Registry { registry := readers.NewRegistry() registry.Register(reader) return registry } cfg := config.Config{DatabasePath: filepath.Join(tmp, "index.db")} - if err := maybeAutoSync(&cfg, &bytes.Buffer{}); err != nil { + if err := syncService.sync(context.Background(), &cfg, &bytes.Buffer{}, startupPhaseTiming{}); err != nil { t.Fatalf("initial sync: %v", err) } @@ -376,7 +368,7 @@ func TestOriginPerennialZeroMessageReplayPreservesHistoryAndConverges(t *testing } reader.parseErr = fmt.Errorf("injected parse failure") - if err := maybeAutoSync(&cfg, &bytes.Buffer{}); err == nil { + if err := syncService.sync(context.Background(), &cfg, &bytes.Buffer{}, startupPhaseTiming{}); err == nil { t.Fatal("parse failure unexpectedly succeeded") } db, err = storage.Open(cfg.DatabasePath) @@ -405,7 +397,7 @@ func TestOriginPerennialZeroMessageReplayPreservesHistoryAndConverges(t *testing reader.zeroRecords = true reader.parsePaths = nil beforeReplay := reader.parseCalls - if err := maybeAutoSync(&cfg, &bytes.Buffer{}); err != nil { + if err := syncService.sync(context.Background(), &cfg, &bytes.Buffer{}, startupPhaseTiming{}); err != nil { t.Fatalf("zero-message replay: %v", err) } if got := reader.parseCalls - beforeReplay; got != 1 { @@ -440,7 +432,7 @@ func TestOriginPerennialZeroMessageReplayPreservesHistoryAndConverges(t *testing } beforeReplay = reader.parseCalls - if err := maybeAutoSync(&cfg, &bytes.Buffer{}); err != nil { + if err := syncService.sync(context.Background(), &cfg, &bytes.Buffer{}, startupPhaseTiming{}); err != nil { t.Fatalf("converged zero-message replay: %v", err) } if got := reader.parseCalls - beforeReplay; got != 0 { diff --git a/cmd/backscroll/recover.go b/cmd/backscroll/recover.go index feb5b7f..b711a86 100644 --- a/cmd/backscroll/recover.go +++ b/cmd/backscroll/recover.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "io" "strings" @@ -10,10 +11,7 @@ import ( "github.com/spf13/cobra" ) -var recoverExecute = recovery.Execute -var recoverPostInstallSync = maybeAutoSyncContext - -func newRecoverCmd(stdout, stderr io.Writer) *cobra.Command { +func newRecoverCmd(stdout, stderr io.Writer, syncService *startupSyncService, execute func(context.Context, recovery.Options) (recovery.Report, error)) *cobra.Command { var from string var dryRun bool fromValue := singleUseStringValue{target: &from} @@ -40,7 +38,7 @@ func newRecoverCmd(stdout, stderr io.Writer) *cobra.Command { } cfg = loaded } - report, err := recoverExecute(cmd.Context(), recovery.Options{ + report, err := execute(cmd.Context(), recovery.Options{ ActivePath: cfg.DatabasePath, FromPath: from, DryRun: dryRun, @@ -52,7 +50,7 @@ func newRecoverCmd(stdout, stderr io.Writer) *cobra.Command { return fmt.Errorf("recovery failed: %w", err) } if !dryRun { - if err := recoverPostInstallSync(cmd.Context(), cfg, stderr); err != nil { + if err := syncService.sync(cmd.Context(), cfg, stderr, startup.timing); err != nil { installedPath := report.ActivePath if installedPath == "" { installedPath = cfg.DatabasePath diff --git a/cmd/backscroll/recover_test.go b/cmd/backscroll/recover_test.go index fa6fcfd..397c50f 100644 --- a/cmd/backscroll/recover_test.go +++ b/cmd/backscroll/recover_test.go @@ -17,6 +17,7 @@ import ( "github.com/pablontiv/backscroll/internal/compat" "github.com/pablontiv/backscroll/internal/config" + "github.com/pablontiv/backscroll/internal/input_config" "github.com/pablontiv/backscroll/internal/recovery" "github.com/pablontiv/backscroll/internal/storage" "github.com/spf13/cobra" @@ -63,8 +64,7 @@ func TestRecoverExecuteReceivesCommandContext(t *testing.T) { cancel() called := false - originalExecute := recoverExecute - recoverExecute = func(execCtx context.Context, opts recovery.Options) (recovery.Report, error) { + execute := func(execCtx context.Context, opts recovery.Options) (recovery.Report, error) { called = true if got := execCtx.Value(markerKey); got != "present" { t.Fatalf("recovery context marker = %v, want present", got) @@ -77,12 +77,11 @@ func TestRecoverExecuteReceivesCommandContext(t *testing.T) { } return recovery.Report{ActivePath: opts.ActivePath}, nil } - t.Cleanup(func() { recoverExecute = originalExecute }) var stdout, stderr bytes.Buffer - root := buildRootCmdWithStartup(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { return startupResult{Config: cfg} - }) + }, newStartupSyncService(), execute) root.SetContext(ctx) root.SetArgs([]string{"recover", "--from", "stranded.db", "--dry-run"}) if err := root.Execute(); err != nil { @@ -101,35 +100,33 @@ func TestRecoverPostInstallSyncBeforeReport(t *testing.T) { stdout := &firstWriteMarker{events: &events} var stderr bytes.Buffer - originalExecute := recoverExecute - recoverExecute = func(_ context.Context, opts recovery.Options) (recovery.Report, error) { + execute := func(_ context.Context, opts recovery.Options) (recovery.Report, error) { events = append(events, "recover") if opts.ActivePath != cfg.DatabasePath || opts.FromPath != "stranded.db" || opts.DryRun { t.Fatalf("recovery options = %+v, want active=%q from=stranded.db dryRun=false", opts, cfg.DatabasePath) } return recovery.Report{ActivePath: opts.ActivePath}, nil } - t.Cleanup(func() { recoverExecute = originalExecute }) - originalPostInstallSync := recoverPostInstallSync - recoverPostInstallSync = func(gotCtx context.Context, got *config.Config, progress io.Writer) error { + syncService := newStartupSyncService() + syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + return nil, input_config.ModeLegacy, nil + } + open := syncService.open + syncService.open = func(gotCtx context.Context, path string) (*storage.Database, error) { events = append(events, "sync") if gotCtx.Value(recoverContextKey{}) != "post-install" { t.Fatalf("post-install sync did not receive command context") } - if got != cfg { - t.Fatalf("post-install sync config pointer = %p, want %p", got, cfg) - } - if progress != &stderr { - t.Fatalf("post-install sync progress writer = %T, want stderr buffer", progress) + if path != cfg.DatabasePath { + t.Fatalf("post-install sync path = %q, want %q", path, cfg.DatabasePath) } - return nil + return open(gotCtx, path) } - t.Cleanup(func() { recoverPostInstallSync = originalPostInstallSync }) - root := buildRootCmdWithStartup(stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { return startupResult{Config: cfg} - }) + }, syncService, execute) root.SetContext(ctx) root.SetArgs([]string{"recover", "--from", "stranded.db"}) if err := root.Execute(); err != nil { @@ -143,27 +140,24 @@ func TestRecoverPostInstallSyncBeforeReport(t *testing.T) { func TestRecoverDryRunSkipsPostInstallSync(t *testing.T) { cfg := &config.Config{DatabasePath: filepath.Join(t.TempDir(), "active.db")} - originalExecute := recoverExecute - recoverExecute = func(_ context.Context, opts recovery.Options) (recovery.Report, error) { + execute := func(_ context.Context, opts recovery.Options) (recovery.Report, error) { if !opts.DryRun { t.Fatalf("DryRun = false, want true") } return recovery.Report{ActivePath: opts.ActivePath}, nil } - t.Cleanup(func() { recoverExecute = originalExecute }) syncCalled := false - originalPostInstallSync := recoverPostInstallSync - recoverPostInstallSync = func(context.Context, *config.Config, io.Writer) error { + syncService := newStartupSyncService() + syncService.open = func(context.Context, string) (*storage.Database, error) { syncCalled = true - return nil + return nil, errors.New("post-install sync unexpectedly called") } - t.Cleanup(func() { recoverPostInstallSync = originalPostInstallSync }) var stdout, stderr bytes.Buffer - root := buildRootCmdWithStartup(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { return startupResult{Config: cfg} - }) + }, syncService, execute) root.SetArgs([]string{"recover", "--from", "stranded.db", "--dry-run"}) if err := root.Execute(); err != nil { t.Fatalf("recover dry-run returned error: %v", err) @@ -179,22 +173,16 @@ func TestRecoverPostInstallSyncFailurePreservesSyncCause(t *testing.T) { installedPath := cfg.DatabasePath + ".installed" backupPath := cfg.DatabasePath + ".backup-test" - originalExecute := recoverExecute - recoverExecute = func(context.Context, recovery.Options) (recovery.Report, error) { + execute := func(context.Context, recovery.Options) (recovery.Report, error) { return recovery.Report{ActivePath: installedPath, BackupPath: backupPath}, nil } - t.Cleanup(func() { recoverExecute = originalExecute }) - - originalPostInstallSync := recoverPostInstallSync - recoverPostInstallSync = func(context.Context, *config.Config, io.Writer) error { - return syncErr - } - t.Cleanup(func() { recoverPostInstallSync = originalPostInstallSync }) + syncService := newStartupSyncService() + syncService.open = func(context.Context, string) (*storage.Database, error) { return nil, syncErr } var stdout, stderr bytes.Buffer - root := buildRootCmdWithStartup(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { return startupResult{Config: cfg} - }) + }, syncService, execute) root.SetArgs([]string{"recover", "--from", "stranded.db"}) err := root.Execute() if !errors.Is(err, syncErr) { diff --git a/cmd/backscroll/replay_scheduler_test.go b/cmd/backscroll/replay_scheduler_test.go index e296dab..2fcf855 100644 --- a/cmd/backscroll/replay_scheduler_test.go +++ b/cmd/backscroll/replay_scheduler_test.go @@ -312,25 +312,21 @@ func TestReplaySchedulerProductiveDuplicatePathHashesAndParsesOnce(t *testing.T) hashCalls: make(map[string]int), parseCalls: make(map[string]int), } - oldActiveInputs, oldNewRegistry := maybeAutoSyncActiveInputs, maybeAutoSyncNewRegistry - t.Cleanup(func() { - maybeAutoSyncActiveInputs = oldActiveInputs - maybeAutoSyncNewRegistry = oldNewRegistry - }) - maybeAutoSyncActiveInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + syncService := newStartupSyncService() + syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { return []input_config.InputDefinition{ {ID: "z-input", Source: "session", Active: true, Decode: input_config.DecodeConfig{Format: "claude"}}, {ID: "a-input", Source: "session", Active: true}, }, input_config.ModeDeclarative, nil } - maybeAutoSyncNewRegistry = func() *readers.Registry { + syncService.newRegistry = func() *readers.Registry { registry := readers.NewRegistry() registry.Register(reader) return registry } cfg := config.Config{DatabasePath: filepath.Join(tmp, "index.db")} - if err := maybeAutoSync(&cfg, &bytes.Buffer{}); err != nil { + if err := syncService.sync(context.Background(), &cfg, &bytes.Buffer{}, startupPhaseTiming{}); err != nil { t.Fatalf("productive sync: %v", err) } if reader.hashCalls[path] != 1 || reader.parseCalls[path] != 1 { diff --git a/cmd/backscroll/startup_coordination.go b/cmd/backscroll/startup_coordination.go index 040397a..3fb141d 100644 --- a/cmd/backscroll/startup_coordination.go +++ b/cmd/backscroll/startup_coordination.go @@ -23,10 +23,14 @@ type startupCoordinator struct { tryAcquire func(string) (startupLease, bool, error) acquire func(context.Context, string, time.Duration) (startupLease, error) prepareIndex func(context.Context, *config.Config, indexCommandClass) (*storage.Database, *compat.Diagnostic, error) - sync func(context.Context, *config.Config, io.Writer) error + syncService *startupSyncService } func newStartupCoordinator() *startupCoordinator { + return newStartupCoordinatorWithSyncService(newStartupSyncService()) +} + +func newStartupCoordinatorWithSyncService(syncService *startupSyncService) *startupCoordinator { return &startupCoordinator{ mutationWait: defaultStartupMutationWait, tryAcquire: func(path string) (startupLease, bool, error) { @@ -38,7 +42,7 @@ func newStartupCoordinator() *startupCoordinator { prepareIndex: func(ctx context.Context, cfg *config.Config, class indexCommandClass) (*storage.Database, *compat.Diagnostic, error) { return prepareIndex(ctx, cfg, class) }, - sync: maybeAutoSyncContext, + syncService: syncService, } } @@ -47,9 +51,9 @@ func (c *startupCoordinator) coordinate(ctx context.Context, cfg *config.Config, return canceledStartupResult(cfg, startupStageSyncLock, err) } - // Measure lock acquisition time + timing := startupPhaseTiming{measured: c.syncService.diagnostics} var lockStart time.Time - if diagnosticsEnabled() { + if timing.measured { lockStart = time.Now() } @@ -62,19 +66,15 @@ func (c *startupCoordinator) coordinate(ctx context.Context, cfg *config.Config, return result } - // Record lock acquisition timing for successful immediate acquisition - if diagnosticsEnabled() && acquired && lockStart != (time.Time{}) { - if startupDiags == nil { - startupDiags = &startupPhaseTiming{} - } - startupDiags.LockAcquisitionTime = time.Since(lockStart) + if timing.measured && acquired && lockStart != (time.Time{}) { + timing.LockAcquisitionTime = time.Since(lockStart) } if err != nil { return startupLockFailure(cfg, err) } if acquired { - return c.runOwned(ctx, cfg, progress, class, lease) + return c.runOwned(ctx, cfg, progress, class, lease, timing) } switch class { @@ -92,19 +92,14 @@ func (c *startupCoordinator) coordinate(ctx context.Context, cfg *config.Config, waitCtx, cancel := context.WithTimeout(ctx, c.mutationWait) defer cancel() - // Measure waiting for lock - if diagnosticsEnabled() { + if timing.measured { lockStart = time.Now() } lease, err := c.acquire(waitCtx, cfg.DatabasePath, startupLockRetry) - // Record lock wait timing - if diagnosticsEnabled() && lockStart != (time.Time{}) { - if startupDiags == nil { - startupDiags = &startupPhaseTiming{} - } - startupDiags.LockAcquisitionTime = time.Since(lockStart) + if timing.measured && lockStart != (time.Time{}) { + timing.LockAcquisitionTime = time.Since(lockStart) } if err != nil { @@ -120,18 +115,17 @@ func (c *startupCoordinator) coordinate(ctx context.Context, cfg *config.Config, } return startupLockFailure(cfg, err) } - return c.runOwned(ctx, cfg, progress, class, lease) + return c.runOwned(ctx, cfg, progress, class, lease, timing) } } -func (c *startupCoordinator) runOwned(ctx context.Context, cfg *config.Config, progress io.Writer, class startupCommandClass, lease startupLease) startupResult { +func (c *startupCoordinator) runOwned(ctx context.Context, cfg *config.Config, progress io.Writer, class startupCommandClass, lease startupLease, timing startupPhaseTiming) startupResult { if class == startupRemediation { - return startupResult{Config: cfg, Lease: lease} + return startupResult{Config: cfg, Lease: lease, timing: timing} } - // Measure index preparation time var indexPrepareStart time.Time - if diagnosticsEnabled() { + if timing.measured { indexPrepareStart = time.Now() } @@ -140,12 +134,8 @@ func (c *startupCoordinator) runOwned(ctx context.Context, cfg *config.Config, p err = closeIndexDB(db, err) } - // Record index prepare timing - if diagnosticsEnabled() && indexPrepareStart != (time.Time{}) { - if startupDiags == nil { - startupDiags = &startupPhaseTiming{} - } - startupDiags.IndexPrepareTime = time.Since(indexPrepareStart) + if timing.measured && indexPrepareStart != (time.Time{}) { + timing.IndexPrepareTime = time.Since(indexPrepareStart) } if ctxErr := ctx.Err(); ctxErr != nil && diag == nil && err == nil { @@ -160,7 +150,7 @@ func (c *startupCoordinator) runOwned(ctx context.Context, cfg *config.Config, p } return ownedStartupFailureResult(cfg, class, lease, &startupFailure{Stage: startupStageIndexPrepare, Cause: err, Diagnostic: d, Recoverable: true}) } - if err := c.sync(ctx, cfg, progress); err != nil { + if err := c.syncService.sync(ctx, cfg, progress, timing); err != nil { activePath, _ := resolveActiveIndexPath(cfg.DatabasePath) d := continuationFor(compat.Diagnostic{Code: compat.CodeIndexStale, Summary: fmt.Sprintf("index sync failed: %v", err)}, activePath) return ownedStartupFailureResult(cfg, class, lease, &startupFailure{Stage: startupStageStartupSync, Cause: err, Diagnostic: d, Recoverable: true}) diff --git a/cmd/backscroll/startup_coordination_process_test.go b/cmd/backscroll/startup_coordination_process_test.go index 2b7bfe1..3fce111 100644 --- a/cmd/backscroll/startup_coordination_process_test.go +++ b/cmd/backscroll/startup_coordination_process_test.go @@ -2,10 +2,8 @@ package main import ( "bytes" - "context" "encoding/json" "fmt" - "io" "os" "os/exec" "path/filepath" @@ -14,7 +12,7 @@ import ( "testing" "time" - "github.com/pablontiv/backscroll/internal/config" + "github.com/pablontiv/backscroll/internal/input_config" "github.com/pablontiv/backscroll/internal/startuplock" "github.com/pablontiv/backscroll/internal/storage" ) @@ -42,7 +40,7 @@ func TestStartupCoordinationHelperProcess(t *testing.T) { coordinator.mutationWait = parsed } - coordinator.sync = func(context.Context, *config.Config, io.Writer) error { + syncHook := func() error { if counter == "" { return fmt.Errorf("BACKSCROLL_SYNC_COUNTER is required when startup sync runs") } @@ -83,6 +81,9 @@ func TestStartupCoordinationHelperProcess(t *testing.T) { } return nil } + coordinator.syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + return nil, input_config.ModeLegacy, syncHook() + } argvJSON := os.Getenv("BACKSCROLL_HELPER_ARGV") var argv []string diff --git a/cmd/backscroll/startup_coordination_test.go b/cmd/backscroll/startup_coordination_test.go index 4a2dc51..8fed3c7 100644 --- a/cmd/backscroll/startup_coordination_test.go +++ b/cmd/backscroll/startup_coordination_test.go @@ -11,6 +11,7 @@ import ( "github.com/pablontiv/backscroll/internal/compat" "github.com/pablontiv/backscroll/internal/config" + "github.com/pablontiv/backscroll/internal/input_config" "github.com/pablontiv/backscroll/internal/storage" ) @@ -23,6 +24,11 @@ func TestStartupCoordinatorsIsolateDependencies(t *testing.T) { t.Parallel() lease := &fakeStartupLease{} syncCalls := 0 + syncService := newStartupSyncService() + syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + syncCalls++ + return nil, input_config.ModeLegacy, nil + } coordinator := &startupCoordinator{ mutationWait: defaultStartupMutationWait, tryAcquire: func(path string) (startupLease, bool, error) { @@ -38,13 +44,7 @@ func TestStartupCoordinatorsIsolateDependencies(t *testing.T) { prepareIndex: func(context.Context, *config.Config, indexCommandClass) (*storage.Database, *compat.Diagnostic, error) { return nil, nil, nil }, - sync: func(_ context.Context, cfg *config.Config, _ io.Writer) error { - if !strings.Contains(cfg.DatabasePath, name) { - t.Fatalf("%s sync received another coordinator's config %q", name, cfg.DatabasePath) - } - syncCalls++ - return nil - }, + syncService: syncService, } result := coordinator.coordinate(context.Background(), &config.Config{DatabasePath: filepath.Join(t.TempDir(), name+".db")}, io.Discard, startupSnapshotRead) @@ -66,12 +66,12 @@ func TestCoordinateStartupImmediateOwnerSnapshotSyncsAndReleasesBeforeResult(t * coordinator.prepareIndex = func(context.Context, *config.Config, indexCommandClass) (*storage.Database, *compat.Diagnostic, error) { return nil, nil, nil } - coordinator.sync = func(context.Context, *config.Config, io.Writer) error { + coordinator.syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { syncCalls++ if lease.releases != 0 { t.Fatalf("lease released before sync") } - return nil + return nil, input_config.ModeLegacy, nil } result := coordinator.coordinate(context.Background(), &config.Config{DatabasePath: filepath.Join(t.TempDir(), "index.db")}, io.Discard, startupSnapshotRead) @@ -97,7 +97,10 @@ func TestCoordinateStartupImmediateOwnerMutationSyncsAndRetainsLease(t *testing. coordinator.prepareIndex = func(context.Context, *config.Config, indexCommandClass) (*storage.Database, *compat.Diagnostic, error) { return nil, nil, nil } - coordinator.sync = func(context.Context, *config.Config, io.Writer) error { syncCalls++; return nil } + coordinator.syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + syncCalls++ + return nil, input_config.ModeLegacy, nil + } result := coordinator.coordinate(context.Background(), &config.Config{DatabasePath: filepath.Join(t.TempDir(), "index.db")}, io.Discard, startupMutation) if result.Failure != nil { @@ -122,9 +125,9 @@ func TestCoordinateStartupImmediateRemediationRetainsLeaseWithoutPrepareOrSync(t t.Fatal("remediation must not prepare the index") return nil, nil, nil } - coordinator.sync = func(context.Context, *config.Config, io.Writer) error { + coordinator.syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { t.Fatal("remediation must not run pre-handler sync") - return nil + return nil, input_config.ModeLegacy, nil } cfg := &config.Config{DatabasePath: filepath.Join(t.TempDir(), "index.db")} @@ -146,7 +149,10 @@ func TestCoordinateStartupBusySnapshotUsesCompatibleReadOnlySnapshot(t *testing. cfg := &config.Config{DatabasePath: dbPath} syncCalls := 0 coordinator.tryAcquire = func(string) (startupLease, bool, error) { return nil, false, nil } - coordinator.sync = func(context.Context, *config.Config, io.Writer) error { syncCalls++; return nil } + coordinator.syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + syncCalls++ + return nil, input_config.ModeLegacy, nil + } result := coordinator.coordinate(context.Background(), cfg, io.Discard, startupSnapshotRead) if result.Failure != nil { @@ -204,7 +210,10 @@ func TestCoordinateStartupBusyMutationAcquiresWithinWaitAndBecomesOwner(t *testi coordinator.prepareIndex = func(context.Context, *config.Config, indexCommandClass) (*storage.Database, *compat.Diagnostic, error) { return nil, nil, nil } - coordinator.sync = func(context.Context, *config.Config, io.Writer) error { syncCalls++; return nil } + coordinator.syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + syncCalls++ + return nil, input_config.ModeLegacy, nil + } result := coordinator.coordinate(context.Background(), &config.Config{DatabasePath: filepath.Join(t.TempDir(), "index.db")}, io.Discard, startupMutation) if result.Failure != nil { @@ -235,9 +244,9 @@ func TestCoordinateStartupBusyRemediationAcquiresAndBypassesPrepareSync(t *testi t.Fatal("remediation must not prepare") return nil, nil, nil } - coordinator.sync = func(context.Context, *config.Config, io.Writer) error { + coordinator.syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { t.Fatal("remediation must not sync") - return nil + return nil, input_config.ModeLegacy, nil } result := coordinator.coordinate(context.Background(), &config.Config{DatabasePath: filepath.Join(t.TempDir(), "index.db")}, io.Discard, startupRemediation) @@ -263,9 +272,9 @@ func TestCoordinateStartupBusyMutationDeadlineReturnsSyncInProgressWithoutContin t.Fatal("prepare after timeout") return nil, nil, nil } - coordinator.sync = func(context.Context, *config.Config, io.Writer) error { + coordinator.syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { t.Fatal("sync after timeout") - return nil + return nil, input_config.ModeLegacy, nil } result := coordinator.coordinate(context.Background(), &config.Config{DatabasePath: filepath.Join(t.TempDir(), "index.db")}, io.Discard, class) @@ -325,7 +334,9 @@ func TestCoordinateStartupOwnerFailureLeaseRetentionByCommandClass(t *testing.T) } return nil, nil, nil } - coordinator.sync = func(context.Context, *config.Config, io.Writer) error { return syncErr } + coordinator.syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + return nil, input_config.ModeLegacy, syncErr + } result := coordinator.coordinate(context.Background(), &config.Config{DatabasePath: filepath.Join(t.TempDir(), "index.db")}, io.Discard, tc.class) failure := result.startupFailure() diff --git a/cmd/backscroll/startup_policy.go b/cmd/backscroll/startup_policy.go index 803af30..63e23fc 100644 --- a/cmd/backscroll/startup_policy.go +++ b/cmd/backscroll/startup_policy.go @@ -10,6 +10,7 @@ import ( "github.com/pablontiv/backscroll/internal/compat" "github.com/pablontiv/backscroll/internal/config" "github.com/pablontiv/backscroll/internal/input_config" + "github.com/pablontiv/backscroll/internal/recovery" "github.com/spf13/cobra" ) @@ -88,6 +89,7 @@ type startupResult struct { Failure *startupFailure Warning *startupWarning Lease startupLease + timing startupPhaseTiming } func (r startupResult) startupFailure() *startupFailure { @@ -170,10 +172,14 @@ func buildRootCmd(stdout, stderr io.Writer) *cobra.Command { } func buildRootCmdWithCoordinator(stdout, stderr io.Writer, coordinator *startupCoordinator) *cobra.Command { - return buildRootCmdWithStartup(stdout, stderr, coordinator.defaultStartupPolicy) + return buildRootCmdWithDependencies(stdout, stderr, coordinator.defaultStartupPolicy, coordinator.syncService, recovery.Execute) } func buildRootCmdWithStartup(stdout, stderr io.Writer, policy startupPolicyFunc) *cobra.Command { + return buildRootCmdWithDependencies(stdout, stderr, policy, newStartupSyncService(), recovery.Execute) +} + +func buildRootCmdWithDependencies(stdout, stderr io.Writer, policy startupPolicyFunc, syncService *startupSyncService, execute func(context.Context, recovery.Options) (recovery.Report, error)) *cobra.Command { root := &cobra.Command{ Use: "backscroll", Short: "A permanent, searchable record of your coding-agent sessions", @@ -223,7 +229,7 @@ query merges both by rank position (RRF).`, registerStartupCommand(root, startupSnapshotRead, newStatusCmd(stdout, stderr)) registerStartupCommand(root, startupMetadataRead, newConfigCmd(stdout, stderr)) registerStartupCommand(root, startupMutation, newAnnotateCmd(stdout, stderr)) - registerStartupCommand(root, startupRemediation, newRecoverCmd(stdout, stderr)) + registerStartupCommand(root, startupRemediation, newRecoverCmd(stdout, stderr, syncService, execute)) return root } diff --git a/cmd/backscroll/startup_policy_test.go b/cmd/backscroll/startup_policy_test.go index 5f1e3b7..e091267 100644 --- a/cmd/backscroll/startup_policy_test.go +++ b/cmd/backscroll/startup_policy_test.go @@ -14,6 +14,7 @@ import ( "github.com/pablontiv/backscroll/internal/compat" "github.com/pablontiv/backscroll/internal/config" + "github.com/pablontiv/backscroll/internal/input_config" "github.com/pablontiv/backscroll/internal/recovery" "github.com/pablontiv/backscroll/internal/storage" "github.com/spf13/cobra" @@ -202,22 +203,20 @@ func TestRecoverBlocksNonrecoverableStartupFailures(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cause := errors.New("nonrecoverable " + tc.name) called := false - originalExecute := recoverExecute - recoverExecute = func(context.Context, recovery.Options) (recovery.Report, error) { + execute := func(context.Context, recovery.Options) (recovery.Report, error) { called = true return recovery.Report{}, nil } - t.Cleanup(func() { recoverExecute = originalExecute }) var stdout, stderr bytes.Buffer - root := buildRootCmdWithStartup(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { return startupResult{Config: cfg, Failure: &startupFailure{ Stage: tc.stage, Cause: cause, Diagnostic: compat.Diagnostic{Code: compat.CodeMigrationFailed, Summary: "blocked " + tc.name}, Recoverable: false, }} - }) + }, newStartupSyncService(), execute) root.SetArgs([]string{"recover", "--from", "stranded.db", "--dry-run"}) err := root.Execute() if err == nil { @@ -240,15 +239,13 @@ func TestSuccessfulStartupRecoveryFailureOmitsTypedNilStartupFailure(t *testing. cfg := &config.Config{DatabasePath: filepath.Join(t.TempDir(), "active.db")} recoveryErr := errors.New("injected recovery failure") - originalExecute := recoverExecute - recoverExecute = func(context.Context, recovery.Options) (recovery.Report, error) { + execute := func(context.Context, recovery.Options) (recovery.Report, error) { return recovery.Report{}, recoveryErr } - t.Cleanup(func() { recoverExecute = originalExecute }) - root := buildRootCmdWithStartup(io.Discard, io.Discard, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(io.Discard, io.Discard, func(context.Context, io.Writer, startupCommandClass) startupResult { return startupResult{Config: cfg} - }) + }, newStartupSyncService(), execute) root.SetArgs([]string{"recover", "--from", "stranded.db"}) err := root.Execute() if !errors.Is(err, recoveryErr) { @@ -267,20 +264,16 @@ func TestSuccessfulStartupPostInstallSyncFailureOmitsTypedNilStartupFailure(t *t cfg := &config.Config{DatabasePath: filepath.Join(t.TempDir(), "active.db")} syncErr := errors.New("injected post-install sync failure") - originalExecute := recoverExecute - recoverExecute = func(context.Context, recovery.Options) (recovery.Report, error) { + execute := func(context.Context, recovery.Options) (recovery.Report, error) { return recovery.Report{ActivePath: cfg.DatabasePath}, nil } - t.Cleanup(func() { recoverExecute = originalExecute }) - - originalPostInstallSync := recoverPostInstallSync - recoverPostInstallSync = func(context.Context, *config.Config, io.Writer) error { return syncErr } - t.Cleanup(func() { recoverPostInstallSync = originalPostInstallSync }) + syncService := newStartupSyncService() + syncService.open = func(context.Context, string) (*storage.Database, error) { return nil, syncErr } var stdout bytes.Buffer - root := buildRootCmdWithStartup(&stdout, io.Discard, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(&stdout, io.Discard, func(context.Context, io.Writer, startupCommandClass) startupResult { return startupResult{Config: cfg} - }) + }, syncService, execute) root.SetArgs([]string{"recover", "--from", "stranded.db"}) err := root.Execute() if !errors.Is(err, syncErr) { @@ -424,9 +417,9 @@ func TestDefaultStartupPolicyCallsSyncExactlyOnce(t *testing.T) { setIndexPolicyEnv(t, dbPath, t.TempDir()) calls := 0 coordinator := newStartupCoordinator() - coordinator.sync = func(context.Context, *config.Config, io.Writer) error { + coordinator.syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { calls++ - return nil + return nil, input_config.ModeLegacy, nil } result := coordinator.defaultStartupPolicy(context.Background(), io.Discard, startupMutation) @@ -494,9 +487,9 @@ func TestDefaultStartupPolicyNonrecoverableStages(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { coordinator := newStartupCoordinator() - coordinator.sync = func(context.Context, *config.Config, io.Writer) error { + coordinator.syncService.open = func(context.Context, string) (*storage.Database, error) { t.Fatal("startup sync should not run after nonrecoverable startup stage") - return nil + return nil, nil } tc.setup(t) @@ -566,7 +559,9 @@ func TestReadOwnerLeaseReleasedBeforeHandler(t *testing.T) { coordinator.prepareIndex = func(context.Context, *config.Config, indexCommandClass) (*storage.Database, *compat.Diagnostic, error) { return nil, nil, nil } - coordinator.sync = func(context.Context, *config.Config, io.Writer) error { return nil } + coordinator.syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + return nil, input_config.ModeLegacy, nil + } var stdout, stderr bytes.Buffer root := buildRootCmdWithCoordinator(&stdout, &stderr, coordinator) diff --git a/cmd/backscroll/startup_prefilter_test.go b/cmd/backscroll/startup_prefilter_test.go index e761cf2..cba3335 100644 --- a/cmd/backscroll/startup_prefilter_test.go +++ b/cmd/backscroll/startup_prefilter_test.go @@ -60,12 +60,8 @@ func TestMetadataPrefilterAlwaysHashesOpenCode(t *testing.T) { } reader := &sidecarBackedReader{path: sourcePath, hash: "watermark-1", content: "first"} - oldActiveInputs, oldNewRegistry := maybeAutoSyncActiveInputs, maybeAutoSyncNewRegistry - t.Cleanup(func() { - maybeAutoSyncActiveInputs = oldActiveInputs - maybeAutoSyncNewRegistry = oldNewRegistry - }) - maybeAutoSyncActiveInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + syncService := newStartupSyncService() + syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { return []input_config.InputDefinition{{ ID: "opencode", Source: "session", @@ -73,14 +69,14 @@ func TestMetadataPrefilterAlwaysHashesOpenCode(t *testing.T) { Decode: input_config.DecodeConfig{Format: "opencode"}, }}, input_config.ModeDeclarative, nil } - maybeAutoSyncNewRegistry = func() *readers.Registry { + syncService.newRegistry = func() *readers.Registry { registry := readers.NewRegistry() registry.Register(reader) return registry } cfg := config.Config{DatabasePath: filepath.Join(tmpDir, "index.db")} - if err := maybeAutoSync(&cfg, &bytes.Buffer{}); err != nil { + if err := syncService.sync(context.Background(), &cfg, &bytes.Buffer{}, startupPhaseTiming{}); err != nil { t.Fatalf("initial sync: %v", err) } @@ -88,7 +84,7 @@ func TestMetadataPrefilterAlwaysHashesOpenCode(t *testing.T) { // database file metadata remains identical. reader.hash = "watermark-2" reader.content = "second" - if err := maybeAutoSync(&cfg, &bytes.Buffer{}); err != nil { + if err := syncService.sync(context.Background(), &cfg, &bytes.Buffer{}, startupPhaseTiming{}); err != nil { t.Fatalf("sync after sidecar change: %v", err) } if reader.hashCalls != 2 || reader.parseCalls != 2 { @@ -96,7 +92,7 @@ func TestMetadataPrefilterAlwaysHashesOpenCode(t *testing.T) { } // An unchanged watermark is still queried, but does not require a full parse. - if err := maybeAutoSync(&cfg, &bytes.Buffer{}); err != nil { + if err := syncService.sync(context.Background(), &cfg, &bytes.Buffer{}, startupPhaseTiming{}); err != nil { t.Fatalf("sync after unchanged watermark: %v", err) } if reader.hashCalls != 3 || reader.parseCalls != 2 { @@ -185,14 +181,8 @@ func TestMetadataPrefilterSkipsHashingOnUnchangedFiles(t *testing.T) { db.Close() - // Now run second sync without changing the file - // The file should be skipped (not re-hashed) because size and mtime match - oldMaybeAutoSyncOpen := maybeAutoSyncOpen - defer func() { maybeAutoSyncOpen = oldMaybeAutoSyncOpen }() - - maybeAutoSyncOpen = func(ctx context.Context, dbPath string) (*storage.Database, error) { - return oldMaybeAutoSyncOpen(ctx, dbPath) - } + // Now run second sync without changing the file. + // The file should be skipped (not re-hashed) because size and mtime match. // Note: We can't easily inject into the actual reader registry without // modifying the interface, so this test validates the behavior by measuring diff --git a/cmd/backscroll/sync_helpers.go b/cmd/backscroll/sync_helpers.go index 86c3717..e489f7c 100644 --- a/cmd/backscroll/sync_helpers.go +++ b/cmd/backscroll/sync_helpers.go @@ -38,37 +38,47 @@ func contentHashesEqual(readerName, persistedHash, observedHash string) bool { return canonicalContentHash(readerName, persistedHash) == canonicalContentHash(readerName, observedHash) } -var ( - maybeAutoSyncOpen = func(ctx context.Context, path string) (*storage.Database, error) { - db, diag, err := storage.OpenCompatible(ctx, path) - if diag != nil { - if db != nil { - _ = db.Close() +type startupSyncService struct { + diagnostics bool + open func(context.Context, string) (*storage.Database, error) + activeInputs func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) + loadGlobalRegistry func() projects.ProjectRegistry + newRegistry func() *readers.Registry + syncFiles func(context.Context, *storage.Database, []storage.IndexedFile) error + getFileMetadata func(string) (*int64, *string, error) +} + +func newStartupSyncService() *startupSyncService { + return &startupSyncService{ + diagnostics: diagnosticsEnabled(), + open: func(ctx context.Context, path string) (*storage.Database, error) { + db, diag, err := storage.OpenCompatible(ctx, path) + if diag != nil { + if db != nil { + _ = db.Close() + } + return nil, fmt.Errorf("%s: %s", diag.Code, diag.Summary) } - return nil, fmt.Errorf("%s: %s", diag.Code, diag.Summary) - } - return db, err + return db, err + }, + activeInputs: input_config.ActiveInputs, + loadGlobalRegistry: projects.LoadGlobalRegistry, + newRegistry: newDefaultAutoSyncRegistry, + syncFiles: func(ctx context.Context, db *storage.Database, files []storage.IndexedFile) error { + return db.SyncFilesContext(ctx, files) + }, + getFileMetadata: getFileMetadata, } - maybeAutoSyncActiveInputs = input_config.ActiveInputs - maybeAutoSyncLoadGlobalRegistry = projects.LoadGlobalRegistry - maybeAutoSyncNewRegistry = newDefaultAutoSyncRegistry - maybeAutoSyncSyncFiles = func(ctx context.Context, db *storage.Database, files []storage.IndexedFile) error { - return db.SyncFilesContext(ctx, files) - } - maybeAutoSyncGetFileMetadata = getFileMetadata // for testability -) +} -// startupPhaseTiming holds measurements for startup phases that occur before maybeAutoSync. -// Populated by coordinateStartup if diagnosticsEnabled() is true. +// startupPhaseTiming holds measurements for startup phases that occur before sync. +// Each root execution owns its value; direct legacy syncs leave measured false. type startupPhaseTiming struct { LockAcquisitionTime time.Duration IndexPrepareTime time.Duration + measured bool } -// startupDiags holds pre-sync phase timings, set by coordinateStartup. -// Access must be guarded by checking diagnosticsEnabled() first. -var startupDiags *startupPhaseTiming - func newDefaultAutoSyncRegistry() *readers.Registry { reg := readers.NewRegistry() reg.Register(&readers.OpenCodeReader{}) @@ -391,20 +401,21 @@ func isRacyCleanFile(fileMtime string, lastIndexed string) bool { return fileMt.After(indexTime.Add(-time.Duration(racyMarginSeconds) * time.Second)) } -// maybeAutoSync preserves the legacy non-cancellable entry point. +// maybeAutoSync preserves the legacy non-cancellable entry point by creating +// production dependencies for this invocation rather than using shared state. func maybeAutoSync(cfg *config.Config, progress io.Writer) error { - return maybeAutoSyncContext(context.Background(), cfg, progress) + return newStartupSyncService().sync(context.Background(), cfg, progress, startupPhaseTiming{}) } -// maybeAutoSyncContext performs an incremental sync operation with cancellation -// propagated through reader and storage work. Progress is transactional at this -// boundary: callers receive it only after every sync and maintenance phase succeeds. -func maybeAutoSyncContext(ctx context.Context, cfg *config.Config, progress io.Writer) error { +// sync performs an incremental sync operation with cancellation propagated +// through reader and storage work. Progress is transactional at this boundary: +// callers receive it only after every sync and maintenance phase succeeds. +func (s *startupSyncService) sync(ctx context.Context, cfg *config.Config, progress io.Writer, timing startupPhaseTiming) error { if err := ctx.Err(); err != nil { return err } var bufferedProgress bytes.Buffer - if err := maybeAutoSyncWithProgress(ctx, cfg, &bufferedProgress); err != nil { + if err := s.syncWithProgress(ctx, cfg, &bufferedProgress, timing); err != nil { return err } if err := ctx.Err(); err != nil { @@ -414,8 +425,8 @@ func maybeAutoSyncContext(ctx context.Context, cfg *config.Config, progress io.W return nil } -func maybeAutoSyncWithProgress(ctx context.Context, cfg *config.Config, progress io.Writer) (retErr error) { - diag := diagnosticsEnabled() +func (s *startupSyncService) syncWithProgress(ctx context.Context, cfg *config.Config, progress io.Writer, timing startupPhaseTiming) (retErr error) { + diag := s.diagnostics var startTime time.Time if diag { startTime = time.Now() @@ -423,7 +434,7 @@ func maybeAutoSyncWithProgress(ctx context.Context, cfg *config.Config, progress // Open database for reading to check if it exists // (this will auto-create if missing) - db, err := maybeAutoSyncOpen(ctx, cfg.DatabasePath) + db, err := s.open(ctx, cfg.DatabasePath) if err != nil { return fmt.Errorf("open database: %w", err) } @@ -459,10 +470,10 @@ func maybeAutoSyncWithProgress(ctx context.Context, cfg *config.Config, progress const replayParsesCap = 200 // Build reader registry - reg := maybeAutoSyncNewRegistry() + reg := s.newRegistry() // Resolve active inputs - defs, _, err := maybeAutoSyncActiveInputs(cfg.SessionDirs) + defs, _, err := s.activeInputs(cfg.SessionDirs) if err != nil { return fmt.Errorf("resolve inputs: %w", err) } @@ -471,7 +482,7 @@ func maybeAutoSyncWithProgress(ctx context.Context, cfg *config.Config, progress } // Load project registry - registry := maybeAutoSyncLoadGlobalRegistry() + registry := s.loadGlobalRegistry() if err := ctx.Err(); err != nil { return err } @@ -539,7 +550,7 @@ func maybeAutoSyncWithProgress(ctx context.Context, cfg *config.Config, progress } if usesFileMetadataPrefilter(reader) && exists && existingMeta.Size != nil && existingMeta.Mtime != nil && existingMeta.LastIndexed != nil { - if fileSize, fileMtime, metadataErr := maybeAutoSyncGetFileMetadata(ref); metadataErr == nil && + if fileSize, fileMtime, metadataErr := s.getFileMetadata(ref); metadataErr == nil && fileSize != nil && fileMtime != nil && *fileSize == *existingMeta.Size && *fileMtime == *existingMeta.Mtime && !isRacyCleanFile(*fileMtime, *existingMeta.LastIndexed) { state.hash = existingMeta.Hash @@ -566,7 +577,7 @@ func maybeAutoSyncWithProgress(ctx context.Context, cfg *config.Config, progress } if diag { filesHashed++ - if fileSize, _, metadataErr := maybeAutoSyncGetFileMetadata(ref); metadataErr == nil && fileSize != nil { + if fileSize, _, metadataErr := s.getFileMetadata(ref); metadataErr == nil && fileSize != nil { bytesHashed += *fileSize } } @@ -665,7 +676,7 @@ func maybeAutoSyncWithProgress(ctx context.Context, cfg *config.Config, progress }) } - fileSize, fileMtime, _ := maybeAutoSyncGetFileMetadata(ref) + fileSize, fileMtime, _ := s.getFileMetadata(ref) if err := ctx.Err(); err != nil { return err } @@ -697,7 +708,7 @@ func maybeAutoSyncWithProgress(ctx context.Context, cfg *config.Config, progress // Sync all files if len(indexedFiles) > 0 { - if err := maybeAutoSyncSyncFiles(ctx, db, indexedFiles); err != nil { + if err := s.syncFiles(ctx, db, indexedFiles); err != nil { return fmt.Errorf("sync files: %w", err) } if err := ctx.Err(); err != nil { @@ -757,9 +768,9 @@ func maybeAutoSyncWithProgress(ctx context.Context, cfg *config.Config, progress // Report diagnostics _, _ = fmt.Fprintf(progress, "\nStartup diagnostics:\n") - if startupDiags != nil { - _, _ = fmt.Fprintf(progress, " Lock Acquisition:%v\n", startupDiags.LockAcquisitionTime) - _, _ = fmt.Fprintf(progress, " Index Prepare: %v\n", startupDiags.IndexPrepareTime) + if timing.measured { + _, _ = fmt.Fprintf(progress, " Lock Acquisition:%v\n", timing.LockAcquisitionTime) + _, _ = fmt.Fprintf(progress, " Index Prepare: %v\n", timing.IndexPrepareTime) } _, _ = fmt.Fprintf(progress, " Discovery: %v\n", discoveryTime) _, _ = fmt.Fprintf(progress, " Metadata: %v (%d files checked)\n", metadataTime, filesHashed+filesSkipped) @@ -770,8 +781,8 @@ func maybeAutoSyncWithProgress(ctx context.Context, cfg *config.Config, progress // Calculate unattributed time measuredTime := discoveryTime + metadataTime + hashingTime + parsingTime + databaseTime - if startupDiags != nil { - measuredTime += startupDiags.LockAcquisitionTime + startupDiags.IndexPrepareTime + if timing.measured { + measuredTime += timing.LockAcquisitionTime + timing.IndexPrepareTime } unattributedTime := totalTime - measuredTime if unattributedTime > 0 { diff --git a/cmd/backscroll/sync_helpers_context_test.go b/cmd/backscroll/sync_helpers_context_test.go index d8862c0..785cf64 100644 --- a/cmd/backscroll/sync_helpers_context_test.go +++ b/cmd/backscroll/sync_helpers_context_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/pablontiv/backscroll/internal/compat" "github.com/pablontiv/backscroll/internal/config" @@ -82,38 +83,31 @@ func (r *cancelingSyncReader) Parse(ctx context.Context, path string, _ input_co } func TestMaybeAutoSyncContextCancellationStopsAtEachPhase(t *testing.T) { - originalActiveInputs := maybeAutoSyncActiveInputs - originalNewRegistry := maybeAutoSyncNewRegistry - originalSyncFiles := maybeAutoSyncSyncFiles - t.Cleanup(func() { - maybeAutoSyncActiveInputs = originalActiveInputs - maybeAutoSyncNewRegistry = originalNewRegistry - maybeAutoSyncSyncFiles = originalSyncFiles - }) - - maybeAutoSyncActiveInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { - return []input_config.InputDefinition{{ - ID: "cancel-test", - Source: "session", - Active: true, - Decode: input_config.DecodeConfig{Format: "cancel-test"}, - }}, input_config.ModeDeclarative, nil - } - + t.Parallel() for _, phase := range []string{"discovery", "hash", "parse", "storage"} { + phase := phase t.Run(phase, func(t *testing.T) { + t.Parallel() ctx, cancel := context.WithCancel(context.Background()) defer cancel() reader := &cancelingSyncReader{phase: phase, cancel: cancel} - maybeAutoSyncNewRegistry = func() *readers.Registry { + syncService := newStartupSyncService() + syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + return []input_config.InputDefinition{{ + ID: "cancel-test", + Source: "session", + Active: true, + Decode: input_config.DecodeConfig{Format: "cancel-test"}, + }}, input_config.ModeDeclarative, nil + } + syncService.newRegistry = func() *readers.Registry { registry := readers.NewRegistry() registry.Register(reader) return registry } - maybeAutoSyncSyncFiles = originalSyncFiles storageCalls := 0 if phase == "storage" { - maybeAutoSyncSyncFiles = func(ctx context.Context, db *storage.Database, files []storage.IndexedFile) error { + syncService.syncFiles = func(ctx context.Context, db *storage.Database, files []storage.IndexedFile) error { storageCalls++ cancel() return db.SyncFilesContext(ctx, files) @@ -130,7 +124,7 @@ func TestMaybeAutoSyncContextCancellationStopsAtEachPhase(t *testing.T) { } var progress bytes.Buffer - err = maybeAutoSyncContext(ctx, &config.Config{DatabasePath: dbPath}, &progress) + err = syncService.sync(ctx, &config.Config{DatabasePath: dbPath}, &progress, startupPhaseTiming{}) if !errors.Is(err, context.Canceled) { t.Fatalf("error=%v, want context.Canceled", err) } @@ -156,14 +150,46 @@ func TestMaybeAutoSyncContextCancellationStopsAtEachPhase(t *testing.T) { } } +func TestStartupSyncServicesIsolateReaderAndStorageDependencies(t *testing.T) { + t.Parallel() + for _, name := range []string{"first-reader", "second-reader"} { + name := name + t.Run(name, func(t *testing.T) { + t.Parallel() + path := "/" + name + ".jsonl" + reader := &cancelingSyncReader{name: name, path: path} + syncService := newStartupSyncService() + syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + return []input_config.InputDefinition{{ID: name, Source: "session", Active: true, Decode: input_config.DecodeConfig{Format: name}}}, input_config.ModeDeclarative, nil + } + syncService.newRegistry = func() *readers.Registry { + registry := readers.NewRegistry() + registry.Register(reader) + return registry + } + syncCalls := 0 + syncFiles := syncService.syncFiles + syncService.syncFiles = func(ctx context.Context, db *storage.Database, files []storage.IndexedFile) error { + syncCalls++ + if len(files) != 1 || files[0].SourcePath != path { + t.Fatalf("%s storage received files %+v", name, files) + } + return syncFiles(ctx, db, files) + } + + if err := syncService.sync(context.Background(), &config.Config{DatabasePath: filepath.Join(t.TempDir(), name+".db")}, io.Discard, startupPhaseTiming{}); err != nil { + t.Fatalf("sync: %v", err) + } + if reader.hashes != 1 || reader.parses != 1 || syncCalls != 1 { + t.Fatalf("reader/storage calls=%d/%d/%d, want 1/1/1", reader.hashes, reader.parses, syncCalls) + } + }) + } +} + func TestMaybeAutoSyncContextPublishesSuccessfulProgressInOrder(t *testing.T) { t.Setenv("BACKSCROLL_STARTUP_DIAGNOSTICS", "") - originalActiveInputs := maybeAutoSyncActiveInputs - originalNewRegistry := maybeAutoSyncNewRegistry - t.Cleanup(func() { - maybeAutoSyncActiveInputs = originalActiveInputs - maybeAutoSyncNewRegistry = originalNewRegistry - }) + syncService := newStartupSyncService() dbPath := filepath.Join(t.TempDir(), "index.db") const sourcePath = "/success/empty-pi.jsonl" @@ -180,17 +206,17 @@ func TestMaybeAutoSyncContextPublishesSuccessfulProgressInOrder(t *testing.T) { } reader := &cancelingSyncReader{name: "pi", path: sourcePath} - maybeAutoSyncActiveInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { return []input_config.InputDefinition{{ID: "success-empty-pi", Source: "session", Active: true, Decode: input_config.DecodeConfig{Format: "pi"}}}, input_config.ModeDeclarative, nil } - maybeAutoSyncNewRegistry = func() *readers.Registry { + syncService.newRegistry = func() *readers.Registry { registry := readers.NewRegistry() registry.Register(reader) return registry } var progress bytes.Buffer - if err := maybeAutoSyncContext(context.Background(), &config.Config{DatabasePath: dbPath}, &progress); err != nil { + if err := syncService.sync(context.Background(), &config.Config{DatabasePath: dbPath}, &progress, startupPhaseTiming{}); err != nil { t.Fatalf("sync: %v", err) } want := "Re-parsing empty Pi file 1: " + sourcePath + "\n" @@ -199,13 +225,46 @@ func TestMaybeAutoSyncContextPublishesSuccessfulProgressInOrder(t *testing.T) { } } +func TestStartupSyncServicesIsolateDiagnosticsTimings(t *testing.T) { + t.Parallel() + cases := []struct { + name string + lock time.Duration + prepare time.Duration + otherLock time.Duration + }{ + {name: "first", lock: 11 * time.Millisecond, prepare: 13 * time.Millisecond, otherLock: 21 * time.Millisecond}, + {name: "second", lock: 21 * time.Millisecond, prepare: 23 * time.Millisecond, otherLock: 11 * time.Millisecond}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + syncService := newStartupSyncService() + syncService.diagnostics = true + syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + return nil, input_config.ModeLegacy, nil + } + var progress bytes.Buffer + timing := startupPhaseTiming{LockAcquisitionTime: tc.lock, IndexPrepareTime: tc.prepare, measured: true} + err := syncService.sync(context.Background(), &config.Config{DatabasePath: filepath.Join(t.TempDir(), tc.name+".db")}, &progress, timing) + if err != nil { + t.Fatalf("sync: %v", err) + } + for _, want := range []string{" Lock Acquisition:" + tc.lock.String() + "\n", " Index Prepare: " + tc.prepare.String() + "\n"} { + if !strings.Contains(progress.String(), want) { + t.Fatalf("diagnostics=%q missing %q", progress.String(), want) + } + } + if strings.Contains(progress.String(), " Lock Acquisition:"+tc.otherLock.String()+"\n") { + t.Fatalf("diagnostics leaked another execution's timing: %q", progress.String()) + } + }) + } +} + func TestMaybeAutoSyncContextErrorDiscardsBufferedProgress(t *testing.T) { - originalActiveInputs := maybeAutoSyncActiveInputs - originalNewRegistry := maybeAutoSyncNewRegistry - t.Cleanup(func() { - maybeAutoSyncActiveInputs = originalActiveInputs - maybeAutoSyncNewRegistry = originalNewRegistry - }) + syncService := newStartupSyncService() dbPath := filepath.Join(t.TempDir(), "index.db") const sourcePath = "/error/empty-pi.jsonl" @@ -223,7 +282,7 @@ func TestMaybeAutoSyncContextErrorDiscardsBufferedProgress(t *testing.T) { parseErr := errors.New("parse failed after progress") reader := &cancelingSyncReader{name: "pi", path: sourcePath, parseErr: parseErr} - maybeAutoSyncActiveInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { return []input_config.InputDefinition{{ ID: "error-empty-pi", Source: "session", @@ -231,14 +290,14 @@ func TestMaybeAutoSyncContextErrorDiscardsBufferedProgress(t *testing.T) { Decode: input_config.DecodeConfig{Format: "pi"}, }}, input_config.ModeDeclarative, nil } - maybeAutoSyncNewRegistry = func() *readers.Registry { + syncService.newRegistry = func() *readers.Registry { registry := readers.NewRegistry() registry.Register(reader) return registry } var progress bytes.Buffer - err = maybeAutoSyncContext(context.Background(), &config.Config{DatabasePath: dbPath}, &progress) + err = syncService.sync(context.Background(), &config.Config{DatabasePath: dbPath}, &progress, startupPhaseTiming{}) if !errors.Is(err, parseErr) { t.Fatalf("error=%v want %v", err, parseErr) } @@ -252,16 +311,7 @@ func TestMaybeAutoSyncContextErrorDiscardsBufferedProgress(t *testing.T) { func TestRecoverCancellationDiscardsBufferedPostInstallProgress(t *testing.T) { t.Setenv("BACKSCROLL_STARTUP_DIAGNOSTICS", "") - originalActiveInputs := maybeAutoSyncActiveInputs - originalNewRegistry := maybeAutoSyncNewRegistry - originalRecoverExecute := recoverExecute - originalPostInstallSync := recoverPostInstallSync - t.Cleanup(func() { - maybeAutoSyncActiveInputs = originalActiveInputs - maybeAutoSyncNewRegistry = originalNewRegistry - recoverExecute = originalRecoverExecute - recoverPostInstallSync = originalPostInstallSync - }) + syncService := newStartupSyncService() dbPath := filepath.Join(t.TempDir(), "active.db") const sourcePath = "/recover/empty-pi.jsonl" @@ -283,7 +333,7 @@ func TestRecoverCancellationDiscardsBufferedPostInstallProgress(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() reader := &cancelingSyncReader{name: "pi", path: sourcePath, phase: "parse", cancel: cancel} - maybeAutoSyncActiveInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { return []input_config.InputDefinition{{ ID: "recover-empty-pi", Source: "session", @@ -291,22 +341,21 @@ func TestRecoverCancellationDiscardsBufferedPostInstallProgress(t *testing.T) { Decode: input_config.DecodeConfig{Format: "pi"}, }}, input_config.ModeDeclarative, nil } - maybeAutoSyncNewRegistry = func() *readers.Registry { + syncService.newRegistry = func() *readers.Registry { registry := readers.NewRegistry() registry.Register(reader) return registry } - recoverExecute = func(context.Context, recovery.Options) (recovery.Report, error) { + execute := func(context.Context, recovery.Options) (recovery.Report, error) { return recovery.Report{ActivePath: dbPath}, nil } - recoverPostInstallSync = maybeAutoSyncContext cfg := &config.Config{DatabasePath: dbPath} lease := &fakeStartupLease{} var stdout, stderr bytes.Buffer - root := buildRootCmdWithStartup(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { return startupResult{Config: cfg, Lease: lease} - }) + }, syncService, execute) root.SetContext(ctx) root.SetArgs([]string{"recover", "--from", "stranded.db"}) err = root.Execute() @@ -362,26 +411,52 @@ func TestCanceledMutationReleasesRetainedLeaseExactlyOnce(t *testing.T) { func TestOwnedStartupPreservesSuccessfulProgressBytes(t *testing.T) { type startupSyncContextKey struct{} ctx := context.WithValue(context.Background(), startupSyncContextKey{}, "startup-sync") - coordinator := newStartupCoordinator() - lease := &fakeStartupLease{} - coordinator.tryAcquire = func(string) (startupLease, bool, error) { return lease, true, nil } - coordinator.prepareIndex = func(context.Context, *config.Config, indexCommandClass) (*storage.Database, *compat.Diagnostic, error) { - return nil, nil, nil + dbPath := filepath.Join(t.TempDir(), "index.db") + const sourcePath = "/startup/empty-pi.jsonl" + db, err := storage.Open(dbPath) + if err != nil { + t.Fatal(err) + } + if _, err := db.DB().Exec(`INSERT INTO indexed_files (path, hash) VALUES (?, ?)`, sourcePath, "cancel-test-hash"); err != nil { + _ = db.Close() + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) } - coordinator.sync = func(gotCtx context.Context, _ *config.Config, progress io.Writer) error { + + reader := &cancelingSyncReader{name: "pi", path: sourcePath} + syncService := newStartupSyncService() + syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + return []input_config.InputDefinition{{ID: "startup-empty-pi", Source: "session", Active: true, Decode: input_config.DecodeConfig{Format: "pi"}}}, input_config.ModeDeclarative, nil + } + syncService.newRegistry = func() *readers.Registry { + registry := readers.NewRegistry() + registry.Register(reader) + return registry + } + open := syncService.open + syncService.open = func(gotCtx context.Context, path string) (*storage.Database, error) { if gotCtx.Value(startupSyncContextKey{}) != "startup-sync" { t.Fatal("startup sync did not receive coordinator context") } - _, _ = io.WriteString(progress, "first\nsecond\n") - return nil + return open(gotCtx, path) + } + + coordinator := newStartupCoordinatorWithSyncService(syncService) + lease := &fakeStartupLease{} + coordinator.tryAcquire = func(string) (startupLease, bool, error) { return lease, true, nil } + coordinator.prepareIndex = func(context.Context, *config.Config, indexCommandClass) (*storage.Database, *compat.Diagnostic, error) { + return nil, nil, nil } var progress bytes.Buffer - result := coordinator.coordinate(ctx, &config.Config{DatabasePath: filepath.Join(t.TempDir(), "index.db")}, &progress, startupSnapshotRead) + result := coordinator.coordinate(ctx, &config.Config{DatabasePath: dbPath}, &progress, startupSnapshotRead) if result.Failure != nil { t.Fatalf("failure=%v", result.Failure) } - if got, want := progress.String(), "first\nsecond\n"; got != want { + want := "Re-parsing empty Pi file 1: " + sourcePath + "\n" + if got := progress.String(); got != want { t.Fatalf("progress=%q want %q", got, want) } if lease.releases != 1 { From cb223b870afa6f2ace9159c8fd6fc9a50ad69014 Mon Sep 17 00:00:00 2001 From: Pablo Ontiveros Date: Wed, 7 Oct 2026 04:57:55 -0600 Subject: [PATCH 2/2] fix(startup): harden dependency construction --- cmd/backscroll/context_startup_test.go | 13 +- cmd/backscroll/recover.go | 6 + cmd/backscroll/recover_test.go | 4 +- cmd/backscroll/startup_coordination.go | 3 + cmd/backscroll/startup_policy.go | 21 ++- cmd/backscroll/startup_policy_test.go | 162 +++++++++++++++++--- cmd/backscroll/sync_helpers_context_test.go | 4 +- 7 files changed, 173 insertions(+), 40 deletions(-) diff --git a/cmd/backscroll/context_startup_test.go b/cmd/backscroll/context_startup_test.go index 5c8a094..15c33f7 100644 --- a/cmd/backscroll/context_startup_test.go +++ b/cmd/backscroll/context_startup_test.go @@ -14,6 +14,7 @@ import ( "github.com/pablontiv/backscroll/internal/compat" "github.com/pablontiv/backscroll/internal/config" + "github.com/pablontiv/backscroll/internal/recovery" "github.com/pablontiv/backscroll/internal/storage" ) @@ -67,7 +68,7 @@ func TestRootContextRunsStartupOnceAndSupportsAllFormats(t *testing.T) { t.Run(tc.name, func(t *testing.T) { var stdout, stderr bytes.Buffer startupCalls := 0 - root := buildRootCmdWithStartup(&stdout, &stderr, func(_ context.Context, progress io.Writer, class startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(&stdout, &stderr, func(_ context.Context, progress io.Writer, class startupCommandClass) startupResult { startupCalls++ if class != startupSnapshotRead { t.Fatalf("context startup class=%q want %q", class, startupSnapshotRead) @@ -76,7 +77,7 @@ func TestRootContextRunsStartupOnceAndSupportsAllFormats(t *testing.T) { t.Fatalf("write startup progress: %v", err) } return startupResult{Config: cfg} - }) + }, newStartupSyncService(), recovery.Execute) root.SetArgs(tc.args) if err := root.Execute(); err != nil { @@ -112,10 +113,10 @@ func TestRootContextEmitsCommandDiagnosticsInEveryFormat(t *testing.T) { t.Run(tc.name, func(t *testing.T) { var stdout, stderr bytes.Buffer startupCalls := 0 - root := buildRootCmdWithStartup(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { startupCalls++ return startupResult{Config: cfg} - }) + }, newStartupSyncService(), recovery.Execute) root.SetArgs(tc.args) err := root.Execute() @@ -173,10 +174,10 @@ func TestRootContextBusyFollowerUsesCommittedSnapshot(t *testing.T) { var stdout, stderr bytes.Buffer startupCalls := 0 - root := buildRootCmdWithStartup(&stdout, &stderr, func(ctx context.Context, progress io.Writer, class startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(&stdout, &stderr, func(ctx context.Context, progress io.Writer, class startupCommandClass) startupResult { startupCalls++ return coordinator.coordinate(ctx, cfg, progress, class) - }) + }, coordinator.syncService, recovery.Execute) root.SetArgs([]string{"context", "--uuid", "anchor", "--before", "0", "--after", "0", "--max-tokens", "16384", "--json"}) if err := root.Execute(); err != nil { diff --git a/cmd/backscroll/recover.go b/cmd/backscroll/recover.go index b711a86..28087ba 100644 --- a/cmd/backscroll/recover.go +++ b/cmd/backscroll/recover.go @@ -12,6 +12,12 @@ import ( ) func newRecoverCmd(stdout, stderr io.Writer, syncService *startupSyncService, execute func(context.Context, recovery.Options) (recovery.Report, error)) *cobra.Command { + if syncService == nil { + syncService = newStartupSyncService() + } + if execute == nil { + execute = recovery.Execute + } var from string var dryRun bool fromValue := singleUseStringValue{target: &from} diff --git a/cmd/backscroll/recover_test.go b/cmd/backscroll/recover_test.go index 397c50f..f444ee0 100644 --- a/cmd/backscroll/recover_test.go +++ b/cmd/backscroll/recover_test.go @@ -50,9 +50,9 @@ func buildRecoverRootWithConfig(t *testing.T, stdout, stderr io.Writer, activePa t.Fatalf("mkdir empty recovery inputs: %v", err) } cfg := &config.Config{DatabasePath: activePath, SessionDirs: []string{emptyInputs}} - return buildRootCmdWithStartup(stdout, stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { + return buildRootCmdWithDependencies(stdout, stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { return startupResult{Config: cfg} - }) + }, newStartupSyncService(), recovery.Execute) } func TestRecoverExecuteReceivesCommandContext(t *testing.T) { diff --git a/cmd/backscroll/startup_coordination.go b/cmd/backscroll/startup_coordination.go index 3fb141d..738ee4d 100644 --- a/cmd/backscroll/startup_coordination.go +++ b/cmd/backscroll/startup_coordination.go @@ -31,6 +31,9 @@ func newStartupCoordinator() *startupCoordinator { } func newStartupCoordinatorWithSyncService(syncService *startupSyncService) *startupCoordinator { + if syncService == nil { + syncService = newStartupSyncService() + } return &startupCoordinator{ mutationWait: defaultStartupMutationWait, tryAcquire: func(path string) (startupLease, bool, error) { diff --git a/cmd/backscroll/startup_policy.go b/cmd/backscroll/startup_policy.go index 63e23fc..d14fb6d 100644 --- a/cmd/backscroll/startup_policy.go +++ b/cmd/backscroll/startup_policy.go @@ -168,18 +168,29 @@ func validateRequiredFlagsAndGroups(cmd *cobra.Command) error { } func buildRootCmd(stdout, stderr io.Writer) *cobra.Command { - return buildRootCmdWithCoordinator(stdout, stderr, newStartupCoordinator()) + return buildRootCmdWithDependencies(stdout, stderr, nil, nil, nil) } func buildRootCmdWithCoordinator(stdout, stderr io.Writer, coordinator *startupCoordinator) *cobra.Command { + if coordinator == nil { + coordinator = newStartupCoordinator() + } else if coordinator.syncService == nil { + coordinator.syncService = newStartupSyncService() + } return buildRootCmdWithDependencies(stdout, stderr, coordinator.defaultStartupPolicy, coordinator.syncService, recovery.Execute) } -func buildRootCmdWithStartup(stdout, stderr io.Writer, policy startupPolicyFunc) *cobra.Command { - return buildRootCmdWithDependencies(stdout, stderr, policy, newStartupSyncService(), recovery.Execute) -} - func buildRootCmdWithDependencies(stdout, stderr io.Writer, policy startupPolicyFunc, syncService *startupSyncService, execute func(context.Context, recovery.Options) (recovery.Report, error)) *cobra.Command { + if syncService == nil { + syncService = newStartupSyncService() + } + if policy == nil { + policy = newStartupCoordinatorWithSyncService(syncService).defaultStartupPolicy + } + if execute == nil { + execute = recovery.Execute + } + root := &cobra.Command{ Use: "backscroll", Short: "A permanent, searchable record of your coding-agent sessions", diff --git a/cmd/backscroll/startup_policy_test.go b/cmd/backscroll/startup_policy_test.go index e091267..cb3229f 100644 --- a/cmd/backscroll/startup_policy_test.go +++ b/cmd/backscroll/startup_policy_test.go @@ -20,6 +20,118 @@ import ( "github.com/spf13/cobra" ) +func TestInjectableConstructionDefaultsNilDependencies(t *testing.T) { + t.Run("coordinator sync service", func(t *testing.T) { + coordinator := newStartupCoordinatorWithSyncService(nil) + if coordinator == nil || coordinator.syncService == nil { + t.Fatal("nil sync service did not produce a valid coordinator") + } + }) + + t.Run("root coordinator", func(t *testing.T) { + setIndexPolicyEnv(t, filepath.Join(t.TempDir(), "index.db"), t.TempDir()) + var stdout, stderr bytes.Buffer + root := buildRootCmdWithCoordinator(&stdout, &stderr, nil) + root.SetArgs([]string{"config", "--json"}) + if err := root.Execute(); err != nil { + t.Fatalf("nil coordinator root: %v; stdout=%q stderr=%q", err, stdout.String(), stderr.String()) + } + }) + + t.Run("startup policy", func(t *testing.T) { + setIndexPolicyEnv(t, filepath.Join(t.TempDir(), "index.db"), t.TempDir()) + syncService := newStartupSyncService() + activeInputCalls := 0 + syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + activeInputCalls++ + return nil, input_config.ModeLegacy, nil + } + root := buildRootCmdWithDependencies(io.Discard, io.Discard, nil, syncService, nil) + root.SetArgs([]string{"config", "--json"}) + if err := root.Execute(); err != nil { + t.Fatalf("nil policy root: %v", err) + } + if activeInputCalls != 1 { + t.Fatalf("default policy sync calls=%d, want 1", activeInputCalls) + } + }) + + t.Run("sync service", func(t *testing.T) { + t.Setenv("BACKSCROLL_STARTUP_DIAGNOSTICS", "") + setIndexPolicyEnv(t, filepath.Join(t.TempDir(), "active.db"), t.TempDir()) + emptyInputs := t.TempDir() + cfg := &config.Config{DatabasePath: os.Getenv("BACKSCROLL_DATABASE_PATH"), SessionDirs: []string{emptyInputs}} + execute := func(context.Context, recovery.Options) (recovery.Report, error) { + return recovery.Report{ActivePath: cfg.DatabasePath}, nil + } + var stdout, stderr bytes.Buffer + root := buildRootCmdWithDependencies(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { + return startupResult{Config: cfg} + }, nil, execute) + root.SetArgs([]string{"recover", "--from", "stranded.db"}) + if err := root.Execute(); err != nil { + t.Fatalf("nil sync service root: %v; stdout=%q stderr=%q", err, stdout.String(), stderr.String()) + } + }) + + t.Run("recover sync service", func(t *testing.T) { + t.Setenv("BACKSCROLL_STARTUP_DIAGNOSTICS", "") + activePath := filepath.Join(t.TempDir(), "active.db") + setIndexPolicyEnv(t, activePath, t.TempDir()) + execute := func(context.Context, recovery.Options) (recovery.Report, error) { + return recovery.Report{ActivePath: activePath}, nil + } + cmd := newRecoverCmd(io.Discard, io.Discard, nil, execute) + cmd.SetArgs([]string{"--from", "stranded.db"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("nil recover sync service: %v", err) + } + }) + + t.Run("recover execute", func(t *testing.T) { + setIndexPolicyEnv(t, filepath.Join(t.TempDir(), "active.db"), t.TempDir()) + cmd := newRecoverCmd(io.Discard, io.Discard, newStartupSyncService(), nil) + cmd.SetArgs([]string{"--from", filepath.Join(t.TempDir(), "missing.db"), "--dry-run"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "recovery failed") { + t.Fatalf("nil recover execute error=%v, want controlled production recovery failure", err) + } + }) +} + +func TestInjectedRootSharesStartupAndRecoverSyncService(t *testing.T) { + t.Setenv("BACKSCROLL_STARTUP_DIAGNOSTICS", "") + dbPath := filepath.Join(t.TempDir(), "index.db") + setIndexPolicyEnv(t, dbPath, t.TempDir()) + + syncService := newStartupSyncService() + syncCalls := 0 + syncService.activeInputs = func([]string) ([]input_config.InputDefinition, input_config.InputMode, error) { + syncCalls++ + return nil, input_config.ModeLegacy, nil + } + coordinator := newStartupCoordinatorWithSyncService(syncService) + executeCalls := 0 + execute := func(context.Context, recovery.Options) (recovery.Report, error) { + executeCalls++ + return recovery.Report{ActivePath: dbPath}, nil + } + + var stdout, stderr bytes.Buffer + root := buildRootCmdWithDependencies(&stdout, &stderr, coordinator.defaultStartupPolicy, syncService, execute) + root.SetArgs([]string{"config", "--json"}) + if err := root.Execute(); err != nil { + t.Fatalf("startup command: %v; stdout=%q stderr=%q", err, stdout.String(), stderr.String()) + } + root.SetArgs([]string{"recover", "--from", "stranded.db"}) + if err := root.Execute(); err != nil { + t.Fatalf("recover command: %v; stdout=%q stderr=%q", err, stdout.String(), stderr.String()) + } + if syncCalls != 2 || executeCalls != 1 { + t.Fatalf("shared service sync calls=%d execute calls=%d, want 2/1", syncCalls, executeCalls) + } +} + func TestInvalidOperationalCommandsSkipStartup(t *testing.T) { testCases := []struct { name string @@ -55,10 +167,10 @@ func TestInvalidOperationalCommandsSkipStartup(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { startupCalls := 0 - root := buildRootCmdWithStartup(io.Discard, io.Discard, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(io.Discard, io.Discard, func(context.Context, io.Writer, startupCommandClass) startupResult { startupCalls++ return startupResult{Config: &config.Config{DatabasePath: filepath.Join(t.TempDir(), "index.db")}} - }) + }, newStartupSyncService(), recovery.Execute) root.SetArgs(tc.argv) if err := root.Execute(); err == nil { @@ -128,7 +240,7 @@ func TestEveryOperationalCommandRunsStartupBeforeHandler(t *testing.T) { events = append(events, "startup") return startupResult{Config: &config.Config{DatabasePath: filepath.Join(t.TempDir(), "index.db")}} } - root := buildRootCmdWithStartup(io.Discard, io.Discard, policy) + root := buildRootCmdWithDependencies(io.Discard, io.Discard, policy, newStartupSyncService(), recovery.Execute) replaceRootCommandRunE(t, root, argv[0], func(cmd *cobra.Command, args []string) error { markerCalls++ events = append(events, "handler") @@ -156,13 +268,13 @@ func TestEveryOperationalCommandRunsStartupBeforeHandler(t *testing.T) { func TestRemediationCommandDoesNotIgnorePolicyFailure(t *testing.T) { policyErr := errors.New("configuration cannot be interpreted") - root := buildRootCmdWithStartup(io.Discard, io.Discard, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(io.Discard, io.Discard, func(context.Context, io.Writer, startupCommandClass) startupResult { return startupResult{Failure: &startupFailure{ Stage: startupStageConfigLoad, Cause: policyErr, Diagnostic: compat.Diagnostic{Code: compat.CodeMigrationFailed, Summary: policyErr.Error()}, }} - }) + }, newStartupSyncService(), recovery.Execute) root.SetArgs([]string{"recover", "--from", "stranded.db", "--dry-run"}) err := root.Execute() if !errors.Is(err, policyErr) { @@ -173,9 +285,9 @@ func TestRemediationCommandDoesNotIgnorePolicyFailure(t *testing.T) { func TestStartupFailurePreventsHandlerOutput(t *testing.T) { var stdout, stderr bytes.Buffer policyErr := errors.New("injected startup failure") - root := buildRootCmdWithStartup(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { return startupResult{Failure: &startupFailure{Stage: startupStageConfigLoad, Cause: policyErr, Diagnostic: compat.Diagnostic{Code: compat.CodeMigrationFailed, Summary: "injected startup failure"}}} - }) + }, newStartupSyncService(), recovery.Execute) root.SetArgs([]string{"config", "--json"}) err := root.Execute() if !errors.Is(err, policyErr) { @@ -319,13 +431,13 @@ func TestStartupFailureMachineDiagnosticsAreStructuredAndUncontaminated(t *testi } { t.Run(tc.name, func(t *testing.T) { var stdout, stderr bytes.Buffer - root := buildRootCmdWithStartup(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { return startupResult{Failure: &startupFailure{ Stage: startupStageActiveManifest, Cause: errors.New("active manifest invalid"), Diagnostic: compat.Diagnostic{Code: compat.CodeMigrationFailed, Summary: "active manifest invalid"}, }} - }) + }, newStartupSyncService(), recovery.Execute) root.SetArgs(tc.argv) err := root.Execute() if err == nil { @@ -370,9 +482,9 @@ func TestRobotDiagnosticEscapesMultilineValuesAndEncodesContinuationArgv(t *test Summary: "first line\\with slash\r\nsecond line", Continuation: []string{"recover", "--from", "path with spaces\\and\\slashes\r\nnext", "--dry-run"}, } - root := buildRootCmdWithStartup(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { return startupResult{Failure: &startupFailure{Stage: startupStageStartupSync, Diagnostic: diag, Recoverable: true}} - }) + }, newStartupSyncService(), recovery.Execute) root.SetArgs([]string{"search", "needle", "--robot"}) if err := root.Execute(); err == nil { t.Fatalf("robot diagnostic command unexpectedly succeeded; stdout=%q", stdout.String()) @@ -520,7 +632,7 @@ func TestStartupWarningsAlwaysRenderToStderr(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { var stdout, stderr bytes.Buffer - root := buildRootCmdWithStartup(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { return startupResult{ Config: &config.Config{DatabasePath: filepath.Join(t.TempDir(), "index.db")}, Warning: &startupWarning{ @@ -528,7 +640,7 @@ func TestStartupWarningsAlwaysRenderToStderr(t *testing.T) { Summary: "startup sync active; using last committed index snapshot", }, } - }) + }, newStartupSyncService(), recovery.Execute) replaceRootCommandRunE(t, root, "search", func(cmd *cobra.Command, args []string) error { _, err := io.WriteString(cmd.OutOrStdout(), tc.stdoutText) return err @@ -592,9 +704,9 @@ func TestMutationLeaseReleasedAfterHandlerSuccessAndError(t *testing.T) { t.Run(tc.name, func(t *testing.T) { lease := &fakeStartupLease{} var stdout, stderr bytes.Buffer - root := buildRootCmdWithStartup(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { return startupResult{Config: &config.Config{DatabasePath: filepath.Join(t.TempDir(), "index.db")}, Lease: lease} - }) + }, newStartupSyncService(), recovery.Execute) replaceRootCommandRunEWrapped(t, root, "rebuild", func(cmd *cobra.Command, args []string) error { if lease.releases != 0 { t.Fatalf("handler saw early release count %d", lease.releases) @@ -620,14 +732,14 @@ func TestRejectedNonRecoverCommandReleasesBeforeDiagnostic(t *testing.T) { lease := &fakeStartupLease{} startupErr := errors.New("startup blocked") var stdout, stderr bytes.Buffer - root := buildRootCmdWithStartup(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { return startupResult{Config: &config.Config{DatabasePath: filepath.Join(t.TempDir(), "index.db")}, Lease: lease, Failure: &startupFailure{ Stage: startupStageStartupSync, Cause: startupErr, Diagnostic: compat.Diagnostic{Code: compat.CodeIndexStale, Summary: "startup blocked", Continuation: []string{"recover", "--from", "x", "--dry-run"}}, Recoverable: true, }} - }) + }, newStartupSyncService(), recovery.Execute) replaceRootCommandRunE(t, root, "search", func(cmd *cobra.Command, args []string) error { t.Fatal("handler should not run") return nil @@ -648,9 +760,9 @@ func TestRejectedNonRecoverCommandReleasesBeforeDiagnostic(t *testing.T) { func TestRecoverRemediationRetainsLeaseUntilHandlerReturns(t *testing.T) { lease := &fakeStartupLease{} var stdout, stderr bytes.Buffer - root := buildRootCmdWithStartup(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { return startupResult{Config: &config.Config{DatabasePath: filepath.Join(t.TempDir(), "index.db")}, Lease: lease} - }) + }, newStartupSyncService(), recovery.Execute) replaceRootCommandRunEWrapped(t, root, "recover", func(cmd *cobra.Command, args []string) error { if lease.releases != 0 { t.Fatalf("recover handler saw releases=%d want retained", lease.releases) @@ -671,9 +783,9 @@ func TestStartupLeaseReleaseErrorsAreJoined(t *testing.T) { handlerErr := errors.New("handler failed") releaseErr := errors.New("release failed") lease := &fakeStartupLease{err: releaseErr} - root := buildRootCmdWithStartup(io.Discard, io.Discard, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(io.Discard, io.Discard, func(context.Context, io.Writer, startupCommandClass) startupResult { return startupResult{Config: &config.Config{DatabasePath: filepath.Join(t.TempDir(), "index.db")}, Lease: lease} - }) + }, newStartupSyncService(), recovery.Execute) replaceRootCommandRunEWrapped(t, root, "rebuild", func(cmd *cobra.Command, args []string) error { return handlerErr }) root.SetArgs([]string{"rebuild"}) err := root.Execute() @@ -685,13 +797,13 @@ func TestStartupLeaseReleaseErrorsAreJoined(t *testing.T) { startupErr := errors.New("startup failed") releaseErr := errors.New("release failed") lease := &fakeStartupLease{err: releaseErr} - root := buildRootCmdWithStartup(io.Discard, io.Discard, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(io.Discard, io.Discard, func(context.Context, io.Writer, startupCommandClass) startupResult { return startupResult{Config: &config.Config{DatabasePath: filepath.Join(t.TempDir(), "index.db")}, Lease: lease, Failure: &startupFailure{ Stage: startupStageStartupSync, Cause: startupErr, Diagnostic: compat.Diagnostic{Code: compat.CodeIndexStale, Summary: startupErr.Error()}, }} - }) + }, newStartupSyncService(), recovery.Execute) root.SetArgs([]string{"search", "needle"}) err := root.Execute() if !errors.Is(err, startupErr) || !errors.Is(err, releaseErr) { @@ -717,10 +829,10 @@ func TestDiagnosticAlreadyRenderedOnlySuppressesTopLevelDiagnostic(t *testing.T) func TestMetadataCommandsSkipStartup(t *testing.T) { for _, argv := range [][]string{{"--help"}, {"--version"}, {"search", "--help"}} { calls := 0 - root := buildRootCmdWithStartup(io.Discard, io.Discard, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(io.Discard, io.Discard, func(context.Context, io.Writer, startupCommandClass) startupResult { calls++ return startupResult{} - }) + }, newStartupSyncService(), recovery.Execute) root.SetArgs(argv) if err := root.Execute(); err != nil { t.Fatalf("%v: %v", argv, err) diff --git a/cmd/backscroll/sync_helpers_context_test.go b/cmd/backscroll/sync_helpers_context_test.go index 785cf64..1e4eceb 100644 --- a/cmd/backscroll/sync_helpers_context_test.go +++ b/cmd/backscroll/sync_helpers_context_test.go @@ -381,7 +381,7 @@ func TestCanceledMutationReleasesRetainedLeaseExactlyOnce(t *testing.T) { t.Run(phase, func(t *testing.T) { lease := &fakeStartupLease{} var stdout, stderr bytes.Buffer - root := buildRootCmdWithStartup(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { + root := buildRootCmdWithDependencies(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult { result := startupResult{Config: &config.Config{DatabasePath: filepath.Join(t.TempDir(), "index.db")}, Lease: lease} if phase == "pre-run" { result.Failure = &startupFailure{ @@ -391,7 +391,7 @@ func TestCanceledMutationReleasesRetainedLeaseExactlyOnce(t *testing.T) { } } return result - }) + }, newStartupSyncService(), recovery.Execute) if phase == "run" { replaceRootCommandRunEWrapped(t, root, "rebuild", func(*cobra.Command, []string) error { return context.Canceled