sqlite: manage sqlite3_stmt lifetime with RAII - #62419

Merged
nodejs-github-bot merged 2 commits into
nodejs:mainfrom
araujogui:sqlite-stmt-leak
Aug 11, 2026
Merged

sqlite: manage sqlite3_stmt lifetime with RAII#62419
nodejs-github-bot merged 2 commits into
nodejs:mainfrom
araujogui:sqlite-stmt-leak

Conversation

@araujogui

@araujoguiaraujogui commented Mar 24, 2026

Copy link
Copy Markdown
Member

sqlite3_stmt was owned by raw pointer, with sqlite3_finalize() spread across error paths. Replaces it with StatementPtr (DeleteFnPtr<sqlite3_stmt, FinalizeStatement>), moved into StatementSync.

Fixes two things:

  • StatementSync::Create() failure leaked the statement and inserted a null pointer into db->statements_, which FinalizeStatements() dereferences.
  • Tag store statements were never tracked in db->statements_, so db.close() left them open. Tracking them also makes the existing IsFinalized() re-prepare path in the cache lookup reachable.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/sqlite

@nodejs-github-botnodejs-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. sqlite Issues and PRs related to the SQLite subsystem. labels Mar 24, 2026
@codecov

codecovBot commented Mar 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.22807% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.31%. Comparing base (673cdef) to head (31e31a1).
⚠️ Report is 7 commits behind head on main.

Files with missing linesPatch %Lines
src/node_sqlite.cc90.74%2 Missing and 3 partials ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #62419 +/- ##
==========================================
- Coverage 90.32% 90.31% -0.02% 
==========================================
Files 760 760 Lines 248553 248560 +7 Branches 46910 46909 -1 ==========================================
- Hits 224510 224489 -21 - Misses 15462 15497 +35 + Partials 8581 8574 -7 
Files with missing linesCoverage Δ
src/node_sqlite.h83.33% <100.00%> (+0.72%)⬆️
src/node_sqlite.cc81.46% <90.74%> (+0.17%)⬆️

... and 31 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@louwerslouwers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can RAII be used instead?

@araujogui

Copy link
Copy Markdown
MemberAuthor

Can RAII be used instead?

Well, we could use std::unique_ptr<sqlite3_stmt, decltype(&sqlite3_finalize)>, but I feel the current way is good enough.

CopilotAI review requested due to automatic review settings July 16, 2026 13:47
@araujogui

Copy link
Copy Markdown
MemberAuthor

@nodejs/sqlite Bump, I did a safety improvement by implementing RAII for sqlite3_stmt.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a resource-management bug in the SQLite sync binding where a prepared sqlite3_stmt could be leaked (and a nullptr inserted into db->statements_) when StatementSync::Create fails to allocate the JS wrapper object (e.g., OOM).

Changes:

  • Introduces RAII ownership for sqlite3_stmt via StatementPtr (DeleteFnPtr + FinalizeStatement).
  • Updates StatementSync to own the prepared statement with StatementPtr and uses .get() at call sites.
  • Ensures DatabaseSync::Prepare returns early when StatementSync::Create fails, preventing nullptr insertion into db->statements_.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

FileDescription
src/node_sqlite.hAdds FinalizeStatement + StatementPtr alias and updates StatementSync API/member to use RAII for sqlite3_stmt.
src/node_sqlite.ccTransfers statement ownership with StatementPtr, resets via RAII, and guards against Create() failure to avoid leaks/null insertion.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@araujoguiaraujogui changed the title sqlite: fix sqlite3_stmt leak in prepare create failuresqlite: introduces RAII ownership for sqlite3_stmtJul 16, 2026

@TrevorBurnhamTrevorBurnham left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The statement_statement_.get() conversion checks out. Normalizing it away leaves four semantic edits: the Prepare null guard, the StatementPtr member/ctor/Create signature, Finalize()statement_.reset(), and dropping the two manual sqlite3_finalize(s) calls in SQLTagStore::PrepareStatement. All four look right. The removed finalizes are covered by the by-value StatementPtr parameter's destructor when Create bails, and reset() is idempotent, so the FinalizeStatements() / ~StatementSync double-call path stays safe.

While reviewing I hit two pre-existing bugs in the lines this PR touches. Both predate the change and neither is caused by it, so feel free to split them out — but since this PR is about sqlite3_stmt ownership they seemed worth raising here. Both reproduce on a local build of 78e32bc.

1. Prepare tracks a statement it never untracks (node_sqlite.cc:1551-1561)

sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == NULL when the input holds no statement. All of '', ' ', '\n', ';', '-- x', '/* x */' are accepted by prepare() on this branch. The new if (!stmt) return; catches Create failing but not s == nullptr, so a StatementSync with statement_ == nullptr gets inserted into db->statements_ at line 1561. When it is GC'd, ~StatementSync checks if (!IsFinalized()) — already true, since IsFinalized() is statement_ == nullptr — so UntrackStatement(this) is skipped and the freed pointer stays in the set. FinalizeStatements() then calls stmt->Finalize() on freed memory at close().

const{DatabaseSync}=require('node:sqlite');constdb1=newDatabaseSync(':memory:');constdb2=newDatabaseSync(':memory:');db2.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1);');for(leti=0;i<200000;i++)db1.prepare('-- '+i);// stmt == NULLconstlive=[];for(leti=0;i<50000;i++)live.push(db2.prepare('SELECT a FROM t'));db1.close();// walks db1->statements_, now full of stale pointersletbroken=0;for(constsoflive){try{s.get();}catch(e){broken++;}}console.log(broken,'/',live.length,db2.isOpen);

With --max-old-space-size=80: 19889 / 50000 true. Closing db1 corrupts a different database's live statements into "statement has been finalized" while db2.isOpen is still true. Swapping the comment for real SQL ('SELECT ' + i) gives 0 / 50000, isolating it to the NULL-stmt path.

Fix is either bailing before the insert when s == nullptr, or untracking unconditionally in ~StatementSync.

2. Tag store statements are never tracked (node_sqlite.cc:3572-3583)

SQLTagStore::PrepareStatement puts its StatementSync in sql_tags_ but never does db->statements_.insert(...); line 1561 is the only insert in the file. FinalizeStatements() therefore misses them, sqlite3_close_v2 only defers the close, and the cached statement keeps running against the old connection:

constdb=newDatabaseSync(':memory:');db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (111);');conststore=db.createTagStore();store.get`SELECT a FROM t`;// { a: 111 }db.close();db.open();db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (222);');db.prepare('SELECT a FROM t').get();// { a: 222 } new connectionstore.get`SELECT a FROM t`;// { a: 111 } stale

Minor:node_webstorage.h:23 already has stmt_deleter / stmt_unique_ptr for the same job — might be worth one shared definition rather than a second. FinalizeStatement as a free function also reads close to DatabaseSync::FinalizeStatements() and StatementSync::Finalize().

@araujogui

Copy link
Copy Markdown
MemberAuthor

The statement_statement_.get() conversion checks out. Normalizing it away leaves four semantic edits: the Prepare null guard, the StatementPtr member/ctor/Create signature, Finalize()statement_.reset(), and dropping the two manual sqlite3_finalize(s) calls in SQLTagStore::PrepareStatement. All four look right. The removed finalizes are covered by the by-value StatementPtr parameter's destructor when Create bails, and reset() is idempotent, so the FinalizeStatements() / ~StatementSync double-call path stays safe.

While reviewing I hit two pre-existing bugs in the lines this PR touches. Both predate the change and neither is caused by it, so feel free to split them out — but since this PR is about sqlite3_stmt ownership they seemed worth raising here. Both reproduce on a local build of 78e32bc.

1. Prepare tracks a statement it never untracks (node_sqlite.cc:1551-1561)

sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == NULL when the input holds no statement. All of '', ' ', '\n', ';', '-- x', '/* x */' are accepted by prepare() on this branch. The new if (!stmt) return; catches Create failing but not s == nullptr, so a StatementSync with statement_ == nullptr gets inserted into db->statements_ at line 1561. When it is GC'd, ~StatementSync checks if (!IsFinalized()) — already true, since IsFinalized() is statement_ == nullptr — so UntrackStatement(this) is skipped and the freed pointer stays in the set. FinalizeStatements() then calls stmt->Finalize() on freed memory at close().

const{DatabaseSync}=require('node:sqlite');constdb1=newDatabaseSync(':memory:');constdb2=newDatabaseSync(':memory:');db2.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1);');for(leti=0;i<200000;i++)db1.prepare('-- '+i);// stmt == NULLconstlive=[];for(leti=0;i<50000;i++)live.push(db2.prepare('SELECT a FROM t'));db1.close();// walks db1->statements_, now full of stale pointersletbroken=0;for(constsoflive){try{s.get();}catch(e){broken++;}}console.log(broken,'/',live.length,db2.isOpen);

With --max-old-space-size=80: 19889 / 50000 true. Closing db1 corrupts a different database's live statements into "statement has been finalized" while db2.isOpen is still true. Swapping the comment for real SQL ('SELECT ' + i) gives 0 / 50000, isolating it to the NULL-stmt path.

Fix is either bailing before the insert when s == nullptr, or untracking unconditionally in ~StatementSync.

2. Tag store statements are never tracked (node_sqlite.cc:3572-3583)

SQLTagStore::PrepareStatement puts its StatementSync in sql_tags_ but never does db->statements_.insert(...); line 1561 is the only insert in the file. FinalizeStatements() therefore misses them, sqlite3_close_v2 only defers the close, and the cached statement keeps running against the old connection:

constdb=newDatabaseSync(':memory:');db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (111);');conststore=db.createTagStore();store.get`SELECT a FROM t`;// { a: 111 }db.close();db.open();db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (222);');db.prepare('SELECT a FROM t').get();// { a: 222 } new connectionstore.get`SELECT a FROM t`;// { a: 111 } stale

Minor:node_webstorage.h:23 already has stmt_deleter / stmt_unique_ptr for the same job — might be worth one shared definition rather than a second. FinalizeStatement as a free function also reads close to DatabaseSync::FinalizeStatements() and StatementSync::Finalize().

Great catches ty, fixed both.

@TrevorBurnhamTrevorBurnham left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Needs a maintainer to approve.

@araujoguiaraujogui changed the title sqlite: introduces RAII ownership for sqlite3_stmtsqlite: manage sqlite3_stmt lifetime with RAIIAug 9, 2026

@trivikrtrivikr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prepare('') throwing ERR_INVALID_ARG_VALUE where it previously returned a finalized statement is okay without semver, since sqlite is still a Release candidate.

@trivikrtrivikr added author ready PRs with CI started, the required approvals, and no outstanding review comments. request-ci Add this label to start a Jenkins CI on a PR. labels Aug 9, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 9, 2026
@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@nodejs-github-bot

This comment was marked as outdated.

Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
@trivikrtrivikr added request-ci Add this label to start a Jenkins CI on a PR. and removed request-ci Add this label to start a Jenkins CI on a PR. labels Aug 11, 2026
@trivikrtrivikr added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added commit-queue PRs queued for automated landing through the Commit Queue. commit-queue-squash PRs the Commit Queue should land as one squashed commit. labels Aug 11, 2026
@nodejs-github-bot
nodejs-github-bot merged commit b0edc37 into nodejs:mainAug 11, 2026
75 checks passed
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Landed in b0edc37

@nodejs-github-botnodejs-github-bot removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 11, 2026
@araujogui
araujogui deleted the sqlite-stmt-leak branch August 12, 2026 13:37
aduh95 pushed a commit that referenced this pull request Aug 13, 2026
Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
PR-URL: #62419
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
PR-URL: #62419
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author readyPRs with CI started, the required approvals, and no outstanding review comments.c++Issues and PRs that require attention from people who are familiar with C++.commit-queue-squashPRs the Commit Queue should land as one squashed commit.needs-ciPRs that need a full CI run.sqliteIssues and PRs related to the SQLite subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@araujogui@nodejs-github-bot@TrevorBurnham@louwers@trivikr
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

