fix: bound signal/imessage transport readiness waits

Co-authored-by: Szpadel <1857251+Szpadel@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-16 20:33:04 +00:00
parent 0cd24137e8
commit aaa310c047
9 changed files with 172 additions and 26 deletions

View File

@@ -52,8 +52,9 @@
### Fixes
- WhatsApp: default response prefix only for self-chat, using identity name when set.
- Auth: merge main auth profiles into per-agent stores for sub-agents and document inheritance. (#1013) — thanks @marcmarg.
- Agents: avoid JSON Schema `format` collisions in tool params by renaming snapshot format fields. (#1013) — thanks @marcmarg.
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
- Auth: merge main auth profiles into per-agent stores for sub-agents and document inheritance. (#1013) — thanks @marcmarg.
- Agents: avoid JSON Schema `format` collisions in tool params by renaming snapshot format fields. (#1013) — thanks @marcmarg.
- Fix: make `clawdbot update` auto-update global installs when installed via a package manager.
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.

View File

@@ -493,5 +493,5 @@ Thanks to all clawtributors:
<a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a>
<a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
<a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/Szpadel"><img src="https://avatars.githubusercontent.com/u/1857251?v=4&s=48" width="48" height="48" alt="Szpadel" title="Szpadel"/></a>
</p>

View File

@@ -54,6 +54,10 @@ vi.mock("./client.js", () => ({
}),
}));
vi.mock("./probe.js", () => ({
probeIMessage: vi.fn(async () => ({ ok: true })),
}));
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
async function waitForSubscribe() {

View File

@@ -54,6 +54,10 @@ vi.mock("./client.js", () => ({
}),
}));
vi.mock("./probe.js", () => ({
probeIMessage: vi.fn(async () => ({ ok: true })),
}));
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
async function waitForSubscribe() {

View File

@@ -30,6 +30,7 @@ import {
} from "../../config/group-policy.js";
import { resolveStorePath, updateLastRoute } from "../../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { waitForTransportReady } from "../../infra/transport-ready.js";
import { mediaKindFromMime } from "../../media/constants.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
@@ -40,6 +41,7 @@ import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { truncateUtf16Safe } from "../../utils.js";
import { resolveIMessageAccount } from "../accounts.js";
import { createIMessageRpcClient } from "../client.js";
import { probeIMessage } from "../probe.js";
import { sendMessageIMessage } from "../send.js";
import {
formatIMessageChatTarget,
@@ -76,6 +78,8 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
const dmPolicy = imessageCfg.dmPolicy ?? "pairing";
const includeAttachments = opts.includeAttachments ?? imessageCfg.includeAttachments ?? false;
const mediaMaxBytes = (opts.mediaMaxMb ?? imessageCfg.mediaMaxMb ?? 16) * 1024 * 1024;
const cliPath = opts.cliPath ?? imessageCfg.cliPath ?? "imsg";
const dbPath = opts.dbPath ?? imessageCfg.dbPath;
const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "imessage" });
const inboundDebouncer = createInboundDebouncer<{ message: IMessagePayload }>({
@@ -453,9 +457,26 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
await inboundDebouncer.enqueue({ message });
};
await waitForTransportReady({
label: "imsg rpc",
timeoutMs: 30_000,
logAfterMs: 10_000,
logIntervalMs: 10_000,
pollIntervalMs: 500,
abortSignal: opts.abortSignal,
runtime,
check: async () => {
const probe = await probeIMessage(2000, { cliPath, dbPath, runtime });
if (probe.ok) return { ok: true };
return { ok: false, error: probe.error ?? "unreachable" };
},
});
if (opts.abortSignal?.aborted) return;
const client = await createIMessageRpcClient({
cliPath: opts.cliPath ?? imessageCfg.cliPath,
dbPath: opts.dbPath ?? imessageCfg.dbPath,
cliPath,
dbPath,
runtime,
onNotification: (msg) => {
if (msg.method === "message") {

View File

@@ -1,5 +1,6 @@
import { detectBinary } from "../commands/onboard-helpers.js";
import { loadConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import { createIMessageRpcClient } from "./client.js";
export type IMessageProbe = {
@@ -7,10 +8,19 @@ export type IMessageProbe = {
error?: string | null;
};
export async function probeIMessage(timeoutMs = 2000): Promise<IMessageProbe> {
const cfg = loadConfig();
const cliPath = cfg.channels?.imessage?.cliPath?.trim() || "imsg";
const dbPath = cfg.channels?.imessage?.dbPath?.trim();
export type IMessageProbeOptions = {
cliPath?: string;
dbPath?: string;
runtime?: RuntimeEnv;
};
export async function probeIMessage(
timeoutMs = 2000,
opts: IMessageProbeOptions = {},
): Promise<IMessageProbe> {
const cfg = opts.cliPath || opts.dbPath ? undefined : loadConfig();
const cliPath = opts.cliPath?.trim() || cfg?.channels?.imessage?.cliPath?.trim() || "imsg";
const dbPath = opts.dbPath?.trim() || cfg?.channels?.imessage?.dbPath?.trim();
const detected = await detectBinary(cliPath);
if (!detected) {
return { ok: false, error: `imsg not found (${cliPath})` };
@@ -19,6 +29,7 @@ export async function probeIMessage(timeoutMs = 2000): Promise<IMessageProbe> {
const client = await createIMessageRpcClient({
cliPath,
dbPath,
runtime: opts.runtime,
});
try {
await client.request("chats.list", { limit: 1 }, { timeoutMs });

View File

@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from "vitest";
import { waitForTransportReady } from "./transport-ready.js";
describe("waitForTransportReady", () => {
it("returns when the check succeeds and logs after the delay", async () => {
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
let attempts = 0;
await waitForTransportReady({
label: "test transport",
timeoutMs: 500,
logAfterMs: 100,
logIntervalMs: 100,
pollIntervalMs: 50,
runtime,
check: async () => {
attempts += 1;
if (attempts > 3) return { ok: true };
return { ok: false, error: "not ready" };
},
});
expect(runtime.error).toHaveBeenCalled();
});
it("throws after the timeout", async () => {
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
await expect(
waitForTransportReady({
label: "test transport",
timeoutMs: 200,
logAfterMs: 0,
logIntervalMs: 100,
pollIntervalMs: 50,
runtime,
check: async () => ({ ok: false, error: "still down" }),
}),
).rejects.toThrow("test transport not ready");
expect(runtime.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,59 @@
import { danger } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { sleepWithAbort } from "./backoff.js";
export type TransportReadyResult = {
ok: boolean;
error?: string | null;
};
export type WaitForTransportReadyParams = {
label: string;
timeoutMs: number;
logAfterMs?: number;
logIntervalMs?: number;
pollIntervalMs?: number;
abortSignal?: AbortSignal;
runtime: RuntimeEnv;
check: () => Promise<TransportReadyResult>;
};
export async function waitForTransportReady(params: WaitForTransportReadyParams): Promise<void> {
const started = Date.now();
const timeoutMs = Math.max(0, params.timeoutMs);
const deadline = started + timeoutMs;
const logAfterMs = Math.max(0, params.logAfterMs ?? timeoutMs);
const logIntervalMs = Math.max(1_000, params.logIntervalMs ?? 30_000);
const pollIntervalMs = Math.max(50, params.pollIntervalMs ?? 150);
let nextLogAt = started + logAfterMs;
let lastError: string | null = null;
while (true) {
if (params.abortSignal?.aborted) return;
const res = await params.check();
if (res.ok) return;
lastError = res.error ?? null;
const now = Date.now();
if (now >= deadline) break;
if (now >= nextLogAt) {
const elapsedMs = now - started;
params.runtime.error?.(
danger(`${params.label} not ready after ${elapsedMs}ms (${lastError ?? "unknown error"})`),
);
nextLogAt = now + logIntervalMs;
}
try {
await sleepWithAbort(pollIntervalMs, params.abortSignal);
} catch (err) {
if (params.abortSignal?.aborted) return;
throw err;
}
}
params.runtime.error?.(
danger(`${params.label} not ready after ${timeoutMs}ms (${lastError ?? "unknown error"})`),
);
throw new Error(`${params.label} not ready (${lastError ?? "unknown error"})`);
}

View File

@@ -4,10 +4,10 @@ import type { ReplyPayload } from "../auto-reply/types.js";
import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import type { SignalReactionNotificationMode } from "../config/types.js";
import { danger } from "../globals.js";
import { saveMediaBuffer } from "../media/store.js";
import type { RuntimeEnv } from "../runtime.js";
import { normalizeE164 } from "../utils.js";
import { waitForTransportReady } from "../infra/transport-ready.js";
import { resolveSignalAccount } from "./accounts.js";
import { signalCheck, signalRpcRequest } from "./client.js";
import { spawnSignalDaemon } from "./daemon.js";
@@ -145,23 +145,27 @@ async function waitForSignalDaemonReady(params: {
baseUrl: string;
abortSignal?: AbortSignal;
timeoutMs: number;
logAfterMs: number;
logIntervalMs?: number;
runtime: RuntimeEnv;
}): Promise<void> {
const started = Date.now();
let lastError: string | null = null;
while (Date.now() - started < params.timeoutMs) {
if (params.abortSignal?.aborted) return;
const res = await signalCheck(params.baseUrl, 1000);
if (res.ok) return;
lastError = res.error ?? (res.status ? `HTTP ${res.status}` : "unreachable");
await new Promise((r) => setTimeout(r, 150));
}
params.runtime.error?.(
danger(`daemon not ready after ${params.timeoutMs}ms (${lastError ?? "unknown error"})`),
);
throw new Error(`signal daemon not ready (${lastError ?? "unknown error"})`);
await waitForTransportReady({
label: "signal daemon",
timeoutMs: params.timeoutMs,
logAfterMs: params.logAfterMs,
logIntervalMs: params.logIntervalMs,
pollIntervalMs: 150,
abortSignal: params.abortSignal,
runtime: params.runtime,
check: async () => {
const res = await signalCheck(params.baseUrl, 1000);
if (res.ok) return { ok: true };
return {
ok: false,
error: res.error ?? (res.status ? `HTTP ${res.status}` : "unreachable"),
};
},
});
}
async function fetchAttachment(params: {
@@ -305,7 +309,9 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
await waitForSignalDaemonReady({
baseUrl,
abortSignal: opts.abortSignal,
timeoutMs: 10_000,
timeoutMs: 30_000,
logAfterMs: 10_000,
logIntervalMs: 10_000,
runtime,
});
}