wire/server: honour moreToCome on OP_MSG requests

`Message.flags` was parsed and stored but never read. An OP_MSG request with
moreToCome set is fire-and-forget: the client will not read a reply. Sending one
anyway leaves it unread in the socket, so the next command on that connection
reads the previous command's reply and waits forever for its own.

This is not a corner case. Every unacknowledged write uses it, and the Node
driver sends `endSessions` with `writeConcern: {w: 0}` whenever a client closes
-- so an ordinary application that never asks for w:0 still hits it. Before:

  insertOne({w: 0})            -> ok, acknowledged=false
  countDocuments() (same conn) -> BSON element "cursor" is missing

The command still runs; only the reply is suppressed.

The e2e case pins maxPoolSize to 1, because with a larger pool the driver may
hand the next operation a different connection and hide the bug. It asserts the
connection still works afterwards, which is the part that matters -- not that
the unacknowledged write itself returned.
This commit is contained in:
2026-08-03 18:55:50 +03:00
parent d867c37d32
commit e2c25a986b
3 changed files with 72 additions and 0 deletions

View File

@@ -110,6 +110,48 @@ async function main() {
const stillAlive = await users.aggregate([{ $match: {} }, { $count: 'n' }]).toArray();
check('server survives a bare $sort pipeline', stillAlive[0]?.n === 4, JSON.stringify(stillAlive));
// --- a database-level command must not leak the catalog lock ---
// db.aggregate() sends {aggregate: 1}, which names no collection. dispatch
// used to resolve the namespace after taking the catalog lock and bail with a
// plain return, leaking it shared forever. Reads kept working, so the damage
// only showed on the next write that had to create a collection -- which is
// the second half of this check, and would hang rather than fail.
let dbLevelErr = null;
try {
await db.aggregate([{ $listLocalSessions: {} }]).toArray();
} catch (e) {
dbLevelErr = e;
}
check(
'database-level aggregate gives a real error, not an empty reply',
dbLevelErr !== null && typeof dbLevelErr.message === 'string' && dbLevelErr.message !== 'n/a',
String(dbLevelErr && dbLevelErr.message).slice(0, 60),
);
const afterDbLevel = await db.collection('lock_probe').insertOne({ _id: 1 });
check('a write creating a collection still completes afterwards', afterDbLevel.insertedId === 1);
// --- unacknowledged writes must not desync the connection ---
// An OP_MSG request with moreToCome set gets no reply. Sending one anyway
// left it unread in the socket, so the *next* command on that connection
// read the wrong reply. maxPoolSize 1 pins both operations to one socket,
// which is what makes the bug visible; with a larger pool the driver may
// hand out a different connection and hide it. The driver also does this to
// itself on close, via endSessions with {w: 0}.
const w0client = new MongoClient(URL, { maxPoolSize: 1 });
try {
await w0client.connect();
const w0 = w0client.db('e2e').collection('unack');
await w0.deleteMany({});
await w0.insertOne({ _id: 1, v: 'acknowledged' });
const unack = await w0.insertOne({ _id: 2, v: 'unacknowledged' }, { writeConcern: { w: 0 } });
check('unacknowledged insert is not acknowledged', unack.acknowledged === false, JSON.stringify(unack));
// The assertion that matters: the same socket still works afterwards.
const after = await w0.countDocuments({});
check('connection survives an unacknowledged write', after === 2, `count=${after}`);
} finally {
await w0client.close();
}
// --- duplicate key ---
let dupErr = null;
try {