From 43d8fdf79aa5352234dcd6a5e007308ee001af91 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sun, 30 Aug 2026 14:14:58 +0200 Subject: [PATCH] sqlite: run backup completion in callback scope Run SQLite backup after-work callbacks inside an internal callback scope. This drains promise reactions and next ticks before the event loop can become idle. Add a child-process regression test where backup is the final active request. Assisted-by: Codex Signed-off-by: Filip Skokan --- src/node_sqlite.cc | 8 +++-- test/fixtures/sqlite/backup-last-request.mjs | 34 ++++++++++++++++++++ test/parallel/test-sqlite-backup.mjs | 17 +++++++++- 3 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/sqlite/backup-last-request.mjs diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 5892f4af12bd..368eba9f81f5 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -624,9 +624,13 @@ class BackupJob : public ThreadPoolWork { } void AfterThreadPoolWork(int status) override { - HandleScope handle_scope(env()->isolate()); + Isolate* isolate = env()->isolate(); + HandleScope handle_scope(isolate); + Context::Scope context_scope(env()->context()); + InternalCallbackScope callback_scope( + env(), Object::New(isolate), {0, 0}, InternalCallbackScope::kNoFlags); Local resolver = - Local::New(env()->isolate(), resolver_); + Local::New(isolate, resolver_); if (!(backup_status_ == SQLITE_OK || backup_status_ == SQLITE_DONE || backup_status_ == SQLITE_BUSY || backup_status_ == SQLITE_LOCKED)) { diff --git a/test/fixtures/sqlite/backup-last-request.mjs b/test/fixtures/sqlite/backup-last-request.mjs new file mode 100644 index 000000000000..91ad84e77ed5 --- /dev/null +++ b/test/fixtures/sqlite/backup-last-request.mjs @@ -0,0 +1,34 @@ +import { backup, DatabaseSync } from 'node:sqlite'; + +const source = new DatabaseSync(':memory:'); +source.exec(` + CREATE TABLE data(value); + INSERT INTO data VALUES (zeroblob(1048576)); +`); + +let keepAlive = setInterval(() => {}, 1_000); +let settled = false; + +process.once('beforeExit', () => { + if (!settled) { + process.stderr.write('backup promise did not settle before the event loop became idle\n'); + process.exit(1); + } +}); + +backup(source, process.argv[2], { + rate: 1, + progress() { + if (keepAlive !== undefined) { + clearInterval(keepAlive); + keepAlive = undefined; + } + }, +}).then(() => { + settled = true; + source.close(); +}, (error) => { + settled = true; + process.stderr.write(`${error.stack ?? error}\n`); + process.exitCode = 1; +}); diff --git a/test/parallel/test-sqlite-backup.mjs b/test/parallel/test-sqlite-backup.mjs index d1e09569e1ca..f995ae3ca72a 100644 --- a/test/parallel/test-sqlite-backup.mjs +++ b/test/parallel/test-sqlite-backup.mjs @@ -1,5 +1,10 @@ // Flags: --expose-gc -import { isWindows, skipIfSQLiteMissing } from '../common/index.mjs'; +import { + isWindows, + skipIfSQLiteMissing, + spawnPromisified, +} from '../common/index.mjs'; +import fixtures from '../common/fixtures.js'; import tmpdir from '../common/tmpdir.js'; import { join } from 'node:path'; import { describe, test } from 'node:test'; @@ -364,3 +369,13 @@ test('source database is kept alive while a backup is in flight', async (t) => { const rows = backupDb.prepare('SELECT COUNT(*) AS n FROM data').get(); t.assert.strictEqual(rows.n, 500); }); + +test('backup promise settles when the backup is the last active request', async (t) => { + const { code, signal, stderr } = await spawnPromisified(process.execPath, [ + fixtures.path('sqlite', 'backup-last-request.mjs'), + nextDb(), + ]); + + t.assert.strictEqual(signal, null); + t.assert.strictEqual(code, 0, stderr); +});