sqlite: manage sqlite3_stmt lifetime with RAII - #62419

Merged
nodejs-github-bot merged 2 commits into
nodejs:mainfrom
araujogui:sqlite-stmt-leak
Aug 11, 2026
Merged

sqlite: manage sqlite3_stmt lifetime with RAII#62419
nodejs-github-bot merged 2 commits into
nodejs:mainfrom
araujogui:sqlite-stmt-leak

Conversation

@araujogui

@araujoguiaraujogui commented Mar 24, 2026

Copy link
Copy Markdown
Member

sqlite3_stmt was owned by raw pointer, with sqlite3_finalize() spread across error paths. Replaces it with StatementPtr (DeleteFnPtr<sqlite3_stmt, FinalizeStatement>), moved into StatementSync.

Fixes two things:

  • StatementSync::Create() failure leaked the statement and inserted a null pointer into db->statements_, which FinalizeStatements() dereferences.
  • Tag store statements were never tracked in db->statements_, so db.close() left them open. Tracking them also makes the existing IsFinalized() re-prepare path in the cache lookup reachable.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/sqlite

@nodejs-github-botnodejs-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. sqlite Issues and PRs related to the SQLite subsystem. labels Mar 24, 2026
@codecov

codecovBot commented Mar 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.22807% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.31%. Comparing base (673cdef) to head (31e31a1).
⚠️ Report is 7 commits behind head on main.

Files with missing linesPatch %Lines
src/node_sqlite.cc90.74%2 Missing and 3 partials ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #62419 +/- ##
==========================================
- Coverage 90.32% 90.31% -0.02% 
==========================================
Files 760 760 Lines 248553 248560 +7 Branches 46910 46909 -1 ==========================================
- Hits 224510 224489 -21 - Misses 15462 15497 +35 + Partials 8581 8574 -7 
Files with missing linesCoverage Δ
src/node_sqlite.h83.33% <100.00%> (+0.72%)⬆️
src/node_sqlite.cc81.46% <90.74%> (+0.17%)⬆️

... and 31 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@louwerslouwers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can RAII be used instead?

@araujogui

Copy link
Copy Markdown
MemberAuthor

Can RAII be used instead?

Well, we could use std::unique_ptr<sqlite3_stmt, decltype(&sqlite3_finalize)>, but I feel the current way is good enough.

CopilotAI review requested due to automatic review settings July 16, 2026 13:47
@araujogui

Copy link
Copy Markdown
MemberAuthor

@nodejs/sqlite Bump, I did a safety improvement by implementing RAII for sqlite3_stmt.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a resource-management bug in the SQLite sync binding where a prepared sqlite3_stmt could be leaked (and a nullptr inserted into db->statements_) when StatementSync::Create fails to allocate the JS wrapper object (e.g., OOM).

Changes:

  • Introduces RAII ownership for sqlite3_stmt via StatementPtr (DeleteFnPtr + FinalizeStatement).
  • Updates StatementSync to own the prepared statement with StatementPtr and uses .get() at call sites.
  • Ensures DatabaseSync::Prepare returns early when StatementSync::Create fails, preventing nullptr insertion into db->statements_.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

FileDescription
src/node_sqlite.hAdds FinalizeStatement + StatementPtr alias and updates StatementSync API/member to use RAII for sqlite3_stmt.
src/node_sqlite.ccTransfers statement ownership with StatementPtr, resets via RAII, and guards against Create() failure to avoid leaks/null insertion.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@araujoguiaraujogui changed the title sqlite: fix sqlite3_stmt leak in prepare create failuresqlite: introduces RAII ownership for sqlite3_stmtJul 16, 2026

@TrevorBurnhamTrevorBurnham left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The statement_statement_.get() conversion checks out. Normalizing it away leaves four semantic edits: the Prepare null guard, the StatementPtr member/ctor/Create signature, Finalize()statement_.reset(), and dropping the two manual sqlite3_finalize(s) calls in SQLTagStore::PrepareStatement. All four look right. The removed finalizes are covered by the by-value StatementPtr parameter's destructor when Create bails, and reset() is idempotent, so the FinalizeStatements() / ~StatementSync double-call path stays safe.

While reviewing I hit two pre-existing bugs in the lines this PR touches. Both predate the change and neither is caused by it, so feel free to split them out — but since this PR is about sqlite3_stmt ownership they seemed worth raising here. Both reproduce on a local build of 78e32bc.

1. Prepare tracks a statement it never untracks (node_sqlite.cc:1551-1561)

sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == NULL when the input holds no statement. All of '', ' ', '\n', ';', '-- x', '/* x */' are accepted by prepare() on this branch. The new if (!stmt) return; catches Create failing but not s == nullptr, so a StatementSync with statement_ == nullptr gets inserted into db->statements_ at line 1561. When it is GC'd, ~StatementSync checks if (!IsFinalized()) — already true, since IsFinalized() is statement_ == nullptr — so UntrackStatement(this) is skipped and the freed pointer stays in the set. FinalizeStatements() then calls stmt->Finalize() on freed memory at close().

const{DatabaseSync}=require('node:sqlite');constdb1=newDatabaseSync(':memory:');constdb2=newDatabaseSync(':memory:');db2.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1);');for(leti=0;i<200000;i++)db1.prepare('-- '+i);// stmt == NULLconstlive=[];for(leti=0;i<50000;i++)live.push(db2.prepare('SELECT a FROM t'));db1.close();// walks db1->statements_, now full of stale pointersletbroken=0;for(constsoflive){try{s.get();}catch(e){broken++;}}console.log(broken,'/',live.length,db2.isOpen);

With --max-old-space-size=80: 19889 / 50000 true. Closing db1 corrupts a different database's live statements into "statement has been finalized" while db2.isOpen is still true. Swapping the comment for real SQL ('SELECT ' + i) gives 0 / 50000, isolating it to the NULL-stmt path.

Fix is either bailing before the insert when s == nullptr, or untracking unconditionally in ~StatementSync.

2. Tag store statements are never tracked (node_sqlite.cc:3572-3583)

SQLTagStore::PrepareStatement puts its StatementSync in sql_tags_ but never does db->statements_.insert(...); line 1561 is the only insert in the file. FinalizeStatements() therefore misses them, sqlite3_close_v2 only defers the close, and the cached statement keeps running against the old connection:

constdb=newDatabaseSync(':memory:');db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (111);');conststore=db.createTagStore();store.get`SELECT a FROM t`;// { a: 111 }db.close();db.open();db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (222);');db.prepare('SELECT a FROM t').get();// { a: 222 } new connectionstore.get`SELECT a FROM t`;// { a: 111 } stale

Minor:node_webstorage.h:23 already has stmt_deleter / stmt_unique_ptr for the same job — might be worth one shared definition rather than a second. FinalizeStatement as a free function also reads close to DatabaseSync::FinalizeStatements() and StatementSync::Finalize().

@araujogui

Copy link
Copy Markdown
MemberAuthor

The statement_statement_.get() conversion checks out. Normalizing it away leaves four semantic edits: the Prepare null guard, the StatementPtr member/ctor/Create signature, Finalize()statement_.reset(), and dropping the two manual sqlite3_finalize(s) calls in SQLTagStore::PrepareStatement. All four look right. The removed finalizes are covered by the by-value StatementPtr parameter's destructor when Create bails, and reset() is idempotent, so the FinalizeStatements() / ~StatementSync double-call path stays safe.

While reviewing I hit two pre-existing bugs in the lines this PR touches. Both predate the change and neither is caused by it, so feel free to split them out — but since this PR is about sqlite3_stmt ownership they seemed worth raising here. Both reproduce on a local build of 78e32bc.

1. Prepare tracks a statement it never untracks (node_sqlite.cc:1551-1561)

sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == NULL when the input holds no statement. All of '', ' ', '\n', ';', '-- x', '/* x */' are accepted by prepare() on this branch. The new if (!stmt) return; catches Create failing but not s == nullptr, so a StatementSync with statement_ == nullptr gets inserted into db->statements_ at line 1561. When it is GC'd, ~StatementSync checks if (!IsFinalized()) — already true, since IsFinalized() is statement_ == nullptr — so UntrackStatement(this) is skipped and the freed pointer stays in the set. FinalizeStatements() then calls stmt->Finalize() on freed memory at close().

const{DatabaseSync}=require('node:sqlite');constdb1=newDatabaseSync(':memory:');constdb2=newDatabaseSync(':memory:');db2.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1);');for(leti=0;i<200000;i++)db1.prepare('-- '+i);// stmt == NULLconstlive=[];for(leti=0;i<50000;i++)live.push(db2.prepare('SELECT a FROM t'));db1.close();// walks db1->statements_, now full of stale pointersletbroken=0;for(constsoflive){try{s.get();}catch(e){broken++;}}console.log(broken,'/',live.length,db2.isOpen);

With --max-old-space-size=80: 19889 / 50000 true. Closing db1 corrupts a different database's live statements into "statement has been finalized" while db2.isOpen is still true. Swapping the comment for real SQL ('SELECT ' + i) gives 0 / 50000, isolating it to the NULL-stmt path.

Fix is either bailing before the insert when s == nullptr, or untracking unconditionally in ~StatementSync.

2. Tag store statements are never tracked (node_sqlite.cc:3572-3583)

SQLTagStore::PrepareStatement puts its StatementSync in sql_tags_ but never does db->statements_.insert(...); line 1561 is the only insert in the file. FinalizeStatements() therefore misses them, sqlite3_close_v2 only defers the close, and the cached statement keeps running against the old connection:

constdb=newDatabaseSync(':memory:');db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (111);');conststore=db.createTagStore();store.get`SELECT a FROM t`;// { a: 111 }db.close();db.open();db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (222);');db.prepare('SELECT a FROM t').get();// { a: 222 } new connectionstore.get`SELECT a FROM t`;// { a: 111 } stale

Minor:node_webstorage.h:23 already has stmt_deleter / stmt_unique_ptr for the same job — might be worth one shared definition rather than a second. FinalizeStatement as a free function also reads close to DatabaseSync::FinalizeStatements() and StatementSync::Finalize().

Great catches ty, fixed both.

@TrevorBurnhamTrevorBurnham left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Needs a maintainer to approve.

@araujoguiaraujogui changed the title sqlite: introduces RAII ownership for sqlite3_stmtsqlite: manage sqlite3_stmt lifetime with RAIIAug 9, 2026

@trivikrtrivikr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prepare('') throwing ERR_INVALID_ARG_VALUE where it previously returned a finalized statement is okay without semver, since sqlite is still a Release candidate.

@trivikrtrivikr added author ready PRs with CI started, the required approvals, and no outstanding review comments. request-ci Add this label to start a Jenkins CI on a PR. labels Aug 9, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 9, 2026
@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@nodejs-github-bot

This comment was marked as outdated.

Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
@trivikrtrivikr added request-ci Add this label to start a Jenkins CI on a PR. and removed request-ci Add this label to start a Jenkins CI on a PR. labels Aug 11, 2026
@trivikrtrivikr added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added commit-queue PRs queued for automated landing through the Commit Queue. commit-queue-squash PRs the Commit Queue should land as one squashed commit. labels Aug 11, 2026
@nodejs-github-bot
nodejs-github-bot merged commit b0edc37 into nodejs:mainAug 11, 2026
75 checks passed
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Landed in b0edc37

@nodejs-github-botnodejs-github-bot removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 11, 2026
@araujogui
araujogui deleted the sqlite-stmt-leak branch August 12, 2026 13:37
aduh95 pushed a commit that referenced this pull request Aug 13, 2026
Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
PR-URL: #62419
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
PR-URL: #62419
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author readyPRs with CI started, the required approvals, and no outstanding review comments.c++Issues and PRs that require attention from people who are familiar with C++.commit-queue-squashPRs the Commit Queue should land as one squashed commit.needs-ciPRs that need a full CI run.sqliteIssues and PRs related to the SQLite subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@araujogui@nodejs-github-bot@TrevorBurnham@louwers@trivikr
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

sqlite: manage sqlite3_stmt lifetime with RAII - #62419

Merged
nodejs-github-bot merged 2 commits into
nodejs:mainfrom
araujogui:sqlite-stmt-leak
Aug 11, 2026
Merged

sqlite: manage sqlite3_stmt lifetime with RAII#62419
nodejs-github-bot merged 2 commits into
nodejs:mainfrom
araujogui:sqlite-stmt-leak

Conversation

@araujogui

@araujoguiaraujogui commented Mar 24, 2026

Copy link
Copy Markdown
Member

sqlite3_stmt was owned by raw pointer, with sqlite3_finalize() spread across error paths. Replaces it with StatementPtr (DeleteFnPtr<sqlite3_stmt, FinalizeStatement>), moved into StatementSync.

Fixes two things:

  • StatementSync::Create() failure leaked the statement and inserted a null pointer into db->statements_, which FinalizeStatements() dereferences.
  • Tag store statements were never tracked in db->statements_, so db.close() left them open. Tracking them also makes the existing IsFinalized() re-prepare path in the cache lookup reachable.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/sqlite

@nodejs-github-botnodejs-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. sqlite Issues and PRs related to the SQLite subsystem. labels Mar 24, 2026
@codecov

codecovBot commented Mar 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.22807% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.31%. Comparing base (673cdef) to head (31e31a1).
⚠️ Report is 7 commits behind head on main.

Files with missing linesPatch %Lines
src/node_sqlite.cc90.74%2 Missing and 3 partials ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #62419 +/- ##
==========================================
- Coverage 90.32% 90.31% -0.02% 
==========================================
Files 760 760 Lines 248553 248560 +7 Branches 46910 46909 -1 ==========================================
- Hits 224510 224489 -21 - Misses 15462 15497 +35 + Partials 8581 8574 -7 
Files with missing linesCoverage Δ
src/node_sqlite.h83.33% <100.00%> (+0.72%)⬆️
src/node_sqlite.cc81.46% <90.74%> (+0.17%)⬆️

... and 31 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@louwerslouwers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can RAII be used instead?

@araujogui

Copy link
Copy Markdown
MemberAuthor

Can RAII be used instead?

Well, we could use std::unique_ptr<sqlite3_stmt, decltype(&sqlite3_finalize)>, but I feel the current way is good enough.

CopilotAI review requested due to automatic review settings July 16, 2026 13:47
@araujogui

Copy link
Copy Markdown
MemberAuthor

@nodejs/sqlite Bump, I did a safety improvement by implementing RAII for sqlite3_stmt.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a resource-management bug in the SQLite sync binding where a prepared sqlite3_stmt could be leaked (and a nullptr inserted into db->statements_) when StatementSync::Create fails to allocate the JS wrapper object (e.g., OOM).

Changes:

  • Introduces RAII ownership for sqlite3_stmt via StatementPtr (DeleteFnPtr + FinalizeStatement).
  • Updates StatementSync to own the prepared statement with StatementPtr and uses .get() at call sites.
  • Ensures DatabaseSync::Prepare returns early when StatementSync::Create fails, preventing nullptr insertion into db->statements_.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

FileDescription
src/node_sqlite.hAdds FinalizeStatement + StatementPtr alias and updates StatementSync API/member to use RAII for sqlite3_stmt.
src/node_sqlite.ccTransfers statement ownership with StatementPtr, resets via RAII, and guards against Create() failure to avoid leaks/null insertion.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@araujoguiaraujogui changed the title sqlite: fix sqlite3_stmt leak in prepare create failuresqlite: introduces RAII ownership for sqlite3_stmtJul 16, 2026

@TrevorBurnhamTrevorBurnham left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The statement_statement_.get() conversion checks out. Normalizing it away leaves four semantic edits: the Prepare null guard, the StatementPtr member/ctor/Create signature, Finalize()statement_.reset(), and dropping the two manual sqlite3_finalize(s) calls in SQLTagStore::PrepareStatement. All four look right. The removed finalizes are covered by the by-value StatementPtr parameter's destructor when Create bails, and reset() is idempotent, so the FinalizeStatements() / ~StatementSync double-call path stays safe.

While reviewing I hit two pre-existing bugs in the lines this PR touches. Both predate the change and neither is caused by it, so feel free to split them out — but since this PR is about sqlite3_stmt ownership they seemed worth raising here. Both reproduce on a local build of 78e32bc.

1. Prepare tracks a statement it never untracks (node_sqlite.cc:1551-1561)

sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == NULL when the input holds no statement. All of '', ' ', '\n', ';', '-- x', '/* x */' are accepted by prepare() on this branch. The new if (!stmt) return; catches Create failing but not s == nullptr, so a StatementSync with statement_ == nullptr gets inserted into db->statements_ at line 1561. When it is GC'd, ~StatementSync checks if (!IsFinalized()) — already true, since IsFinalized() is statement_ == nullptr — so UntrackStatement(this) is skipped and the freed pointer stays in the set. FinalizeStatements() then calls stmt->Finalize() on freed memory at close().

const{DatabaseSync}=require('node:sqlite');constdb1=newDatabaseSync(':memory:');constdb2=newDatabaseSync(':memory:');db2.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1);');for(leti=0;i<200000;i++)db1.prepare('-- '+i);// stmt == NULLconstlive=[];for(leti=0;i<50000;i++)live.push(db2.prepare('SELECT a FROM t'));db1.close();// walks db1->statements_, now full of stale pointersletbroken=0;for(constsoflive){try{s.get();}catch(e){broken++;}}console.log(broken,'/',live.length,db2.isOpen);

With --max-old-space-size=80: 19889 / 50000 true. Closing db1 corrupts a different database's live statements into "statement has been finalized" while db2.isOpen is still true. Swapping the comment for real SQL ('SELECT ' + i) gives 0 / 50000, isolating it to the NULL-stmt path.

Fix is either bailing before the insert when s == nullptr, or untracking unconditionally in ~StatementSync.

2. Tag store statements are never tracked (node_sqlite.cc:3572-3583)

SQLTagStore::PrepareStatement puts its StatementSync in sql_tags_ but never does db->statements_.insert(...); line 1561 is the only insert in the file. FinalizeStatements() therefore misses them, sqlite3_close_v2 only defers the close, and the cached statement keeps running against the old connection:

constdb=newDatabaseSync(':memory:');db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (111);');conststore=db.createTagStore();store.get`SELECT a FROM t`;// { a: 111 }db.close();db.open();db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (222);');db.prepare('SELECT a FROM t').get();// { a: 222 } new connectionstore.get`SELECT a FROM t`;// { a: 111 } stale

Minor:node_webstorage.h:23 already has stmt_deleter / stmt_unique_ptr for the same job — might be worth one shared definition rather than a second. FinalizeStatement as a free function also reads close to DatabaseSync::FinalizeStatements() and StatementSync::Finalize().

@araujogui

Copy link
Copy Markdown
MemberAuthor

The statement_statement_.get() conversion checks out. Normalizing it away leaves four semantic edits: the Prepare null guard, the StatementPtr member/ctor/Create signature, Finalize()statement_.reset(), and dropping the two manual sqlite3_finalize(s) calls in SQLTagStore::PrepareStatement. All four look right. The removed finalizes are covered by the by-value StatementPtr parameter's destructor when Create bails, and reset() is idempotent, so the FinalizeStatements() / ~StatementSync double-call path stays safe.

While reviewing I hit two pre-existing bugs in the lines this PR touches. Both predate the change and neither is caused by it, so feel free to split them out — but since this PR is about sqlite3_stmt ownership they seemed worth raising here. Both reproduce on a local build of 78e32bc.

1. Prepare tracks a statement it never untracks (node_sqlite.cc:1551-1561)

sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == NULL when the input holds no statement. All of '', ' ', '\n', ';', '-- x', '/* x */' are accepted by prepare() on this branch. The new if (!stmt) return; catches Create failing but not s == nullptr, so a StatementSync with statement_ == nullptr gets inserted into db->statements_ at line 1561. When it is GC'd, ~StatementSync checks if (!IsFinalized()) — already true, since IsFinalized() is statement_ == nullptr — so UntrackStatement(this) is skipped and the freed pointer stays in the set. FinalizeStatements() then calls stmt->Finalize() on freed memory at close().

const{DatabaseSync}=require('node:sqlite');constdb1=newDatabaseSync(':memory:');constdb2=newDatabaseSync(':memory:');db2.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1);');for(leti=0;i<200000;i++)db1.prepare('-- '+i);// stmt == NULLconstlive=[];for(leti=0;i<50000;i++)live.push(db2.prepare('SELECT a FROM t'));db1.close();// walks db1->statements_, now full of stale pointersletbroken=0;for(constsoflive){try{s.get();}catch(e){broken++;}}console.log(broken,'/',live.length,db2.isOpen);

With --max-old-space-size=80: 19889 / 50000 true. Closing db1 corrupts a different database's live statements into "statement has been finalized" while db2.isOpen is still true. Swapping the comment for real SQL ('SELECT ' + i) gives 0 / 50000, isolating it to the NULL-stmt path.

Fix is either bailing before the insert when s == nullptr, or untracking unconditionally in ~StatementSync.

2. Tag store statements are never tracked (node_sqlite.cc:3572-3583)

SQLTagStore::PrepareStatement puts its StatementSync in sql_tags_ but never does db->statements_.insert(...); line 1561 is the only insert in the file. FinalizeStatements() therefore misses them, sqlite3_close_v2 only defers the close, and the cached statement keeps running against the old connection:

constdb=newDatabaseSync(':memory:');db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (111);');conststore=db.createTagStore();store.get`SELECT a FROM t`;// { a: 111 }db.close();db.open();db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (222);');db.prepare('SELECT a FROM t').get();// { a: 222 } new connectionstore.get`SELECT a FROM t`;// { a: 111 } stale

Minor:node_webstorage.h:23 already has stmt_deleter / stmt_unique_ptr for the same job — might be worth one shared definition rather than a second. FinalizeStatement as a free function also reads close to DatabaseSync::FinalizeStatements() and StatementSync::Finalize().

Great catches ty, fixed both.

@TrevorBurnhamTrevorBurnham left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Needs a maintainer to approve.

@araujoguiaraujogui changed the title sqlite: introduces RAII ownership for sqlite3_stmtsqlite: manage sqlite3_stmt lifetime with RAIIAug 9, 2026

@trivikrtrivikr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prepare('') throwing ERR_INVALID_ARG_VALUE where it previously returned a finalized statement is okay without semver, since sqlite is still a Release candidate.

@trivikrtrivikr added author ready PRs with CI started, the required approvals, and no outstanding review comments. request-ci Add this label to start a Jenkins CI on a PR. labels Aug 9, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 9, 2026
@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@nodejs-github-bot

This comment was marked as outdated.

Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
@trivikrtrivikr added request-ci Add this label to start a Jenkins CI on a PR. and removed request-ci Add this label to start a Jenkins CI on a PR. labels Aug 11, 2026
@trivikrtrivikr added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added commit-queue PRs queued for automated landing through the Commit Queue. commit-queue-squash PRs the Commit Queue should land as one squashed commit. labels Aug 11, 2026
@nodejs-github-bot
nodejs-github-bot merged commit b0edc37 into nodejs:mainAug 11, 2026
75 checks passed
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Landed in b0edc37

@nodejs-github-botnodejs-github-bot removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 11, 2026
@araujogui
araujogui deleted the sqlite-stmt-leak branch August 12, 2026 13:37
aduh95 pushed a commit that referenced this pull request Aug 13, 2026
Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
PR-URL: #62419
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
PR-URL: #62419
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author readyPRs with CI started, the required approvals, and no outstanding review comments.c++Issues and PRs that require attention from people who are familiar with C++.commit-queue-squashPRs the Commit Queue should land as one squashed commit.needs-ciPRs that need a full CI run.sqliteIssues and PRs related to the SQLite subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@araujogui@nodejs-github-bot@TrevorBurnham@louwers@trivikr
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

