Various homeserver state FST and queue fixes #101

Open
l1s5 wants to merge 6 commits from l1s5/out-of-your-element:homeserver-online-check-fixes into main
4 changed files with 288 additions and 20 deletions

View file

@ -130,7 +130,7 @@ async function sendError(roomID, source, type, e, payload) {
|| (e instanceof mreq.MatrixServerError && [502, 503].includes(e.httpStatus) && e.errcode === "CX_SERVER_ERROR") || (e instanceof mreq.MatrixServerError && [502, 503].includes(e.httpStatus) && e.errcode === "CX_SERVER_ERROR")
) { ) {
// Matrix homeserver is down (reverse proxy indicated failure) // Matrix homeserver is down (reverse proxy indicated failure)
homeserverStatus.homeserverStatus.setErrorWithPacket([source, payload]) homeserverStatus.homeserverStatus.queuePacket([source, payload])
} }
} }
} }

View file

@ -1,8 +1,8 @@
// @ts-check // @ts-check
const assert = require("assert").strict
const Denque = require("denque") const Denque = require("denque")
const StateMachine = require("snowtransfer").StateMachine const StateMachine = require("snowtransfer").StateMachine
const Ty = require("../types")
const passthrough = require("../passthrough") const passthrough = require("../passthrough")
const {sync} = passthrough const {sync} = passthrough
@ -19,7 +19,7 @@ function debugHomeserverStatus(message) {
} }
} }
const homeserverStatus = sync.remember(() => new class HomeserverStatus { class HomeserverStatus {
constructor() { constructor() {
/** @private */ /** @private */
this.queue = new Denque() this.queue = new Denque()
@ -39,10 +39,14 @@ const homeserverStatus = sync.remember(() => new class HomeserverStatus {
.defineState("checking", { .defineState("checking", {
onEnter: [async (event) => { onEnter: [async (event) => {
this.checkingWhileOnline = event === "check while online" this.checkingWhileOnline = event === "check assume online"
const pingResult = await api.ping().catch(e => ({ok: false})) const pingResult = await api.ping().catch(e => ({ok: false}))
this.lastCheck = Date.now() this.lastCheck = Date.now()
if (pingResult.ok) { // ping result is stale if something else changed the state in the async gap
if (this.sm.currentStateName !== "checking") return
if (pingResult.ok && !this.queue.isEmpty()) {
this.sm.doTransition("check ok with queue")
} else if (pingResult.ok) {
this.sm.doTransition("check ok") this.sm.doTransition("check ok")
} else { } else {
this.sm.doTransition("check fail") this.sm.doTransition("check fail")
@ -58,6 +62,8 @@ const homeserverStatus = sync.remember(() => new class HomeserverStatus {
this.pingInterval = setInterval(async () => { this.pingInterval = setInterval(async () => {
const pingResult = await api.ping().catch(e => ({ok: false, status: "net", root: e.message})) const pingResult = await api.ping().catch(e => ({ok: false, status: "net", root: e.message}))
this.lastCheck = Date.now() this.lastCheck = Date.now()
// ping result is stale if something else changed the state in the async gap
if (this.sm.currentStateName !== "offline") return
if (pingResult.ok) { if (pingResult.ok) {
this.sm.doTransition("ping ok") this.sm.doTransition("ping ok")
} }
@ -76,11 +82,16 @@ const homeserverStatus = sync.remember(() => new class HomeserverStatus {
while (!this.queue.isEmpty()) { while (!this.queue.isEmpty()) {
const [source, packet] = this.queue.peekFront() // same position as .shift() const [source, packet] = this.queue.peekFront() // same position as .shift()
debugHomeserverStatus(`homeserver status: ${new Date().toISOString()} dq sourceAndPacket ${packet.t} ${packet.d?.content}`) debugHomeserverStatus(`homeserver status: ${new Date().toISOString()} dq sourceAndPacket ${packet.t} ${packet.d?.content}`)
if (source === "Discord") { try {
await discordPackets.dispatchPacketToBridge(passthrough.discord, packet) if (source === "Discord") {
} else { await discordPackets.dispatchPacketToBridge(passthrough.discord, packet)
// Unfortunately cannot await this, just have to trust it } else {
passthrough.as.emit(`type:${packet.type}`, packet) await Promise.all(passthrough.as.listeners(`type:${packet.type}`).map(listener => listener(packet)))
}
} catch (e) {
// `dispatchPacketToBridge` should not throw, this is here in case of dev error.
// Skip the packet so that it can't block the queue forever.
console.error("[homeserver status] Skipping a queued event that couldn't be replayed:", e)
} }
if (this.sm.currentStateName !== "recovering") return // got kicked out due to another error if (this.sm.currentStateName !== "recovering") return // got kicked out due to another error
this.queue.shift() this.queue.shift()
@ -99,6 +110,7 @@ const homeserverStatus = sync.remember(() => new class HomeserverStatus {
.defineTransition("online", "check assume online", "checking") .defineTransition("online", "check assume online", "checking")
.defineTransition("online", "check assume offline", "checking") .defineTransition("online", "check assume offline", "checking")
.defineTransition("checking", "check ok", "online") .defineTransition("checking", "check ok", "online")
.defineTransition("checking", "check ok with queue", "recovering")
.defineTransition("checking", "check fail", "offline") .defineTransition("checking", "check fail", "offline")
this.sm.on("enter", st => debugHomeserverStatus(`homeserver status: ${st}`)) this.sm.on("enter", st => debugHomeserverStatus(`homeserver status: ${st}`))
@ -137,20 +149,28 @@ const homeserverStatus = sync.remember(() => new class HomeserverStatus {
} }
/** /**
* When offline or recovering, call this for incoming packets to queue them to be sent in order later. * Queue a packet to be sent in order later, particularly when the homeserver is not realtime.
*
* Queueing while realtime implies the homeserver went down, so this also transitions the state to offline.
*
* @param {["Discord", import("cloudstorm").IGatewayMessage]
* | ["Matrix", Ty.Event.Outer<any>]} sourceAndPacket
*/ */
queuePacket(sourceAndPacket) { queuePacket(sourceAndPacket) {
assert(["offline", "recovering"].includes(this.sm.currentStateName)) if (this.sm.currentStateName === "recovering" && this.queue.peekFront()?.[1] === sourceAndPacket[1]) {
this.sm.doTransition("error")
return
}
if (this.isRealTime()) {
this.sm.doTransition("error")
}
this.queue.push(sourceAndPacket) this.queue.push(sourceAndPacket)
} }
}
setErrorWithPacket(sourceAndPacket) { const homeserverStatus = sync.remember(() => new HomeserverStatus())
const wasRecovering = this.sm.currentStateName === "recovering"
this.sm.doTransition("error")
if (!wasRecovering) { // if was recovering then sourceAndPacket is already in the right place in queue
this.queuePacket(sourceAndPacket)
}
}
})
module.exports.homeserverStatus = homeserverStatus module.exports.homeserverStatus = homeserverStatus
module.exports._HomeserverStatus = HomeserverStatus

View file

@ -0,0 +1,247 @@
// @ts-check
const {test} = require("supertape")
const {EventEmitter, on} = require("events")
const timers = require("timers/promises")
const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../types")
const data = require("../../test/data")
const passthrough = require("../passthrough")
const {sync} = passthrough
/** @type {import("./api")} */
const api = sync.require("./api")
/** @type {import("../d2m/discord-packets")} */
const discordPackets = sync.require("../d2m/discord-packets")
const {_HomeserverStatus: HomeserverStatus} = require("./homeserver-status")
const originalPing = api.ping
const originalDispatch = discordPackets.dispatchPacketToBridge
const originalLog = console.log
const originalError = console.error
/**
* Create a `MESSAGE_CREATE` discord packet
* @param {string} content
* @returns {import("cloudstorm").IGatewayMessage}
*/
function discordMessage(content) {
return {
op: DiscordTypes.GatewayOpcodes.Dispatch,
t: DiscordTypes.GatewayDispatchEvents.MessageCreate,
s: 1,
d: {...data.message.simple_plaintext, content},
shard_id: 0
}
}
/**
* Create a `m.room.message` matrix event
* @param {string} body
* @returns {Ty.Event.Outer_M_Room_Message}
*/
function matrixMessage(body) {
return {
type: "m.room.message",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
sender: "@cadence:cadence.moe",
content: {msgtype: "m.text", body},
origin_server_ts: 0,
event_id: "$event"
}
}
/**
* Overriding the `HomeserverStatus` type to expose some private items.
* @typedef {Omit<HomeserverStatus, "sm" | "queue"> & {
* sm: HomeserverStatus["sm"],
* queue: HomeserverStatus["queue"],
* }} ExposedHomeserverStatus
*/
/** @returns {{hs: ExposedHomeserverStatus, pings: PromiseWithResolvers<any>[]}} */
function setup() {
console.log = () => {}
console.error = () => {}
const pings = []
api.ping = () => {
const ping = Promise.withResolvers()
pings.push(ping)
return ping.promise
}
/** @type {ExposedHomeserverStatus} */ // @ts-ignore
const hs = new HomeserverStatus()
return {hs, pings}
}
/**
* @param {(packet: import("cloudstorm").IGatewayMessage) => any} [onDiscord] called for each replayed Discord packet
* @param {(event: Ty.Event.Outer_M_Room_Message) => any} [onMatrix] called for each replayed m.room.message event
*/
function stubBridge(
onDiscord = () => { },
onMatrix = () => { },
) {
discordPackets.dispatchPacketToBridge = async (_client, packet) => onDiscord(packet)
const as = new EventEmitter()
as.on("type:m.room.message", async event => onMatrix(event))
// @ts-ignore
passthrough.as = as
}
/** Undo the effects of `setup()` and `stubBridge()` */
function restore() {
console.log = originalLog
console.error = originalError
api.ping = originalPing
discordPackets.dispatchPacketToBridge = originalDispatch
// @ts-ignore
delete passthrough.as
}
/**
* Pretend the offline ping succeeded and wait until the end of queue replay.
* @param {import("supertape").Test} t
* @param {ExposedHomeserverStatus} hs
* @param {string} [endState] the state the replay is expected to end in
*/
async function replayQueue(t, hs, endState = "online") {
t.equal(hs.sm.currentStateName, "offline", "replaying the queue from offline")
const enters = on(hs.sm, "enter") // listen before transitioning, since the end state might be reached straight away
hs.sm.doTransition("ping ok")
for await (const [stateName] of enters) {
if (stateName === endState) break
}
}
test("homeserver status: check assuming online stays real time", async t => {
const {hs, pings} = setup()
const online = hs.waitForOnline(false)
t.equal(hs.sm.currentStateName, "checking")
t.ok(hs.isRealTime())
pings[0].resolve({ok: true})
await online
t.equal(hs.sm.currentStateName, "online")
restore()
})
test("homeserver status: check assuming offline replays queue on success", async t => {
const {hs, pings} = setup()
const replayed = []
stubBridge(packet => replayed.push(packet))
const online = hs.waitForOnline(true)
t.equal(hs.sm.currentStateName, "checking")
t.notOk(hs.isRealTime())
const packet = discordMessage("sent during the check")
hs.queuePacket(["Discord", packet])
pings[0].resolve({ok: true})
await online
t.deepEqual(replayed, [packet])
restore()
})
test("homeserver status: successful check ping ignored if packet fails mid-check", async t => {
const {hs, pings} = setup()
stubBridge()
hs.waitForOnline(false)
hs.queuePacket(["Discord", discordMessage("failed during the check")])
t.equal(hs.sm.currentStateName, "offline")
// check ping suceeds, but it's too late
pings[0].resolve({ok: true})
await timers.setImmediate()
t.equal(hs.sm.currentStateName, "offline", "check result was ignored")
await replayQueue(t, hs)
restore()
})
test("homeserver status: failed check ping goes offline, replays queue", async t => {
const {hs, pings} = setup()
stubBridge()
const online = hs.waitForOnline(true)
pings[0].resolve({ok: false})
await timers.setImmediate()
t.equal(hs.sm.currentStateName, "offline")
await replayQueue(t, hs)
await online
t.equal(hs.sm.currentStateName, "online")
restore()
})
test("homeserver status: no new check if the last one was recent", async t => {
const {hs, pings} = setup()
const first = hs.waitForOnline(false)
pings[0].resolve({ok: true})
await first
await hs.waitForOnline(false)
t.equal(pings.length, 1, "didn't ping again")
const forced = hs.waitForOnline(true)
t.equal(pings.length, 2, "forced check pings anyway")
pings[1].resolve({ok: true})
await forced
restore()
})
test("homeserver status: replay waits for matrix events", async t => {
const {hs} = setup()
const slow = matrixMessage("slow")
const next = discordMessage("next")
const replayed = []
stubBridge(
packet => replayed.push(packet),
async event => {
await timers.setTimeout(10)
replayed.push(event)
}
)
hs.queuePacket(["Matrix", slow])
hs.queuePacket(["Discord", next])
await replayQueue(t, hs)
t.deepEqual(replayed, [slow, next])
restore()
})
test("homeserver status: replay skips packets that throw", async t => {
const {hs} = setup()
const broken = discordMessage("throws")
const next = discordMessage("next")
const replayed = []
stubBridge(packet => {
if (packet === broken) throw new Error("this packet is broken")
replayed.push(packet)
})
hs.queuePacket(["Discord", broken])
hs.queuePacket(["Discord", next])
await replayQueue(t, hs)
t.deepEqual(replayed, [next])
t.equal(hs.queue.length, 0)
restore()
})
test("homeserver status: failing queue packet keeps its queue position", async t => {
const {hs} = setup()
const first = discordMessage("first")
const second = discordMessage("second")
hs.queuePacket(["Discord", first])
hs.queuePacket(["Discord", second])
stubBridge(packet => hs.queuePacket(["Discord", packet])) // homeserver went down again
await replayQueue(t, hs, "offline")
t.deepEqual(
hs.queue.toArray(),
[["Discord", first], ["Discord", second]],
"still at the front, and not duplicated",
)
const replayed = []
stubBridge(packet => replayed.push(packet))
await replayQueue(t, hs)
t.deepEqual(replayed, [first, second])
restore()
})

View file

@ -140,6 +140,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
require("../src/matrix/kstate.test") require("../src/matrix/kstate.test")
require("../src/matrix/api.test") require("../src/matrix/api.test")
require("../src/matrix/file.test") require("../src/matrix/file.test")
require("../src/matrix/homeserver-status.test")
require("../src/matrix/mreq.test") require("../src/matrix/mreq.test")
require("../src/matrix/read-registration.test") require("../src/matrix/read-registration.test")
require("../src/matrix/room-upgrade.test") require("../src/matrix/room-upgrade.test")