Skip to content

fix(xim): surface multi-repo install failures + best-effort catalog (#374) [0.4.67]#375

Merged
Sunrisepeak merged 3 commits into
mainfrom
fix/issue374-multi-repo-silent-exit
Jul 18, 2026
Merged

fix(xim): surface multi-repo install failures + best-effort catalog (#374) [0.4.67]#375
Sunrisepeak merged 3 commits into
mainfrom
fix/issue374-multi-repo-silent-exit

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

Fixes #374.

Symptom

xlings interface install_packages / plan_install exited 1 with an empty NDJSON stream whenever a project configured ≥2 index_repos and one of them was degenerate (no pkgs/, unreachable URL, empty default-namespace redirect target). Consumers (mcpp, mcpp-community/mcpp#238) saw only:

{"exitCode":1,"kind":"result"}

…with zero diagnostics. Single-repo configs worked. Reproduced with a clean single-vs-double comparison (see design doc §3).

Two root causes (architectural)

  1. Observability gap — the xim command layer reported failures via the global log::error(), which interface mode (platform::set_tui_mode(true)) fully suppresses (not even stderr). The NDJSON protocol already has a first-class ErrorEvent kind that subos.cppm uses — cmd_install just never did. This affected every exit-1 path, not only multi-repo.
  2. Multi-repo all-or-nothingPackageCatalog::rebuild() and sync_all_repos() fail-fast on the first bad repo, so one degenerate index_repo collapsed the whole catalog (healthy repos included). Sub-index repos were already best-effort right next to the fatal project-repo path.

Fix

  • P0 (observability): cmd_install emits a structured ErrorEvent (code + hint + searched-repos) at every resolve/build/plan/install failure point instead of a swallowed log::error. interface::run adds a backstop invariant: a non-zero exit that emitted no ErrorEvent synthesizes one, so the stream is never empty on failure.
  • P1 (robustness): catalog.rebuild() and sync_all_repos() are best-effort — a repo that can't load/sync is skipped and recorded, the rest keep working, and the skip is surfaced on the wire as a warn LogEvent. rebuild hard-fails only when nothing loaded. Mirrors the existing sub-index precedent.

Result for the reported scenario: healthy repos still resolve packages (exit 0) and the skipped repo is visible on the wire; genuinely-missing targets exit non-zero with a structured E_NOT_FOUND error naming the searched repos.

Verification

  • New e2e E2E-29 interface_multi_repo_error_visibility_test.sh:
    • A1 healthy target resolves despite a broken sibling repo → exit 0 + skip surfaced on wire.
    • A2 / A2b missing target → non-zero exit and {"kind":"error"} on the wire (both plan_install and install_packages).
  • RED on 0.4.66 (silent {"exitCode":1}), GREEN on 0.4.67.
  • mcpp test: 11/11 unit binaries pass (incl. test_interface_protocol).
  • Offline e2e subset (install/index/resolve/error paths): no regressions.

Bumps version 0.4.66 → 0.4.67.

Design doc: .agents/docs/2026-07-19-issue374-multi-repo-silent-exit-design.md

…374)

`interface install_packages`/`plan_install` exited 1 with an EMPTY NDJSON
stream whenever a project configured >=2 index_repos and one was degenerate
(no pkgs/, unreachable, empty default-namespace redirect target). Consumers
(mcpp) saw `{"exitCode":1,"kind":"result"}` with zero diagnostics.

Two independent architectural defects combined:

  1. Observability: the xim command layer reported failures via the global
     log::error(), which interface mode (platform::set_tui_mode(true)) fully
     suppresses. The NDJSON protocol has a first-class ErrorEvent kind that
     subos.cppm already uses — cmd_install just never did.

  2. Multi-repo robustness: PackageCatalog::rebuild() and sync_all_repos()
     fail-fast on the first bad repo, so one degenerate index_repo collapses
     the whole catalog — even the healthy repos. (Sub-index repos were
     already best-effort right next to the fatal project-repo path.)

Fix (design doc §4):
  * P0 — cmd_install emits structured ErrorEvent (code + hint + searched
    repos) at every resolve/build/plan/install failure point, instead of a
    swallowed log::error. interface::run adds a backstop invariant: a
    non-zero exit with no ErrorEvent synthesizes one, so the stream is never
    empty on failure.
  * P1 — catalog.rebuild() and sync_all_repos() are best-effort: a repo that
    can't load/sync is skipped and recorded, the rest keep working, and the
    skip is surfaced on the wire as a warn LogEvent. rebuild hard-fails only
    when NOTHING loaded. Matches the sub-index precedent.

Adds e2e E2E-29 (interface_multi_repo_error_visibility_test.sh):
  A1 healthy target resolves despite a broken sibling repo (exit 0 + skip
     surfaced); A2 missing target -> non-zero + {"kind":"error"} on the wire.
Verified: RED on 0.4.66, GREEN on 0.4.67; 11/11 unit tests pass.

Bumps version 0.4.66 -> 0.4.67.

Refs: .agents/docs/2026-07-19-issue374-multi-repo-silent-exit-design.md
Code-review follow-up on the non-zero-exit backstop added in the previous
commit. Two paths returned non-zero without routing through the session, so
the generic backstop fired with a misleading E_INTERNAL:

  * CancelledException (client `{"action":"cancel"}`, exit 130) emitted no
    ErrorEvent → backstop synthesized `E_INTERNAL "... failed with no error
    detail"` for a NORMAL, expected cancellation. Now emits the dedicated
    E_CANCELLED (recoverable) — which also finally makes that enum value
    live — so saw_error() is true with the correct code and the backstop
    is bypassed.
  * std::exception wrote its error straight to std::cout (not via session),
    leaving saw_error() false → backstop emitted a SECOND, generic error on
    top of the specific one. Now routed through session.emit_event so it is
    counted and not duplicated.

Strengthens test_interface_protocol's plan_install-missing case to assert
the #374 guarantee directly: non-zero exit + a structured E_NOT_FOUND error
on the wire (was only checking that some NDJSON was produced).

Verified: mcpp test 11/11; e2e interface_multi_repo_error_visibility GREEN.
…ation)

The E2E-29 test pointed the global index at tests/fixtures/xim-pkgindex and
wrote an xim-indexrepos.lua into it to neutralise sub-index discovery —
mutating a shared, checked-in fixture (leaves an untracked file; unsafe if
other tests read it). Build a dedicated local global index under the runtime
dir instead, so the test is fully self-contained and offline and never
touches tests/fixtures. Verified: A1/A2/A2b GREEN, fixture tree stays clean.
@Sunrisepeak
Sunrisepeak merged commit cf9b60d into main Jul 18, 2026
7 checks passed
@Sunrisepeak
Sunrisepeak deleted the fix/issue374-multi-repo-silent-exit branch July 18, 2026 23:14
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.

install_packages silently fails (exit 1) when ≥2 project-level index_repos are configured

1 participant