Skip to content

fix(loader): persist user-supplied FileNames, not the extends-mutated slice#487

Merged
F1bonacc1 merged 2 commits into
F1bonacc1:mainfrom
eike-hass:fix/loader-filenames-mutation-on-reload
May 18, 2026
Merged

fix(loader): persist user-supplied FileNames, not the extends-mutated slice#487
F1bonacc1 merged 2 commits into
F1bonacc1:mainfrom
eike-hass:fix/loader-filenames-mutation-on-reload

Conversation

@eike-hass

@eike-hass eike-hass commented May 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Any project using extends: becomes permanently unable to be reloaded via POST /project/configuration after its first successful load. Reload returns:

HTTP 400  {"error":"project <extends-target> is already specified in files to load"}

This is the same error string as #294 but a different trigger — that issue is about multiple -f files with cross-extends at startup; this is about reload-time state leaking from one Load() call to the next when extends: is used.

The bug

loadExtendProject (src/loader/loader.go#L98-L120) mutates opts.FileNames in place when it resolves an extends: directive:

if slices.Contains(opts.FileNames, p.ExtendsProject) {
    return fmt.Errorf("project %s is already specified in files to load", p.ExtendsProject)
}
opts.FileNames = slices.Insert(opts.FileNames, index, p.ExtendsProject)

After Load() finishes, mergedProject.FileNames is set to that mutated slice (loader.go#L50) and stored on the long-lived project.

ProjectRunner.ReloadProject() then reuses p.project.FileNames to build the next LoaderOptions:

opts := &loader.LoaderOptions{
    FileNames: p.project.FileNames,    // ← already contains the extends target
    ...
}
project, err := loader.Load(opts)

The second Load() walks the now-two-element FileNames, gets to the file with extends:, and the Contains check trips on an entry the loader itself inserted on the prior pass.

The fix

One line:

-	mergedProject.FileNames = opts.FileNames
+	mergedProject.FileNames = fileNames

fileNames is the snapshot of user-supplied FileNames already taken at the top of Load() for the for-loop. Persisting the snapshot — instead of the post-mutation opts.FileNames — means subsequent Load() calls see the pristine input again. The Contains check at line 103 keeps doing its real job (detecting genuine self-extends or duplicate -f) and stops false-firing on its own bookkeeping. Existing TestLoadExtendProject/prevent_same_project still passes.

Minimal reproduction

Three files in a directory; run ./run.sh (with process-compose on $PATH, or pass PROCESS_COMPOSE=/path/to/binary).

main.yaml

version: "0.5"

# This is the user-edited "main" project file. It pulls in extended.yaml via
# `extends:`. PC is launched with a single `-f main.yaml`.
extends: extended.yaml

processes:
  main-hello:
    command: echo main-hello && sleep 3600
    availability:
      restart: always

extended.yaml

version: "0.5"

# Pulled in by main.yaml via `extends: extended.yaml`. PC is NOT launched with
# `-f extended.yaml` — extends is the only path that puts this file into the
# loaded set.
processes:
  extended-world:
    command: echo extended-world && sleep 3600
    availability:
      restart: always

run.sh

#!/usr/bin/env bash
# Minimal reproduction of the process-compose reload bug:
#
#   Starting PC with `-f main.yaml` where main.yaml has `extends: extended.yaml`
#   loads both files correctly. But the very first call to POST /project/configuration
#   fails with `project <extended.yaml> is already specified in files to load` —
#   even though no config changed and no second `-f` was passed.
#
# Root cause is in src/loader/loader.go's loadExtendProject: the initial Load()
# mutates opts.FileNames in place (inserting the extends target), and that
# mutated slice is what the long-lived ProjectRunner hands to ReloadProject on
# the next /project/configuration call. The Contains check then trips on
# something the loader itself inserted.
#
# Usage:
#   ./run.sh                     # process-compose must be on $PATH
#   PROCESS_COMPOSE=/path/to/process-compose ./run.sh
#   PORT=19999 ./run.sh
set -euo pipefail

PC="${PROCESS_COMPOSE:-process-compose}"
PORT="${PORT:-19999}"

if ! command -v "$PC" >/dev/null 2>&1; then
    echo "ERROR: process-compose not found. Set PROCESS_COMPOSE=/path or add it to PATH." >&2
    exit 2
fi

cd "$(dirname "$0")"

PC_PID=""
cleanup() {
    if [[ -n "$PC_PID" ]]; then
        kill "$PC_PID" 2>/dev/null || true
        wait "$PC_PID" 2>/dev/null || true
    fi
}
trap cleanup EXIT

echo "== process-compose version =="
"$PC" version
echo

echo "== launching: $PC up --keep-project --tui=false -f main.yaml --port $PORT =="
echo "   (main.yaml has 'extends: extended.yaml' — single -f, no second file)"
"$PC" up --keep-project --tui=false -f main.yaml --port "$PORT" >pc.log 2>&1 &
PC_PID=$!

for _ in $(seq 1 50); do
    if curl -fsS "http://127.0.0.1:$PORT/live" >/dev/null 2>&1; then break; fi
    sleep 0.1
done
if ! curl -fsS "http://127.0.0.1:$PORT/live" >/dev/null 2>&1; then
    echo "FAIL: PC did not come up on port $PORT within 5s. Log:" >&2
    cat pc.log >&2
    exit 1
fi

echo "== /processes — both main-hello AND extended-world are present, proving extends loaded fine =="
curl -fsS "http://127.0.0.1:$PORT/processes" \
    | python3 -c 'import sys,json; d=json.load(sys.stdin); [print(f"  - {p[\"name\"]} ({p[\"status\"]})") for p in d.get("data", d if isinstance(d,list) else [])]' \
    2>/dev/null || curl -fsS "http://127.0.0.1:$PORT/processes"
echo

echo "== POST /project/configuration — the very first reload, with no config change =="
echo "-- request --"
echo "POST http://127.0.0.1:$PORT/project/configuration  (empty body)"
echo "-- response --"
curl -sS -X POST "http://127.0.0.1:$PORT/project/configuration" \
    -w $'\n-- HTTP %{http_code} --\n'

echo
echo "Expected: HTTP 200 with reconcile-status JSON (no config changed → no-op)."
echo "Actual:   HTTP 400/500 with 'is already specified in files to load'."

Before / After

Built v1.110.0 (commit b9b8820) from source twice — once clean, once with this patch — and ran the script above.

Before (unmodified)

== /processes (extends loaded fine at startup) ==
{"data":[
  {"name":"extended-world", "status":"Running", ...},
  {"name":"main-hello",     "status":"Running", ...}
]}

== POST /project/configuration (first reload, no config change) ==
{"error":"project extended.yaml is already specified in files to load"}
-- HTTP 400 --

After (this patch)

== /processes (extends loaded fine at startup) ==
{"data":[
  {"name":"extended-world", "status":"Running", ...},
  {"name":"main-hello",     "status":"Running", ...}
]}

== POST /project/configuration (first reload, no config change) ==
{}
-- HTTP 200 --

Empty status map = no-op reconcile, exactly the expected outcome for a reload with no on-disk changes.

Tests

go test ./src/loader/ passes, including TestLoadExtendProject/prevent_same_project which guards the legitimate self-extends detection that the Contains check at line 103 implements (and which this fix deliberately preserves — only the leak across Load() calls is removed).

Why this fix vs alternatives

  • Weaken the Contains check to skip-instead-of-error: works, but silences the genuine "X extends itself" case and breaks TestLoadExtendProject/prevent_same_project.
  • Snapshot FileNames separately and check against the snapshot inside loadExtendProject: works but requires plumbing the snapshot through, when the same effect is achievable by not persisting the mutated slice in the first place.
  • Make loadExtendProject not mutate opts.FileNames at all: would require restructuring how the extends-resolved files participate in the outer for-loop iteration in Load(). 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

eike-hass and others added 2 commits May 17, 2026 14:10
… 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).
@sonarqubecloud

Copy link
Copy Markdown

@F1bonacc1 F1bonacc1 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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

@F1bonacc1 F1bonacc1 merged commit 5e62578 into F1bonacc1:main May 18, 2026
7 checks passed
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.

2 participants