diff --git a/cmd/shuttle/main.go b/cmd/shuttle/main.go index f89dc3f..4362a80 100644 --- a/cmd/shuttle/main.go +++ b/cmd/shuttle/main.go @@ -158,24 +158,29 @@ func run() int { rootCmd.AddCommand(runCmd, versionCmd, validateCmd, doctorCmd) - // Context wired to OS signals. The goroutine sets signaled before canceling - // so the exit-code check below can distinguish a signal from a normal error. - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - var signaled bool - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + // Context canceled on SIGINT/SIGTERM. The parent is Background and stop + // runs only after the exit-code check below, so once ExecuteContext + // returns, ctx.Err() != nil can mean exactly one thing: a signal arrived. + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + // Companion channel for UX only: print the interrupt notice at receipt + // time and restore the default disposition so a second signal terminates + // the process immediately instead of being swallowed. Registration order + // is load-bearing: NotifyContext above must register first so a signal + // landing between the two calls still cancels (at worst the notice line + // is skipped). + interruptCh := make(chan os.Signal, 1) + signal.Notify(interruptCh, syscall.SIGINT, syscall.SIGTERM) go func() { - <-sigCh - signaled = true + <-interruptCh + signal.Reset(syscall.SIGINT, syscall.SIGTERM) fmt.Fprintln(os.Stderr, "\nInterrupted. Shutting down...") - cancel() }() err := rootCmd.ExecuteContext(ctx) - if signaled { + if ctx.Err() != nil { return exitSignal } if errors.Is(err, errPartialFailure) { diff --git a/cmd/shuttle/main_test.go b/cmd/shuttle/main_test.go index 0c0f7d8..067d325 100644 --- a/cmd/shuttle/main_test.go +++ b/cmd/shuttle/main_test.go @@ -1,13 +1,16 @@ package main import ( + "bufio" "bytes" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" "strings" + "syscall" "testing" "time" @@ -29,7 +32,7 @@ func TestMain(m *testing.M) { shuttleBin = filepath.Join(dir, "shuttle") // Build from repo root; test files run from the package directory. - buildCmd := exec.Command("go", "build", "-o", shuttleBin, "./cmd/shuttle") + buildCmd := exec.Command("go", "build", "-race", "-o", shuttleBin, "./cmd/shuttle") buildCmd.Dir = filepath.Join("..", "..") if out, err := buildCmd.CombinedOutput(); err != nil { fmt.Fprintf(os.Stderr, "building shuttle: %v\n%s\n", err, out) @@ -877,3 +880,84 @@ func TestResolveRclonePassword_EnvPreset_ReturnsEmpty(t *testing.T) { t.Errorf("RCLONE_CONFIG_PASS = %q, want unchanged 'already-set'", v) } } + +// assertSignalExitsWithSignalCode verifies the public exit-code contract for +// interrupted runs: the given signal during an active job yields exit 130 and +// the interrupt notice on stderr. The job is throttled via --bwlimit so the +// process is reliably still alive when the signal lands; the exit code is +// 130 regardless of which pipeline stage the cancellation interrupts. +func assertSignalExitsWithSignalCode(t *testing.T, sig syscall.Signal) { + t.Helper() + if _, err := exec.LookPath("rsync"); err != nil { + t.Skip("rsync not found on PATH") + } + src := t.TempDir() + payload := make([]byte, 1<<20) // 1 MiB at --bwlimit=100 (KB/s) ≈ 10s window + if err := os.WriteFile(filepath.Join(src, "big.bin"), payload, 0o644); err != nil { + t.Fatalf("writing payload: %v", err) + } + dst := t.TempDir() + + env := writeConfig(t, fmt.Sprintf(` +[[job]] +name = "slow" +engine = "rsync" +sources = [%q] +destination = %q +extra_flags = ["--bwlimit=100"] +`, filepath.Join(src, "big.bin"), dst)) + + cmd := exec.Command(shuttleBin, "run") + cmd.Env = env + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("stdout pipe: %v", err) + } + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + if err := cmd.Start(); err != nil { + t.Fatalf("starting shuttle: %v", err) + } + + // Signal only after the run has observably started so the process + // cannot have exited before the kill lands. + scanner := bufio.NewScanner(stdout) + started := false + for scanner.Scan() { + if strings.Contains(scanner.Text(), "Shuttle Started") { + started = true + break + } + } + if !started { + _ = cmd.Process.Kill() + t.Fatalf("never saw startup line; stderr: %s", stderrBuf.String()) + } + if err := cmd.Process.Signal(sig); err != nil { + t.Fatalf("sending %v: %v", sig, err) + } + // Drain remaining stdout so the child never blocks on a full pipe. + go func() { _, _ = io.Copy(io.Discard, stdout) }() + + err = cmd.Wait() + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("Wait() = %v, want ExitError with code 130", err) + } + if code := exitErr.ExitCode(); code != 130 { + t.Errorf("exit code = %d, want 130; stderr: %s", code, stderrBuf.String()) + } + if !strings.Contains(stderrBuf.String(), "Interrupted") { + t.Errorf("stderr = %q, want interrupt notice", stderrBuf.String()) + } +} + +func TestCLI_SIGINT_ExitsWithSignalCode(t *testing.T) { + assertSignalExitsWithSignalCode(t, syscall.SIGINT) +} + +// SIGTERM is what cron and launchd send on shutdown, so the second registered +// signal gets the same contract coverage as the interactive ctrl-C path. +func TestCLI_SIGTERM_ExitsWithSignalCode(t *testing.T) { + assertSignalExitsWithSignalCode(t, syscall.SIGTERM) +} diff --git a/internal/engine/rclone.go b/internal/engine/rclone.go index 3195f1b..41751a0 100644 --- a/internal/engine/rclone.go +++ b/internal/engine/rclone.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "context" + "errors" "fmt" "io" "os" @@ -233,17 +234,73 @@ func selectMode(mode, destination, remoteName, backupPath, runTimestamp string, return "sync", "" } +// archiveDateLayout is the date prefix on archive directory names; the run +// timestamp used for --backup-dir construction starts with this format. +const archiveDateLayout = "2006-01-02" + +// rcloneExitDirNotFound is rclone's documented exit code for "directory not +// found", the one lsd failure that means "no archives yet" rather than a +// real problem. https://rclone.org/docs/#exit-code +const rcloneExitDirNotFound = 3 + +// archiveDirExpired classifies an archive directory name against a cutoff +// date (archiveDateLayout format). recognized is false when the name does +// not begin with a calendar-valid date, in which case expired is +// meaningless. Pure so purge eligibility is testable without rclone; the +// caller filters empty names before calling. +func archiveDirExpired(dirName, cutoff string) (expired, recognized bool) { + if len(dirName) < len(archiveDateLayout) { + return false, false + } + datePrefix := dirName[:len(archiveDateLayout)] + if _, err := time.Parse(archiveDateLayout, datePrefix); err != nil { + return false, false + } + return datePrefix < cutoff, true +} + +// isDirNotFound reports whether an rclone invocation failed only because the +// target directory does not exist. +func isDirNotFound(err error) bool { + var exitErr *exec.ExitError + return errors.As(err, &exitErr) && exitErr.ExitCode() == rcloneExitDirNotFound +} + +// firstStderrLine returns the first stderr line captured by Output(), or "" +// when stderr was empty (rclone routes error text to --log-file when set, +// leaving stderr blank in normal runs). +func firstStderrLine(err error) string { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || len(exitErr.Stderr) == 0 { + return "" + } + return oneLine(string(exitErr.Stderr)) +} + +// purgeArchiveDir runs rclone purge for one expired archive directory, +// mirroring the lsd call's log-file routing. The caller decides how to +// handle a failure (warn and continue). +func (e *RcloneExecutor) purgeArchiveDir(ctx context.Context, target string) error { + purgeArgs := []string{"purge", target} + if e.logFile != "" { + purgeArgs = append(purgeArgs, "--log-file", e.logFile, "--log-level", "INFO") + } + return e.rcloneCommand(ctx, purgeArgs...).Run() +} + // CleanupArchives purges archive subdirectories older than retentionDays -// from the backup root on the given remote. It is non-fatal: individual purge -// failures are logged as warnings and do not stop processing of remaining -// directories. Skipped during dry-run, when backupPath is empty, or when -// retentionDays is non-positive. +// from the backup root on the given remote. Individual purge failures are +// logged as warnings and do not stop processing of remaining directories, +// but a failure to list the backup root at all is returned as an error so +// the caller can surface it; a missing backup root (rclone exit 3) is the +// expected first-run state and stays a non-error. Skipped during dry-run, +// when backupPath is empty, or when retentionDays is non-positive. func (e *RcloneExecutor) CleanupArchives(ctx context.Context, remoteName, backupPath string, retentionDays int, dryRun bool) error { if backupPath == "" || retentionDays <= 0 || dryRun { return nil } - cutoff := time.Now().AddDate(0, 0, -retentionDays).Format("2006-01-02") + cutoff := time.Now().AddDate(0, 0, -retentionDays).Format(archiveDateLayout) archiveRoot := fmt.Sprintf("%s:%s", remoteName, strings.TrimRight(backupPath, "/")) lsdArgs := []string{"lsd", archiveRoot + "/"} @@ -252,8 +309,14 @@ func (e *RcloneExecutor) CleanupArchives(ctx context.Context, remoteName, backup } output, err := e.rcloneCommand(ctx, lsdArgs...).Output() if err != nil { - e.logger.Info(fmt.Sprintf("no archive directory on %s (nothing to clean)", remoteName)) - return nil + if isDirNotFound(err) { + e.logger.Info(fmt.Sprintf("no archive directory on %s (nothing to clean)", remoteName)) + return nil + } + if detail := firstStderrLine(err); detail != "" { + return fmt.Errorf("listing archive root %s: %w (%s)", archiveRoot, err, detail) + } + return fmt.Errorf("listing archive root %s: %w", archiveRoot, err) } purged := 0 @@ -264,30 +327,25 @@ func (e *RcloneExecutor) CleanupArchives(ctx context.Context, remoteName, backup continue } dirName := fields[len(fields)-1] - // Archive directory names are prefixed with a YYYY-MM-DD date stamp. - // Skip entries that are too short to contain a full date prefix. - if len(dirName) < 10 { + expired, recognized := archiveDirExpired(dirName, cutoff) + if !recognized { + e.logger.Warn(fmt.Sprintf("archive cleanup: skipping unrecognized directory %s on %s", dirName, remoteName)) continue } - dirDate := dirName[:10] - // Validate that the prefix looks like YYYY-MM-DD before comparing. - if dirDate[4] != '-' || dirDate[7] != '-' { + if !expired { continue } - if dirDate < cutoff { - target := archiveRoot + "/" + dirName - e.logger.Info(fmt.Sprintf("purging expired archive: %s (%s < %s)", target, dirDate, cutoff)) - purgeArgs := []string{"purge", target} - if e.logFile != "" { - purgeArgs = append(purgeArgs, "--log-file", e.logFile, "--log-level", "INFO") - } - if purgeErr := e.rcloneCommand(ctx, purgeArgs...).Run(); purgeErr != nil { - e.logger.Warn(fmt.Sprintf("failed to purge %s: %v", target, purgeErr)) - } else { - purged++ - } + target := archiveRoot + "/" + dirName + e.logger.Info(fmt.Sprintf("purging expired archive: %s (%s < %s)", target, dirName[:len(archiveDateLayout)], cutoff)) + if purgeErr := e.purgeArchiveDir(ctx, target); purgeErr != nil { + e.logger.Warn(fmt.Sprintf("failed to purge %s: %v", target, purgeErr)) + } else { + purged++ } } + if scanErr := scanner.Err(); scanErr != nil { + return fmt.Errorf("scanning archive listing for %s: %w", archiveRoot, scanErr) + } if purged > 0 { e.logger.Info(fmt.Sprintf("archive cleanup: purged %d expired director(ies) from %s", purged, remoteName)) diff --git a/internal/engine/rclone_test.go b/internal/engine/rclone_test.go index 52214c2..3eed69b 100644 --- a/internal/engine/rclone_test.go +++ b/internal/engine/rclone_test.go @@ -2,6 +2,7 @@ package engine import ( "context" + "errors" "os" "os/exec" "path/filepath" @@ -484,6 +485,130 @@ func TestCleanupArchives_KeepsRecent(t *testing.T) { } } +func TestCleanupArchives_ListingFailure_ReturnsError(t *testing.T) { + skipIfNoRclone(t) + cleanup := writeRcloneConfig(t) + defer cleanup() + executor, _ := newRcloneTestExecutor(t) + // "nosuchremote" is not defined in the temp rclone config, so lsd fails + // with a non-3 exit code: a real configuration problem, not "no archives". + err := executor.CleanupArchives(context.Background(), "nosuchremote", "/tmp/whatever", 7, false) + if err == nil { + t.Fatal("CleanupArchives = nil, want error for undefined remote") + } + if !strings.Contains(err.Error(), "listing archive root") { + t.Errorf("error %q should carry the listing context", err) + } +} + +func TestFirstStderrLine(t *testing.T) { + tests := []struct { + name string + err error + want string + }{ + {"non-exit error", errors.New("plain failure"), ""}, + {"exit error with empty stderr", &exec.ExitError{}, ""}, + {"multi-line stderr returns first line trimmed", &exec.ExitError{Stderr: []byte(" first line \nsecond line\n")}, "first line"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := firstStderrLine(tt.err); got != tt.want { + t.Errorf("firstStderrLine(%v) = %q, want %q", tt.err, got, tt.want) + } + }) + } +} + +func TestCleanupArchives_CanceledContext_ReturnsError(t *testing.T) { + skipIfNoRclone(t) + cleanup := writeRcloneConfig(t) + defer cleanup() + archiveRoot := t.TempDir() + // Seeded expired-looking directory: asserting it survives makes the + // "failed probe performs zero purges" property observable, not just + // structural. + expiredDir := filepath.Join(archiveRoot, "2020-01-01_000000") + if err := os.MkdirAll(expiredDir, 0o755); err != nil { + t.Fatalf("seeding expired dir: %v", err) + } + ctx, cancelCtx := context.WithCancel(context.Background()) + cancelCtx() + executor, _ := newRcloneTestExecutor(t) + err := executor.CleanupArchives(ctx, "testlocal", archiveRoot, 7, false) + if err == nil { + t.Fatal("CleanupArchives = nil, want error for canceled context") + } + if _, statErr := os.Stat(expiredDir); statErr != nil { + t.Errorf("expired dir should survive a failed probe (zero purges): %v", statErr) + } +} + +func TestCleanupArchives_MissingBackupRoot_NoError(t *testing.T) { + skipIfNoRclone(t) + cleanup := writeRcloneConfig(t) + defer cleanup() + executor, _ := newRcloneTestExecutor(t) + missing := filepath.Join(t.TempDir(), "never-created") + if err := executor.CleanupArchives(context.Background(), "testlocal", missing, 7, false); err != nil { + t.Fatalf("CleanupArchives = %v, want nil for missing backup root (rclone exit 3)", err) + } +} + +func TestCleanupArchives_UnrecognizedAndInvalidDirs_NeverPurged(t *testing.T) { + skipIfNoRclone(t) + cleanup := writeRcloneConfig(t) + defer cleanup() + archiveRoot := t.TempDir() + keep := []string{ + "manual-backup-keep", // no date prefix + "0000-13-45-junk", // date-shaped but calendar-invalid + "with space 2019-01-01", // lsd fragment names a nonexistent sibling + } + for _, name := range keep { + if err := os.MkdirAll(filepath.Join(archiveRoot, name), 0o755); err != nil { + t.Fatalf("seeding %s: %v", name, err) + } + } + executor, _ := newRcloneTestExecutor(t) + if err := executor.CleanupArchives(context.Background(), "testlocal", archiveRoot, 7, false); err != nil { + t.Fatalf("CleanupArchives returned error: %v", err) + } + for _, name := range keep { + if _, err := os.Stat(filepath.Join(archiveRoot, name)); err != nil { + t.Errorf("directory %q should have survived cleanup: %v", name, err) + } + } +} + +func TestArchiveDirExpired(t *testing.T) { + const cutoff = "2026-05-10" + tests := []struct { + name string + dir string + expired bool + recognized bool + }{ + {"valid and old", "2020-01-01_000000", true, true}, + {"valid and recent", "2026-06-01_000000", false, true}, + {"valid equal to cutoff is kept", "2026-05-10_000000", false, true}, + {"bare date older", "2020-01-01", true, true}, + {"too short", "2020-01", false, false}, + {"dashes misplaced", "20200101--name", false, false}, + {"calendar invalid", "0000-13-45-junk", false, false}, + {"no date at all", "manual-backup", false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expired, recognized := archiveDirExpired(tt.dir, cutoff) + if expired != tt.expired || recognized != tt.recognized { + t.Errorf("archiveDirExpired(%q) = (%v, %v), want (%v, %v)", + tt.dir, expired, recognized, tt.expired, tt.recognized) + } + }) + } +} + func TestRcloneExec_ExpiredContext_ReturnsTimedOut(t *testing.T) { skipIfNoRclone(t) diff --git a/internal/engine/rsync.go b/internal/engine/rsync.go index 15625ad..8c7f4f5 100644 --- a/internal/engine/rsync.go +++ b/internal/engine/rsync.go @@ -64,17 +64,18 @@ func parseRsyncProgress(segment string) string { return strings.Join(parts, ", ") } -// scanRsyncProgress reads rsync stdout from r, writes all bytes to capture -// (preserving raw output for ParseRsyncStats), and extracts progress updates -// from \r-delimited segments. Each progress update is passed to onProgress. -// If onProgress is nil, bytes are still written to capture but no parsing occurs. -func scanRsyncProgress(r io.Reader, capture *bytes.Buffer, onProgress func(string)) { +// scanRsyncProgress reads rsync stdout from r, writes the byte stream to +// capture (preserving output for ParseRsyncStats), and extracts progress +// updates from \r-delimited segments. Each progress update is passed to +// onProgress. If onProgress is nil, bytes are still written to capture but no +// parsing occurs. +func scanRsyncProgress(r io.Reader, capture io.Writer, onProgress func(string)) { buf := make([]byte, 4096) var segment []byte for { n, err := r.Read(buf) if n > 0 { - capture.Write(buf[:n]) + _, _ = capture.Write(buf[:n]) if onProgress != nil { for _, b := range buf[:n] { @@ -98,6 +99,43 @@ func scanRsyncProgress(r io.Reader, capture *bytes.Buffer, onProgress func(strin } } +// rsyncCaptureTailBytes bounds in-memory capture of rsync stdout. Only the +// trailing --stats block (~600 bytes) is parsed after the run; without a +// bound, user flags like -v make the captured listing grow with file count. +const rsyncCaptureTailBytes = 64 * 1024 + +// tailBuffer is an io.Writer retaining only the last capacity bytes written. +// Hand-rolled because the stdlib has no bounded byte-tail writer: +// bytes.Buffer is unbounded and container/ring is element-oriented. +type tailBuffer struct { + capacity int + buf []byte +} + +func newTailBuffer(capacity int) *tailBuffer { + return &tailBuffer{capacity: capacity} +} + +// Write keeps the suffix of the stream within capacity. It never fails; the +// error return exists to satisfy io.Writer. +func (t *tailBuffer) Write(p []byte) (int, error) { + if len(p) >= t.capacity { + t.buf = append(t.buf[:0], p[len(p)-t.capacity:]...) + return len(p), nil + } + if overflow := len(t.buf) + len(p) - t.capacity; overflow > 0 { + t.buf = append(t.buf[:0], t.buf[overflow:]...) + } + t.buf = append(t.buf, p...) + return len(p), nil +} + +// Bytes returns the retained tail. The slice aliases internal storage and is +// valid until the next Write. +func (t *tailBuffer) Bytes() []byte { + return t.buf +} + // Exec runs rsync with the given pre-assembled argument list. // Stdout is captured for stats parsing. If onProgress is non-nil, progress // updates from --info=progress2 are parsed in real-time and forwarded. @@ -109,7 +147,7 @@ func (e *RsyncExecutor) Exec(ctx context.Context, args []string, onProgress func name := filepath.Base(strings.TrimRight(source, "/")) start := time.Now() - var capture bytes.Buffer + capture := newTailBuffer(rsyncCaptureTailBytes) cmd := exec.CommandContext(ctx, "rsync", args...) stdout, err := cmd.StdoutPipe() @@ -134,7 +172,7 @@ func (e *RsyncExecutor) Exec(ctx context.Context, args []string, onProgress func pipeWg.Add(1) go func() { defer pipeWg.Done() - scanRsyncProgress(stdout, &capture, onProgress) + scanRsyncProgress(stdout, capture, onProgress) }() pipeWg.Wait() diff --git a/internal/engine/rsync_test.go b/internal/engine/rsync_test.go index 9abd1f2..57dbd87 100644 --- a/internal/engine/rsync_test.go +++ b/internal/engine/rsync_test.go @@ -235,3 +235,75 @@ func TestRsyncExec_ExpiredContext_ReturnsTimedOut(t *testing.T) { t.Errorf("Status = %q, want %q", result.Status, StatusTimedOut) } } + +func TestTailBuffer_UnderCapacity_KeepsEverything(t *testing.T) { + tb := newTailBuffer(8) + _, _ = tb.Write([]byte("abc")) + _, _ = tb.Write([]byte("de")) + if got := string(tb.Bytes()); got != "abcde" { + t.Errorf("Bytes() = %q, want %q", got, "abcde") + } +} + +func TestTailBuffer_ExactlyAtCapacity_KeepsEverything(t *testing.T) { + tb := newTailBuffer(4) + _, _ = tb.Write([]byte("abcd")) + if got := string(tb.Bytes()); got != "abcd" { + t.Errorf("Bytes() = %q, want %q", got, "abcd") + } +} + +func TestTailBuffer_SingleWriteOverCapacity_KeepsTailOfWrite(t *testing.T) { + tb := newTailBuffer(4) + _, _ = tb.Write([]byte("abcdef")) + if got := string(tb.Bytes()); got != "cdef" { + t.Errorf("Bytes() = %q, want %q", got, "cdef") + } +} + +func TestTailBuffer_MultiWriteWrap_KeepsLastBytes(t *testing.T) { + tb := newTailBuffer(5) + _, _ = tb.Write([]byte("abc")) + _, _ = tb.Write([]byte("def")) + if got := string(tb.Bytes()); got != "bcdef" { + t.Errorf("Bytes() = %q, want %q", got, "bcdef") + } + _, _ = tb.Write([]byte("XYZ")) + if got := string(tb.Bytes()); got != "efXYZ" { + t.Errorf("Bytes() = %q, want %q", got, "efXYZ") + } +} + +func TestTailBuffer_EmptyWrite_LeavesContentUnchanged(t *testing.T) { + tb := newTailBuffer(4) + if got := tb.Bytes(); len(got) != 0 { + t.Errorf("Bytes() before any write = %q, want empty", got) + } + _, _ = tb.Write([]byte("ab")) + _, _ = tb.Write(nil) + if got := string(tb.Bytes()); got != "ab" { + t.Errorf("Bytes() = %q, want %q", got, "ab") + } +} + +func TestScanRsyncProgress_OverCapacityStream_StatsStillParsed(t *testing.T) { + // Simulates a -v run whose file listing exceeds the capture bound: only + // the head may be dropped; the trailing stats block must survive. + listing := strings.Repeat("verbose-file-listing-line.txt\n", 4000) // ~120 KiB + stats := readFixture(t, "rsync_stats_transferred.txt") + r := strings.NewReader(listing + string(stats)) + capture := newTailBuffer(rsyncCaptureTailBytes) + + scanRsyncProgress(r, capture, nil) + + parsed := ParseRsyncStats(capture.Bytes()) + if parsed.FilesTransferred != 8 { + t.Errorf("FilesTransferred = %d, want 8", parsed.FilesTransferred) + } + if parsed.FilesChecked != 250 { + t.Errorf("FilesChecked = %d, want 250", parsed.FilesChecked) + } + if parsed.BytesSent != "45.35M" { + t.Errorf("BytesSent = %q, want %q", parsed.BytesSent, "45.35M") + } +}