Skip to content

Feature/pipeline explicit native release - #1

Open
IrishBAM wants to merge 7 commits into
mainfrom
feature/pipeline-explicit-native-release
Open

Feature/pipeline explicit native release#1
IrishBAM wants to merge 7 commits into
mainfrom
feature/pipeline-explicit-native-release

Conversation

@IrishBAM

@IrishBAM IrishBAM commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Add Pipeline.dispose() for explicit native release

WHAT

Adds a dispose() method to Pipeline that releases the underlying native
GstPipeline immediately, plus a use-after-dispose guard, TypeScript type,
documentation, an example, and unit tests.

  • src/cpp/pipeline.cpp / pipeline.hpp — new dispose(); drops the owning
    reference (unique_ptr::reset()gst_object_unref) without issuing a state
    change. Adds a require_pipeline() guard so every method throws
    Pipeline used after dispose() instead of dereferencing a freed pointer.
  • src/ts/index.tsdispose(): void on the Pipeline interface.
  • src/ts/pipeline-dispose.test.ts — coverage for idempotency and the
    use-after-dispose guard (sync + async methods).
  • README.md / examples/dispose.mjs — usage, rationale, and the disposal
    contract.

WHY

The native GstPipeline allocation (buffers, decoders, GStreamer internals)
lives outside V8's heap and is invisible to its GC accounting. A pipeline that
is simply dropped 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 unit of work (recording,
transcode, feed) can watch RSS climb steadily while the JS heap stays flat.
There was previously no way to release the native pipeline eagerly.

HOW

  • dispose() drops the wrapper's owning reference; GStreamer tears the pipeline
    down to NULL when the last reference is released. It does not issue a
    synchronous gst_element_set_state(NULL), which would block the JS thread —
    callers stop() first (the documented contract), matching how
    play/pause/stop offload transitions to a worker.
  • Thread-safe by construction: in-flight BusPopWorker/StateChangeWorker
    instances each hold their own gst_object_ref (see async-workers.cpp), so
    dropping the wrapper's reference cannot free the pipeline out from under a
    running busPop()/state change.
  • Terminal and idempotent: a second dispose() is a no-op; any other method
    call afterward throws via the require_pipeline() guard.
  • Documented lifecycle: stop() (or endOfStream() + wait for EOS, then
    stop()) before dispose(), and release any getElementByName() elements /
    pad-probe / onSample() subscriptions first, since those hold independent
    references and are not invalidated by disposing the pipeline.

Testing

  • npm run build (native + TS) and npm run lint clean.
  • Dispose suite passes; full unit suite green.

Release the native GstPipeline eagerly instead of waiting for GC to
finalize the wrapper, avoiding unbounded native memory growth when a
pipeline is built per unit of work. dispose() drives the pipeline to
NULL and drops the owning reference; a require_pipeline guard makes any
use-after-dispose throw. Terminal and idempotent.

Adds unit tests, README docs, and an example.
dispose() no longer issues a synchronous gst_element_set_state(NULL) on
the JS thread — it just drops the owning reference via reset(), letting
GStreamer tear the pipeline down when the last ref is released. Removes
the event-loop blocking and the ignored state-change return raised in
review. Document the stop()-and-release-elements-before-dispose contract
and adjust the still-playing dispose test.
@mlbrig

mlbrig commented Sep 3, 2026

Copy link
Copy Markdown

Review Target

feature/pipeline-explicit-native-release @ 1a634d2main · 6 files, +268/-18 — full diff loaded
Application source (C++/N-API addon plus TypeScript). The native teardown claim was traced against upstream gst_element_dispose, not executed — GStreamer is not installed on the reviewing machine.

Prior Review Status

No prior review comments, review bodies, or inline comments on this PR.

Spec Compliance: ISSUES FOUND

Interpretation Gaps

  1. Teardown premise is inverted (HOW, bullet 1) — src/cpp/pipeline.cpp:355-363
    The description states GStreamer tears the pipeline down to NULL when the last reference is released. Upstream does the opposite: it refuses to clean up and warns. See finding 1.

  2. Documentation deliverable contradicts the shipped semanticsREADME.md:824-826
    The PR promises documentation of the disposal contract. The rationale paragraph still describes the first commit's set_state(NULL) behaviour, which the second commit removed. See finding 3.

Everything else claimed is present and verified: dispose(), the require_pipeline() guard on all ten methods, the TypeScript type, the example, and the test suite.

Summary

Adds Pipeline.dispose() to release the native GstPipeline eagerly, with a use-after-dispose guard on every method. The guard is well built, but dropping the last reference without a state change is the one thing GStreamer explicitly refuses to handle, so the release path leaks exactly the memory the feature exists to reclaim.

Critical Issues

1. Disposing a non-NULL pipeline skips GStreamer teardown

src/cpp/pipeline.cpp:355-363

  // … When the last reference goes away GStreamer
  // tears the pipeline down to NULL itself. …
  pipeline.reset();

