Skip to content

feat(engine): real back-pressure in StreamingEncoder.writeFrame (await drain when accepted === false) #1353

@Claudemeri

Description

@Claudemeri

Context

PR #1351 enables multi-worker streaming encode via interleaved frame distribution. During review, @jrusso1020 correctly identified that the current writeFrame implementation in packages/engine/src/services/streamingEncoder.ts calls ffmpeg.stdin.write(copy) without awaiting drain when accepted === false. Node's writable-stream highWaterMark is advisory — the buffer grows without bound if FFmpeg encodes slower than workers capture.

Worst-case numbers (from review thread)

  • 3-worker capture: ~105 fps total
  • FFmpeg h264 medium encode: ~60 fps
  • Net backlog: ~45 fps × ~500KB/frame × 3600s ≈ ~80 GB unbounded Node buffer growth → OOM the producer process before the render finishes

PR #1351 works around this with a fixed 1800s cap for multi-worker streaming, which bounds the practical risk for current workloads (~548s longest composition). But the cap is a band-aid — the durable fix is real back-pressure.

Proposed fix

Change writeFrame to return a Promise<boolean> and await the drain event when ffmpeg.stdin.write(copy) returns false:

writeFrame: async (buffer: Buffer): Promise<boolean> => {
  if (exitStatus !== 'running' || !ffmpeg.stdin || ffmpeg.stdin.destroyed) return false;
  const copy = Buffer.from(buffer);
  const accepted = ffmpeg.stdin.write(copy);
  if (accepted) {
    resetTimer();
    return true;
  }
  // Await drain before returning — back-pressure propagates to the caller
  // (captureStreamingStage's onFrameBuffer), which blocks the worker's
  // captureFrameRange loop. This bounds the in-flight buffer to one frame per
  // worker regardless of composition length.
  await new Promise<void>((resolve) => ffmpeg.stdin!.once('drain', resolve));
  resetTimer();
  return true;
},

Caller changes

captureStreamingStage.ts's onFrameBuffer already awaits onFrameBuffer(frameIndex, buffer), so it naturally propagates back-pressure to the captureFrameRange loop once writeFrame becomes async. The single-worker sequential path in the same stage also awaits each frame, so it benefits too.

Signature impact

StreamingEncoder.writeFrame signature changes from (buffer: Buffer) => boolean to (buffer: Buffer) => Promise<boolean>. This is a breaking change for any direct caller — check all uses before merging.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions