Skip to content

qtwebengine-linker-shebang-fix

Overlays

A nixpkgs overlay that fixes a qtwebengine 6.11+ build failure inside the Nix build sandbox.

The problem

Building qtwebengine (directly, or transitively via anything that pulls it in — Plasma, qutebrowser, pyside6, …) fails during linking:

/build/.../linker_ulimit.sh: /bin/bash: bad interpreter: No such file or directory
... exited with code 126

cmake generates build/linker_ulimit.sh with a hardcoded #!/bin/bash shebang. The Nix build sandbox has no /bin/bash, so the moment the link step tries to run that script it dies with exit 126 ("bad interpreter").

Why the obvious fixes don't work

  • patchShebangs in patchPhase is too early. linker_ulimit.sh does not exist yet — cmake only emits it after configure runs. A patchShebangs call in patchPhase/postPatch matches the .sh.in template at best, and nothing at worst.
  • patchShebangs can't be find -exec'd. It's a shell function, not a binary, so you can't discover the generated file later and hand it to patchShebangs via find -exec. You have to rewrite the shebang line yourself with sed.

So the overlay rewrites the shebang in two phases:

Phase Target When it exists
postPatch linker_ulimit.sh.in template before cmake configure
preBuild generated linker_ulimit.sh after cmake configure

Each phase does a sed -i "1s|^#!.*bash.*|#!$(command -v bash)|" on the first line only, pointing the shebang at the sandbox's real bash.

The subtle trap: two paths to qtwebengine

qtwebengine is reached in more than one way, and a naive overrideScope patch only fixes one of them:

  1. Top-level qt6.qtwebengine — used by Plasma, qutebrowser, etc. qt6.overrideScope patchQtwebengine covers this.
  2. Rebound scope (qt6.override { python3 = ...; }).qtwebenginepyside6 (and anything that rebinds Qt to a specific Python) calls qt6.override, which produces a fresh package set that your overrideScope never touched.

To catch both, the overlay wraps qt6.override so every overridden scope is also run through overrideScope patchQtwebengine:

qt6 = (applyPatch prev.qt6) // {
  override = args: applyPatch (prev.qt6.override args);
};

If you only patch the top level, pyside6 (and friends) will still fail to build with the same exit-126 error.

Usage

Add the overlay to your nixpkgs overlay list.

NixOS module:

{
  nixpkgs.overlays = [ (import ./overlays/qtwebengine-linker-shebang-fix) ];
}

Flake / plain import <nixpkgs>:

pkgs = import nixpkgs {
  inherit system;
  overlays = [ (import ./overlays/qtwebengine-linker-shebang-fix) ];
};

No options to configure — it's a pure overlay.

Caveats

  • This is a workaround for a specific upstream packaging quirk. Once nixpkgs (or Qt upstream) makes the generated linker script sandbox-safe, the overlay becomes a harmless no-op — the sed simply matches nothing. Drop it when you confirm qtwebengine builds clean without it.
  • The shebang match is deliberately loose (^#!.*bash.*) so it survives minor changes to how cmake writes the line. If a future version renames the script or drops the bash shebang entirely, revisit the find -name patterns.
  • Version-observed on qtwebengine 6.11.0; the pattern applies to any release that emits linker_ulimit.sh with an absolute-path bash shebang.

Source

overlays/qtwebengine-linker-shebang-fix/default.nix
# qtwebengine-linker-shebang-fix
#
# Nixpkgs overlay that fixes a qtwebengine 6.11+ build failure inside the Nix
# sandbox: cmake generates `build/linker_ulimit.sh` with a `#!/bin/bash`
# shebang, but the sandbox has no `/bin/bash`, so the link step dies with
# exit 126 ("bad interpreter").
#
# Why not `patchShebangs`?
#   - The offending script does not exist until *after* cmake configure, so a
#     `patchShebangs` in `patchPhase` runs too early and matches nothing.
#   - `patchShebangs` is a shell function, so it cannot be `find -exec`'d over
#     files discovered later. We use `sed` on the shebang line instead.
#
# The fix rewrites the shebang in BOTH places the file appears:
#   - postPatch: the `linker_ulimit.sh.in` template (before configure).
#   - preBuild:  the generated `linker_ulimit.sh` copy (after configure).
#
# The subtle part — patch BOTH ways qtwebengine is reached:
#   - top-level `qt6.qtwebengine` (e.g. plasma, qutebrowser).
#   - `(qt6.override { ... }).qtwebengine`, which pyside6 / a Python rebind
#     produces. `overrideScope` only reaches the first, so we also wrap
#     `qt6.override` to route the overridden scope through `overrideScope`.
#
# Usage — add to nixpkgs.overlays:
#
#   nixpkgs.overlays = [ (import ./overlays/qtwebengine-linker-shebang-fix) ];
#
# or in a flake:
#
#   pkgs = import nixpkgs {
#     inherit system;
#     overlays = [ (import ./overlays/qtwebengine-linker-shebang-fix) ];
#   };

final: prev:
let
  patchQtwebengine = qfinal: qprev: {
    qtwebengine = qprev.qtwebengine.overrideAttrs (old: {
      # Fix the template before cmake configure copies it.
      postPatch = (old.postPatch or "") + ''
        for f in $(find . -type f \( -name 'linker_ulimit.sh' -o -name 'linker_ulimit.sh.in' \) 2>/dev/null); do
          echo "[qtwebengine-overlay] patching shebang in $f (postPatch)"
          sed -i "1s|^#!.*bash.*|#!$(command -v bash)|" "$f"
        done
      '';
      # Fix the generated copy that only exists after configure.
      preBuild = (old.preBuild or "") + ''
        for f in $(find . -name linker_ulimit.sh -type f 2>/dev/null); do
          echo "[qtwebengine-overlay] patching shebang in $f (preBuild)"
          sed -i "1s|^#!.*bash.*|#!$(command -v bash)|" "$f"
        done
      '';
    });
  };
  applyPatch = q: q.overrideScope patchQtwebengine;
in
{
  qt6 =
    let
      patched = applyPatch prev.qt6;
    in
    patched
    // {
      # Route overridden scopes (e.g. pyside6's `qt6.override { python3 = ...; }`)
      # through the same patch, since `overrideScope` above does not reach them.
      override = args: applyPatch (prev.qt6.override args);
    };
}