sqlite: manage sqlite3_stmt lifetime with RAII - #62419

Merged
nodejs-github-bot merged 2 commits into
nodejs:mainfrom
araujogui:sqlite-stmt-leak
Aug 11, 2026
Merged

sqlite: manage sqlite3_stmt lifetime with RAII#62419
nodejs-github-bot merged 2 commits into
nodejs:mainfrom
araujogui:sqlite-stmt-leak

Conversation

@araujogui

@araujoguiaraujogui commented Mar 24, 2026

Copy link
Copy Markdown
Member

sqlite3_stmt was owned by raw pointer, with sqlite3_finalize() spread across error paths. Replaces it with StatementPtr (DeleteFnPtr<sqlite3_stmt, FinalizeStatement>), moved into StatementSync.

Fixes two things:

  • StatementSync::Create() failure leaked the statement and inserted a null pointer into db->statements_, which FinalizeStatements() dereferences.
  • Tag store statements were never tracked in db->statements_, so db.close() left them open. Tracking them also makes the existing IsFinalized() re-prepare path in the cache lookup reachable.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/sqlite

@nodejs-github-botnodejs-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. sqlite Issues and PRs related to the SQLite subsystem. labels Mar 24, 2026
@codecov

codecovBot commented Mar 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.22807% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.31%. Comparing base (673cdef) to head (31e31a1).
⚠️ Report is 7 commits behind head on main.

Files with missing linesPatch %Lines
src/node_sqlite.cc90.74%2 Missing and 3 partials ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #62419 +/- ##
==========================================
- Coverage 90.32% 90.31% -0.02% 
==========================================
Files 760 760 Lines 248553 248560 +7 Branches 46910 46909 -1 ==========================================
- Hits 224510 224489 -21 - Misses 15462 15497 +35 + Partials 8581 8574 -7 
Files with missing linesCoverage Δ
src/node_sqlite.h83.33% <100.00%> (+0.72%)⬆️
src/node_sqlite.cc81.46% <90.74%> (+0.17%)⬆️

... and 31 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@louwerslouwers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can RAII be used instead?

@araujogui

Copy link
Copy Markdown
MemberAuthor

Can RAII be used instead?

Well, we could use std::unique_ptr<sqlite3_stmt, decltype(&sqlite3_finalize)>, but I feel the current way is good enough.

CopilotAI review requested due to automatic review settings July 16, 2026 13:47
@araujogui

Copy link
Copy Markdown
MemberAuthor

@nodejs/sqlite Bump, I did a safety improvement by implementing RAII for sqlite3_stmt.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a resource-management bug in the SQLite sync binding where a prepared sqlite3_stmt could be leaked (and a nullptr inserted into db->statements_) when StatementSync::Create fails to allocate the JS wrapper object (e.g., OOM).

Changes:

  • Introduces RAII ownership for sqlite3_stmt via StatementPtr (DeleteFnPtr + FinalizeStatement).
  • Updates StatementSync to own the prepared statement with StatementPtr and uses .get() at call sites.
  • Ensures DatabaseSync::Prepare returns early when StatementSync::Create fails, preventing nullptr insertion into db->statements_.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

FileDescription
src/node_sqlite.hAdds FinalizeStatement + StatementPtr alias and updates StatementSync API/member to use RAII for sqlite3_stmt.
src/node_sqlite.ccTransfers statement ownership with StatementPtr, resets via RAII, and guards against Create() failure to avoid leaks/null insertion.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@araujoguiaraujogui changed the title sqlite: fix sqlite3_stmt leak in prepare create failuresqlite: introduces RAII ownership for sqlite3_stmtJul 16, 2026

@TrevorBurnhamTrevorBurnham left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The statement_statement_.get() conversion checks out. Normalizing it away leaves four semantic edits: the Prepare null guard, the StatementPtr member/ctor/Create signature, Finalize()statement_.reset(), and dropping the two manual sqlite3_finalize(s) calls in SQLTagStore::PrepareStatement. All four look right. The removed finalizes are covered by the by-value StatementPtr parameter's destructor when Create bails, and reset() is idempotent, so the FinalizeStatements() / ~StatementSync double-call path stays safe.

While reviewing I hit two pre-existing bugs in the lines this PR touches. Both predate the change and neither is caused by it, so feel free to split them out — but since this PR is about sqlite3_stmt ownership they seemed worth raising here. Both reproduce on a local build of 78e32bc.

1. Prepare tracks a statement it never untracks (node_sqlite.cc:1551-1561)

sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == NULL when the input holds no statement. All of '', ' ', '\n', ';', '-- x', '/* x */' are accepted by prepare() on this branch. The new if (!stmt) return; catches Create failing but not s == nullptr, so a StatementSync with statement_ == nullptr gets inserted into db->statements_ at line 1561. When it is GC'd, ~StatementSync checks if (!IsFinalized()) — already true, since IsFinalized() is statement_ == nullptr — so UntrackStatement(this) is skipped and the freed pointer stays in the set. FinalizeStatements() then calls stmt->Finalize() on freed memory at close().

const{DatabaseSync}=require('node:sqlite');constdb1=newDatabaseSync(':memory:');constdb2=newDatabaseSync(':memory:');db2.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1);');for(leti=0;i<200000;i++)db1.prepare('-- '+i);// stmt == NULLconstlive=[];for(leti=0;i<50000;i++)live.push(db2.prepare('SELECT a FROM t'));db1.close();// walks db1->statements_, now full of stale pointersletbroken=0;for(constsoflive){try{s.get();}catch(e){broken++;}}console.log(broken,'/',live.length,db2.isOpen);

With --max-old-space-size=80: 19889 / 50000 true. Closing db1 corrupts a different database's live statements into "statement has been finalized" while db2.isOpen is still true. Swapping the comment for real SQL ('SELECT ' + i) gives 0 / 50000, isolating it to the NULL-stmt path.

Fix is either bailing before the insert when s == nullptr, or untracking unconditionally in ~StatementSync.

2. Tag store statements are never tracked (node_sqlite.cc:3572-3583)

SQLTagStore::PrepareStatement puts its StatementSync in sql_tags_ but never does db->statements_.insert(...); line 1561 is the only insert in the file. FinalizeStatements() therefore misses them, sqlite3_close_v2 only defers the close, and the cached statement keeps running against the old connection:

constdb=newDatabaseSync(':memory:');db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (111);');conststore=db.createTagStore();store.get`SELECT a FROM t`;// { a: 111 }db.close();db.open();db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (222);');db.prepare('SELECT a FROM t').get();// { a: 222 } new connectionstore.get`SELECT a FROM t`;// { a: 111 } stale

Minor:node_webstorage.h:23 already has stmt_deleter / stmt_unique_ptr for the same job — might be worth one shared definition rather than a second. FinalizeStatement as a free function also reads close to DatabaseSync::FinalizeStatements() and StatementSync::Finalize().

@araujogui

Copy link
Copy Markdown
MemberAuthor

The statement_statement_.get() conversion checks out. Normalizing it away leaves four semantic edits: the Prepare null guard, the StatementPtr member/ctor/Create signature, Finalize()statement_.reset(), and dropping the two manual sqlite3_finalize(s) calls in SQLTagStore::PrepareStatement. All four look right. The removed finalizes are covered by the by-value StatementPtr parameter's destructor when Create bails, and reset() is idempotent, so the FinalizeStatements() / ~StatementSync double-call path stays safe.

While reviewing I hit two pre-existing bugs in the lines this PR touches. Both predate the change and neither is caused by it, so feel free to split them out — but since this PR is about sqlite3_stmt ownership they seemed worth raising here. Both reproduce on a local build of 78e32bc.

1. Prepare tracks a statement it never untracks (node_sqlite.cc:1551-1561)

sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == NULL when the input holds no statement. All of '', ' ', '\n', ';', '-- x', '/* x */' are accepted by prepare() on this branch. The new if (!stmt) return; catches Create failing but not s == nullptr, so a StatementSync with statement_ == nullptr gets inserted into db->statements_ at line 1561. When it is GC'd, ~StatementSync checks if (!IsFinalized()) — already true, since IsFinalized() is statement_ == nullptr — so UntrackStatement(this) is skipped and the freed pointer stays in the set. FinalizeStatements() then calls stmt->Finalize() on freed memory at close().

const{DatabaseSync}=require('node:sqlite');constdb1=newDatabaseSync(':memory:');constdb2=newDatabaseSync(':memory:');db2.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1);');for(leti=0;i<200000;i++)db1.prepare('-- '+i);// stmt == NULLconstlive=[];for(leti=0;i<50000;i++)live.push(db2.prepare('SELECT a FROM t'));db1.close();// walks db1->statements_, now full of stale pointersletbroken=0;for(constsoflive){try{s.get();}catch(e){broken++;}}console.log(broken,'/',live.length,db2.isOpen);

With --max-old-space-size=80: 19889 / 50000 true. Closing db1 corrupts a different database's live statements into "statement has been finalized" while db2.isOpen is still true. Swapping the comment for real SQL ('SELECT ' + i) gives 0 / 50000, isolating it to the NULL-stmt path.

Fix is either bailing before the insert when s == nullptr, or untracking unconditionally in ~StatementSync.

2. Tag store statements are never tracked (node_sqlite.cc:3572-3583)

SQLTagStore::PrepareStatement puts its StatementSync in sql_tags_ but never does db->statements_.insert(...); line 1561 is the only insert in the file. FinalizeStatements() therefore misses them, sqlite3_close_v2 only defers the close, and the cached statement keeps running against the old connection:

constdb=newDatabaseSync(':memory:');db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (111);');conststore=db.createTagStore();store.get`SELECT a FROM t`;// { a: 111 }db.close();db.open();db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (222);');db.prepare('SELECT a FROM t').get();// { a: 222 } new connectionstore.get`SELECT a FROM t`;// { a: 111 } stale

Minor:node_webstorage.h:23 already has stmt_deleter / stmt_unique_ptr for the same job — might be worth one shared definition rather than a second. FinalizeStatement as a free function also reads close to DatabaseSync::FinalizeStatements() and StatementSync::Finalize().

Great catches ty, fixed both.

@TrevorBurnhamTrevorBurnham left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Needs a maintainer to approve.

@araujoguiaraujogui changed the title sqlite: introduces RAII ownership for sqlite3_stmtsqlite: manage sqlite3_stmt lifetime with RAIIAug 9, 2026

@trivikrtrivikr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prepare('') throwing ERR_INVALID_ARG_VALUE where it previously returned a finalized statement is okay without semver, since sqlite is still a Release candidate.

@trivikrtrivikr added author ready PRs with CI started, the required approvals, and no outstanding review comments. request-ci Add this label to start a Jenkins CI on a PR. labels Aug 9, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 9, 2026
@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@nodejs-github-bot

This comment was marked as outdated.

Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
@trivikrtrivikr added request-ci Add this label to start a Jenkins CI on a PR. and removed request-ci Add this label to start a Jenkins CI on a PR. labels Aug 11, 2026
@trivikrtrivikr added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added commit-queue PRs queued for automated landing through the Commit Queue. commit-queue-squash PRs the Commit Queue should land as one squashed commit. labels Aug 11, 2026
@nodejs-github-bot
nodejs-github-bot merged commit b0edc37 into nodejs:mainAug 11, 2026
75 checks passed
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Landed in b0edc37

@nodejs-github-botnodejs-github-bot removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 11, 2026
@araujogui
araujogui deleted the sqlite-stmt-leak branch August 12, 2026 13:37
aduh95 pushed a commit that referenced this pull request Aug 13, 2026
Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
PR-URL: #62419
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
PR-URL: #62419
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author readyPRs with CI started, the required approvals, and no outstanding review comments.c++Issues and PRs that require attention from people who are familiar with C++.commit-queue-squashPRs the Commit Queue should land as one squashed commit.needs-ciPRs that need a full CI run.sqliteIssues and PRs related to the SQLite subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@araujogui@nodejs-github-bot@TrevorBurnham@louwers@trivikr
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

