Skip to content

diagnostics_channel: fix dangling binding pointer - #65860

Draft
TrevorBurnham wants to merge 1 commit into
nodejs:mainfrom
TrevorBurnham:dc-clear-channel-binding-data
Draft

diagnostics_channel: fix dangling binding pointer#65860
TrevorBurnham wants to merge 1 commit into
nodejs:mainfrom
TrevorBurnham:dc-clear-channel-binding-data

Conversation

@TrevorBurnham

@TrevorBurnham TrevorBurnham commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Channel reads its subscriber count through a raw BindingData* that is never cleared, so any native holder that outlives environment cleanup reads a destroyed object. The null check in HasSubscribers() cannot fire, because binding_data_ is only ever assigned in the constructor.

node:sqlite holds a strong BaseObjectPtr<Channel> for the lifetime of a DatabaseSync, which makes this reachable from ordinary JavaScript: a statement left mid-step at exit is finalized by the destructor chain after Environment::RunCleanup() has destroyed the binding, and sqlite3_finalize() invokes the profile callback for such a statement. A release build segfaults at normal process exit; a debug build hits the DCHECK(is_valid()) in AliasedBufferBase::GetValue(). Inside a worker it takes down the whole process.

This clears binding_data_ on every Channel the binding owns whenever it gives up that ownership, both in the destructor and in PrepareForSerialization(), so the existing null check in HasSubscribers() does its job. The second check in Publish() becomes unreachable and is dropped. That protects any holder which is itself a BaseObject, and so is destroyed later in the same cleanup; a holder that is not a BaseObject still needs a cleanup hook or a weak reference, because Realm::~Realm() checks that no BaseObjects remain.

The alternative of giving node:sqlite a cleanup hook, as crypto's FIPS indicator does, leaves the same trap set for the next native holder that forgets it, so the general fix goes in diagnostics_channel.

Two changes go to node:sqlite as well. DatabaseSync::trace_channel_ becomes a BaseObjectWeakPtr, matching the convention permission documents and follows, where BindingData is the sole owner of channels; TraceCallback already null-checks, so that needs no other change. And TraceCallback now tests AreTraceEventsSuppressed() before the channel, so a suppressed callback does not dereference it at all: StatementSync::Finalize() already suppresses trace events, so the reported path was meant to be a no-op, and only the order of the || operands took it through the channel first.

The three changes are independent, and each was checked on its own. With only the weak reference, or with only the reordered condition, the node:sqlite tests pass but the cctest still segfaults, because its holder keeps a strong reference and publishes unsuppressed. With only the diagnostics_channel change, all of them pass. The cctest is therefore what guards that change, and it does so in builds configured --without-sqlite; the node:sqlite test is the user-visible reproduction of the original report, covering the main-thread and worker teardown paths.

Fixes: #65858

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/sqlite

@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. labels Sep 6, 2026
@TrevorBurnham
TrevorBurnham force-pushed the dc-clear-channel-binding-data branch 3 times, most recently from ff5512d to 7db0a64 Compare September 6, 2026 18:14
`Channel` reads its subscriber count through a raw `BindingData*` that
was never cleared, so any native holder that outlives environment
cleanup reads a destroyed object. The null check in `HasSubscribers()`
could not fire, because the pointer was only ever assigned in the
constructor.

`node:sqlite` holds a strong `BaseObjectPtr<Channel>` for the lifetime
of a `DatabaseSync`, which made this reachable from ordinary
JavaScript. A statement left mid-step at exit is finalized by the
destructor chain after `Environment::RunCleanup()` has destroyed the
binding, and `sqlite3_finalize()` invokes the profile callback for such
a statement. The result was a segfault at normal process exit; inside a
worker it took down the whole process.

Clear `binding_data_` on every `Channel` the binding owns whenever it
gives up that ownership, both in the destructor and in
`PrepareForSerialization()`, so that the existing null check in
`HasSubscribers()` does its job. The second check in `Publish()` is now
unreachable and is dropped. This protects any holder that is itself a
`BaseObject`, and so is destroyed later in the same cleanup. A holder
that is not a `BaseObject` still needs a cleanup hook or a weak
reference, because `Realm::~Realm()` checks that no `BaseObject`s
remain.

On the `node:sqlite` side, switch `DatabaseSync::trace_channel_` to a
`BaseObjectWeakPtr`, so that it follows the same convention `permission`
documents, where `BindingData` is the sole owner of channels.
`TraceCallback` already null-checks, so this needs no other change
there.

Also check `AreTraceEventsSuppressed()` before the channel in
`TraceCallback()`, so that a suppressed callback does not dereference it
at all. `StatementSync::Finalize()` already suppresses trace events, so
the reported path was meant to be a no-op; only the order of the `||`
operands took it through the channel first.

Fixes: nodejs#65858
Assisted-by: Claude Opus 5
Signed-off-by: Trevor Burnham <trevorburnham@gmail.com>
@TrevorBurnham
TrevorBurnham force-pushed the dc-clear-channel-binding-data branch from 7db0a64 to 4d9852c Compare September 6, 2026 21:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

diagnostics_channel: Channel::binding_data_ dangles after environment cleanup, crashing node:sqlite at exit

2 participants