mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:28:38 +00:00
fix: harden feishu startup probe sequencing (#29941) (thanks @bmendonca3)
This commit is contained in:
@@ -267,7 +267,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Agents/Ollama discovery: skip Ollama discovery when explicit models are configured. (#28827) Thanks @Kansodata and @vincentkoc.
|
- Agents/Ollama discovery: skip Ollama discovery when explicit models are configured. (#28827) Thanks @Kansodata and @vincentkoc.
|
||||||
- Issues/triage labeling: consolidate bug intake to a single bug issue form with required bug-type classification (regression/crash/behavior), auto-apply matching subtype labels from issue form content, and retire the separate regression template to reduce misfiled issue types and improve queue filtering. Thanks @vincentkoc.
|
- Issues/triage labeling: consolidate bug intake to a single bug issue form with required bug-type classification (regression/crash/behavior), auto-apply matching subtype labels from issue form content, and retire the separate regression template to reduce misfiled issue types and improve queue filtering. Thanks @vincentkoc.
|
||||||
- Android/Onboarding + voice reliability: request per-toggle onboarding permissions, update pairing guidance to `openclaw devices list/approve`, restore assistant speech playback in mic capture flow, cancel superseded in-flight speech (mute + per-reply token rotation), and keep `talk.config` loads retryable after transient failures. (#29796) Thanks @obviyus.
|
- Android/Onboarding + voice reliability: request per-toggle onboarding permissions, update pairing guidance to `openclaw devices list/approve`, restore assistant speech playback in mic capture flow, cancel superseded in-flight speech (mute + per-reply token rotation), and keep `talk.config` loads retryable after transient failures. (#29796) Thanks @obviyus.
|
||||||
- Feishu/Startup probes: serialize multi-account bot-info probes during monitor startup so large Feishu account sets do not burst `/open-apis/bot/v3/info` and trigger avoidable rate limits. (#26685)
|
- Feishu/Startup probes: serialize multi-account bot-info probes during monitor startup so large Feishu account sets do not burst `/open-apis/bot/v3/info`, bound startup probe latency/abort handling to avoid head-of-line stalls, and avoid triggering rate limits. (#26685, #29941) Thanks @bmendonca3.
|
||||||
- FS/Sandbox workspace boundaries: add a dedicated `outside-workspace` safe-open error code for root-escape checks, and propagate specific outside-workspace messages across edit/browser/media consumers instead of generic not-found/invalid-path fallbacks. (#29715) Thanks @YuzuruS.
|
- FS/Sandbox workspace boundaries: add a dedicated `outside-workspace` safe-open error code for root-escape checks, and propagate specific outside-workspace messages across edit/browser/media consumers instead of generic not-found/invalid-path fallbacks. (#29715) Thanks @YuzuruS.
|
||||||
- Config/Doctor group allowlist diagnostics: align `groupPolicy: "allowlist"` warnings with per-channel runtime semantics by excluding Google Chat sender-list checks and by warning when no-fallback channels (for example iMessage) omit `groupAllowFrom`, with regression coverage. (#28477) Thanks @tonydehnke.
|
- Config/Doctor group allowlist diagnostics: align `groupPolicy: "allowlist"` warnings with per-channel runtime semantics by excluding Google Chat sender-list checks and by warning when no-fallback channels (for example iMessage) omit `groupAllowFrom`, with regression coverage. (#28477) Thanks @tonydehnke.
|
||||||
- Slack/Disabled channel startup: skip Slack monitor socket startup entirely when `channels.slack.enabled=false` (including configs that still contain valid tokens), preventing disabled accounts from opening websocket connections. (#30586) Thanks @liuxiaopai-ai.
|
- Slack/Disabled channel startup: skip Slack monitor socket startup entirely when `channels.slack.enabled=false` (including configs that still contain valid tokens), preventing disabled accounts from opening websocket connections. (#30586) Thanks @liuxiaopai-ai.
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ const botOpenIds = new Map<string, string>();
|
|||||||
const FEISHU_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
|
const FEISHU_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
|
||||||
const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
|
const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
|
||||||
const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500;
|
const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500;
|
||||||
|
const FEISHU_STARTUP_BOT_INFO_TIMEOUT_MS = 10_000;
|
||||||
|
const FEISHU_BOT_INFO_FETCH_ABORTED = Symbol("feishu-bot-info-fetch-aborted");
|
||||||
|
const FEISHU_BOT_INFO_FETCH_TIMED_OUT = Symbol("feishu-bot-info-fetch-timed-out");
|
||||||
|
|
||||||
export type FeishuReactionCreatedEvent = {
|
export type FeishuReactionCreatedEvent = {
|
||||||
message_id: string;
|
message_id: string;
|
||||||
@@ -188,12 +191,68 @@ export async function resolveReactionSyntheticEvent(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string | undefined> {
|
type FetchBotOpenIdOptions = {
|
||||||
|
runtime?: RuntimeEnv;
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchBotOpenId(
|
||||||
|
account: ResolvedFeishuAccount,
|
||||||
|
options: FetchBotOpenIdOptions = {},
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (options.abortSignal?.aborted) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = options.timeoutMs ?? FEISHU_STARTUP_BOT_INFO_TIMEOUT_MS;
|
||||||
|
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let abortHandler: (() => void) | undefined;
|
||||||
try {
|
try {
|
||||||
const result = await probeFeishu(account);
|
const contenders: Array<
|
||||||
return result.ok ? result.botOpenId : undefined;
|
Promise<
|
||||||
|
| string
|
||||||
|
| undefined
|
||||||
|
| typeof FEISHU_BOT_INFO_FETCH_ABORTED
|
||||||
|
| typeof FEISHU_BOT_INFO_FETCH_TIMED_OUT
|
||||||
|
>
|
||||||
|
> = [
|
||||||
|
probeFeishu(account)
|
||||||
|
.then((result) => (result.ok ? result.botOpenId : undefined))
|
||||||
|
.catch(() => undefined),
|
||||||
|
new Promise((resolve) => {
|
||||||
|
timeoutHandle = setTimeout(() => resolve(FEISHU_BOT_INFO_FETCH_TIMED_OUT), timeoutMs);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
if (options.abortSignal) {
|
||||||
|
contenders.push(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
abortHandler = () => resolve(FEISHU_BOT_INFO_FETCH_ABORTED);
|
||||||
|
options.abortSignal?.addEventListener("abort", abortHandler, { once: true });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const outcome = await Promise.race(contenders);
|
||||||
|
if (outcome === FEISHU_BOT_INFO_FETCH_ABORTED) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (outcome === FEISHU_BOT_INFO_FETCH_TIMED_OUT) {
|
||||||
|
const error = options.runtime?.error ?? console.error;
|
||||||
|
error(
|
||||||
|
`feishu[${account.accountId}]: bot info probe timed out after ${timeoutMs}ms; continuing startup`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return outcome;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
} finally {
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
|
if (abortHandler) {
|
||||||
|
options.abortSignal?.removeEventListener("abort", abortHandler);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +406,9 @@ async function monitorSingleAccount(params: MonitorAccountParams): Promise<void>
|
|||||||
const log = runtime?.log ?? console.log;
|
const log = runtime?.log ?? console.log;
|
||||||
|
|
||||||
// Fetch bot open_id
|
// Fetch bot open_id
|
||||||
const botOpenId = params.botOpenIdPrefetched ? params.botOpenId : await fetchBotOpenId(account);
|
const botOpenId = params.botOpenIdPrefetched
|
||||||
|
? params.botOpenId
|
||||||
|
: await fetchBotOpenId(account, { runtime, abortSignal });
|
||||||
botOpenIds.set(accountId, botOpenId ?? "");
|
botOpenIds.set(accountId, botOpenId ?? "");
|
||||||
log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
|
log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
|
||||||
|
|
||||||
@@ -550,8 +611,19 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
|
|||||||
|
|
||||||
const monitorPromises: Promise<void>[] = [];
|
const monitorPromises: Promise<void>[] = [];
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
|
if (opts.abortSignal?.aborted) {
|
||||||
|
log("feishu: abort signal received during startup preflight; stopping startup");
|
||||||
|
break;
|
||||||
|
}
|
||||||
// Probe sequentially so large multi-account startups do not burst Feishu's bot-info endpoint.
|
// Probe sequentially so large multi-account startups do not burst Feishu's bot-info endpoint.
|
||||||
const botOpenId = await fetchBotOpenId(account);
|
const botOpenId = await fetchBotOpenId(account, {
|
||||||
|
runtime: opts.runtime,
|
||||||
|
abortSignal: opts.abortSignal,
|
||||||
|
});
|
||||||
|
if (opts.abortSignal?.aborted) {
|
||||||
|
log("feishu: abort signal received during startup preflight; stopping startup");
|
||||||
|
break;
|
||||||
|
}
|
||||||
monitorPromises.push(
|
monitorPromises.push(
|
||||||
monitorSingleAccount({
|
monitorSingleAccount({
|
||||||
cfg,
|
cfg,
|
||||||
|
|||||||
@@ -287,9 +287,9 @@ describe("Feishu webhook security hardening", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.resolve();
|
for (let i = 0; i < 10 && !started.includes("beta"); i += 1) {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
}
|
||||||
|
|
||||||
expect(started).toEqual(["alpha", "beta"]);
|
expect(started).toEqual(["alpha", "beta"]);
|
||||||
expect(started.filter((accountId) => accountId === "alpha")).toHaveLength(1);
|
expect(started.filter((accountId) => accountId === "alpha")).toHaveLength(1);
|
||||||
@@ -299,4 +299,73 @@ describe("Feishu webhook security hardening", () => {
|
|||||||
await monitorPromise;
|
await monitorPromise;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("continues startup when a sequential preflight probe times out", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const started: string[] = [];
|
||||||
|
let releaseBetaProbe!: () => void;
|
||||||
|
const betaProbeReleased = new Promise<void>((resolve) => {
|
||||||
|
releaseBetaProbe = () => resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
probeFeishuMock.mockImplementation((account: { accountId: string }) => {
|
||||||
|
started.push(account.accountId);
|
||||||
|
if (account.accountId === "alpha") {
|
||||||
|
return new Promise<never>(() => {});
|
||||||
|
}
|
||||||
|
return betaProbeReleased.then(() => ({ ok: true, botOpenId: `bot_${account.accountId}` }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||||
|
const monitorPromise = monitorFeishuProvider({
|
||||||
|
config: buildMultiAccountWebsocketConfig(["alpha", "beta"]),
|
||||||
|
runtime,
|
||||||
|
abortSignal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(started).toEqual(["alpha"]);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(started).toEqual(["alpha", "beta"]);
|
||||||
|
expect(runtime.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("bot info probe timed out"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
releaseBetaProbe();
|
||||||
|
abortController.abort();
|
||||||
|
await monitorPromise;
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops sequential preflight when aborted during a stuck probe", async () => {
|
||||||
|
const started: string[] = [];
|
||||||
|
probeFeishuMock.mockImplementation((account: { accountId: string }) => {
|
||||||
|
started.push(account.accountId);
|
||||||
|
return new Promise<never>(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const monitorPromise = monitorFeishuProvider({
|
||||||
|
config: buildMultiAccountWebsocketConfig(["alpha", "beta"]),
|
||||||
|
abortSignal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(started).toEqual(["alpha"]);
|
||||||
|
|
||||||
|
abortController.abort();
|
||||||
|
await monitorPromise;
|
||||||
|
|
||||||
|
expect(started).toEqual(["alpha"]);
|
||||||
|
} finally {
|
||||||
|
abortController.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ vi.mock("./client.js", () => ({
|
|||||||
createFeishuClient: createFeishuClientMock,
|
createFeishuClient: createFeishuClientMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { probeFeishu, clearProbeCache } from "./probe.js";
|
import { FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } from "./probe.js";
|
||||||
|
|
||||||
function makeRequestFn(response: Record<string, unknown>) {
|
function makeRequestFn(response: Record<string, unknown>) {
|
||||||
return vi.fn().mockResolvedValue(response);
|
return vi.fn().mockResolvedValue(response);
|
||||||
@@ -59,6 +59,23 @@ describe("probeFeishu", () => {
|
|||||||
expect(requestFn).toHaveBeenCalledTimes(1);
|
expect(requestFn).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses explicit timeout for bot info request", async () => {
|
||||||
|
const requestFn = setupClient({
|
||||||
|
code: 0,
|
||||||
|
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await probeFeishu({ appId: "cli_123", appSecret: "secret" });
|
||||||
|
|
||||||
|
expect(requestFn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "GET",
|
||||||
|
url: "/open-apis/bot/v3/info",
|
||||||
|
timeout: FEISHU_PROBE_REQUEST_TIMEOUT_MS,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns cached result on subsequent calls within TTL", async () => {
|
it("returns cached result on subsequent calls within TTL", async () => {
|
||||||
const requestFn = setupClient({
|
const requestFn = setupClient({
|
||||||
code: 0,
|
code: 0,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { FeishuProbeResult } from "./types.js";
|
|||||||
const probeCache = new Map<string, { result: FeishuProbeResult; expiresAt: number }>();
|
const probeCache = new Map<string, { result: FeishuProbeResult; expiresAt: number }>();
|
||||||
const PROBE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
const PROBE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
const MAX_PROBE_CACHE_SIZE = 64;
|
const MAX_PROBE_CACHE_SIZE = 64;
|
||||||
|
export const FEISHU_PROBE_REQUEST_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
export async function probeFeishu(creds?: FeishuClientCredentials): Promise<FeishuProbeResult> {
|
export async function probeFeishu(creds?: FeishuClientCredentials): Promise<FeishuProbeResult> {
|
||||||
if (!creds?.appId || !creds?.appSecret) {
|
if (!creds?.appId || !creds?.appSecret) {
|
||||||
@@ -35,6 +36,7 @@ export async function probeFeishu(creds?: FeishuClientCredentials): Promise<Feis
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
url: "/open-apis/bot/v3/info",
|
url: "/open-apis/bot/v3/info",
|
||||||
data: {},
|
data: {},
|
||||||
|
timeout: FEISHU_PROBE_REQUEST_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.code !== 0) {
|
if (response.code !== 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user