fix: two long-running HLS transcode issues (#355)

* fix: release targets from Output._targets on finalize

Long-running HLS transcodes leak memory. Every finalized BufferTarget
stays in _targets until the outer Output closes, pinning its buffer.
Writer.finalize() already does this cleanup for writer-based flows;
extend it to buffer-finalize paths via the public 'finalized' event.

* fix: ReadOrchestrator LRU eviction picks only drained workers

assert(pendingSlices.length === 0) fires under heavy concurrent reads
(e.g. multi-rendition HLS decode from BlobSource). LRU filter only
checked !running; workers with queued slices could be evicted.
Add pendingSlices.length === 0 to the filter.

* fix: export AppendOnlyStreamTarget from index

Missing from the re-export; public docs import it by name.

* fix: join HLS init segment path with root + playlist path

Init path went through _getTarget bare; segments got joined with rootPath + playlist.path. Playlist-relative URI then can't resolve when the playlist lives in a subdirectory.

* Fix targets not being cleaned up, fix paused workers with remaining pending slices, modify doc block, fixed isRoot not being changed on proxied requests

---------

Co-authored-by: Vanilagy <[email protected]>
This commit is contained in:
Ahmed Rowaihi
2026-04-23 14:37:56 +02:00
committed by GitHub
co-authored by Vanilagy
parent 7acae8dda1
commit b05cdbe7e0
10 changed files with 151 additions and 109 deletions
+14 -18
View File
@@ -378,7 +378,7 @@ export class Output<
/** @internal */
_muxer: Muxer;
/** @internal */
_targets = new Set<Target>();
_unfinalizedTargets = new Set<Target>();
/** @internal */
_rootWriterPromise: Promise<Writer> | null = null;
/** @internal */
@@ -439,12 +439,7 @@ export class Output<
throw new TypeError('options.target must be a Target or a PathedTarget.');
}
if (options.target instanceof Target) {
if (options.target._output) {
throw new Error('Target is already used for another output.');
}
options.target._output = this;
this._targets.add(options.target);
this._rememberTarget(options.target);
}
if (
options.initTarget !== undefined
@@ -466,8 +461,7 @@ export class Output<
this._initTarget = options.initTarget ?? null;
if (this._initTarget instanceof Target) {
this._initTarget._output = this;
this._targets.add(this._initTarget);
this._rememberTarget(this._initTarget);
}
this._muxer = options.format._createMuxer(this);
@@ -498,18 +492,23 @@ export class Output<
assert(this._target instanceof PathedTarget);
const target = await this._getTargetValidated(request);
target._output = this;
this._emit('target', { target, request, isRoot: request.isRoot });
if (this.state === 'canceled') {
await target._close();
} else {
this._targets.add(target);
this._rememberTarget(target);
}
return target;
}
/** @internal */
_rememberTarget(target: Target) {
this._unfinalizedTargets.add(target);
target.on('finalized', () => this._unfinalizedTargets.delete(target), { once: true });
}
/** @internal */
async _getInitTarget(): Promise<T> {
assert(this._initTarget !== null);
@@ -519,12 +518,11 @@ export class Output<
}
const target = await this._initTarget();
target._output = this;
if (this.state === 'canceled') {
await target._close();
} else {
this._targets.add(target);
this._rememberTarget(target);
}
return target;
@@ -558,13 +556,11 @@ export class Output<
const result = this._getTargetValidated(request);
const handleResult = (target: T) => {
target._output = this;
if (this.state === 'canceled') {
// Promise thrown away here, but no way to surface it to the user really
void target._close();
} else {
this._targets.add(target);
this._rememberTarget(target);
}
this._emit('target', { target, request, isRoot: true });
@@ -849,8 +845,8 @@ export class Output<
const promises = this._tracks.map(x => x.source._flushOrWaitForOngoingClose(true)); // Force close
await Promise.all(promises);
await Promise.all([...this._targets].map(target => target._close()));
this._targets.clear();
await Promise.all([...this._unfinalizedTargets].map(target => target._close()));
this._unfinalizedTargets.clear();
} finally {
release();
}