Skip to content

Commit b14c78f

Browse files
fix(telegram): keep timed-out lanes guarded
1 parent f30bbd8 commit b14c78f

2 files changed

Lines changed: 23 additions & 28 deletions

File tree

extensions/telegram/src/polling-session.test.ts

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2803,7 +2803,7 @@ describe("TelegramPollingSession", () => {
28032803
}
28042804
});
28052805

2806-
it("releases a timed-out lane when the old handler ignores reply abort", async () => {
2806+
it("keeps a timed-out lane guarded when the old handler ignores reply abort", async () => {
28072807
vi.useFakeTimers({ shouldAdvanceTime: true });
28082808
const abort = new AbortController();
28092809
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-spool-"));
@@ -2813,31 +2813,23 @@ describe("TelegramPollingSession", () => {
28132813
const firstTurnDone = new Promise<void>((resolve) => {
28142814
releaseFirstTurn = resolve;
28152815
});
2816-
const firstBot = {
2817-
api: {
2818-
deleteWebhook: vi.fn(async () => true),
2819-
config: { use: vi.fn() },
2820-
},
2821-
init: vi.fn(async () => undefined),
2822-
handleUpdate: vi.fn(async (update: { update_id?: number }) => {
2823-
events.push(`first:${update.update_id}`);
2824-
await firstTurnDone;
2825-
}),
2826-
stop: vi.fn(async () => undefined),
2827-
};
2828-
const secondBot = {
2816+
const bot = {
28292817
api: {
28302818
deleteWebhook: vi.fn(async () => true),
28312819
config: { use: vi.fn() },
28322820
},
28332821
init: vi.fn(async () => undefined),
28342822
handleUpdate: vi.fn(async (update: { update_id?: number }) => {
2835-
events.push(`second:${update.update_id}`);
2836-
abort.abort();
2823+
events.push(`handled:${update.update_id}`);
2824+
if (update.update_id === 42) {
2825+
await firstTurnDone;
2826+
} else {
2827+
abort.abort();
2828+
}
28372829
}),
28382830
stop: vi.fn(async () => undefined),
28392831
};
2840-
createTelegramBotMock.mockReturnValueOnce(firstBot).mockReturnValueOnce(secondBot);
2832+
createTelegramBotMock.mockReturnValue(bot);
28412833
await writeSpooledTestUpdates(tempDir, [
28422834
topicUpdate(42, 10, "wedged topic 10 turn"),
28432835
topicUpdate(43, 10, "later topic 10 turn"),
@@ -2859,23 +2851,25 @@ describe("TelegramPollingSession", () => {
28592851

28602852
try {
28612853
const runPromise = session.runUntilAbort();
2862-
await vi.waitFor(() => expect(events).toEqual(["first:42"]));
2854+
await vi.waitFor(() => expect(events).toEqual(["handled:42"]));
28632855

28642856
await vi.advanceTimersByTimeAsync(250);
28652857
await vi.waitFor(() => expectLogIncludes(log, "did not stop within 100ms"));
28662858
await vi.advanceTimersByTimeAsync(500);
2867-
await vi.waitFor(() => expect(worker.createWorker).toHaveBeenCalledTimes(2));
2868-
await vi.waitFor(() => expect(events).toEqual(["first:42", "second:43"]));
28692859

28702860
expect(await failedUpdateIds(tempDir)).toEqual([42]);
2871-
expect(await pendingUpdateIds(tempDir, "all")).toEqual([]);
2861+
expect(events).toEqual(["handled:42"]);
2862+
expect(await pendingUpdateIds(tempDir, "all")).toEqual([43]);
2863+
expect(worker.createWorker).toHaveBeenCalledTimes(1);
28722864

2865+
abort.abort();
28732866
releaseFirstTurn?.();
28742867
await vi.advanceTimersByTimeAsync(20_000);
28752868
await runPromise;
28762869

2877-
expect(firstBot.stop).toHaveBeenCalledTimes(1);
2878-
expect(secondBot.stop).toHaveBeenCalledTimes(1);
2870+
expect(events).toEqual(["handled:42"]);
2871+
expect(await pendingUpdateIds(tempDir, "all")).toEqual([43]);
2872+
expect(bot.stop).toHaveBeenCalledTimes(1);
28792873
} finally {
28802874
releaseFirstTurn?.();
28812875
abort.abort();

extensions/telegram/src/polling-session.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -875,7 +875,7 @@ export class TelegramPollingSession {
875875
}
876876
const age = formatDurationPrecise(timedOutHandler.ageMs);
877877
activeHandler.timedOutAt = Date.now();
878-
const message = `Telegram isolated polling spool handler timed out behind update ${handler.updateId} on lane ${handler.laneKey} after ${age}; marking the update failed, aborting active reply work, and restarting isolated ingress so later updates can drain.`;
878+
const message = `Telegram isolated polling spool handler timed out behind update ${handler.updateId} on lane ${handler.laneKey} after ${age}; marking the update failed, aborting active reply work, and waiting for the handler to stop before releasing the lane.`;
879879
activeHandler.timeoutMessage = message;
880880
try {
881881
const failed = await failTelegramSpooledUpdateClaim({
@@ -916,13 +916,14 @@ export class TelegramPollingSession {
916916
!handlerStopped &&
917917
activeSpooledUpdateHandlersByLane.get(handler.handlerKey) === activeHandler
918918
) {
919+
// Reply fences can cancel OpenClaw-owned send work, but they cannot kill
920+
// arbitrary handler code. Keep the lane guarded until the handler settles
921+
// so same-lane updates never run concurrently.
919922
this.opts.log(
920-
`[telegram][diag] timed out spooled update ${handler.updateId} did not stop within ${formatDurationPrecise(this.#spooledUpdateHandlerAbortGraceMs)} after reply abort; releasing lane ${handler.laneKey} and restarting isolated ingress so later updates can drain.`,
923+
`[telegram][diag] timed out spooled update ${handler.updateId} did not stop within ${formatDurationPrecise(this.#spooledUpdateHandlerAbortGraceMs)} after reply abort; keeping lane ${handler.laneKey} guarded until the handler stops.`,
921924
);
922-
activeSpooledUpdateHandlersByLane.delete(handler.handlerKey);
923-
this.#spooledUpdateHandlerKeys.delete(handler.handlerKey);
924925
this.#status.notePollingError(message);
925-
return { handlerKey: handler.handlerKey, restart: true };
926+
return { handlerKey: handler.handlerKey, restart: false };
926927
}
927928
if (activeSpooledUpdateHandlersByLane.get(handler.handlerKey) === activeHandler) {
928929
activeSpooledUpdateHandlersByLane.delete(handler.handlerKey);

0 commit comments

Comments
 (0)