Skip to content

fix(io): stage writes through existing output symlinks - #12520

Merged
oneandonlydean merged 1 commit into
boykopovar:mainfrom
TurboTheTurtle:fix/relinker-output-preservation
Oct 29, 2026
Merged

oneandonlydean merged 1 commit into
boykopovar:mainfrom
TurboTheTurtle:fix/relinker-output-preservation

Conversation

@TurboTheTurtle

Copy link
Copy Markdown
Contributor

What

A failed write through an output symlink could truncate the previous target. Resolve existing symlinks before applying the existing POSIX staging-and-replacement path, retaining absolute, relative and chained links. Windows is unchanged.

This intentionally extends #5018's regular-file replacement semantics to existing regular symlink targets: mode bits are retained, but the target gets a new inode; other hard links and open descriptors keep the previous bytes, and ownership, ACLs and extended attributes may change. Dangling links and special files retain direct writes. The existing fallback when staging or rename fails remains, so this does not guarantee preservation for every failure or provide multi-file transactions or crash durability.

Tested

macOS 27.2, Apple M5, AppleClang 21, native arm64 Release relinker-only build; synthetic fixtures, no game files.

  • New binary/text regressions fail on the original writer and pass with the fix. Cover three symlink forms, short and 64 KiB writes, failure diagnostics, retained modes/links, old hard links/open files, cleanup, dangling links and device fallback.
  • Real CLI, eight probes: successful conversions still produce ELF output; under a child file-size limit, symlink targets previously shrank from 6,900 to 1,024 bytes and now retain all previous bytes. Regular-file controls also pass.
  • Full relinker build and all 393 CTests pass. Conventions and test-registration checks pass.

Independent review found no P0–P2 findings. The overlap scan covered 1,362 open PRs; manual CMake comparisons for ten PRs with pre-existing main conflicts confirmed this registration adds no shared-file conflicts.

Linux and Windows were not run locally. The file-size limit simulates partial writes; actual disk-full and concurrent filesystem mutation were not tested.

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
  • Fixes: none
  • Unblocks: none
  • GPUs tested: n/a
  • AI-assisted: yes

@oneandonlydean oneandonlydean self-assigned this Oct 28, 2026
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Some comparisons could not be completed because a pull request conflicts with the base branch. Review these shared files manually before concluding that there is no overlap.

Pull request Shared files not compared
PR 5021 core/relinker/CMakeLists.txt
PR 9793 core/relinker/CMakeLists.txt
PR 10861 core/relinker/CMakeLists.txt

@rooklz rooklz 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.

Independent re-run on a second machine and a second OS family. Head 0974e1378e, base 7fea87746b. git merge-tree against current main 2199ee0d47 is clean.

macOS 26.2 arm64, AppleClang, relinker-only Release

  • Full ctest: 393/393 pass. symlink_file_writer_binary and symlink_file_writer_text pass.
  • With main's FileWriter.cpp swapped in, both tests are RED (absolute failed write changed the previous target).

Linux, first run (body says Linux was not run)

  • Environment: Ubuntu 24.04 amd64 container, GCC 13, -fsanitize=address,undefined, standalone SymlinkFileWriterTests.cpp + FileWriter.cpp + NativePath.cpp.
  • Head: binary and text both rc 0, with no sanitizer reports.
  • Main: both rc 1, with the same message as on macOS.

Mutants of the new block (each run on both modes)

  • Killed:
    • Read the link once (parent / read_symlink) instead of canonical: fails in the chained case.
    • Drop the re-symlink_status after resolving: fails in the absolute case.
  • Equivalent: status instead of symlink_status after canonical. canonical has already resolved every link, so both calls give the same result.
  • Survivors (non-blocking):
    • weakly_canonical instead of canonical. For a dangling link, this stages a new file at the missing target instead of falling back to the direct write that the body documents ("Dangling links retain direct writes"). The dangling fixture only checks the final link and target, so both behaviours pass. A failure-limit probe on the dangling link would pin it: a direct write leaves a partial file, while staging leaves none.
    • Dropping if (error) return false;. canonical then returns an empty path, so createTemporary writes the payload into ./.anyps5-XXXXXX in the current directory before the rename to "" fails and the code falls back. Nothing visible remains afterwards, so no test can see it. The guard is necessary; this is only a note that it is not pinned.

