zulip/static/js/server_events.js

326 lines
11 KiB
JavaScript
Raw Normal View History

import $ from "jquery";
import _ from "lodash";
import * as blueslip from "./blueslip";
import * as channel from "./channel";
import * as echo from "./echo";
import * as message_events from "./message_events";
import * as message_lists from "./message_lists";
import {page_params} from "./page_params";
import * as reload from "./reload";
import * as reload_state from "./reload_state";
import * as sent_messages from "./sent_messages";
import * as server_events_dispatch from "./server_events_dispatch";
import * as ui_report from "./ui_report";
import * as watchdog from "./watchdog";
// Docs: https://zulip.readthedocs.io/en/latest/subsystems/events-system.html
let waiting_on_homeview_load = true;
let events_stored_while_loading = [];
let get_events_xhr;
let get_events_timeout;
let get_events_failures = 0;
const get_events_params = {};
function get_events_success(events) {
let messages = [];
const update_message_events = [];
const post_message_events = [];
const clean_event = function clean_event(event) {
// Only log a whitelist of the event to remove private data
return _.pick(event, "id", "type", "op");
};
js: Automatically convert _.each to for…of. This commit was automatically generated by the following script, followed by lint --fix and a few small manual lint-related cleanups. import * as babelParser from "recast/parsers/babel"; import * as recast from "recast"; import * as tsParser from "recast/parsers/typescript"; import { builders as b, namedTypes as n } from "ast-types"; import { Context } from "ast-types/lib/path-visitor"; import K from "ast-types/gen/kinds"; import { NodePath } from "ast-types/lib/node-path"; import assert from "assert"; import fs from "fs"; import path from "path"; import process from "process"; const checkExpression = (node: n.Node): node is K.ExpressionKind => n.Expression.check(node); const checkStatement = (node: n.Node): node is K.StatementKind => n.Statement.check(node); for (const file of process.argv.slice(2)) { console.log("Parsing", file); const ast = recast.parse(fs.readFileSync(file, { encoding: "utf8" }), { parser: path.extname(file) === ".ts" ? tsParser : babelParser, }); let changed = false; let inLoop = false; let replaceReturn = false; const visitLoop = (...args: string[]) => function(this: Context, path: NodePath) { for (const arg of args) { this.visit(path.get(arg)); } const old = { inLoop }; inLoop = true; this.visit(path.get("body")); inLoop = old.inLoop; return false; }; recast.visit(ast, { visitDoWhileStatement: visitLoop("test"), visitExpressionStatement(path) { const { expression, comments } = path.node; let valueOnly; if ( n.CallExpression.check(expression) && n.MemberExpression.check(expression.callee) && !expression.callee.computed && n.Identifier.check(expression.callee.object) && expression.callee.object.name === "_" && n.Identifier.check(expression.callee.property) && ["each", "forEach"].includes(expression.callee.property.name) && [2, 3].includes(expression.arguments.length) && checkExpression(expression.arguments[0]) && (n.FunctionExpression.check(expression.arguments[1]) || n.ArrowFunctionExpression.check(expression.arguments[1])) && [1, 2].includes(expression.arguments[1].params.length) && n.Identifier.check(expression.arguments[1].params[0]) && ((valueOnly = expression.arguments[1].params[1] === undefined) || n.Identifier.check(expression.arguments[1].params[1])) && (expression.arguments[2] === undefined || n.ThisExpression.check(expression.arguments[2])) ) { const old = { inLoop, replaceReturn }; inLoop = false; replaceReturn = true; this.visit( path .get("expression") .get("arguments") .get(1) .get("body") ); inLoop = old.inLoop; replaceReturn = old.replaceReturn; const [right, { body, params }] = expression.arguments; const loop = b.forOfStatement( b.variableDeclaration("let", [ b.variableDeclarator( valueOnly ? params[0] : b.arrayPattern([params[1], params[0]]) ), ]), valueOnly ? right : b.callExpression( b.memberExpression(right, b.identifier("entries")), [] ), checkStatement(body) ? body : b.expressionStatement(body) ); loop.comments = comments; path.replace(loop); changed = true; } this.traverse(path); }, visitForStatement: visitLoop("init", "test", "update"), visitForInStatement: visitLoop("left", "right"), visitForOfStatement: visitLoop("left", "right"), visitFunction(path) { this.visit(path.get("params")); const old = { replaceReturn }; replaceReturn = false; this.visit(path.get("body")); replaceReturn = old.replaceReturn; return false; }, visitReturnStatement(path) { if (replaceReturn) { assert(!inLoop); // could use labeled continue if this ever fires const { argument, comments } = path.node; if (argument === null) { const s = b.continueStatement(); s.comments = comments; path.replace(s); } else { const s = b.expressionStatement(argument); s.comments = comments; path.replace(s, b.continueStatement()); } return false; } this.traverse(path); }, visitWhileStatement: visitLoop("test"), }); if (changed) { console.log("Writing", file); fs.writeFileSync(file, recast.print(ast).code, { encoding: "utf8" }); } } Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-02-06 06:19:47 +01:00
for (const event of events) {
try {
get_events_params.last_event_id = Math.max(get_events_params.last_event_id, event.id);
} catch (error) {
blueslip.error(
"Failed to update last_event_id",
{event: clean_event(event)},
error.stack,
);
}
js: Automatically convert _.each to for…of. This commit was automatically generated by the following script, followed by lint --fix and a few small manual lint-related cleanups. import * as babelParser from "recast/parsers/babel"; import * as recast from "recast"; import * as tsParser from "recast/parsers/typescript"; import { builders as b, namedTypes as n } from "ast-types"; import { Context } from "ast-types/lib/path-visitor"; import K from "ast-types/gen/kinds"; import { NodePath } from "ast-types/lib/node-path"; import assert from "assert"; import fs from "fs"; import path from "path"; import process from "process"; const checkExpression = (node: n.Node): node is K.ExpressionKind => n.Expression.check(node); const checkStatement = (node: n.Node): node is K.StatementKind => n.Statement.check(node); for (const file of process.argv.slice(2)) { console.log("Parsing", file); const ast = recast.parse(fs.readFileSync(file, { encoding: "utf8" }), { parser: path.extname(file) === ".ts" ? tsParser : babelParser, }); let changed = false; let inLoop = false; let replaceReturn = false; const visitLoop = (...args: string[]) => function(this: Context, path: NodePath) { for (const arg of args) { this.visit(path.get(arg)); } const old = { inLoop }; inLoop = true; this.visit(path.get("body")); inLoop = old.inLoop; return false; }; recast.visit(ast, { visitDoWhileStatement: visitLoop("test"), visitExpressionStatement(path) { const { expression, comments } = path.node; let valueOnly; if ( n.CallExpression.check(expression) && n.MemberExpression.check(expression.callee) && !expression.callee.computed && n.Identifier.check(expression.callee.object) && expression.callee.object.name === "_" && n.Identifier.check(expression.callee.property) && ["each", "forEach"].includes(expression.callee.property.name) && [2, 3].includes(expression.arguments.length) && checkExpression(expression.arguments[0]) && (n.FunctionExpression.check(expression.arguments[1]) || n.ArrowFunctionExpression.check(expression.arguments[1])) && [1, 2].includes(expression.arguments[1].params.length) && n.Identifier.check(expression.arguments[1].params[0]) && ((valueOnly = expression.arguments[1].params[1] === undefined) || n.Identifier.check(expression.arguments[1].params[1])) && (expression.arguments[2] === undefined || n.ThisExpression.check(expression.arguments[2])) ) { const old = { inLoop, replaceReturn }; inLoop = false; replaceReturn = true; this.visit( path .get("expression") .get("arguments") .get(1) .get("body") ); inLoop = old.inLoop; replaceReturn = old.replaceReturn; const [right, { body, params }] = expression.arguments; const loop = b.forOfStatement( b.variableDeclaration("let", [ b.variableDeclarator( valueOnly ? params[0] : b.arrayPattern([params[1], params[0]]) ), ]), valueOnly ? right : b.callExpression( b.memberExpression(right, b.identifier("entries")), [] ), checkStatement(body) ? body : b.expressionStatement(body) ); loop.comments = comments; path.replace(loop); changed = true; } this.traverse(path); }, visitForStatement: visitLoop("init", "test", "update"), visitForInStatement: visitLoop("left", "right"), visitForOfStatement: visitLoop("left", "right"), visitFunction(path) { this.visit(path.get("params")); const old = { replaceReturn }; replaceReturn = false; this.visit(path.get("body")); replaceReturn = old.replaceReturn; return false; }, visitReturnStatement(path) { if (replaceReturn) { assert(!inLoop); // could use labeled continue if this ever fires const { argument, comments } = path.node; if (argument === null) { const s = b.continueStatement(); s.comments = comments; path.replace(s); } else { const s = b.expressionStatement(argument); s.comments = comments; path.replace(s, b.continueStatement()); } return false; } this.traverse(path); }, visitWhileStatement: visitLoop("test"), }); if (changed) { console.log("Writing", file); fs.writeFileSync(file, recast.print(ast).code, { encoding: "utf8" }); } } Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-02-06 06:19:47 +01:00
}
if (waiting_on_homeview_load) {
events_stored_while_loading = events_stored_while_loading.concat(events);
return;
}
if (events_stored_while_loading.length > 0) {
events = events_stored_while_loading.concat(events);
events_stored_while_loading = [];
}
// Most events are dispatched via the code server_events_dispatch,
// called in the default case. The goal of this split is to avoid
// contributors needing to read or understand the complex and
// rarely modified logic for non-normal events.
const dispatch_event = function dispatch_event(event) {
switch (event.type) {
case "message": {
const msg = event.message;
msg.flags = event.flags;
if (event.local_message_id) {
msg.local_id = event.local_message_id;
sent_messages.report_event_received(event.local_message_id);
}
messages.push(msg);
break;
}
case "update_message":
update_message_events.push(event);
break;
case "delete_message":
case "submessage":
case "update_message_flags":
post_message_events.push(event);
break;
Fix bugs related to batching message events. This is general fix that makes sure that we apply all message-modifying events after we apply the events for the initial incoming messages. The particular scenario that was reported here was when you would have two tabs for Zulip, with one of them open and in a PM view, and with the open tab being at the bottom of the feed, such that incoming messages would be immediately visible. Now suppose the other person in that PM conversation sent you a message. The open tab would properly immediately mark the message as read, and notify the server. The problem was that the closed tab would not process the main message event until it "woke up", by which time the flag-update event was bundled into the same event batch as the main message event. We'd then process the flag-update first, which essentially was a noop, since the actual message wasn't in the message store yet. The user would then see unread counts increment in the closed tab, while the open tab didn't increment. This was confusing. Now `server_events.js` processes the actual message first and does the flag-update as part of a `post_message_events` loop. We include events for updating message flags, deleting messages, and attaching submessages to messages in the `post_message_events` array. This bug was a bit difficult to simulate in a dev environment, since you needed your "open" tab to be in focus to simulate the race, but as soon as you tab to another place to deliver a message (whether from the browser or otherwise), the open tab is no longer in focus. I did this in the console of my "open" tab to work around it: unread_ops.process_visible = unread_ops.mark_current_list_as_read; This problem was easy to reproduce, but it wasn't entirely consistent. I often needed to send several messages in succession to trigger event batching and force the race condition. (This wasn't precisely a "race", as events actually arrive in the correct order; it was having them arrive in the same batch that triggered the bug.)
2018-08-25 13:36:25 +02:00
default:
server_events_dispatch.dispatch_normal_event(event);
}
};
js: Automatically convert _.each to for…of. This commit was automatically generated by the following script, followed by lint --fix and a few small manual lint-related cleanups. import * as babelParser from "recast/parsers/babel"; import * as recast from "recast"; import * as tsParser from "recast/parsers/typescript"; import { builders as b, namedTypes as n } from "ast-types"; import { Context } from "ast-types/lib/path-visitor"; import K from "ast-types/gen/kinds"; import { NodePath } from "ast-types/lib/node-path"; import assert from "assert"; import fs from "fs"; import path from "path"; import process from "process"; const checkExpression = (node: n.Node): node is K.ExpressionKind => n.Expression.check(node); const checkStatement = (node: n.Node): node is K.StatementKind => n.Statement.check(node); for (const file of process.argv.slice(2)) { console.log("Parsing", file); const ast = recast.parse(fs.readFileSync(file, { encoding: "utf8" }), { parser: path.extname(file) === ".ts" ? tsParser : babelParser, }); let changed = false; let inLoop = false; let replaceReturn = false; const visitLoop = (...args: string[]) => function(this: Context, path: NodePath) { for (const arg of args) { this.visit(path.get(arg)); } const old = { inLoop }; inLoop = true; this.visit(path.get("body")); inLoop = old.inLoop; return false; }; recast.visit(ast, { visitDoWhileStatement: visitLoop("test"), visitExpressionStatement(path) { const { expression, comments } = path.node; let valueOnly; if ( n.CallExpression.check(expression) && n.MemberExpression.check(expression.callee) && !expression.callee.computed && n.Identifier.check(expression.callee.object) && expression.callee.object.name === "_" && n.Identifier.check(expression.callee.property) && ["each", "forEach"].includes(expression.callee.property.name) && [2, 3].includes(expression.arguments.length) && checkExpression(expression.arguments[0]) && (n.FunctionExpression.check(expression.arguments[1]) || n.ArrowFunctionExpression.check(expression.arguments[1])) && [1, 2].includes(expression.arguments[1].params.length) && n.Identifier.check(expression.arguments[1].params[0]) && ((valueOnly = expression.arguments[1].params[1] === undefined) || n.Identifier.check(expression.arguments[1].params[1])) && (expression.arguments[2] === undefined || n.ThisExpression.check(expression.arguments[2])) ) { const old = { inLoop, replaceReturn }; inLoop = false; replaceReturn = true; this.visit( path .get("expression") .get("arguments") .get(1) .get("body") ); inLoop = old.inLoop; replaceReturn = old.replaceReturn; const [right, { body, params }] = expression.arguments; const loop = b.forOfStatement( b.variableDeclaration("let", [ b.variableDeclarator( valueOnly ? params[0] : b.arrayPattern([params[1], params[0]]) ), ]), valueOnly ? right : b.callExpression( b.memberExpression(right, b.identifier("entries")), [] ), checkStatement(body) ? body : b.expressionStatement(body) ); loop.comments = comments; path.replace(loop); changed = true; } this.traverse(path); }, visitForStatement: visitLoop("init", "test", "update"), visitForInStatement: visitLoop("left", "right"), visitForOfStatement: visitLoop("left", "right"), visitFunction(path) { this.visit(path.get("params")); const old = { replaceReturn }; replaceReturn = false; this.visit(path.get("body")); replaceReturn = old.replaceReturn; return false; }, visitReturnStatement(path) { if (replaceReturn) { assert(!inLoop); // could use labeled continue if this ever fires const { argument, comments } = path.node; if (argument === null) { const s = b.continueStatement(); s.comments = comments; path.replace(s); } else { const s = b.expressionStatement(argument); s.comments = comments; path.replace(s, b.continueStatement()); } return false; } this.traverse(path); }, visitWhileStatement: visitLoop("test"), }); if (changed) { console.log("Writing", file); fs.writeFileSync(file, recast.print(ast).code, { encoding: "utf8" }); } } Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-02-06 06:19:47 +01:00
for (const event of events) {
try {
dispatch_event(event);
} catch (error) {
blueslip.error(
"Failed to process an event\n" + blueslip.exception_msg(error),
{event: clean_event(event)},
error.stack,
);
}
js: Automatically convert _.each to for…of. This commit was automatically generated by the following script, followed by lint --fix and a few small manual lint-related cleanups. import * as babelParser from "recast/parsers/babel"; import * as recast from "recast"; import * as tsParser from "recast/parsers/typescript"; import { builders as b, namedTypes as n } from "ast-types"; import { Context } from "ast-types/lib/path-visitor"; import K from "ast-types/gen/kinds"; import { NodePath } from "ast-types/lib/node-path"; import assert from "assert"; import fs from "fs"; import path from "path"; import process from "process"; const checkExpression = (node: n.Node): node is K.ExpressionKind => n.Expression.check(node); const checkStatement = (node: n.Node): node is K.StatementKind => n.Statement.check(node); for (const file of process.argv.slice(2)) { console.log("Parsing", file); const ast = recast.parse(fs.readFileSync(file, { encoding: "utf8" }), { parser: path.extname(file) === ".ts" ? tsParser : babelParser, }); let changed = false; let inLoop = false; let replaceReturn = false; const visitLoop = (...args: string[]) => function(this: Context, path: NodePath) { for (const arg of args) { this.visit(path.get(arg)); } const old = { inLoop }; inLoop = true; this.visit(path.get("body")); inLoop = old.inLoop; return false; }; recast.visit(ast, { visitDoWhileStatement: visitLoop("test"), visitExpressionStatement(path) { const { expression, comments } = path.node; let valueOnly; if ( n.CallExpression.check(expression) && n.MemberExpression.check(expression.callee) && !expression.callee.computed && n.Identifier.check(expression.callee.object) && expression.callee.object.name === "_" && n.Identifier.check(expression.callee.property) && ["each", "forEach"].includes(expression.callee.property.name) && [2, 3].includes(expression.arguments.length) && checkExpression(expression.arguments[0]) && (n.FunctionExpression.check(expression.arguments[1]) || n.ArrowFunctionExpression.check(expression.arguments[1])) && [1, 2].includes(expression.arguments[1].params.length) && n.Identifier.check(expression.arguments[1].params[0]) && ((valueOnly = expression.arguments[1].params[1] === undefined) || n.Identifier.check(expression.arguments[1].params[1])) && (expression.arguments[2] === undefined || n.ThisExpression.check(expression.arguments[2])) ) { const old = { inLoop, replaceReturn }; inLoop = false; replaceReturn = true; this.visit( path .get("expression") .get("arguments") .get(1) .get("body") ); inLoop = old.inLoop; replaceReturn = old.replaceReturn; const [right, { body, params }] = expression.arguments; const loop = b.forOfStatement( b.variableDeclaration("let", [ b.variableDeclarator( valueOnly ? params[0] : b.arrayPattern([params[1], params[0]]) ), ]), valueOnly ? right : b.callExpression( b.memberExpression(right, b.identifier("entries")), [] ), checkStatement(body) ? body : b.expressionStatement(body) ); loop.comments = comments; path.replace(loop); changed = true; } this.traverse(path); }, visitForStatement: visitLoop("init", "test", "update"), visitForInStatement: visitLoop("left", "right"), visitForOfStatement: visitLoop("left", "right"), visitFunction(path) { this.visit(path.get("params")); const old = { replaceReturn }; replaceReturn = false; this.visit(path.get("body")); replaceReturn = old.replaceReturn; return false; }, visitReturnStatement(path) { if (replaceReturn) { assert(!inLoop); // could use labeled continue if this ever fires const { argument, comments } = path.node; if (argument === null) { const s = b.continueStatement(); s.comments = comments; path.replace(s); } else { const s = b.expressionStatement(argument); s.comments = comments; path.replace(s, b.continueStatement()); } return false; } this.traverse(path); }, visitWhileStatement: visitLoop("test"), }); if (changed) { console.log("Writing", file); fs.writeFileSync(file, recast.print(ast).code, { encoding: "utf8" }); } } Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-02-06 06:19:47 +01:00
}
if (messages.length !== 0) {
// Sort by ID, so that if we get multiple messages back from
// the server out-of-order, we'll still end up with our
// message lists in order.
messages = _.sortBy(messages, "id");
try {
messages = echo.process_from_server(messages);
if (messages.length > 0) {
const sent_by_this_client = messages.some((msg) =>
sent_messages.messages.has(msg.local_id),
);
// If some message in this batch of events was sent by this
// client, almost every time, this message will be the only one
// in messages, because multiple messages being returned by
// get_events usually only happens when a client is offline.
// But in any case, insert_new_messages handles multiple
// messages, only one of which was sent by this client,
// correctly.
js: Automatically convert _.each to for…of. This commit was automatically generated by the following script, followed by lint --fix and a few small manual lint-related cleanups. import * as babelParser from "recast/parsers/babel"; import * as recast from "recast"; import * as tsParser from "recast/parsers/typescript"; import { builders as b, namedTypes as n } from "ast-types"; import { Context } from "ast-types/lib/path-visitor"; import K from "ast-types/gen/kinds"; import { NodePath } from "ast-types/lib/node-path"; import assert from "assert"; import fs from "fs"; import path from "path"; import process from "process"; const checkExpression = (node: n.Node): node is K.ExpressionKind => n.Expression.check(node); const checkStatement = (node: n.Node): node is K.StatementKind => n.Statement.check(node); for (const file of process.argv.slice(2)) { console.log("Parsing", file); const ast = recast.parse(fs.readFileSync(file, { encoding: "utf8" }), { parser: path.extname(file) === ".ts" ? tsParser : babelParser, }); let changed = false; let inLoop = false; let replaceReturn = false; const visitLoop = (...args: string[]) => function(this: Context, path: NodePath) { for (const arg of args) { this.visit(path.get(arg)); } const old = { inLoop }; inLoop = true; this.visit(path.get("body")); inLoop = old.inLoop; return false; }; recast.visit(ast, { visitDoWhileStatement: visitLoop("test"), visitExpressionStatement(path) { const { expression, comments } = path.node; let valueOnly; if ( n.CallExpression.check(expression) && n.MemberExpression.check(expression.callee) && !expression.callee.computed && n.Identifier.check(expression.callee.object) && expression.callee.object.name === "_" && n.Identifier.check(expression.callee.property) && ["each", "forEach"].includes(expression.callee.property.name) && [2, 3].includes(expression.arguments.length) && checkExpression(expression.arguments[0]) && (n.FunctionExpression.check(expression.arguments[1]) || n.ArrowFunctionExpression.check(expression.arguments[1])) && [1, 2].includes(expression.arguments[1].params.length) && n.Identifier.check(expression.arguments[1].params[0]) && ((valueOnly = expression.arguments[1].params[1] === undefined) || n.Identifier.check(expression.arguments[1].params[1])) && (expression.arguments[2] === undefined || n.ThisExpression.check(expression.arguments[2])) ) { const old = { inLoop, replaceReturn }; inLoop = false; replaceReturn = true; this.visit( path .get("expression") .get("arguments") .get(1) .get("body") ); inLoop = old.inLoop; replaceReturn = old.replaceReturn; const [right, { body, params }] = expression.arguments; const loop = b.forOfStatement( b.variableDeclaration("let", [ b.variableDeclarator( valueOnly ? params[0] : b.arrayPattern([params[1], params[0]]) ), ]), valueOnly ? right : b.callExpression( b.memberExpression(right, b.identifier("entries")), [] ), checkStatement(body) ? body : b.expressionStatement(body) ); loop.comments = comments; path.replace(loop); changed = true; } this.traverse(path); }, visitForStatement: visitLoop("init", "test", "update"), visitForInStatement: visitLoop("left", "right"), visitForOfStatement: visitLoop("left", "right"), visitFunction(path) { this.visit(path.get("params")); const old = { replaceReturn }; replaceReturn = false; this.visit(path.get("body")); replaceReturn = old.replaceReturn; return false; }, visitReturnStatement(path) { if (replaceReturn) { assert(!inLoop); // could use labeled continue if this ever fires const { argument, comments } = path.node; if (argument === null) { const s = b.continueStatement(); s.comments = comments; path.replace(s); } else { const s = b.expressionStatement(argument); s.comments = comments; path.replace(s, b.continueStatement()); } return false; } this.traverse(path); }, visitWhileStatement: visitLoop("test"), }); if (changed) { console.log("Writing", file); fs.writeFileSync(file, recast.print(ast).code, { encoding: "utf8" }); } } Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-02-06 06:19:47 +01:00
message_events.insert_new_messages(messages, sent_by_this_client);
}
} catch (error) {
blueslip.error(
"Failed to insert new messages\n" + blueslip.exception_msg(error),
undefined,
error.stack,
);
}
}
if (message_lists.home.selected_id() === -1 && !message_lists.home.empty()) {
message_lists.home.select_id(message_lists.home.first().id, {then_scroll: false});
}
if (update_message_events.length !== 0) {
try {
message_events.update_messages(update_message_events);
} catch (error) {
blueslip.error(
"Failed to update messages\n" + blueslip.exception_msg(error),
undefined,
error.stack,
);
}
}
Fix bugs related to batching message events. This is general fix that makes sure that we apply all message-modifying events after we apply the events for the initial incoming messages. The particular scenario that was reported here was when you would have two tabs for Zulip, with one of them open and in a PM view, and with the open tab being at the bottom of the feed, such that incoming messages would be immediately visible. Now suppose the other person in that PM conversation sent you a message. The open tab would properly immediately mark the message as read, and notify the server. The problem was that the closed tab would not process the main message event until it "woke up", by which time the flag-update event was bundled into the same event batch as the main message event. We'd then process the flag-update first, which essentially was a noop, since the actual message wasn't in the message store yet. The user would then see unread counts increment in the closed tab, while the open tab didn't increment. This was confusing. Now `server_events.js` processes the actual message first and does the flag-update as part of a `post_message_events` loop. We include events for updating message flags, deleting messages, and attaching submessages to messages in the `post_message_events` array. This bug was a bit difficult to simulate in a dev environment, since you needed your "open" tab to be in focus to simulate the race, but as soon as you tab to another place to deliver a message (whether from the browser or otherwise), the open tab is no longer in focus. I did this in the console of my "open" tab to work around it: unread_ops.process_visible = unread_ops.mark_current_list_as_read; This problem was easy to reproduce, but it wasn't entirely consistent. I often needed to send several messages in succession to trigger event batching and force the race condition. (This wasn't precisely a "race", as events actually arrive in the correct order; it was having them arrive in the same batch that triggered the bug.)
2018-08-25 13:36:25 +02:00
// We do things like updating message flags and deleting messages last,
// to avoid ordering issues that are caused by batch handling of
// messages above.
js: Automatically convert _.each to for…of. This commit was automatically generated by the following script, followed by lint --fix and a few small manual lint-related cleanups. import * as babelParser from "recast/parsers/babel"; import * as recast from "recast"; import * as tsParser from "recast/parsers/typescript"; import { builders as b, namedTypes as n } from "ast-types"; import { Context } from "ast-types/lib/path-visitor"; import K from "ast-types/gen/kinds"; import { NodePath } from "ast-types/lib/node-path"; import assert from "assert"; import fs from "fs"; import path from "path"; import process from "process"; const checkExpression = (node: n.Node): node is K.ExpressionKind => n.Expression.check(node); const checkStatement = (node: n.Node): node is K.StatementKind => n.Statement.check(node); for (const file of process.argv.slice(2)) { console.log("Parsing", file); const ast = recast.parse(fs.readFileSync(file, { encoding: "utf8" }), { parser: path.extname(file) === ".ts" ? tsParser : babelParser, }); let changed = false; let inLoop = false; let replaceReturn = false; const visitLoop = (...args: string[]) => function(this: Context, path: NodePath) { for (const arg of args) { this.visit(path.get(arg)); } const old = { inLoop }; inLoop = true; this.visit(path.get("body")); inLoop = old.inLoop; return false; }; recast.visit(ast, { visitDoWhileStatement: visitLoop("test"), visitExpressionStatement(path) { const { expression, comments } = path.node; let valueOnly; if ( n.CallExpression.check(expression) && n.MemberExpression.check(expression.callee) && !expression.callee.computed && n.Identifier.check(expression.callee.object) && expression.callee.object.name === "_" && n.Identifier.check(expression.callee.property) && ["each", "forEach"].includes(expression.callee.property.name) && [2, 3].includes(expression.arguments.length) && checkExpression(expression.arguments[0]) && (n.FunctionExpression.check(expression.arguments[1]) || n.ArrowFunctionExpression.check(expression.arguments[1])) && [1, 2].includes(expression.arguments[1].params.length) && n.Identifier.check(expression.arguments[1].params[0]) && ((valueOnly = expression.arguments[1].params[1] === undefined) || n.Identifier.check(expression.arguments[1].params[1])) && (expression.arguments[2] === undefined || n.ThisExpression.check(expression.arguments[2])) ) { const old = { inLoop, replaceReturn }; inLoop = false; replaceReturn = true; this.visit( path .get("expression") .get("arguments") .get(1) .get("body") ); inLoop = old.inLoop; replaceReturn = old.replaceReturn; const [right, { body, params }] = expression.arguments; const loop = b.forOfStatement( b.variableDeclaration("let", [ b.variableDeclarator( valueOnly ? params[0] : b.arrayPattern([params[1], params[0]]) ), ]), valueOnly ? right : b.callExpression( b.memberExpression(right, b.identifier("entries")), [] ), checkStatement(body) ? body : b.expressionStatement(body) ); loop.comments = comments; path.replace(loop); changed = true; } this.traverse(path); }, visitForStatement: visitLoop("init", "test", "update"), visitForInStatement: visitLoop("left", "right"), visitForOfStatement: visitLoop("left", "right"), visitFunction(path) { this.visit(path.get("params")); const old = { replaceReturn }; replaceReturn = false; this.visit(path.get("body")); replaceReturn = old.replaceReturn; return false; }, visitReturnStatement(path) { if (replaceReturn) { assert(!inLoop); // could use labeled continue if this ever fires const { argument, comments } = path.node; if (argument === null) { const s = b.continueStatement(); s.comments = comments; path.replace(s); } else { const s = b.expressionStatement(argument); s.comments = comments; path.replace(s, b.continueStatement()); } return false; } this.traverse(path); }, visitWhileStatement: visitLoop("test"), }); if (changed) { console.log("Writing", file); fs.writeFileSync(file, recast.print(ast).code, { encoding: "utf8" }); } } Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-02-06 06:19:47 +01:00
for (const event of post_message_events) {
Fix bugs related to batching message events. This is general fix that makes sure that we apply all message-modifying events after we apply the events for the initial incoming messages. The particular scenario that was reported here was when you would have two tabs for Zulip, with one of them open and in a PM view, and with the open tab being at the bottom of the feed, such that incoming messages would be immediately visible. Now suppose the other person in that PM conversation sent you a message. The open tab would properly immediately mark the message as read, and notify the server. The problem was that the closed tab would not process the main message event until it "woke up", by which time the flag-update event was bundled into the same event batch as the main message event. We'd then process the flag-update first, which essentially was a noop, since the actual message wasn't in the message store yet. The user would then see unread counts increment in the closed tab, while the open tab didn't increment. This was confusing. Now `server_events.js` processes the actual message first and does the flag-update as part of a `post_message_events` loop. We include events for updating message flags, deleting messages, and attaching submessages to messages in the `post_message_events` array. This bug was a bit difficult to simulate in a dev environment, since you needed your "open" tab to be in focus to simulate the race, but as soon as you tab to another place to deliver a message (whether from the browser or otherwise), the open tab is no longer in focus. I did this in the console of my "open" tab to work around it: unread_ops.process_visible = unread_ops.mark_current_list_as_read; This problem was easy to reproduce, but it wasn't entirely consistent. I often needed to send several messages in succession to trigger event batching and force the race condition. (This wasn't precisely a "race", as events actually arrive in the correct order; it was having them arrive in the same batch that triggered the bug.)
2018-08-25 13:36:25 +02:00
server_events_dispatch.dispatch_normal_event(event);
js: Automatically convert _.each to for…of. This commit was automatically generated by the following script, followed by lint --fix and a few small manual lint-related cleanups. import * as babelParser from "recast/parsers/babel"; import * as recast from "recast"; import * as tsParser from "recast/parsers/typescript"; import { builders as b, namedTypes as n } from "ast-types"; import { Context } from "ast-types/lib/path-visitor"; import K from "ast-types/gen/kinds"; import { NodePath } from "ast-types/lib/node-path"; import assert from "assert"; import fs from "fs"; import path from "path"; import process from "process"; const checkExpression = (node: n.Node): node is K.ExpressionKind => n.Expression.check(node); const checkStatement = (node: n.Node): node is K.StatementKind => n.Statement.check(node); for (const file of process.argv.slice(2)) { console.log("Parsing", file); const ast = recast.parse(fs.readFileSync(file, { encoding: "utf8" }), { parser: path.extname(file) === ".ts" ? tsParser : babelParser, }); let changed = false; let inLoop = false; let replaceReturn = false; const visitLoop = (...args: string[]) => function(this: Context, path: NodePath) { for (const arg of args) { this.visit(path.get(arg)); } const old = { inLoop }; inLoop = true; this.visit(path.get("body")); inLoop = old.inLoop; return false; }; recast.visit(ast, { visitDoWhileStatement: visitLoop("test"), visitExpressionStatement(path) { const { expression, comments } = path.node; let valueOnly; if ( n.CallExpression.check(expression) && n.MemberExpression.check(expression.callee) && !expression.callee.computed && n.Identifier.check(expression.callee.object) && expression.callee.object.name === "_" && n.Identifier.check(expression.callee.property) && ["each", "forEach"].includes(expression.callee.property.name) && [2, 3].includes(expression.arguments.length) && checkExpression(expression.arguments[0]) && (n.FunctionExpression.check(expression.arguments[1]) || n.ArrowFunctionExpression.check(expression.arguments[1])) && [1, 2].includes(expression.arguments[1].params.length) && n.Identifier.check(expression.arguments[1].params[0]) && ((valueOnly = expression.arguments[1].params[1] === undefined) || n.Identifier.check(expression.arguments[1].params[1])) && (expression.arguments[2] === undefined || n.ThisExpression.check(expression.arguments[2])) ) { const old = { inLoop, replaceReturn }; inLoop = false; replaceReturn = true; this.visit( path .get("expression") .get("arguments") .get(1) .get("body") ); inLoop = old.inLoop; replaceReturn = old.replaceReturn; const [right, { body, params }] = expression.arguments; const loop = b.forOfStatement( b.variableDeclaration("let", [ b.variableDeclarator( valueOnly ? params[0] : b.arrayPattern([params[1], params[0]]) ), ]), valueOnly ? right : b.callExpression( b.memberExpression(right, b.identifier("entries")), [] ), checkStatement(body) ? body : b.expressionStatement(body) ); loop.comments = comments; path.replace(loop); changed = true; } this.traverse(path); }, visitForStatement: visitLoop("init", "test", "update"), visitForInStatement: visitLoop("left", "right"), visitForOfStatement: visitLoop("left", "right"), visitFunction(path) { this.visit(path.get("params")); const old = { replaceReturn }; replaceReturn = false; this.visit(path.get("body")); replaceReturn = old.replaceReturn; return false; }, visitReturnStatement(path) { if (replaceReturn) { assert(!inLoop); // could use labeled continue if this ever fires const { argument, comments } = path.node; if (argument === null) { const s = b.continueStatement(); s.comments = comments; path.replace(s); } else { const s = b.expressionStatement(argument); s.comments = comments; path.replace(s, b.continueStatement()); } return false; } this.traverse(path); }, visitWhileStatement: visitLoop("test"), }); if (changed) { console.log("Writing", file); fs.writeFileSync(file, recast.print(ast).code, { encoding: "utf8" }); } } Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-02-06 06:19:47 +01:00
}
}
function show_ui_connection_error() {
ui_report.show_error($("#connection-error"));
$("#connection-error").addClass("get-events-error");
}
function hide_ui_connection_error() {
ui_report.hide_error($("#connection-error"));
$("#connection-error").removeClass("get-events-error");
}
function get_events({dont_block = false} = {}) {
if (reload_state.is_in_progress()) {
return;
}
// TODO: In the future, we may implement Tornado support for live
// update for spectator, but until then, there's nothing
// to do here.
if (page_params.is_spectator) {
return;
}
get_events_params.dont_block = dont_block || get_events_failures > 0;
if (get_events_params.dont_block) {
// If we're requesting an immediate re-connect to the server,
// that means it's fairly likely that this client has been off
// the Internet and thus may have stale state (which is
// important for potential presence issues).
watchdog.set_suspect_offline(true);
}
if (get_events_params.queue_id === undefined) {
get_events_params.queue_id = page_params.queue_id;
get_events_params.last_event_id = page_params.last_event_id;
}
if (get_events_xhr !== undefined) {
get_events_xhr.abort();
}
if (get_events_timeout !== undefined) {
clearTimeout(get_events_timeout);
}
get_events_params.client_gravatar = true;
get_events_params.slim_presence = true;
get_events_timeout = undefined;
get_events_xhr = channel.get({
url: "/json/events",
data: get_events_params,
timeout: page_params.event_queue_longpoll_timeout_seconds * 1000,
success(data) {
watchdog.set_suspect_offline(false);
try {
get_events_xhr = undefined;
get_events_failures = 0;
hide_ui_connection_error();
get_events_success(data.events);
} catch (error) {
blueslip.error(
"Failed to handle get_events success\n" + blueslip.exception_msg(error),
undefined,
error.stack,
);
}
get_events_timeout = setTimeout(get_events, 0);
},
error(xhr, error_type) {
try {
get_events_xhr = undefined;
// If we're old enough that our message queue has been
// garbage collected, immediately reload.
if (
xhr.status === 400 &&
JSON.parse(xhr.responseText).code === "BAD_EVENT_QUEUE_ID"
) {
page_params.event_queue_expired = true;
reload.initiate({
immediate: true,
save_pointer: false,
save_narrow: true,
save_compose: true,
});
return;
}
if (error_type === "abort") {
// Don't restart if we explicitly aborted
return;
} else if (error_type === "timeout") {
// Retry indefinitely on timeout.
get_events_failures = 0;
hide_ui_connection_error();
} else {
get_events_failures += 1;
}
if (get_events_failures >= 5) {
show_ui_connection_error();
} else {
hide_ui_connection_error();
}
} catch (error) {
blueslip.error(
"Failed to handle get_events error\n" + blueslip.exception_msg(error),
undefined,
error.stack,
);
}
const retry_sec = Math.min(90, Math.exp(get_events_failures / 2));
get_events_timeout = setTimeout(get_events, retry_sec * 1000);
},
});
}
export function assert_get_events_running(error_message) {
if (get_events_xhr === undefined && get_events_timeout === undefined) {
restart_get_events({dont_block: true});
blueslip.error(error_message);
}
}
export function restart_get_events(options) {
get_events(options);
}
export function force_get_events() {
get_events_timeout = setTimeout(get_events, 0);
}
export function home_view_loaded() {
waiting_on_homeview_load = false;
get_events_success([]);
$(document).trigger("home_view_loaded.zulip");
}
export function initialize() {
watchdog.on_unsuspend(() => {
// Immediately poll for new events on unsuspend
blueslip.log("Restarting get_events due to unsuspend");
get_events_failures = 0;
restart_get_events({dont_block: true});
});
get_events();
}
export function cleanup_event_queue() {
// Submit a request to the server to clean up our event queue
if (page_params.event_queue_expired === true || page_params.no_event_queue === true) {
return;
}
blueslip.log("Cleaning up our event queue");
// Set expired because in a reload we may be called twice.
page_params.event_queue_expired = true;
channel.del({
url: "/json/events",
data: {queue_id: page_params.queue_id},
ignore_reload: true,
});
}
window.addEventListener("beforeunload", () => {
cleanup_event_queue();
});
// For unit testing
export const _get_events_success = get_events_success;