fix(apns): keep diagnostics valid when response capture reaches its byte limit (#110483)

* fix(apns): preserve UTF-8 response captures

* test(apns): cover UTF-8 at the real capture limit

* fix(apns): preserve UTF-8 in bounded response captures

Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
xingzhou
2026-07-18 22:51:31 +08:00
committed by GitHub
parent a115af2774
commit 33151cbc40
4 changed files with 57 additions and 20 deletions

View File

@@ -64,8 +64,13 @@ const {
setEncoding: vi.fn(),
end: vi.fn(() => {
queueMicrotask(() => {
const responseBody = Buffer.from(
'{"reason":"InvalidProviderToken","detail":"split 🚀 response"}',
);
const emojiOffset = responseBody.indexOf(Buffer.from("🚀"));
fakeRequestLocal.emit("response", { ":status": 403 });
fakeRequestLocal.emit("data", '{"reason":"InvalidProviderToken"}');
fakeRequestLocal.emit("data", responseBody.subarray(0, emojiOffset + 2));
fakeRequestLocal.emit("data", responseBody.subarray(emojiOffset + 2));
fakeRequestLocal.emit("end");
});
}),
@@ -373,9 +378,10 @@ describe("connectApnsHttp2Session", () => {
expect(result).toEqual({
status: 403,
body: '{"reason":"InvalidProviderToken"}',
body: '{"reason":"InvalidProviderToken","detail":"split 🚀 response"}',
responseHeaders: {},
});
expect(fakeRequest.setEncoding).not.toHaveBeenCalled();
const tunnelCall = lastTunnelCall();
const proxyUrl = tunnelCall.proxyUrl;
expect(proxyUrl).toBeInstanceOf(URL);

View File

@@ -2,6 +2,7 @@
import { once } from "node:events";
import http2 from "node:http2";
import tls from "node:tls";
import { decodeTextPrefix } from "@openclaw/normalization-core";
import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
import { openProxyConnectTunnel } from "@openclaw/proxyline";
import { toErrorObject } from "./errors.js";
@@ -26,7 +27,8 @@ const APNS_RESPONSE_BODY_MAX_BYTES = 8192;
const APNS_HTTP2_MIN_TIMEOUT_MS = 1000;
type ApnsResponseBodyCapture = {
text: string;
chunks: Buffer[];
capturedBytes: number;
bytes: number;
truncated: boolean;
};
@@ -205,7 +207,7 @@ function resolveApnsHttp2TimeoutMs(timeoutMs: number): number {
}
export function createApnsResponseBodyCapture(): ApnsResponseBodyCapture {
return { text: "", bytes: 0, truncated: false };
return { chunks: [], capturedBytes: 0, bytes: 0, truncated: false };
}
export function appendApnsResponseBodyCapture(
@@ -213,20 +215,27 @@ export function appendApnsResponseBodyCapture(
chunk: unknown,
maxBytes = APNS_RESPONSE_BODY_MAX_BYTES,
): void {
const buffer = Buffer.from(String(chunk));
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
capture.bytes += buffer.byteLength;
const remaining = maxBytes - Buffer.byteLength(capture.text);
const remaining = maxBytes - capture.capturedBytes;
if (remaining <= 0) {
capture.truncated = capture.truncated || buffer.byteLength > 0;
return;
}
const slice = buffer.byteLength > remaining ? buffer.subarray(0, remaining) : buffer;
capture.text += slice.toString("utf8");
capture.chunks.push(Buffer.from(slice));
capture.capturedBytes += slice.byteLength;
if (slice.byteLength < buffer.byteLength) {
capture.truncated = true;
}
}
export function getApnsResponseBodyCaptureText(capture: ApnsResponseBodyCapture): string {
return decodeTextPrefix(Buffer.concat(capture.chunks, capture.capturedBytes), {
truncated: capture.truncated,
});
}
/** Sends an intentionally invalid APNs push through a proxy to prove HTTP/2 reachability. */
export async function probeApnsHttp2ReachabilityViaProxy(
params: ProbeApnsHttp2ReachabilityViaProxyParams,
@@ -278,7 +287,6 @@ export async function probeApnsHttp2ReachabilityViaProxy(
});
session.once("error", fail);
request.setEncoding("utf8");
request.on("response", (headers) => {
const rawStatus = headers[":status"];
status = typeof rawStatus === "number" ? rawStatus : Number(rawStatus);
@@ -302,7 +310,7 @@ export async function probeApnsHttp2ReachabilityViaProxy(
reject(new Error("APNs reachability probe ended without an HTTP/2 status"));
return;
}
resolve({ status, body: body.text, responseHeaders });
resolve({ status, body: getApnsResponseBodyCaptureText(body), responseHeaders });
});
request.end(JSON.stringify({ aps: { alert: "OpenClaw APNs proxy validation" } }));
});

View File

@@ -6,7 +6,11 @@ import net from "node:net";
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import { afterEach, describe, expect, it, vi } from "vitest";
import { startProxy, stopProxy, type ProxyHandle } from "./net/proxy/proxy-lifecycle.js";
import { appendApnsResponseBodyCapture, createApnsResponseBodyCapture } from "./push-apns-http2.js";
import {
appendApnsResponseBodyCapture,
createApnsResponseBodyCapture,
getApnsResponseBodyCaptureText,
} from "./push-apns-http2.js";
import {
sendApnsAlert,
sendApnsBackgroundWake,
@@ -292,11 +296,32 @@ describe("push APNs send semantics", () => {
appendApnsResponseBodyCapture(capture, "abc", 5);
appendApnsResponseBodyCapture(capture, "def", 5);
expect(capture).toEqual({
text: "abcde",
bytes: 6,
truncated: true,
});
expect(getApnsResponseBodyCaptureText(capture)).toBe("abcde");
expect(capture).toMatchObject({ capturedBytes: 5, bytes: 6, truncated: true });
});
it("preserves UTF-8 across HTTP/2 chunks and drops an incomplete capped suffix", () => {
const splitCapture = createApnsResponseBodyCapture();
const emoji = Buffer.from("🚀");
appendApnsResponseBodyCapture(splitCapture, Buffer.from("before "));
appendApnsResponseBodyCapture(splitCapture, emoji.subarray(0, 2));
appendApnsResponseBodyCapture(splitCapture, emoji.subarray(2));
appendApnsResponseBodyCapture(splitCapture, Buffer.from(" after"));
expect(getApnsResponseBodyCaptureText(splitCapture)).toBe("before 🚀 after");
const cappedCapture = createApnsResponseBodyCapture();
const cappedPrefix = "a".repeat(8191);
appendApnsResponseBodyCapture(cappedCapture, Buffer.from(`${cappedPrefix}🚀`));
expect(getApnsResponseBodyCaptureText(cappedCapture)).toBe(cappedPrefix);
expect(cappedCapture).toMatchObject({ capturedBytes: 8192, bytes: 8195, truncated: true });
});
it("preserves replacement decoding for a complete malformed APNs response", () => {
const capture = createApnsResponseBodyCapture();
appendApnsResponseBodyCapture(capture, Buffer.from([0x61, 0xf0, 0x9f]));
expect(getApnsResponseBodyCaptureText(capture)).toBe("a<>");
expect(capture).toMatchObject({ capturedBytes: 3, bytes: 3, truncated: false });
});
it("sends alert pushes with alert headers and payload", async () => {

View File

@@ -11,6 +11,7 @@ import {
appendApnsResponseBodyCapture,
connectApnsHttp2Session,
createApnsResponseBodyCapture,
getApnsResponseBodyCaptureText,
} from "./push-apns-http2.js";
import {
createApnsAlertPayload,
@@ -304,7 +305,6 @@ async function sendApnsRequest(params: {
let apnsId: string | undefined;
const responseBody = createApnsResponseBodyCapture();
req.setEncoding("utf8");
req.setTimeout(params.timeoutMs, () => {
req.close(APNS_HTTP2_CANCEL_CODE);
fail(new Error(`APNs request timed out after ${params.timeoutMs}ms`));
@@ -318,12 +318,10 @@ async function sendApnsRequest(params: {
}
});
req.on("data", (chunk) => {
if (typeof chunk === "string") {
appendApnsResponseBodyCapture(responseBody, chunk);
}
appendApnsResponseBodyCapture(responseBody, chunk);
});
req.on("end", () => {
finish({ status: statusCode, apnsId, body: responseBody.text });
finish({ status: statusCode, apnsId, body: getApnsResponseBodyCaptureText(responseBody) });
});
req.on("error", (err) => fail(err));