Problem — upstream gst_element_dispose does the opposite: when the state is not NULL it emits g_critical and returns before releasing pads, bus, clock and contexts.
Riskstop() resolves even when the transition fails (async-workers.cpp:260), so the documented stop-first path can still leave a non-NULL pipeline. Streaming threads then run against unparented children, the memory leaks, and G_DEBUG=fatal-criticals aborts.
Fix — set GST_STATE_NULL before pipeline.reset(), or throw when the pipeline is not already in NULL.

Major Issues

2. Test asserts the unsupported sequence is safe

src/ts/pipeline-dispose.test.ts:23-33

  it("should not throw when disposing a still-playing pipeline", async () => {
    
    // and GStreamer tears the pipeline down when the last reference is released.
    expect(() => pipeline.dispose()).not.toThrow();
  });

Problem — the assertion holds only because g_critical writes to stderr instead of throwing, so the test passes while the pipeline leaks.
Risk — CI stays green over finding 1, and the suite records the unsupported sequence as a supported one.
Fix — once finding 1 is resolved, stop the pipeline first and assert the disposed state, rather than asserting no throw.

3. README rationale describes the superseded behaviour

README.md:824-826

climb steadily while the JS heap stays flat. `dispose()` drives the pipeline to
the NULL state and drops the native reference synchronously, so the memory is
returned immediately.

Problemdispose() issues no state change (pipeline.cpp:349-366), and line 851 of the same section says so, contradicting this paragraph.
Risk — this is the rationale a reader reaches first, and it tells them the stop-first bullet is unnecessary. That is the sequence finding 1 breaks on.
Fix — state that dispose() drops the reference and requires an already-stopped pipeline. examples/dispose.mjs:12-13 repeats the same stale claim.

Minor Issues

4. The thread-safety argument is untested

src/ts/pipeline-dispose.test.ts:1-77

Problem — the central safety claim is that in-flight workers hold their own gst_object_ref (async-workers.cpp:9, async-workers.cpp:204), but no test disposes while a worker is running.
Risk — a later change that drops a worker's own reference would surface as a segfault in production rather than a failing test.
Fix — add a test that starts busPop(1000), calls dispose(), then awaits the pending promise.

Also noticed

  • src/cpp/pipeline.cpp:363 — while an in-flight worker holds a reference the release is deferred, so memory is not returned "immediately" as README.md:826 claims.

Positive Observations

  • The require_pipeline() guard is applied to all ten instance methods, and every call site checks the null return rather than relying on the throw to unwind. That is required here, because the addon builds with NAPI_DISABLE_CPP_EXCEPTIONS (binding.gyp), and it is easy to get wrong.
  • Using the null unique_ptr as the disposed sentinel makes idempotency fall out of the design instead of needing a separate flag.
  • The disposal contract in the README is unusually thorough about what dispose() does not cover — elements from getElementByName(), pad probes and onSample() subscriptions are called out explicitly.

Verdict

Request Changes

dispose() called pipeline.reset() without checking state. GStreamer
refuses to tear down a non-NULL element, so disposing a running
pipeline leaked the native memory instead of reclaiming it.

Now query state and throw "dispose() requires a stopped pipeline" unless
the pipeline is already NULL. Update tests to assert the still-playing
case throws, add a worker-in-flight test, and correct the README and
example that described the removed set_state(NULL) behaviour.
…ndows

Sending EOS to a running source and calling stop() immediately raced
GStreamer's internal basesrc has_pending_eos handling, aborting the vitest
worker fork on Windows CI (all JS assertions passed but the fork died).

Add a waitForEos helper and await EOS on the bus before stop() in the
affected EOS tests so the source streaming thread unwinds its loop cleanly
before the state teardown.
@mlbrig

mlbrig commented Sep 4, 2026

Copy link
Copy Markdown

Review Target

feature/pipeline-explicit-native-release @ 257a5f7main · 9 files, +351/-37 — full diff loaded
Application source (C++/N-API addon plus TypeScript). Built and ran the suites against GStreamer 1.28.6 locally in a throwaway worktree; the leak figures below are measured, not inferred.

Prior Review Status

  • 3 of 5 previous comments resolved
  • 1 partially resolved (see finding 2), 1 still unresolved (see finding 3)

Prior report was at 1a634d2; 9037820, 8c5cb16, 257a5f7 landed after it.

Prior item Status
1 (Critical) non-NULL teardown skips cleanup Resolvedpipeline.cpp:369-378 forces NULL first; measured +4.5 MB / 200 pipelines on that path
2 (Major) test blesses an unsupported sequence Resolved — the sequence is now genuinely supported; pipeline-dispose.test.ts:23-38 asserts the disposed state
3 (Major) README rationale describes superseded behaviour PartialREADME.md:824-827 fixed, but the same rot moved to README.md:852
4 (Minor) thread-safety argument untested Unresolved — the new test awaits the worker before disposing
Also noticed (deferred release wording) Resolved — now "as soon as the last reference goes away"

Spec Compliance: ISSUES FOUND

