Present since at least d4c9b04, found by the new spec-test harness on its first
run. The $sort stage's materialization branch built its document list with the
reply arena and then handed it to `trees`, whose scope-exit deinit -- and the
$match branch above it -- free with the gpa. So a gpa free was handed an
arena-owned pointer. macOS malloc catches it and aborts with SIGTRAP and no
panic text, which is why the symptom read as "the connection closed":
mfm_free <- Allocator.rawFree
<- array_list.Aligned(*const bson.Document).deinit
<- commands.cmd_aggregate
Any pipeline with $sort and no preceding $group reached it, e.g.
aggregate([{$sort: {x: 1}}]) -- so a client could kill the server with one
ordinary query. With a $group first the stream is already in tree form and the
branch is skipped, which is precisely why it survived: every aggregate case in
e2e.js and e2e6.js sorts *after* grouping.
The list buffer now comes from ctx.gpa. The documents stay in the arena on
purpose -- it outlives the command, and only the ArrayList's own allocator has
to match its deinit.
Tests. The unit test uses a bare $sort pipeline, since a $group first would not
reach the branch, and leans on testing.allocator detecting the invalid free
itself rather than on the host allocator noticing -- mutation-checked by
restoring `arena` on the append, which gives `panic: Invalid free`. The e2e case
adds a second command afterwards, because the assertion that matters is not
that the sort returned rows but that the connection is still there.
150 lines
6.5 KiB
JavaScript
150 lines
6.5 KiB
JavaScript
// End-to-end test: official MongoDB Node.js driver against multiforadb.
|
|
const { MongoClient, ObjectId } = require('mongodb');
|
|
|
|
const URL = 'mongodb://127.0.0.1:27020';
|
|
const results = [];
|
|
function check(name, cond, detail = '') {
|
|
results.push({ name, ok: !!cond, detail: String(detail) });
|
|
if (!cond) console.error(` ✗ ${name} ${detail}`);
|
|
}
|
|
async function main() {
|
|
const client = new MongoClient(URL, { serverSelectionTimeoutMS: 5000 });
|
|
await client.connect();
|
|
const db = client.db('e2e');
|
|
const users = db.collection('users');
|
|
await users.drop().catch(() => {});
|
|
|
|
// --- insert ---
|
|
await users.insertOne({ name: 'alice', age: 30, tags: ['a', 'b'] });
|
|
const many = await users.insertMany([
|
|
{ name: 'bob', age: 25, tags: ['b'] },
|
|
{ name: 'carol', age: 35, tags: ['c', 'a'] },
|
|
{ name: 'dave', age: 40, tags: [] },
|
|
]);
|
|
check('insertMany acknowledged', many.acknowledged === true, many);
|
|
check('auto _id assigned', ObjectId.isValid(many.insertedIds[0]));
|
|
|
|
// --- find: filters, operators, sort, skip, limit, projection ---
|
|
const gt = await users.find({ age: { $gt: 28 } }).sort({ age: -1 }).toArray();
|
|
check('find $gt + sort desc', gt.map((d) => d.name).join(',') === 'dave,carol,alice', gt.map((d) => d.name));
|
|
|
|
const inq = await users.find({ name: { $in: ['alice', 'bob'] } }).count();
|
|
check('find $in count', inq === 2, inq);
|
|
|
|
const rgx = await users.find({ name: /^[bc]/ }).toArray();
|
|
check('find $regex', rgx.length === 2, rgx.map((d) => d.name));
|
|
|
|
const exists = await users.find({ tags: { $exists: true } }).count();
|
|
check('find $exists', exists === 4, exists);
|
|
|
|
const lim = await users.find({}).sort({ age: 1 }).skip(1).limit(2).toArray();
|
|
check('find skip+limit+sort', lim.map((d) => d.name).join(',') === 'alice,carol', lim.map((d) => d.name));
|
|
|
|
const proj = await users.findOne({ name: 'alice' }, { projection: { _id: 0, name: 1 } });
|
|
check('projection', proj.name === 'alice' && proj.age === undefined, JSON.stringify(proj));
|
|
|
|
const dot = await users.findOne({ 'tags.0': 'c' });
|
|
check('dot path + array', dot?.name === 'carol');
|
|
|
|
// --- count ---
|
|
check('countDocuments', (await users.countDocuments({})) === 4);
|
|
check('countDocuments with filter', (await users.countDocuments({ age: { $gte: 30 } })) === 3);
|
|
check('estimatedDocumentCount', (await users.estimatedDocumentCount()) === 4);
|
|
|
|
// --- update ---
|
|
const u1 = await users.updateOne({ name: 'alice' }, { $set: { vip: true }, $inc: { age: 1 } });
|
|
check('updateOne nModified', u1.modifiedCount === 1, u1);
|
|
const alice = await users.findOne({ name: 'alice' });
|
|
check('updateOne $set+$inc applied', alice.vip === true && alice.age === 31, JSON.stringify(alice));
|
|
|
|
const um = await users.updateMany({}, { $set: { seen: true } });
|
|
check('updateMany', um.modifiedCount === 4, um);
|
|
|
|
const push = await users.updateOne({ name: 'dave' }, { $push: { tags: 'x' } });
|
|
check('$push', push.modifiedCount === 1);
|
|
check('$push visible', (await users.findOne({ name: 'dave' })).tags.length === 1);
|
|
|
|
const ups = await users.updateOne({ name: 'erin' }, { $set: { age: 28 } }, { upsert: true });
|
|
check('upsert', ups.upsertedCount === 1 && ups.matchedCount === 0, ups);
|
|
check('upsert doc exists', (await users.findOne({ name: 'erin' }))?.age === 28);
|
|
|
|
// --- findOneAndUpdate (findAndModify) ---
|
|
const fam = await users.findOneAndUpdate(
|
|
{ name: 'bob' },
|
|
{ $set: { lucky: true } },
|
|
{ returnDocument: 'after' },
|
|
);
|
|
const famDoc = fam.value ?? fam;
|
|
check('findOneAndUpdate returns new', famDoc.lucky === true, JSON.stringify(fam));
|
|
const famRemove = await users.findOneAndDelete({ name: 'erin' });
|
|
const famDelDoc = famRemove.value ?? famRemove;
|
|
check('findOneAndDelete', famDelDoc?.name === 'erin', JSON.stringify(famRemove));
|
|
|
|
// --- aggregate ---
|
|
const grp = await users
|
|
.aggregate([
|
|
{ $match: { age: { $gte: 25 } } },
|
|
{ $group: { _id: '$tags.length', total: { $sum: '$age' } } },
|
|
{ $sort: { _id: 1 } },
|
|
])
|
|
.toArray();
|
|
check('aggregate $match+$group+$sum+$sort', grp.length >= 1 && grp.some((g) => g.total > 0), JSON.stringify(grp));
|
|
|
|
const cnt = await users.aggregate([{ $match: {} }, { $count: 'n' }]).toArray();
|
|
check('aggregate $count', cnt[0]?.n === 4, JSON.stringify(cnt));
|
|
|
|
// $sort with NO preceding $group. This shape used to kill the server: the
|
|
// stage materialized its document list from the reply arena and it was then
|
|
// freed with the general allocator. Every aggregate case above happens to
|
|
// sort after a $group, which leaves the stream already materialized and the
|
|
// guilty branch unreached -- so the bug survived the whole suite. The second
|
|
// aggregate is the part that actually proves recovery: if the server died,
|
|
// this connection is gone.
|
|
const sorted = await users.aggregate([{ $sort: { age: -1 } }]).toArray();
|
|
const ages = sorted.map((d) => d.age);
|
|
check(
|
|
'aggregate bare $sort (no $group) returns sorted docs',
|
|
ages.length === 4 && ages.every((a, i) => i === 0 || ages[i - 1] >= a),
|
|
JSON.stringify(ages),
|
|
);
|
|
const stillAlive = await users.aggregate([{ $match: {} }, { $count: 'n' }]).toArray();
|
|
check('server survives a bare $sort pipeline', stillAlive[0]?.n === 4, JSON.stringify(stillAlive));
|
|
|
|
// --- duplicate key ---
|
|
let dupErr = null;
|
|
try {
|
|
await users.insertOne({ _id: many.insertedIds[0], name: 'clobber' });
|
|
} catch (e) {
|
|
dupErr = e;
|
|
}
|
|
check('duplicate key rejected', dupErr?.code === 11000, dupErr?.message);
|
|
|
|
// --- listCollections / listDatabases ---
|
|
const colls = await db.listCollections({}, { nameOnly: true }).toArray();
|
|
check('listCollections', colls.some((c) => c.name === 'users'), JSON.stringify(colls));
|
|
const dbs = await client.db('admin').admin().listDatabases();
|
|
check('listDatabases', dbs.databases.some((d) => d.name === 'e2e'), JSON.stringify(dbs.databases.map((d) => d.name)));
|
|
|
|
// --- delete ---
|
|
const del1 = await users.deleteOne({ name: 'dave' });
|
|
check('deleteOne', del1.deletedCount === 1, del1);
|
|
const delMany = await users.deleteMany({});
|
|
check('deleteMany', delMany.deletedCount === 3, delMany);
|
|
check('empty after delete', (await users.countDocuments({})) === 0);
|
|
|
|
await client.close();
|
|
|
|
const failed = results.filter((r) => !r.ok);
|
|
console.log(`\n${results.length - failed.length}/${results.length} checks passed`);
|
|
if (failed.length) {
|
|
console.log('FAILED:', failed.map((f) => f.name).join(', '));
|
|
process.exit(1);
|
|
}
|
|
console.log('E2E_OK');
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error('E2E_FAIL', e);
|
|
process.exit(1);
|
|
});
|