Files
SnapOtter/docker/verify-bundle-compatibility.sh
T
SnapOtterandGitHub 60d01ab2dd fix: release-acceptance QA follow-ups (upload crash, scipy ABI conflict, rate limit, OCR fallback) (#458)
* fix(api): prevent a crash when an over-limit upload stream has no consumer yet

busboy's "limit" handler destroyed the file stream with an error but never
attached its own error listener, relying entirely on whatever consumes
part.file downstream to do so. On a fast enough connection (or a fully
buffered body, e.g. Fastify inject()), busboy can process enough bytes to
hit the size limit before the route handler's receiveUpload() call has
attached its own stream listener, leaving the resulting "error" event with
zero listeners -- which crashes the whole process by default in Node.

Surfaced by tonight's FULL_MATRIX+FUZZ integration run (880 uncaught
exceptions, all the same root cause). Reproduces deterministically in
isolation; unrelated to this release's actual code delta (file untouched
since PR #413, well before the baseline QA pass).

Fix: attach a baseline no-op error listener the moment the stream is
created, guaranteeing at least one listener always exists. EventEmitter
delivers "error" to every registered listener, so the real consumer's own
error handling is unaffected.

* fix(ai-bundles): rebuild upscale-enhance and photo-restoration to reconcile scipy ABI

upscale-enhance and photo-restoration both depend on codeformer-pip, whose
transitive closure (basicsr -> realesrgan -> gfpgan) pulls in an unpinned
scipy. Both bundles were last built ~June 18-19, before PR #437 added the
manifest's `constraints` array (numpy==1.26.4, scipy==1.12.0, etc.) to pin
exactly this kind of dependency during bundle builds. Only the ocr bundle
was rebuilt after that fix landed.

