Repository navigation
fix(content): keep text a merge writes off a new file's other links - #471
Merged
yifanxuaaa merged 2 commits intoOct 27, 2026
Merged
Conversation
A clean Branch merge could replace the bytes of an untouched alias with text synthesized for another name. A conflicted Workspace Commit could likewise install conflict markers at a clean alias, while publishing no Commit. At caebd26, Plan::assign reused a source serial absent from into for the written value. emit_change then suppressed the original new-file value because the source had an assignment. Surviving aliases still bound that serial and therefore read the synthesized bytes. Pass EntryFacts to assign. For a source absent from into with more than one source binding, give a new synthesized result a fresh serial. Existing equal assignments are still reused. Check valued in emit_change so an assignment carried by another serial does not suppress the original value. Remove the unused Plan::assigned method. The source link-count check is conservative: a fresh carrier is also used when later decisions lose every original binding. Existing drop_new and unlinked accounting suppresses the original source once all its links are lost. The filesystem update derives both carriers' stored link counts from their bindings. Update 81-compare-and-merge.md and add four external API tests. The clean merge and conflicted-install alias tests fail before the fix. The reverse merge and all-original-bindings-merged cases are controls that pass before and after. Add stored-count and text readers to support/view.rs. No tests are deleted. Outside-sandbox validation on Colab CPU, Ubuntu 24.04.5 LTS, Linux 6.6.159+, x86_64, Rust/Cargo 1.85.1, Disposable Stores: - merge_by_serial: base plus tests 17 passed / 2 failed; fix 19 passed. - Standalone issue reproducer: base 2 failed; fix 2 passed. - Content/API fast lane: base 584 passed / 1 failed / 8 skipped; fix 588 passed / 1 failed / 8 skipped. Both fail the same local_install case because the environment's overlay filesystem is refused. - Formatting and scoped warning-denying Clippy pass. The boundary guard reports the same two violations on both sides; tool tests pass. The added cases do not force an in-lane hard-link merge or inspect a mounted alias's cached st_nlink after conflicted installation. The standalone reproducer and scratch differential target are not committed. Durable execution: NOT_RUN — disabled by owner until explicit reauthorization. Production LOC: 102,200 -> 102,199 (delta -1) core 38,241 -> 38,240 (-1): layerfs-content 25,978 -> 25,977 (-1). engine 61,313 and api 2,646 unchanged; dsh scope absent. Reference absent (retired R9c; last counted 65,417 at tree 498dd19). Method: tools/production_loc.py (blob 3341e4d) --root <snapshot> --json on an archive of parent caebd26 and that archive with the final patch applied. The retained patch matches the committed parent-to-HEAD diff. Tests, docs and tools are excluded. Fixes #ISSUE AI disclosure: this was prepared with AI assistance (Claude Opus 5.5). Signed-off-by: Jackmeson1 <130693642+Jackmeson1@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TTGAv6fe859ebbCLLfLHP
) Main gained Ephemeral-AI-Lab#418/Ephemeral-AI-Lab#419 merge tests and Ephemeral-AI-Lab#445 since this branch's base caebd26. The only conflict was engine/crates/layerfs-api/tests/ merge_by_serial.rs, where both sides appended tests after the same test (a_new_file_is_dropped_once_every_link_is_lost). Resolved by keeping both: this branch's helper changes, then main's added tests, then this branch's added tests. No line of either side was changed. Production source merged without conflict. Production LOC: 102199 -> 102527 (delta +328) First-parent comparison (bdef701 -> this merge); the +328 is main's. Against main 303246d (102528) this branch is delta -1: core layerfs-content 26058 -> 26057. Merged scopes: api 2659, core 38334, engine 61534; reference absent (retired R9c; last counted 65,417 at tree 498dd19). Counted with tools/production_loc.py (blob 3341e4d) --json --root on git archive extracts of the parent, the merged tree and main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A merge that synthesized text at one name of a new hard-linked file also assigned those bytes to its untouched aliases. Preserve the original file at surviving bindings and give the synthesized value a separate carrier.
This fixes successful Branch merges that changed an untouched alias, and conflicted Workspace Commits whose locally installed root put conflict markers at a clean alias. A conflicted Commit still publishes no Commit.
Fixes #470
Reproduction and cause
Issue #470 contains the standalone
repro_hardlinkmerge.rs.Outside-sandbox Linux validation on
caebd268dreproduced both failures:branch.merge(branch=ours, with=theirs)putOUR_LINE1\nline2\nTHEIR_LINE3\nat/uncontested.txt, although its expected bytes wereOUR_LINE1\nline2\nline3\n./clean_alias.txt, whose expected bytes wereour new file\n. It reported one conflict at/conflict.txt,history=None, andwritten=["/clean_alias.txt"].The reverse Branch merge preserves the alias before and after this change. This example does not imply general merge symmetry.
At
caebd268d,core/crates/layerfs-content/src/merge/build.rs:197reused a source serial absent fromintofor synthesized content.merge/serial.rs:400then suppressed the original new-file value because that source had an assignment. Every surviving alias still bound the same serial and therefore read the synthesized bytes.Change
EntryFactstoPlan::assign, so it can inspect the source value's link count.intoand its source link count exceeds 1, allocate a fresh serial for a new synthesized result. An existing equal assignment is still reused.emit_change, check whether the original serial itself has a planned value (valued), rather than whether any carrier was assigned for that source. Emit the original value when surviving names still require it.Plan::assignedmethod.81-compare-and-merge.md.The link-count predicate is conservative: the synthesized result receives a fresh serial even if other decisions eventually remove every original binding. Existing
collide/drop_new/unlinkedaccounting drops the original source once all its links are lost, before its value is emitted. Final stored link counts are derived from the selected bindings for both the original and synthesized carriers.Single-link sources absent from
intoretain the existing reuse behavior. The existing rules for a serial already present ininto, and decisions that select an existing entry instead of synthesizing bytes, are unchanged.Construction continues through the existing merge records and backed filesystem update. This change adds no whole-tree traversal or resident collection.
Tests
Four external tests are added to
layerfs-api::merge_by_serialusing the existing Workspace fixture:text_merged_at_one_link_of_a_new_file_leaves_its_other_link_alone: fails on base because/uncontested.txtcontains the other side's line-3 edit. Checks both contents, separate link groups and stored counts of 1.text_merged_at_one_link_of_a_new_file_leaves_its_other_link_alone_in_the_other_order: reverse-order control; passes before and after.a_new_file_with_every_name_merged_keeps_none_of_its_own_bytes: all-original-bindings-lost control; passes before and after. Its two variants check different synthesized values as separate files with counts of 1, and equal assigned values as one file with count 2.a_conflict_at_one_link_of_a_new_file_leaves_its_other_link_alone: fails on base because/clean_alias.txtcontains markers. Checks the locally installed root, stored counts, unchanged Branch contents,history=None, the single conflict, andwritten=["/conflict.txt"].support/view.rsgains stored-count and text readers plus a publichexhelper. No test is deleted.Validation
Outside-sandbox validation was run on Colab CPU, Ubuntu 24.04.5 LTS, Linux
6.6.159+, x86_64, with Rust/Cargo 1.85.1 and cargo-nextest 0.9.150. The root filesystem wasoverlay; the mount selections ran as root with/dev/fusepresent. Stores used Disposable andLAYERFS_CONSTRUCTION_WORKERS=1.Base is
caebd268dc376865e964f33f78047f7918763190. The final targeted run records patch SHA-2561dff2c642df1f670a8fa2715397d7497971067c3ddefd48804aec434061d5e9a, matching this change's retained patch.Rust targets were built before execution. Recorded test invocations had explicit wall timeouts of at most 120 seconds.
merge_by_serial, with the added test filesrepro_hardlinkmergecheck_product_boundary.py --skip-sdkThe fast-lane failure on both sides is
layerfs-api::local_install::import_installs_the_store_in_process: direct Store installation refuses this environment'soverlayfilesystem and requires ext4, xfs or btrfs.The daemon failure on both sides is
mounted_commit::r5_9_stores_through_shared_mappings_are_committed_once_written_back, withPermissionDeniedatengine/crates/layerfs-daemon/tests/mounted_commit.rs:758.The boundary violations on both sides report
unsafeoutside the audited module at:core/crates/layerfs-storage/src/encoding/codec.rs:1engine/crates/layerfs-persistence/src/backend/sqlite/file_control.rs:1The fuser check verified the authorized patch; it did not verify an installed registry package.
The slow/heavy selections were:
scale_lineage_cap(3),sdk_merge_scale(2 passed, 9 skipped),history_ancestry_oracle(1),history_diff_oracle(1),sdk_diff_entries(2),sdk_list_entries(1),sdk_text_scan(1),mount_control(9).filesystem_sorted_localized(5).The API mount-lane selections were
kernel_mount_routes(2),kernel_application(1),observed_application(9), andr5_1_actual_daemon_commit(1).The daemon selections were
mounted_links(1),mounted_commit(9),mounted_install(4),r5_5_install_survival(4),mounted_parallel_commits(1),mounted_failure_scope(2),mounted_low_water(3),negative_entries(2),store_commit(10), andstore_reader_lifecycle(5).Core commands, from the repository root:
These are scoped results, not an all-green workspace claim. Durable execution was NOT_RUN — disabled by owner until explicit reauthorization.
Coverage limits
The ordinary Workspace merge and the in-lane step share
merge_rootsandPlan::assign, but the added hard-link tests do not force the in-lane step. They also do not check a mounted alias's cachedst_nlinkafter this conflicted install; they verify the installed root's stored counts.The standalone issue reproducer and the scratch randomized differential target are not included in this PR. No new performance or unrestricted resident-memory qualification is claimed.
After the merge of main (
303246deb)Main at
303246deb(after #445) was merged into this branch in7f1c824a7; the only conflict was inmerge_by_serial.rs, where #445 and this change both appended tests. I checked an equivalent tree, this change rebased onto303246deb, which differs from7f1c824a7only in where #445'sa_one_sided_mode_change_is_keptsits in that file. On it, on the same kind of Colab CPU host:cargo fmt --all -- --checkpasses,merge_by_serialruns 27 tests and all pass, the selected slow/heavy Content/API targets pass, and the Content/API fast lane gives 612 passed, 1 failed, 8 skipped. The failure islocal_install::import_installs_the_store_in_process, which reports that the host's overlay root filesystem does not carry SQLite locks; the same test failed the same way on the base in the runs above.Related work
Duplicate-search snapshot: saved searches at 2026-10-27 04:43:26 UTC and 05:20:12 UTC, refreshed at 06:21:25 UTC, found no matching item for these synthesized-content alias failures. At that snapshot, #409 was merged, #418 contained lost-link tests, and #419 addressed replaced directories.
The saved #418/#419 diffs overlap the test insertion area and use the API test path from before the layer relocation. #419 also touches nearby merge code and document 81. Neither changes
Plan::assignor this emission guard; integration may still need conflict resolution.Production LOC: 102,200 -> 102,199 (delta -1)
Counted with
tools/production_loc.py, blob3341e4d0362c822b6d9a08419c432942b3a13800, using--root <snapshot> --jsonon the parent archive and the parent with the final patch applied. Core changes from 38,241 to 38,240;layerfs-contentfrom 25,978 to 25,977. Engine 61,313 and API 2,646 are unchanged; the dsh scope remains absent. Against303246deb, the same count gives 102,528 -> 102,527 (delta -1): core 38,335 -> 38,334,layerfs-content26,058 -> 26,057.Reference absent (retired R9c; last counted 65,417 at tree 498dd1917812).
AI disclosure: this was prepared with AI assistance (Claude Opus 5.5).
🤖 Generated with Claude Code
https://claude.ai/code/session_019TTGAv6fe859ebbCLLfLHP