Skip to content

Improvement: HKSV recording stream AbortSignal support and graceful generator termination.#1110

Merged
hjdhjd merged 1 commit into
latestfrom
hksv-abort-signal-recording-stream
Mar 21, 2026
Merged

Improvement: HKSV recording stream AbortSignal support and graceful generator termination.#1110
hjdhjd merged 1 commit into
latestfrom
hksv-abort-signal-recording-stream

Conversation

@hjdhjd

@hjdhjd hjdhjd commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add AbortSignal support to CameraRecordingDelegate.handleRecordingStreamRequest() for immediate, cooperative cancellation of recording stream generators when an HDS stream closes.
  • Call generator.return() in handleClosed() as a protocol-level backstop to ensure generator termination.
  • Add idempotency guard to handleClosed() to prevent double invocation from batched TCP messages.
  • Downgrade the "Delegate yielded fragment after stream was already closed" message from console.error to debug().
  • Extensively tested with homebridge-unifi-protect and validated as working correctly.

Background

When an HDS recording stream closes, handleClosed() sets this.closed = true and calls closeRecordingStream() on the delegate. The delegate's async generator is expected to stop yielding. However, if the generator is suspended in an async operation (e.g. a pacing delay or FFmpeg wait), it can't observe the close signal until that operation completes — which may be seconds later. By then, the generator yields one more fragment to a closed stream, triggering the "Delegate yielded fragment after stream was already closed" error.

This is an inherent race condition in the async generator protocol: handleClosed() can't preemptively stop a generator that has a pending .next() call. The consumer must wait for the generator to yield before it can break the loop.

Additionally, handleClosed() lacked an idempotency guard — unlike close() which checks this.closed before proceeding, handleClosed() could be invoked multiple times when multiple close-related messages arrive in the same TCP chunk and are processed in the forEach loop within onSocketData. This resulted in closeRecordingStream() being called twice for the same stream, causing duplicate side effects in delegate implementations.

Approach

Four changes, each independently tested and validated:

  1. AbortSignalhandleClosed() aborts the signal immediately alongside setting this.closed. Delegates that make their async operations abortable (e.g. using node:timers/promises setTimeout with a signal) wake up instantly and can return without yielding. This is the modern platform pattern for cooperative async cancellation.

  2. generator.return() — Called in handleClosed() as a fire-and-forget signal. Per the async generator spec, the return is queued and takes effect after the generator's current await completes. This ensures termination even for delegates that don't adopt the AbortSignal.

  3. handleClosed() idempotency guard — Matches the existing pattern in close(). Prevents double invocation when batched TCP messages trigger multiple close events for the same stream in a single onSocketData processing cycle.

  4. debug() downgrade — The post-close yield is an inherent race for delegates that haven't adopted the signal. The fragment is already discarded and the loop breaks. Logging it as console.error implies a programming mistake when it's actually a protocol-level timing condition.

Backwards compatibility

The signal parameter on handleRecordingStreamRequest() is optional. Existing delegates that don't declare it continue working exactly as before — the signal is passed but ignored. The generator.return() backstop ensures termination regardless of delegate adoption.

Test plan

  • Existing HKSV recording functionality is unaffected — recordings start, stream, and end normally.
  • The "Delegate yielded fragment after stream was already closed" console.error no longer appears in production logs.
  • Delegates using abortable async operations (e.g. setTimeout with signal) exit immediately on stream close without yielding post-close fragments.
  • Delegates NOT using the signal still terminate via the generator.return() backstop.
  • handleClosed() idempotency guard prevents duplicate closeRecordingStream() calls from batched TCP messages.

Backport

Once merged, we should be cherry-picked to the release-0.x branch. The recording management code is identical between 0.14.x and 2.x for the paths we're modifying — the cherry-pick applies cleanly (verified).

@coveralls

coveralls commented Mar 20, 2026

Copy link
Copy Markdown

Pull Request Test Coverage Report for Build 23367641817

Details

  • 0 of 9 (0.0%) changed or added relevant lines in 1 file are covered.
  • 3 unchanged lines in 1 file lost coverage.
  • Overall coverage decreased (-0.06%) to 63.416%

Changes Missing Coverage Covered Lines Changed/Added Lines %
src/lib/camera/RecordingManagement.ts 0 9 0.0%
Files with Coverage Reduction New Missed Lines %
src/lib/camera/RecordingManagement.ts 3 55.27%
Totals Coverage Status
Change from base Build 21796348228: -0.06%
Covered Lines: 6262
Relevant Lines: 9333

💛 - Coveralls

Copilot AI 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.

Pull request overview

This PR improves HomeKit Secure Video (HKSV) recording stream shutdown behavior by introducing cooperative cancellation via AbortSignal, adding a protocol-level generator termination backstop, and making close handling idempotent to avoid duplicate delegate side effects.

Changes:

  • Add an optional AbortSignal parameter to CameraRecordingDelegate.handleRecordingStreamRequest() to support immediate cooperative cancellation.
  • In CameraRecordingStream, abort the signal and invoke generator.return() during close handling, plus guard handleClosed() for idempotency.
  • Downgrade the “delegate yielded after close” log from console.error to debug().

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/lib/controller/CameraController.ts Extends the recording delegate API with an optional AbortSignal to support cooperative cancellation.
src/lib/camera/RecordingManagement.ts Implements abort + generator termination on close and prevents double-close side effects; adjusts logging level.

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

Comment thread src/lib/camera/RecordingManagement.ts Outdated
donavanbecker
donavanbecker previously approved these changes Mar 20, 2026
@hjdhjd
hjdhjd enabled auto-merge (squash) March 21, 2026 01:58
@hjdhjd
hjdhjd merged commit 279dde4 into latest Mar 21, 2026
16 checks passed
@hjdhjd
hjdhjd deleted the hksv-abort-signal-recording-stream branch March 21, 2026 03:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants