[object Object]

← back to Costa Rica

costa-rica: harden fetchT's clone() + full body-method class, proven not asserted (TK-10346)

03b561700b505a705d973b2de86f43d291babde9 · 2026-09-23 19:29:24 -0700 · Steve

Closes the res.clone() gap Cody flagged as latent in cycle 6: a naked res.clone()
returned the RAW un-proxied Response, so a future retry-with-clone caller would get
an unbounded body read with zero indication anything's wrong. No current caller
uses clone() (reconfirmed via grep) — this is hardening-before-it-bites, not a live
bug fix.

Refactored fetchT's Proxy into a reusable wrap(r) applied to both the original
Response AND any res.clone() (clone tees the same incoming stream + shares the
abort signal, so an abort still aborts both readers). Also generalized coverage
from json()/text() only to the full body-consuming method class (arrayBuffer,
blob, formData, bytes) — a caller switching methods would otherwise silently
lose the bound.

Cody gate — SHIP IT, with one required addition: the code comment asserted
"once either read completes, a second read draws from already-buffered bytes"
(the safety argument for sharing one timer/done() across original+clone) but
NO TEST proved that specific compound claim — only individual body-method
bounding was tested. Cody built throwaway probes confirming it empirically
against real WHATWG stream tee() semantics, then required it ported into the
real suite as a red-goes-green guardrail rather than left as a trusted
paragraph. Added: a real delayed-ReadableStream Response, clone() BEFORE any
read, fully drain the ORIGINAL (clearing the shared timer), then assert the
CLONE resolves near-instantly off tee-buffered bytes rather than re-stalling
on the network with no timer left to bound it.

Cody also verified (clean, no defect): clone-of-clone doesn't double-wrap;
clone-after-consumed throws synchronously (a caller bug, correctly NOT
relabelled as PROVIDER_TIMEOUT); Reflect.get(target,prop,target) still passes
the correct receiver for brand-checked getters/Symbols after generalizing the
body-method set; a mock missing a body method degrades to plain delegation
(no crash).

Tests (+4, suite 135 -> 139): clone reads through the wrapper + original still
independently readable; a stalled clone body rejects PROVIDER_TIMEOUT (not
raw/unbounded); a stalled arrayBuffer() is bounded (the full method class, not
just json/text); the sequential clone-after-original-read proof (Cody-required).

Provider-agnostic hardening backlog (#4/#5/#6/#7/§5b#2 + this clone hygiene item)
is now fully closed. Remaining money-path items are live-credential-gated
(CR-KYC blocked).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic

Files touched

Diff

commit 03b561700b505a705d973b2de86f43d291babde9
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Sep 23 19:29:24 2026 -0700

    costa-rica: harden fetchT's clone() + full body-method class, proven not asserted (TK-10346)
    
    Closes the res.clone() gap Cody flagged as latent in cycle 6: a naked res.clone()
    returned the RAW un-proxied Response, so a future retry-with-clone caller would get
    an unbounded body read with zero indication anything's wrong. No current caller
    uses clone() (reconfirmed via grep) — this is hardening-before-it-bites, not a live
    bug fix.
    
    Refactored fetchT's Proxy into a reusable wrap(r) applied to both the original
    Response AND any res.clone() (clone tees the same incoming stream + shares the
    abort signal, so an abort still aborts both readers). Also generalized coverage
    from json()/text() only to the full body-consuming method class (arrayBuffer,
    blob, formData, bytes) — a caller switching methods would otherwise silently
    lose the bound.
    
    Cody gate — SHIP IT, with one required addition: the code comment asserted
    "once either read completes, a second read draws from already-buffered bytes"
    (the safety argument for sharing one timer/done() across original+clone) but
    NO TEST proved that specific compound claim — only individual body-method
    bounding was tested. Cody built throwaway probes confirming it empirically
    against real WHATWG stream tee() semantics, then required it ported into the
    real suite as a red-goes-green guardrail rather than left as a trusted
    paragraph. Added: a real delayed-ReadableStream Response, clone() BEFORE any
    read, fully drain the ORIGINAL (clearing the shared timer), then assert the
    CLONE resolves near-instantly off tee-buffered bytes rather than re-stalling
    on the network with no timer left to bound it.
    
    Cody also verified (clean, no defect): clone-of-clone doesn't double-wrap;
    clone-after-consumed throws synchronously (a caller bug, correctly NOT
    relabelled as PROVIDER_TIMEOUT); Reflect.get(target,prop,target) still passes
    the correct receiver for brand-checked getters/Symbols after generalizing the
    body-method set; a mock missing a body method degrades to plain delegation
    (no crash).
    
    Tests (+4, suite 135 -> 139): clone reads through the wrapper + original still
    independently readable; a stalled clone body rejects PROVIDER_TIMEOUT (not
    raw/unbounded); a stalled arrayBuffer() is bounded (the full method class, not
    just json/text); the sequential clone-after-original-read proof (Cody-required).
    
    Provider-agnostic hardening backlog (#4/#5/#6/#7/§5b#2 + this clone hygiene item)
    is now fully closed. Remaining money-path items are live-credential-gated
    (CR-KYC blocked).
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
---
 lib/payments/http.js          |  41 ++++++++++-----
 test/payments-timeout.test.js | 117 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 145 insertions(+), 13 deletions(-)

diff --git a/lib/payments/http.js b/lib/payments/http.js
index b55751a..2256bd8 100644
--- a/lib/payments/http.js
+++ b/lib/payments/http.js
@@ -82,24 +82,39 @@ async function fetchT(url, opts = {}) {
   // body-consuming methods; clear the timer once the body settles (success/error).
   let cleared = false;
   const done = () => { if (!cleared) { cleared = true; clearTimeout(timer); } };
-  const wrapBody = (fnName) => async (...args) => {
-    try {
-      return await res[fnName](...args);
-    } catch (e) {
-      throw asTimeout(e, ms, url, 'body read', ctl);
-    } finally {
-      done();
-    }
-  };
 
-  // Delegate everything to the real Response; only json()/text() are wrapped.
-  return new Proxy(res, {
-    get(target, prop, receiver) {
-      if (prop === 'json' || prop === 'text') return wrapBody(prop);
+  // Wrap a Response so its body-consuming methods (json/text) run under the shared
+  // deadline and relabel an abort as PROVIDER_TIMEOUT. Applied to the original AND
+  // to any clone(): a naked `res.clone()` returns the RAW Response, so
+  // `res.clone().json()` would be an UNBOUNDED body read with no signal that
+  // anything is wrong — the clone must carry the same bound. clone tees the same
+  // incoming stream and shares this signal, so an abort still aborts both readers;
+  // once either read completes it clears the shared timer, and a second read then
+  // draws from already-buffered bytes (no further network stall). No current caller
+  // uses clone(); this is hardening before a future retry-with-clone caller bites.
+  // Every body-consuming Response method — not just json()/text() — is an unbounded
+  // read that must run under the deadline (a caller could switch to arrayBuffer/blob
+  // and silently lose the bound). Wrap the whole class.
+  const BODY_METHODS = new Set(['json', 'text', 'arrayBuffer', 'blob', 'formData', 'bytes']);
+  const wrap = (r) => new Proxy(r, {
+    get(target, prop) {
+      if (typeof prop === 'string' && BODY_METHODS.has(prop) && typeof target[prop] === 'function') {
+        return async (...args) => {
+          try {
+            return await target[prop](...args);
+          } catch (e) {
+            throw asTimeout(e, ms, url, 'body read', ctl);
+          } finally {
+            done();
+          }
+        };
+      }
+      if (prop === 'clone') return () => wrap(target.clone());
       const v = Reflect.get(target, prop, target);
       return typeof v === 'function' ? v.bind(target) : v;
     },
   });
+  return wrap(res);
 }
 
 module.exports = { fetchT, timeoutMs };
diff --git a/test/payments-timeout.test.js b/test/payments-timeout.test.js
index 1ed06ab..fc16d59 100644
--- a/test/payments-timeout.test.js
+++ b/test/payments-timeout.test.js
@@ -61,6 +61,123 @@ test('fetchT: a fast response passes its metadata through (ok/status/headers rea
   }
 });
 
+test('fetchT: res.clone() returns a WRAPPED clone whose body read is bounded too (Cody cycle-6 latent)', async () => {
+  process.env.PROVIDER_HTTP_TIMEOUT_MS = '5000';
+  // A naked clone() would return the RAW Response, whose json() is unbounded. Prove
+  // the clone reads correctly through the wrapper, and the original stays readable
+  // (clone tees the stream). Real undici Response so clone() actually tees.
+  global.fetch = async () => new Response(JSON.stringify({ id: 'ch_clone' }),
+    { status: 200, headers: { 'content-type': 'application/json' } });
+  try {
+    const res = await fetchT('https://provider.test/clone');
+    const clone = res.clone();
+    assert.notStrictEqual(clone, res, 'clone is a distinct object');
+    assert.deepEqual(await clone.json(), { id: 'ch_clone' }, 'clone body reads through the wrapper');
+    assert.deepEqual(await res.json(), { id: 'ch_clone' }, 'original still independently readable after clone');
+  } finally {
+    restore();
+    delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
+  }
+});
+
+test('fetchT: SEQUENTIAL clone-after-original-read is safe — the clone does NOT re-stall on the network even though the shared deadline was already cleared (Cody gate, cycle 8)', async () => {
+  // The wrap()'d clone and original SHARE one timer/`done()`. Draining the original
+  // clears that timer. The safety of a clone read AFTER that point rests entirely on
+  // an unverified claim in the code comment: "once either read completes, a second
+  // read draws from already-buffered bytes." Cody required this be PROVEN, not just
+  // asserted — WHATWG stream tee() proactively buffers the not-yet-read branch as
+  // bytes are pulled by whichever branch IS being read, so the clone should resolve
+  // near-instantly off already-buffered bytes, never touching the (now unguarded)
+  // network wait. If tee did NOT buffer this way, the clone read would re-incur the
+  // full per-chunk delay with no timer to bound it — an unbounded hang, silently.
+  process.env.PROVIDER_HTTP_TIMEOUT_MS = '5000'; // generous; must not fire during the drain
+  const CHUNK_DELAY_MS = 30;
+  const CHUNKS = ['{"a":1,', '"b":2,', '"c":3}'];
+  global.fetch = async () => {
+    let i = 0;
+    const stream = new ReadableStream({
+      pull(controller) {
+        return new Promise((resolve) => {
+          setTimeout(() => {
+            if (i < CHUNKS.length) controller.enqueue(new TextEncoder().encode(CHUNKS[i++]));
+            else controller.close();
+            resolve();
+          }, CHUNK_DELAY_MS);
+        });
+      },
+    });
+    return new Response(stream, { status: 200, headers: { 'content-type': 'application/json' } });
+  };
+  try {
+    const res = await fetchT('https://provider.test/tee-sequential');
+    const clone = res.clone(); // clone BEFORE any read (clone-after-consumed throws)
+
+    const orig = await res.json(); // drains all 3 chunks x 30ms delay ≈ 90ms — clears the shared timer
+    assert.deepEqual(orig, { a: 1, b: 2, c: 3 });
+
+    const t1 = Date.now();
+    const cl = await clone.json(); // proves: does this re-stall (~90ms) or resolve off buffered bytes?
+    const cloneMs = Date.now() - t1;
+    assert.deepEqual(cl, { a: 1, b: 2, c: 3 }, 'clone parses the same body after the original was fully drained');
+    assert.ok(cloneMs < CHUNK_DELAY_MS * CHUNKS.length, `clone resolved in ${cloneMs}ms off tee-buffered bytes, not by re-waiting on the network (would be >=${CHUNK_DELAY_MS * CHUNKS.length}ms)`);
+  } finally {
+    restore();
+    delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
+  }
+});
+
+test('fetchT: a body-stalled CLONE also rejects with PROVIDER_TIMEOUT (clone carries the same bound, not raw)', async () => {
+  process.env.PROVIDER_HTTP_TIMEOUT_MS = '40';
+  // Response-like whose clone() yields a fresh Response-like whose json() hangs
+  // until the shared signal aborts. If clone() returned the RAW body (the pre-fix
+  // behavior) this would hang forever instead of rejecting.
+  const makeStallBody = (opts) => ({
+    ok: true, status: 200,
+    json: () => new Promise((_resolve, reject) => {
+      opts.signal.addEventListener('abort', () => { const e = new Error('The operation was aborted'); e.name = 'AbortError'; reject(e); });
+    }),
+    clone() { return makeStallBody(opts); },
+  });
+  global.fetch = (url, opts) => Promise.resolve(makeStallBody(opts));
+  try {
+    const res = await fetchT('https://provider.test/clonestall'); // headers fast
+    const clone = res.clone();
+    const started = Date.now();
+    await assert.rejects(
+      () => clone.json(),
+      (err) => {
+        assert.equal(err.code, 'PROVIDER_TIMEOUT', 'a stalled clone body is labelled PROVIDER_TIMEOUT');
+        assert.match(err.message, /body read timeout after 40ms/);
+        return true;
+      },
+    );
+    assert.ok(Date.now() - started < 2000, 'clone body aborted promptly, not hung');
+  } finally {
+    restore();
+    delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
+  }
+});
+
+test('fetchT: a stalled arrayBuffer() is bounded too (the whole body-method class, not just json/text)', async () => {
+  process.env.PROVIDER_HTTP_TIMEOUT_MS = '40';
+  global.fetch = (url, opts) => Promise.resolve({
+    ok: true, status: 200,
+    arrayBuffer: () => new Promise((_resolve, reject) => {
+      opts.signal.addEventListener('abort', () => { const e = new Error('aborted'); e.name = 'AbortError'; reject(e); });
+    }),
+  });
+  try {
+    const res = await fetchT('https://provider.test/binstall');
+    await assert.rejects(
+      () => res.arrayBuffer(),
+      (err) => { assert.equal(err.code, 'PROVIDER_TIMEOUT', 'arrayBuffer stall is bounded like json'); return true; },
+    );
+  } finally {
+    restore();
+    delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
+  }
+});
+
 test('fetchT: a header-fast / BODY-stalled response -> json() rejects with PROVIDER_TIMEOUT (PRE-FLIGHT #7)', async () => {
   process.env.PROVIDER_HTTP_TIMEOUT_MS = '40';
   // Headers arrive immediately; the body read hangs until the shared signal aborts.

← f4f8e10 cycle 7 docs: YOLO_NOTES ledger + GO-LIVE runbook — §5b #2 c  ·  back to Costa Rica  ·  cycle 8 docs: YOLO_NOTES ledger — fetchT clone() hardening, 2a75e80 →