fix(loader): persist user-supplied FileNames, not the extends-mutated slice#487
Merged
F1bonacc1 merged 2 commits intoMay 18, 2026
Merged
Conversation
… slice
When a compose file uses `extends:`, `loadExtendProject` resolves the target
and inserts it into `opts.FileNames` in place (via `slices.Insert`). After
`Load()` returns, `mergedProject.FileNames` is set to that mutated slice and
stored on the long-lived project.
`ProjectRunner.ReloadProject()` reuses `p.project.FileNames` to build a new
`LoaderOptions` for the next `Load()`. The slice it passes already contains
the extends-resolved file. On the second pass, the loader walks the merged
file list, gets to the file that declares `extends:`, and the
`slices.Contains` check at line 103 trips on an entry the loader itself
inserted on the prior pass — emitting:
project <extends-target> is already specified in files to load
Net effect: any project using `extends:` becomes permanently unable to be
reloaded via `POST /project/configuration` after its first successful load.
Fix: persist the snapshot of the user-supplied `FileNames` taken at the top
of `Load()` (the existing local `fileNames` variable, already made for loop
iteration), not the post-mutation `opts.FileNames`. This way subsequent
`Load()` calls see the pristine input again and `loadExtendProject` keeps
detecting genuine self-extends / duplicate `-f` while no longer false-firing
on its own bookkeeping.
Repro: a project with `-f main.yaml` where main.yaml has
`extends: extended.yaml`. Startup succeeds; the very first
`POST /project/configuration` fails with the error above. With this patch
it returns the expected reconcile status (`{}` for a no-op reload).
Related to F1bonacc1#294 (same error string, different trigger — multiple `-f`
files with cross-extends at startup).
|
F1bonacc1
approved these changes
May 18, 2026
F1bonacc1
left a comment
Owner
There was a problem hiding this comment.
@eike-hass - pushed a regression test on top (bcf2468) since the fix is exactly the kind of thing a future "simplification" could quietly undo.
Test feeds project.FileNames from one Load() back into the next, mirroring how ProjectRunner.ReloadProject does it. Verified it fails on main and passes with your patch.
Hope you don't mind the maintainer edit - your diagnosis and the one-line fix made it trivial to lock in. Thanks for the thorough write-up and reproduction script.
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
Any project using
extends:becomes permanently unable to be reloaded viaPOST /project/configurationafter its first successful load. Reload returns:This is the same error string as #294 but a different trigger — that issue is about multiple
-ffiles with cross-extends at startup; this is about reload-time state leaking from oneLoad()call to the next whenextends:is used.The bug
loadExtendProject(src/loader/loader.go#L98-L120) mutatesopts.FileNamesin place when it resolves anextends:directive:After
Load()finishes,mergedProject.FileNamesis set to that mutated slice (loader.go#L50) and stored on the long-lived project.ProjectRunner.ReloadProject()then reusesp.project.FileNamesto build the nextLoaderOptions:The second
Load()walks the now-two-elementFileNames, gets to the file withextends:, and theContainscheck trips on an entry the loader itself inserted on the prior pass.The fix
One line:
fileNamesis the snapshot of user-suppliedFileNamesalready taken at the top ofLoad()for the for-loop. Persisting the snapshot — instead of the post-mutationopts.FileNames— means subsequentLoad()calls see the pristine input again. TheContainscheck at line 103 keeps doing its real job (detecting genuine self-extends or duplicate-f) and stops false-firing on its own bookkeeping. ExistingTestLoadExtendProject/prevent_same_projectstill passes.Minimal reproduction
Three files in a directory; run
./run.sh(withprocess-composeon$PATH, or passPROCESS_COMPOSE=/path/to/binary).main.yaml
extended.yaml
run.sh
Before / After
Built
v1.110.0(commitb9b8820) from source twice — once clean, once with this patch — and ran the script above.Before (unmodified)
After (this patch)
Empty status map = no-op reconcile, exactly the expected outcome for a reload with no on-disk changes.
Tests
go test ./src/loader/passes, includingTestLoadExtendProject/prevent_same_projectwhich guards the legitimate self-extends detection that theContainscheck at line 103 implements (and which this fix deliberately preserves — only the leak acrossLoad()calls is removed).Why this fix vs alternatives
Containscheck to skip-instead-of-error: works, but silences the genuine "X extends itself" case and breaksTestLoadExtendProject/prevent_same_project.FileNamesseparately and check against the snapshot insideloadExtendProject: works but requires plumbing the snapshot through, when the same effect is achievable by not persisting the mutated slice in the first place.loadExtendProjectnot mutateopts.FileNamesat all: would require restructuring how the extends-resolved files participate in the outer for-loop iteration inLoad(). Bigger surgery for the same observable result.The one-line change minimizes blast radius — only the field stored on the project changes; everything inside
Load()operates exactly as today.Related
-fcross-extends at startup, fixed). The reload-time manifestation here was not addressed by that fix.