Fixing GNOME Snapshot's PipeWire segfault on NixOS and OpenCode with Deepseek V4 Flash

Fixing GNOME Snapshot's PipeWire segfault on NixOS and OpenCode with Deepseek V4 Flash

TL;DR: GNOME Snapshot segfaults (SIGSEGV 139) when the camera portal
returns a PipeWire file descriptor. The portal steals the connection's fd,
disconnects the client, and hands the dup to Snapshot — which then tries to
re-register on a dead connection. The fix is a 50-line LD_PRELOAD shim that
intercepts pw_context_connect_fd, closes the poisoned fd, and opens a fresh
connection with pw_context_connect.

The symptom

Running snapshot --debug on NixOS 26.11 (labwc, no GNOME):

2026-07-17T12:01:11.078450Z  INFO ashpd::proxy: Calling method org.freedesktop.portal.Camera:OpenPipeWireRemote
2026-07-17T12:01:11.095543Z  DEBUG aperture::device_provider: Starting device provider with file descriptor: 28
[1]    25748 segmentation fault  snapshot --debug

Exit code 139 (128 + SIGSEGV). The portal successfully returned a file
descriptor, but the moment Snapshot's aperture library tried to use it —
boom.

Investigation

The crash site from the abort message:

process_remote in libpipewire-module-protocol-native.so

A null-pointer dereference at address 0x3 in process_remote. This
function processes the initial protocol handshake between a PipeWire client
and the daemon. The fact that it's crashing there means the connection was
in a bad state before any data was exchanged.

Following the fd

The flow is:

  1. Snapshot calls OpenPipeWireRemote on the portal's D-Bus Camera
    interface
  2. The portal frontend (xdg-desktop-portal) calls into the portal-gnome
    backend, which opens a real PipeWire connection via
    pw_context_connect()
  3. Portal-gnome obtains a fd for that connection, calls
    pw_core_steal_fd() to steal it from the pw_core, then passes it back
    through the portal frontend
  4. The portal frontend dups the fd and sends it over D-Bus to Snapshot
  5. Snapshot calls pw_context_connect_fd() on the received fd
  6. Segfault at process_remote

The disconnect

The key insight is in step 3. After pw_core_steal_fd(), the portal
destroys its pw_core remote via pipewire_remote_destroy(). This sends a
DISCONNECT message to the PipeWire daemon over the same protocol
connection. Even though the fd was stolen from the pw_core struct (the
field is set to -1), the protocol connection's socket is still the same
file description — the dup() in step 4 creates a new file descriptor, but
it points to the same open socket.

So by the time Snapshot gets its dup'd fd, the daemon has already processed
the DISCONNECT and marked that connection as defunct. When Snapshot's
pw_context_connect_fd() runs its first process_remote, the protocol
state machine is in an unexpected state, reads a null pointer, and
segfaults.

Why pw-cli works

pw-cli creates its own pw_context and calls pw_context_connect()
directly — it never goes through the portal. The portal's fd is the whole
problem.

The fix: LD_PRELOAD shim

The simplest fix: intercept pw_context_connect_fd() via LD_PRELOAD,
close the poisoned portal fd, and call pw_context_connect() to open a
fresh connection from scratch.

#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/un.h>

struct pw_core;
struct pw_context;
struct pw_properties;

static struct pw_core* (*real_connect_fd)(struct pw_context *, int,
                                           struct pw_properties *, size_t) = NULL;
static struct pw_core* (*real_connect)(struct pw_context *,
                                        struct pw_properties *, size_t) = NULL;

struct pw_core *pw_context_connect_fd(struct pw_context *context, int fd,
                                      struct pw_properties *properties,
                                      size_t user_data_size)
{
    struct pw_core *core = NULL;

    if (!real_connect) {
        void *handle = dlopen("libpipewire-0.3.so.0", RTLD_LAZY | RTLD_NOLOAD);
        if (handle) {
            real_connect = dlsym(handle, "pw_context_connect");
            dlclose(handle);
        }
        if (!real_connect) {
            real_connect = dlsym(RTLD_DEFAULT, "pw_context_connect");
        }
    }

    if (real_connect) {
        close(fd);
        core = real_connect(context, properties, user_data_size);
        return core;
    }

    if (!real_connect_fd) {
        void *handle = dlopen("libpipewire-0.3.so.0", RTLD_LAZY | RTLD_NOLOAD);
        if (handle) {
            real_connect_fd = dlsym(handle, "pw_context_connect_fd");
            dlclose(handle);
        }
    }
    if (real_connect_fd) {
        core = real_connect_fd(context, fd, properties, user_data_size);
        return core;
    }

    return NULL;
}

The dlsym gotcha

Note the use of dlopen("libpipewire-0.3.so.0", RTLD_NOLOAD) +
dlsym(handle, ...). This is important: dlsym(RTLD_NEXT, ...) for
pw_context_connect fails from an LD_PRELOAD shim on this system. The
exact reason is unclear — the symbol is a global function exported in
.dynsym — but dlopen + dlsym works reliably.

NixOS packaging

On NixOS, the fix is integrated as a Nix derivation that compiles the C
source and a wrapper that injects LD_PRELOAD:

snapshotPwFixSrc = ./snapshot-pw-fix.c;
snapshotPwFix = pkgs.stdenv.mkDerivation {
  name = "snapshot-pw-fix";
  src = snapshotPwFixSrc;
  dontUnpack = true;
  buildPhase = ''
    ${pkgs.gcc}/bin/gcc -shared -fPIC -o snapshot-pw-fix.so $src -ldl
  '';
  installPhase = ''
    mkdir -p $out/lib
    cp snapshot-pw-fix.so $out/lib/
  '';
};
snapshotPwWrapped = pkgs.writeShellScriptBin "snapshot" ''
  export LD_PRELOAD=${snapshotPwFix}/lib/snapshot-pw-fix.so''${LD_PRELOAD:+:$LD_PRELOAD}
  exec ${pkgs.snapshot}/bin/snapshot "$@"
'';

Then snapshotPwWrapped replaces snapshot in environment.systemPackages.

Two other requirements

Two things needed to get this far in the first place:

  1. xdg-desktop-portal-gnome as a Camera portal backend. The GTK
    portal does not implement org.freedesktop.impl.portal.Camera. Only the
    GNOME portal does. Without it, OpenPipeWireRemote returns an error
    before the crash even happens.
  2. GST_PLUGIN_SYSTEM_PATH_1_0 must include gst-plugins-bad.
    Snapshot's aperture library needs camerabin from gst-plugins-bad.
    Without it, Snapshot panics on startup. The NixOS binary wrapper for
    snapshot only sets GDK_PIXBUF_MODULE_FILE, so the GStreamer paths must
    be set separately or compiled into the system environment.

Result

2026-07-17T12:35:18.770387Z  INFO ashpd::proxy: Calling method org.freedesktop.portal.Camera:OpenPipeWireRemote
2026-07-17T12:35:18.784920Z  DEBUG aperture::device_provider: Starting device provider with file descriptor: 31
2026-07-17T12:35:18.792816Z  DEBUG aperture::device_provider: Camera found: hm1091_techfront (V4L2)
2026-07-17T12:35:18.796324Z  DEBUG snapshot::widgets::camera: Device provider started
2026-07-17T12:35:19.068207Z  DEBUG aperture::viewfinder: Trying to set camerabin state to Playing

The camera (hm1091_techfront, 1280×720 JPEG) enumerates, the device
provider starts, and Snapshot begins streaming. No crash.