diff --git a/.changeset/olive-clouds-repeat.md b/.changeset/olive-clouds-repeat.md new file mode 100644 index 00000000..b8c22da5 --- /dev/null +++ b/.changeset/olive-clouds-repeat.md @@ -0,0 +1,5 @@ +--- +'@e2b/code-interpreter-template': patch +--- + +replaced systemd and start-up.sh with process-compose, so the sandbox and Docker images are supervised the same way diff --git a/js/tests/supervision.test.ts b/js/tests/supervision.test.ts new file mode 100644 index 00000000..ba8883b8 --- /dev/null +++ b/js/tests/supervision.test.ts @@ -0,0 +1,81 @@ +import { expect } from 'vitest' +import { isDebug, sandboxTest, wait } from './setup' + +async function waitForHealth(sandbox: any, maxRetries = 10, intervalMs = 100) { + for (let i = 0; i < maxRetries; i++) { + try { + const result = await sandbox.commands.run( + 'curl -s -o /dev/null -w "%{http_code}" http://0.0.0.0:49999/health' + ) + if (result.stdout.trim() === '200') { + return true + } + } catch { + // Connection refused or other error, retry + } + await wait(intervalMs) + } + return false +} + +// Recovery has to be judged by running code, not by /health. Killing Jupyter +// leaves the code-interpreter server up but holding dead kernel handles, so it +// answers /health for a few seconds before the process manager recycles it — +// long enough for a health check to pass against a sandbox that cannot execute. +async function waitForWorkingSandbox( + sandbox: any, + maxRetries = 60, + intervalMs = 500 +) { + for (let i = 0; i < maxRetries; i++) { + try { + const result = await sandbox.runCode('x = 1; x') + if (result.text === '1') { + return true + } + } catch { + // Still restarting, retry + } + await wait(intervalMs) + } + return false +} + +sandboxTest.skipIf(isDebug)( + 'restart after jupyter kill', + async ({ sandbox }) => { + // Verify health is up initially + const initialHealth = await waitForHealth(sandbox) + expect(initialHealth).toBe(true) + + // Kill the jupyter process as root. The pattern is bracketed so it cannot + // match the shell running it — `pgrep -f 'jupyter server'` matched its own + // command line, so this only ever killed itself. pkill exits non-zero when + // nothing matched, which fails the test rather than passing it vacuously. + await sandbox.commands.run("pkill -9 -f '[j]upyter-server'", { + user: 'root', + }) + + // Wait for process-compose to restart both processes + const recovered = await waitForWorkingSandbox(sandbox) + expect(recovered).toBe(true) + } +) + +sandboxTest.skipIf(isDebug)( + 'restart after code-interpreter kill', + async ({ sandbox }) => { + // Verify health is up initially + const initialHealth = await waitForHealth(sandbox) + expect(initialHealth).toBe(true) + + // Kill the code-interpreter process as root + await sandbox.commands.run("pkill -9 -f '[u]vicorn main:app'", { + user: 'root', + }) + + // Wait for process-compose to restart it + const recovered = await waitForWorkingSandbox(sandbox) + expect(recovered).toBe(true) + } +) diff --git a/js/tests/systemd.test.ts b/js/tests/systemd.test.ts deleted file mode 100644 index 28f59398..00000000 --- a/js/tests/systemd.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { expect } from 'vitest' -import { isDebug, sandboxTest, wait } from './setup' - -async function waitForHealth(sandbox: any, maxRetries = 10, intervalMs = 100) { - for (let i = 0; i < maxRetries; i++) { - try { - const result = await sandbox.commands.run( - 'curl -s -o /dev/null -w "%{http_code}" http://0.0.0.0:49999/health' - ) - if (result.stdout.trim() === '200') { - return true - } - } catch { - // Connection refused or other error, retry - } - await wait(intervalMs) - } - return false -} - -sandboxTest.skipIf(isDebug)( - 'restart after jupyter kill', - async ({ sandbox }) => { - // Verify health is up initially - const initialHealth = await waitForHealth(sandbox) - expect(initialHealth).toBe(true) - - // Kill the jupyter process as root - // The command handle may get killed too (since killing jupyter cascades to code-interpreter), - // so we catch the error. - try { - await sandbox.commands.run("kill -9 $(pgrep -f 'jupyter server')", { - user: 'root', - }) - } catch { - // Expected — the kill cascade may terminate the command handle - } - - // Wait for systemd to restart both services - const recovered = await waitForHealth(sandbox, 60, 500) - expect(recovered).toBe(true) - - // Verify code execution works after recovery - const result = await sandbox.runCode('x = 1; x') - expect(result.text).toEqual('1') - } -) - -sandboxTest.skipIf(isDebug)( - 'restart after code-interpreter kill', - async ({ sandbox }) => { - // Verify health is up initially - const initialHealth = await waitForHealth(sandbox) - expect(initialHealth).toBe(true) - - // Kill the code-interpreter process as root - try { - await sandbox.commands.run("kill -9 $(pgrep -f 'uvicorn main:app')", { - user: 'root', - }) - } catch { - // Expected — killing code-interpreter may terminate the command handle - } - - // Wait for systemd to restart it and health to come back - const recovered = await waitForHealth(sandbox, 60, 500) - expect(recovered).toBe(true) - - // Verify code execution works after recovery - const result = await sandbox.runCode('x = 1; x') - expect(result.text).toEqual('1') - } -) diff --git a/python/tests/async/test_async_supervision.py b/python/tests/async/test_async_supervision.py new file mode 100644 index 00000000..bfa1cda5 --- /dev/null +++ b/python/tests/async/test_async_supervision.py @@ -0,0 +1,67 @@ +import asyncio + +import pytest + +from e2b_code_interpreter.code_interpreter_async import AsyncSandbox + + +async def wait_for_health(sandbox: AsyncSandbox, max_retries=10, interval_ms=100): + for _ in range(max_retries): + try: + result = await sandbox.commands.run( + 'curl -s -o /dev/null -w "%{http_code}" http://0.0.0.0:49999/health' + ) + if result.stdout.strip() == "200": + return True + except Exception: + pass + await asyncio.sleep(interval_ms / 1000) + return False + + +async def wait_for_working_sandbox( + sandbox: AsyncSandbox, max_retries=60, interval_ms=500 +): + """Recovery has to be judged by running code, not by /health. + + Killing Jupyter leaves the code-interpreter server up but holding dead + kernel handles, so it answers /health for a few seconds before the process + manager recycles it — long enough for a health check to pass against a + sandbox that cannot execute. + """ + for _ in range(max_retries): + try: + result = await sandbox.run_code("x = 1; x") + if result.text == "1": + return True + except Exception: + pass + await asyncio.sleep(interval_ms / 1000) + return False + + +@pytest.mark.skip_debug +async def test_restart_after_jupyter_kill(async_sandbox: AsyncSandbox): + # Verify health is up initially + assert await wait_for_health(async_sandbox) + + # Kill the jupyter process as root. The pattern is bracketed so it cannot + # match the shell running it — `pgrep -f 'jupyter server'` matched its own + # command line, so this only ever killed itself. pkill exits non-zero when + # nothing matched, which fails the test rather than passing it vacuously. + await async_sandbox.commands.run("pkill -9 -f '[j]upyter-server'", user="root") + + # Wait for process-compose to restart both processes + assert await wait_for_working_sandbox(async_sandbox) + + +@pytest.mark.skip_debug +async def test_restart_after_code_interpreter_kill(async_sandbox: AsyncSandbox): + # Verify health is up initially + assert await wait_for_health(async_sandbox) + + # Kill the code-interpreter process as root + await async_sandbox.commands.run("pkill -9 -f '[u]vicorn main:app'", user="root") + + # Wait for process-compose to restart it + assert await wait_for_working_sandbox(async_sandbox) diff --git a/python/tests/async/test_async_systemd.py b/python/tests/async/test_async_systemd.py deleted file mode 100644 index 5a129abc..00000000 --- a/python/tests/async/test_async_systemd.py +++ /dev/null @@ -1,63 +0,0 @@ -import asyncio - -import pytest - -from e2b_code_interpreter.code_interpreter_async import AsyncSandbox - - -async def wait_for_health(sandbox: AsyncSandbox, max_retries=10, interval_ms=100): - for _ in range(max_retries): - try: - result = await sandbox.commands.run( - 'curl -s -o /dev/null -w "%{http_code}" http://0.0.0.0:49999/health' - ) - if result.stdout.strip() == "200": - return True - except Exception: - pass - await asyncio.sleep(interval_ms / 1000) - return False - - -@pytest.mark.skip_debug -async def test_restart_after_jupyter_kill(async_sandbox: AsyncSandbox): - # Verify health is up initially - assert await wait_for_health(async_sandbox) - - # Kill the jupyter process as root - # The command handle may get killed too (killing jupyter cascades to code-interpreter), - # so we catch the error. - try: - await async_sandbox.commands.run( - "kill -9 $(pgrep -f 'jupyter server')", user="root" - ) - except Exception: - pass - - # Wait for systemd to restart both services - assert await wait_for_health(async_sandbox, 60, 500) - - # Verify code execution works after recovery - result = await async_sandbox.run_code("x = 1; x") - assert result.text == "1" - - -@pytest.mark.skip_debug -async def test_restart_after_code_interpreter_kill(async_sandbox: AsyncSandbox): - # Verify health is up initially - assert await wait_for_health(async_sandbox) - - # Kill the code-interpreter process as root - try: - await async_sandbox.commands.run( - "kill -9 $(pgrep -f 'uvicorn main:app')", user="root" - ) - except Exception: - pass - - # Wait for systemd to restart it and health to come back - assert await wait_for_health(async_sandbox, 60, 500) - - # Verify code execution works after recovery - result = await async_sandbox.run_code("x = 1; x") - assert result.text == "1" diff --git a/python/tests/sync/test_supervision.py b/python/tests/sync/test_supervision.py new file mode 100644 index 00000000..8c9aa509 --- /dev/null +++ b/python/tests/sync/test_supervision.py @@ -0,0 +1,65 @@ +import time + +import pytest + +from e2b_code_interpreter.code_interpreter_sync import Sandbox + + +def wait_for_health(sandbox: Sandbox, max_retries=10, interval_ms=100): + for _ in range(max_retries): + try: + result = sandbox.commands.run( + 'curl -s -o /dev/null -w "%{http_code}" http://0.0.0.0:49999/health' + ) + if result.stdout.strip() == "200": + return True + except Exception: + pass + time.sleep(interval_ms / 1000) + return False + + +def wait_for_working_sandbox(sandbox: Sandbox, max_retries=60, interval_ms=500): + """Recovery has to be judged by running code, not by /health. + + Killing Jupyter leaves the code-interpreter server up but holding dead + kernel handles, so it answers /health for a few seconds before the process + manager recycles it — long enough for a health check to pass against a + sandbox that cannot execute. + """ + for _ in range(max_retries): + try: + result = sandbox.run_code("x = 1; x") + if result.text == "1": + return True + except Exception: + pass + time.sleep(interval_ms / 1000) + return False + + +@pytest.mark.skip_debug +def test_restart_after_jupyter_kill(sandbox: Sandbox): + # Verify health is up initially + assert wait_for_health(sandbox) + + # Kill the jupyter process as root. The pattern is bracketed so it cannot + # match the shell running it — `pgrep -f 'jupyter server'` matched its own + # command line, so this only ever killed itself. pkill exits non-zero when + # nothing matched, which fails the test rather than passing it vacuously. + sandbox.commands.run("pkill -9 -f '[j]upyter-server'", user="root") + + # Wait for process-compose to restart both processes + assert wait_for_working_sandbox(sandbox) + + +@pytest.mark.skip_debug +def test_restart_after_code_interpreter_kill(sandbox: Sandbox): + # Verify health is up initially + assert wait_for_health(sandbox) + + # Kill the code-interpreter process as root + sandbox.commands.run("pkill -9 -f '[u]vicorn main:app'", user="root") + + # Wait for process-compose to restart it + assert wait_for_working_sandbox(sandbox) diff --git a/python/tests/sync/test_systemd.py b/python/tests/sync/test_systemd.py deleted file mode 100644 index 574ed7d0..00000000 --- a/python/tests/sync/test_systemd.py +++ /dev/null @@ -1,59 +0,0 @@ -import time - -import pytest - -from e2b_code_interpreter.code_interpreter_sync import Sandbox - - -def wait_for_health(sandbox: Sandbox, max_retries=10, interval_ms=100): - for _ in range(max_retries): - try: - result = sandbox.commands.run( - 'curl -s -o /dev/null -w "%{http_code}" http://0.0.0.0:49999/health' - ) - if result.stdout.strip() == "200": - return True - except Exception: - pass - time.sleep(interval_ms / 1000) - return False - - -@pytest.mark.skip_debug -def test_restart_after_jupyter_kill(sandbox: Sandbox): - # Verify health is up initially - assert wait_for_health(sandbox) - - # Kill the jupyter process as root - # The command handle may get killed too (killing jupyter cascades to code-interpreter), - # so we catch the error. - try: - sandbox.commands.run("kill -9 $(pgrep -f 'jupyter server')", user="root") - except Exception: - pass - - # Wait for systemd to restart both services - assert wait_for_health(sandbox, 60, 500) - - # Verify code execution works after recovery - result = sandbox.run_code("x = 1; x") - assert result.text == "1" - - -@pytest.mark.skip_debug -def test_restart_after_code_interpreter_kill(sandbox: Sandbox): - # Verify health is up initially - assert wait_for_health(sandbox) - - # Kill the code-interpreter process as root - try: - sandbox.commands.run("kill -9 $(pgrep -f 'uvicorn main:app')", user="root") - except Exception: - pass - - # Wait for systemd to restart it and health to come back - assert wait_for_health(sandbox, 60, 500) - - # Verify code execution works after recovery - result = sandbox.run_code("x = 1; x") - assert result.text == "1" diff --git a/template/README.md b/template/README.md index 4f64fabe..2ee346a8 100644 --- a/template/README.md +++ b/template/README.md @@ -91,16 +91,31 @@ execution = sbx.run_code("print('Hello, World!')") print(execution.logs.stdout) ``` -## Debugging a server that won't start +## Process supervision + +Jupyter and the code-interpreter server are supervised by +[process-compose](https://f1bonacc1.github.io/process-compose/), configured in +`process-compose.yaml`. The same config runs in the E2B sandbox and in the +Docker image (`make start-template-server`), so a server that boots under +Docker boots the same way in production. + +The two servers are not independent: the code-interpreter server opens kernel +websockets while starting, so it is gated on Jupyter being up +(`jupyter-healthcheck.sh`) and is restarted whenever Jupyter is replaced +(`jupyter-instance-check.sh`, which compares Jupyter's reported start time +against the one recorded at gate time). + +Jupyter writes to its log file directly rather than through process-compose, +because kernels inherit its stdout and stderr and outlive it — with +process-compose holding those descriptors, a killed Jupyter is not restarted +while any kernel is still running. The cost is that `process-compose process +logs jupyter` shows nothing and the file is not rotated; read +`/var/log/jupyter.log` instead. -The template runs Jupyter and the code-interpreter server as **systemd** -services (`systemd/jupyter.service`, `systemd/code-interpreter.service`). This is -the path CI and production use — note it is *different* from `make -start-template-server`, which runs the Docker `start-up.sh` path. The two can -diverge, so a server that boots fine under Docker may still fail under systemd. +## Debugging a server that won't start When a build fails its readiness check (`Waiting for template to be ready ... -timed out`), the real cause is in the service journals. To see them: +timed out`), the cause is in the process logs. To see them: ``` make debug-template @@ -108,17 +123,19 @@ make debug-template This builds a debug template (gated on a fixed timeout instead of `/health`, so it finalizes even while the server is crash-looping), spawns a sandbox, and -prints `systemctl status` + the full `journalctl` for both services. It needs -`template/.env` with your `E2B_API_KEY` and the deps from `requirements-dev.txt`. +prints the process list and all three logs. It needs `template/.env` with your +`E2B_API_KEY` and the deps from `requirements-dev.txt`. -The debug build also applies a systemd drop-in that routes Jupyter's stdout to -the journal (`make_template(debug=True)`). Production builds keep -`StandardOutput=null`, so Jupyter's request/error logs are only captured in the -debug template. - -Inside a running sandbox you can also inspect things directly: +Inside a running sandbox you can inspect things directly: ``` -journalctl -u jupyter -u code-interpreter -systemctl status code-interpreter +cat /var/log/process-compose.log # restarts, probe failures +cat /var/log/jupyter.log +cat /var/log/code-interpreter.log + +# process-compose listens on a unix socket rather than a TCP port, so its +# subcommands need to be pointed at it +alias pc='process-compose -U -u /var/run/process-compose.sock' +pc process list +pc process restart code-interpreter ``` diff --git a/template/build_debug.py b/template/build_debug.py index f4dc63b4..c39dac69 100644 --- a/template/build_debug.py +++ b/template/build_debug.py @@ -12,7 +12,6 @@ make_template( kernels=["python", "javascript"], ready=wait_for_timeout(60_000), - debug=True, ), alias=alias, cpu_count=2, diff --git a/template/debug_logs.py b/template/debug_logs.py index cdd7b49a..11618883 100644 --- a/template/debug_logs.py +++ b/template/debug_logs.py @@ -10,11 +10,14 @@ sbx = Sandbox.create(template=alias, timeout=600) print(f"sandbox: {sbx.sandbox_id}") +PC = "process-compose -U -u /var/run/process-compose.sock" + CMDS = [ - "systemctl --no-pager status jupyter || true", - "systemctl --no-pager status code-interpreter || true", - "journalctl --no-pager -u jupyter || true", - "journalctl --no-pager -u code-interpreter || true", + f"{PC} process list || true", + # Restarts, probe failures and other supervision decisions. + "cat /var/log/process-compose.log || true", + "cat /var/log/jupyter.log || true", + "cat /var/log/code-interpreter.log || true", "curl -s --max-time 3 -o /dev/null -w 'jupyter :8888 -> %{http_code}\\n' http://localhost:8888/api/status || true", "curl -s --max-time 3 -o /dev/null -w 'server :49999 -> %{http_code}\\n' http://localhost:49999/health || true", ] diff --git a/template/jupyter-healthcheck.sh b/template/jupyter-healthcheck.sh index a4c00131..e57f0e3e 100644 --- a/template/jupyter-healthcheck.sh +++ b/template/jupyter-healthcheck.sh @@ -1,14 +1,19 @@ #!/bin/bash -# Custom health check for Jupyter Server -# Verifies the server is responsive via the /api/status endpoint +# Blocks until Jupyter Server is responsive, then records which Jupyter +# instance answered. jupyter-instance-check.sh compares against that recording +# to notice when Jupyter has been replaced. MAX_RETRIES=50 RETRY_INTERVAL=0.2 +INSTANCE_FILE=/run/jupyter-instance for i in $(seq 1 $MAX_RETRIES); do - status_code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:8888/api/status") + # /api/status reports the server's start time, which is what identifies + # this particular Jupyter process. + started=$(curl -fsS -m 2 "http://localhost:8888/api/status" 2>/dev/null | jq -r '.started') - if [ "$status_code" -eq 200 ]; then + if [ -n "$started" ] && [ "$started" != "null" ]; then + echo "$started" >"$INSTANCE_FILE" echo "Jupyter Server is healthy" exit 0 fi diff --git a/template/jupyter-instance-check.sh b/template/jupyter-instance-check.sh new file mode 100644 index 00000000..449f7577 --- /dev/null +++ b/template/jupyter-instance-check.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Readiness check for the Code Interpreter server: is Jupyter still the same +# instance the server started against? +# +# The server opens kernel websockets while booting, so a replaced Jupyter +# leaves it holding handles to kernels that no longer exist. Reporting that as +# unhealthy is what gets the server recycled alongside Jupyter, the way +# systemd's PartOf=jupyter.service used to. +# +# Everything else has to report healthy. process-compose keeps probing across a +# restart (initial_delay_seconds only covers the first start), so a check that +# fails while the server boots would restart it forever. + +INSTANCE_FILE=/run/jupyter-instance + +# Nothing recorded yet: the server is still starting and hasn't picked the +# Jupyter it will attach to. +recorded=$(cat "$INSTANCE_FILE" 2>/dev/null) || exit 0 +[ -n "$recorded" ] || exit 0 + +# Jupyter unreachable: it has its own supervision, and until it answers again +# there's no way to tell whether it was replaced. +started=$(curl -fsS -m 2 "http://localhost:8888/api/status" 2>/dev/null | jq -r '.started') +[ -n "$started" ] && [ "$started" != "null" ] || exit 0 + +# A mismatch is sticky: every check fails until the server restarts and +# jupyter-healthcheck.sh records the instance it reconnected to. +[ "$started" = "$recorded" ] diff --git a/template/jupyter_server_config.py b/template/jupyter_server_config.py index 663682cc..d08fa3a5 100644 --- a/template/jupyter_server_config.py +++ b/template/jupyter_server_config.py @@ -7,8 +7,8 @@ # # Sessions are created with a relative path (a bare uuid, see # server/contexts.py). Without an explicit root_dir, jupyter-server -# inherits the process working directory as its root — which is "/" -# under systemd (jupyter.service has no WorkingDirectory). Since +# inherits the process working directory as its root, which is +# whatever the process manager happened to start it in. Since # jupyter-server 2.18.0 (CVE-2026-35397 path-traversal hardening), a # root_dir of "/" makes every POST /api/sessions fail with # " is outside root contents directory", so the server never diff --git a/template/process-compose.yaml b/template/process-compose.yaml new file mode 100644 index 00000000..f099d847 --- /dev/null +++ b/template/process-compose.yaml @@ -0,0 +1,72 @@ +# Supervises Jupyter Server and the Code Interpreter server. +# +# The same file drives both images: the E2B (Firecracker) sandbox, which used +# to run these as systemd units, and the Docker image, which used to run them +# from a hand-rolled start-up.sh. Keeping one supervisor means a server that +# boots under Docker boots the same way in production. +version: '0.5' + +log_level: info + +processes: + jupyter: + description: Jupyter Server + # Output goes straight to a file instead of through process-compose. Kernels + # inherit Jupyter's stdout and stderr and outlive it, and process-compose + # waits for those descriptors to close before it acts on the exit — so + # letting it capture them means a killed Jupyter is never restarted as long + # as one kernel is still running, which is the case that matters most (OOM). + command: exec jupyter server --IdentityProvider.token="" >>/var/log/jupyter.log 2>&1 + environment: + - MATPLOTLIBRC=/root/.config/matplotlib/.matplotlibrc + availability: + restart: always + backoff_seconds: 1 + # Kernels die with the server they run under, so a wedged Jupyter has to be + # replaced. failure_threshold is deliberately long: process-compose keeps + # probing across a restart, so a short one would restart a Jupyter that is + # merely slow to boot, over and over. + readiness_probe: + http_get: + host: 127.0.0.1 + port: 8888 + path: /api/status + period_seconds: 1 + timeout_seconds: 3 + failure_threshold: 30 + + code-interpreter: + description: Code Interpreter Server + working_dir: /root/.server + # jupyter-healthcheck.sh is the ExecStartPre equivalent: the server opens + # its default kernel websockets while starting, so Jupyter has to answer + # first or uvicorn exits and we back off into another attempt. + command: /root/.jupyter/jupyter-healthcheck.sh && exec .venv/bin/uvicorn main:app --host 0.0.0.0 --port 49999 --workers 1 --no-access-log --no-use-colors --timeout-keep-alive 640 + log_location: /var/log/code-interpreter.log + # log_configuration has to be set per process — a top-level one is not + # inherited, and without flush_each_line the file stays empty until + # shutdown, which is exactly when it stops being useful. + log_configuration: + disable_json: true + add_timestamp: true + no_color: true + flush_each_line: true + rotation: + max_size_mb: 10 + max_backups: 1 + depends_on: + jupyter: + condition: process_healthy + availability: + restart: always + backoff_seconds: 1 + # jupyter-instance-check.sh fails once Jupyter has been replaced, which is + # how this process gets recycled with it (systemd did that with + # PartOf=jupyter.service). The threshold leaves room for a restart to + # re-record the instance before the check is believed. + readiness_probe: + exec: + command: /root/.jupyter/jupyter-instance-check.sh + period_seconds: 1 + timeout_seconds: 5 + failure_threshold: 3 diff --git a/template/start-up.sh b/template/start-up.sh deleted file mode 100755 index 5786b039..00000000 --- a/template/start-up.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -function start_code_interpreter() { - /root/.jupyter/jupyter-healthcheck.sh - if [ $? -ne 0 ]; then - echo "Jupyter Server failed to start, aborting." - exit 1 - fi - - cd /root/.server/ - .venv/bin/uvicorn main:app --host 0.0.0.0 --port 49999 --workers 1 --no-access-log --no-use-colors --timeout-keep-alive 640 -} - -echo "Starting Code Interpreter server..." -start_code_interpreter & -MATPLOTLIBRC=/root/.config/matplotlib/.matplotlibrc jupyter server --IdentityProvider.token="" >/dev/null 2>&1 diff --git a/template/systemd/code-interpreter.service b/template/systemd/code-interpreter.service deleted file mode 100644 index 04c165b4..00000000 --- a/template/systemd/code-interpreter.service +++ /dev/null @@ -1,17 +0,0 @@ -[Unit] -Description=Code Interpreter Server -Documentation=https://github.com/e2b-dev/code-interpreter -Requires=jupyter.service -After=jupyter.service -PartOf=jupyter.service -StartLimitBurst=0 - -[Service] -Type=simple -WorkingDirectory=/root/.server -ExecStartPre=/root/.jupyter/jupyter-healthcheck.sh -ExecStart=/root/.server/.venv/bin/uvicorn main:app --host 0.0.0.0 --port 49999 --workers 1 --no-access-log --no-use-colors --timeout-keep-alive 640 -Restart=on-failure -RestartSec=1 -StandardOutput=journal -StandardError=journal diff --git a/template/systemd/jupyter-debug.conf b/template/systemd/jupyter-debug.conf deleted file mode 100644 index 89377a6f..00000000 --- a/template/systemd/jupyter-debug.conf +++ /dev/null @@ -1,5 +0,0 @@ -# Debug-only drop-in: route Jupyter's stdout to the journal (the base unit -# sends it to /dev/null) so ServerApp request/error logs are visible via -# `journalctl -u jupyter`. Applied only by `make_template(debug=True)`. -[Service] -StandardOutput=journal diff --git a/template/systemd/jupyter.service b/template/systemd/jupyter.service deleted file mode 100644 index 37b83f29..00000000 --- a/template/systemd/jupyter.service +++ /dev/null @@ -1,15 +0,0 @@ -[Unit] -Description=Jupyter Server -Documentation=https://jupyter-server.readthedocs.io -Wants=code-interpreter.service -StartLimitBurst=0 - -[Service] -Type=simple -Environment=MATPLOTLIBRC=/root/.config/matplotlib/.matplotlibrc -ExecStart=/usr/local/bin/jupyter server --IdentityProvider.token="" -ExecStartPost=-/usr/bin/systemctl reset-failed code-interpreter -Restart=on-failure -RestartSec=1 -StandardOutput=null -StandardError=journal diff --git a/template/template.py b/template/template.py index fd8dd937..ea8d02f6 100644 --- a/template/template.py +++ b/template/template.py @@ -5,7 +5,6 @@ def make_template( kernels: list[str] = ["python", "r", "javascript", "bash", "java"], is_docker: bool = False, ready: ReadyCmd | None = None, - debug: bool = False, ): enabled_kernels = set(["python", "javascript"] + kernels) # Start with base template @@ -23,6 +22,7 @@ def make_template( "JAVA_HOME": "/usr/lib/jvm/jdk-${JAVA_VERSION}", "IJAVA_VERSION": "1.3.0", "R_VERSION": "4.5.*", + "PROCESS_COMPOSE_VERSION": "1.120.0", } ) .apt_install( @@ -39,6 +39,14 @@ def make_template( ) .run_cmd("curl -fsSL https://deb.nodesource.com/setup_20.x | bash -") .apt_install("nodejs") + # Supervises Jupyter and the Code Interpreter server; see + # process-compose.yaml. Built for the image's own architecture so the + # Docker path works on arm64 machines too. + .run_cmd( + "curl -fsSL https://github.com/F1bonacc1/process-compose/releases/download" + "/v${PROCESS_COMPOSE_VERSION}/process-compose_linux_$(dpkg --print-architecture).tar.gz" + " | tar -xz -C /usr/local/bin process-compose" + ) .copy("requirements.txt", "requirements.txt") .pip_install("--no-cache-dir -r requirements.txt") ) @@ -98,31 +106,17 @@ def make_template( template = ( template.copy("matplotlibrc", ".config/matplotlib/.matplotlibrc") .copy("jupyter-healthcheck.sh", ".jupyter/jupyter-healthcheck.sh") - .run_cmd("chmod +x .jupyter/jupyter-healthcheck.sh") + .copy("jupyter-instance-check.sh", ".jupyter/jupyter-instance-check.sh") + .run_cmd( + "chmod +x .jupyter/jupyter-healthcheck.sh .jupyter/jupyter-instance-check.sh" + ) + .copy("process-compose.yaml", ".jupyter/process-compose.yaml") .copy("jupyter_server_config.py", ".jupyter/") .make_dir(".ipython/profile_default/startup") .copy("ipython_kernel_config.py", ".ipython/profile_default/") .copy("startup_scripts", ".ipython/profile_default/startup") ) - if not is_docker: - template = template.copy( - "systemd/jupyter.service", "/etc/systemd/system/jupyter.service" - ).copy( - "systemd/code-interpreter.service", - "/etc/systemd/system/code-interpreter.service", - ) - if debug: - # Drop-in that routes Jupyter's stdout to the journal for debugging. - template = template.copy( - "systemd/jupyter-debug.conf", - "/etc/systemd/system/jupyter.service.d/debug.conf", - ) - else: - template = template.copy("start-up.sh", ".jupyter/start-up.sh").run_cmd( - "chmod +x .jupyter/start-up.sh" - ) - if is_docker: # create user user and /home/user template = template.run_cmd("useradd -m user") @@ -135,10 +129,16 @@ def make_template( template = template.set_user("user").set_workdir("/home/user") - if is_docker: - start_cmd = "sudo --preserve-env=E2B_LOCAL /root/.jupyter/start-up.sh" - else: - start_cmd = "sudo systemctl start jupyter" + # --disable-dotenv keeps a stray .env in the working directory out of the + # servers' environment, and the unix socket keeps process-compose's control + # API off a TCP port the sandbox would otherwise expose. + start_cmd = ( + "sudo --preserve-env=E2B_LOCAL process-compose up" + " --config /root/.jupyter/process-compose.yaml" + " --log-file /var/log/process-compose.log" + " --unix-socket /var/run/process-compose.sock" + " --use-uds --disable-dotenv --ordered-shutdown --tui=false" + ) if ready is None: ready = wait_for_url("http://localhost:49999/health")