install_feature.py has no pip install step -- it's a raw tarfile extraction
with no cross-bundle conflict resolution, so installing OCR alongside either
stale bundle left three incompatible scipy versions' files mixed in the same
site-packages directory (a compiled _rotation.*.so from one release next to
Python files expecting a different release's API), breaking the `upscale`
tool and OCR's higher-quality tiers with an ImportError.

Rebuilt both bundles for amd64-gpu and arm64-cpu from the current manifest,
verified scipy/scikit-learn/scikit-image/pandas all resolve to the pinned
versions in the tarballs themselves, then verified end-to-end on real
hardware (Mac arm64 CPU and ubuntu_gpu .248 RTX 4070): installing all
affected bundles together now yields exactly one version of each constrained
package, `upscale` produces correct output, and OCR's balanced/best tiers
correctly use PaddleOCR-GPU instead of erroring out.

Published the rebuilt tarballs to the public deepsafe/feature-bundles
HuggingFace repo and updated this manifest's sha256/sizes to match.

Also adds verify-bundle-compatibility.sh: verify-bundle.sh checks each
bundle in isolation (a fresh venv per bundle), which is exactly why this
shipped twice -- nothing ever checked that bundles built at different times
agree once layered into the one shared venv real installs use. The new
script installs every bundle for an arch into one venv and asserts each
constrained package has exactly one, correct version.

Known follow-up (not fixed here, needs separate discussion): uninstalling a
bundle only removes its downloaded model weights, never the site-packages
it added, so existing installations that already hit this bug have no clean
self-service fix via uninstall+reinstall -- they need a full AI-venv wipe.

* fix(docker): bake a real rate limit default for the all-in-one one-liner

The documented single-container `docker run` install had RATE_LIMIT_PER_MIN=0
(effectively unlimited, ~50k/min) baked in, since only docker-compose.yml
carried a hardened override. A self-hoster following the one-liner path got
no meaningful throttling anywhere, including auth-adjacent routes with no
dedicated per-route limit. Bakes a generous-but-real 1000/min default into
the Dockerfile, raises both compose files' fallback to match so the two
documented install paths converge on the same posture, and updates the Zod
schema default plus docs that quoted the old value.

* fix(api): boot log undercounted tool routes by the conversion-preset total

The "Tool routes: N active" line logged before registerConversionPresets(app)
ran, so it only ever reported the base 158 tools, 83 short of the real
241-tool total. Presets have to register after the base loop (they delegate
to each base tool's own processV2), so the fix moves the log line to after
that call and has registerConversionPresets return its count instead of
reordering the dependency.

* fix(ai): forward {info}/{warning} stderr JSON instead of dropping it

The dispatcher stderr parser only recognized {ready} and {progress,stage}
shaped JSON lines; anything else that parsed as valid JSON (like ocr.py's
GPU-to-tesseract downgrade notice, an {"info": ...} line) matched neither
branch and fell through silently, never reaching docker logs. Adds explicit
{info}/{warning} handling that forwards to console.log/console.warn, same as
the existing [prefix]-tagged non-JSON path.

* fix(api): fall back to a lower OCR tier when PaddleOCR itself is unusable

ocr.ts already retries lower quality tiers on a crashed dispatcher, but the
condition only matched crash-style messages (segfault, exited unexpectedly).
ocr.py's own ImportError/exception handlers already produce messages telling
the caller to use a lower tier (e.g. on the scipy ABI conflict class of bug),
but nothing ever acted on them, so a broken PaddleOCR hard-failed with 422
instead of degrading to Tesseract like ocr-pdf effectively does. Broadens the
retry condition to also catch PaddleOCR-engine-unusable messages.

Note: ocr-pdf's tesseract-only behavior turned out to be an unrelated,
pre-existing, deliberate design choice (PaddleOCR segfaults on rasterized PDF
pages on arm64), not a graceful-fallback mechanism to copy -- the two tools
weren't actually solving the same problem, so this fixes ocr.ts's own gap
rather than trying to mirror ocr-pdf.
2026-07-07 12:18:28 +08:00

172 lines
6.6 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# ──────────────────────────────────────────────────────────────────────────────
# verify-bundle-compatibility.sh -- Verify ALL bundles share a consistent
# constrained-package closure when installed together
#
# Usage: verify-bundle-compatibility.sh <arch> <bundlesDir>
#
# arch - amd64-gpu or arm64-cpu
# bundlesDir - directory containing <bundleId>-<arch>.tar.gz for every bundle
# in feature-manifest.json's "bundles" map
#
# Runs with --entrypoint bash (no entrypoint bootstrap), so uses /opt/venv
# directly, matching build-bundle.sh's own base assumption.
#
# Why this exists: verify-bundle.sh (this directory) verifies each bundle in
# ISOLATION -- a fresh /opt/venv per bundle. That can never catch a conflict
# that only appears when two bundles are installed into the SAME shared venv,
# which is exactly how real users install them (BullMQ /data/ai/venv is one
# shared venv for every installed bundle, not one venv per bundle). That gap is
# exactly how the scipy ABI strand (see docs/QA_REPORT_v2.0.0_release_2026-07-07.md
# BUG-2, and the earlier incident in project memory project_ai_bundle_numpy_abi_strand)
# shipped twice: each bundle passed verify-bundle.sh alone, and the manifest's
# `constraints` pin was correctly applied at each bundle's OWN build time, but
# nothing ever checked that bundles built at DIFFERENT times agree with each
# other once layered into one venv.
#
# This script installs every bundle for the given arch into ONE fresh venv, in
# manifest order, then asserts that every constrained package (per the
# manifest's top-level "constraints" array) has EXACTLY ONE version present,
# and that it matches the constraint exactly. Run this before publishing any
# bundle rebuild, for every arch, on any subset of bundles you touched AND the
# full set (a rebuild of bundle A can still leave bundle B stale).
#
# Exit codes: 0=pass, 1=integrity, 2=constraint violation
# ──────────────────────────────────────────────────────────────────────────────
ARCH="${1:?Usage: verify-bundle-compatibility.sh <arch> <bundlesDir>}"
BUNDLES_DIR="${2:?Usage: verify-bundle-compatibility.sh <arch> <bundlesDir>}"
MANIFEST="/app/docker/feature-manifest.json"
VENV="/opt/venv"
SITE_PACKAGES="${VENV}/lib/python3.11/site-packages"
if [[ ! -d "${SITE_PACKAGES}" ]]; then
SITE_PACKAGES="${VENV}/lib/python3.12/site-packages"
fi
log() { echo "=== $* ==="; }
pass() { echo -e "\033[32mPASS: $*\033[0m"; }
fail() {
echo -e "\033[31mFAIL: $1\033[0m" >&2
exit "${2:-1}"
}
if [[ ! -f "${MANIFEST}" ]]; then
fail "Manifest not found at ${MANIFEST}"
fi
BUNDLE_IDS="$(python3 -c "
import json
with open('${MANIFEST}') as f:
m = json.load(f)
print(' '.join(m['bundles'].keys()))
")"
log "Installing every bundle for ${ARCH} into one shared venv: ${BUNDLE_IDS}"
for bundle_id in ${BUNDLE_IDS}; do
archive="${BUNDLES_DIR}/${bundle_id}-${ARCH}.tar.gz"
if [[ ! -f "${archive}" ]]; then
echo " SKIP ${bundle_id}: no archive at ${archive} (not built for this arch, or intentionally excluded)"
continue
fi
staging="/tmp/compat-staging-${bundle_id}"
rm -rf "${staging}"
mkdir -p "${staging}"
tar -xzf "${archive}" -C "${staging}"
if [[ -d "${staging}/site-packages" ]]; then
cp -a "${staging}/site-packages/." "${SITE_PACKAGES}/"
fi
rm -rf "${staging}"
echo " Installed ${bundle_id}"
done
pass "All available bundles layered into ${SITE_PACKAGES}"
log "Checking constrained-package version consistency"
"${VENV}/bin/python3" -c "
import json, re, sys
from importlib.metadata import distributions
with open('${MANIFEST}') as f:
manifest = json.load(f)
constraints = manifest.get('constraints', [])
if not constraints:
print('No constraints declared in manifest -- nothing to check')
sys.exit(0)
# Group every installed distribution's dist-info by normalized name (PEP 503:
# runs of -_. collapse to one _, case-insensitive), so 'scikit-image' and the
# dist-info directory name 'scikit_image-0.24.0' resolve to the same key.
def normalize(name):
return re.sub(r'[-_.]+', '_', name).lower()
by_name = {}
for dist in distributions():
name = dist.metadata.get('Name') or dist.name
version = dist.version
key = normalize(name)
by_name.setdefault(key, []).append((name, version, str(dist._path)))
violations = []
for spec in constraints:
m = re.match(r'^([A-Za-z0-9_.-]+)==([A-Za-z0-9_.-]+)\$', spec)
if not m:
print(f' WARNING: could not parse constraint {spec!r}, skipping')
continue
pkg_name, expected_version = m.group(1), m.group(2)
key = normalize(pkg_name)
found = by_name.get(key, [])
if not found:
print(f' {pkg_name}: not present in any installed bundle (fine if nothing needs it)')
continue
versions = sorted({v for _, v, _ in found})
paths = [p for _, _, p in found]
print(f' {pkg_name}: version(s) {versions} ({len(found)} dist-info entr{\"y\" if len(found)==1 else \"ies\"})')
if len(versions) > 1:
violations.append(
f'{pkg_name}: multiple DIFFERENT versions installed simultaneously '
f'({versions}) -- bundles built at different times disagree, and Python '
f'import resolution for this package is now undefined. This is the exact '
f'failure mode that breaks a merged venv. Paths: {paths}'
)
elif len(found) > 1:
violations.append(
f'{pkg_name}: {len(found)} duplicate dist-info entries for the same '
f'version {versions[0]} -- stale metadata left behind by an overlay '
f'install; harmless today but worth cleaning. Paths: {paths}'
)
elif versions[0] != expected_version:
violations.append(
f'{pkg_name}: installed version {versions[0]} does not match manifest '
f'constraint {expected_version} -- this bundle was built before the '
f'constraint existed or changed, and needs a rebuild.'
)
if violations:
print()
print('CONSTRAINT VIOLATIONS:')
for v in violations:
print(f' - {v}')
sys.exit(1)
print()
print('All constrained packages present in exactly one, correct version.')
"
if [[ $? -ne 0 ]]; then
fail "Constrained-package consistency check failed -- see violations above" 2
fi
pass "All bundles for ${ARCH} are mutually compatible"
log "Done"
exit 0