feat: add resume support to Gemini protocol

This commit is contained in:
2026-06-29 20:06:08 +02:00
parent fc65ea8340
commit 9ef62520c7
6 changed files with 387 additions and 13 deletions
+260
View File
@@ -15,12 +15,14 @@ import (
"math/big"
"net"
"net/url"
"os"
"strconv"
"strings"
"testing"
"time"
"codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/output"
)
type stringWriter struct{ sb *strings.Builder }
@@ -170,3 +172,261 @@ func TestGeminiPinnedCertMismatch(t *testing.T) {
t.Errorf("expected pinning error, got: %v", err)
}
}
// TestGeminiResumeSaveMetadata verifies that partial download progress is
// persisted to a .goget.meta sidecar when the context is cancelled.
func TestGeminiResumeSaveMetadata(t *testing.T) {
cfg := testTLSConfig()
listener, err := tls.Listen("tcp", "127.0.0.1:0", cfg)
if err != nil {
t.Fatal(err)
}
defer listener.Close()
port := listener.Addr().(*net.TCPAddr).Port
// Send data in small pieces with delays so the download is still in
// progress when the context is cancelled.
body := make([]byte, 2000)
for i := range body {
body[i] = 'A'
}
go func() {
conn, _ := listener.Accept()
if conn == nil {
return
}
defer conn.Close()
r := bufio.NewReader(conn)
r.ReadString('\n')
conn.Write([]byte("20 application/octet-stream\r\n"))
// Send 500 bytes per chunk with 100ms delays.
for off := 0; off < len(body); off += 500 {
end := off + 500
if end > len(body) {
end = len(body)
}
conn.Write(body[off:end])
time.Sleep(100 * time.Millisecond)
}
}()
tmp := t.TempDir()
outPath := tmp + "/partial.bin"
proto := NewProtocol()
proto.TLSInsecure = true
u, _ := url.Parse("gemini://127.0.0.1:" + strconv.Itoa(port) + "/")
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
_, err := proto.Download(ctx, &core.DownloadRequest{
URL: u,
Output: outPath,
})
errCh <- err
}()
// Cancel mid-download — the slow server keeps the connection open
// long enough for the cancellation to fire between reads.
time.Sleep(150 * time.Millisecond)
cancel()
err = <-errCh
if err == nil {
t.Fatal("expected error from cancelled context")
}
// Verify resume metadata was saved.
metaPath := outPath + output.ResumeMetaFileSuffix
if _, err := os.Stat(metaPath); os.IsNotExist(err) {
t.Fatal("expected .goget.meta sidecar to exist")
}
meta, err := output.LoadResumeMetadata(outPath)
if err != nil {
t.Fatalf("LoadResumeMetadata: %v", err)
}
if meta == nil {
t.Fatal("expected resume metadata, 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())
}
}
// TestGeminiResumeDownload verifies end-to-end resume: a first download is
// interrupted, and the second download picks up from the saved offset.
func TestGeminiResumeDownload(t *testing.T) {
cfg := testTLSConfig()
listener, err := tls.Listen("tcp", "127.0.0.1:0", cfg)
if err != nil {
t.Fatal(err)
}
defer listener.Close()
port := listener.Addr().(*net.TCPAddr).Port
fullBody := make([]byte, 1000)
for i := range fullBody {
fullBody[i] = byte('A' + i%26)
}
cancelReady := make(chan struct{}) // client signals it has cancelled
connReady := make(chan struct{}) // server signals connection is done
go func() {
for i := 0; i < 2; i++ {
conn, lerr := listener.Accept()
if lerr != nil {
return
}
r := bufio.NewReader(conn)
r.ReadString('\n')
conn.Write([]byte("20 application/octet-stream\r\n"))
if i == 0 {
// First connection: send 500 bytes, then wait
// for the client to cancel before closing.
conn.Write(fullBody[:500])
<-cancelReady
conn.Close()
connReady <- struct{}{}
} else {
// Second connection: send the full body.
conn.Write(fullBody)
conn.Close()
}
}
}()
tmp := t.TempDir()
outPath := tmp + "/resume.bin"
proto := NewProtocol()
proto.TLSInsecure = true
u, _ := url.Parse("gemini://127.0.0.1:" + strconv.Itoa(port) + "/data")
// First attempt: the server sends 500 bytes and blocks. We cancel
// the context, then the server closes the connection.
ctx1, cancel1 := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
_, dlErr := proto.Download(ctx1, &core.DownloadRequest{
URL: u,
Output: outPath,
})
errCh <- dlErr
}()
time.Sleep(100 * time.Millisecond)
cancel1()
cancelReady <- struct{}{} // let server close connection
<-connReady
dlErr := <-errCh
if dlErr == nil {
t.Fatal("expected error from cancelled context")
}
// Verify partial file and metadata exist.
info, statErr := os.Stat(outPath)
if statErr != nil {
t.Fatalf("partial file missing: %v", statErr)
}
if info.Size() == 0 {
t.Fatal("partial file is empty")
}
meta, metaErr := output.LoadResumeMetadata(outPath)
if metaErr != nil || meta == nil {
t.Fatalf("expected resume metadata: err=%v meta=%v", metaErr, meta)
}
// Second attempt: resume.
result, resErr := proto.Download(context.Background(), &core.DownloadRequest{
URL: u,
Output: outPath,
Resume: true,
})
if resErr != nil {
t.Fatalf("resume download failed: %v", resErr)
}
if !result.Resumed {
t.Error("expected Resumed=true")
}
// The file should now contain the full body.
data, readErr := os.ReadFile(outPath)
if readErr != nil {
t.Fatalf("read output: %v", readErr)
}
if string(data) != string(fullBody) {
t.Errorf("file content length = %d, want %d", len(data), len(fullBody))
}
// Resume metadata should be deleted after successful completion.
if output.FileExists(outPath + output.ResumeMetaFileSuffix) {
t.Error("expected .goget.meta to be deleted after successful resume")
}
}
// TestSaveGeminiResumeMetadata verifies the helper function directly.
func TestSaveGeminiResumeMetadata(t *testing.T) {
tmp := t.TempDir()
out := tmp + "/file.bin"
u, err := url.Parse("gemini://example.com/file.bin")
if err != nil {
t.Fatalf("parse URL: %v", err)
}
req := &core.DownloadRequest{
URL: u,
Output: out,
}
saveGeminiResume(req, 4096)
meta, err := output.LoadResumeMetadata(out)
if err != nil {
t.Fatalf("LoadResumeMetadata: %v", err)
}
if meta == nil {
t.Fatal("expected resume metadata, got nil")
}
if meta.Downloaded != 4096 {
t.Errorf("Downloaded = %d, want 4096", meta.Downloaded)
}
if meta.URL != "gemini://example.com/file.bin" {
t.Errorf("URL = %q, want %q", meta.URL, "gemini://example.com/file.bin")
}
// Gemini doesn't expose Content-Length, so Total should be 0.
if meta.Total != 0 {
t.Errorf("Total = %d, want 0", meta.Total)
}
}
// TestSaveGeminiResumeNoop verifies that the helper is a no-op for
// stdout writes, empty output, and zero-byte transfers.
func TestSaveGeminiResumeNoop(t *testing.T) {
u, _ := url.Parse("gemini://example.com/")
t.Run("stdout", func(t *testing.T) {
saveGeminiResume(&core.DownloadRequest{URL: u, Output: "-"}, 100)
// Should not panic or create any file.
})
t.Run("empty output", func(t *testing.T) {
saveGeminiResume(&core.DownloadRequest{URL: u, Output: ""}, 100)
})
t.Run("zero bytes", func(t *testing.T) {
tmp := t.TempDir()
saveGeminiResume(&core.DownloadRequest{URL: u, Output: tmp + "/f"}, 0)
if output.FileExists(tmp + "/f" + output.ResumeMetaFileSuffix) {
t.Error("metadata created for zero-byte transfer")
}
})
t.Run("nil request", func(t *testing.T) {
saveGeminiResume(nil, 100)
// Should not panic.
})
}