From 1671ff9a1e03c4d8f1d04779b0abd8f2fa5f6475 Mon Sep 17 00:00:00 2001 From: xianml Date: Wed, 26 Aug 2026 17:07:48 +0800 Subject: [PATCH 1/4] fix(client): close file handles on task submit via the file manager `SyncHTTPClient..submit()` raised `AttributeError: 'SyncHTTPClient' object has no attribute '_opened_files'` on every call: the `finally` block still cleaned up the pre-`ClientFileManager` attribute, so the request was sent and the task queued but the caller only ever saw the crash. `AsyncHTTPClient._submit` and `SyncHTTPClient._call` were already converted; this brings the last one over. The task endpoints had no test coverage anywhere in the suite, which is how this shipped. Adds a fixture with a trivial `@bentoml.task` service (no model, ~2.5 s for the file) covering submit/status/get/retry, submit with a file argument, and the async client. Two of the three fail before the fix. Co-Authored-By: Claude Opus 5 --- src/_bentoml_impl/client/http.py | 4 +- tests/e2e/bento_new_sdk/test_tasks.py | 65 +++++++++++++++++++++++++++ tests/e2e/fixtures/tasks/service.py | 16 +++++++ 3 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/bento_new_sdk/test_tasks.py create mode 100644 tests/e2e/fixtures/tasks/service.py diff --git a/src/_bentoml_impl/client/http.py b/src/_bentoml_impl/client/http.py index cfe64b4d779..b1235a37701 100644 --- a/src/_bentoml_impl/client/http.py +++ b/src/_bentoml_impl/client/http.py @@ -503,9 +503,7 @@ def _submit( data = resp.json() return Task(data["task_id"], __endpoint, self) finally: - for f in self._opened_files: - f.close() - self._opened_files.clear() + self._file_manager.close() def _get_task_result(self, __endpoint: ClientEndpoint, /, task_id: str) -> t.Any: resp = self.request( diff --git a/tests/e2e/bento_new_sdk/test_tasks.py b/tests/e2e/bento_new_sdk/test_tasks.py new file mode 100644 index 00000000000..f17626cf333 --- /dev/null +++ b/tests/e2e/bento_new_sdk/test_tasks.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import time +import typing as t +from pathlib import Path + +import pytest + +import bentoml + +port = 35681 +TIMEOUT = 30 + + +def _wait_for_result(task: t.Any) -> str: + deadline = time.monotonic() + TIMEOUT + while time.monotonic() < deadline: + status = task.get_status().value + if status in ("completed", "failed", "canceled"): + return status + time.sleep(0.1) + raise AssertionError(f"task {task.id} still {task.get_status().value}") + + +def test_task_submit_status_get_retry(examples: Path) -> None: + with bentoml.serve(".", working_dir=str(examples / "tasks"), port=port) as server: + with bentoml.SyncHTTPClient(server.url, server_ready_timeout=100) as client: + task = client.shout.submit(text="hello") + assert _wait_for_result(task) == "completed" + assert task.get() == {"echo": "HELLO"} + + retried = task.retry() + assert retried.id != task.id + assert _wait_for_result(retried) == "completed" + assert retried.get() == {"echo": "HELLO"} + + +def test_task_submit_with_file(examples: Path, tmp_path: Path) -> None: + payload = tmp_path / "blob.txt" + payload.write_text("0123456789") + + with bentoml.serve( + ".", working_dir=str(examples / "tasks"), port=port + 1 + ) as server: + with bentoml.SyncHTTPClient(server.url, server_ready_timeout=100) as client: + task = client.measure.submit(blob=payload) + assert _wait_for_result(task) == "completed" + assert task.get() == 10 + + +@pytest.mark.asyncio +async def test_async_task_submit(examples: Path) -> None: + with bentoml.serve( + ".", working_dir=str(examples / "tasks"), port=port + 2 + ) as server: + async with bentoml.AsyncHTTPClient( + server.url, server_ready_timeout=100 + ) as client: + task = await client.shout.submit(text="async") + deadline = time.monotonic() + TIMEOUT + while time.monotonic() < deadline: + if (await task.get_status()).value == "completed": + break + time.sleep(0.1) + assert await task.get() == {"echo": "ASYNC"} diff --git a/tests/e2e/fixtures/tasks/service.py b/tests/e2e/fixtures/tasks/service.py new file mode 100644 index 00000000000..609d2c8c039 --- /dev/null +++ b/tests/e2e/fixtures/tasks/service.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from pathlib import Path + +import bentoml + + +@bentoml.service(workers=1) +class TaskService: + @bentoml.task + def shout(self, text: str) -> dict[str, str]: + return {"echo": text.upper()} + + @bentoml.task + def measure(self, blob: Path) -> int: + return len(blob.read_text()) From 986b1015307fddffe12a7391504b123d79f1c0f8 Mon Sep 17 00:00:00 2001 From: xianml Date: Wed, 26 Aug 2026 17:08:01 +0800 Subject: [PATCH 2/4] fix: only reject hidden path components below the working directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolve_user_filepath` walked every part of the resolved absolute path, so a hidden *ancestor* of the working directory made any relative reference illegal. Serving a built bento whose `service.py` calls `Image().requirements_file("requirements.txt")` therefore failed with "Accessing hidden files is not allowed" whenever BENTOML_HOME was a dotted path: BENTOML_HOME=~/.bentoml bentoml serve my_bento:latest -> ValueError: .../.bentoml/bentos/my_bento//src/requirements.txt The path above cwd is not user input, and cwd containment is already enforced one check earlier, so the hidden-file rule now applies to the relative part only. Dotfiles inside the project, escaping cwd, absolute paths and /etc//proc stay rejected — covered by the new tests, two of which fail before this change. Co-Authored-By: Claude Opus 5 --- src/bentoml/_internal/utils/filesystem.py | 4 +- tests/unit/_internal/utils/test_filesystem.py | 63 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 tests/unit/_internal/utils/test_filesystem.py diff --git a/src/bentoml/_internal/utils/filesystem.py b/src/bentoml/_internal/utils/filesystem.py index c1a8e7006c1..c090ba26cc1 100644 --- a/src/bentoml/_internal/utils/filesystem.py +++ b/src/bentoml/_internal/utils/filesystem.py @@ -162,7 +162,9 @@ def resolve_user_filepath( raise ValueError( f"Accessing file outside of current working directory is not allowed: {_path}" ) - if any(part.startswith(".") for part in _path.parts): + # Only the part below cwd is user-supplied; an ancestor of cwd may legitimately be + # hidden (e.g. a bento built under BENTOML_HOME=~/.bentoml). + if any(part.startswith(".") for part in _path.relative_to(cwd).parts): raise ValueError(f"Accessing hidden files is not allowed: {_path}") if any(_path.is_relative_to(item) for item in ("/etc", "/proc")): raise ValueError(f"Accessing system files is not allowed: {_path}") diff --git a/tests/unit/_internal/utils/test_filesystem.py b/tests/unit/_internal/utils/test_filesystem.py new file mode 100644 index 00000000000..8e8168cea13 --- /dev/null +++ b/tests/unit/_internal/utils/test_filesystem.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import os + +import pytest + +from bentoml._internal.utils.filesystem import resolve_user_filepath + + +@pytest.fixture +def project(tmp_path, monkeypatch): + """A project directory that lives under a hidden ancestor, as a bento built with + BENTOML_HOME=~/.bentoml does.""" + root = tmp_path / ".hidden-home" / "proj" + (root / "sub").mkdir(parents=True) + (root / "requirements.txt").write_text("bentoml\n") + (root / ".secret").write_text("token\n") + (root / "sub" / ".env").write_text("KEY=value\n") + (tmp_path / "outside.txt").write_text("outside\n") + monkeypatch.chdir(root) + return root + + +def test_relative_path_under_hidden_ancestor(project): + assert resolve_user_filepath("requirements.txt", None) == str( + project / "requirements.txt" + ) + assert resolve_user_filepath("./requirements.txt", None) == str( + project / "requirements.txt" + ) + + +def test_relative_to_ctx(project): + (project / "sub" / "extra.txt").write_text("x\n") + assert resolve_user_filepath("extra.txt", os.path.join(str(project), "sub")) == str( + project / "sub" / "extra.txt" + ) + + +@pytest.mark.parametrize("path", [".secret", "sub/.env", "./.secret"]) +def test_hidden_files_below_cwd_are_rejected(project, path): + with pytest.raises(ValueError, match="hidden files"): + resolve_user_filepath(path, None) + + +def test_escaping_cwd_is_rejected(project): + with pytest.raises(ValueError, match="outside of current working directory"): + resolve_user_filepath("../../outside.txt", None) + + +def test_absolute_path_is_rejected(project): + with pytest.raises(ValueError, match="Absolute path"): + resolve_user_filepath(str(project / "requirements.txt"), None) + + +def test_absolute_path_allowed_when_insecure(project): + target = str(project / "requirements.txt") + assert resolve_user_filepath(target, None, secure=False) == target + + +def test_missing_file(project): + with pytest.raises(FileNotFoundError): + resolve_user_filepath("nope.txt", None) From f377632148295545a28f3d9f7f9ac0d3d3a3d7c3 Mon Sep 17 00:00:00 2001 From: xianml Date: Wed, 26 Aug 2026 17:08:01 +0800 Subject: [PATCH 3/4] docs: correct the task status values and the cancel/retry HTTP methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ResultStatus` is pending/in_progress/completed/failed/canceled, but the docs told readers to compare against 'success' and 'failure' — a branch that can never be taken — and the generated OpenAPI schema advertised the same wrong enum, so clients generated from the spec disagreed with the server. Verified against a running server: `GET //status` returns "completed". The two mutating routes were also swapped: retry is POST, cancel is PUT. Cancel is additionally unsupported by the local development server, which is now stated. Co-Authored-By: Claude Opus 5 --- docs/source/get-started/async-task-queues.rst | 12 +++++++----- src/_bentoml_sdk/service/openapi.py | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/source/get-started/async-task-queues.rst b/docs/source/get-started/async-task-queues.rst index 06b4722a2b9..1a55368bc32 100644 --- a/docs/source/get-started/async-task-queues.rst +++ b/docs/source/get-started/async-task-queues.rst @@ -45,8 +45,8 @@ Under the hood, BentoML automatically generates several endpoints for creating t - ``POST /submit``: Submit a task to the queue. A unique task identifier is returned immediately. - ``GET /status``: Get the status of a task given the task identifier. - ``GET /get``: Get the result of a task given the task identifier. -- ``POST /cancel``: Attempt to cancel a task given the task identifier, if the task hasn't started execution. -- ``PUT /retry``: Retry a task given the task identifier. +- ``PUT /cancel``: Attempt to cancel a task given the task identifier, if the task hasn't started execution. Not supported by the local development server. +- ``POST /retry``: Retry a task given the task identifier. Call a task endpoint -------------------- @@ -70,13 +70,15 @@ Async tasks can be submitted through the ``SyncHTTPClient`` or ``AsyncHTTPClient Once a task is submitted, the request is enqueued in the request queue and a unique task identifier is returned immediately, which can be used to get the status and retrieve the result. +The status is one of ``pending``, ``in_progress``, ``completed``, ``failed`` and ``canceled``. + .. code-block:: python # Use the following code at a later time status = task.get_status() - if status.value == 'success': + if status.value == 'completed': print("The task runs successfully. The result is", task.get()) - elif status.value == 'failure': + elif status.value == 'failed': print("The task run failed.") else: print("The task is still running.") @@ -86,7 +88,7 @@ Use ``retry()`` if a task fails or you need to rerun the task with the same para .. code-block:: python status = task.get_status() - if status.value == 'failure': + if status.value == 'failed': print("Task failed, retrying...") new_task = task.retry() new_status = new_task.get_status() diff --git a/src/_bentoml_sdk/service/openapi.py b/src/_bentoml_sdk/service/openapi.py index 97b9d7ca8f2..0915eeb247b 100644 --- a/src/_bentoml_sdk/service/openapi.py +++ b/src/_bentoml_sdk/service/openapi.py @@ -85,7 +85,7 @@ def generate_spec(svc: Service[t.Any], *, openapi_version: str = "3.0.2"): class TaskStatusResponse(pydantic.BaseModel): task_id: str - status: t.Literal["in_progress", "success", "failure", "cancelled"] + status: t.Literal["pending", "in_progress", "completed", "failed", "canceled"] created_at: str executed_at: t.Optional[str] From d1b12ecefda7942ca88de77ee37f0e8bec45714d Mon Sep 17 00:00:00 2001 From: xianml Date: Wed, 26 Aug 2026 17:15:52 +0800 Subject: [PATCH 4/4] docs: a custom service command must bind http.proxy_port, and $PORT is undefined MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every snippet in "Custom service start command" passed `$PORT` (one also `$BENTOML_HOST`). `cmd` entries go through `expand_envs`, which substitutes from `os.environ` and raises on a missing key, and BentoML defines neither variable — so all four examples fail at startup with `KeyError: 'PORT'` (reproduced on 1.4.39). The port also has to match `http.proxy_port`, since that is where the proxy sends requests, and a custom-command Service defaults to `min(16, cpu/2)` workers of which only the first starts the process. Snippets now hard-code a port matching `proxy_port` and set `workers=1`, with a note covering both rules and what happens if `$VAR` is not in the environment. Co-Authored-By: Claude Opus 5 --- docs/source/build-with-bentoml/services.rst | 26 +++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/source/build-with-bentoml/services.rst b/docs/source/build-with-bentoml/services.rst index c39f941b42a..dcda0f00da6 100644 --- a/docs/source/build-with-bentoml/services.rst +++ b/docs/source/build-with-bentoml/services.rst @@ -326,22 +326,34 @@ In some cases, you may want your Service to start using a custom process (for ex "uvicorn", "myapp:app", "--host", - "$BENTOML_HOST", + "127.0.0.1", "--port", - "$PORT", - ] + "8000", # must match http.proxy_port (default 8000) + ], + workers=1, ) class ExternalServer: pass +.. important:: + + The custom command must listen on ``http.proxy_port`` (``8000`` by default), because that is + where BentoML proxies requests. ``$VAR`` references in ``cmd`` are expanded from the process + environment, and BentoML does not define ``PORT`` or ``BENTOML_HOST`` itself — using them + without setting them (for example through ``envs``) fails at startup with + ``KeyError: 'PORT'``. + + Set ``workers=1`` unless the command can share a port. A Service with a custom command + defaults to ``min(16, cpu_count/2)`` workers, and only the first one starts the process. + Alternatively, compute the command at runtime: .. code-block:: python - @bentoml.service + @bentoml.service(workers=1) class ExternalServer: def __command__(self) -> list[str]: - return ["myserver", "--port", "$PORT"] + return ["myserver", "--port", "8000", "--model", self.model_path] Use this method when there are parameters whose values can only be determined at runtime. @@ -349,7 +361,7 @@ BentoML operates by establishing a proxy service that directs all requests to th .. code-block:: python - @bentoml.service(cmd=["myserver", "--port", "$PORT"], http={"proxy_port": 9000}) + @bentoml.service(cmd=["myserver", "--port", "9000"], http={"proxy_port": 9000}, workers=1) class ExternalServer: pass @@ -361,7 +373,7 @@ To achieve this, you can implement the ``__metrics__`` method in your Service cl .. code-block:: python - @bentoml.service(cmd=["myserver", "--port", "$PORT"]) + @bentoml.service(cmd=["myserver", "--port", "8000"], workers=1) class ExternalServer: def __metrics__(self, original_metrics: str) -> str: # Modify the original metrics as needed