fix(telegram): isolate long polling transport

This commit is contained in:
xmanrui 2026-08-31 03:31:07 +08:00
parent 02ddea0054
commit 14519060f0
13 changed files with 603 additions and 181 deletions

View file

@ -6,6 +6,11 @@ This file records the notable changes in each dsh-im release. Its format follows
## [Unreleased]
### Fixed / 修复
- Telegram 机器人现在为长轮询和发送请求使用同一 Runtime 私有、代理感知的有限连接池,避免全局 HTTP 连接受限时 `getUpdates` 阻塞消息发送;停止或启动失败时会显式释放连接池。
Telegram bots now use a private, proxy-aware bounded connection pool per Runtime for both long polling and sends, preventing `getUpdates` from blocking delivery when global HTTP connections are constrained; the pool is explicitly released on stop and failed startup.
## [4.2.0] - 2026-08-31
### Added / 新增

View file

@ -8,7 +8,7 @@ The WeCom QR-authorization request sequence is adapted from the official [`@weco
The Host bundle includes [`@larksuiteoapi/node-sdk`](https://github.com/larksuite/node-sdk) 1.73.0, [`@whiskeysockets/baileys`](https://github.com/WhiskeySockets/Baileys) 7.0.0-rc14, [`https-proxy-agent`](https://github.com/TooTallNate/proxy-agents) 5.0.1, and their [`protobufjs`](https://github.com/protobufjs/protobuf.js) 7.6.5 runtime. The Lark SDK, Baileys, and https-proxy-agent are licensed under the MIT License. protobufjs is licensed under the BSD 3-Clause License; both license texts are reproduced below.
This package depends at runtime on [`dingtalk-stream`](https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs) 2.1.4, [`@wecom/aibot-node-sdk`](https://github.com/WecomTeam/aibot-node-sdk) 1.0.7, [`@tencent-connect/qqbot-nodejs`](https://github.com/tencent-connect/qqbot) 1.0.4, and [`qrcode`](https://github.com/soldair/node-qrcode) 1.5.4. These packages are licensed under the MIT License; `dingtalk-stream` is copyright 2023 钉钉开放平台团队.
This package depends at runtime on [`dingtalk-stream`](https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs) 2.1.4, [`@wecom/aibot-node-sdk`](https://github.com/WecomTeam/aibot-node-sdk) 1.0.7, [`@tencent-connect/qqbot-nodejs`](https://github.com/tencent-connect/qqbot) 1.0.4, [`qrcode`](https://github.com/soldair/node-qrcode) 1.5.4, and [`undici`](https://github.com/nodejs/undici) 7.29.0. These packages are licensed under the MIT License; `dingtalk-stream` is copyright 2023 钉钉开放平台团队, and Undici is copyright Matteo Collina and Undici contributors.
QQ QR binding uses Tencent Connect's official [`@tencent-connect/qqbot-connector`](https://www.npmjs.com/package/@tencent-connect/qqbot-connector) 1.2.0 package as an external runtime dependency. Its npm metadata declares `UNLICENSED`; no connector source is copied into this project.
@ -38,6 +38,30 @@ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
## Undici license
MIT License
Copyright (c) Matteo Collina and Undici contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## Lark Node SDK license
MIT License

View file

@ -1,8 +1,8 @@
# Issue #85:Telegram 长轮询阻塞发送最小修复方案
日期:2026-08-30。代码基线:v4.1.0 / `e08c3e9`。状态:待实施。
方案日期:2026-08-30。实施基线:v4.2.0 / `02ddea0`。状态:已实施并验证(2026-08-31)。
需求来源:[Issue #85](https://github.com/xmanrui/dsh-im/issues/85)。本文只定义关闭该 Issue 所需的最小改动;列出的测试均为待补测试。
需求来源:[Issue #85](https://github.com/xmanrui/dsh-im/issues/85)。本文定义并记录关闭该 Issue 所需的最小改动及验收结果。
## 1. 决策
@ -142,10 +142,10 @@ Runtime 注入配套的 `undici.fetch + undici.FormData`。其他直接构造 `T
| `src/channels/telegram/telegram-runtime.mjs` | 创建、注入和释放 transport |
| `plugin-src/host/build.mjs` | 外置 `undici` |
| `scripts/verify-package.mjs` | 校验直接依赖和 bundle 外置 |
| `test/channels/telegram/telegram-http.test.mjs` | 核心传输回归 |
| `test/channels/telegram/telegram.test.mjs` | API 和 Runtime 最小测试 |
| `test/channels/telegram/telegram-http.test.mjs` | 核心传输、真实 multipart 和 Runtime 资源所有权回归 |
| `test/channels/telegram/telegram.test.mjs` | 复用现有 API、超时及 uncertain 语义测试,无需修改 |
| `THIRD_PARTY_NOTICES.md`、`CHANGELOG.md` | 许可证和修复记录 |
| `lib/index.js` | 通过现有构建命令生成 |
| `lib/index.js`、`lib/client.js` | 通过现有构建命令生成 |
## 6. 最小测试集
@ -199,6 +199,8 @@ npm pack
- 没有新增最终消息或文件上传重试。
- 完整构建、测试和干净发布包安装通过。
实施验收记录:`npm run check` 构建成功且 1949 项测试全部通过;实际 tarball 在空临时目录安装后可导入 `lib/index.js`,并可从安装包执行本地 Telegram transport 请求;本机 DSH 重启加载新构建后,在 Telegram Runtime 长轮询运行期间向真实会话发送成功,HTTP 投递返回 200。
## 8. 后续加固
以下内容单独建 issue,不阻塞 #85:

View file

@ -670,7 +670,8 @@ var package_default = {
"@tencent-connect/qqbot-nodejs": "1.0.4",
"@wecom/aibot-node-sdk": "1.0.7",
"dingtalk-stream": "2.1.4",
qrcode: "1.5.4"
qrcode: "1.5.4",
undici: "7.29.0"
},
devDependencies: {
"@deepseek-ai/cordis": "4.0.1",

File diff suppressed because one or more lines are too long

12
package-lock.json generated
View file

@ -13,7 +13,8 @@
"@tencent-connect/qqbot-nodejs": "1.0.4",
"@wecom/aibot-node-sdk": "1.0.7",
"dingtalk-stream": "2.1.4",
"qrcode": "1.5.4"
"qrcode": "1.5.4",
"undici": "7.29.0"
},
"bin": {
"dsh-im": "bin/dsh-im.mjs"
@ -2801,6 +2802,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",

View file

@ -99,7 +99,8 @@
"@tencent-connect/qqbot-nodejs": "1.0.4",
"@wecom/aibot-node-sdk": "1.0.7",
"dingtalk-stream": "2.1.4",
"qrcode": "1.5.4"
"qrcode": "1.5.4",
"undici": "7.29.0"
},
"devDependencies": {
"@deepseek-ai/cordis": "4.0.1",

View file

@ -15,6 +15,7 @@ const externalRuntimePackages = [
'@wecom/aibot-node-sdk',
'dingtalk-stream',
'qrcode',
'undici',
];
const external = externalRuntimePackages.flatMap((name) => [name, `${name}/*`]);

View file

@ -52,6 +52,7 @@ const required = [
'src/channels/slack/slack-runtime.mjs',
'src/channels/wecom/wecom-runtime.mjs',
'src/channels/telegram/telegram-runtime.mjs',
'src/channels/telegram/telegram-http.mjs',
'src/channels/discord/discord-runtime.mjs',
'src/channels/whatsapp/whatsapp-runtime.mjs',
'src/channels/whatsapp/whatsapp-web-session.mjs',
@ -198,6 +199,7 @@ const directDependencies = {
'@tencent-connect/qqbot-nodejs': '1.0.4',
'@wecom/aibot-node-sdk': '1.0.7',
qrcode: '1.5.4',
undici: '7.29.0',
};
for (const [name, version] of Object.entries(directDependencies)) {
if (manifest.dependencies?.[name] !== version) {
@ -226,6 +228,9 @@ if (manifest.bin?.['dsh-im'] !== 'bin/dsh-im.mjs') {
if (/(?:from\s*|import\s*\(|require\s*\()\s*["'](?:@larksuiteoapi\/node-sdk|@whiskeysockets\/baileys|https-proxy-agent|protobufjs)(?:\/[^"']*)?["']/.test(host)) {
throw new Error('host bundle must not import a bundled SDK, proxy agent, or protobufjs at runtime');
}
if (!/(?:from\s*|import\s*\()\s*["']undici["']/.test(host)) {
throw new Error('host bundle must retain undici as an external runtime dependency');
}
if ((executable.mode & 0o111) === 0) throw new Error('dsh-im CLI is not executable');
if (/private-bot-token|must-be-rolled-back|DEEPSEEK_API_KEY=/.test(client + host)) {
throw new Error('built artifacts contain a test or environment secret marker');

View file

@ -105,19 +105,23 @@ export const COMMANDS_MENU_BUTTON = Object.freeze({ type: 'commands' });
export class TelegramApi {
#token;
#fetch;
#FormDataImpl;
#baseUrl;
#fileUploadTimeoutMs;
constructor({
token,
fetchImpl = fetch,
FormDataImpl = globalThis.FormData,
baseUrl = DEFAULT_BASE_URL,
fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
}) {
if (!validTelegramToken(token)) throw new TypeError('Telegram Bot Token is invalid');
if (typeof fetchImpl !== 'function') throw new TypeError('TelegramApi requires fetch');
if (typeof FormDataImpl !== 'function') throw new TypeError('TelegramApi requires FormData');
this.#token = token.trim();
this.#fetch = fetchImpl;
this.#FormDataImpl = FormDataImpl;
this.#baseUrl = new URL(baseUrl);
this.#fileUploadTimeoutMs = positiveTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
}
@ -273,7 +277,7 @@ export class TelegramApi {
|| !Buffer.isBuffer(file.bytes)) {
throw new TypeError(`A Telegram ${mediaLabel} is required`);
}
const payload = new FormData();
const payload = new this.#FormDataImpl();
payload.append('chat_id', String(chatId));
payload.append(
fieldName,

View file

@ -0,0 +1,21 @@
import {
EnvHttpProxyAgent,
FormData as UndiciFormData,
fetch as undiciFetch,
} from 'undici';
const TELEGRAM_CONNECTIONS = 4;
export function createTelegramHttpTransport() {
const dispatcher = new EnvHttpProxyAgent({ connections: TELEGRAM_CONNECTIONS });
let destroyed = false;
return {
fetchImpl: (url, options = {}) => undiciFetch(url, { ...options, dispatcher }),
FormDataImpl: UndiciFormData,
async destroy() {
if (destroyed) return;
destroyed = true;
await dispatcher.destroy();
},
};
}

View file

@ -5,6 +5,7 @@ import { createTextDeliveryBlock } from '../shared/semantic/delivery.mjs';
import { t } from '../shared/i18n.mjs';
import { captureContextEnhancement } from '../shared/context-enhancement.mjs';
import { COMMANDS_MENU_BUTTON, TelegramApi } from './telegram-api.mjs';
import { createTelegramHttpTransport } from './telegram-http.mjs';
import { createTelegramBridgeStatus, TelegramHarnessBridge } from './telegram-bridge.mjs';
import {
splitTelegramRegularText,
@ -671,9 +672,11 @@ export class TelegramRuntime {
#logger;
#replyTimeoutMs;
#createApi;
#createHttpTransport;
#accessMode;
#allowedPrivateUserIds;
#status = createTelegramRuntimeStatus();
#httpTransport = null;
#api = null;
#bridge = null;
#abortController = null;
@ -689,6 +692,7 @@ export class TelegramRuntime {
logger = console,
replyTimeoutMs = 600_000,
createApi = (options) => new TelegramApi(options),
createHttpTransport = createTelegramHttpTransport,
}) {
if (!config || !token || !harness || !state) {
throw new TypeError('TelegramRuntime requires config, token, Harness, and state');
@ -701,6 +705,7 @@ export class TelegramRuntime {
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#createApi = createApi;
this.#createHttpTransport = createHttpTransport;
const accessPolicy = normalizeTelegramAccessPolicy(config);
this.#accessMode = accessPolicy.accessMode;
this.#allowedPrivateUserIds = new Set(accessPolicy.allowedUsers);
@ -762,9 +767,15 @@ export class TelegramRuntime {
const controller = new AbortController();
this.#abortController = controller;
const api = this.#createApi({ token: this.#token });
this.#api = api;
try {
const transport = this.#createHttpTransport();
this.#httpTransport = transport;
const api = this.#createApi({
token: this.#token,
fetchImpl: transport.fetchImpl,
FormDataImpl: transport.FormDataImpl,
});
this.#api = api;
const bot = await api.getMe({ signal: controller.signal });
if (String(bot?.id ?? '') !== this.#config.platformId || bot?.is_bot !== true) {
throw new Error('Telegram token identity does not match the saved bot');
@ -878,9 +889,11 @@ export class TelegramRuntime {
async stop() {
const pollTask = this.#pollTask;
const bridge = this.#bridge;
const httpTransport = this.#httpTransport;
this.#abortController?.abort();
this.#abortController = null;
this.#pollTask = null;
this.#httpTransport = null;
this.#api = null;
this.#bridge = null;
await Promise.race([
@ -891,6 +904,11 @@ export class TelegramRuntime {
bridge?.waitForIdle() ?? Promise.resolve(),
new Promise((resolve) => setTimeout(resolve, 2_000)),
]);
try {
await httpTransport?.destroy();
} catch {
this.#logger.warn?.('[dsh-im:telegram] Telegram HTTP transport cleanup failed');
}
this.#status.ready = false;
this.#status.connectionState = 'idle';
return this.status;

View file

@ -0,0 +1,330 @@
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { createServer } from 'node:http';
import { promisify } from 'node:util';
import { resolve } from 'node:path';
import test from 'node:test';
import { TelegramApi } from '../../../src/channels/telegram/telegram-api.mjs';
import { createTelegramHttpTransport } from '../../../src/channels/telegram/telegram-http.mjs';
import { TelegramRuntime } from '../../../src/channels/telegram/telegram-runtime.mjs';
const TOKEN = '123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef123456';
const execFileAsync = promisify(execFile);
const packageRoot = resolve(import.meta.dirname, '../../..');
function deferred() {
let resolvePromise;
let rejectPromise;
const promise = new Promise((resolveValue, rejectValue) => {
resolvePromise = resolveValue;
rejectPromise = rejectValue;
});
return { promise, resolve: resolvePromise, reject: rejectPromise };
}
async function bounded(promise, message, timeoutMs = 1_000) {
let timer;
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
}),
]);
} finally {
clearTimeout(timer);
}
}
async function listen(server) {
await new Promise((resolvePromise, rejectPromise) => {
server.once('error', rejectPromise);
server.listen(0, '127.0.0.1', resolvePromise);
});
const address = server.address();
return `http://127.0.0.1:${address.port}/`;
}
async function close(server) {
await new Promise((resolvePromise, rejectPromise) => {
server.close((error) => (error ? rejectPromise(error) : resolvePromise()));
});
}
function withLocalhostProxyBypass() {
const names = ['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY', 'no_proxy', 'NO_PROXY'];
const previous = Object.fromEntries(names.map((name) => [name, process.env[name]]));
for (const name of names) delete process.env[name];
process.env.no_proxy = '127.0.0.1,localhost';
process.env.NO_PROXY = '127.0.0.1,localhost';
return () => {
for (const name of names) {
if (previous[name] === undefined) delete process.env[name];
else process.env[name] = previous[name];
}
};
}
test('Telegram private transport sends while a long poll remains open', async () => {
const pollStarted = deferred();
const releasePoll = deferred();
const events = [];
const server = createServer((request, response) => {
request.resume();
if (request.url.endsWith('/getUpdates')) {
events.push('poll-started');
pollStarted.resolve();
void releasePoll.promise.then(() => {
events.push('poll-released');
response.setHeader('content-type', 'application/json');
response.end(JSON.stringify({ ok: true, result: [] }));
});
return;
}
events.push('send-completed');
response.setHeader('content-type', 'application/json');
response.end(JSON.stringify({ ok: true, result: true }));
});
const baseUrl = await listen(server);
const restoreProxy = withLocalhostProxyBypass();
const transport = createTelegramHttpTransport();
const api = new TelegramApi({
token: TOKEN,
baseUrl,
fetchImpl: transport.fetchImpl,
FormDataImpl: transport.FormDataImpl,
});
let pollSettled = false;
const poll = api.getUpdates().finally(() => { pollSettled = true; });
try {
await bounded(pollStarted.promise, 'Telegram long poll did not start');
assert.equal(await bounded(
api.sendChatAction({ chatId: 42 }),
'Telegram send was blocked behind its long poll',
), true);
assert.equal(pollSettled, false);
assert.deepEqual(events, ['poll-started', 'send-completed']);
releasePoll.resolve();
assert.deepEqual(await poll, []);
assert.deepEqual(events, ['poll-started', 'send-completed', 'poll-released']);
} finally {
releasePoll.resolve();
await poll.catch(() => undefined);
await transport.destroy();
restoreProxy();
await close(server);
}
});
test('Telegram private transport is independent from a saturated global dispatcher', async () => {
const source = String.raw`
import { createServer } from 'node:http';
import { Agent, fetch, setGlobalDispatcher } from 'undici';
import { createTelegramHttpTransport } from './src/channels/telegram/telegram-http.mjs';
let releaseHold;
let markHoldStarted;
const holdStarted = new Promise((resolve) => { markHoldStarted = resolve; });
const holdReleased = new Promise((resolve) => { releaseHold = resolve; });
const server = createServer((request, response) => {
request.resume();
if (request.url === '/hold') {
markHoldStarted();
void holdReleased.then(() => response.end('held'));
return;
}
response.end('private');
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const origin = 'http://127.0.0.1:' + server.address().port;
const globalAgent = new Agent({ connections: 1 });
setGlobalDispatcher(globalAgent);
const occupied = fetch(origin + '/hold');
await holdStarted;
const transport = createTelegramHttpTransport();
let timeout;
try {
const response = await Promise.race([
transport.fetchImpl(origin + '/send'),
new Promise((_, reject) => { timeout = setTimeout(
() => reject(new Error('private transport used the saturated global dispatcher')),
1_000,
); }),
]);
if (await response.text() !== 'private') throw new Error('unexpected private response');
process.stdout.write('independent');
} finally {
clearTimeout(timeout);
releaseHold();
await occupied;
await transport.destroy();
await globalAgent.destroy();
await new Promise((resolve, reject) => server.close(
(error) => error ? reject(error) : resolve(),
));
}
`;
const childEnv = { ...process.env };
for (const name of ['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY']) {
delete childEnv[name];
}
childEnv.no_proxy = '127.0.0.1,localhost';
childEnv.NO_PROXY = '127.0.0.1,localhost';
const { stdout, stderr } = await execFileAsync(process.execPath, [
'--input-type=module',
'--eval',
source,
], {
cwd: packageRoot,
env: childEnv,
timeout: 5_000,
});
assert.equal(stderr, '');
assert.equal(stdout, 'independent');
});
test('Telegram API sends real undici multipart data without losing metadata or bytes', async () => {
const received = deferred();
const server = createServer((request, response) => {
const chunks = [];
request.on('data', (chunk) => chunks.push(chunk));
request.on('end', () => {
received.resolve({
contentType: request.headers['content-type'],
body: Buffer.concat(chunks),
});
response.setHeader('content-type', 'application/json');
response.end(JSON.stringify({ ok: true, result: { message_id: 901 } }));
});
});
const baseUrl = await listen(server);
const restoreProxy = withLocalhostProxyBypass();
const transport = createTelegramHttpTransport();
const bytes = Buffer.from([0, 1, 2, 3, 0xff, 0x80, 0x41]);
const api = new TelegramApi({
token: TOKEN,
baseUrl,
fetchImpl: transport.fetchImpl,
FormDataImpl: transport.FormDataImpl,
});
try {
assert.deepEqual(await api.sendDocument({
chatId: -100123,
replyToMessageId: 44,
messageThreadId: 55,
file: {
fileName: 'result.bin',
mediaType: 'application/octet-stream',
bytes,
},
}), { message_id: 901 });
const request = await bounded(received.promise, 'Telegram multipart request was not received');
assert.match(request.contentType, /^multipart\/form-data; boundary=/);
assert.ok(request.body.includes(Buffer.from('name="chat_id"\r\n\r\n-100123')));
assert.ok(request.body.includes(Buffer.from('name="message_thread_id"\r\n\r\n55')));
assert.ok(request.body.includes(Buffer.from('name="reply_parameters"')));
assert.ok(request.body.includes(Buffer.from('"message_id":44')));
assert.ok(request.body.includes(Buffer.from(
'name="document"; filename="result.bin"\r\nContent-Type: application/octet-stream',
)));
assert.ok(request.body.includes(bytes));
} finally {
await transport.destroy();
restoreProxy();
await close(server);
}
});
function runtimeState(cursor = 0) {
return {
cursor: () => cursor,
setCursor: async (value) => { cursor = value; },
hasSeen: () => false,
markSeen: async () => undefined,
sessionFor: () => null,
};
}
function runtimeConfig() {
return {
botId: 'telegram_transport_test',
platformId: '123456789',
username: 'HarnessBot',
};
}
function healthyApi() {
return {
getMe: async () => ({ id: 123456789, is_bot: true }),
getWebhookInfo: async () => ({ url: '' }),
setMyCommands: async () => true,
setChatMenuButton: async () => true,
getUpdates: async ({ signal }) => new Promise((_, reject) => {
if (signal.aborted) reject(signal.reason);
else signal.addEventListener('abort', () => reject(signal.reason), { once: true });
}),
};
}
function fakeTransport() {
let destroyCalls = 0;
class FakeFormData {}
const fetchImpl = async () => undefined;
return {
fetchImpl,
FormDataImpl: FakeFormData,
destroy: async () => { destroyCalls += 1; },
get destroyCalls() { return destroyCalls; },
};
}
test('Telegram Runtime injects and destroys one private transport exactly once', async () => {
const transport = fakeTransport();
let apiOptions;
const runtime = new TelegramRuntime({
config: runtimeConfig(),
token: TOKEN,
harness: { ensureRunning: async () => true },
state: runtimeState(),
createHttpTransport: () => transport,
createApi: (options) => {
apiOptions = options;
return healthyApi();
},
});
await runtime.start();
assert.equal(apiOptions.fetchImpl, transport.fetchImpl);
assert.equal(apiOptions.FormDataImpl, transport.FormDataImpl);
await runtime.stop();
await runtime.stop();
assert.equal(transport.destroyCalls, 1);
});
for (const failurePoint of ['getMe', 'createApi']) {
test(`Telegram Runtime destroys its private transport when ${failurePoint} fails`, async () => {
const transport = fakeTransport();
const failure = new Error(`${failurePoint} failed`);
const runtime = new TelegramRuntime({
config: runtimeConfig(),
token: TOKEN,
harness: { ensureRunning: async () => true },
state: runtimeState(),
createHttpTransport: () => transport,
createApi: failurePoint === 'createApi'
? () => { throw failure; }
: () => ({ ...healthyApi(), getMe: async () => { throw failure; } }),
});
await assert.rejects(runtime.start(), (error) => error === failure);
assert.equal(transport.destroyCalls, 1);
await runtime.stop();
assert.equal(transport.destroyCalls, 1);
});
}