Skip to content

sqlite: check sqlite3_step() and sqlite3_reset() results - #63319

Open
semimikoh wants to merge 2 commits into
nodejs:mainfrom
semimikoh:sqlite/check-step-reset-returns
Open

sqlite: check sqlite3_step() and sqlite3_reset() results#63319
semimikoh wants to merge 2 commits into
nodejs:mainfrom
semimikoh:sqlite/check-step-reset-returns

Conversation

@semimikoh

Copy link
Copy Markdown
Contributor

Summary

Per the SQLite docs, sqlite3_reset(S) may return a deferred error code
from the prior sqlite3_step(S) call. Several statement execution paths in
src/node_sqlite.cc dropped that return value, which could silently ignore
SQLite errors.

This also checks the previously ignored sqlite3_step() result in
StatementExecutionHelper::Run().

Fixes: #63311

Approach

Successful execution paths now explicitly check sqlite3_reset().

Functions with early-return or V8-exception paths keep an OnScopeLeave
reset guard so prepared statements are left reusable. The guard intentionally
drops the reset result to avoid replacing an already-pending SQLite or V8
exception.

StatementSyncIterator::Next() and StatementSyncIterator::Return() use a
direct checked reset because their control flow is linear.

$ python3 tools/cpplint.py src/node_sqlite.cc

Done processing src/node_sqlite.cc

$ git diff --check -- src/node_sqlite.cc

A full local build was not completed because this machine has Apple clang
16.0.0, while the current tree requires a newer macOS toolchain. The build
fails in V8 because std::atomic_ref is unavailable.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/sqlite

@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. sqlite Issues and PRs related to the SQLite subsystem. labels May 15, 2026
@semimikoh
semimikoh force-pushed the sqlite/check-step-reset-returns branch 3 times, most recently from 557bcde to 4740589 Compare May 15, 2026 05:40
@codecov

codecov Bot commented May 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 74.28571% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.31%. Comparing base (6c862f4) to head (52592e6).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
src/node_sqlite.cc 74.28% 2 Missing and 7 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #63319      +/-   ##
==========================================
+ Coverage   90.29%   90.31%   +0.01%     
==========================================
  Files         759      759              
  Lines      248295   248322      +27     
  Branches    46861    46876      +15     
==========================================
+ Hits       224205   224268      +63     
+ Misses      15517    15473      -44     
- Partials     8573     8581       +8     
Files with missing lines Coverage Δ
src/node_sqlite.cc 80.64% <74.28%> (-0.21%) ⬇️

... and 28 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.

@geeksilva97

Copy link
Copy Markdown
Contributor

I tried to reproduce a situation where the current code wouldn't catch the error but I couldn't. Please, get such a case into a test so it's clear which situation we must cover.

@TrevorBurnham TrevorBurnham 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.

Reviewed the reset-error handling. The direction looks right; sqlite3_reset() returning a deferred error from the prior sqlite3_step() is real and worth surfacing. Two things I'd want addressed:

  1. In StatementSyncIterator, RESET_OR_THROW expands to a return, so the new throwing paths skip iter->done_ = true even though the statement has already been reset. A caught error then leaves the iterator resumable, and it replays the result set from the top. Details inline.

  2. No tests. get()/all() can now throw where they previously returned an already-built row/array, which is a user-visible change on a success path. Worth coverage pinning the new behavior, plus a note on whether it needs semver-major.

Things I checked that look correct: needs_reset = false is sequenced before sqlite3_reset(), so there's no double reset on the throwing path; no function can call THROW_ERR_SQLITE_ERROR twice, so the ShouldIgnoreSQLiteError() one-shot isn't consumed twice; void() threads through both macro layers; and every RESET_AND_CHECK caller keeps its OnScopeLeave safety net for the earlier failure paths.

Comment thread src/node_sqlite.cc Outdated
Isolate* isolate = env->isolate();

sqlite3_reset(iter->stmt_->statement_);
RESET_OR_THROW(

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.

CHECK_ERROR_OR_THROW does return (ret);, so when this reset reports a deferred error, iter->done_ = true on the next line is skipped — but sqlite3_reset() has already reset the statement, and reset_generation_ wasn't bumped (this is a raw reset, not ResetStatement()). So:

const it = stmt.iterate();
try { for (const row of it) break; } catch {}  // it.return() throws
it.next();  // done_ === false, generation matches -> re-steps from row 1

Setting done_ = true before the checked reset fixes it.

Separately: iterator.return() is called by the language during abrupt completion, including exception unwinding (for (const row of it) { throw err; }). Throwing here discards the user's pending exception, which is exactly what the PR description's "avoid replacing an already-pending exception" rule is meant to prevent — and this is the one place it isn't applied. Worth considering whether Return() should keep ignoring the reset result.

Comment thread src/node_sqlite.cc
CHECK_ERROR_OR_THROW(
env->isolate(), iter->stmt_->db_.get(), r, SQLITE_DONE, void());
sqlite3_reset(iter->stmt_->statement_);
RESET_OR_THROW(env->isolate(),

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.

Same shape: this early return skips the {done: true, value: null} result, and done_ is never set on this path (it's only written in the constructor and in Return()), so a caught error leaves the iterator resumable on an already-reset statement.

Setting iter->done_ = true here also fixes a pre-existing bug — after natural exhaustion the iterator already restarts today:

const it = stmt.iterate();
while (!it.next().done);
it.next();  // yields row 1 again

Comment thread src/node_sqlite.cc
isolate, Null(isolate), keys.data(), row_values.data(), num_cols);
}

RESET_AND_CHECK(isolate, db, stmt, needs_reset, MaybeLocal<Value>());

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.

After a SQLITE_ROW step the VDBE is still in RUN_STATE, so this reset runs sqlite3VdbeHalt() and can genuinely fail — unlike the SQLITE_DONE path above, where the halt already happened during the step, making that check effectively a no-op. So get() can now throw after the row was built: e.g. INSERT ... RETURNING id with PRAGMA foreign_keys = ON and a deferred FK violation, where the implicit commit fails at reset. Same for all(), which discards a fully-built array.

That's arguably the more correct behavior, but it's a change on a path that currently succeeds, so it deserves a test and possibly a notable-change label.

Comment thread src/node_sqlite.cc
});

int step_r = sqlite3_step(stmt);
if (step_r != SQLITE_DONE && step_r != SQLITE_ROW) {

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.

Nit: accepting SQLITE_ROW here is right (run() on a RETURNING/SELECT statement should step once and discard) and matches the previous behavior of ignoring the step result entirely. A short comment would keep someone from "tightening" this to != SQLITE_DONE later.

@semimikoh
semimikoh force-pushed the sqlite/check-step-reset-returns branch from 4740589 to 4443a70 Compare August 7, 2026 01:53
@semimikoh

Copy link
Copy Markdown
Contributor Author

Rebased onto main and addressed the feedback:

  • Return() now ignores the reset result (like the other OnScopeLeave guards) so it can't discard a pending exception during abrupt iterator close
  • Next()'s exhaustion path setting done_ came in via the rebase
  • Added tests for both, plus the get()/all() deferred-error case
  • Added the suggested comment in Run()

get()/all() can now throw on a previously-succeeding path — let me know if this needs notable-change.

Signed-off-by: semimikoh <ejffjeosms@gmail.com>
@trivikr

This comment was marked as outdated.

@semimikoh
semimikoh force-pushed the sqlite/check-step-reset-returns branch from 4443a70 to ba4e195 Compare August 7, 2026 03:47
@semimikoh

This comment was marked as outdated.

@trivikr

This comment was marked as outdated.

@semimikoh
semimikoh force-pushed the sqlite/check-step-reset-returns branch from ba4e195 to ea9f2df Compare August 7, 2026 03:59
@semimikoh

This comment was marked as outdated.

- StatementSyncIterator::Return() no longer throws on a deferred
  reset error, matching the OnScopeLeave guards used elsewhere: it
  is invoked during abrupt iterator completion (e.g. a throw inside
  a for...of body), and throwing there would discard the caller's
  already-pending exception.
- Add a short comment on the accepted SQLITE_ROW result in Run().
- Add tests covering get()/all() surfacing a deferred SQLite error
  from reset() after already building a row/array, the iterator not
  replaying results after natural exhaustion, and a pending exception
  propagating correctly when the loop body throws mid-iteration.

Signed-off-by: semimikoh <ejffjeosms@gmail.com>
@semimikoh
semimikoh force-pushed the sqlite/check-step-reset-returns branch from ea9f2df to 52592e6 Compare August 7, 2026 06:44
@semimikoh

Copy link
Copy Markdown
Contributor Author

@trivikr CI is green now (conflicts resolved, lint fixed). Ready for another look whenever you have time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unchecked sqlite3 API calls

5 participants