sqlite: manage sqlite3_stmt lifetime with RAII - #62419

Merged
nodejs-github-bot merged 2 commits into
nodejs:mainfrom
araujogui:sqlite-stmt-leak
Aug 11, 2026
Merged

sqlite: manage sqlite3_stmt lifetime with RAII#62419
nodejs-github-bot merged 2 commits into
nodejs:mainfrom
araujogui:sqlite-stmt-leak

Conversation

@araujogui

@araujoguiaraujogui commented Mar 24, 2026

Copy link
Copy Markdown
Member

sqlite3_stmt was owned by raw pointer, with sqlite3_finalize() spread across error paths. Replaces it with StatementPtr (DeleteFnPtr<sqlite3_stmt, FinalizeStatement>), moved into StatementSync.

Fixes two things:

  • StatementSync::Create() failure leaked the statement and inserted a null pointer into db->statements_, which FinalizeStatements() dereferences.
  • Tag store statements were never tracked in db->statements_, so db.close() left them open. Tracking them also makes the existing IsFinalized() re-prepare path in the cache lookup reachable.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/sqlite

@nodejs-github-botnodejs-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. sqlite Issues and PRs related to the SQLite subsystem. labels Mar 24, 2026
@codecov

codecovBot commented Mar 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.22807% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.31%. Comparing base (673cdef) to head (31e31a1).
⚠️ Report is 7 commits behind head on main.

Files with missing linesPatch %Lines
src/node_sqlite.cc90.74%2 Missing and 3 partials ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #62419 +/- ##
==========================================
- Coverage 90.32% 90.31% -0.02% 
==========================================
Files 760 760 Lines 248553 248560 +7 Branches 46910 46909 -1 ==========================================
- Hits 224510 224489 -21 - Misses 15462 15497 +35 + Partials 8581 8574 -7 
Files with missing linesCoverage Δ
src/node_sqlite.h83.33% <100.00%> (+0.72%)⬆️
src/node_sqlite.cc81.46% <90.74%> (+0.17%)⬆️

... and 31 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@louwerslouwers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can RAII be used instead?

@araujogui

Copy link
Copy Markdown
MemberAuthor

Can RAII be used instead?

Well, we could use std::unique_ptr<sqlite3_stmt, decltype(&sqlite3_finalize)>, but I feel the current way is good enough.

CopilotAI review requested due to automatic review settings July 16, 2026 13:47
@araujogui

Copy link
Copy Markdown
MemberAuthor

@nodejs/sqlite Bump, I did a safety improvement by implementing RAII for sqlite3_stmt.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a resource-management bug in the SQLite sync binding where a prepared sqlite3_stmt could be leaked (and a nullptr inserted into db->statements_) when StatementSync::Create fails to allocate the JS wrapper object (e.g., OOM).

Changes:

  • Introduces RAII ownership for sqlite3_stmt via StatementPtr (DeleteFnPtr + FinalizeStatement).
  • Updates StatementSync to own the prepared statement with StatementPtr and uses .get() at call sites.
  • Ensures DatabaseSync::Prepare returns early when StatementSync::Create fails, preventing nullptr insertion into db->statements_.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

FileDescription
src/node_sqlite.hAdds FinalizeStatement + StatementPtr alias and updates StatementSync API/member to use RAII for sqlite3_stmt.
src/node_sqlite.ccTransfers statement ownership with StatementPtr, resets via RAII, and guards against Create() failure to avoid leaks/null insertion.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@araujoguiaraujogui changed the title sqlite: fix sqlite3_stmt leak in prepare create failuresqlite: introduces RAII ownership for sqlite3_stmtJul 16, 2026

@TrevorBurnhamTrevorBurnham left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The statement_statement_.get() conversion checks out. Normalizing it away leaves four semantic edits: the Prepare null guard, the StatementPtr member/ctor/Create signature, Finalize()statement_.reset(), and dropping the two manual sqlite3_finalize(s) calls in SQLTagStore::PrepareStatement. All four look right. The removed finalizes are covered by the by-value StatementPtr parameter's destructor when Create bails, and reset() is idempotent, so the FinalizeStatements() / ~StatementSync double-call path stays safe.

While reviewing I hit two pre-existing bugs in the lines this PR touches. Both predate the change and neither is caused by it, so feel free to split them out — but since this PR is about sqlite3_stmt ownership they seemed worth raising here. Both reproduce on a local build of 78e32bc.

1. Prepare tracks a statement it never untracks (node_sqlite.cc:1551-1561)

sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == NULL when the input holds no statement. All of '', ' ', '\n', ';', '-- x', '/* x */' are accepted by prepare() on this branch. The new if (!stmt) return; catches Create failing but not s == nullptr, so a StatementSync with statement_ == nullptr gets inserted into db->statements_ at line 1561. When it is GC'd, ~StatementSync checks if (!IsFinalized()) — already true, since IsFinalized() is statement_ == nullptr — so UntrackStatement(this) is skipped and the freed pointer stays in the set. FinalizeStatements() then calls stmt->Finalize() on freed memory at close().

const{DatabaseSync}=require('node:sqlite');constdb1=newDatabaseSync(':memory:');constdb2=newDatabaseSync(':memory:');db2.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1);');for(leti=0;i<200000;i++)db1.prepare('-- '+i);// stmt == NULLconstlive=[];for(leti=0;i<50000;i++)live.push(db2.prepare('SELECT a FROM t'));db1.close();// walks db1->statements_, now full of stale pointersletbroken=0;for(constsoflive){try{s.get();}catch(e){broken++;}}console.log(broken,'/',live.length,db2.isOpen);

With --max-old-space-size=80: 19889 / 50000 true. Closing db1 corrupts a different database's live statements into "statement has been finalized" while db2.isOpen is still true. Swapping the comment for real SQL ('SELECT ' + i) gives 0 / 50000, isolating it to the NULL-stmt path.

Fix is either bailing before the insert when s == nullptr, or untracking unconditionally in ~StatementSync.

2. Tag store statements are never tracked (node_sqlite.cc:3572-3583)

SQLTagStore::PrepareStatement puts its StatementSync in sql_tags_ but never does db->statements_.insert(...); line 1561 is the only insert in the file. FinalizeStatements() therefore misses them, sqlite3_close_v2 only defers the close, and the cached statement keeps running against the old connection:

constdb=newDatabaseSync(':memory:');db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (111);');conststore=db.createTagStore();store.get`SELECT a FROM t`;// { a: 111 }db.close();db.open();db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (222);');db.prepare('SELECT a FROM t').get();// { a: 222 } new connectionstore.get`SELECT a FROM t`;// { a: 111 } stale

Minor:node_webstorage.h:23 already has stmt_deleter / stmt_unique_ptr for the same job — might be worth one shared definition rather than a second. FinalizeStatement as a free function also reads close to DatabaseSync::FinalizeStatements() and StatementSync::Finalize().

@araujogui

Copy link
Copy Markdown
MemberAuthor

The statement_statement_.get() conversion checks out. Normalizing it away leaves four semantic edits: the Prepare null guard, the StatementPtr member/ctor/Create signature, Finalize()statement_.reset(), and dropping the two manual sqlite3_finalize(s) calls in SQLTagStore::PrepareStatement. All four look right. The removed finalizes are covered by the by-value StatementPtr parameter's destructor when Create bails, and reset() is idempotent, so the FinalizeStatements() / ~StatementSync double-call path stays safe.

While reviewing I hit two pre-existing bugs in the lines this PR touches. Both predate the change and neither is caused by it, so feel free to split them out — but since this PR is about sqlite3_stmt ownership they seemed worth raising here. Both reproduce on a local build of 78e32bc.

1. Prepare tracks a statement it never untracks (node_sqlite.cc:1551-1561)

sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == NULL when the input holds no statement. All of '', ' ', '\n', ';', '-- x', '/* x */' are accepted by prepare() on this branch. The new if (!stmt) return; catches Create failing but not s == nullptr, so a StatementSync with statement_ == nullptr gets inserted into db->statements_ at line 1561. When it is GC'd, ~StatementSync checks if (!IsFinalized()) — already true, since IsFinalized() is statement_ == nullptr — so UntrackStatement(this) is skipped and the freed pointer stays in the set. FinalizeStatements() then calls stmt->Finalize() on freed memory at close().

const{DatabaseSync}=require('node:sqlite');constdb1=newDatabaseSync(':memory:');constdb2=newDatabaseSync(':memory:');db2.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1);');for(leti=0;i<200000;i++)db1.prepare('-- '+i);// stmt == NULLconstlive=[];for(leti=0;i<50000;i++)live.push(db2.prepare('SELECT a FROM t'));db1.close();// walks db1->statements_, now full of stale pointersletbroken=0;for(constsoflive){try{s.get();}catch(e){broken++;}}console.log(broken,'/',live.length,db2.isOpen);

With --max-old-space-size=80: 19889 / 50000 true. Closing db1 corrupts a different database's live statements into "statement has been finalized" while db2.isOpen is still true. Swapping the comment for real SQL ('SELECT ' + i) gives 0 / 50000, isolating it to the NULL-stmt path.

Fix is either bailing before the insert when s == nullptr, or untracking unconditionally in ~StatementSync.

2. Tag store statements are never tracked (node_sqlite.cc:3572-3583)

SQLTagStore::PrepareStatement puts its StatementSync in sql_tags_ but never does db->statements_.insert(...); line 1561 is the only insert in the file. FinalizeStatements() therefore misses them, sqlite3_close_v2 only defers the close, and the cached statement keeps running against the old connection:

constdb=newDatabaseSync(':memory:');db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (111);');conststore=db.createTagStore();store.get`SELECT a FROM t`;// { a: 111 }db.close();db.open();db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (222);');db.prepare('SELECT a FROM t').get();// { a: 222 } new connectionstore.get`SELECT a FROM t`;// { a: 111 } stale

Minor:node_webstorage.h:23 already has stmt_deleter / stmt_unique_ptr for the same job — might be worth one shared definition rather than a second. FinalizeStatement as a free function also reads close to DatabaseSync::FinalizeStatements() and StatementSync::Finalize().

Great catches ty, fixed both.

@TrevorBurnhamTrevorBurnham left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Needs a maintainer to approve.

@araujoguiaraujogui changed the title sqlite: introduces RAII ownership for sqlite3_stmtsqlite: manage sqlite3_stmt lifetime with RAIIAug 9, 2026

@trivikrtrivikr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prepare('') throwing ERR_INVALID_ARG_VALUE where it previously returned a finalized statement is okay without semver, since sqlite is still a Release candidate.

@trivikrtrivikr added author ready PRs with CI started, the required approvals, and no outstanding review comments. request-ci Add this label to start a Jenkins CI on a PR. labels Aug 9, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 9, 2026
@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@nodejs-github-bot

This comment was marked as outdated.

Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
@trivikrtrivikr added request-ci Add this label to start a Jenkins CI on a PR. and removed request-ci Add this label to start a Jenkins CI on a PR. labels Aug 11, 2026
@trivikrtrivikr added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added commit-queue PRs queued for automated landing through the Commit Queue. commit-queue-squash PRs the Commit Queue should land as one squashed commit. labels Aug 11, 2026
@nodejs-github-bot
nodejs-github-bot merged commit b0edc37 into nodejs:mainAug 11, 2026
75 checks passed
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Landed in b0edc37

@nodejs-github-botnodejs-github-bot removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 11, 2026
@araujogui
araujogui deleted the sqlite-stmt-leak branch August 12, 2026 13:37
aduh95 pushed a commit that referenced this pull request Aug 13, 2026
Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
PR-URL: #62419
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
PR-URL: #62419
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author readyPRs with CI started, the required approvals, and no outstanding review comments.c++Issues and PRs that require attention from people who are familiar with C++.commit-queue-squashPRs the Commit Queue should land as one squashed commit.needs-ciPRs that need a full CI run.sqliteIssues and PRs related to the SQLite subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@araujogui@nodejs-github-bot@TrevorBurnham@louwers@trivikr
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

sqlite: manage sqlite3_stmt lifetime with RAII - #62419

Merged
nodejs-github-bot merged 2 commits into
nodejs:mainfrom
araujogui:sqlite-stmt-leak
Aug 11, 2026
Merged

sqlite: manage sqlite3_stmt lifetime with RAII#62419
nodejs-github-bot merged 2 commits into
nodejs:mainfrom
araujogui:sqlite-stmt-leak

Conversation

@araujogui

@araujoguiaraujogui commented Mar 24, 2026

Copy link
Copy Markdown
Member

sqlite3_stmt was owned by raw pointer, with sqlite3_finalize() spread across error paths. Replaces it with StatementPtr (DeleteFnPtr<sqlite3_stmt, FinalizeStatement>), moved into StatementSync.

Fixes two things:

  • StatementSync::Create() failure leaked the statement and inserted a null pointer into db->statements_, which FinalizeStatements() dereferences.
  • Tag store statements were never tracked in db->statements_, so db.close() left them open. Tracking them also makes the existing IsFinalized() re-prepare path in the cache lookup reachable.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/sqlite

@nodejs-github-botnodejs-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. sqlite Issues and PRs related to the SQLite subsystem. labels Mar 24, 2026
@codecov

codecovBot commented Mar 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.22807% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.31%. Comparing base (673cdef) to head (31e31a1).
⚠️ Report is 7 commits behind head on main.

Files with missing linesPatch %Lines
src/node_sqlite.cc90.74%2 Missing and 3 partials ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #62419 +/- ##
==========================================
- Coverage 90.32% 90.31% -0.02% 
==========================================
Files 760 760 Lines 248553 248560 +7 Branches 46910 46909 -1 ==========================================
- Hits 224510 224489 -21 - Misses 15462 15497 +35 + Partials 8581 8574 -7 
Files with missing linesCoverage Δ
src/node_sqlite.h83.33% <100.00%> (+0.72%)⬆️
src/node_sqlite.cc81.46% <90.74%> (+0.17%)⬆️

... and 31 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@louwerslouwers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can RAII be used instead?

@araujogui

Copy link
Copy Markdown
MemberAuthor

Can RAII be used instead?

Well, we could use std::unique_ptr<sqlite3_stmt, decltype(&sqlite3_finalize)>, but I feel the current way is good enough.

CopilotAI review requested due to automatic review settings July 16, 2026 13:47
@araujogui

Copy link
Copy Markdown
MemberAuthor

@nodejs/sqlite Bump, I did a safety improvement by implementing RAII for sqlite3_stmt.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a resource-management bug in the SQLite sync binding where a prepared sqlite3_stmt could be leaked (and a nullptr inserted into db->statements_) when StatementSync::Create fails to allocate the JS wrapper object (e.g., OOM).

Changes:

  • Introduces RAII ownership for sqlite3_stmt via StatementPtr (DeleteFnPtr + FinalizeStatement).
  • Updates StatementSync to own the prepared statement with StatementPtr and uses .get() at call sites.
  • Ensures DatabaseSync::Prepare returns early when StatementSync::Create fails, preventing nullptr insertion into db->statements_.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

FileDescription
src/node_sqlite.hAdds FinalizeStatement + StatementPtr alias and updates StatementSync API/member to use RAII for sqlite3_stmt.
src/node_sqlite.ccTransfers statement ownership with StatementPtr, resets via RAII, and guards against Create() failure to avoid leaks/null insertion.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@araujoguiaraujogui changed the title sqlite: fix sqlite3_stmt leak in prepare create failuresqlite: introduces RAII ownership for sqlite3_stmtJul 16, 2026

@TrevorBurnhamTrevorBurnham left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The statement_statement_.get() conversion checks out. Normalizing it away leaves four semantic edits: the Prepare null guard, the StatementPtr member/ctor/Create signature, Finalize()statement_.reset(), and dropping the two manual sqlite3_finalize(s) calls in SQLTagStore::PrepareStatement. All four look right. The removed finalizes are covered by the by-value StatementPtr parameter's destructor when Create bails, and reset() is idempotent, so the FinalizeStatements() / ~StatementSync double-call path stays safe.

While reviewing I hit two pre-existing bugs in the lines this PR touches. Both predate the change and neither is caused by it, so feel free to split them out — but since this PR is about sqlite3_stmt ownership they seemed worth raising here. Both reproduce on a local build of 78e32bc.

1. Prepare tracks a statement it never untracks (node_sqlite.cc:1551-1561)

sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == NULL when the input holds no statement. All of '', ' ', '\n', ';', '-- x', '/* x */' are accepted by prepare() on this branch. The new if (!stmt) return; catches Create failing but not s == nullptr, so a StatementSync with statement_ == nullptr gets inserted into db->statements_ at line 1561. When it is GC'd, ~StatementSync checks if (!IsFinalized()) — already true, since IsFinalized() is statement_ == nullptr — so UntrackStatement(this) is skipped and the freed pointer stays in the set. FinalizeStatements() then calls stmt->Finalize() on freed memory at close().

const{DatabaseSync}=require('node:sqlite');constdb1=newDatabaseSync(':memory:');constdb2=newDatabaseSync(':memory:');db2.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1);');for(leti=0;i<200000;i++)db1.prepare('-- '+i);// stmt == NULLconstlive=[];for(leti=0;i<50000;i++)live.push(db2.prepare('SELECT a FROM t'));db1.close();// walks db1->statements_, now full of stale pointersletbroken=0;for(constsoflive){try{s.get();}catch(e){broken++;}}console.log(broken,'/',live.length,db2.isOpen);

With --max-old-space-size=80: 19889 / 50000 true. Closing db1 corrupts a different database's live statements into "statement has been finalized" while db2.isOpen is still true. Swapping the comment for real SQL ('SELECT ' + i) gives 0 / 50000, isolating it to the NULL-stmt path.

Fix is either bailing before the insert when s == nullptr, or untracking unconditionally in ~StatementSync.

2. Tag store statements are never tracked (node_sqlite.cc:3572-3583)

SQLTagStore::PrepareStatement puts its StatementSync in sql_tags_ but never does db->statements_.insert(...); line 1561 is the only insert in the file. FinalizeStatements() therefore misses them, sqlite3_close_v2 only defers the close, and the cached statement keeps running against the old connection:

constdb=newDatabaseSync(':memory:');db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (111);');conststore=db.createTagStore();store.get`SELECT a FROM t`;// { a: 111 }db.close();db.open();db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (222);');db.prepare('SELECT a FROM t').get();// { a: 222 } new connectionstore.get`SELECT a FROM t`;// { a: 111 } stale

Minor:node_webstorage.h:23 already has stmt_deleter / stmt_unique_ptr for the same job — might be worth one shared definition rather than a second. FinalizeStatement as a free function also reads close to DatabaseSync::FinalizeStatements() and StatementSync::Finalize().

@araujogui

Copy link
Copy Markdown
MemberAuthor

The statement_statement_.get() conversion checks out. Normalizing it away leaves four semantic edits: the Prepare null guard, the StatementPtr member/ctor/Create signature, Finalize()statement_.reset(), and dropping the two manual sqlite3_finalize(s) calls in SQLTagStore::PrepareStatement. All four look right. The removed finalizes are covered by the by-value StatementPtr parameter's destructor when Create bails, and reset() is idempotent, so the FinalizeStatements() / ~StatementSync double-call path stays safe.

While reviewing I hit two pre-existing bugs in the lines this PR touches. Both predate the change and neither is caused by it, so feel free to split them out — but since this PR is about sqlite3_stmt ownership they seemed worth raising here. Both reproduce on a local build of 78e32bc.

1. Prepare tracks a statement it never untracks (node_sqlite.cc:1551-1561)

sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == NULL when the input holds no statement. All of '', ' ', '\n', ';', '-- x', '/* x */' are accepted by prepare() on this branch. The new if (!stmt) return; catches Create failing but not s == nullptr, so a StatementSync with statement_ == nullptr gets inserted into db->statements_ at line 1561. When it is GC'd, ~StatementSync checks if (!IsFinalized()) — already true, since IsFinalized() is statement_ == nullptr — so UntrackStatement(this) is skipped and the freed pointer stays in the set. FinalizeStatements() then calls stmt->Finalize() on freed memory at close().

const{DatabaseSync}=require('node:sqlite');constdb1=newDatabaseSync(':memory:');constdb2=newDatabaseSync(':memory:');db2.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1);');for(leti=0;i<200000;i++)db1.prepare('-- '+i);// stmt == NULLconstlive=[];for(leti=0;i<50000;i++)live.push(db2.prepare('SELECT a FROM t'));db1.close();// walks db1->statements_, now full of stale pointersletbroken=0;for(constsoflive){try{s.get();}catch(e){broken++;}}console.log(broken,'/',live.length,db2.isOpen);

With --max-old-space-size=80: 19889 / 50000 true. Closing db1 corrupts a different database's live statements into "statement has been finalized" while db2.isOpen is still true. Swapping the comment for real SQL ('SELECT ' + i) gives 0 / 50000, isolating it to the NULL-stmt path.

Fix is either bailing before the insert when s == nullptr, or untracking unconditionally in ~StatementSync.

2. Tag store statements are never tracked (node_sqlite.cc:3572-3583)

SQLTagStore::PrepareStatement puts its StatementSync in sql_tags_ but never does db->statements_.insert(...); line 1561 is the only insert in the file. FinalizeStatements() therefore misses them, sqlite3_close_v2 only defers the close, and the cached statement keeps running against the old connection:

constdb=newDatabaseSync(':memory:');db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (111);');conststore=db.createTagStore();store.get`SELECT a FROM t`;// { a: 111 }db.close();db.open();db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (222);');db.prepare('SELECT a FROM t').get();// { a: 222 } new connectionstore.get`SELECT a FROM t`;// { a: 111 } stale

Minor:node_webstorage.h:23 already has stmt_deleter / stmt_unique_ptr for the same job — might be worth one shared definition rather than a second. FinalizeStatement as a free function also reads close to DatabaseSync::FinalizeStatements() and StatementSync::Finalize().

Great catches ty, fixed both.

@TrevorBurnhamTrevorBurnham left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Needs a maintainer to approve.

@araujoguiaraujogui changed the title sqlite: introduces RAII ownership for sqlite3_stmtsqlite: manage sqlite3_stmt lifetime with RAIIAug 9, 2026

@trivikrtrivikr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prepare('') throwing ERR_INVALID_ARG_VALUE where it previously returned a finalized statement is okay without semver, since sqlite is still a Release candidate.

@trivikrtrivikr added author ready PRs with CI started, the required approvals, and no outstanding review comments. request-ci Add this label to start a Jenkins CI on a PR. labels Aug 9, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 9, 2026
@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@nodejs-github-bot

This comment was marked as outdated.

Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
@trivikrtrivikr added request-ci Add this label to start a Jenkins CI on a PR. and removed request-ci Add this label to start a Jenkins CI on a PR. labels Aug 11, 2026
@trivikrtrivikr added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added commit-queue PRs queued for automated landing through the Commit Queue. commit-queue-squash PRs the Commit Queue should land as one squashed commit. labels Aug 11, 2026
@nodejs-github-bot
nodejs-github-bot merged commit b0edc37 into nodejs:mainAug 11, 2026
75 checks passed
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Landed in b0edc37

@nodejs-github-botnodejs-github-bot removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 11, 2026
@araujogui
araujogui deleted the sqlite-stmt-leak branch August 12, 2026 13:37
aduh95 pushed a commit that referenced this pull request Aug 13, 2026
Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
PR-URL: #62419
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
PR-URL: #62419
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author readyPRs with CI started, the required approvals, and no outstanding review comments.c++Issues and PRs that require attention from people who are familiar with C++.commit-queue-squashPRs the Commit Queue should land as one squashed commit.needs-ciPRs that need a full CI run.sqliteIssues and PRs related to the SQLite subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@araujogui@nodejs-github-bot@TrevorBurnham@louwers@trivikr
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

sqlite: manage sqlite3_stmt lifetime with RAII - #62419

