A drop-in replacement for sharp that doesn't segfault under Electron on Linux.
sharp crashes with a native segfault — not a catchable JS error — when decoding an image (JPEG, PNG, etc.) inside any process built on Electron's Linux binary. The cause is two copies of glib colliding in one process; no configuration flag fixes it. This package is a rebuild of sharp and its bundled libvips with the collision fixed at the linker level. To use it, add one overrides entry to your package.json (Usage). macOS and Windows aren't affected, so there this package transparently re-exports the real, unmodified sharp. To rebuild it yourself or bump it to a newer sharp release, see Building from source and Engineering notes.
Electron's Linux binary dynamically links the system's glib (for GTK integration). sharp/libvips bundle their own private glib inside libvips-cpp.so, but don't fully hide its symbols. Two copies of glib exporting the same symbols into one process corrupt glib's internal state, and the process dies with SIGSEGV the first time an image is actually decoded — no JS exception, no stack trace. This affects any process built on Electron's binary, including a utilityProcess, and is a known, currently-unresolved upstream bug: electron/electron#46323.
Characteristically, encoding raw pixels works fine — only decoding a real compressed image crashes. If that matches what you're seeing, this is your bug.
Use npm overrides — not a direct import — so that every require('sharp') / import sharp from 'sharp' in your dependency tree, direct or transitive, resolves to this package:
{
"overrides": {
"sharp": "npm:@janhapke/sharp-electron@0.35.4-electron.1"
}
}npm installThat's it — no source changes anywhere in your project. The npm: alias syntax is required: the override target's package name differs from sharp, so a plain "sharp": "0.35.3-electron.1" would tell npm to look for a package literally named sharp at that version, which doesn't exist.
If sharp is also a direct dependency of your project, npm rejects an override that conflicts with it (EOVERRIDE). In that case point the dependency itself at this package and let the override reference it via the nested form, not a plain "sharp": "$sharp" (npm requires the nested form whenever a root dependency and an override target the same package name, regardless of what either resolves to):
{
"dependencies": {
"sharp": "npm:@janhapke/sharp-electron@0.35.4-electron.1"
},
"overrides": {
"sharp": "$sharp"
}
}This package's own internal fallback dependency (the real, unpatched sharp its non-Linux build and its TypeScript types re-export) is deliberately installed under the aliased name sharp-upstream, not sharp — so an overrides rule targeting "sharp" can never recurse into it, however the rule is written. (Earlier releases installed it as a plain sharp dependency, which meant a plain overrides rule silently rewrote that internal dependency back into a self-referential circular reference — no install error, but require('sharp') inside the fallback returned {} at runtime and tsc failed to resolve sharp's types. If you hit that, upgrade to 0.35.4-electron.1 or later.)
To test a locally built copy of this repo (see Building from source) against a real consuming project, don't point an overrides rule at file:../sharp-electron/package or a packed .tgz — both reliably trigger bugs in npm's dependency resolver (@npmcli/arborist) once the consuming project's dependency graph is large enough: a file:-directory override crashes npm install outright (Cannot read properties of null (reading 'package'), a race in how arborist links file: "Link" nodes when more than one edge resolves to the same target concurrently — confirmed via Promise.all in the crash's own stack trace), and overriding to a packed tarball can leave a broken symlink pointing at the .tgz file itself instead of extracting it. Both are npm bugs, not something fixable from this package's package.json.
The reliable way to test a local build: let npm install the real sharp normally (no override at all — this sidesteps the buggy code path entirely, since overrides never enters the picture), then physically replace every real-sharp directory it created with this repo's built package/:
# in your consuming project, with a normal "sharp": "^X.Y.Z" dependency (no override):
rm -rf node_modules && npm install
# then, for every node_modules/**/sharp directory npm actually created:
find node_modules -maxdepth 4 -iname sharp -type d | while read -r d; do
rm -rf "$d"
cp -r ../sharp-electron/package "$d"
doneRe-run the find/cp -r loop after every rebuild of this repo (npm run package) — there's no live symlink here, just a plain directory copy, so changes don't propagate on their own.
You can instead npm install @janhapke/sharp-electron and change call sites to import it directly — but only safely if you change every place your project touches sharp, including transitive dependencies you don't control.
The failure mode if you miss one: this package and the real sharp both ship a libvips-cpp.so with the identical SONAME (intentionally, for compatibility). The ELF dynamic linker deduplicates shared libraries by SONAME within a process — once one libvips-cpp.so is loaded, every other module in that process that needs that SONAME silently reuses the already-loaded copy, regardless of how correctly it was built or what its RPATH says. So if two plugins each import sharp and only one is switched over, whichever loads first wins for both. If the unpatched one wins, the symptom isn't even the original segfault — it's a confusing ELF loader error (undefined symbol: vips_g_...) in the correctly-patched module. This exact scenario happened while validating this package in a real project.
overrides sidesteps the problem structurally: every sharp-touching module resolves to the exact same files, so two different libvips-cpp.sos can never coexist in the process. Auditing call sites by hand gives no such guarantee.
| Platform | Behavior |
|---|---|
linux-x64 |
The patched build. Verified against sharp's own upstream test suite and a dedicated Electron crash-regression test. |
linux (other architectures, e.g. arm64) |
Throws a clear error at load time rather than silently falling back to real sharp — a silent fallback would just defer the crash to the first image decode. |
| macOS / Windows | Re-exports the real, unmodified sharp (a normal dependency of this package; npm installs the correct official binary automatically). The bug doesn't exist on these platforms. |
-
undefined symbol: vips_g_...at load time (an exception, not a crash) — almost always the SONAME-collision issue described above: some module in the process still resolvessharpto the real, unpatched package. Check that theoverridesentry is in place and thatnode_modules/sharpactually contains this package's files. -
Segfault decoding an image under Electron on Linux, even with
overridescorrectly in place — an unpatchedlibvips-cpp.sofrom some other source is winning the SONAME race (e.g. a copy loaded by a non-npm mechanism). Check what else in the process loadslibvips. -
ERR_DLOPEN_FAILED: libvips-cpp.so.8.18.3: cannot open shared object file— but only in a packaged app, never undernpm start/dev mode. This isn't asharp-electronbug — it's your packager silently dropping or mis-placing thelibvips-cpp.sothat sits next tolinux-x64/sharp/.../sharp-linux-x64-*.node(found via that addon's$ORIGINRPATH). Two independent causes, both confirmed in a real Electron Forge project — check both:- Your own native-module whitelist/externals script only walks
dependencies, notoptionalDependencies. If you (or your packaging plugin) hand-roll a "whichnode_modulespackages contain native binaries" trace to avoid webpack bundling them, and it only followspackage.json'sdependenciesfield, it will never reach@img/sharp-libvips-linux-x64— prebuilt-binary packages like this are conventionally declared asoptionalDependencies. Result: the.sogets excluded from the packaged app outright, not just mis-placed. - Your asar tooling unpacks
.nodefiles but not their.sosidecars. Electron Forge's@electron-forge/plugin-auto-unpack-natives, for example, setsasar.unpackto a glob matching only**/*.node— it physically moves the addon out of the compressedapp.asarintoapp.asar.unpacked, but leaves any co-located shared library trapped inside the archive, wheredlopen()can never reach it (even thoughnpx asar listwill still show it "present" — that lists the archive's logical contents, not what's been physically unpacked). You need to extend the unpack glob yourself to also match.so/.so.*, e.g. (Forge):packagerConfig.asar.unpack = '{**/*.so,**/*.so.*}'(merges with the plugin's own.nodepattern rather than replacing it).
Diagnose by checking the packaged output directly, not just build logs:
find <packaged-app>/resources/app.asar.unpacked -iname 'libvips-cpp.so*'— if it's missing, you're hitting #1; if the addon is there but the.soisn't, you're hitting #2. - Your own native-module whitelist/externals script only walks
-
npm error Invalid comparatorfromnpm install/npm ci, before any code runs at all. This is an npm bug, not asharp-electronbug — but it's directly triggered by following the Usage instructions above verbatim. Root cause: npm's arborist cannot resolve anoverridesrule targeting a package name (sharp) when your rootpackage.json's own dependency of that same name is declared via annpm:alias (exactly the form bothoverridessnippets above use:"sharp": "npm:@janhapke/sharp-electron@..."). The alias means your root "version" forsharpisn't an ordinary semver range, and arborist's overrides matcher throws trying to compare it against the override's version selector — regardless of whether your override is the flat or nested form.There's no
package.json-only fix for this — it reproduces with either override shape. Two ways out, in order of preference:-
Drop the
npm:alias in favor of the plainoverrides-only form (top of Usage): don't addsharpas a direct/dev dependency at all, just the singleoverridesentry pointing at this package by its real name. This is exactly what that form was already meant for — the crash only happens when a root dependency and an override target the same name and the root side is an alias. If nothing else in your tree needssharpas a direct dependency, this sidesteps the bug entirely and keeps the full structural guaranteeoverridesgives (see Why overrides). -
If you do need
sharpas a direct/dev dependency via thenpm:alias (e.g. your own code doesimport sharp from 'sharp'and you want that exact package resolved, not just an override rewriting transitive requires), you can't useoverridesfor this package at all without hitting the crash. Fall back to a targetedpostinstallstep that strips any other dependency's own bundled, nested copy ofsharpso itsrequire('sharp')has nothing local to resolve to and falls through to your root, aliased install instead:{ "scripts": { "postinstall": "node -e \"require('fs').rmSync('node_modules/<the-other-dependency>/node_modules', { recursive: true, force: true })\"" } }This is narrower than
overrides' guarantee — it only closes the gap for the specific nested copy you name, not structurally for anything that might bundlesharpin the future. If you add another dependency that also bundles its ownsharp, you need to extend this by hand; nothing will warn you if you miss one (the symptom would be the original SONAME-collision failure mode above, not an install-time error). Revisit option 1 if you can.
-
Requires only Docker on the host — the entire toolchain runs in a container matching sharp's own CI environment.
git clone --recurse-submodules https://github.com/janhapke/sharp-electron.git
cd sharp-electron
npm install
npm run buildThis applies the patches to the vendored sharp-libvips/sharp submodules, rebuilds both, runs both test gates, and packages the result into package/ — ready to consume via a file: override as shown above. The individual stages are also available separately: npm run build:libvips, npm run build:sharp, npm run test:gates, npm run package.
To confirm the bug itself on the unpatched sharp first (recommended before trusting any of this): npm run repro runs test/electron-crash-repro.js against the stock npm sharp under ELECTRON_RUN_AS_NODE — expect the encode step to succeed and the decode step to kill the process.
Everything a contributor (or future maintainer bumping versions) needs to know about how and why this works.
The fix has two halves, applied as patches (in patches/) to two upstream repos vendored as git submodules pinned at release tags. The submodules stay pristine in git; scripts/apply-patches.sh applies the patches to their working trees at build time (idempotently — it detects an already-patched tree and skips).
Phase A — sharp-libvips (the libvips binary). sharp-libvips doesn't vendor libvips's source; its build/posix.sh downloads the upstream release tarball at build time and patches it inline. Our patch extends that same mechanism:
- Adds
extra/glib_wrapper.c/.h: thin wrapper functions re-exporting each neededglibsymbol under a renamedvips_g_*identity (e.g.vips_g_malloccalls the bundledglib'sg_malloc). - Broadens the
vips.maplinker version script from hiding a single symbol (g_param_spec_types— upstream's own partial fix for this class of bug) to hiding everything exceptlibvips's own API:{ global: vips_*; _Z*; local: *; }. This is what actually removes the collidingg_*exports fromlibvips-cpp.so. - Patches
libvips's C++ headerVImage8.h, whose inlineVObjectsmart-pointer class callsg_object_ref/g_object_unrefdirectly — those calls get compiled straight intosharp's addon wherevervips::VImageis used, so they must be redirected at the header level, not insharp's source.
One non-obvious gotcha, recorded because it will bite again: libvips-cpp.so's meson target sets gnu_symbol_visibility: 'hidden', which strips any new symbol from the dynamic table at compile time — before the version script even applies — unless it's explicitly marked __attribute__((visibility("default"))). The build succeeds cleanly either way; only objdump -T reveals the difference. A clean build is not sufficient evidence here.
Phase B — sharp (the Node addon). Redirects sharp's own direct glib calls (common.cc, metadata.cc, sharp.cc) to the vips_g_* wrappers, then rebuilds the addon against the Phase A libvips using sharp's own supported mechanism for custom libvips builds: SHARP_FORCE_GLOBAL_LIBVIPS=1 plus PKG_CONFIG_PATH pointed at generated .pc files. The build runs in a Docker image (scripts/sharp-build.Dockerfile) replicating sharp's official CI environment (Rocky Linux 8, gcc-toolset-14, Node 20), so nothing touches the host toolchain and the binary matches upstream's baseline glibc compatibility.
Seven symbols are wrapped. The machine-readable source of truth — including each symbol's origin, risk classification, and how it was found — is patches/wrapper-symbols.json; version bumps should diff a fresh grep against that file, not against this prose. The short version:
g_object_ref,g_object_unref— fromlibvips'sVImage8.hinline header (see Phase A above); the original crash culprits.g_malloc,g_free— fromsharp's own source. Wrapped as a pair deliberately: an allocator and its matching free must come from the sameglibcopy, or the result is silent heap corruption, not just a symbol-hygiene issue.g_signal_connect_data,g_log_set_handler— fromsharp's own source.g_utf8_validate— fromsharp'smetadata.cc. Notably, this one was missed by grepping and only surfaced when runningsharp's full test suite against the rebuilt addon (undefined symbolat runtime). Lesson: the test-gate loop below is load-bearing, not a formality — expect a version bump to surface a symbol the grep missed.
Three hard-won, non-obvious decisions live in scripts/package-local.sh:
libvips-cpp.sosits next to the.nodeaddon, found via RPATH — and it must be old-styleDT_RPATH, notDT_RUNPATH.patchelf --set-rpathproducesDT_RUNPATHby default, which the loader consults afterLD_LIBRARY_PATH— so anything else in a real consumer's environment ornode_modulesthat provides the same SONAME can win over the co-located patched copy.patchelf --force-rpathproducesDT_RPATH, consulted beforeLD_LIBRARY_PATH, so the co-located copy always wins for the addon's own direct dependency. (Settingprocess.env.LD_LIBRARY_PATHfrom JavaScript doesn't work at all: glibc reads it once at process start, not perdlopen().)- The SONAME is kept identical to upstream's — that's what makes the per-process SONAME deduplication safe when everything resolves to this package (see Why overrides) and is why
overridesis the recommended install method rather than an optional nicety. package/runs its ownnpm installduring packaging because npm does not recursively install afile:dependency's own dependencies the way it does for registry packages — without this, the non-Linuxrequire('sharp-upstream')fallback breaks in every local-testing setup. Relatedly,package/package.jsonmust not declare"os"/"cpu"restrictions: those make npm skip installing the package entirely on other platforms, which would prevent the fallback from ever existing there.- The fallback's real-
sharpdependency is namedsharp-upstream, notsharp("sharp-upstream": "npm:sharp@<version>"inpackage/package.json, matched byrequire('sharp-upstream')inindex.jsandindex.d.ts). A consumer'soverridesrule targeting"sharp"applies recursively to every dependency edge namedsharpanywhere in the tree — including this package's own internal one, since npm doesn't distinguish "the package I'm overriding" from "a same-named dependency three levels inside it." Without the alias, that self-reference resolves silently (no install error) into a circular reference:require('sharp')inside the fallback returns{}at runtime, andindex.d.ts'simport sharp = require('sharp')can't resolve realsharp's types, producing spurioustscerrors in consumers (has no exported member 'X',can only be referenced with ECMAScript imports) with no indication the cause is this package. The alias sidesteps the collision structurally, the same reason this package's own published name isn'tsharp.
Every build must pass both, enforced by scripts/run-gates.sh (non-zero exit on failure, usable in CI):
sharp's own upstream test suite against the rebuilt addon (1804/1811 at last run; the one known failure istest/unit/esm.mjs, a pre-existing Node CJS/ESM interop quirk unrelated to these patches — it fails identically against stocksharp).- The Electron crash regression test (test/electron-crash-repro.js): encode raw pixels to JPEG, then decode a real JPEG via
.metadata()and.resize().toBuffer(), underELECTRON_RUN_AS_NODE. On an unpatched build the decode step reliably segfaults; on a correct build both steps pass. The same script serves both directions viaSHARP_MODULE_PATH.
If a gate fails with undefined symbol: g_<something>: that's a missing wrapper symbol. Add it to extra/glib_wrapper.c/.h in vendor/sharp-libvips (remember the visibility("default") attribute), patch the call site, update patches/wrapper-symbols.json, regenerate the patch files, rebuild, re-run the gates. This loop is normal — it's how g_utf8_validate was found.
Tried before concluding a from-source rebuild was the only real fix:
| Approach | Result |
|---|---|
LD_PRELOAD sharp's libvips-cpp.so before Electron starts |
Made Electron crash even earlier |
objcopy --localize-symbols (hide symbols post-hoc, no recompile) |
Still segfaults — sharp.node's undefined references then bind to Electron's glib instead |
RTLD_DEEPBIND |
Crashes differently, during load |
Do sharp work in a real separate Node process (not Electron's binary) |
Works, but requires bundling a Node binary and adds an IPC boundary |
This is deliberately a guided Claude Code command, not a blind script: .claude/commands/rebuild-for-version.md (/rebuild-for-version <sharp-version> <sharp-libvips-version>). A version bump involves genuine judgment calls a script would get silently wrong — whether the bug even still reproduces upstream, whether a patch conflict is trivial context drift or a structural change, whether the wrapper symbol set changed. The command:
- Re-pins the submodules and first verifies the bug still reproduces on an unpatched build — upstream may have fixed it.
- Attempts the existing patches; diagnoses (rather than force-fixes) any conflict.
- Re-runs the exhaustive
glib-call grep and diffs it againstpatches/wrapper-symbols.json. - Rebuilds, runs both gates, and follows the wrapper-expansion loop above (bounded to 3 iterations before stopping to report).
- Re-verifies symbol visibility (
objdump -T) andDT_RPATH(readelf -d), and prepares — but never executes — the release.
Version scheme: <upstream-sharp-version>-electron.N — the version always names the exact sharp release it tracks; N bumps for this package's own revisions. Every version this scheme produces is, by construction, a semver prerelease (the -electron.N suffix), which matters at publish time — see below.
npm run release <version> (scripts/release.sh) runs the full pipeline, then syncs every version reference — package/package.json's version and its sharp fallback dependency (pinned to the upstream part of the new version), and this README's install snippets — then prints the remaining steps. Publishing is always a manual, human-confirmed action — no script or command in this repo runs npm publish, git tag, or git push on its own. That's a deliberate design decision, not a missing feature: a bad automated judgment call shipping silently to consumers is a worse failure mode than a release waiting a day for review.
Publishing requires --tag latest, every time: npm publish --tag latest (not plain npm publish). Because every version has the -electron.N suffix, npm/semver treats it as a prerelease and refuses to move the latest dist-tag implicitly (npm error: You must specify a tag using --tag when publishing a prerelease version) — without --tag latest, a bare npm install @janhapke/sharp-electron (or the version-less overrides form) would resolve to nothing. --access public is not needed on the command line; it's already set via package/package.json's publishConfig.
Note for the published package: package/README.md is generated at package time (a copy of this file, since npmjs.com displays the published package's own README) — edit this file, never that copy.
patches/ the actual fix: two .patch files + wrapper-symbols.json (symbol manifest)
vendor/sharp-libvips/ git submodule, pinned at the target release tag (pristine; patched at build time)
vendor/sharp/ git submodule, same
scripts/ the full pipeline: apply-patches → build-libvips → build-sharp → run-gates → package-local
test/electron-crash-repro.js the regression test (and original bug repro)
package/ the published npm package: index.js dispatcher, index.d.ts (forwards to sharp's own types), built linux-x64/ payload
dist/ gitignored Phase A build output
.github/workflows/ci.yml Linux full-pipeline job + macOS/Windows fallback smoke test
The linux-x64 build passes both test gates and has been verified end-to-end in a real consuming Electron project. The macOS/Windows fallback is implemented and covered by CI — check the workflow's status for the current result on real runners. linux-arm64 has no patched build — it fails loudly rather than silently.
Apache-2.0, matching sharp. See LICENSE and THIRD-PARTY-NOTICES.md (this project patches libvips, LGPL-2.1-or-later; the glib_wrapper.c/.h shim is original code).