Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions src/lib/camera/RecordingManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,7 @@
readonly delegate: CameraRecordingDelegate;
readonly hdsRequestId: number;
readonly streamId: number;
private abortController?: AbortController;
private closed = false;

eventHandler?: Record<string, EventHandler> = {
Expand Down Expand Up @@ -997,11 +998,12 @@
let lastFragmentWasMarkedLast = false;

try {
this.generator = this.delegate.handleRecordingStreamRequest(this.streamId);
this.abortController = new AbortController();
this.generator = this.delegate.handleRecordingStreamRequest(this.streamId, this.abortController.signal);

for await (const packet of this.generator) {
if (this.closed) {
console.error(`[HDS ${this.connection.remoteAddress}] Delegate yielded fragment after stream ${this.streamId} was already closed!`);
debug("[HDS %s] Delegate yielded fragment after stream %d was already closed.", this.connection.remoteAddress, this.streamId);
break;
}

Expand Down Expand Up @@ -1082,6 +1084,7 @@
}
return;
} finally {
this.abortController = undefined;
this.generator = undefined;

if (this.generatorTimeout) {
Expand Down Expand Up @@ -1137,8 +1140,16 @@
}

private handleClosed(closure: () => void): void {
if (this.closed) {
return;
}

this.closed = true;

// Signal the delegate's generator to stop immediately. This allows delegates with abortable async operations (e.g. pacing delays) to interrupt them and
// return without yielding to a closed stream.
this.abortController?.abort();

if (this.closingTimeout) {
clearTimeout(this.closingTimeout);
this.closingTimeout = undefined;
Expand All @@ -1148,6 +1159,13 @@
this.connection.removeListener(DataStreamConnectionEvent.CLOSED, this.closeListener);

if (this.generator) {
// Signal the generator to terminate via the async generator protocol. The return is queued and takes effect after the generator's current await completes.

Check warning on line 1162 in src/lib/camera/RecordingManagement.ts

View workflow job for this annotation

GitHub Actions / lint / ESLint

This line has a length of 161. Maximum allowed is 160
// Combined with the AbortSignal above, this ensures the generator terminates promptly even if the delegate doesn't check the signal. We catch rejections
// to prevent unhandled promise warnings if the generator's cleanup throws.
// @ts-expect-error: AsyncGenerator.return() requires a value parameter, but we're signaling termination — no meaningful RecordingPacket to provide.
void this.generator.return().catch((error: Error) =>
debug("[HDS %s] Error while closing recording generator for stream %d: %s", this.connection.remoteAddress, this.streamId, error.stack ?? String(error)));

Check warning on line 1167 in src/lib/camera/RecordingManagement.ts

View workflow job for this annotation

GitHub Actions / lint / ESLint

This line has a length of 161. Maximum allowed is 160

// when this variable is defined, the generator hasn't returned yet.
// we start a timeout to uncover potential programming mistakes where we await forever and can't free resources.
this.generatorTimeout = setTimeout(() => {
Expand Down
4 changes: 3 additions & 1 deletion src/lib/controller/CameraController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,10 @@ export interface CameraRecordingDelegate {
* NOTE: Don't rely on the streamId for unique identification. Two {@link DataStreamConnection}s might share the same identifier space.
*
* @param streamId - The streamId of the currently ongoing stream.
* @param signal - An optional {@link AbortSignal} that is aborted when the recording stream is closed. Delegates can use this to interrupt pending async
* operations (e.g. pacing delays) for immediate cleanup, rather than waiting for {@link closeRecordingStream} to be called.
*/
handleRecordingStreamRequest(streamId: number): AsyncGenerator<RecordingPacket>;
handleRecordingStreamRequest(streamId: number, signal?: AbortSignal): AsyncGenerator<RecordingPacket>;

/**
* This method is called once the HomeKit Controller acknowledges the `endOfStream`.
Expand Down
Loading