Skip to content

cli & replayer: Prevent Linux worker process orphaning with race-free PR_SET_PDEATHSIG - #305

Open
louzt wants to merge 7 commits into
ValveSoftware:masterfrom
louzt:fix/linux-orphan-reaper
Open

cli & replayer: Prevent Linux worker process orphaning with race-free PR_SET_PDEATHSIG#305
louzt wants to merge 7 commits into
ValveSoftware:masterfrom
louzt:fix/linux-orphan-reaper

Conversation

@louzt

@louzt louzt commented Jul 24, 2026

Copy link
Copy Markdown

Background

When a Steam/Proton parent exits abruptly (SIGKILL, crash, game exit), the
forked fossilize_replay / ExternalReplayer worker can end up reparented
to a subreaper (PID 1 in plain hosts, pv-adverb inside pressure-vessel /
srt-bwrap) and keep compiling Vulkan SPIR-V in the background.

Two upstream fixes already exist on this path:

This PR complements #96 by binding the worker to a kernel-level death
signal (prctl(PR_SET_PDEATHSIG, SIGKILL)) so the cleanup also fires when
the worker ends up in a different process group (the CLI tool path used
when inherit_process_group=true, see cli/fossilize_replay.cpp). It also
adds explicit --device-pci-vendor / --device-pci-device selection so
hybrid laptops can pin shader compilation to the target dGPU instead of
falling through to the iGPU.

Closes: #247, #264, #279, #302.

#288 (104 GB RAM growth) and #303 (status flag) are tracked separately
and are not in scope of this PR — see "Out of scope" below.


Relationship with PR #96

flowchart TD
    subgraph PR96["PR #96 (HansKristian-Work, 2020) - User-Space Group Cleanup"]
        P1["Worker spawned"] --> P2{"inherit_process_group?"}
        P2 -- "false (default)" --> P3["close_fd pipe + setpgid()"]
        P3 --> P4["epoll sentinel in master"]
        P4 --> P5{"Parent dies?"}
        P5 -- "Yes, gracefully" --> P6["killpg(SIGKILL) in user-space"]
        P5 -- "SIGKILL / OOM / crash" --> P7["Master code never runs, no killpg, worker orphaned to PID 1"]
        P2 -- "true (CLI path)" --> P8["Pipe and setpgid bypassed, no user-space cleanup at all"]
    end

    subgraph PR305["PR #305 (Louzt, 2026) - Kernel-Level Invariant"]
        Q1["Worker spawned"] --> Q2["prctl(PR_SET_PDEATHSIG, SIGKILL)"]
        Q2 --> Q3["getppid() race-free gate"]
        Q3 --> Q4["Kernel delivers SIGKILL on parent death, any pgroup or namespace"]
    end

    P7 -. "Orphaned to PID 1 or pv-adverb" .-> Q1
    P8 -. "Same orphan path" .-> Q1
Loading

PR #96 works while control returns to the replayer epoll loop. It cannot
cover three cases that PR #305 now does:

  1. Involuntary parent death (SIGKILL, Proton panic, OOM killer):
    the master code path is interrupted before killpg is reached, so the
    worker outlives the parent.
  2. inherit_process_group=true (CLI tool path, see
    cli/fossilize_replay.cpp)
    : the close_fd pipe is only created when
    !options.inherit_process_group. When the CLI inherits the process
    group, no user-space cleanup runs at all.
  3. Container namespaces (pressure-vessel / srt-bwrap): workers
    reparented to pv-adverb (the container subreaper) escape the parent's
    process group, so neither killpg nor setpgid reaches them.

PR #305 binds each worker to a kernel death signal after fork() instead
of relying on user-space event loops, which closes all three gaps.


Changes

Linux worker cleanup (fossilize_replay_linux.hpp, fossilize_external_replayer_linux.hpp)

After fork() in both the CLI replayer master and the ExternalReplayer
worker path:

