A first SIGINT or SIGTERM cancels the scan. It commits the hashed records still in its batch, with a context that is not cancelled for that one write, and starts no other write or deletion; deletions need a complete walk, so records under paths an interrupted walk never reached are kept. A batch whose commit failed is now kept for that final commit instead of dropped. The progress display is finished (a bar stopped short is no longer filled up), `scan: interrupted after N files` goes to stderr, and the exit code is 1. A second signal ends the process at once. A SIGINT inherited as ignored stays ignored. Model: opus-5-5
This commit is contained in:
+25
-16
@@ -9,11 +9,13 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -456,7 +458,7 @@ func TestScanContentWithinOperand(t *testing.T) {
|
||||
added := sparseFile(t, dir, "d2", headTailMin)
|
||||
|
||||
st := syncTree(t, db, dir)
|
||||
if st != (scanStats{added: 1, unchanged: 2}) {
|
||||
if st != (scanStats{walked: 3, added: 1, unchanged: 2}) {
|
||||
t.Fatalf("rescan stats = %+v, want 1 added 2 unchanged", st)
|
||||
}
|
||||
|
||||
@@ -502,7 +504,7 @@ func TestScanContentStalePartners(t *testing.T) {
|
||||
sparseFile(t, dirB, "changed-copy", headTailMin+1)
|
||||
|
||||
st := syncTree(t, db, dirB)
|
||||
if st != (scanStats{added: 2}) {
|
||||
if st != (scanStats{walked: 2, added: 2}) {
|
||||
t.Errorf("stats = %+v, want 2 added and nothing skipped", st)
|
||||
}
|
||||
|
||||
@@ -559,7 +561,7 @@ func TestScanContentHashedStalePartners(t *testing.T) {
|
||||
b := sparseFile(t, t.TempDir(), "copy", headTailMin)
|
||||
|
||||
st := syncTree(t, db, filepath.Dir(b))
|
||||
if st != (scanStats{added: 1}) {
|
||||
if st != (scanStats{walked: 1, added: 1}) {
|
||||
t.Errorf("stats = %+v, want 1 added and nothing skipped", st)
|
||||
}
|
||||
|
||||
@@ -599,7 +601,7 @@ func TestScanContentReadFailure(t *testing.T) {
|
||||
b := sparseFile(t, dirB, "b", headTailMin)
|
||||
|
||||
st := syncTree(t, db, dirB)
|
||||
if st != (scanStats{added: 1, skipped: 1}) {
|
||||
if st != (scanStats{walked: 1, added: 1, skipped: 1}) {
|
||||
t.Fatalf("stats = %+v, want 1 added 1 skipped", st)
|
||||
}
|
||||
|
||||
@@ -613,7 +615,7 @@ func TestScanContentReadFailure(t *testing.T) {
|
||||
}
|
||||
|
||||
st = syncTree(t, db, dirB)
|
||||
if st != (scanStats{unchanged: 1}) {
|
||||
if st != (scanStats{walked: 1, unchanged: 1}) {
|
||||
t.Fatalf("rescan stats = %+v, want 1 unchanged", st)
|
||||
}
|
||||
|
||||
@@ -659,7 +661,7 @@ func TestScanContentCheckError(t *testing.T) {
|
||||
b := sparseFile(t, t.TempDir(), "b", headTailMin)
|
||||
|
||||
st := syncTree(t, db, filepath.Dir(b))
|
||||
if st != (scanStats{added: 1, skipped: 1}) {
|
||||
if st != (scanStats{walked: 1, added: 1, skipped: 1}) {
|
||||
t.Fatalf("stats = %+v, want 1 added 1 skipped", st)
|
||||
}
|
||||
|
||||
@@ -687,7 +689,7 @@ func TestScanContentHardlinks(t *testing.T) {
|
||||
c := sparseFile(t, dir, "copy", headTailMin)
|
||||
|
||||
st := syncTree(t, db, dir)
|
||||
if st != (scanStats{added: 3}) {
|
||||
if st != (scanStats{walked: 3, added: 3}) {
|
||||
t.Fatalf("stats = %+v, want 3 added", st)
|
||||
}
|
||||
|
||||
@@ -1058,7 +1060,7 @@ func TestScanPipeline(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
st := syncTree(t, db, dir)
|
||||
if st != (scanStats{added: smokeTreeFiles}) {
|
||||
if st != (scanStats{walked: smokeTreeFiles, added: smokeTreeFiles}) {
|
||||
t.Fatalf("stats = %+v, want %d added only", st, smokeTreeFiles)
|
||||
}
|
||||
|
||||
@@ -1081,7 +1083,7 @@ func TestSyncScanUnchangedReuse(t *testing.T) {
|
||||
writeFile(t, dir, "b.bin", pattern(2, 600))
|
||||
|
||||
st := syncTree(t, db, dir)
|
||||
if st != (scanStats{added: 2}) {
|
||||
if st != (scanStats{walked: 2, added: 2}) {
|
||||
t.Fatalf("first scan stats = %+v, want 2 added", st)
|
||||
}
|
||||
|
||||
@@ -1095,7 +1097,7 @@ func TestSyncScanUnchangedReuse(t *testing.T) {
|
||||
}
|
||||
|
||||
st = syncTree(t, db, dir)
|
||||
if st != (scanStats{unchanged: 2}) {
|
||||
if st != (scanStats{walked: 2, unchanged: 2}) {
|
||||
t.Fatalf("rescan stats = %+v, want 2 unchanged", st)
|
||||
}
|
||||
|
||||
@@ -1124,7 +1126,7 @@ func TestSyncScanMtimeBump(t *testing.T) {
|
||||
}
|
||||
|
||||
st := syncTree(t, db, dir)
|
||||
if st != (scanStats{updated: 1}) {
|
||||
if st != (scanStats{walked: 1, updated: 1}) {
|
||||
t.Fatalf("mtime-bump stats = %+v, want 1 updated", st)
|
||||
}
|
||||
|
||||
@@ -1152,7 +1154,7 @@ func TestSyncScanAddRemove(t *testing.T) {
|
||||
}
|
||||
|
||||
st := syncTree(t, db, dir)
|
||||
if st != (scanStats{added: 1, removed: 1, unchanged: 1}) {
|
||||
if st != (scanStats{walked: 2, added: 1, removed: 1, unchanged: 1}) {
|
||||
t.Fatalf("add/remove stats = %+v, want 1 added 1 removed 1 unchanged",
|
||||
st)
|
||||
}
|
||||
@@ -1269,7 +1271,7 @@ func TestSyncScanOverlappingRoots(t *testing.T) {
|
||||
// A file reachable via two overlapping operands is deduplicated
|
||||
// by path in the shared walk and processed once.
|
||||
st := syncTree(t, db, dir, filepath.Join(dir, "sub"))
|
||||
if st != (scanStats{added: 1}) {
|
||||
if st != (scanStats{walked: 1, added: 1}) {
|
||||
t.Fatalf("stats = %+v, want 1 added", st)
|
||||
}
|
||||
|
||||
@@ -1290,7 +1292,7 @@ func TestScanSkipsUniqueSizes(t *testing.T) {
|
||||
// Neither size is shared, so neither file is read: both records
|
||||
// are written without hashes and no duplicates are reported.
|
||||
st := syncTree(t, db, dir)
|
||||
if st != (scanStats{added: 2}) {
|
||||
if st != (scanStats{walked: 2, added: 2}) {
|
||||
t.Fatalf("stats = %+v, want 2 added", st)
|
||||
}
|
||||
|
||||
@@ -1312,7 +1314,7 @@ func TestScanSkipsUniqueSizes(t *testing.T) {
|
||||
c := writeFile(t, dir, "c.bin", pattern(1, 500))
|
||||
|
||||
st = syncTree(t, db, dir)
|
||||
if st != (scanStats{added: 1, updated: 1, unchanged: 1}) {
|
||||
if st != (scanStats{walked: 3, added: 1, updated: 1, unchanged: 1}) {
|
||||
t.Fatalf("rescan stats = %+v, want 1 added 1 updated 1 unchanged",
|
||||
st)
|
||||
}
|
||||
@@ -1376,7 +1378,7 @@ func TestScanHardlinksReadOnce(t *testing.T) {
|
||||
}
|
||||
|
||||
st := syncTree(t, db, dir)
|
||||
if st != (scanStats{added: 2}) {
|
||||
if st != (scanStats{walked: 2, added: 2}) {
|
||||
t.Fatalf("stats = %+v, want 2 added", st)
|
||||
}
|
||||
|
||||
@@ -1498,6 +1500,13 @@ func injectWriteFailure(t *testing.T, path string) {
|
||||
func baselineGoroutines(t *testing.T) int {
|
||||
t.Helper()
|
||||
|
||||
// The first scan in a process starts os/signal's goroutine, which
|
||||
// never exits. Start it now, so the baseline counts it instead of
|
||||
// the scan seeming to leave it behind.
|
||||
ch := make(chan os.Signal, 1)
|
||||
signal.Notify(ch, syscall.SIGINT)
|
||||
signal.Stop(ch)
|
||||
|
||||
deadline := time.Now().Add(goroutineSettle)
|
||||
last := runtime.NumGoroutine()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user