Guarantee the database is closed on every fatal exit path (closes #4)
All checks were successful
check / check (push) Successful in 57s
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:
139
main.go
139
main.go
@@ -16,20 +16,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Exit codes: 0 is success (even with per-file warnings), exitFatal is
|
||||
// a fatal error, exitUsage is a usage error.
|
||||
// Exit codes: exitOK is success (even with per-file warnings),
|
||||
// exitFatal is a fatal error, exitUsage is a usage error.
|
||||
const (
|
||||
exitOK = 0
|
||||
exitFatal = 1
|
||||
exitUsage = 2
|
||||
)
|
||||
|
||||
// The subcommand names, as typed on the command line.
|
||||
const (
|
||||
cmdScan = "scan"
|
||||
cmdReport = "report"
|
||||
cmdTrees = "trees"
|
||||
)
|
||||
|
||||
// errNoSubcommand is returned by the root command when it is invoked
|
||||
// without a subcommand. That is a usage error, and the usage text
|
||||
// cobra prints for it is the whole message.
|
||||
var errNoSubcommand = errors.New("no subcommand")
|
||||
|
||||
// Version is the build version, injected at link time via -ldflags
|
||||
// (see the Makefile); "dev" for a plain go build.
|
||||
//
|
||||
@@ -37,22 +52,64 @@ const (
|
||||
var Version = "dev"
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stderr))
|
||||
}
|
||||
|
||||
// run executes args against the command tree and returns the process
|
||||
// exit code. It is the program's single exit point: the subcommands
|
||||
// return their errors instead of exiting, so every deferred cleanup —
|
||||
// above all closing the database, which checkpoints the SQLite WAL —
|
||||
// runs before the process ends.
|
||||
func run(args []string, stderr io.Writer) int {
|
||||
// A nil slice makes cobra fall back to os.Args, which would let a
|
||||
// test binary's own flags reach the command tree.
|
||||
if args == nil {
|
||||
args = []string{}
|
||||
}
|
||||
|
||||
root := newRootCommand(stderr)
|
||||
root.SetArgs(args)
|
||||
|
||||
err := root.Execute()
|
||||
|
||||
var fatal fatalError
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
return exitOK
|
||||
case errors.As(err, &fatal):
|
||||
// The command ran and failed: a runtime error, reported
|
||||
// without the usage text that a usage error gets.
|
||||
_, _ = fmt.Fprintf(stderr, "sfdupes: %v\n", err)
|
||||
|
||||
return exitFatal
|
||||
default:
|
||||
// A usage error: cobra has already printed the message and
|
||||
// the usage text.
|
||||
return exitUsage
|
||||
}
|
||||
}
|
||||
|
||||
// newRootCommand builds the command tree. Everything on stdout is
|
||||
// machine-readable data; all human-facing output (help, usage, errors)
|
||||
// goes to stderr.
|
||||
func newRootCommand(stderr io.Writer) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "sfdupes",
|
||||
Short: "Find candidate duplicate files by size and head/tail SHA-256",
|
||||
Version: Version,
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
// A missing subcommand prints usage and exits 2.
|
||||
_ = cmd.Usage()
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
// A missing subcommand prints usage and exits 2: cobra
|
||||
// prints the usage text for the returned error, and run
|
||||
// maps everything that is not a fatal error to exit 2.
|
||||
cmd.SilenceErrors = true
|
||||
|
||||
os.Exit(exitUsage)
|
||||
return errNoSubcommand
|
||||
},
|
||||
}
|
||||
// Everything on stdout is machine-readable data; all human-facing
|
||||
// output (help, usage, errors) goes to stderr.
|
||||
root.SetOut(os.Stderr)
|
||||
root.SetErr(os.Stderr)
|
||||
root.SetOut(stderr)
|
||||
root.SetErr(stderr)
|
||||
root.CompletionOptions.DisableDefaultCmd = true
|
||||
|
||||
var (
|
||||
@@ -61,12 +118,12 @@ func main() {
|
||||
)
|
||||
|
||||
scanCmd := &cobra.Command{
|
||||
Use: "scan [--workers N] [-x] PATH...",
|
||||
Use: cmdScan + " [--workers N] [-x] PATH...",
|
||||
Short: "Walk trees and synchronize the scan database",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
runScan(args, scanWorkers, scanOneFS)
|
||||
},
|
||||
RunE: runE(func(args []string) error {
|
||||
return runScan(args, scanWorkers, scanOneFS)
|
||||
}),
|
||||
}
|
||||
scanCmd.Flags().IntVar(&scanWorkers, "workers", runtime.NumCPU(),
|
||||
"concurrent workers for the walk and hash phases")
|
||||
@@ -74,35 +131,55 @@ func main() {
|
||||
"do not cross filesystem boundaries")
|
||||
|
||||
reportCmd := &cobra.Command{
|
||||
Use: "report",
|
||||
Use: cmdReport,
|
||||
Short: "Read the scan database and print the file-level duplicates report",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
runReport()
|
||||
},
|
||||
RunE: runE(func(_ []string) error {
|
||||
return runReport()
|
||||
}),
|
||||
}
|
||||
|
||||
treesCmd := &cobra.Command{
|
||||
Use: "trees",
|
||||
Use: cmdTrees,
|
||||
Short: "Read the scan database and print the duplicate-tree report",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
runTrees()
|
||||
},
|
||||
RunE: runE(func(_ []string) error {
|
||||
return runTrees()
|
||||
}),
|
||||
}
|
||||
|
||||
root.AddCommand(scanCmd, reportCmd, treesCmd)
|
||||
|
||||
err := root.Execute()
|
||||
if err != nil {
|
||||
// Cobra has already printed the error and usage to stderr;
|
||||
// an invalid subcommand or bad arguments is a usage error.
|
||||
os.Exit(exitUsage)
|
||||
return root
|
||||
}
|
||||
|
||||
// runE adapts a subcommand implementation to cobra's RunE. Cobra
|
||||
// prints the error and the command's usage text for every error RunE
|
||||
// returns, but a subcommand that ran and failed has no usage problem
|
||||
// to report: both are silenced here, and the error is marked fatal so
|
||||
// that run reports it on stderr and exits 1 rather than 2.
|
||||
func runE(fn func(args []string) error) func(*cobra.Command, []string) error {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
|
||||
err := fn(args)
|
||||
if err != nil {
|
||||
return fatalError{err: err}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// fatalf reports a fatal error and exits 1.
|
||||
func fatalf(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "sfdupes: "+format+"\n", args...)
|
||||
os.Exit(exitFatal)
|
||||
// fatalError marks a runtime failure, as opposed to the usage errors
|
||||
// cobra itself produces while parsing arguments and flags. Both come
|
||||
// out of Execute as plain errors, so the wrapper is what tells run to
|
||||
// report this one as "sfdupes: ..." and exit 1.
|
||||
type fatalError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e fatalError) Error() string { return e.err.Error() }
|
||||
|
||||
func (e fatalError) Unwrap() error { return e.err }
|
||||
|
||||
Reference in New Issue
Block a user