diff --git a/README.md b/README.md index 508df7b..c135b59 100644 --- a/README.md +++ b/README.md @@ -810,6 +810,52 @@ setTimeout(async () => { }, 5000); ``` +### Releasing a Pipeline (dispose) + +Call `dispose()` when you are permanently done with a pipeline to free its native +memory right away, instead of waiting for the garbage collector to finalize the +wrapper. + +Why it matters: the native `GstPipeline` allocation (buffers, decoders, GStreamer +internals) lives outside V8's heap and is invisible to its GC accounting. A +pipeline you simply drop is only reclaimed when GC happens to collect the small JS +wrapper — and with a flat JS heap, V8 feels little pressure to do so. A long +running process that builds a pipeline per recording or transcode can watch RSS +climb steadily while the JS heap stays flat. `dispose()` closes that gap: it +drives the pipeline to NULL if it is not already there and drops the native +reference synchronously, so the memory is released as soon as the last reference +goes away rather than at some later GC. + +```javascript +import { Pipeline } from "gst-kit"; + +const pipeline = new Pipeline("videotestsrc ! theoraenc ! oggmux ! filesink location=out.ogv"); +await pipeline.play(); + +// ... record, then finish cleanly ... +pipeline.endOfStream(); +while (true) { + const msg = await pipeline.busPop(1000); + if (msg?.type === "eos") break; +} +await pipeline.stop(); + +// Release the native pipeline now rather than at some later GC. +pipeline.dispose(); + +// dispose() is terminal and idempotent. Any method call afterward throws +// "Pipeline used after dispose()", and a second dispose() is a harmless no-op. +``` + +**Key points:** + +- **Stop first (recommended)**: prefer `stop()` (or `endOfStream()` + wait for EOS, then `stop()`) before `dispose()`. If the pipeline has not reached the NULL state, `dispose()` drives it there synchronously before releasing it — GStreamer cannot cleanly free a non-NULL pipeline. That transition is normally instant, but on a not-fully-stopped pipeline it runs on the calling thread and is bounded by a 5s wait, so stopping first keeps `dispose()` cheap and non-blocking. +- **Terminal**: call it once, when the pipeline will not be used again. +- **Not a graceful stop**: `dispose()` does the hard release, not a flush. +- **Release elements first**: any element obtained via `getElementByName()`, and any pad-probe or `onSample()` subscription on it, must be released/unsubscribed before `dispose()`. Elements hold their own reference and are not invalidated by disposing the pipeline. +- **Idempotent**: calling `dispose()` again does nothing. +- **Use-after-dispose is loud**: subsequent method calls throw rather than touching freed memory. + ### Message Bus Handling ```javascript @@ -916,6 +962,9 @@ class Pipeline { // Message handling busPop(timeoutMs?: number): Promise; + + // Lifecycle — release the native pipeline and free its memory immediately + dispose(): void; } ``` @@ -1072,6 +1121,7 @@ gst-kit/ │ ├── appsrc.mjs # AppSrc usage │ ├── appsrc-eos.mjs # AppSrc with end-of-stream │ ├── pipeline-eos.mjs # Pipeline-level end-of-stream +│ ├── dispose.mjs # Releasing a pipeline's native memory │ ├── record-to-file.mjs # Recording to file example │ ├── rtp-timestamp.mjs # RTP handling │ ├── bus.mjs # Message bus handling diff --git a/examples/dispose.mjs b/examples/dispose.mjs new file mode 100644 index 0000000..5fb8fb1 --- /dev/null +++ b/examples/dispose.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +/** + * Pipeline Disposal Example + * + * Demonstrates dispose() — releasing a pipeline's native memory eagerly instead + * of waiting for garbage collection to finalize the wrapper. + * + * The native GstPipeline allocation is invisible to V8's heap accounting, so a + * pipeline you simply drop is not reclaimed until GC happens to collect the small + * JS wrapper. A process that builds a pipeline per unit of work (recording, + * transcode, feed) can see RSS climb steadily while the JS heap stays flat. + * dispose() closes that gap: it drives the pipeline to NULL if it is not already + * there and drops the native reference synchronously, so the memory is released + * without waiting for GC. Stopping first is recommended so dispose() stays cheap + * and non-blocking, but dispose() will force the NULL transition itself if + * needed. + * + * This example builds, plays, stops, and disposes many short-lived pipelines in a + * loop — the exact pattern where relying on GC timing leaks native memory. RSS is + * always reported; run with `node --expose-gc examples/dispose.mjs` to also force + * a GC at the end so the "after" figure excludes collectable JS wrappers. + */ +import { Pipeline } from "../dist/esm/index.mjs"; + +const ITERATIONS = 25; + +function rssMb() { + return (process.memoryUsage().rss / 1024 / 1024).toFixed(1); +} + +console.log(`📊 RSS before: ${rssMb()} MB`); + +for (let i = 1; i <= ITERATIONS; i++) { + const pipeline = new Pipeline("videotestsrc ! fakesink"); + + await pipeline.play(); + await pipeline.stop(); + + // Release the native pipeline now. Without this, each iteration's native + // allocation would linger until GC decided to collect the wrapper. + pipeline.dispose(); + + // dispose() is idempotent and terminal: a second call is a no-op, and any + // other method call after dispose() throws "Pipeline used after dispose()". + pipeline.dispose(); + try { + pipeline.playing(); + } catch (err) { + if (i === 1) console.log(`🔒 use-after-dispose is guarded: ${err.message}`); + } + + if (i % 5 === 0) console.log(`♻️ disposed ${i}/${ITERATIONS} — RSS: ${rssMb()} MB`); +} + +if (typeof globalThis.gc === "function") { + globalThis.gc(); +} + +console.log(`📊 RSS after: ${rssMb()} MB`); +console.log("✅ Done — native pipelines were released as each one finished."); diff --git a/src/cpp/async-workers.cpp b/src/cpp/async-workers.cpp index 4be5e3d..8fde89c 100644 --- a/src/cpp/async-workers.cpp +++ b/src/cpp/async-workers.cpp @@ -1,4 +1,5 @@ #include "async-workers.hpp" +#include "pipeline.hpp" #include "type-conversion.hpp" #include @@ -195,11 +196,12 @@ void PullSampleWorker::cleanup() { // StateChangeWorker implementation StateChangeWorker::StateChangeWorker( - const Napi::Env &env, GstPipeline *pipeline, GstState target_state, GstClockTime timeout + const Napi::Env &env, Pipeline *owner, GstPipeline *pipeline, GstState target_state, + GstClockTime timeout ) : - Napi::AsyncWorker(env), pipeline(pipeline), target_state(target_state), timeout(timeout), - state_change_result(GST_STATE_CHANGE_FAILURE), final_state(GST_STATE_VOID_PENDING), - deferred(env) { + Napi::AsyncWorker(env), owner(owner), pipeline(pipeline), target_state(target_state), + timeout(timeout), state_change_result(GST_STATE_CHANGE_FAILURE), + final_state(GST_STATE_VOID_PENDING), deferred(env), owner_notified(false) { // Increase reference count since we'll be using this in another thread gst_object_ref(pipeline); } @@ -257,11 +259,17 @@ void StateChangeWorker::OnOK() { result.Set("finalState", Napi::Number::New(Env(), final_state)); result.Set("targetState", Napi::Number::New(Env(), target_state)); + // Notify the owner before resolving so any deferred dispose() teardown runs + // while this worker's own reference is still held — the release then sees the + // last reference drop and finalizes a truly-NULL pipeline. + notify_owner_finished(); + deferred.Resolve(result); } void StateChangeWorker::OnError(const Napi::Error &error) { Napi::HandleScope scope(Env()); + notify_owner_finished(); deferred.Reject(error.Value()); } @@ -271,3 +279,12 @@ void StateChangeWorker::cleanup() { pipeline = nullptr; } } + +void StateChangeWorker::notify_owner_finished() { + // Runs on the JS thread (OnOK/OnError). Idempotent: OnOK and OnError are + // mutually exclusive, but guard anyway so the owner count can never be + // decremented twice for one worker. + if (owner_notified) return; + owner_notified = true; + owner->state_worker_finished(); +} diff --git a/src/cpp/async-workers.hpp b/src/cpp/async-workers.hpp index 63fae0f..a2d57c4 100644 --- a/src/cpp/async-workers.hpp +++ b/src/cpp/async-workers.hpp @@ -53,11 +53,14 @@ class PullSampleWorker : public Napi::AsyncWorker { Napi::Promise::Deferred deferred; }; +class Pipeline; + // AsyncWorker for pipeline state changes with timeout class StateChangeWorker : public Napi::AsyncWorker { public: StateChangeWorker( - const Napi::Env &env, GstPipeline *pipeline, GstState target_state, GstClockTime timeout + const Napi::Env &env, Pipeline *owner, GstPipeline *pipeline, GstState target_state, + GstClockTime timeout ); ~StateChangeWorker(); @@ -69,11 +72,14 @@ class StateChangeWorker : public Napi::AsyncWorker { private: void cleanup(); + void notify_owner_finished(); + Pipeline *owner; GstPipeline *pipeline; GstState target_state; GstClockTime timeout; GstStateChangeReturn state_change_result; GstState final_state; Napi::Promise::Deferred deferred; + bool owner_notified; }; diff --git a/src/cpp/pipeline.cpp b/src/cpp/pipeline.cpp index 86a3791..08e9bde 100644 --- a/src/cpp/pipeline.cpp +++ b/src/cpp/pipeline.cpp @@ -23,7 +23,8 @@ Napi::Object Pipeline::Init(const Napi::Env &env, const Napi::Object &exports) { } Pipeline::Pipeline(const Napi::CallbackInfo &info) : - Napi::ObjectWrap(info), pipeline(nullptr, gst_object_unref) { + Napi::ObjectWrap(info), pipeline(nullptr, gst_object_unref), disposed(false), + in_flight_state_changes(0) { ensure_gst_initialized(); Napi::Env env = info.Env(); GError *err = NULL; @@ -89,6 +90,11 @@ Pipeline::Pipeline(const Napi::CallbackInfo &info) : [this](const Napi::CallbackInfo &info) -> Napi::Value { return this->end_of_stream(info); }, "endOfStream" ); + auto dispose_method = Napi::Function::New( + env, + [this](const Napi::CallbackInfo &info) -> Napi::Value { return this->dispose(info); }, + "dispose" + ); thisObj.DefineProperties( {Napi::PropertyDescriptor::Value("play", play_method, napi_enumerable), @@ -102,10 +108,75 @@ Pipeline::Pipeline(const Napi::CallbackInfo &info) : Napi::PropertyDescriptor::Value("queryDuration", queryDuration_method, napi_enumerable), Napi::PropertyDescriptor::Value("busPop", busPop_method, napi_enumerable), Napi::PropertyDescriptor::Value("seek", seek_method, napi_enumerable), - Napi::PropertyDescriptor::Value("endOfStream", end_of_stream_method, napi_enumerable)} + Napi::PropertyDescriptor::Value("endOfStream", end_of_stream_method, napi_enumerable), + Napi::PropertyDescriptor::Value("dispose", dispose_method, napi_enumerable)} ); } +GstPipeline *Pipeline::require_pipeline(const Napi::Env &env) { + // `disposed` is the authoritative sentinel, not a null `pipeline`: when + // dispose() runs while a state-change worker is in flight the native teardown + // is deferred, so the pointer can still be non-null after dispose() returns. + // Guarding on the flag keeps use-after-dispose loud in that window too. + if (disposed || pipeline.get() == nullptr) { + Napi::Error::New(env, "Pipeline used after dispose()").ThrowAsJavaScriptException(); + return nullptr; + } + return pipeline.get(); +} + +void Pipeline::release_native_pipeline() { + GstPipeline *raw = pipeline.get(); + if (raw == nullptr) return; + + // The native pipeline must reach NULL before its reference is dropped: + // gst_object_unref on a non-NULL pipeline leaks its pads, bus, clock, and the + // elements' internal buffers (and can emit a g_critical). Callers stop() + // first, so this is normally already NULL. But stop() is bounded by a timeout + // and its NULL transition may not have fully settled — so rather than skip the + // unref (which guarantees the leak we are trying to prevent), force the + // pipeline to NULL here, synchronously, and then release. Setting an + // already-NULL pipeline to NULL is a cheap no-op, so the common stopped-first + // path pays almost nothing; the blocking transition only happens on the rare + // not-fully-stopped path, where it is exactly what prevents the leak. + GstState state; + GstState pending; + gst_element_get_state(GST_ELEMENT(raw), &state, &pending, 0); + if (state != GST_STATE_NULL || pending != GST_STATE_VOID_PENDING) { + gst_element_set_state(GST_ELEMENT(raw), GST_STATE_NULL); + // Block until the NULL transition completes so the unref below finalizes a + // truly-NULL pipeline. NULL is reached synchronously for virtually all + // pipelines; the wait is bounded so a pathological element cannot hang here. + gst_element_get_state(GST_ELEMENT(raw), &state, &pending, 5 * GST_SECOND); + } + + // Drop our reference and let unique_ptr's deleter (gst_object_unref) run. + pipeline.reset(); +} + +void Pipeline::state_worker_started() { + // Keep the JS wrapper alive for as long as a worker holds a back-pointer to + // this Pipeline, so the worker's OnOK/OnError can safely call back into it + // even if all JS references are dropped while the state change is in flight. + if (in_flight_state_changes == 0) Ref(); + in_flight_state_changes++; +} + +void Pipeline::state_worker_finished() { + if (in_flight_state_changes > 0) in_flight_state_changes--; + + // If dispose() was requested while workers were running, the last worker to + // finish performs the deferred native teardown. By now no state change can + // drive the pipeline back up, so forcing NULL and releasing is safe. + if (disposed && in_flight_state_changes == 0) { + release_native_pipeline(); + } + + // Balance the Ref() taken in state_worker_started() once the last worker is + // done. This may finalize the wrapper, so touch no members afterward. + if (in_flight_state_changes == 0) Unref(); +} + Napi::Value Pipeline::play(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); @@ -123,10 +194,13 @@ Napi::Value Pipeline::play(const Napi::CallbackInfo &info) { } } + GstPipeline *raw = require_pipeline(env); + if (raw == nullptr) return env.Undefined(); + // Create worker and get its promise - StateChangeWorker *worker = - new StateChangeWorker(env, pipeline.get(), GST_STATE_PLAYING, timeout); + StateChangeWorker *worker = new StateChangeWorker(env, this, raw, GST_STATE_PLAYING, timeout); Napi::Promise promise = worker->GetPromise().Promise(); + state_worker_started(); worker->Queue(); return promise; @@ -149,9 +223,13 @@ Napi::Value Pipeline::pause(const Napi::CallbackInfo &info) { } } + GstPipeline *raw = require_pipeline(env); + if (raw == nullptr) return env.Undefined(); + // Create worker and get its promise - StateChangeWorker *worker = new StateChangeWorker(env, pipeline.get(), GST_STATE_PAUSED, timeout); + StateChangeWorker *worker = new StateChangeWorker(env, this, raw, GST_STATE_PAUSED, timeout); Napi::Promise promise = worker->GetPromise().Promise(); + state_worker_started(); worker->Queue(); return promise; @@ -174,17 +252,25 @@ Napi::Value Pipeline::stop(const Napi::CallbackInfo &info) { } } + GstPipeline *raw = require_pipeline(env); + if (raw == nullptr) return env.Undefined(); + // Create worker and get its promise - StateChangeWorker *worker = new StateChangeWorker(env, pipeline.get(), GST_STATE_NULL, timeout); + StateChangeWorker *worker = new StateChangeWorker(env, this, raw, GST_STATE_NULL, timeout); Napi::Promise promise = worker->GetPromise().Promise(); + state_worker_started(); worker->Queue(); return promise; } Napi::Value Pipeline::get_element_by_name(const Napi::CallbackInfo &info) { + Napi::Env env = info.Env(); + GstPipeline *raw = require_pipeline(env); + if (raw == nullptr) return env.Undefined(); + auto name = info[0].As().Utf8Value(); - GstElement *e = gst_bin_get_by_name(GST_BIN(pipeline.get()), name.c_str()); + GstElement *e = gst_bin_get_by_name(GST_BIN(raw), name.c_str()); if (e == nullptr) return info.Env().Null(); @@ -193,10 +279,14 @@ Napi::Value Pipeline::get_element_by_name(const Napi::CallbackInfo &info) { } Napi::Value Pipeline::playing(const Napi::CallbackInfo &info) { + Napi::Env env = info.Env(); + GstPipeline *raw = require_pipeline(env); + if (raw == nullptr) return env.Undefined(); + GstState state; GstState pending; GstStateChangeReturn ret = - gst_element_get_state(GST_ELEMENT(pipeline.get()), &state, &pending, 5 * GST_MSECOND); + gst_element_get_state(GST_ELEMENT(raw), &state, &pending, 5 * GST_MSECOND); // If state change is in progress and we're transitioning to PLAYING, consider it as playing bool is_playing = @@ -206,15 +296,23 @@ Napi::Value Pipeline::playing(const Napi::CallbackInfo &info) { } Napi::Value Pipeline::query_position(const Napi::CallbackInfo &info) { + Napi::Env env = info.Env(); + GstPipeline *raw = require_pipeline(env); + if (raw == nullptr) return env.Undefined(); + gint64 pos; - gst_element_query_position(GST_ELEMENT(pipeline.get()), GST_FORMAT_TIME, &pos); + gst_element_query_position(GST_ELEMENT(raw), GST_FORMAT_TIME, &pos); double r = pos == -1 ? -1 : (double)pos / GST_SECOND; return Napi::Number::New(info.Env(), r); } Napi::Value Pipeline::query_duration(const Napi::CallbackInfo &info) { + Napi::Env env = info.Env(); + GstPipeline *raw = require_pipeline(env); + if (raw == nullptr) return env.Undefined(); + gint64 dur; - gst_element_query_duration(GST_ELEMENT(pipeline.get()), GST_FORMAT_TIME, &dur); + gst_element_query_duration(GST_ELEMENT(raw), GST_FORMAT_TIME, &dur); double r = dur == -1 ? -1 : (double)dur / GST_SECOND; return Napi::Number::New(info.Env(), r); } @@ -236,8 +334,11 @@ Napi::Value Pipeline::bus_pop(const Napi::CallbackInfo &info) { } } + GstPipeline *raw = require_pipeline(env); + if (raw == nullptr) return env.Undefined(); + // Create worker and get its promise - BusPopWorker *worker = new BusPopWorker(env, pipeline.get(), timeout); + BusPopWorker *worker = new BusPopWorker(env, raw, timeout); Napi::Promise promise = worker->GetPromise().Promise(); worker->Queue(); @@ -260,12 +361,15 @@ Napi::Value Pipeline::seek(const Napi::CallbackInfo &info) { return env.Undefined(); } + GstPipeline *raw = require_pipeline(env); + if (raw == nullptr) return env.Undefined(); + // Convert seconds to nanoseconds GstClockTime position_ns = static_cast(position_seconds * GST_SECOND); // Perform the seek gboolean result = gst_element_seek( - GST_ELEMENT(pipeline.get()), + GST_ELEMENT(raw), 1.0, // Rate (1.0 = normal speed) GST_FORMAT_TIME, // Format (time-based seeking) GST_SEEK_FLAG_FLUSH, // Flags (flush pipeline) @@ -280,6 +384,8 @@ Napi::Value Pipeline::seek(const Napi::CallbackInfo &info) { Napi::Value Pipeline::end_of_stream(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); + GstPipeline *raw = require_pipeline(env); + if (raw == nullptr) return env.Undefined(); // Query pipeline state with a 5ms timeout // Note: Sending EOS to a PAUSED pipeline where sinks have not yet prerolled @@ -287,7 +393,7 @@ Napi::Value Pipeline::end_of_stream(const Napi::CallbackInfo &info) { // which waits on the preroll condition. This is a GStreamer-level behavior. GstState state; GstState pending; - gst_element_get_state(GST_ELEMENT(pipeline.get()), &state, &pending, 5 * GST_MSECOND); + gst_element_get_state(GST_ELEMENT(raw), &state, &pending, 5 * GST_MSECOND); // Only send EOS if pipeline is in PLAYING or PAUSED state if (state != GST_STATE_PLAYING && state != GST_STATE_PAUSED) { @@ -295,11 +401,37 @@ Napi::Value Pipeline::end_of_stream(const Napi::CallbackInfo &info) { } // Send EOS event to the pipeline - gboolean result = gst_element_send_event(GST_ELEMENT(pipeline.get()), gst_event_new_eos()); + gboolean result = gst_element_send_event(GST_ELEMENT(raw), gst_event_new_eos()); return Napi::Boolean::New(env, result); } +Napi::Value Pipeline::dispose(const Napi::CallbackInfo &info) { + Napi::Env env = info.Env(); + + // Idempotent: once disposed, further calls are no-ops. + if (disposed) return env.Undefined(); + + // Mark disposed immediately so use-after-dispose is guarded and a second + // dispose() is a no-op, regardless of whether the native teardown runs now or + // is deferred below. + disposed = true; + + // dispose() drives the pipeline to NULL and drops the native reference. It + // must not do that while a state-change worker (play/pause/stop) is still in + // flight: that worker holds its own reference and can drive the state back up + // after we force NULL, leaving it to finalize a non-NULL pipeline — which + // leaks exactly what dispose() exists to reclaim. So when a worker is in + // flight, defer the teardown; the last worker to finish runs it (see + // state_worker_finished()). In-flight busPop()/pull workers do not change + // state, so they do not need to gate the teardown. + if (in_flight_state_changes > 0) return env.Undefined(); + + release_native_pipeline(); + + return env.Undefined(); +} + Napi::Value Pipeline::ElementExists(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); diff --git a/src/cpp/pipeline.hpp b/src/cpp/pipeline.hpp index a976577..d98fa04 100644 --- a/src/cpp/pipeline.hpp +++ b/src/cpp/pipeline.hpp @@ -23,10 +23,18 @@ class Pipeline : public Napi::ObjectWrap { Napi::Value bus_pop(const Napi::CallbackInfo &info); Napi::Value seek(const Napi::CallbackInfo &info); Napi::Value end_of_stream(const Napi::CallbackInfo &info); + Napi::Value dispose(const Napi::CallbackInfo &info); + + void state_worker_started(); + void state_worker_finished(); private: std::string pipeline_string; std::unique_ptr pipeline; + bool disposed; + int in_flight_state_changes; static bool gst_initialized; static void ensure_gst_initialized(); + GstPipeline *require_pipeline(const Napi::Env &env); + void release_native_pipeline(); }; diff --git a/src/ts/index.ts b/src/ts/index.ts index c9c594b..7049312 100644 --- a/src/ts/index.ts +++ b/src/ts/index.ts @@ -155,6 +155,7 @@ interface Pipeline { busPop(timeoutMs?: number): Promise; seek(positionSeconds: number): boolean; endOfStream(): boolean; + dispose(): void; } interface PipelineConstructor { diff --git a/src/ts/pipeline-dispose.test.ts b/src/ts/pipeline-dispose.test.ts new file mode 100644 index 0000000..1687a0d --- /dev/null +++ b/src/ts/pipeline-dispose.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import { Pipeline } from "."; + +describe("Pipeline dispose()", () => { + it("should dispose a pipeline that was never played", () => { + const pipeline = new Pipeline("videotestsrc ! fakesink"); + + // Disposing a freshly constructed (never played) pipeline is valid and + // should not throw. + expect(() => pipeline.dispose()).not.toThrow(); + }); + + it("should dispose a pipeline after play and stop", async () => { + const pipeline = new Pipeline("videotestsrc ! fakesink"); + + await pipeline.play(); + expect(pipeline.playing()).toBe(true); + await pipeline.stop(); + + expect(() => pipeline.dispose()).not.toThrow(); + }); + + it("should dispose a still-playing pipeline by forcing it to NULL first", async () => { + const pipeline = new Pipeline("videotestsrc ! fakesink"); + + await pipeline.play(); + expect(pipeline.playing()).toBe(true); + + // Callers are expected to stop() first, but dispose() must not leak if they + // don't: rather than throw (which would skip the unref and leak the native + // pipeline), dispose() drives the pipeline to NULL synchronously and then + // releases it. So disposing a still-playing pipeline is safe and does not + // throw. + expect(() => pipeline.dispose()).not.toThrow(); + + // After dispose() the pipeline is released; any further use throws. + expect(() => pipeline.playing()).toThrow(/used after dispose/); + }); + + it("should be safe to dispose after a worker started against the pipeline resolves", async () => { + const pipeline = new Pipeline("videotestsrc ! fakesink"); + + await pipeline.play(); + + // Start an async worker (busPop) that holds its own gst_object_ref while it + // runs. The pipeline is stopped and the worker awaited before dispose(), so + // the reference the worker held is already released. + const pending = pipeline.busPop(1000); + await pipeline.stop(); + await pending; + + expect(() => pipeline.dispose()).not.toThrow(); + }); + + it("should dispose safely while a state-change worker is still in flight", async () => { + const pipeline = new Pipeline("videotestsrc ! videoconvert ! queue ! fakesink"); + + // Start play() but do NOT await it: the StateChangeWorker is queued and will + // run on a background thread after dispose() returns. dispose() must defer + // its native teardown until that worker finishes, otherwise the worker + // drives the state back up and finalizes a non-NULL pipeline (a leak). + const playing = pipeline.play(); + + // dispose() while the worker is in flight — marks disposed immediately and + // defers the release. + expect(() => pipeline.dispose()).not.toThrow(); + + // The pipeline is observably disposed right away. + expect(() => pipeline.playing()).toThrow(/used after dispose/); + + // Awaiting the in-flight worker completes without error; the deferred + // teardown runs when it finishes. + await expect(playing).resolves.toBeDefined(); + + // Still disposed, and a second dispose() is a no-op. + expect(() => pipeline.dispose()).not.toThrow(); + }); + + it("should not grow RSS when disposing with a state change in flight repeatedly", async () => { + // Guards finding 1: a queued state-change worker must not finalize a + // non-NULL pipeline after dispose(). Without the deferred teardown this loop + // leaks several MB per iteration; with it, RSS stays essentially flat. + const iterations = 100; + + const build = () => new Pipeline("videotestsrc ! videoconvert ! queue ! fakesink"); + + // Warm up so first-touch allocations don't skew the baseline. + for (let i = 0; i < 10; i++) { + const p = build(); + const playing = p.play(); + p.dispose(); + await playing; + } + if (typeof globalThis.gc === "function") globalThis.gc(); + const before = process.memoryUsage().rss; + + for (let i = 0; i < iterations; i++) { + const p = build(); + const playing = p.play(); // in flight + p.dispose(); // deferred teardown + await playing; // teardown runs here + } + + if (typeof globalThis.gc === "function") globalThis.gc(); + const after = process.memoryUsage().rss; + + const growthMb = (after - before) / 1024 / 1024; + // A per-iteration leak of the ~3 MB pipeline would be hundreds of MB over + // 100 iterations. Allow generous headroom for allocator/GC noise. + expect(growthMb).toBeLessThan(50); + }); + + it("should be idempotent — a second dispose() is a no-op", async () => { + const pipeline = new Pipeline("videotestsrc ! fakesink"); + + await pipeline.play(); + await pipeline.stop(); + + pipeline.dispose(); + // Calling dispose() again must not throw. + expect(() => pipeline.dispose()).not.toThrow(); + }); + + it("should throw on synchronous method calls after dispose()", async () => { + const pipeline = new Pipeline("videotestsrc ! fakesink"); + + await pipeline.play(); + await pipeline.stop(); + pipeline.dispose(); + + // Every driver-delegating method routes through the native disposed guard, + // so a use-after-dispose is a loud error rather than a native null-deref. + expect(() => pipeline.playing()).toThrow(/used after dispose/); + expect(() => pipeline.queryPosition()).toThrow(/used after dispose/); + expect(() => pipeline.queryDuration()).toThrow(/used after dispose/); + expect(() => pipeline.getElementByName("sink")).toThrow(/used after dispose/); + expect(() => pipeline.seek(0)).toThrow(/used after dispose/); + expect(() => pipeline.endOfStream()).toThrow(/used after dispose/); + }); + + it("should throw on async method calls after dispose()", async () => { + const pipeline = new Pipeline("videotestsrc ! fakesink"); + + await pipeline.play(); + await pipeline.stop(); + pipeline.dispose(); + + // Async methods throw synchronously (at call time) because the disposed + // guard runs before the worker is queued. + expect(() => pipeline.play()).toThrow(/used after dispose/); + expect(() => pipeline.pause()).toThrow(/used after dispose/); + expect(() => pipeline.stop()).toThrow(/used after dispose/); + expect(() => pipeline.busPop(0)).toThrow(/used after dispose/); + }); +});