Some checks failed
Quality gates / quality (pull_request) Failing after 2m39s
Closes the PR 63 hostile-review blockers: 1. Immutable pinned Python/Pillow runtime (TIMMY_PYTHON absolute, --verify-pin), deployment/runtime re-encode smoke gate in build_release + deploy_staging. 2. Header-only width/height/total-pixel/bomb rejection before full decode; proves 6000x6000 and 12000x12000 stay resource bounded (RSS + address-space caps). 3. Fail-fast decoder concurrency ceiling; tests count actual spawned children. 4. Rate-limiter key cardinality hard-bounded under 20k+ unexpired identities, trusted-loopback-proxy identity, no spoofable forwarded headers, fixed-window boundary burst smoothed by two-window sliding count. 5. build_release explicitly syntax/gates every new JS module + Python re-encoder + production-runtime smoke; CI runs reencode-image test and the runtime pin smoke. 6. Robust polyglot contract via canonical container parsing; rejects uppercase/mixed script, appended HTML, ZIP local/EOCD and archive tails, data after canonical JPEG/PNG/WebP end; no naive compressed-byte scans (false-positive controls pass). 7. Canonical base64 data-URL grammar with byte-exact round trip; rejects missing/excess padding, whitespace/CRLF, malformed and noncanonical encodings; exact MIME policy. 8. Missing Pillow / runtime-unavailable maps to sanitized 503 + manual fallback. 9. Inbound body-read timeout and stop-on-oversize; preserves sanitized 413, base path, provider suppression, and temp cleanup; socket torn down on rejection. Audited prior partial edits: reused the sound source modules, re-wired new tests into the unit/syntax gates, fixed a non-canonical readJson that destroyed the socket before delivering 413/408, and hardened test flakiness (port reuse, EPIPE, startup races).
147 lines
6.5 KiB
JavaScript
147 lines
6.5 KiB
JavaScript
// Concurrency ceiling contract.
|
|
//
|
|
// The decoder runs as a subprocess and each one costs real memory. The service
|
|
// runs under MemoryMax=512M, so a burst must be bounded by a small fail-fast
|
|
// ceiling rather than queued indefinitely: excess requests are refused
|
|
// immediately with a sanitized retry message, and the number of decoder
|
|
// children alive at once never exceeds the ceiling.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { chmod, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import {
|
|
INGRESS_MAX_CONCURRENCY,
|
|
IngressUnavailableError,
|
|
ingressConcurrencyState,
|
|
validateImageIngress,
|
|
} from '../src/image-ingress.js';
|
|
|
|
const fixture = (name) => fileURLToPath(new URL(`../tests/fixtures/${name}`, import.meta.url));
|
|
const clean = await readFile(fixture('ingress-clean.jpg'));
|
|
const cleanDataUrl = `data:image/jpeg;base64,${clean.toString('base64')}`;
|
|
|
|
// An instrumented stand-in interpreter: it records one marker file per spawn
|
|
// while it is alive, so the test can observe the true peak child count instead
|
|
// of trusting an internal counter.
|
|
async function trackingInterpreter(name, holdSeconds = '0.6') {
|
|
const dir = join(tmpdir(), `timmy-ingress-conc-${name}`);
|
|
await rm(dir, { recursive: true, force: true });
|
|
await mkdir(join(dir, 'live'), { recursive: true });
|
|
await mkdir(join(dir, 'seen'), { recursive: true });
|
|
const path = join(dir, 'python3');
|
|
await writeFile(path, [
|
|
'#!/bin/sh',
|
|
`marker="${dir}/live/$$"`,
|
|
`touch "$marker" "${dir}/seen/$$"`,
|
|
`sleep ${holdSeconds}`,
|
|
'rm -f "$marker"',
|
|
// Emit a valid success verdict and write the expected output file.
|
|
'out=""',
|
|
'while [ $# -gt 0 ]; do',
|
|
' if [ "$1" = "--out" ]; then out="$2"; fi',
|
|
' shift',
|
|
'done',
|
|
`cp "${fixture('ingress-clean.jpg')}" "$out"`,
|
|
'echo \'{"ok": true, "format": "jpeg", "width": 64, "height": 64, "bytes": 639, "metadataStripped": true}\'',
|
|
].join('\n'));
|
|
await chmod(path, 0o755);
|
|
return { path, dir };
|
|
}
|
|
|
|
const withPython = async (path, run) => {
|
|
const previous = process.env.TIMMY_PYTHON;
|
|
process.env.TIMMY_PYTHON = path;
|
|
try {
|
|
return await run();
|
|
} finally {
|
|
if (previous === undefined) delete process.env.TIMMY_PYTHON;
|
|
else process.env.TIMMY_PYTHON = previous;
|
|
}
|
|
};
|
|
|
|
test('the concurrency ceiling is small enough to stay inside the service memory budget', () => {
|
|
assert.equal(typeof INGRESS_MAX_CONCURRENCY, 'number');
|
|
assert.ok(INGRESS_MAX_CONCURRENCY >= 1, 'at least one decode must be permitted');
|
|
assert.ok(INGRESS_MAX_CONCURRENCY <= 4,
|
|
`ceiling ${INGRESS_MAX_CONCURRENCY} is too high for a 512 MiB service`);
|
|
// Worst observed single-decoder peak is ~24 MiB; the whole burst must leave
|
|
// ample headroom under MemoryMax=512M alongside the Node process itself.
|
|
assert.ok(INGRESS_MAX_CONCURRENCY * 64 * 1024 * 1024 < 512 * 1024 * 1024,
|
|
'the ceiling must bound peak decoder memory well under 512 MiB');
|
|
});
|
|
|
|
test('a burst beyond the ceiling never runs more decoder children than the ceiling', async () => {
|
|
const { path, dir } = await trackingInterpreter('peak');
|
|
await withPython(path, async () => {
|
|
let peakLive = 0;
|
|
const sampler = setInterval(async () => {
|
|
try {
|
|
const live = await readdir(join(dir, 'live'));
|
|
peakLive = Math.max(peakLive, live.length);
|
|
} catch { /* directory races are fine */ }
|
|
}, 15);
|
|
const burst = await Promise.allSettled(
|
|
Array.from({ length: 24 }, () => validateImageIngress({ consent: true, imageDataUrl: cleanDataUrl })),
|
|
);
|
|
clearInterval(sampler);
|
|
const spawned = (await readdir(join(dir, 'seen'))).length;
|
|
const rejected = burst.filter((r) => r.status === 'rejected');
|
|
|
|
assert.ok(peakLive <= INGRESS_MAX_CONCURRENCY,
|
|
`observed ${peakLive} concurrent decoder children, ceiling is ${INGRESS_MAX_CONCURRENCY}`);
|
|
assert.ok(spawned <= INGRESS_MAX_CONCURRENCY,
|
|
`a 24-request burst spawned ${spawned} decoders; only ${INGRESS_MAX_CONCURRENCY} may run and the rest must be refused`);
|
|
assert.ok(rejected.length >= 24 - INGRESS_MAX_CONCURRENCY,
|
|
'excess requests must be refused rather than queued');
|
|
});
|
|
});
|
|
|
|
test('over-ceiling requests fail fast with a sanitized unavailable message', async () => {
|
|
const { path } = await trackingInterpreter('failfast');
|
|
await withPython(path, async () => {
|
|
const started = Date.now();
|
|
const burst = await Promise.allSettled(
|
|
Array.from({ length: 12 }, () => validateImageIngress({ consent: true, imageDataUrl: cleanDataUrl })),
|
|
);
|
|
const rejected = burst.filter((r) => r.status === 'rejected').map((r) => r.reason);
|
|
assert.ok(rejected.length > 0, 'a 12-request burst must refuse some requests');
|
|
for (const error of rejected) {
|
|
assert.ok(error instanceof IngressUnavailableError,
|
|
'an over-capacity refusal is a server-capacity condition, not corrupt client input');
|
|
assert.match(error.message, /temporarily unavailable|slow down|try again/i);
|
|
assert.doesNotMatch(error.message, /corrupt|malformed/i);
|
|
assert.doesNotMatch(error.message, /[A-Za-z0-9+/]{40,}/, 'no payload data in refusals');
|
|
assert.ok(error.message.length < 200);
|
|
}
|
|
// Fail-fast: refusals must not wait for the in-flight decoders to finish.
|
|
assert.ok(Date.now() - started < 5000, 'refusals must be immediate, not queued behind decodes');
|
|
});
|
|
});
|
|
|
|
test('capacity is released so later requests succeed after a burst', async () => {
|
|
const { path } = await trackingInterpreter('release', '0.05');
|
|
await withPython(path, async () => {
|
|
await Promise.allSettled(
|
|
Array.from({ length: 12 }, () => validateImageIngress({ consent: true, imageDataUrl: cleanDataUrl })),
|
|
);
|
|
const state = ingressConcurrencyState();
|
|
assert.equal(state.active, 0, 'every slot must be released after the burst settles');
|
|
const after = await validateImageIngress({ consent: true, imageDataUrl: cleanDataUrl });
|
|
assert.equal(after.format, 'jpeg');
|
|
});
|
|
});
|
|
|
|
test('rejected input releases its slot too', async () => {
|
|
const garbage = await readFile(fixture('ingress-garbage.jpg'));
|
|
for (let i = 0; i < INGRESS_MAX_CONCURRENCY + 3; i += 1) {
|
|
await assert.rejects(() => validateImageIngress({
|
|
consent: true, imageDataUrl: `data:image/jpeg;base64,${garbage.toString('base64')}`,
|
|
}));
|
|
}
|
|
assert.equal(ingressConcurrencyState().active, 0,
|
|
'a rejected request must not leak a concurrency slot');
|
|
});
|