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: 3 additions & 2 deletions examples/novita_tbench2_simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ async def main() -> int:
provider = NovitaSandboxProvider()
# First start builds a Novita template from the Dockerfile, which can take a
# few minutes; later starts reuse the cached template.
# Keep wait_for_ready inside try/finally so a readiness timeout still stops
# the sandbox (otherwise it lives until Novita's hard lifetime).
base_url = provider.start_container(image=image)
provider.wait_for_ready(base_url, timeout_s=300)

try:
provider.wait_for_ready(base_url, timeout_s=300)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch on the leak. Moving wait_for_ready inside the try means a readiness timeout now reaches finally: provider.stop_container() instead of leaving the sandbox alive until Novita's hard lifetime. start_container correctly stays outside the try (nothing to stop if it fails), and stop_container() is idempotent (no-op when _sandbox is None), so the pre-existing second teardown via the Tbench2Env context-manager exit remains harmless. This reinforces RFC 002's explicit-cleanup / S6 bounded-blast-radius invariants.

async with Tbench2Env(base_url=base_url, provider=provider) as env:
result = await env.reset(task_id=task_id)
print("Instruction head:")
Expand Down
11 changes: 10 additions & 1 deletion src/openenv/core/containers/runtime/novita_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ def _resolve_from_references(content: str) -> str:
- ``FROM --platform=linux/amd64 python:3.10-slim`` likewise keeps the flag
as part of the name. Novita builds for its own platform, so the flag is
dropped rather than propagated.

Substitutions are applied longest-name-first so a short unbraced prefix
cannot corrupt a longer sibling (e.g. ``$BASE`` must not rewrite
``$BASE_IMAGE`` / ``${BASE_IMAGE}`` into ``python:3.12_IMAGE``).
"""
arg_defaults: Dict[str, str] = {}
for line in content.split("\n"):
Expand All @@ -138,6 +142,11 @@ def _resolve_from_references(content: str) -> str:
if match:
arg_defaults[match.group("name")] = match.group("value")

# Longest names first: `$BASE` must not rewrite `$BASE_IMAGE` mid-token.
arg_items = sorted(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct fix. Sorting ARG names longest-first guarantees $BASE_IMAGE is substituted before $BASE, so the shorter name can't corrupt the longer sibling. Verified against the old behavior: with dict-insertion order, FROM $BASE_IMAGE became python:3.12_IMAGE (the $BASE replace matched the prefix); with this sort it correctly resolves to python:3.12-slim. sorted is stable, so equal-length names keep declaration order — fine here since prefix collisions have differing lengths.

Non-blocking / out of scope: this disambiguates only among declared ARGs. Since the substitution is still a blunt str.replace, an undeclared prefix-sharing var (e.g. declared ARG BASE + FROM $BASE_TAG) could still be corrupted. Low-risk (undeclared ARGs are unusual; Docker treats them as empty) — a word-boundary-aware regex would be the fully general form if you ever revisit this.

arg_defaults.items(), key=lambda item: len(item[0]), reverse=True
)

out: List[str] = []
for line in content.split("\n"):
match = _FROM_RE.match(line)
Expand All @@ -147,7 +156,7 @@ def _resolve_from_references(content: str) -> str:

reference = match.group("rest").strip()
reference = re.sub(r"^(--platform=\S+\s*)+", "", reference).strip()
for name, value in arg_defaults.items():
for name, value in arg_items:
reference = reference.replace(f"${{{name}}}", value)
reference = reference.replace(f"${name}", value)
out.append(f"FROM {reference}")
Expand Down
46 changes: 46 additions & 0 deletions tests/scripts/test_novita_examples_release_sandbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Every Novita example must release its sandbox if readiness never arrives."""

from __future__ import annotations

import ast
from pathlib import Path

import pytest

EXAMPLES_DIR = Path(__file__).resolve().parents[2] / "examples"
NOVITA_EXAMPLES = sorted(EXAMPLES_DIR.glob("novita_*.py"))


def _calls(node: ast.AST, name: str) -> bool:
return any(
isinstance(child, ast.Attribute) and child.attr == name
for child in ast.walk(node)
)


def _guarded_by_stop_container(tree: ast.AST) -> list[ast.Try]:
return [
node
for node in ast.walk(tree)
if isinstance(node, ast.Try)
and any(_calls(handler, "stop_container") for handler in node.finalbody)
]


@pytest.mark.parametrize("path", NOVITA_EXAMPLES, ids=lambda path: path.name)
def test_wait_for_ready_runs_under_the_stop_container_guard(path: Path):
tree = ast.parse(path.read_text())

if not _calls(tree, "wait_for_ready"):
pytest.skip(f"{path.name} does not wait for readiness")

guards = _guarded_by_stop_container(tree)
assert guards, f"{path.name} calls wait_for_ready without a stop_container finally"
assert any(
_calls(statement, "wait_for_ready")
for guard in guards
for statement in guard.body
), (
f"{path.name} calls wait_for_ready outside the try that stops the sandbox; "
"a readiness timeout would leak a paid sandbox"
)
31 changes: 31 additions & 0 deletions tests/test_core/test_novita_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,37 @@ def test_resolve_arg_in_from(self):
assert "FROM python:3.11" in out
assert "${BASE_IMAGE}" not in out.split("\n")[1]

def test_resolve_prefix_colliding_unbraced_args(self):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

High-signal regression test: it fails on the old code (unbraced FROM $BASE_IMAGE → python:3.12_IMAGE) and passes with the fix, and asserting the corrupted python:3.12_IMAGE is absent (not just that the right image is present) locks the bug down well.

Minor: the braced ${BASE_IMAGE} case actually resolved correctly even before this change — an unbraced $BASE never matches inside ${BASE_IMAGE} — so that half is a guard against future regressions rather than a reproduction of this bug. The docstring's / ${BASE_IMAGE} wording slightly overstates the original blast radius; harmless.

"""Shorter ARG names must not corrupt longer sibling references.

Declaring ``ARG BASE`` before ``ARG BASE_IMAGE`` used to rewrite
``FROM $BASE_IMAGE`` / ``FROM ${BASE_IMAGE}`` into
``FROM python:3.12_IMAGE`` because ``$BASE`` is a prefix of
``$BASE_IMAGE``.
"""
from openenv.core.containers.runtime.novita_provider import (
_resolve_from_references,
)

dockerfile = (
"ARG BASE=python:3.12\n"
"ARG BASE_IMAGE=python:3.12-slim\n"
"FROM $BASE_IMAGE\n"
"RUN echo hi\n"
)
out = _resolve_from_references(dockerfile)
assert "FROM python:3.12-slim" in out
assert "python:3.12_IMAGE" not in out

braced = (
"ARG BASE=python:3.12\n"
"ARG BASE_IMAGE=python:3.12-slim\n"
"FROM ${BASE_IMAGE}\n"
)
out_braced = _resolve_from_references(braced)
assert "FROM python:3.12-slim" in out_braced
assert "python:3.12_IMAGE" not in out_braced

def test_arg_after_first_from_not_in_scope(self):
from openenv.core.containers.runtime.novita_provider import (
_resolve_from_references,
Expand Down
Loading