From 60ed8a9f02ebfed57cf6336b4b2d2014ec2c004f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 07:00:45 +0100 Subject: [PATCH] fix: expose RPC watch debounce --- CHANGELOG.md | 1 + Sources/imsg/RPCPayloads.swift | 11 +++++++++++ Sources/imsg/RPCServer+Handlers.swift | 6 +++++- Tests/imsgTests/RPCPayloadsTests.swift | 24 ++++++++++++++++++++++++ docs/rpc.md | 5 +++++ 5 files changed, 46 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 718721a..49e9fcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased - feat: add `imsg group` chat metadata lookup and group fields to `chats --json` (#88, thanks @mryanb) +- fix: expose RPC watch debounce and default it to 500ms to reduce outbound echo races (#72, #80) - fix: speed up chat listing by using `chat_message_join.message_date` when available (#76, thanks @tmad4000) - fix: speed up JSON history metadata lookups by batching attachments and reactions (#81, thanks @kacy) - docs: clarify stale Full Disk Access and Terminal.app troubleshooting (#28, #32, #33, #83) diff --git a/Sources/imsg/RPCPayloads.swift b/Sources/imsg/RPCPayloads.swift index 309d4b6..195531a 100644 --- a/Sources/imsg/RPCPayloads.swift +++ b/Sources/imsg/RPCPayloads.swift @@ -115,3 +115,14 @@ func stringArrayParam(_ value: Any?) -> [String] { } return [] } + +let defaultRPCWatchDebounceInterval: TimeInterval = 0.5 + +func watchDebounceIntervalParam(_ params: [String: Any]) throws -> TimeInterval { + let raw = params["debounce_ms"] ?? params["debounceMs"] + guard let raw else { return defaultRPCWatchDebounceInterval } + guard let milliseconds = intParam(raw), milliseconds >= 0 else { + throw RPCError.invalidParams("debounce_ms must be a non-negative integer") + } + return Double(milliseconds) / 1000 +} diff --git a/Sources/imsg/RPCServer+Handlers.swift b/Sources/imsg/RPCServer+Handlers.swift index d36d27f..41670bf 100644 --- a/Sources/imsg/RPCServer+Handlers.swift +++ b/Sources/imsg/RPCServer+Handlers.swift @@ -70,12 +70,16 @@ extension RPCServer { let endISO = stringParam(params["end"]) let includeAttachments = boolParam(params["attachments"]) ?? false let includeReactions = boolParam(params["include_reactions"]) ?? false + let debounceInterval = try watchDebounceIntervalParam(params) let filter = try MessageFilter.fromISO( participants: participants, startISO: startISO, endISO: endISO ) - let config = MessageWatcherConfiguration(includeReactions: includeReactions) + let config = MessageWatcherConfiguration( + debounceInterval: debounceInterval, + includeReactions: includeReactions + ) let subID = await subscriptions.allocateID() let localStore = store let localWatcher = watcher diff --git a/Tests/imsgTests/RPCPayloadsTests.swift b/Tests/imsgTests/RPCPayloadsTests.swift index 728f57c..d070b05 100644 --- a/Tests/imsgTests/RPCPayloadsTests.swift +++ b/Tests/imsgTests/RPCPayloadsTests.swift @@ -120,6 +120,30 @@ func messagePayloadOmitsEmptyReplyToGuid() throws { #expect(payload["guid"] as? String == "msg-guid-6") } +@Test +func watchDebounceIntervalDefaultsToHalfSecond() throws { + #expect(try watchDebounceIntervalParam([:]) == 0.5) +} + +@Test +func watchDebounceIntervalAcceptsSnakeAndCamelCaseMilliseconds() throws { + #expect(try watchDebounceIntervalParam(["debounce_ms": 750]) == 0.75) + #expect(try watchDebounceIntervalParam(["debounceMs": "125"]) == 0.125) +} + +@Test +func watchDebounceIntervalRejectsInvalidValues() { + do { + _ = try watchDebounceIntervalParam(["debounce_ms": -1]) + #expect(Bool(false)) + } catch let error as RPCError { + #expect(error.code == -32602) + #expect(error.data?.contains("debounce_ms") == true) + } catch { + #expect(Bool(false)) + } +} + @Test func paramParsingHelpers() { #expect(stringParam(123 as NSNumber) == "123") diff --git a/docs/rpc.md b/docs/rpc.md index 311d0cf..87028e3 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -38,11 +38,16 @@ Params: - `start` / `end` (ISO8601, optional) - `attachments` (bool, default false) - `include_reactions` (bool, default false) +- `debounce_ms` / `debounceMs` (int milliseconds, default 500) Result: - `{ "subscription": 1 }` Notifications: - `{"jsonrpc":"2.0","method":"message","params":{"subscription":1,"message":}}` +The RPC default debounce is intentionally higher than the CLI default so macOS +has time to settle follow-up writes such as `is_from_me` updates on outbound +messages. Clients that need lower latency can pass `debounce_ms`. + ### `watch.unsubscribe` Params: - `subscription` (int, required)