2026-06-26 21:21:58 +02:00
|
|
|
|
//go:build linux || freebsd
|
|
|
|
|
|
// +build linux freebsd
|
|
|
|
|
|
|
|
|
|
|
|
package gopher
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bufio"
|
|
|
|
|
|
"context"
|
2026-06-30 14:54:52 +02:00
|
|
|
|
"fmt"
|
2026-06-26 21:21:58 +02:00
|
|
|
|
"net"
|
|
|
|
|
|
"net/url"
|
2026-06-30 18:43:01 +02:00
|
|
|
|
"os"
|
2026-06-30 14:54:52 +02:00
|
|
|
|
"runtime"
|
2026-06-26 21:21:58 +02:00
|
|
|
|
"strconv"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"testing"
|
2026-06-30 18:43:01 +02:00
|
|
|
|
"time"
|
2026-06-26 21:21:58 +02:00
|
|
|
|
|
|
|
|
|
|
"codeberg.org/petrbalvin/goget/internal/core"
|
2026-06-30 18:43:01 +02:00
|
|
|
|
"codeberg.org/petrbalvin/goget/internal/output"
|
2026-06-26 21:21:58 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
func TestFormatGopherMenu(t *testing.T) {
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
line string
|
|
|
|
|
|
expected string
|
|
|
|
|
|
}{
|
|
|
|
|
|
{"0About\t/about\tgopher.floodgap.com\t70\r\n", "📄 About [/about]"},
|
|
|
|
|
|
{"1Fun\t/fun\tgopher.floodgap.com\t70\r\n", "📁 Fun [/fun]"},
|
|
|
|
|
|
{"iWelcome to Gopher\t(null)\t(null)\t0\r\n", "ℹ️ Welcome to Gopher"},
|
|
|
|
|
|
{"hNews\t/news\tgopher.floodgap.com\t70\r\n", "🌐 News [/news]"},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, tc := range tests {
|
|
|
|
|
|
result := formatGopherMenu(tc.line)
|
|
|
|
|
|
if result != tc.expected {
|
|
|
|
|
|
t.Errorf("formatGopherMenu(%q) = %q, want %q", tc.line, result, tc.expected)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestStripGopherMeta(t *testing.T) {
|
|
|
|
|
|
result := stripGopherMeta("0About\t/about\thost\t70")
|
|
|
|
|
|
if result != "About" {
|
|
|
|
|
|
t.Errorf("got %q, want %q", result, "About")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestGopherIcon(t *testing.T) {
|
|
|
|
|
|
if i := gopherIcon('0'); i != "📄" {
|
|
|
|
|
|
t.Errorf("got %q", i)
|
|
|
|
|
|
}
|
|
|
|
|
|
if i := gopherIcon('1'); i != "📁" {
|
|
|
|
|
|
t.Errorf("got %q", i)
|
|
|
|
|
|
}
|
|
|
|
|
|
if i := gopherIcon('9'); i != "💾" {
|
|
|
|
|
|
t.Errorf("got %q", i)
|
|
|
|
|
|
}
|
|
|
|
|
|
if i := gopherIcon('h'); i != "🌐" {
|
|
|
|
|
|
t.Errorf("got %q", i)
|
|
|
|
|
|
}
|
|
|
|
|
|
if i := gopherIcon('7'); i != "🔍" {
|
|
|
|
|
|
t.Errorf("got %q", i)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestIsTextGopherType(t *testing.T) {
|
|
|
|
|
|
if !isTextGopherType('0') {
|
|
|
|
|
|
t.Error("0 should be text")
|
|
|
|
|
|
}
|
|
|
|
|
|
if isTextGopherType('9') {
|
|
|
|
|
|
t.Error("9 should not be text")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type stringWriter struct{ sb *strings.Builder }
|
|
|
|
|
|
|
|
|
|
|
|
func (w *stringWriter) Write(p []byte) (int, error) { return w.sb.Write(p) }
|
|
|
|
|
|
|
|
|
|
|
|
func TestGopherDownloadMock(t *testing.T) {
|
|
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer listener.Close()
|
|
|
|
|
|
|
|
|
|
|
|
port := listener.Addr().(*net.TCPAddr).Port
|
|
|
|
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
conn, _ := listener.Accept()
|
|
|
|
|
|
if conn == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer conn.Close()
|
|
|
|
|
|
reader := bufio.NewReader(conn)
|
|
|
|
|
|
reader.ReadString('\n') // selector
|
|
|
|
|
|
conn.Write([]byte("0Hello from gopher\t(null)\t(null)\t0\r\n"))
|
|
|
|
|
|
conn.Write([]byte(".\r\n"))
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
|
|
proto := NewProtocol()
|
|
|
|
|
|
u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/")
|
|
|
|
|
|
|
|
|
|
|
|
var buf strings.Builder
|
|
|
|
|
|
_, err = proto.Download(context.Background(), &core.DownloadRequest{
|
|
|
|
|
|
URL: u,
|
|
|
|
|
|
Writer: &stringWriter{&buf},
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(buf.String(), "Hello from gopher") {
|
|
|
|
|
|
t.Errorf("output missing text: %q", buf.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-30 18:43:01 +02:00
|
|
|
|
|
|
|
|
|
|
// TestGopherResumeSaveMetadata verifies that interrupting a Gopher text
|
|
|
|
|
|
// download persists a .goget.meta sidecar describing the partial state.
|
|
|
|
|
|
func TestGopherResumeSaveMetadata(t *testing.T) {
|
|
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer listener.Close()
|
|
|
|
|
|
port := listener.Addr().(*net.TCPAddr).Port
|
|
|
|
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
conn, _ := listener.Accept()
|
|
|
|
|
|
if conn == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer conn.Close()
|
|
|
|
|
|
reader := bufio.NewReader(conn)
|
|
|
|
|
|
reader.ReadString('\n')
|
|
|
|
|
|
// Send the first display line, sleep so the client has a chance
|
|
|
|
|
|
// to land in the read loop and receive the cancellation, then
|
|
|
|
|
|
// send the second line and the terminator.
|
|
|
|
|
|
conn.Write([]byte("hFirst\t/first\thost\t70\r\n"))
|
|
|
|
|
|
time.Sleep(150 * time.Millisecond)
|
|
|
|
|
|
conn.Write([]byte("hSecond\t/second\thost\t70\r\n"))
|
|
|
|
|
|
conn.Write([]byte(".\r\n"))
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
|
|
tmp := t.TempDir()
|
|
|
|
|
|
outPath := tmp + "/page.html"
|
|
|
|
|
|
|
|
|
|
|
|
proto := NewProtocol()
|
|
|
|
|
|
u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/page")
|
|
|
|
|
|
|
2026-06-30 11:06:48 +02:00
|
|
|
|
// progress is signalled every time the download loop calls
|
|
|
|
|
|
// req.ProgressCallback — which happens *after* a successful
|
|
|
|
|
|
// writer.Write. Reading from it is therefore a deterministic
|
|
|
|
|
|
// barrier waiting for the first byte to land on disk.
|
|
|
|
|
|
progress := make(chan int64, 1)
|
|
|
|
|
|
|
2026-06-30 18:43:01 +02:00
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
|
errCh := make(chan error, 1)
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
_, dlErr := proto.Download(ctx, &core.DownloadRequest{
|
|
|
|
|
|
URL: u,
|
|
|
|
|
|
Output: outPath,
|
2026-06-30 11:06:48 +02:00
|
|
|
|
ProgressCallback: func(current, total int64, speed float64) {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case progress <- current:
|
|
|
|
|
|
default:
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-06-30 18:43:01 +02:00
|
|
|
|
})
|
|
|
|
|
|
errCh <- dlErr
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
2026-06-30 11:06:48 +02:00
|
|
|
|
// Wait until the first byte has been written to disk, then cancel.
|
|
|
|
|
|
// No fixed sleep — this is robust against slow CI schedulers.
|
|
|
|
|
|
<-progress
|
2026-06-30 18:43:01 +02:00
|
|
|
|
cancel()
|
|
|
|
|
|
|
|
|
|
|
|
dlErr := <-errCh
|
|
|
|
|
|
if dlErr == nil {
|
|
|
|
|
|
t.Fatal("expected error from cancelled context")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
metaPath := outPath + output.ResumeMetaFileSuffix
|
|
|
|
|
|
if _, statErr := os.Stat(metaPath); statErr != nil {
|
|
|
|
|
|
t.Fatalf("expected .goget.meta sidecar to exist: %v", statErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
meta, metaErr := output.LoadResumeMetadata(outPath)
|
|
|
|
|
|
if metaErr != nil {
|
|
|
|
|
|
t.Fatalf("LoadResumeMetadata: %v", metaErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
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())
|
|
|
|
|
|
}
|
|
|
|
|
|
// Gopher does not expose Content-Length, so Total stays 0.
|
|
|
|
|
|
if meta.Total != 0 {
|
|
|
|
|
|
t.Errorf("Total = %d, want 0", meta.Total)
|
|
|
|
|
|
}
|
|
|
|
|
|
// Sanity-check: the saved Downloaded should match what's on disk.
|
|
|
|
|
|
info, statErr := os.Stat(outPath)
|
|
|
|
|
|
if statErr != nil {
|
|
|
|
|
|
t.Fatalf("partial file missing: %v", statErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
if info.Size() != meta.Downloaded {
|
|
|
|
|
|
t.Errorf("file size = %d, metadata.Downloaded = %d (must match)",
|
|
|
|
|
|
info.Size(), meta.Downloaded)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestGopherResumeDownload verifies end-to-end resume: an interrupted
|
|
|
|
|
|
// download completes on the second attempt with --resume=true, and the
|
|
|
|
|
|
// metadata sidecar is removed after the successful completion.
|
|
|
|
|
|
func TestGopherResumeDownload(t *testing.T) {
|
|
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer listener.Close()
|
|
|
|
|
|
port := listener.Addr().(*net.TCPAddr).Port
|
|
|
|
|
|
|
|
|
|
|
|
cancelReady := make(chan struct{})
|
|
|
|
|
|
connReady := make(chan struct{})
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
for i := 0; i < 2; i++ {
|
|
|
|
|
|
conn, lerr := listener.Accept()
|
|
|
|
|
|
if lerr != nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
r := bufio.NewReader(conn)
|
|
|
|
|
|
r.ReadString('\n')
|
|
|
|
|
|
if i == 0 {
|
|
|
|
|
|
// First attempt: send the first line, then wait
|
|
|
|
|
|
// for the client to cancel before closing.
|
|
|
|
|
|
conn.Write([]byte("0Line one\t/foo\thost\t70\r\n"))
|
|
|
|
|
|
<-cancelReady
|
|
|
|
|
|
conn.Close()
|
|
|
|
|
|
connReady <- struct{}{}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Second attempt: send the full body.
|
|
|
|
|
|
conn.Write([]byte("0Line one\t/foo\thost\t70\r\n"))
|
|
|
|
|
|
conn.Write([]byte("0Line two\t/bar\thost\t70\r\n"))
|
|
|
|
|
|
conn.Write([]byte(".\r\n"))
|
|
|
|
|
|
conn.Close()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
|
|
tmp := t.TempDir()
|
|
|
|
|
|
outPath := tmp + "/resume.txt"
|
|
|
|
|
|
|
|
|
|
|
|
proto := NewProtocol()
|
|
|
|
|
|
u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/foo")
|
|
|
|
|
|
|
|
|
|
|
|
// First attempt: send only the first line, then cancel.
|
2026-06-30 11:06:48 +02:00
|
|
|
|
// progress signals after the first writer.Write, so waiting on
|
|
|
|
|
|
// it replaces the previous fixed sleep with a deterministic barrier.
|
|
|
|
|
|
progress := make(chan int64, 1)
|
2026-06-30 18:43:01 +02:00
|
|
|
|
ctx1, cancel1 := context.WithCancel(context.Background())
|
|
|
|
|
|
errCh := make(chan error, 1)
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
_, dlErr := proto.Download(ctx1, &core.DownloadRequest{
|
|
|
|
|
|
URL: u,
|
|
|
|
|
|
Output: outPath,
|
2026-06-30 11:06:48 +02:00
|
|
|
|
ProgressCallback: func(current, total int64, speed float64) {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case progress <- current:
|
|
|
|
|
|
default:
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-06-30 18:43:01 +02:00
|
|
|
|
})
|
|
|
|
|
|
errCh <- dlErr
|
|
|
|
|
|
}()
|
2026-06-30 11:06:48 +02:00
|
|
|
|
<-progress
|
2026-06-30 18:43:01 +02:00
|
|
|
|
cancel1()
|
|
|
|
|
|
cancelReady <- struct{}{}
|
|
|
|
|
|
<-connReady
|
|
|
|
|
|
dlErr := <-errCh
|
|
|
|
|
|
if dlErr == nil {
|
|
|
|
|
|
t.Fatal("expected error from cancelled context")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Sanity-check: partial file + metadata sidecar present.
|
|
|
|
|
|
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 should pick up where we left off.
|
|
|
|
|
|
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 contain the full body, with the resume logic
|
|
|
|
|
|
// skipping already-written bytes via the post-transform offset.
|
|
|
|
|
|
data, readErr := os.ReadFile(outPath)
|
|
|
|
|
|
if readErr != nil {
|
|
|
|
|
|
t.Fatalf("read output: %v", readErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
want := "Line one\nLine two\n"
|
|
|
|
|
|
if string(data) != want {
|
|
|
|
|
|
t.Errorf("file content = %q, want %q", string(data), want)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Metadata sidecar should be deleted after a successful completion.
|
|
|
|
|
|
if output.FileExists(outPath + output.ResumeMetaFileSuffix) {
|
|
|
|
|
|
t.Error("expected .goget.meta to be deleted after successful resume")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestSaveGopherResumeMetadata exercises the helper directly.
|
|
|
|
|
|
func TestSaveGopherResumeMetadata(t *testing.T) {
|
|
|
|
|
|
tmp := t.TempDir()
|
|
|
|
|
|
out := tmp + "/file.txt"
|
|
|
|
|
|
|
|
|
|
|
|
u, err := url.Parse("gopher://example.com/foo.txt")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("parse URL: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
req := &core.DownloadRequest{
|
|
|
|
|
|
URL: u,
|
|
|
|
|
|
Output: out,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
saveGopherResume(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 != "gopher://example.com/foo.txt" {
|
|
|
|
|
|
t.Errorf("URL = %q, want %q", meta.URL, "gopher://example.com/foo.txt")
|
|
|
|
|
|
}
|
|
|
|
|
|
if meta.Total != 0 {
|
|
|
|
|
|
t.Errorf("Total = %d, want 0", meta.Total)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestSaveGopherResumeNoop verifies that the helper is a no-op for
|
|
|
|
|
|
// stdout writes, empty output paths, zero-byte transfers, and nil
|
|
|
|
|
|
// requests.
|
|
|
|
|
|
func TestSaveGopherResumeNoop(t *testing.T) {
|
|
|
|
|
|
u, _ := url.Parse("gopher://example.com/")
|
|
|
|
|
|
|
|
|
|
|
|
t.Run("stdout", func(t *testing.T) {
|
|
|
|
|
|
saveGopherResume(&core.DownloadRequest{URL: u, Output: "-"}, 100)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
t.Run("empty output", func(t *testing.T) {
|
|
|
|
|
|
saveGopherResume(&core.DownloadRequest{URL: u, Output: ""}, 100)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
t.Run("zero bytes", func(t *testing.T) {
|
|
|
|
|
|
tmp := t.TempDir()
|
|
|
|
|
|
saveGopherResume(&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) {
|
|
|
|
|
|
saveGopherResume(nil, 100)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestGopherResumeStaleMetadata exercises the path where the .goget.meta
|
|
|
|
|
|
// sidecar exists but the partial file doesn't (or has been truncated),
|
|
|
|
|
|
// forcing the resume code to clean up and start fresh.
|
|
|
|
|
|
func TestGopherResumeStaleMetadata(t *testing.T) {
|
|
|
|
|
|
tmp := t.TempDir()
|
|
|
|
|
|
outPath := tmp + "/stale.txt"
|
|
|
|
|
|
|
|
|
|
|
|
// Pre-create an orphan .goget.meta for a URL we will not use.
|
|
|
|
|
|
orphanMeta := output.NewResumeMetadata("gopher://other.example/foo", "", "", 1234, 0)
|
|
|
|
|
|
if err := orphanMeta.Save(outPath); err != nil {
|
|
|
|
|
|
t.Fatalf("seed meta: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
// Do not pre-create the partial file; CanResume requires file size
|
|
|
|
|
|
// to match metadata Downloaded.
|
|
|
|
|
|
|
|
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer listener.Close()
|
|
|
|
|
|
port := listener.Addr().(*net.TCPAddr).Port
|
|
|
|
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
conn, _ := listener.Accept()
|
|
|
|
|
|
if conn == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer conn.Close()
|
|
|
|
|
|
r := bufio.NewReader(conn)
|
|
|
|
|
|
r.ReadString('\n')
|
|
|
|
|
|
conn.Write([]byte("0Fresh\t/fresh\thost\t70\r\n.\r\n"))
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
|
|
proto := NewProtocol()
|
|
|
|
|
|
u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/fresh")
|
|
|
|
|
|
|
|
|
|
|
|
res, err := proto.Download(context.Background(), &core.DownloadRequest{
|
|
|
|
|
|
URL: u,
|
|
|
|
|
|
Output: outPath,
|
|
|
|
|
|
Resume: true,
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("download: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if res.Resumed {
|
|
|
|
|
|
t.Error("expected Resumed=false after stale-metadata cleanup")
|
|
|
|
|
|
}
|
|
|
|
|
|
data, _ := os.ReadFile(outPath)
|
|
|
|
|
|
if string(data) != "Fresh\n" {
|
|
|
|
|
|
t.Errorf("content = %q, want %q", string(data), "Fresh\n")
|
|
|
|
|
|
}
|
2026-06-30 11:32:09 +02:00
|
|
|
|
// The orphan sidecar from a different URL must be removed once the
|
|
|
|
|
|
// successful fresh download completes — otherwise stale metadata
|
|
|
|
|
|
// could be picked up by a future --resume run.
|
|
|
|
|
|
if output.FileExists(outPath + output.ResumeMetaFileSuffix) {
|
|
|
|
|
|
t.Error("expected orphan .goget.meta to be deleted after fresh download")
|
|
|
|
|
|
}
|
2026-06-30 18:43:01 +02:00
|
|
|
|
}
|
2026-06-30 11:07:46 +02:00
|
|
|
|
|
|
|
|
|
|
// TestGopherResumeIgnoredWhenWriterSet verifies that --resume is silently
|
|
|
|
|
|
// disabled when the caller also supplies a Writer: the resume metadata
|
|
|
|
|
|
// sidecar would otherwise lie about bytes written to Output, since the
|
|
|
|
|
|
// real data ends up in the Writer.
|
|
|
|
|
|
func TestGopherResumeIgnoredWhenWriterSet(t *testing.T) {
|
|
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer listener.Close()
|
|
|
|
|
|
port := listener.Addr().(*net.TCPAddr).Port
|
|
|
|
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
conn, _ := listener.Accept()
|
|
|
|
|
|
if conn == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer conn.Close()
|
|
|
|
|
|
r := bufio.NewReader(conn)
|
|
|
|
|
|
r.ReadString('\n')
|
|
|
|
|
|
conn.Write([]byte("0Line one\t/foo\thost\t70\r\n.\r\n"))
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
|
|
proto := NewProtocol()
|
|
|
|
|
|
u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/foo")
|
|
|
|
|
|
outPath := t.TempDir() + "/ignored-output.txt"
|
|
|
|
|
|
|
|
|
|
|
|
var buf strings.Builder
|
|
|
|
|
|
res, dlErr := proto.Download(context.Background(), &core.DownloadRequest{
|
|
|
|
|
|
URL: u,
|
|
|
|
|
|
Output: outPath,
|
|
|
|
|
|
Writer: &stringWriter{&buf},
|
|
|
|
|
|
Resume: true,
|
|
|
|
|
|
})
|
|
|
|
|
|
if dlErr != nil {
|
|
|
|
|
|
t.Fatalf("download: %v", dlErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
if res.Resumed {
|
|
|
|
|
|
t.Error("expected Resumed=false when Writer is set (resume must be ignored)")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(buf.String(), "Line one") {
|
|
|
|
|
|
t.Errorf("writer should receive the body, got %q", buf.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
// Output is supplied but a Writer takes precedence; the file must
|
|
|
|
|
|
// not be created or touched by resume machinery.
|
|
|
|
|
|
if _, statErr := os.Stat(outPath); statErr == nil {
|
|
|
|
|
|
t.Error("Output file should not be created when Writer is set")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-30 11:55:52 +02:00
|
|
|
|
|
|
|
|
|
|
// TestGopherResumeInformation verifies that resume machinery works for the
|
|
|
|
|
|
// informational (type i) response path, which is also routed through
|
|
|
|
|
|
// downloadText. Informational lines are passed through verbatim (no
|
|
|
|
|
|
// metadata strip), so the resume offset matches the raw line length
|
|
|
|
|
|
// including trailing CR/LF.
|
|
|
|
|
|
func TestGopherResumeInformation(t *testing.T) {
|
|
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer listener.Close()
|
|
|
|
|
|
port := listener.Addr().(*net.TCPAddr).Port
|
|
|
|
|
|
|
|
|
|
|
|
cancelReady := make(chan struct{})
|
|
|
|
|
|
connReady := make(chan struct{})
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
for i := 0; i < 2; i++ {
|
|
|
|
|
|
conn, lerr := listener.Accept()
|
|
|
|
|
|
if lerr != nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
r := bufio.NewReader(conn)
|
|
|
|
|
|
r.ReadString('\n')
|
|
|
|
|
|
if i == 0 {
|
|
|
|
|
|
// First half: send two info lines, then wait
|
|
|
|
|
|
// for the client to cancel before closing.
|
|
|
|
|
|
conn.Write([]byte("iFirst line.\r\n"))
|
|
|
|
|
|
conn.Write([]byte("iSecond line.\r\n"))
|
|
|
|
|
|
<-cancelReady
|
|
|
|
|
|
conn.Close()
|
|
|
|
|
|
connReady <- struct{}{}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Second half: full body.
|
|
|
|
|
|
conn.Write([]byte("iFirst line.\r\n"))
|
|
|
|
|
|
conn.Write([]byte("iSecond line.\r\n"))
|
|
|
|
|
|
conn.Write([]byte(".\r\n"))
|
|
|
|
|
|
conn.Close()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
|
|
tmp := t.TempDir()
|
|
|
|
|
|
outPath := tmp + "/info.txt"
|
|
|
|
|
|
|
|
|
|
|
|
proto := NewProtocol()
|
|
|
|
|
|
u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/info")
|
|
|
|
|
|
|
|
|
|
|
|
progress := make(chan int64, 1)
|
|
|
|
|
|
ctx1, cancel1 := context.WithCancel(context.Background())
|
|
|
|
|
|
errCh := make(chan error, 1)
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
_, dlErr := proto.Download(ctx1, &core.DownloadRequest{
|
|
|
|
|
|
URL: u,
|
|
|
|
|
|
Output: outPath,
|
|
|
|
|
|
ProgressCallback: func(current, total int64, speed float64) {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case progress <- current:
|
|
|
|
|
|
default:
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
errCh <- dlErr
|
|
|
|
|
|
}()
|
|
|
|
|
|
<-progress
|
|
|
|
|
|
cancel1()
|
|
|
|
|
|
cancelReady <- struct{}{}
|
|
|
|
|
|
<-connReady
|
|
|
|
|
|
if dlErr := <-errCh; dlErr == nil {
|
|
|
|
|
|
t.Fatal("expected error from cancelled context")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Resume should append the missing bytes. For informational lines
|
|
|
|
|
|
// we do not strip metadata, so the output preserves the server CR/LF.
|
|
|
|
|
|
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")
|
|
|
|
|
|
}
|
|
|
|
|
|
if result.ContentType != "text/plain" {
|
|
|
|
|
|
t.Errorf("ContentType = %q, want text/plain", result.ContentType)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
data, _ := os.ReadFile(outPath)
|
|
|
|
|
|
// Informational lines are passed through verbatim: the 'i' item-type
|
|
|
|
|
|
// prefix and CR/LF framing from the server survive into the output.
|
|
|
|
|
|
want := "iFirst line.\r\niSecond line.\r\n"
|
|
|
|
|
|
if string(data) != want {
|
|
|
|
|
|
t.Errorf("content = %q, want %q", string(data), want)
|
|
|
|
|
|
}
|
|
|
|
|
|
if output.FileExists(outPath + output.ResumeMetaFileSuffix) {
|
|
|
|
|
|
t.Error("expected .goget.meta to be deleted after successful resume")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-30 14:54:52 +02:00
|
|
|
|
|
|
|
|
|
|
// TestGopherCancelGoroutineExits verifies that the ctx-cancellation
|
|
|
|
|
|
// goroutine spawned by Download does not outlive Download itself.
|
|
|
|
|
|
// The goroutine used to wait forever on <-ctx.Done() when the parent
|
|
|
|
|
|
// context was never cancelled; the done-channel bound added with M3
|
|
|
|
|
|
// must let every iteration return to the runtime goroutine pool.
|
|
|
|
|
|
func TestGopherCancelGoroutineExits(t *testing.T) {
|
|
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer listener.Close()
|
|
|
|
|
|
port := listener.Addr().(*net.TCPAddr).Port
|
|
|
|
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
// Drain connections so the listener does not backpressure
|
|
|
|
|
|
// the test loop. We do not read or write — a quick accept
|
|
|
|
|
|
// and close is enough for Download to complete.
|
|
|
|
|
|
for {
|
|
|
|
|
|
conn, lerr := listener.Accept()
|
|
|
|
|
|
if lerr != nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
go func(c net.Conn) {
|
|
|
|
|
|
// Reply with the response header so the client
|
|
|
|
|
|
// reaches downloadText and spawns its goroutine.
|
|
|
|
|
|
defer c.Close()
|
|
|
|
|
|
fmt.Fprintf(c, "0hi\t/x\th\t70\r\n.\r\n")
|
|
|
|
|
|
// Block until the peer closes — this lets the
|
|
|
|
|
|
// client-side goroutine sit on ReadString until
|
|
|
|
|
|
// it's raced by done, exercising the bound.
|
|
|
|
|
|
buf := make([]byte, 1)
|
|
|
|
|
|
c.Read(buf)
|
|
|
|
|
|
}(conn)
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
|
|
// Warm up: a single download is enough to settle the runtime
|
|
|
|
|
|
// goroutine pool (timer, GC, poll, etc.) before we measure.
|
|
|
|
|
|
proto := NewProtocol()
|
|
|
|
|
|
u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/x")
|
|
|
|
|
|
req := core.DownloadRequest{URL: u, Writer: &stringWriter{&strings.Builder{}}}
|
|
|
|
|
|
if _, dlErr := proto.Download(context.Background(), &req); dlErr != nil {
|
|
|
|
|
|
t.Fatalf("warmup: %v", dlErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Settle baseline after warmup.
|
|
|
|
|
|
runtime.GC()
|
|
|
|
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
|
|
runtime.GC()
|
|
|
|
|
|
baseline := runtime.NumGoroutine()
|
|
|
|
|
|
|
|
|
|
|
|
const iterations = 50
|
|
|
|
|
|
for i := 0; i < iterations; i++ {
|
|
|
|
|
|
if _, dlErr := proto.Download(context.Background(), &req); dlErr != nil {
|
|
|
|
|
|
t.Fatalf("download %d: %v", i, dlErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// After every successful Download returns, its cancellation
|
|
|
|
|
|
// goroutine must have exited. Allow a brief settle window for
|
|
|
|
|
|
// the runtime to reclaim them.
|
|
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
|
|
|
|
for time.Now().Before(deadline) {
|
|
|
|
|
|
runtime.GC()
|
|
|
|
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
|
|
// One tolerance for any incidental stdlib goroutine that
|
|
|
|
|
|
// the Go runtime occasionally spins up between calls.
|
|
|
|
|
|
if runtime.NumGoroutine() <= baseline+1 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
t.Fatalf("goroutine leak: baseline=%d, after %d downloads = %d",
|
|
|
|
|
|
baseline, iterations, runtime.NumGoroutine())
|
|
|
|
|
|
}
|