Skip to content

Commit fcefbec

Browse files
committed
fix(telemetry): cancel pending sends before CLI exit
1 parent 6f0cd3d commit fcefbec

8 files changed

Lines changed: 498 additions & 39 deletions

File tree

‎docs/agents/telemetry-change.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ AEM 映射:
163163
- [ ] 成功与失败均覆盖;遥测异常必须静默且不改变业务退出码
164164
- [ ] runtime 本地语义错误应记录 `exitCode`;新增字段默认随 AEM `ext` 上报,无需占用新的 `c1`—`c5`
165165
- [ ] 检查 `DO_NOT_TRACK=1` 与 `telemetry: false` 两个关闭入口
166+
- [ ] 遥测发送的 DNS/HTTP/响应体必须受可取消预算控制;flush 超时必须取消在途操作,不能只停止等待,也不能修改全局 DNS/dispatcher 或强制退出宿主
167+
- [ ] 退出生命周期变更保留真实子进程回归:挂起响应头/响应体、DNS 预查询和关闭遥测;检查实际退出及进程仍存活时的连接释放,不以 flush Promise 返回代替验收
166168
- [ ] 错误字段不得额外拼接 token、请求体、prompt 或本地路径
167169

168170
## 完成后自查

‎packages/core/lib/remote-telemetry/tracker.cjs‎

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -887,9 +887,6 @@ module.exports = (function (e) {
887887
},
888888
};
889889
var _ = y,
890-
B = n(3),
891-
S = n.n(B),
892-
T = n(4),
893890
j = n(5),
894891
N = n.n(j);
895892
function w(e) {
@@ -1106,8 +1103,7 @@ module.exports = (function (e) {
11061103
app_version: x,
11071104
device_id: N.a.createHash("md5").update(E()).digest("hex"),
11081105
platform: "node",
1109-
}),
1110-
I = Object(T.promisify)(S.a.resolve);
1106+
});
11111107
function H(e) {
11121108
((this._offlineQueue = []),
11131109
(e.endpoint = e.endpoint || "gm.mmstat.com"),
@@ -1116,27 +1112,58 @@ module.exports = (function (e) {
11161112
}
11171113
((H.prototype = (((M = function () {}).prototype = _.prototype), new M())),
11181114
(H.prototype.constructor = H),
1119-
(H.prototype.send = function (e) {
1120-
var t,
1121-
n = this;
1122-
return ((t = this._config.endpoint), I(t))
1123-
.then(function (t) {
1124-
return (
1125-
n._offlineQueue.forEach(function (e) {
1126-
n.send(e);
1127-
}),
1128-
(n._offlineQueue = []),
1129-
n._print("send", e),
1130-
a()(n._config.endpoint_url, {
1131-
method: "POST",
1132-
keepalive: true,
1133-
body: JSON.stringify({ gokey: encodeURIComponent(e), gmkey: "EXP" }),
1134-
}).catch(function () {})
1135-
);
1136-
})
1137-
.catch(function (t) {
1138-
(n._offlineQueue.length > 500 && n._offlineQueue.shift(), n._offlineQueue.push(e));
1139-
});
1115+
(H.prototype.send = async function (payload, options = {}) {
1116+
const signal = options.signal;
1117+
if (signal?.aborted) return;
1118+
// DNS follows the cancellable fetch; a separate preflight could outlive
1119+
// the CLI's flush budget and also bypass an explicitly configured proxy.
1120+
const payloads = [...this._offlineQueue.splice(0), payload];
1121+
// Undici request cancellation alone does not own an unfinished connector.
1122+
// Only telemetry gets this private dispatcher, including its proxy hops.
1123+
let dispatcher;
1124+
if (!process.versions.bun) {
1125+
const { Agent, EnvHttpProxyAgent } = require("undici");
1126+
const connect = { signal, timeout: 1000 };
1127+
const httpProxy = process.env.HTTP_PROXY?.trim() || undefined;
1128+
const httpsProxy = process.env.HTTPS_PROXY?.trim() || undefined;
1129+
dispatcher =
1130+
httpProxy || httpsProxy
1131+
? new EnvHttpProxyAgent({
1132+
httpProxy,
1133+
httpsProxy,
1134+
noProxy: process.env.NO_PROXY?.trim() || undefined,
1135+
connect,
1136+
requestTls: connect,
1137+
proxyTls: connect,
1138+
})
1139+
: new Agent({ connect });
1140+
}
1141+
try {
1142+
await Promise.all(
1143+
payloads.map(async (eventPayload) => {
1144+
if (signal?.aborted) return;
1145+
try {
1146+
this._print("send", eventPayload);
1147+
const response = await a()(this._config.endpoint_url, {
1148+
method: "POST",
1149+
keepalive: true,
1150+
signal,
1151+
...(dispatcher ? { dispatcher } : {}),
1152+
body: JSON.stringify({ gokey: encodeURIComponent(eventPayload), gmkey: "EXP" }),
1153+
});
1154+
// Telemetry never uses response data. Release streaming bodies too.
1155+
await response.body?.cancel().catch(() => {});
1156+
} catch {
1157+
if (!signal?.aborted) {
1158+
if (this._offlineQueue.length > 500) this._offlineQueue.shift();
1159+
this._offlineQueue.push(eventPayload);
1160+
}
1161+
}
1162+
}),
1163+
);
1164+
} finally {
1165+
await dispatcher?.destroy().catch(() => {});
1166+
}
11401167
}));
11411168
t.default = H;
11421169
},
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
declare class RemoteTracker {
22
constructor(config: { pid: string; debug?: boolean; [key: string]: unknown });
33
use(plugin: unknown): (...args: any[]) => void;
4-
send(gokey: string): Promise<void>;
4+
send(gokey: string, options?: { signal?: AbortSignal }): Promise<void>;
55
}
66

77
export = RemoteTracker;

‎packages/core/src/telemetry/sink.ts‎

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ let remoteSendEvent: ((name: string, opts: Record<string, unknown>) => void) | u
1717
// 如果不追踪,CLI 在短命令或 SIGINT 场景下会在请求真正落网络前就退出。
1818
// 配合 tracker.js 中的 `keepalive: true`,await 这些 promise 可以在不阻塞
1919
// 主流程的前提下,尽最大努力把埋点送达。
20-
const inflightSends = new Set<Promise<unknown>>();
20+
const SEND_TIMEOUT_MS = 1000;
21+
interface PendingSend {
22+
controller: AbortController;
23+
promise: Promise<void>;
24+
}
25+
const inflightSends = new Set<PendingSend>();
2126
let remoteClient: any = undefined;
2227

2328
try {
@@ -28,13 +33,24 @@ try {
2833
// 不修改 prototype,避免污染其他可能存在的 Tracker 使用方。
2934
const originalSend = client.send.bind(client);
3035
client.send = function (payload: string) {
31-
const result = originalSend(payload);
32-
if (result && typeof (result as Promise<unknown>).then === "function") {
33-
const p = result as Promise<unknown>;
34-
inflightSends.add(p);
35-
void p.finally(() => inflightSends.delete(p));
36-
}
37-
return result;
36+
const controller = new AbortController();
37+
const deadline = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
38+
deadline.unref?.();
39+
const pending: PendingSend = {
40+
controller,
41+
promise: Promise.resolve()
42+
.then(() => originalSend(payload, { signal: controller.signal }))
43+
.catch(() => {})
44+
.finally(() => {
45+
// Bun may resolve body.cancel() before stopping its underlying fetch.
46+
// Finish this send's private scope before removing its deadline.
47+
controller.abort();
48+
clearTimeout(deadline);
49+
inflightSends.delete(pending);
50+
}),
51+
};
52+
inflightSends.add(pending);
53+
return pending.promise;
3854
};
3955
remoteClient = client;
4056
remoteSendEvent = client.use(EventPlugin);
@@ -47,12 +63,13 @@ try {
4763
*
4864
* 1. 先调用 `_sendAll` 排空 tracker 内部的去抖队列,把还卡在 500ms 合并窗口里
4965
* 的事件立刻推上网络。
50-
* 2. 然后用硬超时 race 所有已追踪的 fetch promise。
66+
* 2. 在预算内等待发送,预算耗尽时真正取消 DNS/HTTP/body 工作。
5167
*
5268
* 埋点永远不应阻塞 CLI:调用方应传入较短的超时(例如 1000ms),并始终与超时
5369
* race。错误与超时一律静默吞掉。
5470
*/
5571
export async function flushTelemetry(timeoutMs = 1000): Promise<void> {
72+
let deadline: ReturnType<typeof setTimeout> | undefined;
5673
try {
5774
if (remoteClient) {
5875
try {
@@ -66,13 +83,20 @@ export async function flushTelemetry(timeoutMs = 1000): Promise<void> {
6683
}
6784
}
6885
if (inflightSends.size === 0) return;
69-
const pending = [...inflightSends].map((p) => p.catch(() => undefined));
7086
await Promise.race([
71-
Promise.allSettled(pending),
72-
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs).unref?.()),
87+
Promise.allSettled([...inflightSends].map((pending) => pending.promise)),
88+
new Promise<void>((resolve) => {
89+
deadline = setTimeout(() => {
90+
for (const pending of inflightSends) pending.controller.abort();
91+
resolve();
92+
}, timeoutMs);
93+
deadline.unref?.();
94+
}),
7395
]);
7496
} catch {
7597
// 埋点逻辑任何异常都不能影响 CLI 主流程
98+
} finally {
99+
clearTimeout(deadline);
76100
}
77101
}
78102

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { createRequire } from "node:module";
2+
import dns from "node:dns";
3+
import { afterEach, beforeEach, expect, test, vi, type MockInstance } from "vite-plus/test";
4+
import Tracker from "../lib/remote-telemetry/tracker.cjs";
5+
6+
const require = createRequire(import.meta.url);
7+
const transport = require("undici") as typeof import("undici");
8+
let fetchSpy: MockInstance<typeof transport.fetch>;
9+
let resolveSpy: MockInstance<typeof dns.resolve>;
10+
11+
beforeEach(() => {
12+
fetchSpy = vi
13+
.spyOn(transport, "fetch")
14+
.mockResolvedValue(new Response(null, { status: 204 }) as never);
15+
resolveSpy = vi.spyOn(dns, "resolve").mockImplementation((...args: unknown[]) => {
16+
(args.at(-1) as (error: null, addresses: string[]) => void)(null, ["127.0.0.1"]);
17+
});
18+
});
19+
afterEach(() => vi.restoreAllMocks());
20+
21+
test("telemetry uses the bounded transport without a separate DNS preflight", async () => {
22+
const controller = new AbortController();
23+
const tracker = new Tracker({ pid: "fixture-pid", env: "dev" });
24+
await tracker.send("fixture event", { signal: controller.signal });
25+
expect(resolveSpy).not.toHaveBeenCalled();
26+
expect(fetchSpy).toHaveBeenCalledOnce();
27+
expect(fetchSpy.mock.calls[0]).toEqual([
28+
"https://gm.mmstat.com/aes.1.1",
29+
expect.objectContaining({
30+
method: "POST",
31+
signal: controller.signal,
32+
body: JSON.stringify({ gokey: "fixture%20event", gmkey: "EXP" }),
33+
}),
34+
]);
35+
});
36+
37+
test("telemetry releases the unused response body before completing", async () => {
38+
const cancel = vi.fn();
39+
fetchSpy.mockResolvedValue(new Response(new ReadableStream({ cancel })) as never);
40+
await new Tracker({ pid: "fixture-pid" }).send("event", { signal: new AbortController().signal });
41+
expect(cancel).toHaveBeenCalledOnce();
42+
});
43+
44+
test("an aborted send neither opens a request nor replays queued events", async () => {
45+
const tracker = new Tracker({ pid: "fixture-pid" });
46+
const queued = tracker as unknown as { _offlineQueue: string[] };
47+
queued._offlineQueue.push("queued");
48+
const controller = new AbortController();
49+
controller.abort();
50+
await tracker.send("event", { signal: controller.signal });
51+
expect(fetchSpy).not.toHaveBeenCalled();
52+
expect(queued._offlineQueue).toEqual(["queued"]);
53+
});
54+
55+
test("queued retries share the current cancellation signal and retain event encoding", async () => {
56+
const tracker = new Tracker({ pid: "fixture-pid" });
57+
(tracker as unknown as { _offlineQueue: string[] })._offlineQueue.push("old event");
58+
const controller = new AbortController();
59+
await tracker.send("new event", { signal: controller.signal });
60+
expect(fetchSpy).toHaveBeenCalledTimes(2);
61+
expect(fetchSpy.mock.calls.map(([, options]) => options?.signal)).toEqual([
62+
controller.signal,
63+
controller.signal,
64+
]);
65+
expect(fetchSpy.mock.calls.map(([, options]) => JSON.parse(options?.body as string))).toEqual([
66+
{ gokey: "old%20event", gmkey: "EXP" },
67+
{ gokey: "new%20event", gmkey: "EXP" },
68+
]);
69+
});
70+
71+
test("cancellation failures are silent and are not queued for retry", async () => {
72+
const tracker = new Tracker({ pid: "fixture-pid" });
73+
const controller = new AbortController();
74+
fetchSpy.mockImplementation(async () => {
75+
controller.abort();
76+
throw new DOMException("cancelled", "AbortError");
77+
});
78+
await expect(tracker.send("event", { signal: controller.signal })).resolves.toBeUndefined();
79+
expect((tracker as unknown as { _offlineQueue: string[] })._offlineQueue).toEqual([]);
80+
});
81+
82+
test("response cleanup failures do not replay an already acknowledged event", async () => {
83+
const tracker = new Tracker({ pid: "fixture-pid" });
84+
fetchSpy.mockResolvedValue(
85+
new Response(
86+
new ReadableStream({
87+
cancel() {
88+
return Promise.reject(new Error("cleanup failed"));
89+
},
90+
}),
91+
) as never,
92+
);
93+
await expect(
94+
tracker.send("event", { signal: new AbortController().signal }),
95+
).resolves.toBeUndefined();
96+
expect((tracker as unknown as { _offlineQueue: string[] })._offlineQueue).toEqual([]);
97+
});
98+
99+
test.each([false, true])(
100+
"Node telemetry owns its dispatcher without replacing the global one (proxy=%s)",
101+
async (proxy) => {
102+
vi.stubEnv("HTTP_PROXY", proxy ? "http://127.0.0.1:9" : "");
103+
vi.stubEnv("HTTPS_PROXY", proxy ? "http://127.0.0.1:9" : "");
104+
const globalDispatcher = transport.getGlobalDispatcher();
105+
const destroy = vi.spyOn(globalDispatcher, "destroy");
106+
try {
107+
await new Tracker({ pid: "fixture-pid" }).send("event", {
108+
signal: new AbortController().signal,
109+
});
110+
const dispatcher = fetchSpy.mock.calls[0]?.[1]?.dispatcher;
111+
expect(dispatcher).toBeInstanceOf(proxy ? transport.EnvHttpProxyAgent : transport.Agent);
112+
expect(dispatcher).not.toBe(globalDispatcher);
113+
expect(transport.getGlobalDispatcher()).toBe(globalDispatcher);
114+
expect(destroy).not.toHaveBeenCalled();
115+
} finally {
116+
vi.unstubAllEnvs();
117+
}
118+
},
119+
);
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { afterEach, expect, test, vi } from "vite-plus/test";
2+
import type { TrackingEvent } from "../src/telemetry/event.ts";
3+
4+
const fixture = vi.hoisted(() => ({
5+
signals: [] as Array<AbortSignal | undefined>,
6+
complete: false,
7+
}));
8+
vi.mock("../lib/remote-telemetry/tracker.cjs", () => ({
9+
default: class {
10+
send(_payload: string, options: { signal?: AbortSignal } = {}) {
11+
fixture.signals.push(options.signal);
12+
if (fixture.complete) return Promise.resolve();
13+
return new Promise<void>((resolve) =>
14+
options.signal?.addEventListener("abort", () => resolve(), { once: true }),
15+
);
16+
}
17+
use() {
18+
return () => {
19+
void this.send("event");
20+
};
21+
}
22+
_sendAll() {}
23+
},
24+
}));
25+
26+
afterEach(() => {
27+
vi.clearAllTimers();
28+
vi.useRealTimers();
29+
vi.resetModules();
30+
fixture.signals = [];
31+
fixture.complete = false;
32+
});
33+
34+
test("flush cancels in-flight work when its shorter shutdown budget expires", async () => {
35+
vi.useFakeTimers();
36+
const { remoteSink, flushTelemetry } = await import("../src/telemetry/sink.ts");
37+
await remoteSink({ command: "fixture" } as TrackingEvent);
38+
const pending = flushTelemetry(50);
39+
await vi.advanceTimersByTimeAsync(49);
40+
expect(fixture.signals).toHaveLength(1);
41+
expect(fixture.signals[0]?.aborted).toBe(false);
42+
await vi.advanceTimersByTimeAsync(1);
43+
await pending;
44+
expect(fixture.signals[0]?.aborted).toBe(true);
45+
expect(vi.getTimerCount()).toBe(0);
46+
});
47+
48+
test("send has a bounded lifetime even when a host does not flush", async () => {
49+
vi.useFakeTimers();
50+
const { remoteSink } = await import("../src/telemetry/sink.ts");
51+
await remoteSink({ command: "fixture" } as TrackingEvent);
52+
await vi.advanceTimersByTimeAsync(999);
53+
expect(fixture.signals[0]?.aborted).toBe(false);
54+
await vi.advanceTimersByTimeAsync(1);
55+
expect(fixture.signals[0]?.aborted).toBe(true);
56+
expect(vi.getTimerCount()).toBe(0);
57+
});
58+
59+
test("successful sending and repeated flushes leave no deadlines behind", async () => {
60+
vi.useFakeTimers();
61+
fixture.complete = true;
62+
const { remoteSink, flushTelemetry } = await import("../src/telemetry/sink.ts");
63+
await remoteSink({ command: "fixture" } as TrackingEvent);
64+
await flushTelemetry(50);
65+
await flushTelemetry(50);
66+
expect(fixture.signals[0]?.aborted).toBe(true);
67+
expect(vi.getTimerCount()).toBe(0);
68+
});

0 commit comments

Comments
 (0)