From 277178776d955b1a04bb545f8ea58423d59a5c9d Mon Sep 17 00:00:00 2001 From: Petr Date: Mon, 29 Jun 2026 11:53:14 +0200 Subject: [PATCH] feat: persist resume metadata on SFTP single-file cancel --- internal/protocol/sftp/sftp.go | 8 ++ internal/protocol/sftp/sftp_graceful_test.go | 83 ++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 internal/protocol/sftp/sftp_graceful_test.go diff --git a/internal/protocol/sftp/sftp.go b/internal/protocol/sftp/sftp.go index 9e42a29..6daf6ae 100644 --- a/internal/protocol/sftp/sftp.go +++ b/internal/protocol/sftp/sftp.go @@ -725,6 +725,14 @@ func downloadSingleFile(ctx context.Context, c *client, req *core.DownloadReques for { select { case <-ctx.Done(): + // Persist partial progress so the user can resume later + // with --resume. Unconditional — mirrors the FTP and SFTP + // parallel paths that always save a .goget.meta sidecar on + // cancellation. + if outputPath != "" && outputPath != "-" && total > 0 { + meta := output.NewResumeMetadata(req.URL.String(), "", "", int64(total), totalSize) + _ = meta.Save(outputPath) + } return nil, ctx.Err() default: } diff --git a/internal/protocol/sftp/sftp_graceful_test.go b/internal/protocol/sftp/sftp_graceful_test.go new file mode 100644 index 0000000..4c46a7e --- /dev/null +++ b/internal/protocol/sftp/sftp_graceful_test.go @@ -0,0 +1,83 @@ +//go:build linux || freebsd +// +build linux freebsd + +package sftp + +import ( + "context" + "crypto/rand" + "net/url" + "os" + "path/filepath" + "sync" + "testing" + + "codeberg.org/petrbalvin/goget/internal/core" + "codeberg.org/petrbalvin/goget/internal/output" +) + +// TestDownloadSingleFileGracefulShutdown verifies that downloadSingleFile +// persists a .goget.meta sidecar when the context is cancelled mid-transfer. +// Without this, a user hitting Ctrl-C during an SFTP single-file download +// would lose all progress and be unable to --resume. +func TestDownloadSingleFileGracefulShutdown(t *testing.T) { + addr, cleanup := startTestServer(t) + defer cleanup() + + // Use a large file so the download takes multiple read iterations, + // giving the context cancellation a chance to land between reads. + bigContent := make([]byte, 1<<20) // 1 MiB + if _, err := rand.Read(bigContent); err != nil { + t.Fatalf("rand: %v", err) + } + origContent := testFileContent + testFileContent = bigContent + t.Cleanup(func() { testFileContent = origContent }) + + u, _ := url.Parse("sftp://user@" + addr + "/testfile") + outDir := t.TempDir() + outputPath := filepath.Join(outDir, "out") + + ctx, cancel := context.WithCancel(context.Background()) + + // Cancel the context on the first progress callback — at that point + // at least one chunk has been written, so total > 0 and the + // .goget.meta sidecar will be persisted. + var once sync.Once + progressCallback := func(current, total int64, speed float64) { + once.Do(cancel) + } + + req := &core.DownloadRequest{ + URL: u, + Output: outputPath, + Ctx: ctx, + SSHInsecure: true, + ProgressCallback: progressCallback, + } + + _, err := download(ctx, req) + if err == nil { + t.Fatal("expected error from cancelled context, got nil") + } + + // The output file must exist (partial data was written). + if _, statErr := os.Stat(outputPath); statErr != nil { + t.Fatalf("output file should exist: %v", statErr) + } + + // Verify .goget.meta was saved with partial progress. + meta, err := output.LoadResumeMetadata(outputPath) + if err != nil { + t.Fatalf("LoadResumeMetadata: %v", err) + } + if meta == nil { + t.Fatal("expected resume metadata after SIGINT, got nil") + } + if meta.Downloaded <= 0 { + t.Errorf("Downloaded = %d, want > 0", meta.Downloaded) + } + if meta.URL != u.String() { + t.Errorf("URL = %q, want %q", meta.URL, u.String()) + } +}