THRIFT-4987: Fix TBinaryProtocol support in XHRConnection
Client: js
Patch: CJCombrink
Generated-By: Co-authored by Claude AI (see file comments)
This closes #3341
diff --git a/lib/nodejs/lib/thrift/xhr_connection.js b/lib/nodejs/lib/thrift/xhr_connection.js
index ea542d1..d1b0387 100644
--- a/lib/nodejs/lib/thrift/xhr_connection.js
+++ b/lib/nodejs/lib/thrift/xhr_connection.js
@@ -108,17 +108,30 @@
var xreq = this.getXmlHttpRequestObject();
- if (xreq.overrideMimeType) {
- xreq.overrideMimeType("application/json");
+ if (this.protocol == TJSONProtocol) {
+ xreq.overrideMimeType('application/json');
+ } else {
+ xreq.responseType = 'arraybuffer';
+ xreq.overrideMimeType('application/octet-stream');
}
xreq.onreadystatechange = function () {
if (this.readyState == 4 && this.status == 200) {
- self.setRecvBuffer(this.responseText);
+ if(self.protocol == TJSONProtocol) {
+ self.setRecvBuffer(this.responseText);
+ }
+ else {
+ self.setRecvBuffer(this.response);
+ }
}
};
+ xreq.ontimeout = function(error) { self.emit("error", error); };
+ xreq.onerror = function(error) { self.emit("error", error); };
+
xreq.open("POST", this.url, true);
+ if (this.options.timeout)
+ xreq.timeout = this.options.timeout;
Object.keys(this.headers).forEach(function (headerKey) {
xreq.setRequestHeader(headerKey, self.headers[headerKey]);
diff --git a/lib/nodejs/test/testAll.sh b/lib/nodejs/test/testAll.sh
index 1823a8c..4f85ca4 100755
--- a/lib/nodejs/test/testAll.sh
+++ b/lib/nodejs/test/testAll.sh
@@ -127,6 +127,7 @@
node ${DIR}/int64.test.js || TESTOK=1
node ${DIR}/deep-constructor.test.js || TESTOK=1
node ${DIR}/include.test.mjs || TESTOK=1
+node ${DIR}/thrift_4987_xhr_protocol.test.mjs || TESTOK=1
# integration tests
diff --git a/lib/nodejs/test/thrift_4987_xhr_protocol.test.mjs b/lib/nodejs/test/thrift_4987_xhr_protocol.test.mjs
new file mode 100644
index 0000000..2c639ef
--- /dev/null
+++ b/lib/nodejs/test/thrift_4987_xhr_protocol.test.mjs
@@ -0,0 +1,623 @@
+#!/usr/bin/env node
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Regression test for THRIFT-4987:
+ * "TProtocolException: Bad version in readMessageBegin when using XHR
+ * client with binary protocol against a C++ server"
+ *
+ * Disclaimer: This file, except for this disclaimer, was written entirely by
+ * Claude. Claude needed a lot of guidance on getting to this solution to test
+ * the actual code in question which lead to finding an issue in the previous
+ * proposed solution and it was subsequently fixed. The Claude output was fixed
+ * using eslint.
+ *
+ * ─────────────────────────────────────────────────────────────────────────
+ * BUG ANATOMY
+ * ─────────────────────────────────────────────────────────────────────────
+ * File: lib/nodejs/lib/thrift/xhr_connection.js
+ *
+ * XHRConnection.flush() (line 103–121) builds an XMLHttpRequest and fires
+ * it. The onreadystatechange handler reads the server reply as:
+ *
+ * self.setRecvBuffer(this.responseText); // line 117 — THE BUG
+ *
+ * setRecvBuffer() (line 134) then does:
+ *
+ * if ([object ArrayBuffer]) { data = new Uint8Array(buf); } // line 140
+ * var thing = new Buffer(data || buf); // line 143
+ *
+ * When `buf` is a string (which it always is from line 117), the ArrayBuffer
+ * guard on line 140 is never reached — it is dead code. `new Buffer(string)`
+ * encodes the string as UTF-8: any character with code point 0x80–0xFF
+ * becomes a 2-byte UTF-8 sequence, inflating the buffer and shifting all
+ * subsequent byte positions.
+ *
+ * TBinaryProtocol and TCompactProtocol both start their REPLY messages with
+ * a byte >= 0x80 (the protocol identifier / version word), so after the
+ * corruption the first readI32/readByte reads a garbage value and throws
+ * TProtocolException.
+ *
+ * TJSONProtocol is NOT affected because its wire format is pure 7-bit ASCII
+ * — the Latin-1→UTF-8 round-trip is a no-op for bytes <= 0x7F. This is
+ * why xhr_connection.js defaulting to TJSONProtocol masked the bug for years.
+ *
+ * THE FIX (two lines in XHRConnection.flush()):
+ * xreq.responseType = 'arraybuffer'; // BEFORE xreq.open() — line ~105
+ * self.setRecvBuffer(this.response); // not this.responseText — line 117
+ *
+ * With responseType='arraybuffer', this.response is an ArrayBuffer, the
+ * guard on line 140 fires, and new Buffer(Uint8Array) copies bytes verbatim.
+ *
+ * ─────────────────────────────────────────────────────────────────────────
+ * TEST APPROACH
+ * ─────────────────────────────────────────────────────────────────────────
+ * We exercise XHRConnection.flush() and XHRConnection.setRecvBuffer()
+ * directly by replacing getXmlHttpRequestObject() with a MockXHR.
+ *
+ * MockXHR:
+ * - holds both responseText (Latin-1 string of the raw bytes) and
+ * response (actual ArrayBuffer of the raw bytes) pre-loaded from
+ * the raw wire-level REPLY buffer produced by the server protocol
+ * - its send() method fires onreadystatechange immediately, using
+ * whichever property flush() selected (responseType decides this):
+ * responseType === 'arraybuffer' → delivers this.response (ArrayBuffer)
+ * responseType === '' → delivers this.responseText (string)
+ *
+ * We also supply a minimal stub Thrift client attached to the connection
+ * (connection.client) so that __decodeCallback can look up recv_testI32.
+ * That stub captures the decoded result or error so the test can assert on it.
+ *
+ * Three scenarios per protocol:
+ * A reproduceIssue=true — _brokenFlush() active:
+ * verbatim copy of the unpatched XHRConnection.flush(), which uses
+ * responseText with no responseType set → new Buffer(string) → UTF-8
+ * inflation → TProtocolException thrown from __decodeCallback
+ *
+ * B reproduceIssue=false — real XHRConnection.prototype.flush() called:
+ * no flush() override on the instance; the library's own flush() runs.
+ * Once the fix (responseType='arraybuffer' + this.response) is applied
+ * to the library, this path produces a clean decode. If the library is
+ * still unpatched, this path fails too — making the regression visible.
+ *
+ * ─────────────────────────────────────────────────────────────────────────
+ * RUNNING (standalone, no server needed)
+ * ─────────────────────────────────────────────────────────────────────────
+ * cd <thrift-repo>/test/nodejs
+ * npm install thrift
+ * node thrift_4987_xhr_protocol.test.mjs
+ *
+ * ─────────────────────────────────────────────────────────────────────────
+ * WIRING INTO THE CROSS-LANGUAGE MAKE TARGET
+ * ─────────────────────────────────────────────────────────────────────────
+ * In test/nodejs/Makefile.am, add alongside the existing client.mjs target:
+ *
+ * check-THRIFT-4987:
+ * $(NODE) thrift_4987_xhr_protocol.test.mjs && echo "THRIFT-4987 PASS"
+ *
+ * No server is required; the test is entirely self-contained.
+ */
+
+import { createRequire } from "module";
+const require = createRequire(import.meta.url);
+
+const thrift = require("thrift");
+const XHRConnection =
+ require("thrift/lib/nodejs/lib/thrift/xhr_connection").XHRConnection;
+const TBufferedTransport = require("thrift/lib/nodejs/lib/thrift/buffered_transport");
+const TBinaryProtocol = require("thrift/lib/nodejs/lib/thrift/binary_protocol");
+const TCompactProtocol = require("thrift/lib/nodejs/lib/thrift/compact_protocol");
+const TJSONProtocol = require("thrift/lib/nodejs/lib/thrift/json_protocol");
+const { Thrift } = thrift;
+
+const GREEN = (s) => `\x1b[32m${s}\x1b[0m`;
+const RED = (s) => `\x1b[31m${s}\x1b[0m`;
+const BOLD = (s) => `\x1b[1m${s}\x1b[0m`;
+const DIM = (s) => `\x1b[2m${s}\x1b[0m`;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Encode a server-side TBinaryProtocol / TCompactProtocol / TJSONProtocol
+// REPLY for testI32(42). This is the raw buffer the server would send over
+// HTTP; what arrives at the browser depends on how XHR decodes the body.
+// ─────────────────────────────────────────────────────────────────────────────
+function encodeServerReply(ProtoClass, methodName, seqId, i32Value) {
+ let encoded = null;
+ const t = new TBufferedTransport(null, (buf) => {
+ encoded = buf;
+ });
+ const p = new ProtoClass(t);
+ p.writeMessageBegin(methodName, Thrift.MessageType.REPLY, seqId);
+ p.writeStructBegin("testI32_result");
+ p.writeFieldBegin("success", Thrift.Type.I32, 0);
+ p.writeI32(i32Value);
+ p.writeFieldEnd();
+ p.writeFieldStop();
+ p.writeStructEnd();
+ p.writeMessageEnd();
+ t.flush();
+ if (!encoded) throw new Error("TBufferedTransport did not flush");
+ return encoded;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// MockXHR — drop-in replacement for the browser XMLHttpRequest object.
+//
+// Holds two representations of the raw server reply:
+// responseText — Latin-1 string (charCode === byte value), as a browser
+// delivers when Content-Type has no charset or charset=latin-1
+// response — actual ArrayBuffer of the raw bytes, as a browser delivers
+// when xreq.responseType = 'arraybuffer'
+//
+// send() fires onreadystatechange synchronously (simulating a completed XHR).
+// It inspects this.responseType (set by flush() before open() in the fix) to
+// decide which property to hand back, exactly mirroring browser behaviour.
+// ─────────────────────────────────────────────────────────────────────────────
+class MockXHR {
+ constructor(rawBuf) {
+ this.readyState = 0;
+ this.status = 0;
+ this.responseType = ""; // flush() writes this; send() reads it back
+
+ // responseText: browser Latin-1 decoding of the raw HTTP body bytes.
+ // Each byte value maps 1:1 to a character code point.
+ let latin1 = "";
+ for (let i = 0; i < rawBuf.length; i++)
+ latin1 += String.fromCharCode(rawBuf[i]);
+ this.responseText = latin1;
+
+ // response: ArrayBuffer view of the same raw bytes (responseType='arraybuffer').
+ const ab = new ArrayBuffer(rawBuf.length);
+ new Uint8Array(ab).set(rawBuf);
+ this.response = ab;
+ }
+
+ overrideMimeType() {}
+
+ open() {
+ this.readyState = 1;
+ }
+
+ setRequestHeader() {}
+
+ send() {
+ this.readyState = 4;
+ this.status = 200;
+ // Mirror browser: deliver response vs responseText based on responseType.
+ // This is the decision point under test:
+ // - responseType === '' → this.responseText is a string → bug path
+ // - responseType === 'arraybuffer' → this.response is an ArrayBuffer → fix path
+ if (this.responseType === "arraybuffer") {
+ // Temporarily shadow responseText so setRecvBuffer can only get the ArrayBuffer
+ const savedText = this.responseText;
+ this.responseText = null;
+ this.onreadystatechange();
+ this.responseText = savedText;
+ } else {
+ // Temporarily shadow response so setRecvBuffer can only get the string
+ const savedResponse = this.response;
+ this.response = null;
+ this.onreadystatechange();
+ this.response = savedResponse;
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// TestableXHRConnection — subclass of XHRConnection that injects MockXHR.
+//
+// getXmlHttpRequestObject() is always overridden so the real network is never
+// touched; MockXHR pre-loads the raw server reply and fires onreadystatechange
+// synchronously when send() is called.
+//
+// reproduceIssue (boolean):
+// true — also overrides flush() with the original broken implementation
+// (responseText, no responseType) to confirm the bug is present in
+// the unpatched code path. _brokenFlush() is a verbatim copy of
+// the buggy XHRConnection.flush() so the test documents exactly
+// what is wrong.
+//
+// false — does NOT override flush(); the real XHRConnection.flush() from
+// the library is called as-is. Once the fix is applied to the
+// library, this path must produce a clean decode. If flush() is
+// still broken in the library, this path will also fail — which
+// is precisely the point of the regression test.
+// ─────────────────────────────────────────────────────────────────────────────
+class TestableXHRConnection extends XHRConnection {
+ constructor(rawReplyBuf, options, reproduceIssue) {
+ // XHRConnection constructor accesses window.location when host/port are
+ // omitted; supply dummy values to prevent that reference error in Node.js.
+ super("localhost", 9090, options);
+ this._rawReplyBuf = rawReplyBuf;
+ this._reproduceIssue = reproduceIssue;
+
+ // Bind the broken flush onto this instance only when reproducing the bug.
+ // This leaves the prototype chain untouched so the fixed path genuinely
+ // calls XHRConnection.prototype.flush with no override in the way.
+ if (reproduceIssue) {
+ this.flush = this._brokenFlush.bind(this);
+ }
+ // When reproduceIssue is false, flush is not set here, so this.flush
+ // resolves to XHRConnection.prototype.flush — the real library code.
+ }
+
+ getXmlHttpRequestObject() {
+ return new MockXHR(this._rawReplyBuf);
+ }
+
+ // Verbatim copy of the buggy XHRConnection.flush() (xhr_connection.js
+ // lines 103-127) before the fix was applied.
+ // Used only when reproduceIssue=true.
+ // The two lines that constitute the fix are deliberately absent:
+ // MISSING: xreq.responseType = 'arraybuffer';
+ // MISSING: self.setRecvBuffer(this.response); (uses responseText instead)
+ /* eslint-disable */
+ _brokenFlush() {
+ var self = this;
+ if (this.url === undefined || this.url === "") {
+ return this.send_buf;
+ }
+
+ var xreq = this.getXmlHttpRequestObject();
+
+ if (xreq.overrideMimeType) {
+ xreq.overrideMimeType("application/json");
+ }
+
+ xreq.onreadystatechange = function () {
+ if (this.readyState == 4 && this.status == 200) {
+ self.setRecvBuffer(this.responseText);
+ }
+ };
+
+ xreq.open("POST", this.url, true);
+
+ Object.keys(this.headers).forEach(function (headerKey) {
+ xreq.setRequestHeader(headerKey, self.headers[headerKey]);
+ });
+
+ xreq.send(this.send_buf);
+ }
+ /* eslint-enable */
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Minimal stub Thrift client.
+//
+// XHRConnection.__decodeCallback() calls:
+// client._reqs[dummy_seqid] = function(err, success) { ... }
+// client['recv_' + header.fname](proto, header.mtype, dummy_seqid)
+//
+// The real generated client's recv_testI32 reads the i32 from the protocol
+// and invokes the callback stored in _reqs[seqid]. We supply a lightweight
+// version that captures the result so the test can assert on it.
+// ─────────────────────────────────────────────────────────────────────────────
+function makeStubClient(ProtoClass, seqId, onResult) {
+ return {
+ _reqs: {
+ // The real seqid callback — called by our recv_ after it reads the value
+ [seqId]: (err, val) => onResult(err, val),
+ },
+ recv_testI32(proto) {
+ // Mirrors what the generated client recv_ method does:
+ // read the success field from the REPLY struct, then invoke callback
+ let result = null;
+ proto.readStructBegin();
+ while (true) {
+ const field = proto.readFieldBegin();
+ if (field.ftype === Thrift.Type.STOP) break;
+ if (field.fid === 0 && field.ftype === Thrift.Type.I32) {
+ result = proto.readI32();
+ } else {
+ proto.skip(field.ftype);
+ }
+ proto.readFieldEnd();
+ }
+ proto.readStructEnd();
+ proto.readMessageEnd();
+ // Invoke the callback registered by __decodeCallback under dummy_seqid
+ const dummy_seqid = seqId * -1;
+ const cb = this._reqs[dummy_seqid];
+ if (cb) cb(null, result);
+ },
+ };
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Run one scenario against a real XHRConnection.flush() call.
+//
+// Flow:
+// 1. Build raw server REPLY buffer (what the server would write on the wire)
+// 2. Create a TestableXHRConnection with a MockXHR pre-loaded with that buffer
+// 3. Attach a stub client with a recv_testI32 and a seqid callback
+// 4. Write the encoded client REQUEST into the connection's send buffer,
+// then call flush() — this is either _brokenFlush() (reproduceIssue=true)
+// or the real XHRConnection.prototype.flush() (reproduceIssue=false)
+// 5. MockXHR.send() fires onreadystatechange synchronously; depending on
+// whether responseType='arraybuffer' was set by flush(), it delivers
+// either this.response (ArrayBuffer) or this.responseText (string)
+// 6. setRecvBuffer() → transport.receiver() → __decodeCallback()
+// → proto.readMessageBegin() → recv_testI32 (success) or throw (bug)
+// ─────────────────────────────────────────────────────────────────────────────
+async function runScenario(ProtoClass, reproduceIssue) {
+ const METHOD = "testI32",
+ SEQ_ID = 1,
+ VALUE = 42;
+
+ // The raw buffer the server sends on the wire
+ const rawReplyBuf = encodeServerReply(ProtoClass, METHOD, SEQ_ID, VALUE);
+
+ return new Promise((resolve) => {
+ let settled = false;
+ const done = (err, val) => {
+ if (!settled) {
+ settled = true;
+ resolve({ err, val });
+ }
+ };
+
+ const conn = new TestableXHRConnection(
+ rawReplyBuf,
+ { transport: TBufferedTransport, protocol: ProtoClass },
+ reproduceIssue,
+ );
+
+ // Listen for errors emitted by the connection on non-protocol failures.
+ // NOTE: __decodeCallback in xhr_connection.js throws TProtocolException
+ // directly (it does not call self.emit('error')), so protocol errors are
+ // caught via the try/catch around conn.flush() below, not here.
+ conn.on("error", (e) => done(e, null));
+
+ // Attach the stub client
+ conn.client = makeStubClient(ProtoClass, SEQ_ID, done);
+
+ // Encode a minimal client-side REQUEST (just enough to have a send_buf
+ // so flush() proceeds past the early-return guard).
+ // We write directly to the underlying transport the same way createClient does.
+ const writeCb = (buf) => {
+ conn.send_buf = buf;
+ };
+ const sendTransport = new TBufferedTransport(undefined, writeCb);
+ sendTransport.setCurrSeqId(SEQ_ID);
+ const sendProto = new ProtoClass(sendTransport);
+ sendProto.writeMessageBegin(METHOD, Thrift.MessageType.CALL, SEQ_ID);
+ sendProto.writeStructBegin("testI32_args");
+ sendProto.writeFieldBegin("thing", Thrift.Type.I32, 1);
+ sendProto.writeI32(VALUE);
+ sendProto.writeFieldEnd();
+ sendProto.writeFieldStop();
+ sendProto.writeStructEnd();
+ sendProto.writeMessageEnd();
+ sendTransport.flush(); // populates conn.send_buf via writeCb
+
+ // Now fire the actual XHRConnection.flush().
+ // NOTE: XHRConnection.__decodeCallback() re-throws TProtocolException
+ // directly (line 199 of xhr_connection.js) rather than emitting 'error'
+ // as http_connection.js does. The throw propagates synchronously back
+ // through setRecvBuffer → MockXHR.send() → flush() so we catch it here.
+ try {
+ conn.flush();
+ } catch (e) {
+ done(e, null);
+ }
+
+ // Guard: if neither the error event nor the recv_ callback fired
+ // synchronously (nor a thrown exception), the mock wiring is broken.
+ if (!settled)
+ done(
+ new Error("Neither result nor error received — check MockXHR wiring"),
+ null,
+ );
+ });
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Protocol table
+// ─────────────────────────────────────────────────────────────────────────────
+const PROTOCOLS = [
+ {
+ name: "TBinaryProtocol",
+ Class: TBinaryProtocol,
+ affected: true,
+ // Version word first byte: 0x80 → inflates to 0xC2 0x80 via UTF-8
+ symptom: "Bad version in readMessageBegin",
+ },
+ {
+ name: "TCompactProtocol",
+ Class: TCompactProtocol,
+ affected: true,
+ // PROTOCOL_ID first byte: 0x82 → inflates to 0xC2 0x82 via UTF-8
+ symptom: "Bad protocol identifier",
+ },
+ {
+ name: "TJSONProtocol",
+ Class: TJSONProtocol,
+ affected: false,
+ // Wire format is pure 7-bit ASCII — no bytes >= 0x80, no inflation
+ symptom: null,
+ },
+];
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Main
+// ─────────────────────────────────────────────────────────────────────────────
+async function main() {
+ console.log(
+ BOLD(
+ "\nTHRIFT-4987 — XHRConnection.flush() + setRecvBuffer() regression test",
+ ),
+ );
+ console.log(
+ DIM("Exercises real XHRConnection code paths via MockXHR injection\n"),
+ );
+
+ let totalPass = 0,
+ totalFail = 0;
+ const summary = [];
+
+ for (const proto of PROTOCOLS) {
+ console.log(BOLD(`${"─".repeat(68)}`));
+ console.log(
+ BOLD(
+ ` ${proto.name} ${proto.affected ? RED("(AFFECTED)") : GREEN("(NOT AFFECTED)")}`,
+ ),
+ );
+ console.log(BOLD(`${"─".repeat(68)}`));
+ console.log(
+ ` ${DIM(
+ proto.affected
+ ? `First wire byte >= 0x80 → inflates under UTF-8 → corrupt → "${proto.symptom}"`
+ : "Wire format is 7-bit ASCII → Latin-1→UTF-8 round-trip is a no-op",
+ )}\n`,
+ );
+
+ let scenPass = 0,
+ scenFail = 0;
+
+ // ── Scenario A: BUG path — _brokenFlush() active (reproduceIssue=true) ──
+ {
+ const { err, val } = await runScenario(proto.Class, true);
+ process.stdout.write(
+ ` Scenario A ${DIM("reproduceIssue=true — _brokenFlush() (responseText, no responseType)")}:\n`,
+ );
+
+ if (proto.affected) {
+ // Expect an error containing the known symptom string
+ if (err && err.message && err.message.includes(proto.symptom)) {
+ console.log(
+ GREEN(
+ ` ✓ PASS — XHRConnection emitted error: ${err.constructor.name}: ${err.message}`,
+ ),
+ );
+ scenPass++;
+ } else if (err) {
+ console.log(
+ RED(
+ ` ✗ FAIL — got error but wrong message: ${err.constructor.name}: ${err.message}`,
+ ),
+ );
+ scenFail++;
+ } else {
+ console.log(
+ RED(
+ ` ✗ FAIL — expected error "${proto.symptom}" but got val=${val}`,
+ ),
+ );
+ scenFail++;
+ }
+ } else {
+ // JSON: not affected, should decode cleanly on bug path too
+ if (!err && val === 42) {
+ console.log(
+ GREEN(
+ ` ✓ PASS — decoded correctly (JSON is immune): val=${val}`,
+ ),
+ );
+ scenPass++;
+ } else if (err) {
+ console.log(
+ RED(
+ ` ✗ FAIL — unexpected error: ${err.constructor.name}: ${err.message}`,
+ ),
+ );
+ scenFail++;
+ } else {
+ console.log(RED(` ✗ FAIL — unexpected val=${val}`));
+ scenFail++;
+ }
+ }
+ }
+
+ // ── Scenario B: FIX path — real XHRConnection.flush() (reproduceIssue=false) ──
+ {
+ const { err, val } = await runScenario(proto.Class, false);
+ process.stdout.write(
+ ` Scenario B ${DIM("reproduceIssue=false — real XHRConnection.prototype.flush() from library")}:\n`,
+ );
+
+ if (!err && val === 42) {
+ console.log(GREEN(` ✓ PASS — decoded correctly: val=${val}`));
+ scenPass++;
+ } else if (err) {
+ console.log(
+ RED(
+ ` ✗ FAIL — unexpected error on fix path: ${err.constructor.name}: ${err.message}`,
+ ),
+ );
+ scenFail++;
+ } else {
+ console.log(RED(` ✗ FAIL — wrong value: val=${val}`));
+ scenFail++;
+ }
+ }
+
+ totalPass += scenPass;
+ totalFail += scenFail;
+ const status = scenFail === 0 ? GREEN("PASS") : RED("FAIL");
+ summary.push({
+ name: proto.name,
+ affected: proto.affected,
+ scenPass,
+ scenFail,
+ status,
+ });
+ console.log();
+ }
+
+ // ── Summary ────────────────────────────────────────────────────────────────
+ console.log(BOLD(`${"═".repeat(68)}`));
+ console.log(BOLD(" SUMMARY"));
+ console.log(BOLD(`${"═".repeat(68)}`));
+ console.log(
+ ` ${"Protocol".padEnd(22)} ${"Affected".padEnd(14)} ${"Scenarios".padEnd(14)} Result`,
+ );
+ console.log(` ${"─".repeat(64)}`);
+ for (const r of summary) {
+ const aff = r.affected ? RED("YES") : GREEN("NO");
+ const sc = `${r.scenPass}/${r.scenPass + r.scenFail} passed`;
+ console.log(
+ ` ${r.name.padEnd(22)} ${aff.padEnd(22)} ${sc.padEnd(14)} ${r.status}`,
+ );
+ }
+ console.log(BOLD(`${"═".repeat(68)}`));
+ console.log(`\n Total: ${totalPass} passed, ${totalFail} failed\n`);
+
+ if (totalFail > 0) {
+ console.log(
+ RED(" REGRESSION: unexpected scenario result(s) — see above.\n"),
+ );
+ process.exit(1);
+ } else {
+ console.log(GREEN(" All scenarios passed."));
+ console.log(
+ " TBinaryProtocol and TCompactProtocol fail on the bug path (scenario A)",
+ );
+ console.log(
+ " and pass on the fix path (scenario B, real XHRConnection.prototype.flush).",
+ );
+ console.log(
+ " TJSONProtocol passes on both paths (immune due to 7-bit ASCII wire format).\n",
+ );
+ }
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});