Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
`cjson` (v1.7.18), `nanopb` (v0.4.9.1), `lvgl` (v9.2.2), `tinyusb` (v0.18.0), and `unity` (v2.6.1).

### Fixed
- **A missing or unknown cross toolchain no longer crashes `ebuild build`.**
`build`, `configure` and `test` let `ToolchainError` escape, so a project
whose `toolchain.target` compiler was not installed (or was misspelt) ended
in a Python traceback. They now print `Toolchain error: <reason>` and exit 1,
like other configuration errors.
- **Package archive downloads can no longer hang or poison the cache.**
`PackageFetcher` downloaded with `urlretrieve()`, which has no timeout: one
unresponsive mirror left `ebuild build` hanging until the user killed it.
Expand Down
14 changes: 12 additions & 2 deletions ebuild/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
PackagePaths,
executable_output_path,
)
from ebuild.build.toolchain import resolve_toolchain
from ebuild.build.toolchain import ToolchainError, resolve_toolchain
from ebuild.cli.integration import register_commands as _register_integration_commands
from ebuild.cli.logger import Logger
from ebuild.core.config import ConfigError, load_config, ProjectConfig
Expand Down Expand Up @@ -1148,6 +1148,9 @@ def build(log: Logger, config_path: str, build_dir: str, backend: Optional[str],
except (ConfigError, RecipeError) as e:
log.error(f"Configuration error: {e}")
raise SystemExit(1)
except ToolchainError as e:
log.error(f"Toolchain error: {e}")
raise SystemExit(1)
except CycleError as e:
log.error(f"Dependency error: {e}")
raise SystemExit(1)
Expand Down Expand Up @@ -1344,6 +1347,9 @@ def configure(log: Logger, config_path: str, build_dir: str, backend: Optional[s
except (ConfigError, RecipeError) as e:
log.error(f"Configuration error: {e}")
raise SystemExit(1)
except ToolchainError as e:
log.error(f"Toolchain error: {e}")
raise SystemExit(1)
except (CycleError, ResolveError, FetchError, BuildError) as e:
log.error(f"Error: {e}")
raise SystemExit(1)
Expand Down Expand Up @@ -2717,7 +2723,11 @@ def test(
source_dir = cfg.source_dir
native = [t for t in cfg.targets if t.target_type == "test"]
if native:
_run_native_tests(cfg, native, build_path, log, name_filter)
try:
_run_native_tests(cfg, native, build_path, log, name_filter)
except ToolchainError as e:
log.error(f"Toolchain error: {e}")
raise SystemExit(1)
return

if runner != "auto":
Expand Down
70 changes: 70 additions & 0 deletions tests/unit/test_toolchain_error_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 EoS Project

"""A missing or unknown cross toolchain is a user error, not a crash.

`resolve_toolchain` raises ToolchainError when `toolchain.target` names an
unknown toolchain or one whose compiler is not on PATH (#171). The `build`,
`configure` and `test` commands did not catch it, so the user got a Python
traceback ending in `ebuild.build.toolchain.ToolchainError` instead of the
one-line reason and exit code 1 that every other configuration error gets.
"""

import os
from unittest import mock

import pytest
import yaml
from click.testing import CliRunner

from ebuild.cli.commands import cli


def _project(root, target, target_type="executable"):
(root / "main.c").write_text("int main(void) { return 0; }\n", encoding="utf-8")
(root / "build.yaml").write_text(
yaml.safe_dump(
{
"project": {"name": "demo", "version": "0.1.0"},
"toolchain": {"target": target},
"targets": [
{"name": "app", "type": target_type, "sources": ["main.c"]}
],
}
),
encoding="utf-8",
)


def _invoke(monkeypatch, root, *args):
monkeypatch.chdir(root)
# An empty PATH guarantees no cross compiler is found on any machine.
with mock.patch.dict(os.environ, {"PATH": str(root / "no-such-bin")}):
return CliRunner().invoke(cli, list(args))


@pytest.mark.parametrize(
"command,target_type",
[("build", "executable"), ("configure", "executable"), ("test", "test")],
)
@pytest.mark.parametrize(
"target,reason",
[
("arm-none-eabi", "not found on PATH"),
("arm-none-eabii", "unknown toolchain target"),
],
)
def test_toolchain_error_is_reported_not_raised(
tmp_path, monkeypatch, command, target_type, target, reason
):
_project(tmp_path, target, target_type)
result = _invoke(monkeypatch, tmp_path, command)

# Exit 1 through SystemExit, with no exception escaping the command.
assert result.exception is None or isinstance(result.exception, SystemExit), (
f"{command} raised {result.exception!r}"
)
assert result.exit_code == 1
assert "Traceback" not in result.output
assert "Toolchain error:" in result.output
assert reason in result.output
Loading