From 5624782490de8aeb6d7b9d66306e1b2c9729c210 Mon Sep 17 00:00:00 2001 From: Kannamoris Date: Mon, 12 Oct 2026 01:23:31 +0200 Subject: [PATCH] fix: stop dropping background frames on iOS The background buffer dropped its oldest frames past 3000, losing presence updates, PMs and channel messages. Status and typing frames are now coalesced per character, the cap is raised to 50k, and an overflow triggers a reconnect on resume to resync the online list. --- mobile/NativeSocketConnection.ts | 85 +++++++++------- mobile/ios/Solstice/Bridge/FrameBuffer.swift | 98 +++++++++++++++++++ mobile/ios/Solstice/Bridge/NativeSocket.swift | 33 ++++--- .../ios/SolsticeTests/FrameBufferTests.swift | 98 +++++++++++++++++++ 4 files changed, 263 insertions(+), 51 deletions(-) create mode 100644 mobile/ios/Solstice/Bridge/FrameBuffer.swift create mode 100644 mobile/ios/SolsticeTests/FrameBufferTests.swift diff --git a/mobile/NativeSocketConnection.ts b/mobile/NativeSocketConnection.ts index e50cbe8e..a3555f22 100644 --- a/mobile/NativeSocketConnection.ts +++ b/mobile/NativeSocketConnection.ts @@ -45,46 +45,32 @@ export default class NativeSocketConnection implements WebSocketConnection { void NativeSocket.connect(Socket.host); } - static deliver(type: string, payload: unknown, receivedAt?: number): void { - NativeSocketConnection.current?.handle(type, payload, receivedAt); + static deliver(type: string, payload: unknown): void { + NativeSocketConnection.current?.handle(type, payload); } - private handle(type: string, payload: unknown, receivedAt?: number): void { + private handle(type: string, payload: unknown): void { switch (type) { case 'open': this.state = WebSocketConnection.ReadyState.OPEN; if (this.openHandler !== undefined) this.openHandler(); break; case 'message': - if (this.messageHandler !== undefined) { - const handler = this.messageHandler; - const text = String(payload); - // Present for replayed (buffered) frames so they keep their real arrival time. - const at = typeof receivedAt === 'number' ? receivedAt : undefined; - // Replayed frames were already alerted natively while backgrounded; suppress the JS - // notification path for them so reopening doesn't re-fire a burst of sounds/haptics. - // lastHandler serializes handling, so exactly one frame runs at a time and the flag - // brackets only this frame (live frames have no receivedAt and are untouched). - const run = async (): Promise => { - const notifications = core.notifications as { replaying?: boolean }; - const replay = at !== undefined; - if (replay) notifications.replaying = true; - try { - await handler(text, at); - } finally { - if (replay) { - // Hold suppression through one microtask so a notification/sound the handler - // scheduled (rather than awaited) is still covered. Safe because lastHandler - // serializes handling: the next frame can't run until this run settles, so a live - // frame never overlaps the suppressed window. - await Promise.resolve(); - notifications.replaying = false; - } - } - }; - this.lastHandler = this.lastHandler.then(run, run); - } + this.enqueue(String(payload)); + break; + case 'batch': + // Frames native buffered while backgrounded, in arrival order, each with its arrival time. + for (const [text, at] of payload as [string, number][]) + this.enqueue(text, at); break; + case 'resync': { + // Native's background buffer overflowed and dropped presence frames, so the online list is + // stale. Once the replayed backlog is handled, close the socket: the Connection reconnects, + // and the fresh login resends the full online list. + const resync = async (): Promise => this.close(); + this.lastHandler = this.lastHandler.then(resync, resync); + break; + } case 'close': { this.state = WebSocketConnection.ReadyState.CLOSED; const c = (payload as NativeClose) || { @@ -112,6 +98,35 @@ export default class NativeSocketConnection implements WebSocketConnection { } } + // Queue a frame behind the ones before it. `receivedAt` (ms) is present only for frames replayed + // from native's background buffer, so they keep their real arrival time. + private enqueue(text: string, receivedAt?: number): void { + if (this.messageHandler === undefined) return; + const handler = this.messageHandler; + // Replayed frames were already alerted natively while backgrounded; suppress the JS + // notification path for them so reopening doesn't re-fire a burst of sounds/haptics. + // lastHandler serializes handling, so exactly one frame runs at a time and the flag + // brackets only this frame (live frames have no receivedAt and are untouched). + const run = async (): Promise => { + const notifications = core.notifications as { replaying?: boolean }; + const replay = receivedAt !== undefined; + if (replay) notifications.replaying = true; + try { + await handler(text, receivedAt); + } finally { + if (replay) { + // Hold suppression through one microtask so a notification/sound the handler + // scheduled (rather than awaited) is still covered. Safe because lastHandler + // serializes handling: the next frame can't run until this run settles, so a live + // frame never overlaps the suppressed window. + await Promise.resolve(); + notifications.replaying = false; + } + } + }; + this.lastHandler = this.lastHandler.then(run, run); + } + get readyState(): WebSocketConnection.ReadyState { return this.state; } @@ -144,10 +159,8 @@ export default class NativeSocketConnection implements WebSocketConnection { } } -// Native (NativeSocket.swift) pushes socket events here. `receivedAt` (ms) is present only for -// replayed frames so they keep their real arrival time. +// Native (NativeSocket.swift) pushes socket events here. (window as any).__nativeSocketEvent = ( //tslint:disable-line:no-any type: string, - payload: unknown, - receivedAt?: number -): void => NativeSocketConnection.deliver(type, payload, receivedAt); + payload: unknown +): void => NativeSocketConnection.deliver(type, payload); diff --git a/mobile/ios/Solstice/Bridge/FrameBuffer.swift b/mobile/ios/Solstice/Bridge/FrameBuffer.swift new file mode 100644 index 00000000..b295ff4b --- /dev/null +++ b/mobile/ios/Solstice/Bridge/FrameBuffer.swift @@ -0,0 +1,98 @@ +import Foundation + +// The F-List frames that arrive while the app is backgrounded (WebView suspended), held for +// NativeSocket to replay on resume. Foundation only, so SolsticeTests can exercise it directly. +// +// F-List sends every client the logins, logouts and status changes of every character online, so a +// long idle produces far more of those than anything else. A plain list that dropped its oldest +// frames past a fixed cap silently lost them (issue #32): presence went stale, and PMs and channel +// messages vanished from their conversations and logs. So instead: +// - Status (STA) and typing (TPN) frames are pure state, so only each character's latest is kept. +// - Everything else is kept in arrival order. +// - If the buffer still fills up, presence (NLN/FLN/STA/TPN) is dropped and `needsResync` is set: +// the WebView reconnects after replaying, and the fresh login resends the full online list. +// Should even that not be enough, the oldest frames go, PMs last since they cannot be refetched. +struct FrameBuffer { + struct Frame: Equatable { + let text: String + let timeMs: Int + } + + // seq records arrival order, so the coalesced frames merge back into their place on drain. + private struct Entry { + let seq: Int + let frame: Frame + } + + static let presenceCommands: Set = ["NLN", "FLN", "STA", "TPN"] + + let cap: Int + private var ordered: [Entry] = [] + private var latest: [String: Entry] = [:] + private var nextSeq = 0 + private(set) var needsResync = false + + init(cap: Int = 50_000) { + self.cap = cap + } + + var count: Int { ordered.count + latest.count } + + mutating func append(_ text: String, timeMs: Int) { + // PIN is answered natively and ignored by the JS Connection, so replaying it is waste. + if text == "PIN" { return } + let command = String(text.prefix(3)) + // Once a resync is due the online list gets rebuilt from scratch, so presence is redundant. + if needsResync && Self.presenceCommands.contains(command) { return } + let entry = Entry(seq: nextSeq, frame: Frame(text: text, timeMs: timeMs)) + nextSeq += 1 + if command == "STA" || command == "TPN", let name = Self.character(in: text) { + latest["\(command) \(name.lowercased())"] = entry + } else { + ordered.append(entry) + } + if count > cap { shed() } + } + + // Everything buffered, in arrival order, and whether the WebView must reconnect afterwards. + // Leaves the buffer empty. + mutating func drain() -> (frames: [Frame], needsResync: Bool) { + let frames = (ordered + latest.values).sorted { $0.seq < $1.seq }.map { $0.frame } + let resync = needsResync + removeAll() + return (frames, resync) + } + + mutating func removeAll() { + self = FrameBuffer(cap: cap) + } + + private mutating func shed() { + if !needsResync { + needsResync = true + latest.removeAll() + ordered.removeAll { Self.presenceCommands.contains(String($0.frame.text.prefix(3))) } + } + // Trim to 90% so this O(n) pass runs once per cap/10 frames, not on every frame. + var excess = ordered.count - cap * 9 / 10 + guard excess > 0 else { return } + var kept: [Entry] = [] + kept.reserveCapacity(ordered.count - excess) + for entry in ordered { + if excess > 0 && !entry.frame.text.hasPrefix("PRI ") { + excess -= 1 + } else { + kept.append(entry) + } + } + kept.removeFirst(min(excess, kept.count)) + ordered = kept + } + + // The "character" field of a " " frame. + private static func character(in frame: String) -> String? { + let json = Data(frame.utf8.dropFirst(4)) + let obj = try? JSONSerialization.jsonObject(with: json) as? [String: Any] + return obj?["character"] as? String + } +} diff --git a/mobile/ios/Solstice/Bridge/NativeSocket.swift b/mobile/ios/Solstice/Bridge/NativeSocket.swift index b856ce49..c8bd4cb5 100644 --- a/mobile/ios/Solstice/Bridge/NativeSocket.swift +++ b/mobile/ios/Solstice/Bridge/NativeSocket.swift @@ -18,11 +18,12 @@ final class NativeSocket: NSObject, WKScriptMessageHandlerWithReply, URLSessionW private var session: URLSession? private var task: URLSessionWebSocketTask? private var inBackground = false - // Buffered frames carry the time they arrived, so the WebView can stamp them with their real - // arrival time on replay instead of the (later) resume time. - private var buffer: [(frame: String, timeMs: Int)] = [] + // Frames that arrive while backgrounded, with their arrival times so the WebView stamps them with + // those instead of the (later) resume time. See FrameBuffer for what is kept. + private var buffer = FrameBuffer() private var closeEmitted = false - private static let bufferCap = 3000 + // Frames per evaluateJavaScript call when replaying the buffer on resume. + private static let replayBatch = 500 // Background-notify policy pushed from JS (mobile/notifyConfig.ts) on connect and whenever it // changes, so native can reproduce the foreground decision (chat/conversations.ts) while JS is @@ -126,10 +127,7 @@ final class NativeSocket: NSObject, WKScriptMessageHandlerWithReply, URLSessionW // JS is suspended, so it can't fire notifications. Inspect PMs/highlights natively and // post a local notification, then buffer the frame for the WebView to catch up on resume. notifyIfNeeded(text) - buffer.append((text, Int(Date().timeIntervalSince1970 * 1000))) - if buffer.count > Self.bufferCap { - buffer.removeFirst(buffer.count - Self.bufferCap) - } + buffer.append(text, timeMs: Int(Date().timeIntervalSince1970 * 1000)) } else { emit("message", WebViewController.jsString(text)) } @@ -297,11 +295,17 @@ final class NativeSocket: NSObject, WKScriptMessageHandlerWithReply, URLSessionW @objc private func willForeground() { inBackground = false - let pending = buffer - buffer.removeAll() - for entry in pending { - emit("message", WebViewController.jsString(entry.frame), String(entry.timeMs)) + let (frames, needsResync) = buffer.drain() + // Replay in batches: one evaluateJavaScript per frame is slow for a long backlog, and + // separate batches let the WebView render between them. + for start in stride(from: 0, to: frames.count, by: Self.replayBatch) { + let batch = frames[start.. [String] { + return frames.map { $0.text } + } + + func testKeepsArrivalOrderAndTimes() { + var buffer = FrameBuffer() + buffer.append("MSG {\"channel\":\"c\",\"character\":\"A\",\"message\":\"hi\"}", timeMs: 1) + buffer.append("PRI {\"character\":\"B\",\"message\":\"yo\"}", timeMs: 2) + buffer.append("NLN {\"identity\":\"C\",\"gender\":\"Male\",\"status\":\"online\"}", timeMs: 3) + let (frames, needsResync) = buffer.drain() + XCTAssertEqual(frames.map { $0.timeMs }, [1, 2, 3]) + XCTAssertTrue(frames[1].text.hasPrefix("PRI ")) + XCTAssertFalse(needsResync) + XCTAssertEqual(buffer.count, 0) + } + + func testSkipsPin() { + var buffer = FrameBuffer() + buffer.append("PIN", timeMs: 1) + XCTAssertEqual(buffer.count, 0) + } + + func testCoalescesStatusAndTypingPerCharacter() { + var buffer = FrameBuffer() + buffer.append("STA {\"character\":\"Alice\",\"status\":\"away\",\"statusmsg\":\"\"}", timeMs: 1) + buffer.append("MSG {\"channel\":\"c\",\"character\":\"X\",\"message\":\"m\"}", timeMs: 2) + buffer.append("STA {\"character\":\"Bob\",\"status\":\"busy\",\"statusmsg\":\"\"}", timeMs: 3) + buffer.append("STA {\"character\":\"alice\",\"status\":\"looking\",\"statusmsg\":\"\"}", timeMs: 4) + buffer.append("TPN {\"character\":\"Alice\",\"status\":\"typing\"}", timeMs: 5) + buffer.append("TPN {\"character\":\"Alice\",\"status\":\"clear\"}", timeMs: 6) + let (frames, _) = buffer.drain() + XCTAssertEqual(frames.map { $0.timeMs }, [2, 3, 4, 6]) + XCTAssertTrue(frames[2].text.contains("looking")) + XCTAssertTrue(frames[3].text.contains("clear")) + } + + func testStatusStaysAfterTheLoginItFollows() { + var buffer = FrameBuffer() + buffer.append("STA {\"character\":\"A\",\"status\":\"away\",\"statusmsg\":\"\"}", timeMs: 1) + buffer.append("FLN {\"character\":\"A\"}", timeMs: 2) + buffer.append("NLN {\"identity\":\"A\",\"gender\":\"Male\",\"status\":\"online\"}", timeMs: 3) + buffer.append("STA {\"character\":\"A\",\"status\":\"busy\",\"statusmsg\":\"\"}", timeMs: 4) + let (frames, _) = buffer.drain() + XCTAssertEqual(frames.map { $0.timeMs }, [2, 3, 4]) + } + + func testMalformedStatusIsKeptInOrder() { + var buffer = FrameBuffer() + buffer.append("STA not json", timeMs: 1) + buffer.append("STA not json", timeMs: 2) + XCTAssertEqual(buffer.drain().frames.map { $0.timeMs }, [1, 2]) + } + + func testOverflowDropsPresenceAndRequestsResync() { + var buffer = FrameBuffer(cap: 10) + buffer.append("PRI {\"character\":\"P\",\"message\":\"pm\"}", timeMs: 0) + buffer.append("MSG {\"channel\":\"c\",\"character\":\"X\",\"message\":\"m\"}", timeMs: 1) + for i in 0..<9 { + buffer.append("NLN {\"identity\":\"N\(i)\",\"gender\":\"Male\",\"status\":\"online\"}", timeMs: 2 + i) + } + XCTAssertTrue(buffer.needsResync) + XCTAssertEqual(buffer.count, 2) + // Presence after the overflow is redundant too; everything else still goes in. + buffer.append("FLN {\"character\":\"N0\"}", timeMs: 20) + buffer.append("STA {\"character\":\"N1\",\"status\":\"away\",\"statusmsg\":\"\"}", timeMs: 21) + buffer.append("TPN {\"character\":\"P\",\"status\":\"typing\"}", timeMs: 22) + buffer.append("MSG {\"channel\":\"c\",\"character\":\"X\",\"message\":\"m2\"}", timeMs: 23) + let (frames, needsResync) = buffer.drain() + XCTAssertTrue(needsResync) + XCTAssertEqual(frames.map { $0.timeMs }, [0, 1, 23]) + XCTAssertFalse(buffer.needsResync) + } + + func testOverflowWithoutPresenceDropsOldestButKeepsPMs() { + var buffer = FrameBuffer(cap: 10) + buffer.append("PRI {\"character\":\"P\",\"message\":\"pm\"}", timeMs: 0) + for i in 1...10 { + buffer.append("MSG {\"channel\":\"c\",\"character\":\"X\",\"message\":\"\(i)\"}", timeMs: i) + } + XCTAssertEqual(buffer.count, 9) + XCTAssertEqual(buffer.drain().frames.map { $0.timeMs }, [0, 3, 4, 5, 6, 7, 8, 9, 10]) + } + + func testOverflowOfOnlyPMsStillBoundsMemory() { + var buffer = FrameBuffer(cap: 10) + for i in 0...10 { + buffer.append("PRI {\"character\":\"P\",\"message\":\"\(i)\"}", timeMs: i) + } + XCTAssertEqual(buffer.count, 9) + XCTAssertEqual(buffer.drain().frames.first?.timeMs, 2) + } +}