Skip to content

Commit 686a287

Browse files
authored
fix(cron): truncate failure alert error text on UTF-16 boundary (#97298)
* fix(cron): truncate failure alert error text on UTF-16 boundary * test(cron): add regression test for UTF-16 safe failure alert truncation Verify that emitFailureAlert does not produce dangling surrogates when truncating a cron failure error message with a non-BMP character (emoji) straddling the 200-code-unit truncation boundary. * fix(test): cover last code unit in surrogate scan The dangling surrogate check loop stopped at length-1, missing a high surrogate at the final position. Extend to i < alertText.length so charCodeAt(i+1) returns NaN for the last char, correctly failing the high-surrogate pair assertion. * fix(test): address lint issues in regression test - Use template literal instead of string concatenation - Add braces to type guard if-statement
1 parent c9d2edf commit 686a287

2 files changed

Lines changed: 62 additions & 1 deletion

File tree

src/cron/service.failure-alert.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,4 +589,64 @@ describe("CronService failure alerts", () => {
589589
cron.stop();
590590
await store.cleanup();
591591
});
592+
593+
it("truncates failure alert error text on UTF-16 code-point boundary", async () => {
594+
const store = await makeStorePath();
595+
const sendCronFailureAlert = vi.fn(async () => undefined);
596+
// 209 code units: emoji (surrogate pair) at positions 199-200 straddles the 200-unit boundary
597+
const longError = `${"x".repeat(199)}🎉trailing`;
598+
const runIsolatedAgentJob = vi.fn(async () => ({
599+
status: "error" as const,
600+
error: longError,
601+
}));
602+
603+
const cron = createFailureAlertCron({
604+
storePath: store.storePath,
605+
cronConfig: { failureAlert: { enabled: true, after: 1 } },
606+
runIsolatedAgentJob,
607+
sendCronFailureAlert,
608+
});
609+
610+
await cron.start();
611+
const job = await cron.add({
612+
name: "utf16 boundary job",
613+
enabled: true,
614+
schedule: { kind: "every", everyMs: 60_000 },
615+
sessionTarget: "isolated",
616+
wakeMode: "next-heartbeat",
617+
payload: { kind: "agentTurn", message: "ping" },
618+
delivery: { mode: "announce", channel: "telegram", to: "19098680" },
619+
});
620+
621+
await cron.run(job.id, "force");
622+
expect(sendCronFailureAlert).toHaveBeenCalledTimes(1);
623+
const alertText = alertCallArg(sendCronFailureAlert).text;
624+
expect(typeof alertText).toBe("string");
625+
if (typeof alertText !== "string") {
626+
throw new Error("expected failure alert text");
627+
}
628+
629+
// Verify no dangling surrogates in the truncated error text.
630+
// Must check every character including the last: a dangling high surrogate
631+
// at the final position would be missed by stopping at length-1.
632+
for (let i = 0; i < alertText.length; i++) {
633+
const cu = alertText.charCodeAt(i);
634+
if (cu >= 0xd800 && cu <= 0xdbff) {
635+
expect(alertText.charCodeAt(i + 1) >= 0xdc00 && alertText.charCodeAt(i + 1) <= 0xdfff).toBe(
636+
true,
637+
);
638+
}
639+
if (cu >= 0xdc00 && cu <= 0xdfff) {
640+
expect(
641+
i > 0 && alertText.charCodeAt(i - 1) >= 0xd800 && alertText.charCodeAt(i - 1) <= 0xdbff,
642+
).toBe(true);
643+
}
644+
}
645+
646+
// Verify the emoji was excluded (truncated at the safe boundary before it)
647+
expect(alertText).not.toContain("🎉");
648+
649+
cron.stop();
650+
await store.cleanup();
651+
});
592652
});

src/cron/service/failure-alerts.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/** Resolves and emits cron failure-alert notifications. */
22
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
33
import { resolveFailoverReasonFromError } from "../../agents/failover-error.js";
4+
import { truncateUtf16Safe } from "../../shared/utf16-slice.js";
45
import type { CronFailureNotificationDelivery, CronJob, CronMessageChannel } from "../types.js";
56
import type { CronServiceState } from "./state.js";
67

@@ -113,7 +114,7 @@ function emitFailureAlert(
113114
},
114115
) {
115116
const safeJobName = params.job.name || params.job.id;
116-
const truncatedError = (params.error?.trim() || "unknown reason").slice(0, 200);
117+
const truncatedError = truncateUtf16Safe(params.error?.trim() || "unknown reason", 200);
117118
const errorReason =
118119
params.status === "error" && typeof params.error === "string"
119120
? (resolveFailoverReasonFromError(params.error, params.provider) ?? undefined)

0 commit comments

Comments
 (0)