diff --git a/extensions/imessage/src/constants.ts b/extensions/imessage/src/constants.ts index cc3035aa3289..d36cbb05d65b 100644 --- a/extensions/imessage/src/constants.ts +++ b/extensions/imessage/src/constants.ts @@ -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; diff --git a/extensions/imessage/src/send.test.ts b/extensions/imessage/src/send.test.ts index 37b5979a47c8..42df916e1efd 100644 --- a/extensions/imessage/src/send.test.ts +++ b/extensions/imessage/src/send.test.ts @@ -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, opts?: { timeoutMs?: number }) => + new Promise>((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 () => { diff --git a/extensions/imessage/src/send.ts b/extensions/imessage/src/send.ts index a69d2a178627..e90ee9245692 100644 --- a/extensions/imessage/src/send.ts +++ b/extensions/imessage/src/send.ts @@ -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 =