diff --git a/src/core/png.ts b/src/core/png.ts index 37258a1c..fae935f9 100644 --- a/src/core/png.ts +++ b/src/core/png.ts @@ -1,5 +1,5 @@ /** - * Minimal PNG encoder (grayscale + RGB, 8-bit, filter=None, single IDAT). + * Minimal PNG encoder (grayscale + RGB, 8-bit, filter=Average, single IDAT). * Pure Uint8Array — uses CompressionStream (Node 18+, Workers, browsers); no Buffer/node:zlib. */ @@ -77,6 +77,40 @@ async function deflateZlib(input: Uint8Array): Promise { const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +/** + * Prepend scanline filter bytes using PNG's Average filter (type 3): + * Filt(x) = Orig(x) − floor((Recon(a) + Recon(b)) / 2) + * where `a` is the same channel of the pixel to the left (x − bpp) and `b` the byte directly + * above. Encoding may read the ORIGINAL bytes for a/b because the decoder reconstructs those + * positions exactly before it needs them — the transform is bit-exact reversible, so decoded + * pixels are byte-identical to `pixels`. Nothing about the image the model sees changes. + * + * Chosen over filter=None (the previous behavior): residuals of 5×8 bitmap glyphs on a flat + * background collapse to mostly zeros, which deflate encodes in both less space and less time — + * ~34% smaller IDAT at roughly half the compression cost on a representative dense page. That + * matters because every image is re-uploaded on every turn, so IDAT size is a per-request + * bandwidth cost. Filtering is pure JS ahead of the compressor, so it stays portable to + * Workers unlike a deflate-level change (see the CompressionStream note above). + */ +function filterAverage(pixels: Uint8Array, width: number, height: number, bpp: number): Uint8Array { + const rowBytes = width * bpp; + const stride = rowBytes + 1; + const out = new Uint8Array(stride * height); + for (let y = 0; y < height; y++) { + const src = y * rowBytes; + const dst = y * stride; + out[dst] = 3; // filter: Average + for (let x = 0; x < rowBytes; x++) { + // Off-image neighbors are defined as zero by the spec, not clamped/wrapped. + const a = x >= bpp ? pixels[src + x - bpp]! : 0; + const b = y > 0 ? pixels[src - rowBytes + x]! : 0; + // a + b ≤ 510 so the shift is exact; only the result wraps to a byte. + out[dst + 1 + x] = (pixels[src + x]! - ((a + b) >> 1)) & 0xff; + } + } + return out; +} + /** Encode a single-channel (grayscale) buffer as PNG bytes. pixels is row-major, length = width × height. */ export async function encodeGrayPng(pixels: Uint8Array, width: number, height: number): Promise { if (pixels.length !== width * height) { @@ -90,15 +124,7 @@ export async function encodeGrayPng(pixels: Uint8Array, width: number, height: n ihdr[8] = 8; ihdr[9] = 0; // colorType 0 = grayscale; bytes 10-12 already zero - // Prepend per-scanline filter byte (0 = None). - const stride = width + 1; - const raw = new Uint8Array(stride * height); - for (let y = 0; y < height; y++) { - raw[y * stride] = 0; // filter: None - raw.set(pixels.subarray(y * width, (y + 1) * width), y * stride + 1); - } - - const compressed = await deflateZlib(raw); + const compressed = await deflateZlib(filterAverage(pixels, width, height, 1)); return concat([ PNG_SIGNATURE, @@ -120,15 +146,7 @@ export async function encodeRgbPng(pixels: Uint8Array, width: number, height: nu ihdr[8] = 8; // bit depth per channel ihdr[9] = 2; // colorType 2 = truecolor RGB; bytes 10-12 already zero - // Prepend per-scanline filter byte (0 = None). - const stride = width * 3 + 1; - const raw = new Uint8Array(stride * height); - for (let y = 0; y < height; y++) { - raw[y * stride] = 0; // filter: None - raw.set(pixels.subarray(y * width * 3, (y + 1) * width * 3), y * stride + 1); - } - - const compressed = await deflateZlib(raw); + const compressed = await deflateZlib(filterAverage(pixels, width, height, 3)); return concat([ PNG_SIGNATURE, diff --git a/src/core/proxy.ts b/src/core/proxy.ts index a657dead..1234823a 100644 --- a/src/core/proxy.ts +++ b/src/core/proxy.ts @@ -82,6 +82,10 @@ export interface ProxyEvent { durationMs: number; /** Wall-clock ms from request start to upstream response headers. */ firstByteMs?: number; + /** Wall-clock ms spent in the local transform (render + encode), excluding any + * upstream probe. `durationMs - transformMs` is the upstream half, so a slow + * request can be attributed to our own CPU vs the provider without guessing. */ + transformMs?: number; info?: TransformInfo; /** Usage block from Anthropic's response — input/output/cache tokens. */ usage?: Usage; @@ -1007,6 +1011,8 @@ export function createProxy(config: ProxyConfig = {}) { let reqBodyBytes: Uint8Array | undefined; let reqBodySha8: string | undefined; let reqBodySha256: string | undefined; + // Set once the transform returns; read by fire() at event time. + let transformMs: number | undefined; const fire = ( status: number, @@ -1088,6 +1094,7 @@ export function createProxy(config: ProxyConfig = {}) { status, durationMs: Date.now() - t0, firstByteMs, + transformMs, info, usage: eventUsage, error, @@ -1218,6 +1225,10 @@ export function createProxy(config: ProxyConfig = {}) { : bridgedChatMessages ? anthropicMessagesToOpenAIChat(bodyIn, chatStamp ?? undefined) : bodyIn; + // Local render+encode cost only. The Google branch below issues upstream + // count_tokens probes, so the timer closes here rather than after them — + // otherwise network latency would be charged to our own CPU. + const tTransform = Date.now(); let r = isGoogle ? await transformGoogleGenerateContent(bodyIn, model!, effectiveOpts) : isMessages @@ -1229,6 +1240,7 @@ export function createProxy(config: ProxyConfig = {}) { : isOpenAIChat ? await transformOpenAIChatCompletions(bodyIn, effectiveOpts) : await transformOpenAIResponses(bodyIn, effectiveOpts); + transformMs = Date.now() - tTransform; if (isGoogle && r.info.compressed) { const countHeaders = applyGatewayHeaders(filterHeaders(req.headers, STRIP_REQ_HEADERS)); countHeaders.set('content-type', 'application/json'); diff --git a/src/core/render.ts b/src/core/render.ts index 5c40ed72..77de173f 100644 --- a/src/core/render.ts +++ b/src/core/render.ts @@ -1048,6 +1048,84 @@ export async function renderTextToPngsReflow( } /** Split text into N PNGs each ≤ MAX_HEIGHT_PX tall, respecting per-image char budget. */ +// --- rendered-page cache --------------------------------------------------- + +/** + * Rendering is a pure function of (text, cols, maxCharsPerImage, style, maxHeightPx, + * slotText): the atlases are decoded once at module init and never mutated, and no + * module-level state feeds the output. So identical inputs can safely return the + * identical pages instead of being re-rasterized. + * + * This is worth a cache because the proxy deliberately re-renders the same bytes every + * turn: history chunks are frozen so they stay byte-identical for the upstream prompt + * cache (see the cache-stability note in history.ts), which means a long session pays + * full render cost for pages that provably did not change. Measured on a real session, + * ~134 images cost ~10s of single-threaded CPU per request while the payload hash held + * constant across consecutive turns. + * + * Bounded by total retained bytes (PNG payloads + the key strings, which embed the + * source text and would otherwise be the larger half). Eviction is LRU via Map's + * insertion order: re-inserting on hit moves an entry to the newest position. + */ +const RENDER_CACHE_MAX_BYTES = (() => { + // Edge-safe: `process` is undefined off-Node. + const raw = typeof process !== 'undefined' ? process.env?.PXPIPE_RENDER_CACHE_BYTES : undefined; + const parsed = raw !== undefined && raw.trim() !== '' ? Number(raw) : NaN; + // 0 disables the cache outright; negative/garbage falls back to the default. + if (Number.isFinite(parsed) && parsed >= 0) return Math.floor(parsed); + // 64 MiB holds several turns of a heavy session while staying well inside a + // Workers isolate's memory ceiling. + return 64 * 1024 * 1024; +})(); + +interface RenderCacheEntry { + readonly images: readonly RenderedImage[]; + readonly bytes: number; +} + +const renderCache = new Map(); +let renderCacheBytes = 0; +let renderCacheHits = 0; +let renderCacheMisses = 0; + +/** Observability for the dashboard/tests. Bytes counts retained PNG + key bytes. */ +export function renderCacheStats(): { + entries: number; + bytes: number; + hits: number; + misses: number; +} { + return { entries: renderCache.size, bytes: renderCacheBytes, hits: renderCacheHits, misses: renderCacheMisses }; +} + +/** Drop every entry. Tests use this to isolate hit/miss accounting. */ +export function clearRenderCache(): void { + renderCache.clear(); + renderCacheBytes = 0; + renderCacheHits = 0; + renderCacheMisses = 0; +} + +/** Stable serialization of RenderStyle. Every field is a primitive (see the interface), + * so String() is unambiguous; sorting keys removes any dependence on literal order. + * Undefined values are skipped so `{}` and `{aa: undefined}` share one entry. */ +function styleCacheKey(style: RenderStyle): string { + const src = style as Record; + let out = ''; + for (const k of Object.keys(src).sort()) { + const v = src[k]; + if (v !== undefined) out += `${k}=${String(v)};`; + } + return out; +} + +/** Copy the array and each entry's mutable `droppedCodepoints` map so a caller cannot + * mutate cached state. The PNG bytes are shared deliberately — copying multi-MB + * payloads on every hit would defeat the point; callers only base64-encode them. */ +function cloneForCaller(images: readonly RenderedImage[]): RenderedImage[] { + return images.map((img) => ({ ...img, droppedCodepoints: new Map(img.droppedCodepoints) })); +} + export async function renderTextToPngsWithCharLimit( text: string, cols: number = DEFAULT_COLS, @@ -1055,6 +1133,60 @@ export async function renderTextToPngsWithCharLimit( style: RenderStyle = {}, maxHeightPx: number = MAX_HEIGHT_PX, slotText?: string, +): Promise { + if (RENDER_CACHE_MAX_BYTES === 0) { + return renderTextToPngsUncached(text, cols, maxCharsPerImage, style, maxHeightPx, slotText); + } + // The key embeds the source text verbatim rather than a digest, so it is + // collision-free by construction — a digest collision here would serve the WRONG + // image, a silent correctness bug far worse than a cache miss. + // + // `slotText` is length-prefixed because it and `text` are both free-form: plain + // concatenation would give ("a b", "c") and ("a", "b c") one key. -1 marks absent, + // which must stay distinct from empty — renderTextToPngsUncached branches on + // `slotText !== undefined`. The numeric and style segments contain no `|` (style + // values are booleans, numbers, and dashed string unions), so the fixed-arity + // prefix can never be confused with content. + const slotLen = slotText === undefined ? -1 : slotText.length; + const key = + `${cols}|${maxCharsPerImage}|${maxHeightPx}|${styleCacheKey(style)}|${slotLen}|${slotText ?? ''}${text}`; + const hit = renderCache.get(key); + if (hit !== undefined) { + renderCacheHits++; + renderCache.delete(key); // re-insert to refresh LRU position + renderCache.set(key, hit); + return cloneForCaller(hit.images); + } + renderCacheMisses++; + + const images = await renderTextToPngsUncached(text, cols, maxCharsPerImage, style, maxHeightPx, slotText); + + // Key strings are UTF-16 in memory; 2 bytes/unit is the honest estimate. + let bytes = key.length * 2; + for (const img of images) bytes += img.png.byteLength; + // A single render larger than the whole budget is not cacheable — storing it would + // evict everything and then itself. Serve it and move on. + if (bytes <= RENDER_CACHE_MAX_BYTES) { + renderCache.set(key, { images, bytes }); + renderCacheBytes += bytes; + // Evict oldest-first until back within budget. Map iteration is insertion order. + for (const [k, v] of renderCache) { + if (renderCacheBytes <= RENDER_CACHE_MAX_BYTES) break; + if (k === key) continue; // never evict the entry just stored + renderCache.delete(k); + renderCacheBytes -= v.bytes; + } + } + return cloneForCaller(images); +} + +async function renderTextToPngsUncached( + text: string, + cols: number, + maxCharsPerImage: number, + style: RenderStyle, + maxHeightPx: number, + slotText?: string, ): Promise { const markerScale = Math.max(1, Math.floor(style.markerScale ?? 1)); const cellH = renderCellHeight(style); diff --git a/src/core/tracker.ts b/src/core/tracker.ts index 4c59b775..d565231b 100644 --- a/src/core/tracker.ts +++ b/src/core/tracker.ts @@ -18,6 +18,8 @@ export interface TrackEvent { status: number; duration_ms: number; first_byte_ms?: number; + /** Local render+encode ms. `duration_ms - transform_ms` isolates upstream. */ + transform_ms?: number; // From TransformInfo: compressed?: boolean; @@ -188,6 +190,7 @@ export function toTrackEvent(ev: ProxyEvent): TrackEvent { if (ev.model) out.model = ev.model; if (ev.accountingProvider) out.accounting_provider = ev.accountingProvider; if (ev.firstByteMs !== undefined) out.first_byte_ms = ev.firstByteMs; + if (ev.transformMs !== undefined) out.transform_ms = ev.transformMs; if (ev.error) out.error = ev.error; if (ev.errorBody) out.error_body = ev.errorBody; if (ev.reqBodySha8) out.req_body_sha8 = ev.reqBodySha8; diff --git a/src/node.ts b/src/node.ts index 76f77444..48d4f6ff 100644 --- a/src/node.ts +++ b/src/node.ts @@ -245,6 +245,10 @@ Environment: PXPIPE_LOG JSONL events path (default ~/.pxpipe/events.jsonl) PXPIPE_DUMP_DIR debug: write every rendered PNG here (what the model sees); off unless set. Compress arm only. + PXPIPE_RENDER_CACHE_BYTES max bytes of rendered pages to keep in memory + (default 64 MiB). Frozen history chunks are + byte-identical across turns, so re-rendering them is + wasted CPU; 0 disables the cache. PXPIPE_DEBUG_CAPTURE_4XX debug: set to 1 to persist full 4xx request and upstream error bodies (prompts + any secrets in context) to disk. Off by default. @@ -1173,8 +1177,25 @@ async function main(): Promise { e.usage !== undefined ? ` tokens=${inputTokens}+${e.usage.output_tokens ?? 0} cache_read=${cacheRead}` : ''; + // Split the wall clock into the half we control and the half we don't: + // `tx` is local render+encode, the remainder is upstream. Without this the + // duration alone can't distinguish our CPU from a slow provider. + // + // `fb` further splits the upstream half: request start → response headers, + // so it covers upload + provider queue/processing but NOT generation. With + // all three, `fb - tx` isolates how much a large image payload costs to put + // on the wire, which is the number that decides whether shrinking IDATs pays. + const timingParts = [`${e.durationMs}ms`]; + if (e.transformMs !== undefined) { + timingParts.push( + `tx=${e.transformMs}ms`, + `up=${Math.max(0, e.durationMs - e.transformMs)}ms`, + ); + } + if (e.firstByteMs !== undefined) timingParts.push(`fb=${e.firstByteMs}ms`); + const timing = timingParts.join(' '); console.log( - `[${new Date().toISOString()}] ${e.method} ${e.path} → ${e.status} (${e.durationMs}ms) ${tag}${usageTag}`, + `[${new Date().toISOString()}] ${e.method} ${e.path} → ${e.status} (${timing}) ${tag}${usageTag}`, ); // Upstream error bodies are present only under PXPIPE_DEBUG_CAPTURE_4XX; diff --git a/tests/png-lossless.test.ts b/tests/png-lossless.test.ts new file mode 100644 index 00000000..80c51f30 --- /dev/null +++ b/tests/png-lossless.test.ts @@ -0,0 +1,72 @@ +import { createCanvas, loadImage } from '@napi-rs/canvas'; +import { describe, expect, it } from 'vitest'; +import { encodeGrayPng, encodeRgbPng } from '../src/core/png.js'; + +/** + * The encoder applies PNG's Average scanline filter, which must be bit-exact + * reversible: the model has to see the pixels the renderer drew, not an + * approximation. Verified with skia (@napi-rs/canvas) rather than a hand-rolled + * decoder, so a bug in the filter can't be masked by the same bug in the check. + * + * `loadImage` awaits the decode. `new Image()` + `.src` does NOT, and silently + * yields a blank canvas that passes any comparison against blank expectations. + */ +async function decode(png: Uint8Array): Promise<{ data: Uint8ClampedArray; w: number; h: number }> { + const img = await loadImage(Buffer.from(png)); + const canvas = createCanvas(img.width, img.height); + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); + return { data: ctx.getImageData(0, 0, img.width, img.height).data, w: img.width, h: img.height }; +} + +// Deliberately not a multiple of 8, so the last partial byte-group is exercised. +const W = 259; +const H = 131; + +describe('PNG encoder is lossless', () => { + it('round-trips grayscale pixels bit-for-bit', async () => { + // Includes 0 and 255 (the filter residual wraps past both) and the mid greys + // the antialiased atlas actually emits. + const pixels = new Uint8Array(W * H); + const palette = [0, 31, 68, 119, 255, 1, 254, 128]; + for (let i = 0; i < pixels.length; i++) pixels[i] = palette[i % palette.length]!; + + const out = await decode(await encodeGrayPng(pixels, W, H)); + expect([out.w, out.h]).toEqual([W, H]); + + let firstDiff = -1; + for (let i = 0; i < W * H; i++) { + if (out.data[i * 4] !== pixels[i]) { firstDiff = i; break; } + } + // Row 0 has no upper neighbour and column 0 no left neighbour; both are + // defined as zero by the spec, and both are covered by scanning from index 0. + expect(firstDiff).toBe(-1); + }); + + it('round-trips RGB pixels bit-for-bit', async () => { + const pixels = new Uint8Array(W * H * 3); + for (let i = 0; i < pixels.length; i++) pixels[i] = (i * 37 + (i % 5)) & 255; + + const out = await decode(await encodeRgbPng(pixels, W, H)); + expect([out.w, out.h]).toEqual([W, H]); + + let firstDiff = -1; + outer: for (let i = 0; i < W * H; i++) { + for (let c = 0; c < 3; c++) { + // Filtering is per-channel at bpp=3: the left neighbour is 3 bytes back, + // so a wrong bpp shows up as channel bleed rather than a whole-image break. + if (out.data[i * 4 + c] !== pixels[i * 3 + c]) { firstDiff = i * 3 + c; break outer; } + } + } + expect(firstDiff).toBe(-1); + }); + + it('preserves a solid run and a single-pixel image', async () => { + const solid = new Uint8Array(64 * 4).fill(200); + const s = await decode(await encodeGrayPng(solid, 64, 4)); + expect([...new Set(Array.from({ length: 64 * 4 }, (_, i) => s.data[i * 4]))]).toEqual([200]); + + const one = await decode(await encodeGrayPng(new Uint8Array([137]), 1, 1)); + expect(one.data[0]).toBe(137); + }); +}); diff --git a/tests/render-cache.test.ts b/tests/render-cache.test.ts new file mode 100644 index 00000000..d6e156ad --- /dev/null +++ b/tests/render-cache.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + clearRenderCache, + renderCacheStats, + renderTextToPngsWithCharLimit, + type RenderStyle, +} from '../src/core/render.js'; + +const bytesEqual = (a: Uint8Array, b: Uint8Array): boolean => + a.length === b.length && a.every((v, i) => v === b[i]); + +const TEXT = 'const x = 1;\nfunction f(a, b) { return a + b; }\n// comment line\n'; + +describe('rendered-page cache', () => { + beforeEach(() => { + clearRenderCache(); + }); + + it('returns byte-identical pages on a repeat render', async () => { + const first = await renderTextToPngsWithCharLimit(TEXT, 64); + const second = await renderTextToPngsWithCharLimit(TEXT, 64); + + expect(renderCacheStats()).toMatchObject({ hits: 1, misses: 1 }); + expect(second).toHaveLength(first.length); + for (let i = 0; i < first.length; i++) { + expect(bytesEqual(first[i]!.png, second[i]!.png)).toBe(true); + expect([second[i]!.width, second[i]!.height]).toEqual([first[i]!.width, first[i]!.height]); + } + }); + + // Each of these must MISS. A key that ignored any of them would serve a stale + // image that silently misrepresents the text — worse than no cache at all. + it('keys on every input that changes the output', async () => { + await renderTextToPngsWithCharLimit(TEXT, 64); + const base = renderCacheStats().misses; + + await renderTextToPngsWithCharLimit(`${TEXT}extra`, 64); // text + await renderTextToPngsWithCharLimit(TEXT, 48); // cols + await renderTextToPngsWithCharLimit(TEXT, 64, 500); // maxCharsPerImage + await renderTextToPngsWithCharLimit(TEXT, 64, undefined, { aa: true }); // style + await renderTextToPngsWithCharLimit(TEXT, 64, undefined, undefined, 512); // maxHeightPx + + expect(renderCacheStats().misses).toBe(base + 5); + expect(renderCacheStats().hits).toBe(0); + }); + + it('distinguishes an absent slotText from an empty one', async () => { + const style: RenderStyle = { colorByRole: true }; + await renderTextToPngsWithCharLimit(TEXT, 64, undefined, style, undefined, undefined); + await renderTextToPngsWithCharLimit(TEXT, 64, undefined, style, undefined, ''); + + // undefined disables the slot pass entirely; '' does not. Same entry would be wrong. + expect(renderCacheStats()).toMatchObject({ hits: 0, misses: 2 }); + }); + + it('is not fooled by a slotText/text split ambiguity', async () => { + // Naive concatenation gives ("a b", "c") and ("a", "b c") the same key. + const style: RenderStyle = { colorByRole: true }; + await renderTextToPngsWithCharLimit('c', 64, undefined, style, undefined, 'a b'); + await renderTextToPngsWithCharLimit('b c', 64, undefined, style, undefined, 'a'); + + expect(renderCacheStats()).toMatchObject({ hits: 0, misses: 2 }); + }); + + it('treats style key order and undefined fields as the same entry', async () => { + await renderTextToPngsWithCharLimit(TEXT, 64, undefined, { aa: true, grid: false }); + await renderTextToPngsWithCharLimit(TEXT, 64, undefined, { grid: false, aa: true }); + await renderTextToPngsWithCharLimit(TEXT, 64, undefined, { + grid: false, + aa: true, + inkDilate: undefined, + }); + + expect(renderCacheStats()).toMatchObject({ hits: 2, misses: 1 }); + }); + + it('hands each caller its own droppedCodepoints map', async () => { + // Callers merge this map into their own totals; a shared instance would let one + // request's mutation leak into every later cache hit. + const first = await renderTextToPngsWithCharLimit(TEXT, 64); + first[0]!.droppedCodepoints.set(0x41, 999); + + const second = await renderTextToPngsWithCharLimit(TEXT, 64); + expect(second[0]!.droppedCodepoints.get(0x41)).toBeUndefined(); + }); + + it('accounts bytes and clears them', async () => { + await renderTextToPngsWithCharLimit(TEXT, 64); + const stats = renderCacheStats(); + expect(stats.entries).toBe(1); + expect(stats.bytes).toBeGreaterThan(0); + + clearRenderCache(); + expect(renderCacheStats()).toEqual({ entries: 0, bytes: 0, hits: 0, misses: 0 }); + }); +}); + +// The byte budget is read once at module init, so these load a fresh copy of the +// module under a different env instead of mutating a live cache. +async function freshRender(maxBytes: string) { + vi.resetModules(); + vi.stubEnv('PXPIPE_RENDER_CACHE_BYTES', maxBytes); + const mod = await import('../src/core/render.js'); + mod.clearRenderCache(); + return mod; +} + +describe('rendered-page cache budget', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('evicts least-recently-used entries once the budget is exceeded', async () => { + const A = 'alpha alpha alpha\n'.repeat(40); + const B = 'beta beta beta\n'.repeat(40); + const C = 'gamma gamma gamma\n'.repeat(40); + + // Derive the budget from a real entry instead of guessing a constant: room for + // roughly two of these, so the third must evict the first. + const probe = await freshRender(String(64 * 1024 * 1024)); + await probe.renderTextToPngsWithCharLimit(A, 64); + const oneEntry = probe.renderCacheStats().bytes; + + const mod = await freshRender(String(Math.floor(oneEntry * 2.5))); + const render = (t: string) => mod.renderTextToPngsWithCharLimit(t, 64); + + await render(A); + await render(B); + await render(C); + + const stats = mod.renderCacheStats(); + expect(stats.bytes).toBeLessThanOrEqual(Math.floor(oneEntry * 2.5)); + expect(stats.entries).toBe(2); // A was evicted to make room for C + + // The survivors still hit; the evicted entry must miss and be re-rendered. + const before = mod.renderCacheStats(); + await render(C); + expect(mod.renderCacheStats().hits).toBe(before.hits + 1); + await render(A); + expect(mod.renderCacheStats().misses).toBe(before.misses + 1); + }); + + it('keeps the byte counter exact rather than drifting', async () => { + const big = String(64 * 1024 * 1024); + const a = 'alpha alpha\n'.repeat(40); + const b = 'beta beta beta\n'.repeat(40); + + // Measure each entry's cost in isolation, then together: the counter is only + // correct if the combined total is exactly the sum, with no double-count on + // the LRU re-insert path and no leftover from an eviction. + const m1 = await freshRender(big); + await m1.renderTextToPngsWithCharLimit(a, 64); + const bytesA = m1.renderCacheStats().bytes; + + const m2 = await freshRender(big); + await m2.renderTextToPngsWithCharLimit(b, 64); + const bytesB = m2.renderCacheStats().bytes; + + const m3 = await freshRender(big); + await m3.renderTextToPngsWithCharLimit(a, 64); + await m3.renderTextToPngsWithCharLimit(b, 64); + await m3.renderTextToPngsWithCharLimit(a, 64); // hit: must not re-add bytes + expect(m3.renderCacheStats()).toMatchObject({ entries: 2, hits: 1, bytes: bytesA + bytesB }); + }); + + it('never stores an entry larger than the whole budget', async () => { + const mod = await freshRender('2000'); // smaller than a single page + await mod.renderTextToPngsWithCharLimit('x '.repeat(2000), 64); + + const stats = mod.renderCacheStats(); + expect(stats.entries).toBe(0); + expect(stats.bytes).toBe(0); + }); + + it('PXPIPE_RENDER_CACHE_BYTES=0 disables caching entirely', async () => { + const mod = await freshRender('0'); + await mod.renderTextToPngsWithCharLimit('hello world', 64); + await mod.renderTextToPngsWithCharLimit('hello world', 64); + + expect(mod.renderCacheStats()).toEqual({ entries: 0, bytes: 0, hits: 0, misses: 0 }); + }); +}); diff --git a/tests/transform-ms.test.ts b/tests/transform-ms.test.ts new file mode 100644 index 00000000..f5863454 --- /dev/null +++ b/tests/transform-ms.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { createProxy, type ProxyEvent } from '../src/core/proxy.js'; + +function mockUpstream(handler: (req: Request) => Promise | Response) { + const real = globalThis.fetch; + globalThis.fetch = ((req: Request | string | URL, init?: RequestInit) => { + const r = req instanceof Request ? req : new Request(String(req), init); + return Promise.resolve(handler(r)); + }) as typeof fetch; + return () => { + globalThis.fetch = real; + }; +} + +// Large enough to actually render images, so transformMs reflects real render cost +// rather than a no-op classification pass — but no larger. This file runs in +// parallel with render-heavy e2e suites that sit close to the 30s testTimeout, +// and extra CPU contention here shows up as a timeout over there. +const BIG_SYSTEM = Array.from({ length: 500 }, (_, i) => + `[${i}] export function handler_${i}(req,res){const x=req.body?.value??${i};return res.json({ok:true,x});}`, +).join('\n'); + +const BODY = JSON.stringify({ + model: 'claude-opus-5', // must be in the default PXPIPE_MODELS scope or nothing compresses + messages: [{ role: 'user', content: 'hi' }], + system: BIG_SYSTEM, +}); + +const UPSTREAM_STALL_MS = 300; + +async function roundTrip(force: boolean): Promise { + const restore = mockUpstream(async () => { + await new Promise((r) => setTimeout(r, UPSTREAM_STALL_MS)); + return new Response( + JSON.stringify({ + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'ok' }], + usage: { input_tokens: 1, output_tokens: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }); + let captured: ProxyEvent | undefined; + const proxy = createProxy({ + transform: force ? { charsPerToken: 1, minCompressChars: 1 } : { compress: false }, + onRequest: (e) => { + captured = e; + }, + }); + const res = await proxy( + new Request('http://localhost/v1/messages', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: BODY, + }), + ); + // Drain the client body so the tee finishes, then give onRequest a tick. + await res.text(); + await new Promise((r) => setTimeout(r, 20)); + restore(); + return captured; +} + +describe('transformMs', () => { + it('charges render cost to transform and upstream latency to the remainder', async () => { + const ev = await roundTrip(true); + + // Guard the premise: without images this would pass trivially. + expect(ev?.info?.compressed).toBe(true); + expect(ev!.info!.imageCount).toBeGreaterThan(0); + + expect(ev?.transformMs).toBeTypeOf('number'); + expect(ev!.transformMs!).toBeLessThanOrEqual(ev!.durationMs); + // The stall must land on the upstream side, never be charged to transform. + expect(ev!.durationMs - ev!.transformMs!).toBeGreaterThanOrEqual(UPSTREAM_STALL_MS - 20); + // Rendering real images costs real time; a near-zero value means the timer + // is measuring the wrong span. + expect(ev!.transformMs!).toBeGreaterThan(0); + }); + + it('is still reported when the request passes through uncompressed', async () => { + const ev = await roundTrip(false); + + expect(ev?.info?.compressed).toBe(false); + expect(ev?.transformMs).toBeTypeOf('number'); + expect(ev!.durationMs - ev!.transformMs!).toBeGreaterThanOrEqual(UPSTREAM_STALL_MS - 20); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index a25ed313..26545703 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,10 @@ export default defineConfig({ include: ['tests/**/*.test.ts'], environment: 'node', // Render-heavy tests (full-page PNG encodes) can exceed the 5s default on - // slower machines; the work is CPU-bound, not hung. - testTimeout: 30_000, + // slower machines; the work is CPU-bound, not hung. 30s was not enough + // headroom: the append-only cache-stability case alone runs ~30s, so it + // timed out purely from CPU contention whenever another file joined the + // parallel pool — a failure that says nothing about the code under test. + testTimeout: 60_000, }, });