Skip to content

fix(ffmpeg-ipc): report mid-encode CancelEncode as a clean cancel; extract PrefetchSlot#2033

Merged
yuto-trd merged 4 commits into
mainfrom
loop/ipcconnection-seam-prefetch-statemachine
Jul 5, 2026
Merged

fix(ffmpeg-ipc): report mid-encode CancelEncode as a clean cancel; extract PrefetchSlot#2033
yuto-trd merged 4 commits into
mainfrom
loop/ipcconnection-seam-prefetch-statemachine

Conversation

@yuto-trd

@yuto-trd yuto-trd commented Jun 26, 2026

Copy link
Copy Markdown
Member

Summary

Fixes a worker-side cancellation-reporting bug and removes the duplication that surfaced it, all within the MIT Beutl.FFmpegIpc layer.

The bug (fix)

The worker's IpcConnection is non-multiplexed, and during an active encode the provider's SendAndReceiveAsync is the only pipe reader. The host injects CancelEncode with a fresh id (FFmpegEncodingControllerProxy via NextId()), so it arrives in place of the awaited frame/sample response, fails the response-id-match check, and was thrown as InvalidOperationException("Response ID mismatch") — which EncodingHandler.HandleStartAsync then reported as EncodeComplete{Error="Response ID mismatch"} instead of a clean cancel. (User-visible cancel still worked because the host throws OperationCanceledException on its side, so impact was cosmetic/misleading worker-side reporting.)

Fix: IpcConnection.SendAndReceiveAsync now recognizes a CancelEncode message before the id-match check and throws OperationCanceledException, so a mid-encode cancel surfaces as EncodeComplete{Error="Cancelled"}. The now-dead CancelEncode branches in both providers are removed.

The refactor

Extracted the hand-duplicated double-buffer prefetch/seek state machine from IpcFrameProvider and IpcSampleProvider into a new internal PrefetchSlot<TKey, TValue> (Providers/PrefetchSlot.cs) — arm a prefetch, consume it on a matching key, drain the stale one on a mismatch — and unified both providers onto it.

Tests

  • Red-baseline regression tests (IpcProviderContractTests): RenderFrame/Sample when the host cancels with a fresh id — these fail against the unmodified code (InvalidOperationException) and pass after the fix (OperationCanceledException).
  • PrefetchSlotTests (8): pin the consume/drain/detach behavior.
  • Full Beutl.FFmpegIpc.Tests suite: 46/46 green; the GPL worker builds against the unchanged public provider constructors.

Scope note

