feat: add WebView support for extension (#1359)

This commit is contained in:
Levi authored and GitHub committed 2026-04-24 22:16:33 +08:00
1 parent a205c0b8d7
commit def54f8998
92 files changed
+10248 -2676

No files matched your search

+75 -23
View File
@@ -15,7 +15,8 @@ The `gblob` protocol provides an internal object-URL style transport so extensio
The extension-facing model is intentionally close to the browser:
- `URL.createObjectURL(new Blob(...))`
- `URL.createObjectURL(writableStream)`
- `URL.createObjectURL(readableStream)`
- `URL.createObjectURL(openReadable)`
- `URL.revokeObjectURL(url)`
The returned URL looks like:
@@ -29,7 +30,7 @@ gblob:<id>
## Design Goals
- make extension-generated data download as a normal task
- support both eager `Blob` data and incremental `WritableStream` data
- support eager `Blob` data, one-shot `ReadableStream` data, and resumable opener functions
- avoid large in-memory buffering by spooling to disk
- keep pause/continue behavior intuitive
- allow an extension to recreate a new `gblob` URL after source expiration
@@ -43,7 +44,7 @@ The implementation has two layers in this directory:
The end-to-end flow is:
1. JavaScript creates a `Blob` or `WritableStream` object URL.
1. JavaScript creates a `Blob`, `ReadableStream`, or opener-function object URL.
2. Go registers a `gblob` source and assigns it a unique `gblob:<id>` URL.
3. Source bytes are written into a local spool file.
4. The downloader resolves the `gblob` URL through the `gblob` fetcher.
@@ -67,7 +68,8 @@ Each source tracks:
Key behaviors:
- `Blob` sources are written once and immediately closed.
- `WritableStream` sources stay open until the writer closes or aborts.
- `ReadableStream` sources are bridged into the internal writable-source pipeline.
- opener-function sources are opened lazily and can be reopened from an offset.
- readers wait on a per-source notification channel when no new bytes are available yet.
- revocation only prevents future use; cleanup happens when the source is no longer pinned.
@@ -82,9 +84,9 @@ The fetcher treats `gblob` as a local file-backed download source.
Behavior differences by source type:
- `Blob`: resolved size is the current written size
- `WritableStream`: resolved size is reported as `0` until the writer closes
- `ReadableStream` / opener function: resolved size is whatever the extension declared; if unknown, it stays `0` until download completion updates the final size
`Range` is always `false`.
`Range` is `true` only when the extension provides a resumable opener and marks the resource as ranged.
### Start
@@ -123,7 +125,12 @@ For the same `gblob` id:
- the partially downloaded output file is kept
- the fetcher resumes from the current file size
- `WritableStream` producers may continue writing while the task is paused
- opener-function sources can be reopened from the current offset
For non-reopenable one-shot `ReadableStream` sources:
- a normal in-process pause/continue only works while the same in-memory source is still alive
- they are not suitable for cross-session or reconstructable range resume
### Process restart
@@ -172,19 +179,19 @@ gopeed.events.onResolve(async function (ctx) {
});
```
### 2. Create a streaming object URL
### 2. Create a one-shot `ReadableStream` object URL
```js
gopeed.events.onResolve(async function (ctx) {
const stream = new TransformStream();
const writer = stream.writable.getWriter();
const url = URL.createObjectURL(stream.writable);
(async () => {
await writer.write(new TextEncoder().encode("line 1\n"));
await writer.write(new TextEncoder().encode("line 2\n"));
await writer.close();
})();
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode("line 1\n"));
controller.enqueue(encoder.encode("line 2\n"));
controller.close();
}
});
const url = URL.createObjectURL(stream);
ctx.res = {
name: "stream-example",
@@ -198,14 +205,53 @@ gopeed.events.onResolve(async function (ctx) {
});
```
### 3. Recover in `onError`
### 3. Create a resumable opener object URL
```js
gopeed.events.onResolve(async function (ctx) {
const target = "https://example.com/file.bin";
const openReadable = async (offset) => {
const headers = {};
if (offset > 0) {
headers.Range = `bytes=${offset}-`;
}
const response = await fetch(target, { headers });
if (response.status !== 200 || response.status !== 206) {
throw new Error(`unexpected response status: ${response.status}, expected ${expectedStatus}`);
}
if (!response.body) {
throw new Error("empty response body");
}
return response.body;
};
ctx.res = {
name: "range-example",
range: true,
files: [
{
name: "file.bin",
req: { url: URL.createObjectURL(openReadable) }
}
]
};
});
```
Important:
- opener functions must return a `ReadableStream`
- do not return `response.body.getReader()`
- use opener functions for resumable / range-aware sources
- validate HTTP status codes explicitly:
first request usually expects `200`, resumed range requests usually expect `206`
### 4. Recover in `onError`
```js
gopeed.events.onError(async function (ctx) {
const req = ctx.task.meta.req;
if (!req.rawUrl) return;
if (!req.url.startsWith("gblob:")) return;
req.url = URL.createObjectURL(new Blob(["recovered\n"]));
ctx.task.continue();
});
@@ -215,11 +261,11 @@ This creates a new `gblob` id, so the downloader restarts from byte `0`.
## Limitations
- `gblob` is not a network protocol and does not support range requests.
- `gblob` is not a network protocol. Range support only exists when the extension recreates bytes through a resumable opener.
- unfinished `gblob` sources do not survive process restarts.
- recovery after restart depends on extension logic recreating a new source.
- the current restart behavior is full restart on source-id change, not byte-range recovery across recreated sources.
- `WritableStream` tasks with unknown total size report size `0` until the writer closes.
- one-shot `ReadableStream` tasks with unknown total size report size `0` until completion updates the final size.
## Summary
@@ -231,3 +277,9 @@ The most important rule is simple:
- same `gblob` id means resume
- different `gblob` id means restart
And for extension authors:
- use `ReadableStream` for one-shot streaming content
- use opener functions for resumable content
- opener functions must return `ReadableStream`, not a reader
+91 -53
View File
@@ -62,15 +62,21 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
if name == "" {
name = src.ID
}
size := src.Snapshot().Written
snapshot := src.Snapshot()
size := snapshot.Written
if snapshot.DeclaredSize > 0 {
size = snapshot.DeclaredSize
}
if src.Type == SourceTypeWritableStream {
// Writable streams may continue producing bytes after Resolve,
// so treat the total size as unknown until the writer closes.
size = 0
if snapshot.DeclaredSize == 0 {
size = 0
}
}
f.meta.Res = &base.Resource{
Size: size,
Range: false,
Range: snapshot.Range,
Files: []*base.FileInfo{
{
Name: name,
@@ -102,45 +108,90 @@ func (f *Fetcher) Start() error {
f.cancel = cancel
f.runDone = runDone
f.mu.Unlock()
cleanupStartErr := func(err error) error {
f.mu.Lock()
if f.runDone == runDone {
f.runDone = nil
f.cancel = nil
}
f.mu.Unlock()
return err
}
path := f.meta.SingleFilepath()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
return cleanupStartErr(err)
}
target, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
return cleanupStartErr(err)
}
offset := int64(0)
if f.lastSourceID != "" && f.lastSourceID != currentSourceID {
allowResumeAcrossSource := f.meta.Res != nil && f.meta.Res.Range
if f.lastSourceID != "" && f.lastSourceID != currentSourceID && !allowResumeAcrossSource {
if err := target.Truncate(0); err != nil {
target.Close()
return err
return cleanupStartErr(err)
}
if _, err := target.Seek(0, io.SeekStart); err != nil {
target.Close()
return err
return cleanupStartErr(err)
}
} else {
stat, err := target.Stat()
if err != nil {
target.Close()
return err
return cleanupStartErr(err)
}
offset = stat.Size()
if _, err := target.Seek(offset, io.SeekStart); err != nil {
target.Close()
return err
return cleanupStartErr(err)
}
}
f.lastSourceID = currentSourceID
f.downloaded.Store(offset)
go f.copyLoop(ctx, target, offset)
sourceOffset := offset
src, err := f.registry.Get(f.meta.Req.URL)
if err != nil {
target.Close()
return cleanupStartErr(err)
}
snapshot := src.Snapshot()
if snapshot.Written == 0 && !snapshot.Started {
if f.meta.Res != nil && f.meta.Res.Range && offset > 0 {
if err := f.registry.Reopen(f.meta.Req.URL, offset); err != nil {
target.Close()
return cleanupStartErr(err)
}
sourceOffset = 0
} else if err := f.registry.StartSource(f.meta.Req.URL); err != nil {
target.Close()
return cleanupStartErr(err)
}
src, err = f.registry.Get(f.meta.Req.URL)
if err != nil {
target.Close()
return cleanupStartErr(err)
}
snapshot = src.Snapshot()
}
if f.meta.Res != nil && f.meta.Res.Range && (snapshot.State == SourceStateAborted || snapshot.State == SourceStateFailed) {
if err := f.registry.Reopen(f.meta.Req.URL, offset); err != nil {
target.Close()
return cleanupStartErr(err)
}
sourceOffset = 0
}
go f.copyLoop(ctx, target, offset, sourceOffset)
return nil
}
func (f *Fetcher) copyLoop(ctx context.Context, target *os.File, offset int64) {
func (f *Fetcher) copyLoop(ctx context.Context, target *os.File, targetOffset, sourceOffset int64) {
var finalErr error
defer target.Close()
defer func() {
f.mu.Lock()
@@ -161,85 +212,75 @@ func (f *Fetcher) copyLoop(ctx context.Context, target *os.File, offset int64) {
if paused {
return
}
select {
case f.doneCh <- finalErr:
default:
}
}()
src, err := f.registry.Get(f.meta.Req.URL)
if err != nil {
select {
case f.doneCh <- err:
default:
}
finalErr = err
return
}
sourceFile, err := os.Open(src.Path)
if err != nil {
select {
case f.doneCh <- err:
default:
}
finalErr = err
return
}
defer sourceFile.Close()
buf := make([]byte, 32*1024)
for {
if err := f.registry.WaitForReadable(ctx, f.meta.Req.URL, offset); err != nil {
if err := f.registry.WaitForReadable(ctx, f.meta.Req.URL, sourceOffset); err != nil {
if errors.Is(err, context.Canceled) {
return
}
select {
case f.doneCh <- err:
default:
}
finalErr = err
return
}
snapshot, serr := f.registry.Get(f.meta.Req.URL)
if serr != nil {
select {
case f.doneCh <- serr:
default:
}
finalErr = serr
return
}
current := snapshot.Snapshot()
for offset < current.Written {
remain := current.Written - offset
for sourceOffset < current.Written {
remain := current.Written - sourceOffset
readSize := int64(len(buf))
if remain < readSize {
readSize = remain
}
n, err := sourceFile.ReadAt(buf[:readSize], offset)
n, err := sourceFile.ReadAt(buf[:readSize], sourceOffset)
if err != nil && !errors.Is(err, io.EOF) {
select {
case f.doneCh <- err:
default:
}
finalErr = err
return
}
if n == 0 {
break
}
if _, err := target.Write(buf[:n]); err != nil {
select {
case f.doneCh <- err:
default:
}
finalErr = err
return
}
offset += int64(n)
f.downloaded.Store(offset)
sourceOffset += int64(n)
targetOffset += int64(n)
f.downloaded.Store(targetOffset)
}
if current.State == SourceStateClosed && offset >= current.Written {
if current.State == SourceStateClosed && sourceOffset >= current.Written {
if f.meta.Res != nil {
f.meta.Res.Size = current.Written
f.meta.Res.Files[0].Size = current.Written
}
select {
case f.doneCh <- nil:
default:
if f.meta.Res.Size > 0 && targetOffset < f.meta.Res.Size {
finalErr = io.ErrUnexpectedEOF
return
}
if f.meta.Res.Size == 0 {
f.meta.Res.Size = targetOffset
}
f.meta.Res.Files[0].Size = f.meta.Res.Size
}
finalErr = nil
return
}
if current.State == SourceStateAborted || current.State == SourceStateFailed {
@@ -247,10 +288,7 @@ func (f *Fetcher) copyLoop(ctx context.Context, target *os.File, offset int64) {
if err == nil {
err = ErrSourceAborted
}
select {
case f.doneCh <- err:
default:
}
finalErr = err
return
}
}
+122
View File
@@ -0,0 +1,122 @@
package gblob
import (
"errors"
"io"
"os"
"path/filepath"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/controller"
internalfetcher "github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
)
func TestFetcherClosedSourceBeforeExpectedSizeReturnsError(t *testing.T) {
registry := NewRegistry(t.TempDir())
url, err := registry.CreateWritableStream(&CreateWritableStreamOptions{})
if err != nil {
t.Fatal(err)
}
if err := registry.Write(url, []byte("abc")); err != nil {
t.Fatal(err)
}
if err := registry.CloseSource(url); err != nil {
t.Fatal(err)
}
downloadDir := t.TempDir()
filePath := filepath.Join(downloadDir, "partial.bin")
f := &Fetcher{
registry: registry,
meta: &internalfetcher.FetcherMeta{
Req: &base.Request{URL: url},
Res: &base.Resource{
Size: 10,
Range: true,
Files: []*base.FileInfo{{Name: "partial.bin", Req: &base.Request{URL: url}}},
},
Opts: &base.Options{Path: downloadDir, Name: "partial.bin"},
},
}
f.Setup(controller.NewController())
if err := f.Start(); err != nil {
t.Fatal(err)
}
select {
case err := <-f.doneCh:
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("expected unexpected EOF, got %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for fetcher completion")
}
data, err := os.ReadFile(filePath)
if err != nil {
t.Fatal(err)
}
if string(data) != "abc" {
t.Fatalf("unexpected partial file content: %q", string(data))
}
}
func TestFetcherClosedUnknownSizeSourceCompletesSuccessfully(t *testing.T) {
registry := NewRegistry(t.TempDir())
url, err := registry.CreateWritableStream(&CreateWritableStreamOptions{})
if err != nil {
t.Fatal(err)
}
if err := registry.Write(url, []byte("abcdef")); err != nil {
t.Fatal(err)
}
if err := registry.CloseSource(url); err != nil {
t.Fatal(err)
}
downloadDir := t.TempDir()
filePath := filepath.Join(downloadDir, "stream.bin")
f := &Fetcher{
registry: registry,
meta: &internalfetcher.FetcherMeta{
Req: &base.Request{URL: url},
Res: &base.Resource{
Size: 0,
Range: false,
Files: []*base.FileInfo{{Name: "stream.bin", Req: &base.Request{URL: url}}},
},
Opts: &base.Options{Path: downloadDir, Name: "stream.bin"},
},
}
f.Setup(controller.NewController())
if err := f.Start(); err != nil {
t.Fatal(err)
}
select {
case err := <-f.doneCh:
if err != nil {
t.Fatalf("expected nil completion error, got %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for fetcher completion")
}
data, err := os.ReadFile(filePath)
if err != nil {
t.Fatal(err)
}
if string(data) != "abcdef" {
t.Fatalf("unexpected stream file content: %q", string(data))
}
if got := f.meta.Res.Size; got != int64(len(data)) {
t.Fatalf("unexpected resolved size: got %d want %d", got, len(data))
}
if got := f.meta.Res.Files[0].Size; got != int64(len(data)) {
t.Fatalf("unexpected resolved file size: got %d want %d", got, len(data))
}
}
+193 -23
View File
@@ -42,11 +42,21 @@ type SessionRef interface {
Release()
}
type ResumeFunc func(offset int64) error
type CreateWritableStreamOptions struct {
Session SessionRef
Reopenable bool
}
type Snapshot struct {
Written int64
State SourceState
Err error
WaitCh <-chan struct{}
Written int64
DeclaredSize int64
Range bool
Started bool
State SourceState
Err error
WaitCh <-chan struct{}
}
type Source struct {
@@ -66,16 +76,24 @@ type Source struct {
waitCh chan struct{}
session SessionRef
sessionReleased bool
reopenable bool
rangeEnabled bool
declaredSize int64
started bool
reopen ResumeFunc
}
func (s *Source) Snapshot() Snapshot {
s.mu.Lock()
defer s.mu.Unlock()
return Snapshot{
Written: s.written,
State: s.state,
Err: s.err,
WaitCh: s.waitCh,
Written: s.written,
DeclaredSize: s.declaredSize,
Range: s.rangeEnabled,
Started: s.started,
State: s.state,
Err: s.err,
WaitCh: s.waitCh,
}
}
@@ -122,6 +140,13 @@ func (s *Source) releaseSessionLocked() {
}
}
func (s *Source) retainSessionLocked() {
if s.session != nil && s.sessionReleased {
s.session.Retain()
s.sessionReleased = false
}
}
func (s *Source) write(data []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -146,7 +171,9 @@ func (s *Source) write(data []byte) error {
s.err = err
s.file.Close()
s.file = nil
s.releaseSessionLocked()
if !s.rangeEnabled {
s.releaseSessionLocked()
}
s.notifyLocked()
return err
}
@@ -187,7 +214,9 @@ func (s *Source) abort(err error) error {
_ = s.file.Close()
s.file = nil
}
s.releaseSessionLocked()
if !s.rangeEnabled {
s.releaseSessionLocked()
}
s.notifyLocked()
return nil
}
@@ -196,6 +225,18 @@ func (s *Source) revoke() bool {
s.mu.Lock()
defer s.mu.Unlock()
s.revoked = true
if s.state == SourceStateOpen {
s.state = SourceStateAborted
if s.err == nil {
s.err = ErrSourceRevoked
}
if s.file != nil {
_ = s.file.Close()
s.file = nil
}
s.releaseSessionLocked()
s.notifyLocked()
}
return s.shouldCleanupLocked()
}
@@ -229,6 +270,13 @@ type Registry struct {
sources map[string]*Source
}
func (r *Registry) Dir() string {
if r == nil {
return ""
}
return r.dir
}
func NewRegistry(baseDir string) *Registry {
dir := baseDir
if dir == "" {
@@ -258,7 +306,7 @@ func ParseURL(raw string) (string, error) {
return id, nil
}
func (r *Registry) createSource(sourceType SourceType, contentType string, session SessionRef) (*Source, error) {
func (r *Registry) createSource(sourceType SourceType, contentType string, session SessionRef, reopenable bool, declaredSize int64) (*Source, error) {
if err := os.MkdirAll(r.dir, 0755); err != nil {
return nil, err
}
@@ -275,15 +323,19 @@ func (r *Registry) createSource(sourceType SourceType, contentType string, sessi
session.Retain()
}
src := &Source{
ID: id,
URL: BuildURL(id),
Path: filePath,
Type: sourceType,
ContentType: contentType,
file: file,
state: SourceStateOpen,
waitCh: make(chan struct{}),
session: session,
ID: id,
URL: BuildURL(id),
Path: filePath,
Type: sourceType,
ContentType: contentType,
file: file,
state: SourceStateOpen,
waitCh: make(chan struct{}),
session: session,
reopenable: reopenable,
rangeEnabled: reopenable,
declaredSize: declaredSize,
started: !reopenable,
}
r.mu.Lock()
r.sources[id] = src
@@ -292,7 +344,7 @@ func (r *Registry) createSource(sourceType SourceType, contentType string, sessi
}
func (r *Registry) CreateBlob(data []byte, contentType string) (string, error) {
src, err := r.createSource(SourceTypeBlob, contentType, nil)
src, err := r.createSource(SourceTypeBlob, contentType, nil, false, int64(len(data)))
if err != nil {
return "", err
}
@@ -307,14 +359,59 @@ func (r *Registry) CreateBlob(data []byte, contentType string) (string, error) {
return src.URL, nil
}
func (r *Registry) CreateWritableStream(session SessionRef) (string, error) {
src, err := r.createSource(SourceTypeWritableStream, "", session)
func (r *Registry) CreateWritableStream(opts *CreateWritableStreamOptions) (string, error) {
if opts == nil {
opts = &CreateWritableStreamOptions{}
}
src, err := r.createSource(SourceTypeWritableStream, "", opts.Session, opts.Reopenable, 0)
if err != nil {
return "", err
}
return src.URL, nil
}
func (r *Registry) SetResumeOpener(raw string, reopen ResumeFunc) error {
src, err := r.get(raw)
if err != nil {
return err
}
src.mu.Lock()
defer src.mu.Unlock()
if !src.reopenable {
return nil
}
src.reopen = reopen
return nil
}
func (r *Registry) SetRange(raw string, enabled bool) error {
src, err := r.get(raw)
if err != nil {
return err
}
src.mu.Lock()
src.rangeEnabled = enabled
if !enabled && (src.state == SourceStateAborted || src.state == SourceStateFailed) {
src.releaseSessionLocked()
}
src.mu.Unlock()
return nil
}
func (r *Registry) SetSize(raw string, size int64) error {
src, err := r.get(raw)
if err != nil {
return err
}
src.mu.Lock()
if size < 0 {
size = 0
}
src.declaredSize = size
src.mu.Unlock()
return nil
}
func (r *Registry) get(raw string) (*Source, error) {
id, err := ParseURL(raw)
if err != nil {
@@ -405,10 +502,80 @@ func (r *Registry) WaitForReadable(ctx context.Context, raw string, offset int64
return src.waitForReadable(ctx, offset)
}
func (r *Registry) Reopen(raw string, offset int64) error {
src, err := r.get(raw)
if err != nil {
return err
}
src.mu.Lock()
if !src.reopenable || src.reopen == nil {
src.mu.Unlock()
return ErrSourceNotFound
}
if src.revoked {
src.mu.Unlock()
return ErrSourceRevoked
}
if src.file != nil {
_ = src.file.Close()
src.file = nil
}
if err := os.MkdirAll(filepath.Dir(src.Path), 0755); err != nil {
src.mu.Unlock()
return err
}
file, err := os.Create(src.Path)
if err != nil {
src.mu.Unlock()
return err
}
src.file = file
src.written = 0
src.state = SourceStateOpen
src.err = nil
src.started = true
src.retainSessionLocked()
reopen := src.reopen
src.mu.Unlock()
if err := reopen(offset); err != nil {
src.mu.Lock()
if src.file != nil {
_ = src.file.Close()
src.file = nil
}
src.state = SourceStateFailed
src.err = err
src.releaseSessionLocked()
src.notifyLocked()
src.mu.Unlock()
return err
}
return nil
}
func (r *Registry) StartSource(raw string) error {
src, err := r.get(raw)
if err != nil {
return err
}
src.mu.Lock()
if !src.reopenable || src.started {
src.mu.Unlock()
return nil
}
src.mu.Unlock()
return r.Reopen(raw, 0)
}
func (r *Registry) cleanupIfNeeded(src *Source) {
src.mu.Lock()
shouldCleanup := src.shouldCleanupLocked()
file := src.file
if shouldCleanup {
src.releaseSessionLocked()
}
src.mu.Unlock()
if !shouldCleanup {
return
@@ -443,5 +610,8 @@ func (r *Registry) Close() error {
lastErr = err
}
}
if err := os.RemoveAll(r.dir); err != nil && !errors.Is(err, os.ErrNotExist) {
lastErr = err
}
return lastErr
}
+46
View File
@@ -0,0 +1,46 @@
package gblob
import (
"errors"
"testing"
)
func TestRegistry_RevokeOpenWritableStreamAbortsAndCleansUpAfterUnpin(t *testing.T) {
registry := NewRegistry(t.TempDir())
url, err := registry.CreateWritableStream(&CreateWritableStreamOptions{})
if err != nil {
t.Fatal(err)
}
if err := registry.Pin(url); err != nil {
t.Fatal(err)
}
src, err := registry.Get(url)
if err != nil {
t.Fatal(err)
}
if state := src.Snapshot().State; state != SourceStateOpen {
t.Fatalf("expected initial open state, got %s", state)
}
if err := registry.Revoke(url); err != nil {
t.Fatal(err)
}
src, err = registry.Get(url)
if err != nil {
t.Fatal(err)
}
snapshot := src.Snapshot()
if snapshot.State != SourceStateAborted {
t.Fatalf("expected revoked open source to become aborted, got %s", snapshot.State)
}
if !errors.Is(snapshot.Err, ErrSourceRevoked) {
t.Fatalf("expected revoke error, got %v", snapshot.Err)
}
registry.Unpin(url)
if _, err := registry.Get(url); !errors.Is(err, ErrSourceNotFound) {
t.Fatalf("expected source cleanup after unpin, got %v", err)
}
}
+3 -3
View File
@@ -376,7 +376,7 @@ func TestFetcher_DownloadError(t *testing.T) {
}
func TestFetcher_DownloadLimit(t *testing.T) {
listener := test.StartTestLimitServer(4, 0)
listener := test.StartTestConLimitServer(4)
defer listener.Close()
downloadNormal(listener, 1, t)
@@ -614,7 +614,7 @@ func TestFetcher_SlowStartExpansion(t *testing.T) {
os.Remove(test.DownloadFile)
// Use 100ns delay per byte for faster test (~10MB/s theoretical)
listener := test.StartTestSlowStartServer(100 * time.Nanosecond)
listener := test.StartTestLowSpeedServer(100 * time.Nanosecond)
// Ensure cleanup happens before next subtest
cleanup := func() {
@@ -758,7 +758,7 @@ func TestFetcher_AsyncPrefetch(t *testing.T) {
t.Run("PrefetchPartial", func(t *testing.T) {
// Use slow server with 100 nanosecond delay per byte
// This means ~10MB/s speed, so 100ms should download ~1MB
listener := test.StartTestSlowStartServer(100 * time.Nanosecond)
listener := test.StartTestLowSpeedServer(100 * time.Nanosecond)
defer listener.Close()
fetcher := buildFetcher()