Edge probes, head vs main (identical results)

  • A link to a writable file inside a read-only directory: staging fails, the code falls back to an in-place write, the file gets newdata, and the link is kept.
  • A self-loop link: canonical errors, then Cannot open output file: …/loop.

Gates

  • check_conventions --base upstream/main is clean.
  • pr_overlap --branch matches the body: the only issues are core/relinker/CMakeLists.txt uncomparables (5021/6713/9793/10861, all pre-existing main conflicts).

LGTM.

@rooklz

rooklz commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Correction to my review: the weakly_canonical survivor is equivalent, not a gap. I probed it (macOS libc++, dangling link, 4096-byte write under RLIMIT_FSIZE=1). Head and the mutant give the same result: new-output exists with size 1, and the link is kept. Both paths take the direct-write fallback. weakly_canonical stops at the dangling link and returns the link path itself, and the re-symlink_status then sees a symlink and returns false. No fixture change is needed for it. The note about the if (error) return false; guard still stands. The approval is unchanged.

@MarcZX10 MarcZX10 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.

Reviewed — looks good

Resolving existing symlinks before the existing staged replacement path prevents failed writes from partially truncating their regular-file targets, and the exact-head fixture passes on Linux here.

What I ran

Reviewed head 0974e1378e1fe2d8046b025f0da367176917c1ab against PR base 7fea87746bde8e14a1d3341af85377c5b53f89ad; it also merges cleanly against current main e4da19aff06e25ae16b90f460dff22e8ac1eb786 (merge tree b45e75d054a65ed76dcd572de514825ffef5f204). Environment: Debian 12, GCC 12.2.0. I compiled the exact PR FileWriter.cpp, NativePath.cpp, and symlink test source as a focused standalone target; the include headers were unchanged by the PR.

git fetch upstream main --quiet
git fetch upstream refs/pull/12520/head:refs/codex/review-12520 --quiet
git merge-tree --write-tree upstream/main refs/codex/review-12520
git diff --check upstream/main..refs/codex/review-12520

git show refs/codex/review-12520:core/relinker/io/src/FileWriter.cpp > /tmp/anyps5-pr12520-review/FileWriter.cpp
git show refs/codex/review-12520:core/relinker/io/src/NativePath.cpp > /tmp/anyps5-pr12520-review/NativePath.cpp
git show refs/codex/review-12520:core/relinker/io/tests/SymlinkFileWriterTests.cpp > /tmp/anyps5-pr12520-review/SymlinkFileWriterTests.cpp
c++ -std=c++20 -O2 -I core/relinker/io/include -I core/relinker/domain/include /tmp/anyps5-pr12520-review/SymlinkFileWriterTests.cpp /tmp/anyps5-pr12520-review/FileWriter.cpp /tmp/anyps5-pr12520-review/NativePath.cpp -o /tmp/anyps5-pr12520-review/symlink_file_writer_tests
/tmp/anyps5-pr12520-review/symlink_file_writer_tests binary
/tmp/anyps5-pr12520-review/symlink_file_writer_tests text

# Negative control: replace only FileWriter.cpp with current main, then build and run the same binary fixture.
git show upstream/main:core/relinker/io/src/FileWriter.cpp > /tmp/anyps5-pr12520-review/FileWriter.main.cpp
c++ -std=c++20 -O2 -I core/relinker/io/include -I core/relinker/domain/include /tmp/anyps5-pr12520-review/SymlinkFileWriterTests.cpp /tmp/anyps5-pr12520-review/FileWriter.main.cpp /tmp/anyps5-pr12520-review/NativePath.cpp -o /tmp/anyps5-pr12520-review/symlink_file_writer_tests_main
/tmp/anyps5-pr12520-review/symlink_file_writer_tests_main binary

