Skip to content

fix(relinker): replace output files instead of rewriting them in place - #5018

Merged
adriadam10 merged 2 commits into
boykopovar:mainfrom
SandstromPL:fix/relinker-replace-outputs
Oct 14, 2026
Merged

adriadam10 merged 2 commits into
boykopovar:mainfrom
SandstromPL:fix/relinker-replace-outputs

Conversation

@SandstromPL

@SandstromPL SandstromPL commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

What

On Linux, relinking while the previous build is still running fails with Cannot open output file: …/game.elf (ETXTBSY), but only after the guest modules have been rewritten in place. The running build's loaded modules change under it, and a process that has one mapped gets SIGBUS when the module gets shorter. The output directory is left with new modules beside the previous executable and registries. Separately, a write that fails part way, for example on a full disk, leaves a truncated output.

On Linux and macOS, FileWriter now writes a regular output to a new file beside it and renames it over the output, as savedata::replace_file does. The new file gets a name that does not exist yet: <output>. plus six random characters, created with O_CREAT | O_EXCL. It gets the previous file's permission bits and is written through that descriptor. Open files keep their previous contents, and a failed write leaves the previous file whole. An output is still written in place, as before, in these cases:

  • it is a symbolic link or not a regular file;
  • no file can be created beside it, for example in a read-only directory;
  • the rename is refused.

