core: evaluate node semver ranges instead of guessing the leading major
resolve_node_bin() could not parse a range, so it fell back to "pin by the first number in the string" and took the newest installed version of that major. For openclaw's engines pin `>=22.19.0 <23 || >=23.11.0` that picked v22.17.0 — below the range's own floor — and baked it into the service's unit PATH. The daemon refused to start on it and crash-looped 260k+ times (~7.4s CPU per attempt, every ~5.5s, since before the journal's retention window). StartLimitBurst never tripped because five attempts span ~27s, outside the 10s StartLimitIntervalUSec. Parse the pin properly: split on `||`, expand each comparator (>=, >, <=, <, =, ^, ~, hyphen ranges, x/* wildcards, partial versions) into bounds with node-semver's partial semantics, and pick the newest installed version satisfying any alternative. openclaw now resolves to v24.18.0 via its second alternative. A pin this parser cannot read at all still falls back to newest-installed rather than guessing a version. When nothing satisfies the pin it now fails with the installed versions listed, so the next occurrence is diagnosable at apply time rather than six days later in a restart loop.
This commit is contained in:
@@ -27,12 +27,24 @@ import os
|
|||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# A pin that is a bare, exact-ish version: "24", "24.14", "24.14.1" (optional "v").
|
|
||||||
_EXACT_RE = re.compile(r"v?(\d+)(?:\.(\d+))?(?:\.(\d+))?$")
|
|
||||||
# An installed nvm dir: v24.14.1
|
# An installed nvm dir: v24.14.1
|
||||||
_INSTALLED_RE = re.compile(r"v(\d+)\.(\d+)\.(\d+)$")
|
_INSTALLED_RE = re.compile(r"v(\d+)\.(\d+)\.(\d+)$")
|
||||||
# Wildcards/aliases that mean "newest installed".
|
# Wildcards/aliases that mean "newest installed".
|
||||||
_NEWEST_ALIASES = {"", "*", "x", "node", "latest", "current", "lts", "lts/*"}
|
_NEWEST_ALIASES = {"", "*", "x", "node", "latest", "current", "lts", "lts/*"}
|
||||||
|
# One comparator: an optional operator then a (possibly partial/wildcard) version,
|
||||||
|
# e.g. ">=22.19.0", "^24.1", "~24", "24.x", "23".
|
||||||
|
_PART = r"\d+|[xX*]"
|
||||||
|
_COMPARATOR_RE = re.compile(
|
||||||
|
rf"^(?P<op>>=|<=|>|<|=|\^|~)?\s*v?(?P<maj>{_PART})"
|
||||||
|
rf"(?:\.(?P<min>{_PART}))?(?:\.(?P<pat>{_PART}))?"
|
||||||
|
r"(?:[-+][0-9A-Za-z.\-]+)?$"
|
||||||
|
)
|
||||||
|
# A hyphen range: "22.19.0 - 23.11.0".
|
||||||
|
_HYPHEN_RE = re.compile(r"^(.+?)\s+-\s+(.+)$")
|
||||||
|
|
||||||
|
Version = tuple[int, int, int]
|
||||||
|
# A parsed comparator: (op, version), ANDed within one range alternative.
|
||||||
|
Bound = tuple[str, Version]
|
||||||
|
|
||||||
|
|
||||||
class ToolchainError(Exception):
|
class ToolchainError(Exception):
|
||||||
@@ -83,17 +95,136 @@ def _installed() -> list[tuple[tuple[int, int, int], Path]]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _num(tok: str | None) -> int | None:
|
||||||
|
"""A version part as an int, or None when absent or a wildcard (``x``/``*``)."""
|
||||||
|
if tok is None or tok in ("x", "X", "*"):
|
||||||
|
return None
|
||||||
|
return int(tok)
|
||||||
|
|
||||||
|
|
||||||
|
def _bounds(token: str) -> list[Bound] | None:
|
||||||
|
"""One comparator as a list of ANDed (op, version) bounds.
|
||||||
|
|
||||||
|
Returns ``[]`` for an unconstrained token (``*``/``x``) and ``None`` when the
|
||||||
|
token isn't a comparator this parser understands. Partial versions follow
|
||||||
|
node-semver: ``>22`` means ``>=23.0.0``, ``<23`` means ``<23.0.0``, a bare
|
||||||
|
``24`` means ``>=24.0.0 <25.0.0``."""
|
||||||
|
t = token.strip()
|
||||||
|
if not t or t in ("*", "x", "X"):
|
||||||
|
return []
|
||||||
|
m = _COMPARATOR_RE.match(t)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
op = m["op"] or "="
|
||||||
|
maj, minor, patch = _num(m["maj"]), _num(m["min"]), _num(m["pat"])
|
||||||
|
if maj is None:
|
||||||
|
return [] # "x" / "x.y" — unconstrained
|
||||||
|
# How many parts were given concretely: 24 → 1, 24.14 → 2, 24.14.1 → 3.
|
||||||
|
precision = 1 if minor is None else (2 if patch is None else 3)
|
||||||
|
base: Version = (maj, minor or 0, patch or 0)
|
||||||
|
|
||||||
|
if op == ">=":
|
||||||
|
return [(">=", base)]
|
||||||
|
if op == ">":
|
||||||
|
if precision == 1:
|
||||||
|
return [(">=", (maj + 1, 0, 0))]
|
||||||
|
if precision == 2:
|
||||||
|
return [(">=", (maj, (minor or 0) + 1, 0))]
|
||||||
|
return [(">", base)]
|
||||||
|
if op == "<":
|
||||||
|
return [("<", base)]
|
||||||
|
if op == "<=":
|
||||||
|
if precision == 1:
|
||||||
|
return [("<", (maj + 1, 0, 0))]
|
||||||
|
if precision == 2:
|
||||||
|
return [("<", (maj, (minor or 0) + 1, 0))]
|
||||||
|
return [("<=", base)]
|
||||||
|
if op == "^":
|
||||||
|
# Caret: up to the next non-zero leftmost part.
|
||||||
|
if maj > 0:
|
||||||
|
hi: Version = (maj + 1, 0, 0)
|
||||||
|
elif (minor or 0) > 0:
|
||||||
|
hi = (0, (minor or 0) + 1, 0)
|
||||||
|
else:
|
||||||
|
hi = (0, 0, (patch or 0) + 1)
|
||||||
|
return [(">=", base), ("<", hi)]
|
||||||
|
if op == "~":
|
||||||
|
hi = (maj + 1, 0, 0) if precision == 1 else (maj, (minor or 0) + 1, 0)
|
||||||
|
return [(">=", base), ("<", hi)]
|
||||||
|
# "=" or bare: a partial pin is the range it prefixes.
|
||||||
|
if precision == 1:
|
||||||
|
return [(">=", base), ("<", (maj + 1, 0, 0))]
|
||||||
|
if precision == 2:
|
||||||
|
return [(">=", base), ("<", (maj, (minor or 0) + 1, 0))]
|
||||||
|
return [("==", base)]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_range(pin: str) -> list[list[Bound]] | None:
|
||||||
|
"""A semver range as alternatives (``||``) of ANDed bounds.
|
||||||
|
|
||||||
|
Returns ``None`` when no alternative could be parsed — the caller then falls
|
||||||
|
back to "newest installed" rather than guessing a version."""
|
||||||
|
alternatives: list[list[Bound]] = []
|
||||||
|
for alt in pin.split("||"):
|
||||||
|
alt = alt.strip()
|
||||||
|
hyphen = _HYPHEN_RE.match(alt)
|
||||||
|
tokens = (
|
||||||
|
[f">={hyphen[1]}", f"<={hyphen[2]}"]
|
||||||
|
if hyphen
|
||||||
|
else [t for t in re.split(r"[\s,]+", alt) if t]
|
||||||
|
)
|
||||||
|
bounds: list[Bound] = []
|
||||||
|
ok = True
|
||||||
|
for tok in tokens:
|
||||||
|
parsed = _bounds(tok)
|
||||||
|
if parsed is None:
|
||||||
|
ok = False
|
||||||
|
break
|
||||||
|
bounds.extend(parsed)
|
||||||
|
if ok:
|
||||||
|
alternatives.append(bounds)
|
||||||
|
return alternatives or None
|
||||||
|
|
||||||
|
|
||||||
|
def _satisfies(ver: Version, bounds: list[Bound]) -> bool:
|
||||||
|
"""Does a concrete version meet every bound in one alternative?"""
|
||||||
|
for op, target in bounds:
|
||||||
|
if op == ">=" and not ver >= target:
|
||||||
|
return False
|
||||||
|
if op == ">" and not ver > target:
|
||||||
|
return False
|
||||||
|
if op == "<=" and not ver <= target:
|
||||||
|
return False
|
||||||
|
if op == "<" and not ver < target:
|
||||||
|
return False
|
||||||
|
if op == "==" and ver != target:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _install_hint(pin: str) -> str:
|
||||||
|
"""What to tell the user to `nvm install`.
|
||||||
|
|
||||||
|
An exact-ish pin installs verbatim; for a range, name the highest major it
|
||||||
|
mentions — the newest line the program declares it supports."""
|
||||||
|
majors = [int(m) for m in re.findall(r"(\d+)", pin)]
|
||||||
|
if _COMPARATOR_RE.match(pin.strip()) and not re.search(r"[<>=^~|]", pin):
|
||||||
|
return pin
|
||||||
|
return str(max(majors)) if majors else "--lts"
|
||||||
|
|
||||||
|
|
||||||
def resolve_node_bin(source: Path | None) -> Path | None:
|
def resolve_node_bin(source: Path | None) -> Path | None:
|
||||||
"""Resolve a program's pinned node version to its ``.../bin`` dir on this box.
|
"""Resolve a program's pinned node version to its ``.../bin`` dir on this box.
|
||||||
|
|
||||||
Returns ``None`` when the program pins nothing — wildpc injects no node, it does
|
Returns ``None`` when the program pins nothing — wildpc injects no node, it does
|
||||||
not guess. Raises :class:`ToolchainError` when a version *is* pinned but no
|
not guess. Raises :class:`ToolchainError` when a version *is* pinned but no
|
||||||
matching version is installed.
|
installed version satisfies it.
|
||||||
|
|
||||||
Matching: an exact-ish pin (``24`` / ``24.14`` / ``24.14.1``) matches installed
|
Matching is real semver-range evaluation: every ``||`` alternative is parsed
|
||||||
versions by that precision, newest wins. A range or alias (``>=24``, ``^24.1``,
|
into comparators and the **newest installed version satisfying any alternative**
|
||||||
``lts/*``) is pinned by its leading major (or newest installed if it names none)
|
wins. So ``>=22.19.0 <23 || >=23.11.0`` selects an installed 24.x, and never an
|
||||||
— precise pins belong in ``.node-version``."""
|
installed 22.17.0 — which sits below the range's own floor. A pin this parser
|
||||||
|
can't read at all falls back to newest-installed rather than guessing."""
|
||||||
if source is None:
|
if source is None:
|
||||||
return None
|
return None
|
||||||
pin = read_node_pin(source)
|
pin = read_node_pin(source)
|
||||||
@@ -106,28 +237,23 @@ def resolve_node_bin(source: Path | None) -> Path | None:
|
|||||||
return installed[0][1]
|
return installed[0][1]
|
||||||
raise ToolchainError(
|
raise ToolchainError(
|
||||||
f"node {pin!r} pinned (in {source}) but no node is installed under "
|
f"node {pin!r} pinned (in {source}) but no node is installed under "
|
||||||
f"{node_versions_dir()} — run `nvm install {pin}`"
|
f"{node_versions_dir()} — run `nvm install {_install_hint(pin)}`"
|
||||||
)
|
)
|
||||||
|
|
||||||
low = pin.strip().lower()
|
if pin.strip().lower() in _NEWEST_ALIASES:
|
||||||
if low in _NEWEST_ALIASES:
|
|
||||||
return newest_or_raise()
|
return newest_or_raise()
|
||||||
|
|
||||||
exact = _EXACT_RE.match(low)
|
alternatives = _parse_range(pin)
|
||||||
if exact:
|
if alternatives is None:
|
||||||
parts = [int(g) for g in exact.groups() if g is not None]
|
return newest_or_raise()
|
||||||
else:
|
|
||||||
# A range/alias (>=24, ^24.1, ~24, 24.x): pin by leading major only.
|
|
||||||
major = re.search(r"\d+", low)
|
|
||||||
if not major:
|
|
||||||
return newest_or_raise()
|
|
||||||
parts = [int(major.group())]
|
|
||||||
precision = len(parts)
|
|
||||||
|
|
||||||
for ver, bindir in installed: # newest first
|
for ver, bindir in installed: # newest first
|
||||||
if list(ver[:precision]) == parts:
|
if any(_satisfies(ver, bounds) for bounds in alternatives):
|
||||||
return bindir
|
return bindir
|
||||||
|
|
||||||
|
have = ", ".join(".".join(str(p) for p in v) for v, _ in installed) or "none"
|
||||||
raise ToolchainError(
|
raise ToolchainError(
|
||||||
f"node {pin!r} pinned (in {source}) but not installed under "
|
f"node {pin!r} pinned (in {source}) but no installed version satisfies it "
|
||||||
f"{node_versions_dir()} — run `nvm install {pin}`"
|
f"(installed: {have}) under {node_versions_dir()} — "
|
||||||
|
f"run `nvm install {_install_hint(pin)}`"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -118,4 +118,84 @@ class TestResolveNodeBin:
|
|||||||
|
|
||||||
def test_default_dir_is_nvm(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_default_dir_is_nvm(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.delenv("WILDPC_NODE_VERSIONS_DIR", raising=False)
|
monkeypatch.delenv("WILDPC_NODE_VERSIONS_DIR", raising=False)
|
||||||
assert toolchains.node_versions_dir() == Path.home() / ".nvm" / "versions" / "node"
|
assert (
|
||||||
|
toolchains.node_versions_dir() == Path.home() / ".nvm" / "versions" / "node"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSemverRanges:
|
||||||
|
"""Range pins are evaluated, not reduced to their leading major.
|
||||||
|
|
||||||
|
The regression these guard: openclaw pins `>=22.19.0 <23 || >=23.11.0`, and a
|
||||||
|
leading-major heuristic picked the newest installed 22.x — v22.17.0, *below*
|
||||||
|
the range's own floor — baking a node into the systemd unit that the program
|
||||||
|
refuses to run on."""
|
||||||
|
|
||||||
|
def test_compound_range_uses_later_alternative(
|
||||||
|
self, tmp_path: Path, nvm: Path
|
||||||
|
) -> None:
|
||||||
|
_install(nvm, "v24.18.0", "v22.17.0", "v20.19.3")
|
||||||
|
(tmp_path / "package.json").write_text(
|
||||||
|
'{"engines": {"node": ">=22.19.0 <23 || >=23.11.0"}}'
|
||||||
|
)
|
||||||
|
assert resolve_node_bin(tmp_path) == nvm / "v24.18.0" / "bin"
|
||||||
|
|
||||||
|
def test_never_picks_below_lower_bound(self, tmp_path: Path, nvm: Path) -> None:
|
||||||
|
# Only a 22.x that fails the pin's own >=22.19.0 floor is installed.
|
||||||
|
_install(nvm, "v22.17.0", "v20.19.3")
|
||||||
|
(tmp_path / "package.json").write_text(
|
||||||
|
'{"engines": {"node": ">=22.19.0 <23 || >=23.11.0"}}'
|
||||||
|
)
|
||||||
|
with pytest.raises(ToolchainError, match="no installed version satisfies"):
|
||||||
|
resolve_node_bin(tmp_path)
|
||||||
|
|
||||||
|
def test_upper_bound_excludes_newer(self, tmp_path: Path, nvm: Path) -> None:
|
||||||
|
_install(nvm, "v24.18.0", "v22.19.1")
|
||||||
|
(tmp_path / "package.json").write_text('{"engines": {"node": ">=22.19.0 <23"}}')
|
||||||
|
assert resolve_node_bin(tmp_path) == nvm / "v22.19.1" / "bin"
|
||||||
|
|
||||||
|
def test_error_names_installed_versions(self, tmp_path: Path, nvm: Path) -> None:
|
||||||
|
_install(nvm, "v22.17.0")
|
||||||
|
(tmp_path / "package.json").write_text('{"engines": {"node": ">=24"}}')
|
||||||
|
with pytest.raises(ToolchainError, match=r"installed: 22\.17\.0"):
|
||||||
|
resolve_node_bin(tmp_path)
|
||||||
|
|
||||||
|
def test_tilde_pins_minor(self, tmp_path: Path, nvm: Path) -> None:
|
||||||
|
_install(nvm, "v24.15.0", "v24.14.9", "v24.14.1")
|
||||||
|
(tmp_path / ".nvmrc").write_text("~24.14.1")
|
||||||
|
assert resolve_node_bin(tmp_path) == nvm / "v24.14.9" / "bin"
|
||||||
|
|
||||||
|
def test_caret_excludes_next_major(self, tmp_path: Path, nvm: Path) -> None:
|
||||||
|
_install(nvm, "v25.0.0", "v24.14.1")
|
||||||
|
(tmp_path / "package.json").write_text('{"engines": {"node": "^24.1.0"}}')
|
||||||
|
assert resolve_node_bin(tmp_path) == nvm / "v24.14.1" / "bin"
|
||||||
|
|
||||||
|
def test_gt_partial_means_next_major(self, tmp_path: Path, nvm: Path) -> None:
|
||||||
|
# node-semver: ">22" is >=23.0.0, so an installed 22.19.0 does not match.
|
||||||
|
_install(nvm, "v23.1.0", "v22.19.0")
|
||||||
|
(tmp_path / "package.json").write_text('{"engines": {"node": ">22"}}')
|
||||||
|
assert resolve_node_bin(tmp_path) == nvm / "v23.1.0" / "bin"
|
||||||
|
|
||||||
|
def test_exact_pin_excludes_neighbours(self, tmp_path: Path, nvm: Path) -> None:
|
||||||
|
_install(nvm, "v24.14.9", "v24.14.1")
|
||||||
|
(tmp_path / ".node-version").write_text("=24.14.1")
|
||||||
|
assert resolve_node_bin(tmp_path) == nvm / "v24.14.1" / "bin"
|
||||||
|
|
||||||
|
def test_hyphen_range(self, tmp_path: Path, nvm: Path) -> None:
|
||||||
|
_install(nvm, "v24.0.0", "v23.5.0", "v22.1.0")
|
||||||
|
(tmp_path / "package.json").write_text(
|
||||||
|
'{"engines": {"node": "22.19.0 - 23.11.0"}}'
|
||||||
|
)
|
||||||
|
assert resolve_node_bin(tmp_path) == nvm / "v23.5.0" / "bin"
|
||||||
|
|
||||||
|
def test_x_wildcard_minor(self, tmp_path: Path, nvm: Path) -> None:
|
||||||
|
_install(nvm, "v25.0.0", "v24.14.1")
|
||||||
|
(tmp_path / ".node-version").write_text("24.x")
|
||||||
|
assert resolve_node_bin(tmp_path) == nvm / "v24.14.1" / "bin"
|
||||||
|
|
||||||
|
def test_unparseable_pin_falls_back_to_newest(
|
||||||
|
self, tmp_path: Path, nvm: Path
|
||||||
|
) -> None:
|
||||||
|
_install(nvm, "v24.14.1", "v20.10.0")
|
||||||
|
(tmp_path / ".nvmrc").write_text("lts/hydrogen")
|
||||||
|
assert resolve_node_bin(tmp_path) == nvm / "v24.14.1" / "bin"
|
||||||
|
|||||||
Reference in New Issue
Block a user