Skip to content

Fix Linux OpenCV bundling in .mcpb and guard against silent reship - #51

Merged
jordlee merged 1 commit into
mainfrom
fix/linux-opencv-mcpb-bundling
Aug 13, 2026
Merged

Fix Linux OpenCV bundling in .mcpb and guard against silent reship#51
jordlee merged 1 commit into
mainfrom
fix/linux-opencv-mcpb-bundling

Conversation

@takusaito-ctrl

Copy link
Copy Markdown

Fixes #33.

The bug

opencvLibDir() probed shared/opencv/Linux/Release/{x64,aarch64}/lib, but extract.ts (linuxSpec, toRel: 'Linux') writes Linux libs flat to shared/opencv/Linux. The lookup always returned null, pack:mcp took the silent "assuming static/linked" branch, and the shipped .mcpb contained zero OpenCV libs.

macOS and Windows agree by construction — only Linux was mismatched.

Why it went unnoticed

The binary's RUNPATH is $ORIGIN:<repo>/shared/sdk/lib:<repo>/shared/opencv/Linux. On the build machine $ORIGIN has no OpenCV, so the loader falls through to the developer's source tree and finds it there. The bundle appears to work, and every MCP tool passes.

On any machine without that checkout, the three libopencv_* DT_NEEDED entries fail in the loader before main():

./CameraWebApp: error while loading shared libraries:
libopencv_core.so.408: cannot open shared object file: No such file or directory
exit code: 127

So the symptom is not "OSD rendering breaks" — the server never starts and every camera tool fails.

Changes

Both in cli/src/lib/mcp-build.ts:

  1. opencvLibDir() — probe shared/opencv/Linux first, keeping the two Release/* paths as legacy fallbacks (mirroring how Windows already carries fallbacks).
  2. New linksOpenCv() guardpack:mcp now fails instead of shipping when the binary loads OpenCV dynamically and nothing was staged beside it. It scans the image for loader library names (libopencv_*.so / *.dylib / opencv_*.dll) rather than depending on readelf/otool/dumpbin, so it works on all three platforms. The "assuming static/linked" wording became misleading and is now "none needed — statically linked", only reachable when the binary genuinely doesn't link OpenCV.

Deliberately not included: stripping the absolute dev paths from RUNPATH. That's the masking bug rather than the bundling bug, and it deserves its own issue — $ORIGIN is already first, so this fix is correct without it.

Test plan

Verified on linux-arm64 (Raspberry Pi), SDK V2.02.00, ILCE-7M5 over USB.

  • crsdk pack:mcp reports OpenCV libs 4 (was silently 0); bundle contains all four libopencv_*.so.408
  • Clean-machine simulation — extracted the bundle, rewrote the dev-path prefix in the binary to make the source tree unreachable, leaving only $ORIGIN. All deps resolve, 0 unresolved. This is the exact setup that previously exited 127.
  • Live hardware through the clean-machine bundle — server started, SDK initialized, camera connected, live view 1024x680, OSD composite 720x480 rendered correctly with full camera UI.
  • /proc/<pid>/maps on the running process confirms libopencv_{core,imgcodecs,imgproc} mapped from the bundle, with 0 libs resolved from the source tree.
  • Guard verified — temporarily reverting the path fix makes pack:mcp abort with a clear message and exit 1 (CI-safe), instead of shipping a broken bundle. The previously good bundle is left untouched on failure.
  • Graceful shutdown released the camera cleanly.

Note: the terminate called without an active exception seen at shutdown is pre-existing #42 and unrelated to this change.

opencvLibDir() probed shared/opencv/Linux/Release/{x64,aarch64}/lib, but
extract.ts writes Linux libs flat to shared/opencv/Linux. The lookup always
missed, so pack:mcp took the "assuming static/linked" branch and shipped a
bundle with no OpenCV in it.

This stayed invisible because the binary's RUNPATH lists $ORIGIN first and the
developer's source tree second: on the build machine the loader simply fell
through to the checkout and found the libs there. On any other machine the
three DT_NEEDED OpenCV entries fail in the loader before main(), so the server
exits 127 and every camera tool fails — not just the OSD paths.

Point the Linux lookup at the layout extract.ts actually writes, and refuse to
pack when the binary loads OpenCV dynamically with nothing staged beside it,
so this cannot silently reship. The check scans the image for loader library
names rather than shelling out to readelf/otool/dumpbin.

Fixes #33

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jordlee

jordlee commented Aug 13, 2026

Copy link
Copy Markdown
Member

This supersedes a duplicate fix I had in #54 — I'd written the same change before this PR existed, and dropped my commit in favour of this one. Yours is verified on the platform that actually matters (clean-machine simulation, /proc/<pid>/maps confirming zero libs resolved from the source tree); mine was simulation on macOS. Splitting RUNPATH out into #52/#53 is also the better call — I had it as a patchelf call on the staged copy, which papers over the cause where #53 fixes it.

One refinement worth considering, from having written the same guard:

linksOpenCv() is a boolean, so it only catches "zero staged" — not partial staging. If copyGlob matches three of the four libs (a rename upstream, a versioning change, a partially-populated shared/opencv), cvLibs is 3, the guard passes, and the bundle ships missing one DT_NEEDED — the same exit-127 failure this PR exists to prevent, just harder to spot because the count looks plausible.

Comparing by module name closes it, and is a small change to what you already have:

/** "libopencv_core.so.408" | "opencv_world4100.dll" | "libopencv_core.4.8.dylib" → "core" | "world". */
function opencvModule(file: string): string | null {
  const m = /opencv_([a-z]+)/i.exec(file);
  return m ? m[1].toLowerCase() : null;
}

/** OpenCV modules the binary loads dynamically, e.g. ["core","highgui","imgcodecs","imgproc"]. */
function opencvDynamicDeps(bin: string): string[] {
  const pattern =
    process.platform === 'darwin' ? /libopencv_[a-z0-9]+(?:\.\d+)*\.dylib/gi :
    process.platform === 'win32'  ? /opencv_[a-z0-9]+\.dll/gi :
                                    /libopencv_[a-z0-9]+\.so(?:\.\d+)*/gi;
  const found = readFileSync(bin).toString('latin1').match(pattern) ?? [];
  return [...new Set(found.map(opencvModule).filter((m): m is string => m !== null))].sort();
}

Then the guard compares needed against staged, and names what is missing:

const needed = opencvDynamicDeps(binOut);
const staged = new Set(readdirSync(serverOut).map(opencvModule).filter(Boolean));
const missing = needed.filter((m) => !staged.has(m));
if (missing.length > 0) { fail(`… not in the bundle: ${missing.join(', ')}`); return false; }

Matching on the module name rather than the full filename keeps library versioning from causing a false failure — libopencv_core.408.dylib and libopencv_core.4.8.0.dylib both reduce to core.

Verified on macOS: this reports exactly core, highgui, imgcodecs, imgproc, matching otool -L 4/4 with no false positives, and the existing working pack:mcp still passes. It also makes the reported line specific — 4 staged for 4 needed (core, highgui, imgcodecs, imgproc) rather than a bare count.

Entirely your call whether it's worth the extra code — the boolean form already fixes the shipped bug, and partial staging is the rarer failure. Happy to send it as a follow-up PR on top of this one if you'd rather keep this merge small.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Linux: OpenCV never bundled into .mcpb — opencvLibDir() paths cannot match what extract.ts writes

2 participants