fix(stream): bound FFmpeg memory buffers and propagate Blob backpressure

This commit is contained in:
Levi committed 2026-09-13 18:24:32 +08:00
1 parent 637004c8a8
commit 5d83e91ce5
10 files changed
+142 -125

No files matched your search

-3
View File
@@ -160,8 +160,6 @@ func (e *Engine) addCleanup(cleanup func()) {
} }
type Config struct { type Config struct {
// TempDir supplies an app-writable directory for FFmpeg stream buffers.
TempDir string
// HTTPUserAgent is a snapshot of the downloader HTTP default. Nil leaves standalone engines unchanged. // HTTPUserAgent is a snapshot of the downloader HTTP default. Nil leaves standalone engines unchanged.
HTTPUserAgent *string HTTPUserAgent *string
ProxyConfig *base.DownloaderProxyConfig ProxyConfig *base.DownloaderProxyConfig
@@ -209,7 +207,6 @@ func NewEngine(cfg *Config) *Engine {
return return
} }
if err := ffmpegapi.Enable(runtime, loop, &ffmpegapi.Config{ if err := ffmpegapi.Enable(runtime, loop, &ffmpegapi.Config{
TempDir: cfg.TempDir,
ProxyHandler: cfg.ProxyConfig.ToHandler(), ProxyHandler: cfg.ProxyConfig.ToHandler(),
DefaultUserAgent: cfg.HTTPUserAgent, DefaultUserAgent: cfg.HTTPUserAgent,
RegisterCleanup: engine.addCleanup, RegisterCleanup: engine.addCleanup,
+9 -8
View File
@@ -92,14 +92,15 @@ server validator, same-size remote changes cannot be reliably detected.
Content encoding is forced to identity. Authentication/status failures and Content encoding is forced to identity. Authentication/status failures and
malformed range responses are errors. There is no full-file seek fallback. malformed range responses are errors. There is no full-file seek fallback.
Sequential JS inputs are drained independently into temporary disk ring Sequential JS inputs are drained independently into memory ring buffers that
buffers (up to **64 MiB per input**, independent of total media size). This grow on demand up to **32 MiB per input**, independent of total media size.
allows shared upstream demultiplexers to make progress when FFmpeg reads one No temporary files are used. This does not enable seeking. Full buffers apply
track ahead of another. It does not enable seeking. Full buffers apply async async backpressure until space is available or the operation is cancelled.
backpressure; a full buffer stalled for 30 seconds fails explicitly. Extensions Extensions must propagate backpressure through their upstream queues and
must also bound their own upstream queues. Disk buffers are removed on normal network reads. Independent upstream track requests avoid blocking one track
completion, failure, cancellation and engine shutdown; abrupt process death behind another in a shared response. Buffers are released on close.
can leave temporary files in the OS temporary directory. Blob output writes also wait asynchronously for bounded downstream capacity,
so a slow consumer does not block the extension event loop.
At most two WASM merges execute concurrently per process, with a 512 MiB At most two WASM merges execute concurrently per process, with a 512 MiB
linear-memory limit per instance. Compilation is shared and lazy; the upstream linear-memory limit per instance. Compilation is shared and lazy; the upstream
+30 -43
View File
@@ -7,8 +7,6 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"strings" "strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
@@ -118,10 +116,11 @@ func TestHTTPSequentialAndValidation(t *testing.T) {
} }
func TestStreamRing(t *testing.T) { func TestStreamRing(t *testing.T) {
s := NewStreamInput(context.Background(), "") s := NewStreamInput(context.Background())
defer s.Close() defer s.Close()
// Start near the boundary to exercise wrap-around without a huge fixture. // Start near the boundary to exercise wrap-around without a huge fixture.
s.read = StreamBufferLimit - 3 s.buffer = make([]byte, 256*1024)
s.read = int64(len(s.buffer)) - 3
s.written = s.read s.written = s.read
if _, err := s.Write([]byte("abcdefgh")); err != nil { if _, err := s.Write([]byte("abcdefgh")); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -131,12 +130,11 @@ func TestStreamRing(t *testing.T) {
if err != nil || string(b) != "abcdefgh" { if err != nil || string(b) != "abcdefgh" {
t.Fatalf("%q %v", b, err) t.Fatalf("%q %v", b, err)
} }
name := s.file.Name()
if err = s.Close(); err != nil { if err = s.Close(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err = os.Stat(name); err == nil { if s.buffer != nil {
t.Fatal("temporary file leaked") t.Fatal("buffer retained after close")
} }
} }
@@ -158,16 +156,9 @@ func TestOutputArguments(t *testing.T) {
func TestStreamBackpressureAndCancel(t *testing.T) { func TestStreamBackpressureAndCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
s := NewStreamInput(ctx, "") s := NewStreamInput(ctx)
defer s.Close() defer s.Close()
f, err := os.CreateTemp(t.TempDir(), "ring") s.buffer = make([]byte, StreamBufferLimit)
if err != nil {
t.Fatal(err)
}
s.file = f
if err = f.Truncate(StreamBufferLimit); err != nil {
t.Fatal(err)
}
s.written = StreamBufferLimit s.written = StreamBufferLimit
finished := make(chan error, 1) finished := make(chan error, 1)
go func() { _, err := s.Write([]byte{42}); finished <- err }() go func() { _, err := s.Write([]byte{42}); finished <- err }()
@@ -176,7 +167,7 @@ func TestStreamBackpressureAndCancel(t *testing.T) {
t.Fatalf("did not apply backpressure: %v", err) t.Fatalf("did not apply backpressure: %v", err)
case <-time.After(10 * time.Millisecond): case <-time.After(10 * time.Millisecond):
} }
if _, err = s.Read(make([]byte, 1)); err != nil { if _, err := s.Read(make([]byte, 1)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
select { select {
@@ -199,37 +190,33 @@ func TestStreamBackpressureAndCancel(t *testing.T) {
} }
} }
// A mobile app can write to its storage directory even when the OS default func TestStreamMemoryGrowthAndWrap(t *testing.T) {
// temporary directory is unavailable. The buffer must also be removed on close. s := NewStreamInput(context.Background())
func TestStreamInputAppTempDir(t *testing.T) { defer s.Close()
root := t.TempDir() if len(s.buffer) != 0 {
unavailable := filepath.Join(root, "not-a-directory") t.Fatal("buffer allocated before first write")
if err := os.WriteFile(unavailable, []byte("blocked"), 0600); err != nil { }
a := bytes.Repeat([]byte{1}, 200*1024)
b := bytes.Repeat([]byte{2}, 180*1024)
s.Write(a)
consumed := make([]byte, 180*1024)
if _, err := io.ReadFull(s, consumed); err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Setenv("TMPDIR", unavailable) s.Write(b) // Wrap in the initial 256 KiB allocation.
t.Setenv("TMP", unavailable) c := bytes.Repeat([]byte{3}, 300*1024)
t.Setenv("TEMP", unavailable) s.Write(c) // Grow while unread data wraps around the old ring.
dir := filepath.Join(root, "app", "temp", "ffmpeg")
s := NewStreamInput(context.Background(), dir)
t.Cleanup(func() { s.Close() })
data := []byte("media stream")
if _, err := s.Write(data); err != nil {
t.Fatal(err)
}
if filepath.Dir(s.file.Name()) != dir {
t.Fatalf("unexpected buffer: %s", s.file.Name())
}
s.End(nil) s.End(nil)
got, err := io.ReadAll(s) got, err := io.ReadAll(s)
if err != nil || !bytes.Equal(got, data) { want := append(append(append([]byte{}, a[len(consumed):]...), b...), c...)
t.Fatalf("read %q, error %v", got, err) if err != nil || !bytes.Equal(got, want) {
t.Fatalf("growth corrupted data: %v", err)
} }
if err := s.Close(); err != nil { if len(s.buffer) > StreamBufferLimit {
t.Fatal(err) t.Fatal("buffer exceeded limit")
} }
entries, err := os.ReadDir(dir) s.Close()
if err != nil || len(entries) != 0 { if s.buffer != nil {
t.Fatalf("buffer cleanup: %v, %v", entries, err) t.Fatal("buffer retained on close")
} }
} }
+2 -2
View File
@@ -128,7 +128,7 @@ func TestWebMToMP4(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
inputs[i] = NewStreamInput(ctx, "") inputs[i] = NewStreamInput(ctx)
defer inputs[i].Close() defer inputs[i].Close()
if _, err = inputs[i].Write(data); err != nil { if _, err = inputs[i].Write(data); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -205,7 +205,7 @@ func TestWASMMerge(t *testing.T) {
}) })
} }
// A malformed input must produce an error, not successful empty output. // A malformed input must produce an error, not successful empty output.
s := NewStreamInput(context.Background(), "") s := NewStreamInput(context.Background())
defer s.Close() defer s.Close()
s.Write([]byte("invalid")) s.Write([]byte("invalid"))
s.End(nil) s.End(nil)
+29 -52
View File
@@ -4,22 +4,19 @@ import (
"context" "context"
"errors" "errors"
"io" "io"
"os"
"sync" "sync"
"time"
) )
// A bounded disk ring lets both JS inputs drain independently, including when // A bounded memory ring lets both JS inputs drain independently, including when
// they share a SABR producer. It does not make a sequential input seekable. // they share a SABR producer. It does not make a sequential input seekable.
// A full ring applies backpressure. A prolonged stall fails instead of // A full ring waits for consumption or cancellation. Producers must propagate
// deadlocking a shared upstream or growing forever. // backpressure and allow each track to make progress independently.
const StreamBufferLimit = 64 * 1024 * 1024 const StreamBufferLimit = 32 * 1024 * 1024
type StreamInput struct { type StreamInput struct {
ctx context.Context ctx context.Context
tempDir string
mu sync.Mutex mu sync.Mutex
file *os.File buffer []byte
read, written int64 read, written int64
ended, closed bool ended, closed bool
err error err error
@@ -27,8 +24,8 @@ type StreamInput struct {
space chan struct{} space chan struct{}
} }
func NewStreamInput(ctx context.Context, tempDir string) *StreamInput { func NewStreamInput(ctx context.Context) *StreamInput {
return &StreamInput{ctx: ctx, tempDir: tempDir, wake: make(chan struct{}, 1), space: make(chan struct{}, 1)} return &StreamInput{ctx: ctx, wake: make(chan struct{}, 1), space: make(chan struct{}, 1)}
} }
func (s *StreamInput) Write(p []byte) (int, error) { func (s *StreamInput) Write(p []byte) (int, error) {
@@ -37,24 +34,13 @@ func (s *StreamInput) Write(p []byte) (int, error) {
if len(p) > StreamBufferLimit { if len(p) > StreamBufferLimit {
return 0, errors.New("ffmpeg: input chunk exceeds buffer capacity") return 0, errors.New("ffmpeg: input chunk exceeds buffer capacity")
} }
var timer *time.Timer
defer func() {
if timer != nil {
timer.Stop()
}
}()
for int64(len(p)) > StreamBufferLimit-(s.written-s.read) && !s.closed && !s.ended { for int64(len(p)) > StreamBufferLimit-(s.written-s.read) && !s.closed && !s.ended {
if timer == nil {
timer = time.NewTimer(30 * time.Second)
}
s.mu.Unlock() s.mu.Unlock()
var err error var err error
select { select {
case <-s.space: case <-s.space:
case <-s.ctx.Done(): case <-s.ctx.Done():
err = s.ctx.Err() err = s.ctx.Err()
case <-timer.C:
err = errors.New("ffmpeg: input buffer stalled for 30 seconds; upstream tracks may be too far apart")
} }
s.mu.Lock() s.mu.Lock()
if err != nil { if err != nil {
@@ -70,29 +56,25 @@ func (s *StreamInput) Write(p []byte) (int, error) {
if len(p) == 0 { if len(p) == 0 {
return 0, nil return 0, nil
} }
if s.file == nil { // Grow only as needed. Rebase unread bytes when the ring grows.
// Android apps cannot write to Go's default /data/local/tmp. need := int(s.written-s.read) + len(p)
if s.tempDir != "" { if need > len(s.buffer) {
if err := os.MkdirAll(s.tempDir, 0700); err != nil { capacity := min(StreamBufferLimit, max(256*1024, max(need, len(s.buffer)*2)))
return 0, err buffer := make([]byte, capacity)
} unread := int(s.written - s.read)
} if unread > 0 {
f, err := os.CreateTemp(s.tempDir, "gopeed-ffmpeg-*") off := int(s.read % int64(len(s.buffer)))
if err != nil { first := min(unread, len(s.buffer)-off)
return 0, err copy(buffer, s.buffer[off:off+first])
} copy(buffer[first:], s.buffer[:unread-first])
s.file = f
}
off := s.written % StreamBufferLimit
first := min(len(p), StreamBufferLimit-int(off))
if _, err := s.file.WriteAt(p[:first], off); err != nil {
return 0, err
}
if first < len(p) {
if _, err := s.file.WriteAt(p[first:], 0); err != nil {
return 0, err
} }
s.buffer = buffer
s.read, s.written = 0, int64(unread)
} }
off := int(s.written % int64(len(s.buffer)))
first := min(len(p), len(s.buffer)-off)
copy(s.buffer[off:], p[:first])
copy(s.buffer, p[first:])
s.written += int64(len(p)) s.written += int64(len(p))
select { select {
case s.wake <- struct{}{}: case s.wake <- struct{}{}:
@@ -137,16 +119,16 @@ func (s *StreamInput) Read(p []byte) (int, error) {
return 0, err return 0, err
} }
if s.read < s.written { if s.read < s.written {
off := s.read % StreamBufferLimit off := s.read % int64(len(s.buffer))
n := min(int64(len(p)), s.written-s.read, StreamBufferLimit-off) n := min(int64(len(p)), s.written-s.read, int64(len(s.buffer))-off)
count, err := s.file.ReadAt(p[:n], off) count := copy(p, s.buffer[off:off+n])
s.read += int64(count) s.read += int64(count)
s.mu.Unlock() s.mu.Unlock()
select { select {
case s.space <- struct{}{}: case s.space <- struct{}{}:
default: default:
} }
return count, err return count, nil
} }
ended := s.ended ended := s.ended
s.mu.Unlock() s.mu.Unlock()
@@ -173,12 +155,7 @@ func (s *StreamInput) Close() error {
case s.wake <- struct{}{}: case s.wake <- struct{}{}:
default: default:
} }
if s.file != nil { s.buffer = nil
name := s.file.Name()
s.file.Close()
s.file = nil
return os.Remove(name)
}
return nil return nil
} }
func (*StreamInput) Seek(int64, int) (int64, error) { return 0, ErrSeek } func (*StreamInput) Seek(int64, int) (int64, error) { return 0, ErrSeek }
+1 -2
View File
@@ -22,7 +22,6 @@ import (
var script string var script string
type Config struct { type Config struct {
TempDir string
DefaultUserAgent *string DefaultUserAgent *string
ProxyHandler func(*http.Request) (*url.URL, error) ProxyHandler func(*http.Request) (*url.URL, error)
RegisterCleanup func(func()) RegisterCleanup func(func())
@@ -123,7 +122,7 @@ func Enable(vm *goja.Runtime, loop *eventloop.EventLoop, cfg *Config) error {
j := &job{ctx: ctx, cancel: cancel, output: r} j := &job{ctx: ctx, cancel: cancel, output: r}
for i, source := range []*media.HTTPSource{opts.Video, opts.Audio} { for i, source := range []*media.HTTPSource{opts.Video, opts.Audio} {
if source == nil { if source == nil {
j.streams[i] = media.NewStreamInput(ctx, cfg.TempDir) j.streams[i] = media.NewStreamInput(ctx)
} }
} }
id := fmt.Sprintf("ffmpeg-%d", next.Add(1)) id := fmt.Sprintf("ffmpeg-%d", next.Add(1))
+9 -4
View File
@@ -86,18 +86,23 @@ func Enable(runtime *goja.Runtime, loop *eventloop.EventLoop, cfg *Config) error
} }
if err := runtime.Set("__gopeed_blob_pipe_chunk", func(call goja.FunctionCall) goja.Value { if err := runtime.Set("__gopeed_blob_pipe_chunk", func(call goja.FunctionCall) goja.Value {
id := call.Argument(0).String() id := call.Argument(0).String()
ok := blobPipes.Push(id, exportBytes(call.Argument(1))) chunk := append([]byte(nil), exportBytes(call.Argument(1))...)
return runtime.ToValue(ok) promise, resolve, _ := runtime.NewPromise()
go func() {
ok := blobPipes.Push(id, chunk)
loop.RunOnLoop(func(vm *goja.Runtime) { resolve(ok) })
}()
return runtime.ToValue(promise)
}); err != nil { }); err != nil {
return err return err
} }
if err := runtime.Set("__gopeed_blob_pipe_close", func(id string) { if err := runtime.Set("__gopeed_blob_pipe_close", func(id string) {
blobPipes.Close(id, nil) go blobPipes.Close(id, nil)
}); err != nil { }); err != nil {
return err return err
} }
if err := runtime.Set("__gopeed_blob_pipe_error", func(id string, message string) { if err := runtime.Set("__gopeed_blob_pipe_error", func(id string, message string) {
blobPipes.Close(id, errors.New(message)) go blobPipes.Close(id, errors.New(message))
}); err != nil { }); err != nil {
return err return err
} }
@@ -269,3 +269,57 @@ func newStreamTestEngine(t *testing.T, created chan<- createdObjectURL) (*engine
}) })
return e, e.Close return e, e.Close
} }
// A stalled disk/network consumer must pause the producer without blocking
// the JS event loop, so cancellation and other tracks can still run.
func TestBlobBackpressureLeavesEventLoopResponsive(t *testing.T) {
created := make(chan createdObjectURL, 1)
e, cleanup := newStreamTestEngine(t, created)
defer cleanup()
_, err := e.RunString(`
globalThis.produced = 0;
globalThis.sourceCancelled = false;
__gopeed_blob_create_object_url(() => new ReadableStream({
pull(c) { produced++; c.enqueue(new Uint8Array(64*1024)); },
cancel() { sourceCancelled = true; }
}));
`)
if err != nil {
t.Fatal(err)
}
object := <-created
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
reader, err := object.open(ctx, stream.ObjectURLOpenRequest{End: -1})
if err != nil {
t.Fatal(err)
}
defer reader.Close()
time.Sleep(100 * time.Millisecond)
value, err := e.RunString("produced")
if err != nil {
t.Fatal(err)
}
before := value.(int64)
if before > 10 {
t.Fatalf("unbounded production: %d", before)
}
time.Sleep(50 * time.Millisecond)
after, err := e.RunString("produced")
if err != nil || after != value {
t.Fatalf("producer did not pause: %v -> %v (%v)", value, after, err)
}
if ctx.Err() != nil {
t.Fatal("event loop only resumed after cancellation")
}
if _, err = reader.Read(make([]byte, 64*1024)); err != nil {
t.Fatal(err)
}
time.Sleep(30 * time.Millisecond)
after, err = e.RunString("produced")
if err != nil || after.(int64) <= before {
t.Fatalf("producer did not resume: %v (%v)", after, err)
}
reader.Close()
waitForJSValue(t, e, "String(sourceCancelled)", "true")
}
+8 -5
View File
@@ -764,11 +764,14 @@
return; return;
} }
const chunk = value instanceof Uint8Array ? value : new Uint8Array(value); const chunk = value instanceof Uint8Array ? value : new Uint8Array(value);
const buffer = chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength); for (let offset = 0; offset < chunk.byteLength; offset += 256 * 1024) {
if (!globalThis.__gopeed_blob_pipe_chunk(pipeId, buffer)) { const part = chunk.subarray(offset, offset + 256 * 1024);
state.cancelRequested = true; const buffer = part.buffer.slice(part.byteOffset, part.byteOffset + part.byteLength);
state.cancelReason = "blob pipe closed"; if (!await globalThis.__gopeed_blob_pipe_chunk(pipeId, buffer)) {
return; state.cancelRequested = true;
state.cancelReason = "blob pipe closed";
return;
}
} }
await yieldBlobPipeTask(); await yieldBlobPipeTask();
} }
-6
View File
@@ -3,7 +3,6 @@ package download
import ( import (
"context" "context"
"io" "io"
"path/filepath"
"strings" "strings"
"sync" "sync"
@@ -127,12 +126,7 @@ func (d *Downloader) newExtensionEngine() (*engine.Engine, *engineSession) {
}{UserAgent: httpProtocol.DefaultUserAgent} }{UserAgent: httpProtocol.DefaultUserAgent}
d.getProtocolConfig("http", &httpConfig) d.getProtocolConfig("http", &httpConfig)
userAgent := strings.TrimSpace(httpConfig.UserAgent) userAgent := strings.TrimSpace(httpConfig.UserAgent)
tempDir := ""
if d.cfg.StorageDir != "" {
tempDir = filepath.Join(d.cfg.StorageDir, "temp", "ffmpeg")
}
e := engine.NewEngine(&engine.Config{ e := engine.NewEngine(&engine.Config{
TempDir: tempDir,
HTTPUserAgent: &userAgent, HTTPUserAgent: &userAgent,
ProxyConfig: d.cfg.Proxy, ProxyConfig: d.cfg.Proxy,
StreamConfig: engineCfg, StreamConfig: engineCfg,