Skip to content

Commit d31d6b4

Browse files
committed
fix(telegram): narrow polling keepalive fix
1 parent d0348b6 commit d31d6b4

7 files changed

Lines changed: 23 additions & 167 deletions

File tree

extensions/telegram/src/fetch.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,11 @@ function expectStickyAutoSelectDispatcher(
277277
expect(options?.autoSelectFamilyAttemptTimeout).toBe(300);
278278
}
279279

280+
function expectTelegramKeepAliveOptions(options: Record<string, unknown> | undefined): void {
281+
expect(options?.keepAlive).toBe(true);
282+
expect(options?.keepAliveInitialDelay).toBe(30_000);
283+
}
284+
280285
function expectHttp1OnlyDispatcher(
281286
dispatcher:
282287
| {
@@ -420,6 +425,7 @@ describe("resolveTelegramFetch", () => {
420425
expectHttp1OnlyDispatcher(dispatcher);
421426
expect(dispatcher?.options?.connect?.autoSelectFamily).toBe(true);
422427
expect(dispatcher?.options?.connect?.autoSelectFamilyAttemptTimeout).toBe(300);
428+
expectTelegramKeepAliveOptions(dispatcher?.options?.connect);
423429
expect(typeof dispatcher?.options?.connect?.lookup).toBe("function");
424430
});
425431

@@ -456,8 +462,10 @@ describe("resolveTelegramFetch", () => {
456462
expectHttp1OnlyDispatcher(dispatcher);
457463
expect(dispatcher?.options?.connect?.autoSelectFamily).toBe(false);
458464
expect(dispatcher?.options?.connect?.autoSelectFamilyAttemptTimeout).toBe(300);
465+
expectTelegramKeepAliveOptions(dispatcher?.options?.connect);
459466
expect(dispatcher?.options?.proxyTls?.autoSelectFamily).toBe(false);
460467
expect(dispatcher?.options?.proxyTls?.autoSelectFamilyAttemptTimeout).toBe(300);
468+
expectTelegramKeepAliveOptions(dispatcher?.options?.proxyTls);
461469
});
462470

463471
it("adds managed proxy CA trust to Telegram env proxy dispatchers", async () => {

extensions/telegram/src/fetch.ts

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ function buildTelegramConnectOptions(params: {
171171
keepAlive?: boolean;
172172
keepAliveInitialDelay?: number;
173173
lookup?: LookupFunction;
174-
} | null {
174+
} {
175175
const connect: {
176176
autoSelectFamily?: boolean;
177177
autoSelectFamilyAttemptTimeout?: number;
@@ -260,34 +260,29 @@ function resolveTelegramDispatcherPolicy(params: {
260260
const explicitProxyUrl = params.proxyUrl?.trim();
261261
if (explicitProxyUrl) {
262262
return {
263-
policy: connect
264-
? {
265-
mode: "explicit-proxy",
266-
proxyUrl: explicitProxyUrl,
267-
allowPrivateProxy: true,
268-
proxyTls: { ...connect },
269-
}
270-
: {
271-
mode: "explicit-proxy",
272-
proxyUrl: explicitProxyUrl,
273-
allowPrivateProxy: true,
274-
},
263+
policy: {
264+
mode: "explicit-proxy",
265+
proxyUrl: explicitProxyUrl,
266+
allowPrivateProxy: true,
267+
proxyTls: { ...connect },
268+
},
275269
mode: "explicit-proxy",
276270
};
277271
}
278272
if (params.useEnvProxy) {
279273
return {
280274
policy: {
281275
mode: "env-proxy",
282-
...(connect ? { connect: { ...connect }, proxyTls: { ...connect } } : {}),
276+
connect: { ...connect },
277+
proxyTls: { ...connect },
283278
},
284279
mode: "env-proxy",
285280
};
286281
}
287282
return {
288283
policy: {
289284
mode: "direct",
290-
...(connect ? { connect: { ...connect } } : {}),
285+
connect: { ...connect },
291286
},
292287
mode: "direct",
293288
};

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

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2742,7 +2742,7 @@ describe("TelegramPollingSession", () => {
27422742
await runPromise;
27432743
});
27442744

2745-
it("does not clear polling connected status during recoverable restart cycles", async () => {
2745+
it("keeps polling marked connected across recoverable restart cycles", async () => {
27462746
const abort = new AbortController();
27472747
const recoverableError = new Error("recoverable polling error");
27482748
const setStatus = vi.fn();
@@ -2804,30 +2804,14 @@ describe("TelegramPollingSession", () => {
28042804
(patch) => patch.connected === false,
28052805
);
28062806
expect(disconnectedPatches).toHaveLength(2);
2807-
expect(disconnectedPatches[0]).toEqual({
2808-
mode: "polling",
2809-
connected: false,
2810-
lastConnectedAt: null,
2811-
lastEventAt: null,
2812-
lastTransportActivityAt: null,
2813-
});
2807+
expect(disconnectedPatches[0]?.mode).toBe("polling");
2808+
expect(disconnectedPatches[0]?.lastConnectedAt).toBeNull();
2809+
expect(disconnectedPatches[0]?.lastEventAt).toBeNull();
2810+
expect(disconnectedPatches[0]?.lastTransportActivityAt).toBeNull();
28142811
expect(disconnectedPatches[1]).toEqual({
28152812
mode: "polling",
28162813
connected: false,
28172814
});
2818-
const cycleStartPatches = setStatus.mock.calls.filter(
2819-
([patch]) =>
2820-
(patch as Record<string, unknown>).mode === "polling" &&
2821-
(patch as Record<string, unknown>).connected === false &&
2822-
(patch as Record<string, unknown>).lastEventAt === null,
2823-
);
2824-
expect(cycleStartPatches).toHaveLength(1);
2825-
expect(cycleStartPatches[0]?.[0]).toMatchObject({
2826-
mode: "polling",
2827-
lastConnectedAt: null,
2828-
lastEventAt: null,
2829-
lastTransportActivityAt: null,
2830-
});
28312815
});
28322816

28332817
it("triggers stall restart even after a non-getUpdates API call succeeds", async () => {

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

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,4 @@ describe("createTelegramPollingStatusPublisher", () => {
3030
connected: false,
3131
});
3232
});
33-
34-
it("notePollingStart clears inherited connected state until getUpdates succeeds", () => {
35-
const setStatus = vi.fn();
36-
const status = createTelegramPollingStatusPublisher(setStatus);
37-
38-
status.notePollingStart();
39-
40-
expect(setStatus).toHaveBeenCalledWith({
41-
mode: "polling",
42-
connected: false,
43-
lastConnectedAt: null,
44-
lastEventAt: null,
45-
lastTransportActivityAt: null,
46-
});
47-
});
4833
});

extensions/telegram/src/polling-status.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ type TelegramPollingStatusSink = (patch: Omit<ChannelAccountSnapshot, "accountId
99
export function createTelegramPollingStatusPublisher(setStatus?: TelegramPollingStatusSink) {
1010
return {
1111
notePollingStart() {
12-
// Runtime snapshots are patch-merged, so a fresh polling lifecycle must
13-
// explicitly clear a previous connected:true until getUpdates succeeds.
1412
setStatus?.({
1513
mode: "polling",
1614
connected: false,

src/gateway/channel-health-policy.test.ts

Lines changed: 0 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -223,102 +223,6 @@ describe("evaluateChannelHealth", () => {
223223
expect(evaluation).toEqual({ healthy: true, reason: "healthy" });
224224
});
225225

226-
it("flags polling startups that explicitly remain disconnected after grace", () => {
227-
const evaluation = evaluateChannelHealth(
228-
{
229-
running: true,
230-
connected: false,
231-
enabled: true,
232-
configured: true,
233-
mode: "polling",
234-
lastStartAt: 0,
235-
lastConnectedAt: null,
236-
lastEventAt: null,
237-
lastTransportActivityAt: null,
238-
},
239-
{
240-
channelId: "telegram",
241-
now: 100_000,
242-
channelConnectGraceMs: 10_000,
243-
staleEventThresholdMs: 30_000,
244-
},
245-
);
246-
expect(evaluation).toEqual({ healthy: false, reason: "disconnected" });
247-
});
248-
249-
it("keeps polling channels healthy during the connect grace before getUpdates succeeds", () => {
250-
const evaluation = evaluateChannelHealth(
251-
{
252-
running: true,
253-
connected: false,
254-
enabled: true,
255-
configured: true,
256-
mode: "polling",
257-
lastStartAt: 95_000,
258-
lastConnectedAt: null,
259-
lastTransportActivityAt: null,
260-
},
261-
{
262-
channelId: "telegram",
263-
now: 100_000,
264-
channelConnectGraceMs: 10_000,
265-
staleEventThresholdMs: 30_000,
266-
},
267-
);
268-
expect(evaluation).toEqual({ healthy: true, reason: "startup-connect-grace" });
269-
});
270-
271-
it("does not flag stale-startup when the polling channel has a prior successful connect", () => {
272-
const evaluation = evaluateChannelHealth(
273-
{
274-
running: true,
275-
connected: true,
276-
enabled: true,
277-
configured: true,
278-
mode: "polling",
279-
lastStartAt: 0,
280-
lastConnectedAt: 50_000,
281-
lastTransportActivityAt: null,
282-
},
283-
{
284-
channelId: "telegram",
285-
now: 100_000,
286-
channelConnectGraceMs: 10_000,
287-
staleEventThresholdMs: 30_000,
288-
},
289-
);
290-
expect(evaluation).toEqual({ healthy: true, reason: "healthy" });
291-
});
292-
293-
it("flags polling startups with inherited connected:true and no transport activity", () => {
294-
// Defense in depth: notePollingStart now explicitly clears connected, so
295-
// the normal startup-hang path falls through `connected === false` above.
296-
// This case covers a future code path that forgets to clear connected:true
297-
// on lifecycle start; the inherited true plus null lastConnectedAt plus
298-
// null lastTransportActivityAt past the grace still means the channel
299-
// never reached its first transport event.
300-
const evaluation = evaluateChannelHealth(
301-
{
302-
running: true,
303-
connected: true,
304-
enabled: true,
305-
configured: true,
306-
mode: "polling",
307-
lastStartAt: 0,
308-
lastConnectedAt: null,
309-
lastEventAt: null,
310-
lastTransportActivityAt: null,
311-
},
312-
{
313-
channelId: "telegram",
314-
now: 100_000,
315-
channelConnectGraceMs: 10_000,
316-
staleEventThresholdMs: 30_000,
317-
},
318-
);
319-
expect(evaluation).toEqual({ healthy: false, reason: "stale-socket" });
320-
});
321-
322226
it("keeps quiet telegram webhooks healthy when they do not publish transport tracking", () => {
323227
const evaluation = evaluateChannelHealth(
324228
{

src/gateway/channel-health-policy.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -124,24 +124,6 @@ export function evaluateChannelHealth(
124124
return { healthy: false, reason: "stale-socket" };
125125
}
126126
}
127-
// Defense in depth against patch-merge oversights: `notePollingStart` now
128-
// writes connected:false explicitly so a hung polling restart is normally
129-
// caught by the connected===false branch above. But if a future code path
130-
// forgets to clear the inherited connected flag on lifecycle start, this
131-
// branch catches the same hang: connected:true paired with null
132-
// lastConnectedAt and null lastTransportActivityAt after the grace window
133-
// means the channel never reached its first transport event. Scoped to
134-
// polling mode so webhook channels and channels without continuous transport
135-
// tracking are not falsely flagged.
136-
if (
137-
snapshot.mode === "polling" &&
138-
snapshot.connected === true &&
139-
lastTransportActivityAt == null &&
140-
snapshot.lastConnectedAt == null &&
141-
lastStartAt != null
142-
) {
143-
return { healthy: false, reason: "stale-socket" };
144-
}
145127
return { healthy: true, reason: "healthy" };
146128
}
147129

0 commit comments

Comments
 (0)