The board item also proposed an IIpcConnection seam (part 1). It was deliberately scoped out: the providers are already MIT-side and unit-tested via a real-pipe fake-host harness (PR #2012), so a consumer-less interface would be a speculative abstraction (and is cheap to extract later, internally, when a real consumer appears). The two reviewers concurred.

Review notes

Reviewed by @beutl-design-reviewer and @beutl-reviewer — both approve, no blocking findings. GPL/MIT boundary clean (all changes in MIT Beutl.FFmpegIpc, no new ProjectReference, provider ctors unchanged so the GPL worker still builds). The cancel recognition is correct and carries no false-cancel risk (CancelEncode is never a legitimate reply to a frame/sample request). Not a breaking API change (no public signature changed; the only observable change is the exception type in a previously-broken scenario), so fix: not fix!:. Design-review polish applied before opening: <exception> XML docs on SendAndReceiveAsync, a note on the multiplexed path explaining the deliberate non-multiplexed-only cancel recognition, and a throwing empty-slot guard on PrefetchSlot.Arm (it throws InvalidOperationException if armed while a prefetch is already in flight — active in all build configurations, not compiled out in Release — covered by Arm_WhenAlreadyArmed_Throws).

Left for human merge by /beutl-loop: this is a larger diff (~405 LOC, 6 files) that changes a public method's exception contract in the encode/IPC-critical path — above the loop's auto-merge size/sensitivity threshold, so a human makes the final call.


Board item: Project #9 — Refactor: extract IpcConnection seam + shared prefetch state-machine; fix CancelEncode-as-id-mismatch reporting. Opened autonomously by /beutl-loop.

Summary by CodeRabbit

  • Bug Fixes
    • Improved cancellation handling so interrupted frame and audio requests now stop cleanly with cancellation rather than a response-id mismatch.
    • Enhanced prefetch reliability so stale in-flight prefetch failures are discarded after seeks/rapid changes, allowing the requested frame or chunk to succeed.
  • Tests
    • Added regression tests covering fresh-id cancellation behavior and ensuring seek after a prefetched failure returns the correct requested output.
    • Expanded coverage for prefetch-slot state transitions and fault handling.

yuto-trd added 2 commits June 26, 2026 17:56
…el-during-encode reporting

Extract the hand-duplicated double-buffer prefetch/seek state-machine from IpcFrameProvider and
IpcSampleProvider into a shared MIT-side PrefetchSlot<TKey, TValue> helper — a request whose key
matches the armed prefetch consumes it, any other key drains the stale prefetch (await-and-discard)
before a fresh request is issued — and unify both providers onto it.

Fix the worker-side cancel-during-encode reporting bug. The host injects CancelEncode with a fresh
id (FFmpegEncodingControllerProxy) while the worker is blocked in the non-multiplexed
SendAndReceiveAsync awaiting a frame/sample response, so it is read in place of that response,
trips the id-match check, and surfaced as InvalidOperationException("Response ID mismatch") —
which HandleStartAsync reported as EncodeComplete{Error='Response ID mismatch'} instead of a clean
cancel. SendAndReceiveAsync now recognizes CancelEncode before the id-match check and raises
OperationCanceledException, so HandleStartAsync reports "Cancelled". The resulting dead CancelEncode
branches in both providers are removed.

Tests: PrefetchSlotTests pins the slot mechanics (green); new fresh-id cancel regression tests for
both providers (red against the unmodified id-mismatch path, green after the fix) join the existing
contract suite.

Refs: Project #9
…metry, and Arm precondition

Apply design-review polish (doc/comment/assert only, behavior unchanged):
- Add <exception> tags to IpcConnection.SendAndReceiveAsync documenting the
  OperationCanceledException raised when a host CancelEncode arrives in place of
  the response, plus the existing IOException / FFmpegWorkerException /
  InvalidOperationException throws.
- Note on SendAndReceiveMultiplexedAsync that CancelEncode-as-cancellation is
  intentionally recognized only on the sequential path because the encode
  connection is never multiplexed.
- Add a Debug.Assert guard in PrefetchSlot.Arm for the empty-slot precondition.
Copilot AI review requested due to automatic review settings June 26, 2026 09:11
@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds a shared keyed prefetch slot, moves frame and sample IPC providers to use it, updates sequential IPC receive handling to surface CancelEncode as OperationCanceledException, and adds tests covering the slot behavior and the cancel-response contract.

Changes

IPC prefetch and cancellation handling

Layer / File(s) Summary
Prefetch slot helper
src/Beutl.FFmpegIpc/Providers/PrefetchSlot.cs, tests/Beutl.FFmpegIpc.Tests/PrefetchSlotTests.cs
A keyed single-slot prefetch helper is added, and tests cover arming, matching consumption, stale detachment, detach, and fault state.
Sequential cancel handling
src/Beutl.FFmpegIpc/Transport/IpcConnection.cs, tests/Beutl.FFmpegIpc.Tests/IpcProviderContractTests.cs
Sequential SendAndReceiveAsync now throws OperationCanceledException on CancelEncode, and contract tests cover fresh-id cancellation for frame and sample requests.
Frame provider prefetch slot
src/Beutl.FFmpegIpc/Providers/IpcFrameProvider.cs, tests/Beutl.FFmpegIpc.Tests/IpcProviderContractTests.cs
IpcFrameProvider consumes and arms the shared prefetch slot, removes the old prefetch task fields, and updates fault probing, disposal, and stale-prefetch regression coverage.
Sample provider prefetch slot
src/Beutl.FFmpegIpc/Providers/IpcSampleProvider.cs, tests/Beutl.FFmpegIpc.Tests/IpcProviderContractTests.cs
IpcSampleProvider consumes and arms the shared prefetch slot, updates chunk loading and disposal, removes the old prefetch task fields and cancel check, and adds stale-prefetch regression coverage.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • b-editor/beutl#1934: Shares the same CancelEncode to OperationCanceledException IPC contract change used here.
  • b-editor/beutl#1988: Also changes IpcFrameProvider.RenderFrame prefetch and stale-response handling.
  • b-editor/beutl#2012: Overlaps on IpcFrameProvider/IpcSampleProvider prefetch and cancellation-path changes.

Poem

I hop through pipes with slots of two,
One frame, one sample, fresh and new.
When CancelEncode bounces near,
I twitch my nose and keep things clear. 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.21% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly covers the two main changes: cleanly handling mid-encode CancelEncode and extracting the shared PrefetchSlot helper.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch loop/ipcconnection-seam-prefetch-statemachine

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes worker-side cancellation reporting on non-multiplexed IPC connections by treating an out-of-band CancelEncode message as a clean cancellation (instead of an ID-mismatch error), and removes duplicated prefetch/seek state-machine code by extracting a shared PrefetchSlot<TKey, TValue> helper used by both IPC providers.

Changes:

  • Update IpcConnection.SendAndReceiveAsync to recognize CancelEncode before the response ID match check and throw OperationCanceledException.
  • Refactor IpcFrameProvider / IpcSampleProvider to use a shared PrefetchSlot for prefetch consume/drain/detach behavior.
  • Add regression/contract tests for “fresh-id cancel” and a dedicated PrefetchSlot unit test suite.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/Beutl.FFmpegIpc.Tests/PrefetchSlotTests.cs Adds unit tests pinning PrefetchSlot consume/drain/detach/fault behavior.
tests/Beutl.FFmpegIpc.Tests/IpcProviderContractTests.cs Adds regression tests reproducing host cancel injection with a fresh ID for both frame and sample paths.
src/Beutl.FFmpegIpc/Transport/IpcConnection.cs Recognizes CancelEncode as cancellation on the sequential (non-multiplexed) send/receive path.
src/Beutl.FFmpegIpc/Providers/PrefetchSlot.cs Introduces shared single-slot prefetch state abstraction used by both providers.
src/Beutl.FFmpegIpc/Providers/IpcSampleProvider.cs Replaces hand-rolled prefetch fields/state with PrefetchSlot and removes provider-level CancelEncode handling.
src/Beutl.FFmpegIpc/Providers/IpcFrameProvider.cs Replaces hand-rolled prefetch fields/state with PrefetchSlot and removes provider-level CancelEncode handling.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Beutl.FFmpegIpc/Providers/PrefetchSlot.cs
Comment thread src/Beutl.FFmpegIpc/Providers/PrefetchSlot.cs Outdated
Comment thread src/Beutl.FFmpegIpc/Providers/PrefetchSlot.cs Outdated
Comment thread src/Beutl.FFmpegIpc/Providers/PrefetchSlot.cs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/Beutl.FFmpegIpc/Providers/PrefetchSlot.cs (1)

36-41: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Enforce the single-slot invariant in release builds.

Line 38 only asserts in debug builds; in release, a double Arm silently overwrites the old in-flight task, which can leave stale IPC work unobserved.

Proposed guard
 public void Arm(TKey key, int bufferIndex, Task<TValue> task)
 {
     Debug.Assert(_task is null, "PrefetchSlot armed while a prefetch was already in flight");
+    if (_task is not null)
+        throw new InvalidOperationException("PrefetchSlot already has a prefetch in flight.");
+
     _key = key;
     _bufferIndex = bufferIndex;
     _task = task;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Beutl.FFmpegIpc/Providers/PrefetchSlot.cs` around lines 36 - 41, The
single-slot invariant in PrefetchSlot.Arm is only checked with Debug.Assert, so
a second Arm call can overwrite an in-flight task in release builds. Update Arm
to enforce the _task-is-null precondition at runtime, using the existing
PrefetchSlot members (_task, _key, _bufferIndex) to reject or fail fast on
double-arming before assigning the new values.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Beutl.FFmpegIpc/Providers/IpcFrameProvider.cs`:
- Around line 47-49: Discard stale prefetch worker errors before sending the
fresh frame request in IpcFrameProvider.GetFrameAsync. The stale task returned
by _prefetch.TryDetachStale(frame) should be awaited only to observe and
suppress its failure, not allowed to rethrow and abort the seek; handle or
ignore any exception from the detached stale worker, then continue to request
the target frame normally.

In `@src/Beutl.FFmpegIpc/Providers/IpcSampleProvider.cs`:
- Around line 142-146: The stale prefetch path in IpcSampleProvider should not
surface errors from a discarded chunk before the requested fetch runs. Update
the logic around TryDetachStale and FetchChunk so that awaiting the detached
stale task swallows or ignores its failure after disposal, then continue to load
the current chunk normally. Keep the fresh chunk request from being blocked by
any exception coming from the stale prefetch worker.

---

Nitpick comments:
In `@src/Beutl.FFmpegIpc/Providers/PrefetchSlot.cs`:
- Around line 36-41: The single-slot invariant in PrefetchSlot.Arm is only
checked with Debug.Assert, so a second Arm call can overwrite an in-flight task
in release builds. Update Arm to enforce the _task-is-null precondition at
runtime, using the existing PrefetchSlot members (_task, _key, _bufferIndex) to
reject or fail fast on double-arming before assigning the new values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7d4777b6-e988-4666-934f-e38fdbfc8ad8

📥 Commits

Reviewing files that changed from the base of the PR and between 6cabb40 and 7c9aedc.

📒 Files selected for processing (6)
  • src/Beutl.FFmpegIpc/Providers/IpcFrameProvider.cs
  • src/Beutl.FFmpegIpc/Providers/IpcSampleProvider.cs
  • src/Beutl.FFmpegIpc/Providers/PrefetchSlot.cs
  • src/Beutl.FFmpegIpc/Transport/IpcConnection.cs
  • tests/Beutl.FFmpegIpc.Tests/IpcProviderContractTests.cs
  • tests/Beutl.FFmpegIpc.Tests/PrefetchSlotTests.cs

Comment thread src/Beutl.FFmpegIpc/Providers/IpcFrameProvider.cs Outdated
Comment thread src/Beutl.FFmpegIpc/Providers/IpcSampleProvider.cs Outdated
yuto-trd added 2 commits June 26, 2026 18:22
The new test file lacked the UTF-8 BOM that .editorconfig (charset=utf-8-bom)
requires; the CI dotnet-format check (whole-solution) failed with CHARSET.
Re-applied dotnet format Beutl.slnx.
When a seek (or any non-sequential request) drains the armed-but-stale
prefetch before issuing the fresh request, the providers awaited the
detached task directly. On a worker-error response that await re-threw the
discarded prefetch's FFmpegWorkerException, aborting the fresh request that
should have succeeded: a seek away from a frame/chunk whose prefetch failed
would surface the discarded frame's error and never request the target.

The drain has already consumed the stale response off the pipe (the
non-multiplexed SendAndReceiveAsync reads and releases the lock before
faulting), so the await is needed only for ordering, not its result. Catch
and discard FFmpegWorkerException at both drain sites (IpcFrameProvider,
IpcSampleProvider); IOException and OperationCanceledException still
propagate. Adds red-baseline regression tests for both providers.

Also hardens PrefetchSlot: Arm throws InvalidOperationException on a
double-arm (was Debug.Assert, compiled out in Release) so a silent
overwrite cannot strand an undrained response; key equality uses
EqualityComparer<TKey>.Default for null-safety; Detach clears the key and
buffer-index metadata.
@github-actions

Copy link
Copy Markdown
Contributor

No TODO comments were found.

@github-actions

Copy link
Copy Markdown
Contributor

Code Coverage

Package Line Rate Branch Rate Complexity Health
Beutl 13% 8% 10114
Beutl.Api 15% 7% 1170
Beutl.Configuration 50% 29% 350
Beutl.Controls 30% 13% 5513
Beutl.Core 65% 57% 3067
Beutl.Editor 79% 71% 1742
Beutl.Editor.Components 14% 8% 8556
Beutl.Embedding.MediaFoundation 6% 8% 1376
Beutl.Engine 63% 52% 18133
Beutl.Engine.SourceGenerators 59% 44% 540
Beutl.ExceptionHandler 0% 0% 43
Beutl.Extensibility 47% 65% 112
Beutl.Extensions.AVFoundation 5% 12% 246
Beutl.Extensions.FFmpeg 7% 8% 861
Beutl.FFmpegIpc 26% 33% 832
Beutl.Language 23% 50% 1349
Beutl.NodeGraph 24% 15% 2474
Beutl.PackageTools.UI 0% 0% 657
Beutl.ProjectSystem 61% 46% 1061
Beutl.Testing.Headless 88% 92% 15
Beutl.Threading 100% 90% 137
Beutl.Utilities 94% 87% 358
Beutl.WaitingDialog 0% 0% 34
Summary 36% (57083 / 158288) 29% (12790 / 44742) 58740

Minimum allowed line rate is 0%

@yuto-trd

Copy link
Copy Markdown
Member Author

Ready for human merge — all gates green

/beutl-loop has settled this PR; left for a human to merge (final call) because it is a larger diff (~586 LOC, 6 files) that changes a public method's exception contract (IpcConnection.SendAndReceiveAsync) in the encode/IPC-critical path — above the loop's auto-merge size/sensitivity threshold.

State:

  • CI: build, dotnet-format, find-todo-comments, CodeRabbit — all green
  • Reviews: @beutl-design-reviewer + @beutl-reviewer approve, no blocking findings
  • Threads: 0 unresolved
  • Tests: 49 passed / 0 failed (incl. red-baseline regression tests for both the cancel-reporting fix and the stale-prefetch-error fix)

Bot-review findings resolved during settle:

  • CodeRabbit (MAJOR, functional correctness): confirmed a real bug — draining a faulted stale prefetch on a seek re-threw the discarded item's FFmpegWorkerException and aborted the fresh request. Fixed in both providers (catch/discard only FFmpegWorkerException; IOException/OperationCanceledException still propagate) + red-baseline regression tests.
  • Copilot (hardening): PrefetchSlot.Arm throws on double-arm; EqualityComparer<TKey>.Default key equality; Detach clears metadata.
  • Design polish: <exception> XML docs on SendAndReceiveAsync, a multiplexed-path note, and the Arm guard.

A clean one-click squash-merge whenever you're ready.

@yuto-trd
yuto-trd merged commit 9de1580 into main Jul 5, 2026
12 checks passed
@yuto-trd
yuto-trd deleted the loop/ipcconnection-seam-prefetch-statemachine branch July 5, 2026 15:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants