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) + } +}