Results

Check Result
Merge with current main Clean; merge tree b45e75d054a65ed76dcd572de514825ffef5f204
git diff --check Passed (exit 0; no output)
Exact-head binary fixture Passed (exit 0)
Exact-head text fixture Passed (exit 0)
Negative control using current-main FileWriter.cpp Failed as expected: absolute failed write changed the previous target (exit 1)
Hosted conventions, intake, labels, eligibility Passed
Hosted Ubuntu and Windows relinker, relinker ASAN Passed
Hosted macOS arm64 and x86_64 relinker jobs Pending at review time
Hosted product Linux and Windows jobs Skipped

The PR description reports 393/393 macOS relinker CTests passing. An independent review reports additional macOS and Linux sanitizer runs; I did not reproduce those runs locally.

Unedited focused test output

The PR binary and text fixture commands both exited 0 and printed no output.

Negative-control output:

absolute failed write changed the previous target
negative_control_exit=1

Hosted checks:

relinker (macos-latest, clang++)	pending	0	https://github.com/boykopovar/AnyPS5/actions/runs/40732959805/job/122975693955
conventions	pass	14s	https://github.com/boykopovar/AnyPS5/actions/runs/40732960482/job/122973872364
intake	pass	20s	https://github.com/boykopovar/AnyPS5/actions/runs/40732960200/job/122973870083
labels	pass	11s	https://github.com/boykopovar/AnyPS5/actions/runs/40732960463/job/122973870937
relinker (ubuntu-latest, g++)	pass	5m20s	https://github.com/boykopovar/AnyPS5/actions/runs/40732959805/job/122975694092
relinker (windows-latest, g++)	pass	8m16s	https://github.com/boykopovar/AnyPS5/actions/runs/40732959805/job/122975693587
relinker-asan	pass	2m42s	https://github.com/boykopovar/AnyPS5/actions/runs/40732959805/job/122975693648
relinker (macos-latest, clang++, x86_64)	pending	0	https://github.com/boykopovar/AnyPS5/actions/runs/40732959805/job/122975693885
linux	skipping	0	https://github.com/boykopovar/AnyPS5/actions/runs/40732959805/job/122975696561
register-context	skipping	0	https://github.com/boykopovar/AnyPS5/actions/runs/40732959805/job/122975696733
windows	skipping	0	https://github.com/boykopovar/AnyPS5/actions/runs/40732959805/job/122975696999
write-watch	skipping	0	https://github.com/boykopovar/AnyPS5/actions/runs/40732959805/job/122975696356
eligibility	pass	6s	https://github.com/boykopovar/AnyPS5/actions/runs/40732959805/job/122973866547

Negative control

Assertion Change to code under test Result
An incomplete write through an existing symlink must preserve the previous regular-file target Replaced only FileWriter.cpp with current-main code; kept the PR fixture unchanged Fixture failed with absolute failed write changed the previous target; the PR implementation passes

Findings

Blocking: None found.

Non-blocking: The direct-write fallback for dangling links and special files retains the existing partial-write behavior, and concurrent filesystem mutation is not covered. The PR description states these limits. The independent review’s correction about weakly_canonical is consistent with the tested dangling-link behavior; I found no remaining blocker in that path.

Sources

Not verified

I did not run macOS or Windows locally, exercise actual disk-full conditions, or stress concurrent symlink replacement. The PR author and an independent reviewer report macOS coverage; the independent reviewer also reports Linux sanitizer coverage.

To get this to a good state

No changes are needed for this reviewed diff. If the head changes, I’ll rerun the focused binary/text fixtures and negative control on the new head.

@oneandonlydean
oneandonlydean merged commit 2a48ba7 into boykopovar:main Oct 29, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants