Skip to content

Commit 035a9b8

Browse files
TrevorBurnhamaduh95
authored andcommitted
sqlite: keep sessions alive across SQLite callbacks
Session objects are weak and nothing else holds a strong reference to them, so a garbage collection can free one while SQLite is still using it. SQLite runs "PRAGMA table_xinfo" from inside its pre-update hook, which reaches JavaScript, so a GC during a callback invoked from there can collect a session the hook is still walking. Hold a strong reference to every attached session for the duration of each callback SQLite invokes. The trace callback now enters that guard before building its payload, since the allocation can itself trigger a garbage collection. Fixes: #65460 Signed-off-by: Trevor Burnham <trevorburnham@gmail.com> PR-URL: #65465 Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
1 parent 6ead515 commit 035a9b8

3 files changed

Lines changed: 70 additions & 2 deletions

File tree

src/node_sqlite.cc

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,15 @@ void DatabaseSync::RemoveBackup(BackupJob* job) {
10241024
backups_.erase(job);
10251025
}
10261026

1027+
std::vector<BaseObjectPtr<Session>> DatabaseSync::PinSessions() const {
1028+
std::vector<BaseObjectPtr<Session>> pinned;
1029+
pinned.reserve(sessions_.size());
1030+
for (Session* session : sessions_) {
1031+
pinned.emplace_back(session);
1032+
}
1033+
return pinned;
1034+
}
1035+
10271036
void DatabaseSync::DeleteSessions() {
10281037
// all attached sessions need to be deleted before the database is closed
10291038
// https://www.sqlite.org/session/sqlite3session_create.html
@@ -2832,6 +2841,10 @@ int DatabaseSync::TraceCallback(unsigned int type,
28322841
return 0;
28332842
}
28342843

2844+
// Entered before building the payload below, because allocating it can
2845+
// trigger a garbage collection that SQLite is not prepared for.
2846+
CallbackDepthGuard guard(db);
2847+
28352848
Isolate* isolate = env->isolate();
28362849
HandleScope handle_scope(isolate);
28372850

@@ -2870,7 +2883,6 @@ int DatabaseSync::TraceCallback(unsigned int type,
28702883

28712884
Local<Object> payload = Object::New(isolate, Null(isolate), keys, values, 3);
28722885

2873-
CallbackDepthGuard guard(db);
28742886
ch->Publish(env, payload);
28752887

28762888
return 0;

src/node_sqlite.h

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,13 @@ class DatabaseSync : public BaseObject {
289289
void DecrementCallbackDepth() { --callback_depth_; }
290290
bool IsInCallback() const { return callback_depth_ > 0; }
291291

292+
// SQLite reaches back into JavaScript from inside its pre-update hook, while
293+
// it is still walking this connection's session list. Session objects are
294+
// weak, so a garbage collection during such a callback could collect one and
295+
// free memory SQLite is still using. Returns a strong reference to every
296+
// attached session so that a callback can hold them for its duration.
297+
std::vector<BaseObjectPtr<Session>> PinSessions() const;
298+
292299
// SQLite forbids an authorizer callback from doing anything that modifies
293300
// the database connection that invoked it, which includes preparing and
294301
// stepping statements. See https://www.sqlite.org/c3ref/set_authorizer.html.
@@ -505,9 +512,13 @@ class SQLTagStore : public BaseObject {
505512
friend class StatementExecutionHelper;
506513
};
507514

515+
// Guards a window in which SQLite hands control back to JavaScript. Construct
516+
// it before allocating anything on the V8 heap, since the pinned sessions
517+
// below are what keep a garbage collection during that window safe.
508518
class CallbackDepthGuard {
509519
public:
510-
explicit CallbackDepthGuard(DatabaseSync* db) : db_(db) {
520+
explicit CallbackDepthGuard(DatabaseSync* db)
521+
: db_(db), pinned_sessions_(db->PinSessions()) {
511522
db_->IncrementCallbackDepth();
512523
}
513524
~CallbackDepthGuard() { db_->DecrementCallbackDepth(); }
@@ -516,6 +527,7 @@ class CallbackDepthGuard {
516527

517528
private:
518529
DatabaseSync* db_;
530+
std::vector<BaseObjectPtr<Session>> pinned_sessions_;
519531
};
520532

521533
class TraceEventSuppressionGuard {

test/parallel/test-sqlite-session.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,50 @@ test('session - keeps its database alive after the db handle is dropped', async
682682
session.close();
683683
});
684684

685+
// SQLite runs "PRAGMA table_xinfo" from inside its pre-update hook, while it is
686+
// still walking the connection's session list. Session objects are weak, so a
687+
// GC during a callback that the PRAGMA triggers could collect a session that
688+
// JavaScript no longer references and free memory the walk is still using.
689+
test('session - survives GC during an authorizer callback', (t) => {
690+
const database = new DatabaseSync(':memory:');
691+
database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY)');
692+
database.createSession(); // Never referenced again, so it is collectable.
693+
694+
let ran = false;
695+
database.setAuthorizer((actionCode, param1) => {
696+
if (actionCode === constants.SQLITE_PRAGMA && param1 === 'table_xinfo') {
697+
ran = true;
698+
globalThis.gc();
699+
globalThis.gc();
700+
}
701+
return constants.SQLITE_OK;
702+
});
703+
704+
database.exec('INSERT INTO data VALUES (1)');
705+
t.assert.ok(ran, 'the authorizer callback never ran');
706+
});
707+
708+
test("session - survives GC during a 'sqlite.db.query' subscriber", (t) => {
709+
const dc = require('node:diagnostics_channel');
710+
const database = new DatabaseSync(':memory:');
711+
database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY)');
712+
database.createSession(); // Never referenced again, so it is collectable.
713+
714+
let ran = false;
715+
const handler = ({ sql }) => {
716+
if (sql.includes('table_xinfo')) {
717+
ran = true;
718+
globalThis.gc();
719+
globalThis.gc();
720+
}
721+
};
722+
dc.subscribe('sqlite.db.query', handler);
723+
t.after(() => dc.unsubscribe('sqlite.db.query', handler));
724+
725+
database.exec('INSERT INTO data VALUES (1)');
726+
t.assert.ok(ran, 'the subscriber never ran');
727+
});
728+
685729
test('session supports ERM', (t) => {
686730
const database = new DatabaseSync(':memory:');
687731
let afterDisposeSession;

0 commit comments

Comments
 (0)