if (prctl(PR_SET_PDEATHSIG, SIGKILL) != 0)
{
    LOGE("Failed to set PR_SET_PDEATHSIG, aborting child startup.\n");
    _exit(EXIT_FAILURE);
}
if (getppid() != parent_pid)
    _exit(EXIT_FAILURE);

The getppid() re-check is the race-free gate: between fork() returning
and prctl taking effect, the parent could already have died and been
reaped. If getppid() != parent_pid, the parent is gone and we abort
cleanly instead of running unparented.

Multi-GPU selection (cli/device.cpp, cli/fossilize_replay.cpp, fossilize_external_replayer.cpp/hpp)

New flags --device-pci-vendor and --device-pci-device (hex, e.g.
0x10de for NVIDIA). Selection is gated on vendorID; the optional
deviceID narrows it to a specific GPU. If the filter is set but no GPU
matches, LOGW warns and falls back to default selection instead of
silently picking the wrong device.

The predicate lives in cli/device.hpp::VulkanDevice::pci_filter_matches
so both the CLI selection loop and the unit test exercise the same logic.


Verification

All 8 ctest pass (1.5 s on this host):

1/8 fossilize-system-test ............ Passed
2/8 varint-system-test ............... Passed
3/8 application-info-filter-test ..... Passed
4/8 object-cache-test ................ Passed
5/8 feature-filter-test .............. Passed
6/8 linux-futex-test ................. Passed
7/8 orphan-prevention-test ........... Passed
8/8 gpu-selection-test ............... Passed
  • orphan-prevention-test forks an intermediate parent, kills it, and
    asserts the grandchild is reaped within 2 s (bounded poll, 200 × 10 ms).
  • gpu-selection-test runs pci_filter_matches against a synthetic
    catalogue of NVIDIA / AMD / Intel IDs and checks vendor-only,
    vendor+device, mismatched device, and unset-filter cases.

The change is Linux-only; Windows behavior is unchanged and still
governed by Job Objects (#233).


Out of scope

  • Vulkan UMA / VRAM reclamation timing on SIGKILL — relies on the
    kernel's drm_release() path the same way every other Vulkan process
    does. Not benchmarked here.
  • macOS — prctl is Linux-only. macOS still uses the PR Terminate replayer processes when parent process (unexpectedly) dies. #96 process-group
    cleanup.
  • #288 (104 GB RAM growth): separate pathological pattern — looks like
    compilation state / VM growth rather than orphaning. This PR does not
    address it; a separate report with ps / nvidia-smi output would help
    triage.
  • #303 (progress flag): enhancement request, not a bug fix. Not in scope
    of this PR.
  • The "% CPU" numbers reported in the original PR description were not
    measured in this PR and have been removed.

louzt added 3 commits July 16, 2026 23:23
When Fossilize is forcefully terminated or crashes, the child replay processes currently become orphans and continue to compile shaders in the background, leading to excessive CPU/RAM usage. This adds PR_SET_PDEATHSIG to ensure child processes are terminated immediately when the parent process dies, matching the expected lifecycle of the application.
…rctl return checks

The predicate that backs --device-pci-vendor / --device-pci-device selection
moves from an inline expression in VulkanDevice::init_device into a single
static helper on VulkanDevice::pci_filter_matches. Both the CLI selection
loop and the gpu_selection_test now exercise the same logic, eliminating the
risk of the test drifting from the production path.

Also defensively check the return value of prctl(PR_SET_PDEATHSIG, SIGKILL)
in both Linux fork sites (cli/fossilize_replay_linux.hpp and
fossilize_external_replayer_linux.hpp), aborting the child with
_exit(EXIT_FAILURE) on syscall failure instead of continuing as if the
binding succeeded.

The gpu_selection_test gains a synthetic NVIDIA / AMD / Intel catalogue and
seven cases covering vendor-only, vendor+device, mismatched-device,
unset-filter, middle-slot match, last-entry match, and mixed-vendor
rejection. Replaces the prior 21-line smoke test.

All 8 ctest pass on this host (1.94 s).
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.

Why is fossilize always running?

1 participant