fix(imessage): prevent duplicate messages after delayed sends (#110853)

* fix(imessage): outlive imsg send fallback

* test(imessage): prove delayed fallback resolves once

* fix(imessage): preserve the send timeout floor

---------

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
This commit is contained in:
Omar Shahine
2026-08-01 10:51:06 -07:00
committed by GitHub
parent 926afb66e9
commit b67fa6a2c4
3 changed files with 46 additions and 19 deletions

View File

@@ -1,11 +1,8 @@
/** Default timeout for iMessage probe/RPC operations (10 seconds). */
export const DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS = 10_000;
// Sends get a much longer default than probes: on macOS 26 (Tahoe) the private
// API bridge intermittently stalls up to ~124s before the send completes. The
// 10s probe timeout aborts those mid-flight, and non-recoverable shapes
// (attachment/reply) are then lost. This must clear the observed upper bound
// plus headroom, otherwise the long tail of stalls still loses sends — 150s
// covers 124s with margin. Decoupling keeps probes/health checks fast while
// letting real sends ride out the stall. Akin to the BlueBubbles fix (#69193).
export const DEFAULT_IMESSAGE_SEND_TIMEOUT_MS = 150_000;
// imsg waits up to 150s for a private-bridge send, then auto transport can fall
// back to AppleScript and spend up to 8s verifying the persisted row. The outer
// RPC timeout must outlive both stages; matching imsg's 150s deadline turns a
// successful fallback into an ambiguous timeout that callers may retry.
export const DEFAULT_IMESSAGE_SEND_TIMEOUT_MS = 180_000;

View File

@@ -1709,17 +1709,46 @@ describe("sendMessageIMessage receipts", () => {
);
});
it("uses the dedicated send timeout (covers macOS 26 stalls), not the 10s probe default", async () => {
const client = createClient({ guid: "p:0/imsg-1" });
it("floors a configured probe timeout so one delayed imsg fallback can resolve", async () => {
vi.useFakeTimers();
const delayedFallbackMs = 158_000;
const client = {
request: vi.fn(
(_method: string, _params: Record<string, unknown>, opts?: { timeoutMs?: number }) =>
new Promise<Record<string, unknown>>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("imsg rpc timeout (send)")),
opts?.timeoutMs,
);
setTimeout(() => {
clearTimeout(timeout);
resolve({ guid: "p:0/imsg-delayed-fallback" });
}, delayedFallbackMs);
}),
),
stop: vi.fn(async () => {}),
} as unknown as IMessageRpcClient;
await sendMessageIMessage("chat_id:42", "hello", {
config: IMESSAGE_TEST_CFG,
const send = sendMessageIMessage("chat_id:42", "hello", {
config: {
channels: {
imessage: {
...IMESSAGE_TEST_CFG.channels.imessage,
probeTimeoutMs: 10_000,
},
},
},
client,
});
await vi.advanceTimersByTimeAsync(delayedFallbackMs);
expect(getClientMocks(client).request).toHaveBeenCalledWith("send", expect.any(Object), {
timeoutMs: 150_000,
});
await expect(send).resolves.toMatchObject({ messageId: "p:0/imsg-delayed-fallback" });
expect(getClientMocks(client).request).toHaveBeenCalledTimes(1);
expect(getClientMocks(client).request).toHaveBeenCalledWith(
"send",
expect.any(Object),
expect.objectContaining({ timeoutMs: 180_000 }),
);
});
it("sends explicit chat media-only payloads through send-attachment auto transport", async () => {

View File

@@ -768,11 +768,12 @@ export async function sendMessageIMessage(
replyToId: opts.replyToId,
conversationReadOrigin: opts.conversationReadOrigin,
});
// Sends use a dedicated longer default (not the 10s probe timeout) so macOS 26
// bridge stalls aren't aborted mid-send. Explicit opts/probeTimeoutMs still win
// for callers that tuned them. See DEFAULT_IMESSAGE_SEND_TIMEOUT_MS.
// Sends use a dedicated longer floor (not the 10s probe timeout) so macOS 26
// bridge stalls aren't aborted mid-send. A configured probe timeout may extend
// sends, but only an explicit per-call timeout may shorten them.
const timeoutMs =
opts.timeoutMs ?? account.config.probeTimeoutMs ?? DEFAULT_IMESSAGE_SEND_TIMEOUT_MS;
opts.timeoutMs ??
Math.max(account.config.probeTimeoutMs ?? 0, DEFAULT_IMESSAGE_SEND_TIMEOUT_MS);
const pendingEchoTtlMs = resolvePendingPersistedEchoTtlMs(timeoutMs);
const region = opts.region?.trim() || account.config.region?.trim() || "US";
const maxBytes =