Merged
nodejs-github-bot merged 2 commits into
nodejs:mainfrom
araujogui:sqlite-stmt-leak
Aug 11, 2026
Merged

sqlite: manage sqlite3_stmt lifetime with RAII#62419
nodejs-github-bot merged 2 commits into
nodejs:mainfrom
araujogui:sqlite-stmt-leak

Conversation

@araujogui

@araujoguiaraujogui commented Mar 24, 2026

Copy link
Copy Markdown
Member

sqlite3_stmt was owned by raw pointer, with sqlite3_finalize() spread across error paths. Replaces it with StatementPtr (DeleteFnPtr<sqlite3_stmt, FinalizeStatement>), moved into StatementSync.

Fixes two things:

  • StatementSync::Create() failure leaked the statement and inserted a null pointer into db->statements_, which FinalizeStatements() dereferences.
  • Tag store statements were never tracked in db->statements_, so db.close() left them open. Tracking them also makes the existing IsFinalized() re-prepare path in the cache lookup reachable.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/sqlite

@nodejs-github-botnodejs-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. sqlite Issues and PRs related to the SQLite subsystem. labels Mar 24, 2026
@codecov

codecovBot commented Mar 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.22807% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.31%. Comparing base (673cdef) to head (31e31a1).
⚠️ Report is 7 commits behind head on main.

Files with missing linesPatch %Lines
src/node_sqlite.cc90.74%2 Missing and 3 partials ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #62419 +/- ##
==========================================
- Coverage 90.32% 90.31% -0.02% 
==========================================
Files 760 760 Lines 248553 248560 +7 Branches 46910 46909 -1 ==========================================
- Hits 224510 224489 -21 - Misses 15462 15497 +35 + Partials 8581 8574 -7 
Files with missing linesCoverage Δ
src/node_sqlite.h83.33% <100.00%> (+0.72%)⬆️
src/node_sqlite.cc81.46% <90.74%> (+0.17%)⬆️

... and 31 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@louwerslouwers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can RAII be used instead?

@araujogui

Copy link
Copy Markdown
MemberAuthor

Can RAII be used instead?

Well, we could use std::unique_ptr<sqlite3_stmt, decltype(&sqlite3_finalize)>, but I feel the current way is good enough.

CopilotAI review requested due to automatic review settings July 16, 2026 13:47
@araujogui

Copy link
Copy Markdown
MemberAuthor

@nodejs/sqlite Bump, I did a safety improvement by implementing RAII for sqlite3_stmt.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a resource-management bug in the SQLite sync binding where a prepared sqlite3_stmt could be leaked (and a nullptr inserted into db->statements_) when StatementSync::Create fails to allocate the JS wrapper object (e.g., OOM).

Changes:

  • Introduces RAII ownership for sqlite3_stmt via StatementPtr (DeleteFnPtr + FinalizeStatement).
  • Updates StatementSync to own the prepared statement with StatementPtr and uses .get() at call sites.
  • Ensures DatabaseSync::Prepare returns early when StatementSync::Create fails, preventing nullptr insertion into db->statements_.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

FileDescription
src/node_sqlite.hAdds FinalizeStatement + StatementPtr alias and updates StatementSync API/member to use RAII for sqlite3_stmt.
src/node_sqlite.ccTransfers statement ownership with StatementPtr, resets via RAII, and guards against Create() failure to avoid leaks/null insertion.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@araujoguiaraujogui changed the title sqlite: fix sqlite3_stmt leak in prepare create failuresqlite: introduces RAII ownership for sqlite3_stmtJul 16, 2026

@TrevorBurnhamTrevorBurnham left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The statement_statement_.get() conversion checks out. Normalizing it away leaves four semantic edits: the Prepare null guard, the StatementPtr member/ctor/Create signature, Finalize()statement_.reset(), and dropping the two manual sqlite3_finalize(s) calls in SQLTagStore::PrepareStatement. All four look right. The removed finalizes are covered by the by-value StatementPtr parameter's destructor when Create bails, and reset() is idempotent, so the FinalizeStatements() / ~StatementSync double-call path stays safe.

While reviewing I hit two pre-existing bugs in the lines this PR touches. Both predate the change and neither is caused by it, so feel free to split them out — but since this PR is about sqlite3_stmt ownership they seemed worth raising here. Both reproduce on a local build of 78e32bc.

1. Prepare tracks a statement it never untracks (node_sqlite.cc:1551-1561)

sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == NULL when the input holds no statement. All of '', ' ', '\n', ';', '-- x', '/* x */' are accepted by prepare() on this branch. The new if (!stmt) return; catches Create failing but not s == nullptr, so a StatementSync with statement_ == nullptr gets inserted into db->statements_ at line 1561. When it is GC'd, ~StatementSync checks if (!IsFinalized()) — already true, since IsFinalized() is statement_ == nullptr — so UntrackStatement(this) is skipped and the freed pointer stays in the set. FinalizeStatements() then calls stmt->Finalize() on freed memory at close().

const{DatabaseSync}=require('node:sqlite');constdb1=newDatabaseSync(':memory:');constdb2=newDatabaseSync(':memory:');db2.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1);');for(leti=0;i<200000;i++)db1.prepare('-- '+i);// stmt == NULLconstlive=[];for(leti=0;i<50000;i++)live.push(db2.prepare('SELECT a FROM t'));db1.close();// walks db1->statements_, now full of stale pointersletbroken=0;for(constsoflive){try{s.get();}catch(e){broken++;}}console.log(broken,'/',live.length,db2.isOpen);

With --max-old-space-size=80: 19889 / 50000 true. Closing db1 corrupts a different database's live statements into "statement has been finalized" while db2.isOpen is still true. Swapping the comment for real SQL ('SELECT ' + i) gives 0 / 50000, isolating it to the NULL-stmt path.

Fix is either bailing before the insert when s == nullptr, or untracking unconditionally in ~StatementSync.

2. Tag store statements are never tracked (node_sqlite.cc:3572-3583)

SQLTagStore::PrepareStatement puts its StatementSync in sql_tags_ but never does db->statements_.insert(...); line 1561 is the only insert in the file. FinalizeStatements() therefore misses them, sqlite3_close_v2 only defers the close, and the cached statement keeps running against the old connection:

constdb=newDatabaseSync(':memory:');db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (111);');conststore=db.createTagStore();store.get`SELECT a FROM t`;// { a: 111 }db.close();db.open();db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (222);');db.prepare('SELECT a FROM t').get();// { a: 222 } new connectionstore.get`SELECT a FROM t`;// { a: 111 } stale

Minor:node_webstorage.h:23 already has stmt_deleter / stmt_unique_ptr for the same job — might be worth one shared definition rather than a second. FinalizeStatement as a free function also reads close to DatabaseSync::FinalizeStatements() and StatementSync::Finalize().

@araujogui

Copy link
Copy Markdown
MemberAuthor

The statement_statement_.get() conversion checks out. Normalizing it away leaves four semantic edits: the Prepare null guard, the StatementPtr member/ctor/Create signature, Finalize()statement_.reset(), and dropping the two manual sqlite3_finalize(s) calls in SQLTagStore::PrepareStatement. All four look right. The removed finalizes are covered by the by-value StatementPtr parameter's destructor when Create bails, and reset() is idempotent, so the FinalizeStatements() / ~StatementSync double-call path stays safe.

While reviewing I hit two pre-existing bugs in the lines this PR touches. Both predate the change and neither is caused by it, so feel free to split them out — but since this PR is about sqlite3_stmt ownership they seemed worth raising here. Both reproduce on a local build of 78e32bc.

1. Prepare tracks a statement it never untracks (node_sqlite.cc:1551-1561)

sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == NULL when the input holds no statement. All of '', ' ', '\n', ';', '-- x', '/* x */' are accepted by prepare() on this branch. The new if (!stmt) return; catches Create failing but not s == nullptr, so a StatementSync with statement_ == nullptr gets inserted into db->statements_ at line 1561. When it is GC'd, ~StatementSync checks if (!IsFinalized()) — already true, since IsFinalized() is statement_ == nullptr — so UntrackStatement(this) is skipped and the freed pointer stays in the set. FinalizeStatements() then calls stmt->Finalize() on freed memory at close().

const{DatabaseSync}=require('node:sqlite');constdb1=newDatabaseSync(':memory:');constdb2=newDatabaseSync(':memory:');db2.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1);');for(leti=0;i<200000;i++)db1.prepare('-- '+i);// stmt == NULLconstlive=[];for(leti=0;i<50000;i++)live.push(db2.prepare('SELECT a FROM t'));db1.close();// walks db1->statements_, now full of stale pointersletbroken=0;for(constsoflive){try{s.get();}catch(e){broken++;}}console.log(broken,'/',live.length,db2.isOpen);

With --max-old-space-size=80: 19889 / 50000 true. Closing db1 corrupts a different database's live statements into "statement has been finalized" while db2.isOpen is still true. Swapping the comment for real SQL ('SELECT ' + i) gives 0 / 50000, isolating it to the NULL-stmt path.

Fix is either bailing before the insert when s == nullptr, or untracking unconditionally in ~StatementSync.

2. Tag store statements are never tracked (node_sqlite.cc:3572-3583)

SQLTagStore::PrepareStatement puts its StatementSync in sql_tags_ but never does db->statements_.insert(...); line 1561 is the only insert in the file. FinalizeStatements() therefore misses them, sqlite3_close_v2 only defers the close, and the cached statement keeps running against the old connection:

constdb=newDatabaseSync(':memory:');db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (111);');conststore=db.createTagStore();store.get`SELECT a FROM t`;// { a: 111 }db.close();db.open();db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (222);');db.prepare('SELECT a FROM t').get();// { a: 222 } new connectionstore.get`SELECT a FROM t`;// { a: 111 } stale

Minor:node_webstorage.h:23 already has stmt_deleter / stmt_unique_ptr for the same job — might be worth one shared definition rather than a second. FinalizeStatement as a free function also reads close to DatabaseSync::FinalizeStatements() and StatementSync::Finalize().

Great catches ty, fixed both.

@TrevorBurnhamTrevorBurnham left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Needs a maintainer to approve.

@araujoguiaraujogui changed the title sqlite: introduces RAII ownership for sqlite3_stmtsqlite: manage sqlite3_stmt lifetime with RAIIAug 9, 2026

@trivikrtrivikr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prepare('') throwing ERR_INVALID_ARG_VALUE where it previously returned a finalized statement is okay without semver, since sqlite is still a Release candidate.

@trivikrtrivikr added author ready PRs with CI started, the required approvals, and no outstanding review comments. request-ci Add this label to start a Jenkins CI on a PR. labels Aug 9, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 9, 2026
@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@nodejs-github-bot

This comment was marked as outdated.

Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
@trivikrtrivikr added request-ci Add this label to start a Jenkins CI on a PR. and removed request-ci Add this label to start a Jenkins CI on a PR. labels Aug 11, 2026
@trivikrtrivikr added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added commit-queue PRs queued for automated landing through the Commit Queue. commit-queue-squash PRs the Commit Queue should land as one squashed commit. labels Aug 11, 2026
@nodejs-github-bot
nodejs-github-bot merged commit b0edc37 into nodejs:mainAug 11, 2026
75 checks passed
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Landed in b0edc37

@nodejs-github-botnodejs-github-bot removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 11, 2026
@araujogui
araujogui deleted the sqlite-stmt-leak branch August 12, 2026 13:37
aduh95 pushed a commit that referenced this pull request Aug 13, 2026
Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
PR-URL: #62419
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
PR-URL: #62419
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author readyPRs with CI started, the required approvals, and no outstanding review comments.c++Issues and PRs that require attention from people who are familiar with C++.commit-queue-squashPRs the Commit Queue should land as one squashed commit.needs-ciPRs that need a full CI run.sqliteIssues and PRs related to the SQLite subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@araujogui@nodejs-github-bot@TrevorBurnham@louwers@trivikr
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

sqlite: manage sqlite3_stmt lifetime with RAII - #62419

