Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 49 additions & 36 deletions mobile/NativeSocketConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
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<void> => this.close();
this.lastHandler = this.lastHandler.then(resync, resync);
break;
}
case 'close': {
this.state = WebSocketConnection.ReadyState.CLOSED;
const c = (payload as NativeClose) || {
Expand Down Expand Up @@ -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<void> => {
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;
}
Expand Down Expand Up @@ -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);
98 changes: 98 additions & 0 deletions mobile/ios/Solstice/Bridge/FrameBuffer.swift
Original file line number Diff line number Diff line change
@@ -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<String> = ["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 "<CMD> <json>" 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
}
}
33 changes: 18 additions & 15 deletions mobile/ios/Solstice/Bridge/NativeSocket.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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..<min(start + Self.replayBatch, frames.count)]
let items = batch.map { "[\(WebViewController.jsString($0.text)),\($0.timeMs)]" }
emit("batch", "[\(items.joined(separator: ","))]")
}
// The buffer overflowed and dropped presence, so the online list is stale. The WebView
// reconnects once it has handled the backlog, and the fresh login resends the full list.
if needsResync && task != nil && !closeEmitted { emit("resync", "undefined") }
}

private func closeSocket() {
Expand Down Expand Up @@ -337,8 +341,7 @@ final class NativeSocket: NSObject, WKScriptMessageHandlerWithReply, URLSessionW
emit("close", payload)
}

private func emit(_ type: String, _ payloadJS: String, _ extraJS: String? = nil) {
let extra = extraJS.map { ", \($0)" } ?? ""
host?.evalJS("window.__nativeSocketEvent && window.__nativeSocketEvent('\(type)', \(payloadJS)\(extra))")
private func emit(_ type: String, _ payloadJS: String) {
host?.evalJS("window.__nativeSocketEvent && window.__nativeSocketEvent('\(type)', \(payloadJS))")
}
}
98 changes: 98 additions & 0 deletions mobile/ios/SolsticeTests/FrameBufferTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import XCTest
@testable import Solstice

// Covers the background frame buffer NativeSocket replays on resume (issue #32): nothing is lost
// silently, status/typing are coalesced per character, and an overflow asks for a resync.
final class FrameBufferTests: XCTestCase {
private func texts(_ frames: [FrameBuffer.Frame]) -> [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)
}
}
Loading