Repository navigation
fix(examples): make interpreter startup and session cleanup reliable #2109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
21132bb
2fc4eb7
ecb62f4
d92f4da
886790f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| name: Interpreter Example Test | ||
|
|
||
| on: | ||
| pull_request: | ||
| paths: | ||
| - 'examples/run-code-env-inheritance/**' | ||
| - '.github/workflows/interpreter-example-test.yml' | ||
| push: | ||
| branches: [master] | ||
| paths: | ||
| - 'examples/run-code-env-inheritance/**' | ||
| - '.github/workflows/interpreter-example-test.yml' | ||
| workflow_dispatch: | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| test: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
| - uses: actions/setup-python@v6 | ||
| with: | ||
| python-version: '3.11' | ||
| - name: Install interpreter test dependencies | ||
| run: python -m pip install fastapi==0.119.0 uvicorn==0.38.0 httpx==0.28.1 websockets==15.0.1 | ||
| - name: Run startup and environment regression tests | ||
| run: PYTHONPATH=examples/run-code-env-inheritance/lightweight-code-interpreter python -m unittest discover -s examples/run-code-env-inheritance/tests -v | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,3 +4,10 @@ ARG SANDBOX_CODE_IMAGE=cube-sandbox-cn.tencentcloudcr.com/cube-sandbox/sandbox-c | |
| FROM ${SANDBOX_CODE_IMAGE} | ||
|
|
||
| COPY lightweight-code-interpreter/server.py /opt/lightweight-code-interpreter/server.py | ||
| COPY lightweight-code-interpreter/launch.py /opt/lightweight-code-interpreter/launch.py | ||
|
|
||
| RUN test -x /usr/bin/tini && command -v python && command -v jupyter \ | ||
| && python -c "import uvicorn; import jupyter_server" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The build check does not actually validate the "server imports" it is meant to guard. That matters here because the README explicitly supports overriding RUN test -x /usr/bin/tini && command -v python && command -v jupyter \
&& python -c "import sys; sys.path.insert(0, '/opt/lightweight-code-interpreter'); import server" |
||
|
|
||
| ENTRYPOINT ["/usr/bin/tini", "--"] | ||
| CMD ["python", "/opt/lightweight-code-interpreter/launch.py"] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Verify the inherited This file replaces only Please confirm with Also worth confirming: overriding There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This Please confirm the base image's ENTRYPOINT/CMD split, and run the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replacing both the inherited
A |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,7 +25,21 @@ Sandbox-level values and per-call overrides are applied in a separate background | |
|
|
||
| The next execution waits for pending cleanup so per-call values cannot leak into later executions. This lifecycle follows E2B code-interpreter. Sandbox-level environment variables are loaded only once per Jupyter kernel; this example does not provide a general runtime refresh mechanism. | ||
|
|
||
| This derived interpreter requires envd's `/envs` endpoint. If envd is unavailable, the endpoint is unsupported, the request exceeds `ENVD_TIMEOUT` (2 seconds by default), or the payload is invalid, `run_code` fails with HTTP 502 instead of falling back to the stock interpreter behavior. Set `ENVD_TIMEOUT` in the image environment to adjust the fetch timeout. | ||
| This derived interpreter requires envd's `/envs` endpoint. Startup waits for the endpoint within the context-creation budget and fails if it cannot become ready. After startup, an unavailable endpoint, a request exceeding `ENVD_TIMEOUT` (2 seconds by default), or an invalid payload causes `run_code` to return HTTP 502. Set `ENVD_TIMEOUT` in the image environment to adjust the fetch timeout. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This new paragraph (and the ones below on context-creation timeouts, the tini launcher, and |
||
|
|
||
| Jupyter context creation, WebSocket opening, and working-directory initialization each use `CODE_INTERPRETER_CONTEXT_CREATE_TIMEOUT` (60 seconds by default), rather than the HTTP and WebSocket defaults of 5 and 10 seconds during concurrent cold starts. A timed-out creation request is not replayed because the kernel may already exist. If no session ID was returned, that session cannot be reclaimed through this cleanup path and may live until the sandbox shuts down. If the session ID is known, a failed handshake closes the connection and attempts to delete that session. Startup also verifies the required envd `/envs` endpoint within the context-creation budget. Failed startup closes the HTTP client without becoming ready. User-code execution timeouts are unchanged. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Documentation parity: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Also, the launcher honors There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These four new behavior paragraphs are inserted after the |
||
|
|
||
| The derived image replaces the inherited entrypoint with tini so the launcher owns dependency startup and does not start a second envd. It honors `ENVD_BIN`, `ENVD_EXTRA_ARGS`, and `ENVD_LOG_FILE=-`. Jupyter is configured to fail on a port conflict instead of selecting another port. | ||
|
|
||
| The launcher binds and listens on the interpreter port before starting envd, Jupyter, or application initialization, then passes that same socket to Uvicorn. Uvicorn normally binds after its lifespan startup; dynamic ports allocated during initialization could otherwise take the default port `49999` and terminate startup with `address already in use`. Dependency processes do not inherit the listener and are terminated and reaped on startup failure or shutdown. The image keeps `/usr/bin/tini` as PID 1 to reap orphaned grandchildren, while the Python launcher manages its direct children. Reserving the port does not mean the application is ready. | ||
|
|
||
| The launcher starts envd with `-isnotfc`, matching CubeSandbox's base-image entrypoint. Without this flag, envd 0.5.11 enables its Firecracker MMDS-dependent HTTP log exporter; in an environment without that metadata, the exporter can keep buffering logs without a size limit. Non-Firecracker mode avoids that exporter while preserving the existing local stdout/stderr log file. It does not suppress command logs, force garbage collection, or change command/file APIs. | ||
|
|
||
| Readiness probes must request the HTTP `/health` endpoint and require a successful | ||
| response. The launcher reserves the TCP listener before starting dependencies; | ||
| a successful TCP connection alone does not mean startup has completed. | ||
| `JUPYTER_LOG_FILE` selects the Jupyter log path (default `/var/log/jupyter.log`); | ||
| its parent directory is created before launching the process. | ||
|
|
||
| ## Build and test the image | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| """Reserve the interpreter port before starting services or Jupyter kernels.""" | ||
| import os | ||
| from contextlib import ExitStack | ||
| from pathlib import Path | ||
| import signal | ||
| import shlex | ||
| import socket | ||
| import subprocess | ||
| import sys | ||
|
|
||
| import uvicorn | ||
|
|
||
|
|
||
| def exit_on_signal(signum, frame): | ||
| # Uvicorn restores and re-raises captured signals. Exit through Python so | ||
| # the launcher can reap children even when it is the container's PID 1. | ||
| raise SystemExit(128 + signum) | ||
|
|
||
|
|
||
| def stop_child(process): | ||
| if process.poll() is not None: | ||
| return | ||
| process.terminate() | ||
| try: | ||
| process.wait(timeout=5) | ||
| except subprocess.TimeoutExpired: | ||
| process.kill() | ||
| process.wait(timeout=5) | ||
|
|
||
|
|
||
| def stop_children(children): | ||
| pending_error = sys.exc_info()[0] is not None | ||
| first_error = None | ||
| for child in reversed(children): | ||
| try: | ||
| stop_child(child) | ||
| except (OSError, subprocess.TimeoutExpired) as error: | ||
| print(f"[lightweight-code-interpreter] child cleanup failed: {error}", file=sys.stderr, flush=True) | ||
| if first_error is None: | ||
| first_error = error | ||
| if first_error is not None and not pending_error: | ||
| raise first_error | ||
|
|
||
|
|
||
| def main(): | ||
| host = os.getenv("CODE_INTERPRETER_HOST", "0.0.0.0") | ||
| port = int(os.getenv("CODE_INTERPRETER_PORT", "49999")) | ||
| workdir = Path(os.getenv("CODE_INTERPRETER_WORKDIR", "/workspace")) | ||
| jupyter_host = os.getenv("JUPYTER_HOST", "127.0.0.1") | ||
| jupyter_port = os.getenv("JUPYTER_PORT", "8888") | ||
| envd_port = os.getenv("ENVD_PORT", "49983") | ||
| url_host = f"[{jupyter_host}]" if ":" in jupyter_host else jupyter_host | ||
| os.environ["JUPYTER_BASE_URL"] = f"http://{url_host}:{jupyter_port}" | ||
| workdir.mkdir(parents=True, exist_ok=True) | ||
| config = uvicorn.Config( | ||
| "server:app", host=host, port=port, log_level="info", timeout_keep_alive=640 | ||
| ) | ||
| family = socket.AF_INET6 if ":" in host else socket.AF_INET | ||
| children = [] | ||
| # Uvicorn's usual single-worker path binds after the application lifespan. | ||
| # Its lifespan creates a Jupyter kernel, whose dynamic ports (or an HTTP | ||
| # client's ephemeral port) may otherwise take the still-unbound service port. | ||
| with socket.socket(family, socket.SOCK_STREAM) as listener: | ||
| listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) | ||
| listener.bind((host, port)) | ||
| listener.listen(socket.SOMAXCONN) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Calling HTTP probes (template creation in the README uses |
||
| listener.set_inheritable(False) | ||
| with ExitStack() as stack: | ||
| envd_name = os.getenv("ENVD_LOG_FILE") or "/var/log/envd.log" | ||
| jupyter_name = os.getenv("JUPYTER_LOG_FILE") or "/var/log/jupyter.log" | ||
| for name in (envd_name, jupyter_name): | ||
| if name != "-": | ||
| Path(name).parent.mkdir(parents=True, exist_ok=True) | ||
| envd_log = None if envd_name == "-" else stack.enter_context(open(envd_name, "ab")) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both log files are opened directly here, but unlike |
||
| jupyter_log = None if jupyter_name == "-" else stack.enter_context(open(jupyter_name, "ab")) | ||
| previous_term = signal.signal(signal.SIGTERM, exit_on_signal) | ||
| try: | ||
| children.append(subprocess.Popen( | ||
| [os.getenv("ENVD_BIN", "/usr/bin/envd"), "-isnotfc", "-port", envd_port] | ||
| + shlex.split(os.getenv("ENVD_EXTRA_ARGS", "")), | ||
| stdin=subprocess.DEVNULL, stdout=envd_log, | ||
| stderr=subprocess.STDOUT, close_fds=True, | ||
| )) | ||
|
Comment on lines
+77
to
+83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. envd startup is never verified, so a failed envd is silent.
Related: this hardcodes |
||
| print(f"[lightweight-code-interpreter] envd launched on :{envd_port}", flush=True) | ||
| children.append(subprocess.Popen( | ||
| ["jupyter", "server", "--IdentityProvider.token=", | ||
| f"--ServerApp.ip={jupyter_host}", f"--ServerApp.port={jupyter_port}", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Jupyter's default Since the launcher now owns Jupyter startup, pass |
||
| "--ServerApp.port_retries=0", | ||
| f"--ServerApp.root_dir={workdir}", "--ServerApp.allow_root=True", | ||
| "--ServerApp.disable_check_xsrf=True", "--ServerApp.open_browser=False"], | ||
| stdin=subprocess.DEVNULL, stdout=jupyter_log, | ||
| stderr=subprocess.STDOUT, close_fds=True, | ||
| )) | ||
| print(f"[lightweight-code-interpreter] Jupyter Server started on {os.environ['JUPYTER_BASE_URL']}", flush=True) | ||
| server = uvicorn.Server(config) | ||
| server.run(sockets=[listener]) | ||
| if not server.started: | ||
| raise SystemExit(3) | ||
| finally: | ||
| try: | ||
| stop_children(children) | ||
| finally: | ||
| signal.signal(signal.SIGTERM, previous_term) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ | |
| ENVD_TIMEOUT = float(os.getenv("ENVD_TIMEOUT", "2")) | ||
| WORKDIR = Path(os.getenv("CODE_INTERPRETER_WORKDIR", "/workspace")).resolve() | ||
| DEFAULT_LANGUAGE = "python" | ||
| CONTEXT_CREATE_TIMEOUT = float(os.getenv("CODE_INTERPRETER_CONTEXT_CREATE_TIMEOUT", "60")) | ||
| PING_TIMEOUT = 30 | ||
|
|
||
| logging.basicConfig(level=os.getenv("CODE_INTERPRETER_LOG_LEVEL", "INFO")) | ||
|
|
@@ -156,13 +157,14 @@ async def connect(self) -> None: | |
| ws_logger.setLevel(logging.ERROR) | ||
| self.ws = await connect( | ||
| self.ws_url, | ||
| open_timeout=CONTEXT_CREATE_TIMEOUT, | ||
| ping_timeout=PING_TIMEOUT, | ||
| max_size=None, | ||
| max_queue=None, | ||
| logger=ws_logger, | ||
| ) | ||
| self.receive_task = asyncio.create_task(self._receive_messages()) | ||
| await self._run_background(f"%cd {self.cwd}") | ||
| await asyncio.wait_for(self._run_background(f"%cd {self.cwd}"), timeout=CONTEXT_CREATE_TIMEOUT) | ||
|
|
||
| async def _run_background(self, code: str) -> None: | ||
| if self.ws is None: | ||
|
|
@@ -439,6 +441,29 @@ async def _wait_for_jupyter() -> None: | |
| raise RuntimeError("Jupyter Server did not become ready") | ||
|
|
||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| async def _wait_for_envd(http_client: httpx.AsyncClient) -> None: | ||
| async def ready(): | ||
| while True: | ||
| try: | ||
| await _fetch_sandbox_envs(http_client) | ||
| return | ||
| except HTTPException as exc: | ||
| cause = exc.__cause__ | ||
| retryable = isinstance(cause, httpx.TransportError) or ( | ||
| isinstance(cause, httpx.HTTPStatusError) | ||
| and (cause.response.status_code >= 500 or cause.response.status_code == 429) | ||
| ) | ||
| if not retryable: | ||
| reason = f"{exc.detail}: {cause}" if cause else exc.detail | ||
| raise RuntimeError(f"envd startup failed: {reason}") from exc | ||
| await asyncio.sleep(0.1) | ||
|
|
||
| try: | ||
| await asyncio.wait_for(ready(), timeout=CONTEXT_CREATE_TIMEOUT) | ||
| except asyncio.TimeoutError as exc: | ||
| raise RuntimeError("envd did not become ready") from exc | ||
|
|
||
|
|
||
| contexts: dict[str, KernelContext] = {} | ||
| default_context_id: str | None = None | ||
| client: httpx.AsyncClient | None = None | ||
|
|
@@ -451,6 +476,7 @@ async def create_context(language: str | None = None, cwd: str | None = None) -> | |
|
|
||
| response = await client.post( | ||
| f"{JUPYTER_BASE_URL}/api/sessions", | ||
| timeout=CONTEXT_CREATE_TIMEOUT, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A POST timeout can still leave an orphan session/kernel.
The request already sends unique UUIDs for |
||
| json={ | ||
| "path": str(uuid.uuid4()), | ||
| "kernel": {"name": "python3"}, | ||
|
|
@@ -464,7 +490,20 @@ async def create_context(language: str | None = None, cwd: str | None = None) -> | |
| session = response.json() | ||
| context_id = session["kernel"]["id"] | ||
| kernel = KernelContext(context_id, session["id"], resolved_cwd) | ||
| await kernel.connect() | ||
| try: | ||
| await kernel.connect() | ||
| except BaseException: | ||
| # The session ID is known here, so cleanup is safe without replaying | ||
| # creation. Keep the original handshake/initialization exception. | ||
| try: | ||
| await kernel.close() | ||
| except BaseException as cleanup_error: | ||
| logger.warning("failed context connection cleanup: %s", cleanup_error) | ||
| try: | ||
| await client.delete(f"{JUPYTER_BASE_URL}/api/sessions/{session['id']}") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The session DELETE is only attempted when There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The session DELETE is skipped if Both cleanup steps share one try:
await kernel.close()
except Exception as cleanup_error:
logger.warning("failed context close: %s", cleanup_error)
try:
await client.delete(f"{JUPYTER_BASE_URL}/api/sessions/{session['id']}")
except Exception as cleanup_error:
logger.warning("failed session cleanup: %s", cleanup_error)
raiseThe README claim "closes the connection and attempts to delete that session" then holds unconditionally. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Wrap the two steps separately (or use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The compensating Worth passing an explicit, smaller-but-not-default timeout, e.g. reuse a short bound so the compensating delete does not add 5s to every failed creation: await client.delete(
f"{JUPYTER_BASE_URL}/api/sessions/{session['id']}",
timeout=min(CONTEXT_CREATE_TIMEOUT, 30),
)(Not blocking; the current best-effort behaviour is already documented in the PR description.) |
||
| except BaseException as cleanup_error: | ||
| logger.warning("failed context session cleanup: %s", cleanup_error) | ||
| raise | ||
| contexts[context_id] = kernel | ||
| return Context(id=context_id, language=DEFAULT_LANGUAGE, cwd=resolved_cwd) | ||
|
|
||
|
|
@@ -474,14 +513,18 @@ async def lifespan(app: FastAPI): | |
| global client, default_context_id | ||
| await _wait_for_jupyter() | ||
| client = httpx.AsyncClient() | ||
| default = await create_context(DEFAULT_LANGUAGE, str(WORKDIR)) | ||
| default_context_id = default.id | ||
| try: | ||
| await _wait_for_envd(client) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Making interpreter readiness depend on envd The part that needs verification is timing: this now runs at container boot, before the sandbox is initialized via Could you confirm that |
||
| # Kernel startup competes with other guests during a cold-start burst. | ||
| # Do not replay POST /sessions on timeout: its result can be unknown. | ||
| default = await create_context(DEFAULT_LANGUAGE, str(WORKDIR)) | ||
| default_context_id = default.id | ||
| yield | ||
| finally: | ||
| for context in list(contexts.values()): | ||
| await context.close() | ||
| contexts.clear() | ||
| default_context_id = None | ||
| if client is not None: | ||
| await client.aclose() | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The PR's central mechanism — real
uvicorn.Serveradopting the pre-bound socket viarun(sockets=[...])— is never exercised: everylaunch.pytest replacesuvicorn.Serverwith a Mock. If the image's uvicorn version behaves differently (signature, socket ownership/create_server(sock=...)requirements), CI stays green and the container fails at start. Sinceuvicornis already installed here, a small test using a trivial ASGI app (uvicorn.Config(app, host="127.0.0.1", port=0)) withServer.run(sockets=[prebound])in a thread and aGET /would close that gap without needing Jupyter.Separately, these pins are inline in the workflow while the README recommends running the tests inside the built image to use the same dependencies as the runtime; a shared requirements file (or running the suite in the image) would keep the two from silently diverging.