Merged
nodejs-github-bot merged 2 commits into
nodejs:mainfrom
araujogui:sqlite-stmt-leak
Aug 11, 2026
Merged

sqlite: manage sqlite3_stmt lifetime with RAII#62419
nodejs-github-bot merged 2 commits into
nodejs:mainfrom
araujogui:sqlite-stmt-leak

Conversation

@araujogui

@araujoguiaraujogui commented Mar 24, 2026

Copy link
Copy Markdown
Member

sqlite3_stmt was owned by raw pointer, with sqlite3_finalize() spread across error paths. Replaces it with StatementPtr (DeleteFnPtr<sqlite3_stmt, FinalizeStatement>), moved into StatementSync.

Fixes two things:

  • StatementSync::Create() failure leaked the statement and inserted a null pointer into db->statements_, which FinalizeStatements() dereferences.
  • Tag store statements were never tracked in db->statements_, so db.close() left them open. Tracking them also makes the existing IsFinalized() re-prepare path in the cache lookup reachable.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/sqlite

@nodejs-github-botnodejs-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. sqlite Issues and PRs related to the SQLite subsystem. labels Mar 24, 2026
@codecov

codecovBot commented Mar 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.22807% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.31%. Comparing base (673cdef) to head (31e31a1).
⚠️ Report is 7 commits behind head on main.

Files with missing linesPatch %Lines
src/node_sqlite.cc90.74%2 Missing and 3 partials ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #62419 +/- ##
==========================================
- Coverage 90.32% 90.31% -0.02% 
==========================================
Files 760 760 Lines 248553 248560 +7 Branches 46910 46909 -1 ==========================================
- Hits 224510 224489 -21 - Misses 15462 15497 +35 + Partials 8581 8574 -7 
Files with missing linesCoverage Δ
src/node_sqlite.h83.33% <100.00%> (+0.72%)⬆️
src/node_sqlite.cc81.46% <90.74%> (+0.17%)⬆️

... and 31 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@louwerslouwers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can RAII be used instead?

@araujogui

Copy link
Copy Markdown
MemberAuthor

Can RAII be used instead?

Well, we could use std::unique_ptr<sqlite3_stmt, decltype(&sqlite3_finalize)>, but I feel the current way is good enough.

CopilotAI review requested due to automatic review settings July 16, 2026 13:47
@araujogui

Copy link
Copy Markdown
MemberAuthor

@nodejs/sqlite Bump, I did a safety improvement by implementing RAII for sqlite3_stmt.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a resource-management bug in the SQLite sync binding where a prepared sqlite3_stmt could be leaked (and a nullptr inserted into db->statements_) when StatementSync::Create fails to allocate the JS wrapper object (e.g., OOM).

Changes:

  • Introduces RAII ownership for sqlite3_stmt via StatementPtr (DeleteFnPtr + FinalizeStatement).
  • Updates StatementSync to own the prepared statement with StatementPtr and uses .get() at call sites.
  • Ensures DatabaseSync::Prepare returns early when StatementSync::Create fails, preventing nullptr insertion into db->statements_.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

FileDescription
src/node_sqlite.hAdds FinalizeStatement + StatementPtr alias and updates StatementSync API/member to use RAII for sqlite3_stmt.
src/node_sqlite.ccTransfers statement ownership with StatementPtr, resets via RAII, and guards against Create() failure to avoid leaks/null insertion.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@araujoguiaraujogui changed the title sqlite: fix sqlite3_stmt leak in prepare create failuresqlite: introduces RAII ownership for sqlite3_stmtJul 16, 2026

@TrevorBurnhamTrevorBurnham left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The statement_statement_.get() conversion checks out. Normalizing it away leaves four semantic edits: the Prepare null guard, the StatementPtr member/ctor/Create signature, Finalize()statement_.reset(), and dropping the two manual sqlite3_finalize(s) calls in SQLTagStore::PrepareStatement. All four look right. The removed finalizes are covered by the by-value StatementPtr parameter's destructor when Create bails, and reset() is idempotent, so the FinalizeStatements() / ~StatementSync double-call path stays safe.

While reviewing I hit two pre-existing bugs in the lines this PR touches. Both predate the change and neither is caused by it, so feel free to split them out — but since this PR is about sqlite3_stmt ownership they seemed worth raising here. Both reproduce on a local build of 78e32bc.

1. Prepare tracks a statement it never untracks (node_sqlite.cc:1551-1561)

sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == NULL when the input holds no statement. All of '', ' ', '\n', ';', '-- x', '/* x */' are accepted by prepare() on this branch. The new if (!stmt) return; catches Create failing but not s == nullptr, so a StatementSync with statement_ == nullptr gets inserted into db->statements_ at line 1561. When it is GC'd, ~StatementSync checks if (!IsFinalized()) — already true, since IsFinalized() is statement_ == nullptr — so UntrackStatement(this) is skipped and the freed pointer stays in the set. FinalizeStatements() then calls stmt->Finalize() on freed memory at close().

const{DatabaseSync}=require('node:sqlite');constdb1=newDatabaseSync(':memory:');constdb2=newDatabaseSync(':memory:');db2.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1);');for(leti=0;i<200000;i++)db1.prepare('-- '+i);// stmt == NULLconstlive=[];for(leti=0;i<50000;i++)live.push(db2.prepare('SELECT a FROM t'));db1.close();// walks db1->statements_, now full of stale pointersletbroken=0;for(constsoflive){try{s.get();}catch(e){broken++;}}console.log(broken,'/',live.length,db2.isOpen);

With --max-old-space-size=80: 19889 / 50000 true. Closing db1 corrupts a different database's live statements into "statement has been finalized" while db2.isOpen is still true. Swapping the comment for real SQL ('SELECT ' + i) gives 0 / 50000, isolating it to the NULL-stmt path.

Fix is either bailing before the insert when s == nullptr, or untracking unconditionally in ~StatementSync.

2. Tag store statements are never tracked (node_sqlite.cc:3572-3583)

SQLTagStore::PrepareStatement puts its StatementSync in sql_tags_ but never does db->statements_.insert(...); line 1561 is the only insert in the file. FinalizeStatements() therefore misses them, sqlite3_close_v2 only defers the close, and the cached statement keeps running against the old connection:

constdb=newDatabaseSync(':memory:');db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (111);');conststore=db.createTagStore();store.get`SELECT a FROM t`;// { a: 111 }db.close();db.open();db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (222);');db.prepare('SELECT a FROM t').get();// { a: 222 } new connectionstore.get`SELECT a FROM t`;// { a: 111 } stale

Minor:node_webstorage.h:23 already has stmt_deleter / stmt_unique_ptr for the same job — might be worth one shared definition rather than a second. FinalizeStatement as a free function also reads close to DatabaseSync::FinalizeStatements() and StatementSync::Finalize().

@araujogui

Copy link
Copy Markdown
MemberAuthor

The statement_statement_.get() conversion checks out. Normalizing it away leaves four semantic edits: the Prepare null guard, the StatementPtr member/ctor/Create signature, Finalize()statement_.reset(), and dropping the two manual sqlite3_finalize(s) calls in SQLTagStore::PrepareStatement. All four look right. The removed finalizes are covered by the by-value StatementPtr parameter's destructor when Create bails, and reset() is idempotent, so the FinalizeStatements() / ~StatementSync double-call path stays safe.

While reviewing I hit two pre-existing bugs in the lines this PR touches. Both predate the change and neither is caused by it, so feel free to split them out — but since this PR is about sqlite3_stmt ownership they seemed worth raising here. Both reproduce on a local build of 78e32bc.

1. Prepare tracks a statement it never untracks (node_sqlite.cc:1551-1561)

sqlite3_prepare_v2 returns SQLITE_OK with *ppStmt == NULL when the input holds no statement. All of '', ' ', '\n', ';', '-- x', '/* x */' are accepted by prepare() on this branch. The new if (!stmt) return; catches Create failing but not s == nullptr, so a StatementSync with statement_ == nullptr gets inserted into db->statements_ at line 1561. When it is GC'd, ~StatementSync checks if (!IsFinalized()) — already true, since IsFinalized() is statement_ == nullptr — so UntrackStatement(this) is skipped and the freed pointer stays in the set. FinalizeStatements() then calls stmt->Finalize() on freed memory at close().

const{DatabaseSync}=require('node:sqlite');constdb1=newDatabaseSync(':memory:');constdb2=newDatabaseSync(':memory:');db2.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1);');for(leti=0;i<200000;i++)db1.prepare('-- '+i);// stmt == NULLconstlive=[];for(leti=0;i<50000;i++)live.push(db2.prepare('SELECT a FROM t'));db1.close();// walks db1->statements_, now full of stale pointersletbroken=0;for(constsoflive){try{s.get();}catch(e){broken++;}}console.log(broken,'/',live.length,db2.isOpen);

With --max-old-space-size=80: 19889 / 50000 true. Closing db1 corrupts a different database's live statements into "statement has been finalized" while db2.isOpen is still true. Swapping the comment for real SQL ('SELECT ' + i) gives 0 / 50000, isolating it to the NULL-stmt path.

Fix is either bailing before the insert when s == nullptr, or untracking unconditionally in ~StatementSync.

2. Tag store statements are never tracked (node_sqlite.cc:3572-3583)

SQLTagStore::PrepareStatement puts its StatementSync in sql_tags_ but never does db->statements_.insert(...); line 1561 is the only insert in the file. FinalizeStatements() therefore misses them, sqlite3_close_v2 only defers the close, and the cached statement keeps running against the old connection:

constdb=newDatabaseSync(':memory:');db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (111);');conststore=db.createTagStore();store.get`SELECT a FROM t`;// { a: 111 }db.close();db.open();db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (222);');db.prepare('SELECT a FROM t').get();// { a: 222 } new connectionstore.get`SELECT a FROM t`;// { a: 111 } stale

Minor:node_webstorage.h:23 already has stmt_deleter / stmt_unique_ptr for the same job — might be worth one shared definition rather than a second. FinalizeStatement as a free function also reads close to DatabaseSync::FinalizeStatements() and StatementSync::Finalize().

Great catches ty, fixed both.

@TrevorBurnhamTrevorBurnham left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Needs a maintainer to approve.

@araujoguiaraujogui changed the title sqlite: introduces RAII ownership for sqlite3_stmtsqlite: manage sqlite3_stmt lifetime with RAIIAug 9, 2026

@trivikrtrivikr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prepare('') throwing ERR_INVALID_ARG_VALUE where it previously returned a finalized statement is okay without semver, since sqlite is still a Release candidate.

@trivikrtrivikr added author ready PRs with CI started, the required approvals, and no outstanding review comments. request-ci Add this label to start a Jenkins CI on a PR. labels Aug 9, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 9, 2026
@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@nodejs-github-bot

This comment was marked as outdated.

Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
@trivikrtrivikr added request-ci Add this label to start a Jenkins CI on a PR. and removed request-ci Add this label to start a Jenkins CI on a PR. labels Aug 11, 2026
@trivikrtrivikr added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 11, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added commit-queue PRs queued for automated landing through the Commit Queue. commit-queue-squash PRs the Commit Queue should land as one squashed commit. labels Aug 11, 2026
@nodejs-github-bot
nodejs-github-bot merged commit b0edc37 into nodejs:mainAug 11, 2026
75 checks passed
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Landed in b0edc37

@nodejs-github-botnodejs-github-bot removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 11, 2026
@araujogui
araujogui deleted the sqlite-stmt-leak branch August 12, 2026 13:37
aduh95 pushed a commit that referenced this pull request Aug 13, 2026
Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
PR-URL: #62419
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
PR-URL: #62419
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author readyPRs with CI started, the required approvals, and no outstanding review comments.c++Issues and PRs that require attention from people who are familiar with C++.commit-queue-squashPRs the Commit Queue should land as one squashed commit.needs-ciPRs that need a full CI run.sqliteIssues and PRs related to the SQLite subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@araujogui@nodejs-github-bot@TrevorBurnham@louwers@trivikr