Interpretation Gaps

  1. "Does not block the JS thread" is no longer true (HOW, bullet 1) — src/cpp/pipeline.cpp:371-377
    The body states dispose() "does not issue a synchronous gst_element_set_state(NULL), which would block the JS thread." It does, with a 5-second bounded wait. The description predates 8c5cb16.
  2. Documentation deliverable contradicts shipped semanticsREADME.md:852. See finding 2.
  3. Thread-safety claim is narrower than stated (HOW, bullet 2) — verified for use-after-free (async-workers.cpp:204, :268-273), but it does not cover the leak in finding 1.

Unnecessary Additions

  1. EOS test stabilisation is unrelated to dispose()src/ts/test-utils.ts:24-46, commit 257a5f7
    A Windows basesrc flake fix bundled into a feature PR. Small, and it unblocks this PR's CI — noting once, not blocking.

Everything else claimed is present and verified: dispose(), the guard on all ten methods, the TS type, the README section, the example, and the tests. Testing claim spot-checked — npm run build clean, dispose + EOS suites pass (16 tests).

Summary

dispose() now forces the pipeline to NULL before releasing it, which correctly fixes the previous review's Critical. One hole remains: the force-to-NULL happens on the JS thread with no coordination against already-queued state-change workers, so a worker can raise the state back up after dispose() has let go — reproducing the same leak. The docs still describe the throwing behaviour that 8c5cb16 removed.

Critical Issues

1. dispose() leaks when a state change is in flight

src/cpp/pipeline.cpp:369-385

200 × videotestsrc ! videoconvert ! queue ! fakesink RSS delta
await play(); await stop(); dispose() +6.1 MB
await play(); dispose() (still PLAYING) +4.5 MB
await play(); pause(); dispose(); await pending +645.0 MB

(node --expose-gc, GStreamer 1.28.6, macOS arm64; control without dispose() leaks +951.9 MB, with stop() +16.2 MB)

Problemdispose() forces NULL (:371) then unrefs (:385), but a queued StateChangeWorker runs afterward and drives the state back up. It holds the last reference, so gst_element_dispose finalizes a non-NULL pipeline and bails out early.
Risk — a watchdog disposing a pipeline stuck in a slow play() leaks ~3 MB each, silently — the exact leak this feature exists to prevent.
Fix — track in-flight StateChangeWorkers on Pipeline; force NULL and reset() only once the count reaches zero.

Major Issues

2. Docs promise a throw that no longer exists

README.md:852

`dispose()` only drops the native reference; it does not issue a state change.
GStreamer refuses to tear down a pipeline that is not in the NULL state, so
`dispose()` throws (`dispose() requires a stopped pipeline`) if the pipeline is
still playing or paused.

Problem8c5cb16 replaced the throw with force-to-NULL; both sentences are false, and pipeline-dispose.test.ts:23 asserts the opposite.
Risk — a caller who try/catches dispose() to detect a not-stopped pipeline gets silence, and never learns it can block the JS thread for up to 5s (pipeline.cpp:375).
Fix — state that dispose() drives the pipeline to NULL synchronously when it is not already there. examples/dispose.mjs:14 repeats the stale claim.

Minor Issues

3. Concurrent-dispose safety claim still untested

src/ts/pipeline-dispose.test.ts:40-53

    const pending = pipeline.busPop(1000);
    await pipeline.stop();
    await pending;

    expect(() => pipeline.dispose()).not.toThrow();

Problem — the test added for the previous review's finding 4 awaits the worker before disposing, as its own comment says, so nothing exercises dispose-with-a-worker-in-flight — which is where finding 1 lives.
Fix — dispose while pending is still unresolved, then await it, and assert RSS does not grow across repetitions.

Also noticed

  • src/cpp/pipeline.cpp:355 — the comment block opens "it does not issue a state change", directly above the lines that do.
  • examples/dispose.mjs:18 — says --expose-gc is needed "to also see RSS reported"; RSS prints unconditionally, the flag only affects gc().
  • src/ts/pipeline-eos.test.ts:16,86,99waitForEos's false return is discarded; a never-arriving EOS burns 10s and the test still passes.

Positive Observations

  • The previous Critical is properly fixed, and measurably so: disposing a still-PLAYING pipeline now costs +4.5 MB per 200 pipelines rather than leaking. The force-to-NULL branch does what it claims.
  • require_pipeline() covers all ten instance methods, and every call site checks the null return rather than relying on unwinding — required under NAPI_DISABLE_CPP_EXCEPTIONS (binding.gyp:18) and easy to get wrong.
  • The trade-off behind force-to-NULL is argued in the code (pipeline.cpp:355-366), including why the blocking path was accepted over throwing.
  • waitForEos replaces three arbitrary setTimeout(30) sleeps with a real condition wait — a genuine flake fix rather than a longer sleep.

Verdict

Request Changes

play/pause/stop were identical except for the target state; extract a
shared queue_state_change() helper so each is a one-liner. Pull the
repeated timeout-parsing block into parse_timeout() (reused by bus_pop),
and collapse the duplicated throw in require_pipeline() into a single
condition. No behavior change.
Drop the waitForEos helper and the EOS test changes that used it, keeping
this branch scoped to the dispose() feature. The Windows basesrc flake fix
belongs in its own branch/PR.
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