A relink killed between the write and the rename leaves its temporary file behind. That name is never used again, so later relinks are not affected. (The first revision used <output>.tmp, and a file left there made every later relink fall back to the in-place write; thanks to CnRJay's review.)

Behaviour changes for replaced outputs:

  • A read-only output is replaced instead of failing. On main, that relink fails after it has rewritten the modules.
  • An output hard-linked to another name no longer changes that name.
  • Owner, group, ACLs and extended attributes are those of a new file.
  • Set-uid and set-gid are dropped, as a rewrite by a non-root user already does.

Windows is unchanged. It refuses to replace a running executable or a loaded library as well, so only the partial-write case would improve there, and that needs the rename handling #3660 added for Windows readers.

Tested

Ubuntu 24.04, GCC 13.3, relinker-only build with BUILD_TESTING=ON: 145/145 tests pass on this branch, and 155/155 with it merged into main 016db92.

  • FileWriterTests.cpp (Linux) gains four checks, run for both overloads:

    • A copy of sleep runs from the output path with a descriptor open on it. The write succeeds, and the path has the new contents. The write runs under umask 077, so the previous permission bits (0770) must be restored exactly. The descriptor still reads the previous contents, the process keeps running, and no temporary file is left.
    • The same, with a file left at <output>.tmp by an earlier, killed relink. The output is still replaced, and that file is not touched.
    • A write cut short by RLIMIT_FSIZE fails and keeps the previous file, with no temporary file left.
    • A symbolic-link output is written in place as before, and an existing <output>.tmp is left alone.

    With main's FileWriter, the first two checks fail with Cannot open output file, and the third leaves a truncated file. With this PR's first revision, the second check fails with Cannot open output file. Without the fchmod that restores the previous permission bits, the first check fails.

  • End to end, with the test fixtures (a bundled module and --registry): a copy of sleep runs from the output path, and another process maps a longer previous guest module. Relinking again:

    • on main exits 2. The previous executable and registry are kept, the module is rewritten, and the mapping process gets SIGBUS.
    • on this branch exits 0 with new outputs, and both processes keep running.
  • Other output states give the same result as main, re-run with this revision. They are a fresh output (same contents and modes), an existing 0755 output, a symbolic-link output, a writable output in a read-only directory, an <output>.tmp symbolic link (its target is left alone), and a leftover <output>.tmp file.

Not covered:

Checklist

  • Based on current main; no other open PR implements the same functions
  • One topic per PR; follow-ups go in a new PR
  • No comments in code except technical debt
  • Unimplemented paths throw (NotImplemented_nid_no_patch); silent stubs are listed under Silent stubs in their library's technical debt file
  • No notes, investigation .md files or images added to the repository (attach them to this PR)
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: none
  • Unblocks: none
  • GPUs tested: n/a
  • AI-assisted: yes (Claude Code); I reviewed and tested every line

🤖 Generated with Claude Code

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

@CnRJay CnRJay left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Writing to a sibling file and renaming over the output is the right approach for ETXTBSY and for partial writes. The fallbacks for symlinks and non-regular files are sensible.

One case I'd change: the fixed <output>.tmp name. If a relink is killed between writeFile and moveOver (Ctrl+C or SIGKILL; catch (...) only covers exceptions), the .tmp is left behind. Every later relink of that output then sees an existing .tmp and silently falls back to the in-place write. That brings back exactly the failure this PR fixes: Cannot open output file after the modules have already been rewritten, and SIGBUS for a running process. Nothing tells the user why. A unique name in the same directory avoids that and still never touches an unrelated file. For example, mkstemp on <output>.XXXXXX, then writing through that descriptor, or opening a generated name with O_CREAT | O_EXCL. A unique temporary also has the right mode from the start if it is created with the previous permission bits, rather than created and then changed with permissions().

This also overlaps with #4802. Once outputs are replaced by rename on Linux and macOS, a hard link between two outputs no longer makes one overwrite the other: each rename replaces only its own name. That removes most of the reason for #4802's hard-link identity probing on those hosts. #4802's case-insensitive and same-path checks are still useful.

A relink killed between writing <output>.tmp and renaming it left that
file behind, and every later relink of the output then fell back to the
in-place write, bringing back the failure on a running build. The
temporary file is now created under a new name beside the output with
O_CREAT | O_EXCL and the previous permission bits, and written through
that descriptor, so a file left by an earlier run never blocks a
replacement and is never touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@SandstromPL

Copy link
Copy Markdown
Contributor Author

Thanks, good catch. Fixed in 26c62cc: the temporary file now gets a fresh name beside the output (<output>. plus six random characters), created with O_CREAT | O_EXCL and the previous permission bits, written through that descriptor and renamed over the output. A file left at <output>.tmp, or anywhere else, no longer forces the in-place write. A new test leaves <output>.tmp behind and then replaces a running executable: it fails on the previous revision with Cannot open output file and passes now. The permission check also runs under umask 077 now, so the previous bits must be restored exactly. A killed relink can still leave its own temporary file, but that name is never reused, so it doesn't affect later runs.

On #4802: agreed. With the rename, two hard-linked outputs no longer write through each other on Linux and macOS. Windows still writes outputs in place, so the identity check still matters there.

@jotitadev

jotitadev commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

macOS data point. file_writer_binary/file_writer_text are registered only on Linux (/dev/full, /proc/<pid>/exe), so the new POSIX path had no macOS coverage; I exercised it with a small harness instead.

Apple M2 Max, macOS 27.2 (26B5091g), AppleClang, Ninja Release, relinker-only. Head 26c62cc63 merged without conflicts into main 84b01f9cd.

  • arm64: ctest --parallel 8 --timeout 30 156/156 pass.
  • x86_64 under Rosetta 2 (-DCMAKE_OSX_ARCHITECTURES=x86_64, x86_64 Python): 150/156; the 6 failures (pr_overlap_encoding, progress_history, stubgen_stdout_newlines, release_package, linux_fixed_base, guest_module_args) fail the same way on unmodified main on this machine (five are Python 3.9 incompatibilities of the only x86_64 Python here; guest_module_args is the fixture test(relinker): keep the guest_module_args fixture's GOT out of executable memory #5362 fixes).

Harness (APFS, links FileWriter.cpp from main and from this PR, same driver), output path running as an executable while it is rewritten with a different executable:

Case main This PR
Running arm64 Mach-O replaced write succeeds (no ETXTBSY on macOS), rewritten in place write succeeds, new file renamed over it
Running x86_64 Mach-O (Rosetta) replaced same as arm64 same as arm64
Descriptor opened before the write still reads the previous bytes no yes
Running process survives / new binary runs afterwards yes / yes yes / yes
Mode 0750 kept under umask 077 yes yes
RLIMIT_FSIZE = 1 byte, 64 KiB write Failed to write file, output left truncated to 1 byte Failed to write file, previous previous contents kept, no temporary left
Symlink output written through, link kept written through, link kept

So on macOS main does not fail like on Linux; it silently rewrites the running file in place, which this PR avoids.

Hard links (same on any POSIX host), as listed in the description: an output that is a hard link is now replaced by a new inode, so the other name keeps the old contents (main: both names see the new contents, link count 2; this PR: link count 1).

Written with AI assistance (Claude Code). I ran the builds and tests above on my machine.

@SandstromPL

Copy link
Copy Markdown
Contributor Author

Thanks for the macOS run and the harness, especially the Rosetta and RLIMIT_FSIZE cases. The hard-link change is intended; it's listed under "Behaviour changes for replaced outputs" ("An output hard-linked to another name no longer changes that name"). It's what replacing by rename gives: LLVM's FileOutputBuffer, which LLVM's tools write their outputs through, does the same for regular files (FileOutputBuffer.cpp). Keeping the link would mean rewriting in place, which brings back the Linux ETXTBSY failure and the in-place rewrite of a running executable you measured on macOS.

@jotitadev

Copy link
Copy Markdown
Contributor

You're right, it is listed under "Behaviour changes for replaced outputs"; I missed it. I've corrected my comment above.

@adriadam10 adriadam10 self-assigned this Oct 14, 2026
@adriadam10

Copy link
Copy Markdown
Collaborator

Checked the temp-file-and-rename path in FileWriter, the in-place fallbacks, and the tests: relinker build and ctest are green (129/129 scoped), and file_writer_binary/file_writer_text fail with the previous FileWriter. The unique temp name addresses CnRJay's review, and jotitadev's macOS run covers the POSIX path. Merging.

@adriadam10
adriadam10 merged commit 5561db2 into boykopovar:main Oct 14, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants