Guarantee the database is closed on every fatal exit path (closes #4)
All checks were successful
check / check (push) Successful in 57s

fatalf called os.Exit(1), which does not run deferred functions, so
every defer db.Close() was dead on the fatal path: the SQLite WAL was
left uncheckpointed and the -wal/-shm sidecars were left for the next
process to recover. It also made those paths impossible to exercise
in-process.

fatalf is gone. runScan, runReport, runTrees, loadRecords and
resolveRoots return their errors, so the deferred close always runs,
and the only exit point is run() in main.go.

Mapping errors to exit codes needs care: cobra prints the error and
the command's usage text for anything RunE returns, and main mapped
every Execute() error to exit 2. A runtime failure is not a usage
problem, so the runE adapter silences both for the subcommands and
marks their errors fatalError; run() reports a fatalError as
"sfdupes: ..." on stderr and exits 1, and leaves everything else --
cobra's own argument, flag and unknown-command errors, which cobra has
already reported with its usage text -- on exit 2. The bare
"sfdupes" invocation still prints usage and exits 2.

Exit codes and message text are unchanged: 0 on success even with
per-file warnings, 1 fatal, 2 usage, per README section "Error
handling and exit codes". Everything on stdout is still data only.

main_test.go drives the CLI in-process and covers all three: a fatal
error raised after the database is open (a database with no files
table) closes it and leaves no -wal or -shm behind for scan, report
and trees; a nonexistent PATH operand is fatal, not usage, and prints
no usage text; the usage errors still exit 2; and a scan that skipped
an unreadable file still exits 0.
This commit is contained in:
2026-08-09 02:27:07 +00:00
parent ce6d29dffb
commit 73841c9989
6 changed files with 566 additions and 57 deletions

View File

@@ -28,23 +28,26 @@ type scanRec struct {
// loadRecords opens the database and reads every file record for the
// report and trees subcommands. Any database problem — including a
// missing database — is fatal.
func loadRecords() []scanRec {
// missing database — is fatal. The error is returned rather than
// exiting, so that the deferred close — which checkpoints the SQLite
// WAL — always runs; the database is closed before the caller formats
// its output, so it stays closed even if that output fails.
func loadRecords() ([]scanRec, error) {
dbPath := databasePath()
db, err := openReportDatabase(dbPath)
if err != nil {
fatalf("%v", err)
return nil, err
}
defer func() { _ = db.Close() }()
recs, err := loadFileRows(db)
if err != nil {
fatalf("database %s: %v", dbPath, err)
return nil, fmt.Errorf("database %s: %w", dbPath, err)
}
return recs
return recs, nil
}
// dupeGroup is one set of candidate-duplicate files: identical size,
@@ -59,15 +62,19 @@ type dupeGroup struct {
// from the database and prints the file-level duplicates report as TSV
// on stdout. It never touches the scanned filesystem; its only I/O is
// the database, stdout, and stderr.
func runReport() {
recs := loadRecords()
func runReport() error {
recs, err := loadRecords()
if err != nil {
return err
}
dupes := collectDupeGroups(recs)
out := bufio.NewWriterSize(os.Stdout, ioBufSize)
_, err := fmt.Fprintln(out, "first\tdupe\tsize")
_, err = fmt.Fprintln(out, "first\tdupe\tsize")
if err != nil {
fatalf("write stdout: %v", err)
return fmt.Errorf("write stdout: %w", err)
}
dupeFiles := 0
@@ -79,7 +86,7 @@ func runReport() {
_, err = fmt.Fprintf(out, "%s\t%s\t%d\n",
g.paths[0], p, g.size)
if err != nil {
fatalf("write stdout: %v", err)
return fmt.Errorf("write stdout: %w", err)
}
dupeFiles++
@@ -89,13 +96,15 @@ func runReport() {
err = out.Flush()
if err != nil {
fatalf("write stdout: %v", err)
return fmt.Errorf("write stdout: %w", err)
}
fmt.Fprintf(os.Stderr,
"report: %d records read, %d duplicate groups, %d dupe files, "+
"%s reclaimable\n",
len(recs), len(dupes), dupeFiles, humanBytes(reclaimable))
return nil
}
// collectDupeGroups groups records by signature and returns every group