Skip to content

Commit bbd7a1b

Browse files
committed
sqlite: skip return() on exhausted virtual table iterators
Closing a virtual table cursor invoked the iterator's return() even after next() had already reported done: true, deviating from for...of semantics where return() only runs on abrupt termination. An empty custom iterable with a throwing return() made an otherwise successful query fail. Only close the iterator when the scan stopped early, as with LIMIT, a break, or a cursor re-filter; a normally finished iterator already ran its cleanup. Signed-off-by: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Assisted-by: claude:opus-5.5
1 parent 93bb027 commit bbd7a1b

2 files changed

Lines changed: 41 additions & 2 deletions

File tree

‎src/node_sqlite.cc‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,7 +1109,7 @@ bool VirtualTableModule::CanCallIntoJS() const {
11091109
bool VirtualTableModule::CloseIterator(NodeVTabCursor* cursor) {
11101110
VirtualTableModule* mod = cursor->module;
11111111

1112-
// Skipped in two cases:
1112+
// Skipped in three cases:
11131113
//
11141114
// - While the database is being torn down from a destructor, because those
11151115
// run from a garbage collection callback where JavaScript cannot be
@@ -1120,7 +1120,11 @@ bool VirtualTableModule::CloseIterator(NodeVTabCursor* cursor) {
11201120
// A generator whose own body threw has already run its `finally` as part of
11211121
// that throw, so this only affects an iterator abandoned while suspended
11221122
// because something else failed.
1123-
if (cursor->iterator.IsEmpty() || !mod->CanCallIntoJS() ||
1123+
// - When next() has already reported `done: true`, because the iteration ran
1124+
// to completion. `return()` is only called on abrupt termination in
1125+
// `for...of`; a finished iterator must not be asked to clean up again, and
1126+
// a custom iterable may throw from `return()` even after completion.
1127+
if (cursor->iterator.IsEmpty() || cursor->done || !mod->CanCallIntoJS() ||
11241128
mod->env_->isolate()->HasPendingException()) {
11251129
return false;
11261130
}

‎test/parallel/test-sqlite-virtual-table.js‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,41 @@ suite('Database.prototype.createModule()', () => {
622622
assert.deepStrictEqual(cleanedUp, [1, 2]);
623623
});
624624

625+
test('does not call return() on a normally exhausted iterator', () => {
626+
// Once next() reports `done: true` the iterator is finished, so no
627+
// cleanup should run. A throwing return() must not fail an otherwise
628+
// successful query. Matches `for...of`, which only calls return() on
629+
// abrupt termination.
630+
const db = new Database(':memory:');
631+
for (const [label, makeRows] of [
632+
['empty', () => {
633+
return {
634+
[Symbol.iterator]() { return this; },
635+
next() { return { value: [], done: true }; },
636+
return() { throw new Error('cleanup boom'); },
637+
};
638+
}],
639+
['fully_consumed', () => {
640+
let i = 0;
641+
return {
642+
[Symbol.iterator]() { return this; },
643+
next() { return { value: [i++], done: i > 2 }; },
644+
return() { throw new Error('cleanup boom'); },
645+
};
646+
}],
647+
]) {
648+
db.createModule(`exhausted_${label}`, {
649+
columns: [{ name: 'v', type: 'INTEGER' }],
650+
rows: makeRows,
651+
});
652+
653+
const rows = db.prepare(`SELECT v FROM exhausted_${label}`).all();
654+
assert.deepStrictEqual(
655+
rows.map((row) => row.v),
656+
label === 'empty' ? [] : [0, 1]);
657+
}
658+
});
659+
625660
test('does not run cleanup when the statement is collected', () => {
626661
// The destructor runs from a GC callback, where JavaScript cannot be
627662
// executed. An abandoned generator does not run `finally` in JavaScript

0 commit comments

Comments
 (0)