mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-29 01:41:13 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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" } }));
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user