mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 11:02:33 -05:00
From the #6492 comments today, on WeKan 10.40 with real users: SyncedCron: Fatal error encountered (uncaughtException): TypeError: self._pendingRemoveFunction is not a function at Session.send (packages/ddp-server/livedata_server.js:362:14) at Session._stopSubscription (packages/ddp-server/livedata_server.js:823:10) at Session.unsub (packages/ddp-server/livedata_server.js:562:12) snap.wekan.wekan.service: Main process exited, code=exited, status=1/FAILURE snap.wekan.wekan.service: Scheduled restart job, restart counter is at 4. It is a state-machine hole in Meteor's ddp-server. `_removeSession` sets `session.messageQueue = []` and `session._pendingRemoveFunction = remove`; when `remove()` runs it clears the function and deletes the session but LEAVES the queue - an empty array, which is truthy. `Session.send` then takes the queueing branch for a session that no longer exists, and the message past `maxMessageQueueLength` (100 by default) calls that now-null function. It runs from an Immediate, via unsub -> _stopSubscription -> send, with no try/catch above it, so it is an uncaught exception - and synced-cron's handler exits the process on one. systemd restarts, every user is disconnected, new cards stop arriving until a reload, and the restart loop reads as "WeKan is slow and the CPU is high", which is exactly what the reporter describes. It needs a busy server: a session has to disconnect while more than ~100 further messages are still addressed to it. That is why it shows up with fifty users and never in a quiet test. The guard patches Session.send once, from the first connection (the Session class is not exported, so an instance is how the prototype is reached): a queue with no remove function belongs to a session that is already gone, so the queue is dropped and send() takes its ordinary path - which, with no socket, does nothing. Nothing is lost that was not already lost, the disconnect-grace queue for a real reconnect is untouched, and a valid overflow still removes the session. tests/ddpSessionSendGuard.test.cjs replays the upstream state machine: it asserts the unguarded version really does throw on the 101st message, that the guarded one does not, that a session still waiting for its client keeps its queue, that a legitimate overflow still calls the remove function exactly once, and that a live session sends as before. NOT verified here: there is no server in this environment, so this is reasoned from the ddp-server source in .meteor/local and replayed against a model of it. The high CPU the issue opened with is a separate thread (the FerretDB $changeStream busy loop); this fixes the crash-restart loop reported today. Thanks to bluetopaz1204, Nissulya and xet7 ! Fixes #6492,
91 lines
3.9 KiB
JavaScript
91 lines
3.9 KiB
JavaScript
// #6492: one disconnected DDP session took the whole server down, repeatedly.
|
|
//
|
|
// From the reporter's snap log (WeKan 10.40, ~50 users, LDAP):
|
|
//
|
|
// SyncedCron: Fatal error encountered (uncaughtException):
|
|
// TypeError: self._pendingRemoveFunction is not a function
|
|
// at Session.send (packages/ddp-server/livedata_server.js:362:14)
|
|
// at Session._stopSubscription (packages/ddp-server/livedata_server.js:823:10)
|
|
// at Session.unsub (packages/ddp-server/livedata_server.js:562:12)
|
|
// snap.wekan.wekan.service: Main process exited, code=exited, status=1/FAILURE
|
|
// snap.wekan.wekan.service: Scheduled restart job, restart counter is at 4.
|
|
//
|
|
// The bug is upstream, in Meteor's ddp-server, and it is a state-machine hole:
|
|
//
|
|
// _removeSession(session) sets session.messageQueue = []
|
|
// and session._pendingRemoveFunction = sessionRemoveFunction
|
|
// sessionRemoveFunction() sets session._pendingRemoveFunction = null
|
|
// and deletes the session ... but leaves messageQueue
|
|
// in place, an empty ARRAY, which is truthy.
|
|
//
|
|
// Session.send(msg) then takes the queueing branch for a session that is
|
|
// already gone:
|
|
//
|
|
// if (self.messageQueue && !isIgnoredMsg) {
|
|
// self.messageQueue.push(msg);
|
|
// if (self.messageQueue.length > self.options.maxMessageQueueLength) {
|
|
// Meteor.clearTimeout(self._removeTimeoutHandle);
|
|
// self._pendingRemoveFunction(); // <- null: TypeError
|
|
// }
|
|
// return;
|
|
// }
|
|
//
|
|
// So every message sent to a removed session is queued, and the 101st (the
|
|
// default maxMessageQueueLength is 100) calls null. `Session.unsub` ->
|
|
// `_stopSubscription` -> `send({msg:'nosub'})` runs from an Immediate with no
|
|
// try/catch around it, so the TypeError is an UNCAUGHT EXCEPTION - and the
|
|
// synced-cron package's handler exits the process on one. systemd restarts, every
|
|
// user is disconnected, cards stop arriving until a reload, and the restart loop
|
|
// looks exactly like "WeKan is slow and the CPU is high".
|
|
//
|
|
// It needs a busy server to show up: a session must disconnect while more than
|
|
// ~100 further messages are still addressed to it. That is why it appears with
|
|
// real users on a loaded instance and not in a quiet test.
|
|
//
|
|
// The guard: when a session is queueing but has no remove function left, the
|
|
// session is already removed, so its queue means nothing. Drop the queue instead
|
|
// of pushing to it - `send()` then takes its normal path, which for a removed
|
|
// session with no socket does nothing at all. No message is lost that was not
|
|
// already lost, and the process stays up.
|
|
//
|
|
// The patch is on the prototype, applied once, from the first connection - the
|
|
// Session class is not exported, so an instance is how it is reached.
|
|
|
|
const patched = { done: false };
|
|
|
|
function patchSessionSend(session) {
|
|
if (patched.done || !session) return;
|
|
const proto = Object.getPrototypeOf(session);
|
|
if (!proto || typeof proto.send !== 'function') return;
|
|
patched.done = true;
|
|
|
|
const originalSend = proto.send;
|
|
proto.send = function wekanGuardedSend(msg) {
|
|
// A queue with no owner: the session it belonged to has been removed.
|
|
if (this.messageQueue && typeof this._pendingRemoveFunction !== 'function') {
|
|
this.messageQueue = null;
|
|
}
|
|
return originalSend.call(this, msg);
|
|
};
|
|
}
|
|
|
|
Meteor.startup(() => {
|
|
Meteor.onConnection(connection => {
|
|
try {
|
|
if (patched.done) return;
|
|
const server = Meteor.server;
|
|
const session = server && server.sessions && server.sessions.get
|
|
? server.sessions.get(connection.id)
|
|
: null;
|
|
patchSessionSend(session);
|
|
} catch (e) {
|
|
// A guard that throws would be worse than the bug it guards against.
|
|
if (process.env.DEBUG === 'true') {
|
|
console.error('ddpSessionSendGuard: could not patch Session.send', e);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
export { patchSessionSend };
|