Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- Dockerfile +136 -0
- README.md +229 -5
- __init__.py +13 -0
- client.py +161 -0
- models.py +67 -0
- openenv.yaml +5 -0
- pyproject.toml +40 -0
- server/__init__.py +17 -0
- server/app.py +219 -0
- server/julia_codeact_env.py +452 -0
- server/julia_executor.py +458 -0
- server/julia_process_pool.py +601 -0
- server/julia_repl_worker.jl +209 -0
- server/julia_transforms.py +75 -0
- server/requirements.txt +6 -0
- test_julia_env.sh +215 -0
Dockerfile
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
# Multi-stage build using openenv-base
|
| 8 |
+
# This Dockerfile works for both in-repo and standalone builds.
|
| 9 |
+
|
| 10 |
+
ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
|
| 11 |
+
FROM ${BASE_IMAGE} AS builder
|
| 12 |
+
|
| 13 |
+
WORKDIR /app
|
| 14 |
+
|
| 15 |
+
# Build argument to control whether we're building standalone or in-repo
|
| 16 |
+
ARG BUILD_MODE=in-repo
|
| 17 |
+
|
| 18 |
+
# Install system dependencies for Julia
|
| 19 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 20 |
+
git \
|
| 21 |
+
curl \
|
| 22 |
+
ca-certificates \
|
| 23 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 24 |
+
|
| 25 |
+
# Install juliaup and Julia 1.10
|
| 26 |
+
RUN curl -fsSL https://install.julialang.org | sh -s -- --yes --default-channel 1.10
|
| 27 |
+
|
| 28 |
+
# Add Julia to PATH
|
| 29 |
+
ENV PATH="/root/.juliaup/bin:${PATH}"
|
| 30 |
+
|
| 31 |
+
# Configure juliaup to prevent runtime network calls
|
| 32 |
+
# Set the default channel explicitly and mark juliaup as initialized
|
| 33 |
+
ENV JULIAUP_CHANNEL="1.10"
|
| 34 |
+
RUN juliaup default 1.10
|
| 35 |
+
|
| 36 |
+
# Verify Julia installation
|
| 37 |
+
RUN julia --version
|
| 38 |
+
|
| 39 |
+
# Precompile commonly used Julia packages (Test is built-in)
|
| 40 |
+
RUN julia -e 'using Test; println("Julia Test module ready")'
|
| 41 |
+
|
| 42 |
+
# Copy environment code (always at root of build context)
|
| 43 |
+
COPY . /app/env
|
| 44 |
+
|
| 45 |
+
# Ensure uv is available
|
| 46 |
+
RUN if ! command -v uv >/dev/null 2>&1; then \
|
| 47 |
+
curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
| 48 |
+
mv /root/.local/bin/uv /usr/local/bin/uv && \
|
| 49 |
+
mv /root/.local/bin/uvx /usr/local/bin/uvx; \
|
| 50 |
+
fi
|
| 51 |
+
|
| 52 |
+
WORKDIR /app/env
|
| 53 |
+
|
| 54 |
+
# Install dependencies using uv sync
|
| 55 |
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 56 |
+
if [ -f uv.lock ]; then \
|
| 57 |
+
uv sync --frozen --no-install-project --no-editable; \
|
| 58 |
+
else \
|
| 59 |
+
uv sync --no-install-project --no-editable; \
|
| 60 |
+
fi
|
| 61 |
+
|
| 62 |
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 63 |
+
if [ -f uv.lock ]; then \
|
| 64 |
+
uv sync --frozen --no-editable; \
|
| 65 |
+
else \
|
| 66 |
+
uv sync --no-editable; \
|
| 67 |
+
fi
|
| 68 |
+
|
| 69 |
+
# Final runtime stage
|
| 70 |
+
FROM ${BASE_IMAGE}
|
| 71 |
+
|
| 72 |
+
WORKDIR /app
|
| 73 |
+
|
| 74 |
+
# Install Julia runtime dependencies
|
| 75 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 76 |
+
curl \
|
| 77 |
+
ca-certificates \
|
| 78 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 79 |
+
|
| 80 |
+
# Copy Julia installation from builder
|
| 81 |
+
# juliaup stores the manager in ~/.juliaup and actual Julia versions in ~/.julia/juliaup
|
| 82 |
+
COPY --from=builder /root/.juliaup /root/.juliaup
|
| 83 |
+
COPY --from=builder /root/.julia /root/.julia
|
| 84 |
+
|
| 85 |
+
# Create a direct symlink to Julia binary, bypassing the juliaup wrapper
|
| 86 |
+
# This prevents juliaup from trying to check for updates at runtime
|
| 87 |
+
# The juliaup wrapper (at ~/.juliaup/bin/julia) triggers network calls;
|
| 88 |
+
# the actual Julia binary is in ~/.julia/juliaup/julia-*/bin/julia
|
| 89 |
+
RUN echo "Looking for Julia binary..." && \
|
| 90 |
+
ls -la /root/.julia/juliaup/ && \
|
| 91 |
+
JULIA_BIN=$(find /root/.julia/juliaup -type f -executable -name "julia" 2>/dev/null | head -1) && \
|
| 92 |
+
if [ -z "$JULIA_BIN" ]; then \
|
| 93 |
+
echo "ERROR: Could not find Julia binary. Contents of .julia/juliaup:" && \
|
| 94 |
+
find /root/.julia -name "julia*" -ls && \
|
| 95 |
+
exit 1; \
|
| 96 |
+
fi && \
|
| 97 |
+
ln -sf "$JULIA_BIN" /usr/local/bin/julia && \
|
| 98 |
+
echo "Linked Julia from: $JULIA_BIN"
|
| 99 |
+
|
| 100 |
+
# Verify Julia works without network
|
| 101 |
+
RUN julia --version
|
| 102 |
+
|
| 103 |
+
ENV PATH="/usr/local/bin:${PATH}"
|
| 104 |
+
|
| 105 |
+
# Disable juliaup's version update checks (as fallback if juliaup wrapper is used)
|
| 106 |
+
ENV JULIAUP_CHANNEL="1.10"
|
| 107 |
+
|
| 108 |
+
# Copy the virtual environment from builder
|
| 109 |
+
COPY --from=builder /app/env/.venv /app/.venv
|
| 110 |
+
|
| 111 |
+
# Copy the environment code
|
| 112 |
+
COPY --from=builder /app/env /app/env
|
| 113 |
+
|
| 114 |
+
# Set PATH to use the virtual environment
|
| 115 |
+
ENV PATH="/app/.venv/bin:$PATH"
|
| 116 |
+
|
| 117 |
+
# Set PYTHONPATH so imports work correctly
|
| 118 |
+
ENV PYTHONPATH="/app/env:$PYTHONPATH"
|
| 119 |
+
|
| 120 |
+
# Julia process pool configuration (can be overridden at runtime)
|
| 121 |
+
ENV JULIA_MAX_WORKERS=8
|
| 122 |
+
ENV JULIA_EXECUTION_TIMEOUT=120
|
| 123 |
+
ENV JULIA_LOG_FILE=/tmp/julia_env.log
|
| 124 |
+
ENV JULIA_LOG_LEVEL=INFO
|
| 125 |
+
ENV PYTHONUNBUFFERED=1
|
| 126 |
+
|
| 127 |
+
# Health check
|
| 128 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
| 129 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
| 130 |
+
|
| 131 |
+
# Expose port
|
| 132 |
+
EXPOSE 8000
|
| 133 |
+
|
| 134 |
+
# Run the FastAPI server
|
| 135 |
+
ENV ENABLE_WEB_INTERFACE=true
|
| 136 |
+
CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
|
README.md
CHANGED
|
@@ -1,10 +1,234 @@
|
|
| 1 |
---
|
| 2 |
-
title: Julia
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Julia Environment Server
|
| 3 |
+
emoji: 🔬
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
app_port: 8000
|
| 9 |
+
base_path: /web
|
| 10 |
+
tags:
|
| 11 |
+
- openenv
|
| 12 |
+
- julia
|
| 13 |
---
|
| 14 |
|
| 15 |
+
# Julia Environment
|
| 16 |
+
|
| 17 |
+
A Julia code execution environment that runs Julia code with test result tracking and reward calculation. Perfect for reinforcement learning training with Julia programming tasks.
|
| 18 |
+
|
| 19 |
+
## Quick Start
|
| 20 |
+
|
| 21 |
+
The simplest way to use the Julia environment is through the `JuliaEnv` class:
|
| 22 |
+
|
| 23 |
+
```python
|
| 24 |
+
from envs.julia_env import JuliaAction, JuliaEnv
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
# Create environment from Docker image
|
| 28 |
+
julia_env = JuliaEnv.from_docker_image("julia-env:latest")
|
| 29 |
+
|
| 30 |
+
# Reset
|
| 31 |
+
result = julia_env.reset()
|
| 32 |
+
print(f"Reset complete: exit_code={result.observation.exit_code}")
|
| 33 |
+
|
| 34 |
+
# Execute Julia code with tests
|
| 35 |
+
action = JuliaAction(
|
| 36 |
+
core_code="""
|
| 37 |
+
function multiply(a, b)
|
| 38 |
+
return a * b
|
| 39 |
+
end
|
| 40 |
+
""",
|
| 41 |
+
test_code="""
|
| 42 |
+
using Test
|
| 43 |
+
@test multiply(3, 4) == 12
|
| 44 |
+
@test multiply(5, 6) == 30
|
| 45 |
+
"""
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
result = julia_env.step(action)
|
| 49 |
+
print(f"Tests passed: {result.observation.tests_passed}")
|
| 50 |
+
print(f"Tests failed: {result.observation.tests_failed}")
|
| 51 |
+
print(f"Code compiles: {result.observation.code_compiles}")
|
| 52 |
+
print(f"Reward: {result.reward}")
|
| 53 |
+
|
| 54 |
+
finally:
|
| 55 |
+
# Always clean up
|
| 56 |
+
julia_env.close()
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
That's it! The `JuliaEnv.from_docker_image()` method handles:
|
| 60 |
+
- Starting the Docker container
|
| 61 |
+
- Waiting for the server to be ready
|
| 62 |
+
- Connecting to the environment
|
| 63 |
+
- Container cleanup when you call `close()`
|
| 64 |
+
|
| 65 |
+
## Building the Docker Image
|
| 66 |
+
|
| 67 |
+
Before using the environment, you need to build the Docker image:
|
| 68 |
+
|
| 69 |
+
```bash
|
| 70 |
+
# From the julia_env directory
|
| 71 |
+
cd envs/julia_env
|
| 72 |
+
docker build -t julia-env:latest -f server/Dockerfile .
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
## Environment Details
|
| 76 |
+
|
| 77 |
+
### Action
|
| 78 |
+
|
| 79 |
+
**JuliaAction**: Contains two fields for Julia code execution
|
| 80 |
+
- `core_code` (str) - The main Julia code to execute (e.g., function definitions)
|
| 81 |
+
- `test_code` (str) - Test code using Julia's `Test` module (e.g., `@test` statements)
|
| 82 |
+
|
| 83 |
+
### Observation
|
| 84 |
+
|
| 85 |
+
**JuliaObservation**: Contains the execution results and test outcomes
|
| 86 |
+
- `stdout` (str) - Standard output from Julia execution
|
| 87 |
+
- `stderr` (str) - Standard error from Julia execution
|
| 88 |
+
- `exit_code` (int) - Exit code (0 for success, non-zero for errors)
|
| 89 |
+
- `tests_passed` (int) - Number of tests that passed
|
| 90 |
+
- `tests_failed` (int) - Number of tests that failed
|
| 91 |
+
- `code_compiles` (bool) - Whether the core code compiled/executed successfully
|
| 92 |
+
|
| 93 |
+
### State
|
| 94 |
+
|
| 95 |
+
**JuliaState**: Tracks episode execution state
|
| 96 |
+
- `episode_id` (str) - Unique identifier for the episode
|
| 97 |
+
- `step_count` (int) - Number of steps taken in the episode
|
| 98 |
+
- `last_exit_code` (int) - Exit code from the last execution
|
| 99 |
+
- `last_code_compiles` (bool) - Whether the last code compiled successfully
|
| 100 |
+
- `total_tests_passed` (int) - Cumulative number of tests passed in the episode
|
| 101 |
+
- `total_tests_failed` (int) - Cumulative number of tests failed in the episode
|
| 102 |
+
|
| 103 |
+
### Reward Calculation
|
| 104 |
+
|
| 105 |
+
The environment calculates rewards based on execution success and test results:
|
| 106 |
+
- Code compiles successfully: Base reward
|
| 107 |
+
- Tests pass: Additional reward per test
|
| 108 |
+
- Tests fail or code doesn't compile: Negative reward
|
| 109 |
+
|
| 110 |
+
See `server/julia_transforms.py` for detailed reward logic.
|
| 111 |
+
|
| 112 |
+
## Features
|
| 113 |
+
|
| 114 |
+
- ✅ Execute Julia code in isolated subprocess
|
| 115 |
+
- ✅ Parse Julia `Test` module output (tests passed/failed)
|
| 116 |
+
- ✅ Calculate rewards based on execution results and test outcomes
|
| 117 |
+
- ✅ Safety transforms for output truncation (prevents excessive output)
|
| 118 |
+
- ✅ Docker support for reproducible execution
|
| 119 |
+
- ✅ Compatible with GRPO and other RL training frameworks
|
| 120 |
+
|
| 121 |
+
## Advanced Usage
|
| 122 |
+
|
| 123 |
+
### Connecting to an Existing Server
|
| 124 |
+
|
| 125 |
+
If you already have a Julia environment server running, you can connect directly:
|
| 126 |
+
|
| 127 |
+
```python
|
| 128 |
+
from envs.julia_env import JuliaEnv, JuliaAction
|
| 129 |
+
|
| 130 |
+
# Connect to existing server
|
| 131 |
+
julia_env = JuliaEnv(base_url="http://localhost:8000")
|
| 132 |
+
|
| 133 |
+
# Use as normal
|
| 134 |
+
result = julia_env.reset()
|
| 135 |
+
result = julia_env.step(JuliaAction(
|
| 136 |
+
core_code="println(2 + 2)",
|
| 137 |
+
test_code=""
|
| 138 |
+
))
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
Note: When connecting to an existing server, `julia_env.close()` will NOT stop the server.
|
| 142 |
+
|
| 143 |
+
### Custom Timeout
|
| 144 |
+
|
| 145 |
+
The Julia environment uses a longer timeout (180s) by default to accommodate Julia compilation and execution:
|
| 146 |
+
|
| 147 |
+
```python
|
| 148 |
+
# Custom timeout (in seconds)
|
| 149 |
+
julia_env = JuliaEnv(base_url="http://localhost:8000", message_timeout_s=300.0)
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
### Running with Docker Directly
|
| 153 |
+
|
| 154 |
+
```bash
|
| 155 |
+
# Run with default settings (port 8000)
|
| 156 |
+
docker run -d -p 8000:8000 --name julia-env julia-env:latest
|
| 157 |
+
|
| 158 |
+
# Check health
|
| 159 |
+
curl http://localhost:8000/health
|
| 160 |
+
|
| 161 |
+
# View logs
|
| 162 |
+
docker logs -f julia-env
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
## Example Code
|
| 166 |
+
|
| 167 |
+
Here's a more complex example demonstrating test-driven development:
|
| 168 |
+
|
| 169 |
+
```python
|
| 170 |
+
from envs.julia_env import JuliaAction, JuliaEnv
|
| 171 |
+
|
| 172 |
+
julia_env = JuliaEnv.from_docker_image("julia-env:latest")
|
| 173 |
+
|
| 174 |
+
try:
|
| 175 |
+
# Reset the environment
|
| 176 |
+
julia_env.reset()
|
| 177 |
+
|
| 178 |
+
# Step 1: Define a function with tests
|
| 179 |
+
action = JuliaAction(
|
| 180 |
+
core_code="""
|
| 181 |
+
function fibonacci(n)
|
| 182 |
+
if n <= 1
|
| 183 |
+
return n
|
| 184 |
+
end
|
| 185 |
+
return fibonacci(n-1) + fibonacci(n-2)
|
| 186 |
+
end
|
| 187 |
+
""",
|
| 188 |
+
test_code="""
|
| 189 |
+
using Test
|
| 190 |
+
@test fibonacci(0) == 0
|
| 191 |
+
@test fibonacci(1) == 1
|
| 192 |
+
@test fibonacci(5) == 5
|
| 193 |
+
@test fibonacci(10) == 55
|
| 194 |
+
"""
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
result = julia_env.step(action)
|
| 198 |
+
print(f"Step 1 - Tests passed: {result.observation.tests_passed}/4")
|
| 199 |
+
print(f"Step 1 - Reward: {result.reward}")
|
| 200 |
+
|
| 201 |
+
# Get current state
|
| 202 |
+
state = julia_env.state()
|
| 203 |
+
print(f"Total tests passed so far: {state.total_tests_passed}")
|
| 204 |
+
print(f"Total tests failed so far: {state.total_tests_failed}")
|
| 205 |
+
|
| 206 |
+
finally:
|
| 207 |
+
julia_env.close()
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
## Compatibility
|
| 211 |
+
|
| 212 |
+
This environment is compatible with:
|
| 213 |
+
- **torchforge**: For GRPO training with Julia tasks
|
| 214 |
+
- **TRL**: Hugging Face's RL library
|
| 215 |
+
- **Any OpenEnv-compatible RL framework**
|
| 216 |
+
|
| 217 |
+
## Development
|
| 218 |
+
|
| 219 |
+
For local development without Docker:
|
| 220 |
+
|
| 221 |
+
```bash
|
| 222 |
+
# Install Julia 1.10+ from https://julialang.org/downloads/
|
| 223 |
+
|
| 224 |
+
# Install Python dependencies
|
| 225 |
+
cd envs/julia_env
|
| 226 |
+
pip install -e .
|
| 227 |
+
|
| 228 |
+
# Run the server locally
|
| 229 |
+
uvicorn julia_env.server.app:app --host 0.0.0.0 --port 8000
|
| 230 |
+
```
|
| 231 |
+
|
| 232 |
+
## License
|
| 233 |
+
|
| 234 |
+
This source code is licensed under the BSD-style license found in the LICENSE file in the root directory of this source tree.
|
__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""Julia Environment - Code execution environment for RL training."""
|
| 8 |
+
|
| 9 |
+
from .client import JuliaEnv
|
| 10 |
+
from .models import JuliaAction, JuliaObservation, JuliaState
|
| 11 |
+
|
| 12 |
+
__all__ = ["JuliaAction", "JuliaObservation", "JuliaState", "JuliaEnv"]
|
| 13 |
+
|
client.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
JuliaEnv
|
| 9 |
+
--------
|
| 10 |
+
Client-side wrapper for the Julia environment server.
|
| 11 |
+
|
| 12 |
+
This client maintains a persistent WebSocket connection to the environment
|
| 13 |
+
server, enabling efficient multi-step interactions with lower latency.
|
| 14 |
+
|
| 15 |
+
- Users instantiate JuliaEnv with a base_url provided by the higher-level
|
| 16 |
+
vector/orchestration layer.
|
| 17 |
+
- Environment authors ship the Docker image that serves the API.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
from openenv.core.client_types import StepResult
|
| 23 |
+
from openenv.core.env_client import EnvClient
|
| 24 |
+
|
| 25 |
+
from .models import JuliaAction, JuliaObservation, JuliaState
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class JuliaEnv(EnvClient[JuliaAction, JuliaObservation, JuliaState]):
|
| 29 |
+
"""
|
| 30 |
+
WebSocket client for the Julia Environment.
|
| 31 |
+
|
| 32 |
+
This client connects to a JuliaEnvironment server and provides
|
| 33 |
+
methods to interact with it: reset(), step(), and state access.
|
| 34 |
+
|
| 35 |
+
The default message timeout is set to 180 seconds to accommodate:
|
| 36 |
+
- Server execution timeout: 120s
|
| 37 |
+
- Process pool worker wait: 30s
|
| 38 |
+
- Network overhead: 30s buffer
|
| 39 |
+
|
| 40 |
+
Example:
|
| 41 |
+
>>> # Connect to a running server
|
| 42 |
+
>>> client = JuliaEnv(base_url="http://localhost:8000")
|
| 43 |
+
>>> result = client.reset()
|
| 44 |
+
>>> print(result.observation.stdout)
|
| 45 |
+
>>>
|
| 46 |
+
>>> # Execute Julia code
|
| 47 |
+
>>> action = JuliaAction(
|
| 48 |
+
... core_code='''
|
| 49 |
+
... function multiply(a, b)
|
| 50 |
+
... return a * b
|
| 51 |
+
... end
|
| 52 |
+
... ''',
|
| 53 |
+
... test_code='''
|
| 54 |
+
... using Test
|
| 55 |
+
... @test multiply(3, 4) == 12
|
| 56 |
+
... '''
|
| 57 |
+
... )
|
| 58 |
+
>>> result = client.step(action)
|
| 59 |
+
>>> print(result.observation.tests_passed) # 1
|
| 60 |
+
>>> print(result.reward)
|
| 61 |
+
|
| 62 |
+
Example with Docker:
|
| 63 |
+
>>> # Automatically start container and connect
|
| 64 |
+
>>> client = JuliaEnv.from_docker_image("julia-env:latest")
|
| 65 |
+
>>> result = client.reset()
|
| 66 |
+
>>> result = client.step(JuliaAction(core_code="println(2 + 2)", test_code=""))
|
| 67 |
+
>>> print(result.observation.stdout) # "4\\n"
|
| 68 |
+
>>> client.close()
|
| 69 |
+
"""
|
| 70 |
+
|
| 71 |
+
# Override default timeout to accommodate Julia execution + worker wait
|
| 72 |
+
DEFAULT_MESSAGE_TIMEOUT = 180.0 # 120s execution + 30s worker wait + 30s buffer
|
| 73 |
+
|
| 74 |
+
def __init__(
|
| 75 |
+
self,
|
| 76 |
+
base_url: str,
|
| 77 |
+
connect_timeout_s: float = 10.0,
|
| 78 |
+
message_timeout_s: float | None = None,
|
| 79 |
+
**kwargs,
|
| 80 |
+
):
|
| 81 |
+
"""
|
| 82 |
+
Initialize JuliaEnv client with appropriate timeout.
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
base_url: Base URL of the Julia environment server
|
| 86 |
+
connect_timeout_s: Timeout for establishing WebSocket connection
|
| 87 |
+
message_timeout_s: Timeout for receiving responses (default: 180.0)
|
| 88 |
+
**kwargs: Additional arguments passed to EnvClient
|
| 89 |
+
"""
|
| 90 |
+
if message_timeout_s is None:
|
| 91 |
+
message_timeout_s = self.DEFAULT_MESSAGE_TIMEOUT
|
| 92 |
+
super().__init__(
|
| 93 |
+
base_url,
|
| 94 |
+
connect_timeout_s=connect_timeout_s,
|
| 95 |
+
message_timeout_s=message_timeout_s,
|
| 96 |
+
**kwargs,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
# --- EnvClient abstract hooks ---
|
| 100 |
+
|
| 101 |
+
def _step_payload(self, action: JuliaAction) -> dict:
|
| 102 |
+
"""
|
| 103 |
+
Convert JuliaAction to JSON payload for step request.
|
| 104 |
+
|
| 105 |
+
Args:
|
| 106 |
+
action: JuliaAction instance
|
| 107 |
+
|
| 108 |
+
Returns:
|
| 109 |
+
Dictionary representation suitable for JSON encoding
|
| 110 |
+
"""
|
| 111 |
+
return {
|
| 112 |
+
"core_code": action.core_code,
|
| 113 |
+
"test_code": action.test_code,
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
def _parse_result(self, payload: dict) -> StepResult[JuliaObservation]:
|
| 117 |
+
"""
|
| 118 |
+
Parse server response into StepResult[JuliaObservation].
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
payload: JSON response from server
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
StepResult with JuliaObservation
|
| 125 |
+
"""
|
| 126 |
+
obs_data = payload.get("observation", {})
|
| 127 |
+
observation = JuliaObservation(
|
| 128 |
+
stdout=obs_data.get("stdout", ""),
|
| 129 |
+
stderr=obs_data.get("stderr", ""),
|
| 130 |
+
exit_code=obs_data.get("exit_code", 0),
|
| 131 |
+
tests_passed=obs_data.get("tests_passed", 0),
|
| 132 |
+
tests_failed=obs_data.get("tests_failed", 0),
|
| 133 |
+
code_compiles=obs_data.get("code_compiles", True),
|
| 134 |
+
done=payload.get("done", False),
|
| 135 |
+
reward=payload.get("reward"),
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
return StepResult[JuliaObservation](
|
| 139 |
+
observation=observation,
|
| 140 |
+
reward=payload.get("reward"),
|
| 141 |
+
done=payload.get("done", False),
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
def _parse_state(self, payload: dict) -> JuliaState:
|
| 145 |
+
"""
|
| 146 |
+
Parse server response into JuliaState object.
|
| 147 |
+
|
| 148 |
+
Args:
|
| 149 |
+
payload: JSON response from /state endpoint
|
| 150 |
+
|
| 151 |
+
Returns:
|
| 152 |
+
JuliaState object with episode metadata
|
| 153 |
+
"""
|
| 154 |
+
return JuliaState(
|
| 155 |
+
episode_id=payload.get("episode_id"),
|
| 156 |
+
step_count=payload.get("step_count", 0),
|
| 157 |
+
last_exit_code=payload.get("last_exit_code", 0),
|
| 158 |
+
last_code_compiles=payload.get("last_code_compiles", True),
|
| 159 |
+
total_tests_passed=payload.get("total_tests_passed", 0),
|
| 160 |
+
total_tests_failed=payload.get("total_tests_failed", 0),
|
| 161 |
+
)
|
models.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
Data models for the Julia Environment.
|
| 9 |
+
|
| 10 |
+
The Julia environment executes Julia code and provides feedback through
|
| 11 |
+
compilation and unit test results.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from openenv.core.env_server import Action, Observation, State
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class JuliaAction(Action):
|
| 18 |
+
"""
|
| 19 |
+
Action for the Julia environment - code to execute.
|
| 20 |
+
|
| 21 |
+
Attributes:
|
| 22 |
+
core_code: Core Julia code to execute
|
| 23 |
+
test_code: Optional test code to execute. If not provided, only core_code runs.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
core_code: str
|
| 27 |
+
test_code: str | None = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class JuliaObservation(Observation):
|
| 31 |
+
"""
|
| 32 |
+
Observation from the Julia environment - execution results.
|
| 33 |
+
|
| 34 |
+
Attributes:
|
| 35 |
+
stdout: Standard output from Julia execution
|
| 36 |
+
stderr: Standard error from Julia execution
|
| 37 |
+
exit_code: Exit code (0 = success, non-zero = error)
|
| 38 |
+
tests_passed: Number of tests passed (if tests were run)
|
| 39 |
+
tests_failed: Number of tests failed (if tests were run)
|
| 40 |
+
code_compiles: Whether the core code compiled/executed successfully
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
stdout: str = ""
|
| 44 |
+
stderr: str = ""
|
| 45 |
+
exit_code: int = 0
|
| 46 |
+
tests_passed: int = 0
|
| 47 |
+
tests_failed: int = 0
|
| 48 |
+
code_compiles: bool = True
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class JuliaState(State):
|
| 52 |
+
"""
|
| 53 |
+
State for Julia environment.
|
| 54 |
+
|
| 55 |
+
Attributes:
|
| 56 |
+
episode_id: Unique episode identifier
|
| 57 |
+
step_count: Number of steps taken in episode
|
| 58 |
+
last_exit_code: Exit code from last execution
|
| 59 |
+
last_code_compiles: Whether the last code compiled successfully
|
| 60 |
+
total_tests_passed: Cumulative tests passed in episode
|
| 61 |
+
total_tests_failed: Cumulative tests failed in episode
|
| 62 |
+
"""
|
| 63 |
+
|
| 64 |
+
last_exit_code: int = 0
|
| 65 |
+
last_code_compiles: bool = True
|
| 66 |
+
total_tests_passed: int = 0
|
| 67 |
+
total_tests_failed: int = 0
|
openenv.yaml
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: julia_env
|
| 2 |
+
version: "0.1.0"
|
| 3 |
+
description: "Julia code execution environment for OpenEnv with test tracking and reward calculation"
|
| 4 |
+
action: JuliaAction
|
| 5 |
+
observation: JuliaObservation
|
pyproject.toml
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
[build-system]
|
| 8 |
+
requires = ["setuptools>=45", "wheel"]
|
| 9 |
+
build-backend = "setuptools.build_meta"
|
| 10 |
+
|
| 11 |
+
[project]
|
| 12 |
+
name = "openenv-julia_env"
|
| 13 |
+
version = "0.1.0"
|
| 14 |
+
description = "Julia Environment for OpenEnv - Julia code execution with test tracking and reward calculation"
|
| 15 |
+
requires-python = ">=3.10"
|
| 16 |
+
dependencies = [
|
| 17 |
+
"openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git@main",
|
| 18 |
+
"fastapi>=0.115.0",
|
| 19 |
+
"pydantic>=2.0.0",
|
| 20 |
+
"uvicorn[standard]>=0.24.0",
|
| 21 |
+
"requests>=2.31.0",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
[project.optional-dependencies]
|
| 25 |
+
dev = [
|
| 26 |
+
"pytest>=8.0.0",
|
| 27 |
+
"pytest-cov>=4.0.0",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
[project.scripts]
|
| 31 |
+
server = "julia_env.server.app:main"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
[tool.setuptools]
|
| 35 |
+
packages = ["julia_env", "julia_env.server"]
|
| 36 |
+
package-dir = { "julia_env" = ".", "julia_env.server" = "server" }
|
| 37 |
+
|
| 38 |
+
[tool.setuptools.package-data]
|
| 39 |
+
julia_env = ["**/*.yaml", "**/*.yml", "**/*.jl"]
|
| 40 |
+
"julia_env.server" = ["*.jl"]
|
server/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""Julia Environment Server."""
|
| 8 |
+
|
| 9 |
+
# Support both in-repo and standalone imports
|
| 10 |
+
try:
|
| 11 |
+
from .julia_codeact_env import JuliaCodeActEnv
|
| 12 |
+
from .julia_transforms import create_safe_julia_transform
|
| 13 |
+
except ImportError:
|
| 14 |
+
from server.julia_codeact_env import JuliaCodeActEnv
|
| 15 |
+
from server.julia_transforms import create_safe_julia_transform
|
| 16 |
+
|
| 17 |
+
__all__ = ["JuliaCodeActEnv", "create_safe_julia_transform"]
|
server/app.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
FastAPI application for the Julia Environment with concurrent execution support.
|
| 9 |
+
|
| 10 |
+
This module creates an HTTP server that exposes the JuliaCodeActEnv
|
| 11 |
+
over HTTP and WebSocket endpoints with optimized async execution for handling
|
| 12 |
+
multiple concurrent requests efficiently.
|
| 13 |
+
|
| 14 |
+
Features:
|
| 15 |
+
- WebSocket support for persistent sessions (required by OpenEnv clients)
|
| 16 |
+
- Julia Process Pool for 50-100x speedup on repeated executions
|
| 17 |
+
- Automatic error recovery and retry logic
|
| 18 |
+
- Comprehensive logging to file and console
|
| 19 |
+
|
| 20 |
+
Environment Variables:
|
| 21 |
+
- JULIA_MAX_WORKERS: Number of concurrent Julia executions (default: 8)
|
| 22 |
+
- JULIA_EXECUTION_TIMEOUT: Timeout in seconds (default: 120)
|
| 23 |
+
- JULIA_LOG_FILE: Log file path (default: /tmp/julia_env.log)
|
| 24 |
+
- JULIA_LOG_LEVEL: Log level (default: INFO)
|
| 25 |
+
- ENABLE_WEB_INTERFACE: Enable web interface (default: false)
|
| 26 |
+
|
| 27 |
+
Usage:
|
| 28 |
+
# Development (with auto-reload):
|
| 29 |
+
uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
|
| 30 |
+
|
| 31 |
+
# Production:
|
| 32 |
+
uvicorn server.app:app --host 0.0.0.0 --port 8000
|
| 33 |
+
|
| 34 |
+
# Or run directly:
|
| 35 |
+
python -m server.app
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
import atexit
|
| 39 |
+
import logging
|
| 40 |
+
import os
|
| 41 |
+
import sys
|
| 42 |
+
from logging.handlers import RotatingFileHandler
|
| 43 |
+
|
| 44 |
+
# Support both in-repo and standalone imports
|
| 45 |
+
try:
|
| 46 |
+
# In-repo imports (when running from OpenEnv repository)
|
| 47 |
+
from openenv.core.env_server.http_server import create_app
|
| 48 |
+
from ..models import JuliaAction, JuliaObservation
|
| 49 |
+
from .julia_codeact_env import JuliaCodeActEnv
|
| 50 |
+
from .julia_executor import JuliaExecutor
|
| 51 |
+
except ImportError:
|
| 52 |
+
# Standalone imports (when environment is standalone)
|
| 53 |
+
from openenv.core.env_server.http_server import create_app
|
| 54 |
+
from models import JuliaAction, JuliaObservation
|
| 55 |
+
from server.julia_codeact_env import JuliaCodeActEnv
|
| 56 |
+
from server.julia_executor import JuliaExecutor
|
| 57 |
+
|
| 58 |
+
# Configuration from environment variables
|
| 59 |
+
MAX_WORKERS = int(os.getenv("JULIA_MAX_WORKERS", "8"))
|
| 60 |
+
EXECUTION_TIMEOUT = int(os.getenv("JULIA_EXECUTION_TIMEOUT", "120"))
|
| 61 |
+
LOG_FILE = os.getenv("JULIA_LOG_FILE", "/tmp/julia_env.log")
|
| 62 |
+
LOG_LEVEL = os.getenv("JULIA_LOG_LEVEL", "INFO")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def setup_logging():
|
| 66 |
+
"""Configure logging to both file and console with rotation."""
|
| 67 |
+
# Configure both julia_env and openenv hierarchies to share handlers
|
| 68 |
+
julia_logger = logging.getLogger("julia_env")
|
| 69 |
+
openenv_logger = logging.getLogger("openenv")
|
| 70 |
+
|
| 71 |
+
julia_logger.setLevel(getattr(logging, LOG_LEVEL))
|
| 72 |
+
openenv_logger.setLevel(getattr(logging, LOG_LEVEL))
|
| 73 |
+
|
| 74 |
+
# Prevent duplicate handlers
|
| 75 |
+
if julia_logger.handlers:
|
| 76 |
+
return julia_logger
|
| 77 |
+
|
| 78 |
+
# Create formatters
|
| 79 |
+
detailed_formatter = logging.Formatter(
|
| 80 |
+
"%(asctime)s - %(name)s - [%(process)d:%(thread)d] - %(levelname)s - %(message)s",
|
| 81 |
+
datefmt="%Y-%m-%d %H:%M:%S",
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# File handler with rotation (10MB max, keep 5 backup files)
|
| 85 |
+
try:
|
| 86 |
+
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
|
| 87 |
+
file_handler = RotatingFileHandler(
|
| 88 |
+
LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
|
| 89 |
+
)
|
| 90 |
+
file_handler.setLevel(logging.DEBUG)
|
| 91 |
+
file_handler.setFormatter(detailed_formatter)
|
| 92 |
+
julia_logger.addHandler(file_handler)
|
| 93 |
+
openenv_logger.addHandler(file_handler)
|
| 94 |
+
except Exception as e:
|
| 95 |
+
print(f"Warning: Could not create log file {LOG_FILE}: {e}")
|
| 96 |
+
|
| 97 |
+
# Console handler
|
| 98 |
+
console_handler = logging.StreamHandler(sys.stdout)
|
| 99 |
+
console_handler.setLevel(logging.INFO)
|
| 100 |
+
console_handler.setFormatter(detailed_formatter)
|
| 101 |
+
julia_logger.addHandler(console_handler)
|
| 102 |
+
openenv_logger.addHandler(console_handler)
|
| 103 |
+
|
| 104 |
+
return julia_logger
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# Setup logging
|
| 108 |
+
logger = setup_logging()
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def initialize_julia_pool():
|
| 112 |
+
"""Initialize the Julia process pool for better performance."""
|
| 113 |
+
port = int(os.getenv("PORT", "8000"))
|
| 114 |
+
|
| 115 |
+
logger.info("=" * 80)
|
| 116 |
+
logger.info("Starting Julia Environment Server")
|
| 117 |
+
logger.info(f"Container Port: {port}")
|
| 118 |
+
logger.info(f"Max Workers: {MAX_WORKERS}")
|
| 119 |
+
logger.info(f"Execution Timeout: {EXECUTION_TIMEOUT}s")
|
| 120 |
+
logger.info(f"Log File: {LOG_FILE}")
|
| 121 |
+
logger.info(f"Log Level: {LOG_LEVEL}")
|
| 122 |
+
logger.info("=" * 80)
|
| 123 |
+
|
| 124 |
+
# Enable Julia process pool for better performance
|
| 125 |
+
pool_enabled = JuliaExecutor.enable_process_pool(
|
| 126 |
+
size=MAX_WORKERS, timeout=EXECUTION_TIMEOUT
|
| 127 |
+
)
|
| 128 |
+
if pool_enabled:
|
| 129 |
+
logger.info(f"Julia process pool enabled with {MAX_WORKERS} workers")
|
| 130 |
+
else:
|
| 131 |
+
logger.warning("Julia process pool not available, using subprocess mode")
|
| 132 |
+
|
| 133 |
+
logger.info("Julia Environment Server initialized successfully")
|
| 134 |
+
print(f"Julia Environment Server started on port {port} with {MAX_WORKERS} concurrent workers")
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def shutdown_julia_pool():
|
| 138 |
+
"""Shutdown the Julia process pool."""
|
| 139 |
+
logger.info("Shutting down Julia Environment Server...")
|
| 140 |
+
JuliaExecutor.shutdown_pool()
|
| 141 |
+
logger.info("Julia process pool shutdown complete")
|
| 142 |
+
print("Julia Environment Server shutdown complete")
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# Initialize the pool at module load time
|
| 146 |
+
initialize_julia_pool()
|
| 147 |
+
|
| 148 |
+
# Register cleanup on exit
|
| 149 |
+
atexit.register(shutdown_julia_pool)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# Create the app using OpenEnv's create_app for WebSocket support
|
| 153 |
+
# Pass the class (factory) instead of an instance for session support
|
| 154 |
+
app = create_app(
|
| 155 |
+
JuliaCodeActEnv,
|
| 156 |
+
JuliaAction,
|
| 157 |
+
JuliaObservation,
|
| 158 |
+
env_name="julia_env",
|
| 159 |
+
max_concurrent_envs=MAX_WORKERS,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# Add request logging middleware
|
| 164 |
+
import time as time_module
|
| 165 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 166 |
+
from starlette.requests import Request
|
| 167 |
+
|
| 168 |
+
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
| 169 |
+
"""Log all incoming HTTP requests for debugging."""
|
| 170 |
+
|
| 171 |
+
async def dispatch(self, request: Request, call_next):
|
| 172 |
+
start_time = time_module.time()
|
| 173 |
+
path = request.url.path
|
| 174 |
+
|
| 175 |
+
# Skip health check logging to reduce noise
|
| 176 |
+
if path in ["/health", "/pool_status"]:
|
| 177 |
+
return await call_next(request)
|
| 178 |
+
|
| 179 |
+
logger.debug(f"HTTP Request: {request.method} {path}")
|
| 180 |
+
|
| 181 |
+
response = await call_next(request)
|
| 182 |
+
|
| 183 |
+
elapsed = time_module.time() - start_time
|
| 184 |
+
logger.debug(f"HTTP Response: {request.method} {path} -> {response.status_code} ({elapsed:.2f}s)")
|
| 185 |
+
|
| 186 |
+
return response
|
| 187 |
+
|
| 188 |
+
app.add_middleware(RequestLoggingMiddleware)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# Track active WebSocket connections
|
| 192 |
+
_active_ws_connections = 0
|
| 193 |
+
_total_ws_connections = 0
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
# Add custom health endpoint with pool metrics
|
| 197 |
+
@app.get("/pool_status")
|
| 198 |
+
async def pool_status():
|
| 199 |
+
"""Get Julia process pool status."""
|
| 200 |
+
return {
|
| 201 |
+
"max_workers": MAX_WORKERS,
|
| 202 |
+
"timeout": EXECUTION_TIMEOUT,
|
| 203 |
+
"pool_enabled": JuliaExecutor.is_pool_enabled(),
|
| 204 |
+
"pool_metrics": JuliaExecutor.get_pool_metrics(),
|
| 205 |
+
"active_ws_connections": _active_ws_connections,
|
| 206 |
+
"total_ws_connections": _total_ws_connections,
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def main():
|
| 211 |
+
"""Main entry point for running the server."""
|
| 212 |
+
import uvicorn
|
| 213 |
+
|
| 214 |
+
port = int(os.getenv("PORT", "8000"))
|
| 215 |
+
uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
if __name__ == "__main__":
|
| 219 |
+
main()
|
server/julia_codeact_env.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
Julia Code Action Environment.
|
| 9 |
+
|
| 10 |
+
This module provides a server-side environment implementation for executing
|
| 11 |
+
Julia code actions using JuliaExecutor.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import logging
|
| 15 |
+
import re
|
| 16 |
+
import time
|
| 17 |
+
import uuid
|
| 18 |
+
|
| 19 |
+
# Support both in-repo and standalone imports
|
| 20 |
+
try:
|
| 21 |
+
# In-repo imports (when running from OpenEnv repository)
|
| 22 |
+
from openenv.core.env_server.interfaces import Action, Environment, Observation
|
| 23 |
+
from ..models import JuliaAction, JuliaObservation, JuliaState
|
| 24 |
+
from .julia_executor import JuliaExecutor
|
| 25 |
+
from .julia_transforms import create_safe_julia_transform
|
| 26 |
+
except ImportError:
|
| 27 |
+
# Standalone imports (when environment is standalone)
|
| 28 |
+
from openenv.core.env_server.interfaces import Action, Environment, Observation
|
| 29 |
+
from models import JuliaAction, JuliaObservation, JuliaState
|
| 30 |
+
from server.julia_executor import JuliaExecutor
|
| 31 |
+
from server.julia_transforms import create_safe_julia_transform
|
| 32 |
+
|
| 33 |
+
# Get logger for this module (inherits from julia_env logger)
|
| 34 |
+
logger = logging.getLogger("julia_env.codeact")
|
| 35 |
+
|
| 36 |
+
# Request counter for tracking
|
| 37 |
+
_request_counter = 0
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _detect_infinite_loop(code: str) -> tuple[bool, str]:
|
| 41 |
+
"""
|
| 42 |
+
Detect potential infinite loops in Julia code.
|
| 43 |
+
|
| 44 |
+
This function scans for `while true` loops without break/return/error statements.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
code: Julia code string to analyze
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
Tuple of (has_infinite_loop: bool, reason: str)
|
| 51 |
+
"""
|
| 52 |
+
# Remove comments and strings to avoid false positives
|
| 53 |
+
# Remove single-line comments
|
| 54 |
+
code_without_comments = re.sub(r'#.*', '', code)
|
| 55 |
+
# Remove multi-line strings (triple quotes)
|
| 56 |
+
code_without_comments = re.sub(r'""".*?"""', '', code_without_comments, flags=re.DOTALL)
|
| 57 |
+
# Remove single-line strings
|
| 58 |
+
code_without_comments = re.sub(r'"[^"]*"', '', code_without_comments)
|
| 59 |
+
|
| 60 |
+
# Find all while true blocks
|
| 61 |
+
while_true_pattern = r'\bwhile\s+true\b'
|
| 62 |
+
while_true_matches = list(re.finditer(while_true_pattern, code_without_comments, re.IGNORECASE))
|
| 63 |
+
|
| 64 |
+
if not while_true_matches:
|
| 65 |
+
return False, ""
|
| 66 |
+
|
| 67 |
+
# For each while true, check if there's a break/return/error in the same block
|
| 68 |
+
for match in while_true_matches:
|
| 69 |
+
start_pos = match.end()
|
| 70 |
+
|
| 71 |
+
# Find the end of this while block by counting 'while'/'end' pairs
|
| 72 |
+
# Simplified heuristic: look for break/return/error before the corresponding 'end'
|
| 73 |
+
remaining_code = code_without_comments[start_pos:]
|
| 74 |
+
|
| 75 |
+
# Extract potential loop body (up to next 'end' keyword)
|
| 76 |
+
# This is a simplified check - doesn't perfectly handle nested blocks
|
| 77 |
+
end_match = re.search(r'\bend\b', remaining_code)
|
| 78 |
+
if end_match:
|
| 79 |
+
loop_body = remaining_code[:end_match.start()]
|
| 80 |
+
else:
|
| 81 |
+
loop_body = remaining_code
|
| 82 |
+
|
| 83 |
+
# Check for loop exit mechanisms in this block
|
| 84 |
+
has_break = re.search(r'\bbreak\b', loop_body) is not None
|
| 85 |
+
has_return = re.search(r'\breturn\b', loop_body) is not None
|
| 86 |
+
has_error = re.search(r'\berror\(', loop_body) is not None
|
| 87 |
+
has_throw = re.search(r'\bthrow\(', loop_body) is not None
|
| 88 |
+
has_exit = re.search(r'\bexit\(', loop_body) is not None
|
| 89 |
+
|
| 90 |
+
if not (has_break or has_return or has_error or has_throw or has_exit):
|
| 91 |
+
loop_preview = loop_body[:100].strip()
|
| 92 |
+
return True, f"Infinite loop detected: 'while true' without break/return/error/throw. Preview: {loop_preview}"
|
| 93 |
+
|
| 94 |
+
return False, ""
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class JuliaCodeActEnv(Environment):
|
| 98 |
+
"""
|
| 99 |
+
Julia Code Action Environment for executing code and tracking state.
|
| 100 |
+
|
| 101 |
+
This environment executes Julia code submitted as JuliaAction during step,
|
| 102 |
+
maintains the last exit code in its state, and returns results wrapped
|
| 103 |
+
in JuliaObservation.
|
| 104 |
+
|
| 105 |
+
Example:
|
| 106 |
+
>>> env = JuliaCodeActEnv()
|
| 107 |
+
>>> obs = env.reset()
|
| 108 |
+
>>> action = JuliaAction(core_code='println("Hello, Julia!")', test_code='')
|
| 109 |
+
>>> obs = env.step(action)
|
| 110 |
+
>>> print(obs.stdout) # "Hello, Julia!\\n"
|
| 111 |
+
>>> print(obs.exit_code) # 0
|
| 112 |
+
>>> print(env.state.last_exit_code) # 0
|
| 113 |
+
"""
|
| 114 |
+
|
| 115 |
+
# Allow concurrent sessions - each session has its own isolated state
|
| 116 |
+
SUPPORTS_CONCURRENT_SESSIONS = True
|
| 117 |
+
|
| 118 |
+
def __init__(self, use_process_pool: bool = True):
|
| 119 |
+
"""
|
| 120 |
+
Initialize the Julia Code Act Environment.
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
use_process_pool: Use persistent Julia process pool for better performance
|
| 124 |
+
and to avoid Juliaup lock contention (default: True)
|
| 125 |
+
"""
|
| 126 |
+
self._executor = JuliaExecutor(use_process_pool=use_process_pool)
|
| 127 |
+
self._state = JuliaState()
|
| 128 |
+
self.transform = create_safe_julia_transform()
|
| 129 |
+
|
| 130 |
+
def reset(self, **kwargs) -> Observation:
|
| 131 |
+
"""
|
| 132 |
+
Reset environment for a fresh Julia execution session.
|
| 133 |
+
Returns an empty JuliaObservation with exit_code=0.
|
| 134 |
+
|
| 135 |
+
Note: Executor is reused to leverage process pool.
|
| 136 |
+
"""
|
| 137 |
+
self._state = JuliaState(episode_id=str(uuid.uuid4()), step_count=0)
|
| 138 |
+
self._state.last_exit_code = 0
|
| 139 |
+
self._state.last_code_compiles = True
|
| 140 |
+
# Don't recreate executor - reuse it to leverage process pool
|
| 141 |
+
|
| 142 |
+
observation = JuliaObservation(
|
| 143 |
+
stdout="",
|
| 144 |
+
stderr="",
|
| 145 |
+
exit_code=0,
|
| 146 |
+
reward=0.0,
|
| 147 |
+
metadata={"core_code": "", "test_code": ""},
|
| 148 |
+
tests_passed=0,
|
| 149 |
+
tests_failed=0,
|
| 150 |
+
code_compiles=True,
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
observation = self._apply_transform(observation)
|
| 154 |
+
return observation
|
| 155 |
+
|
| 156 |
+
def step(self, action: Action, **kwargs) -> Observation:
|
| 157 |
+
"""
|
| 158 |
+
Execute Julia code and return the result as JuliaObservation.
|
| 159 |
+
|
| 160 |
+
Optimized single-pass execution:
|
| 161 |
+
- Runs core_code + test_code together
|
| 162 |
+
- Infers compilation status from combined execution
|
| 163 |
+
- 2x faster than double execution
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
action: JuliaAction with core_code and optional test_code
|
| 167 |
+
**kwargs: Optional parameters including:
|
| 168 |
+
- timeout: Execution timeout in seconds (default: 120)
|
| 169 |
+
"""
|
| 170 |
+
global _request_counter
|
| 171 |
+
_request_counter += 1
|
| 172 |
+
request_id = _request_counter
|
| 173 |
+
|
| 174 |
+
if not isinstance(action, JuliaAction):
|
| 175 |
+
logger.error(f"[REQ-{request_id}] Invalid action type: {type(action)}")
|
| 176 |
+
raise ValueError(f"Expected JuliaAction, got {type(action)}")
|
| 177 |
+
|
| 178 |
+
# Get timeout from kwargs (default handled by executor)
|
| 179 |
+
timeout = kwargs.get("timeout")
|
| 180 |
+
|
| 181 |
+
# Log request details
|
| 182 |
+
code_preview = action.core_code[:200] + "..." if len(action.core_code) > 200 else action.core_code
|
| 183 |
+
logger.info(f"[REQ-{request_id}] === NEW EXECUTION REQUEST ===")
|
| 184 |
+
logger.info(f"[REQ-{request_id}] Session: {self._state.episode_id}, Step: {self._state.step_count}")
|
| 185 |
+
logger.info(f"[REQ-{request_id}] Code length: {len(action.core_code)} chars, Test length: {len(action.test_code or '')} chars")
|
| 186 |
+
logger.debug(f"[REQ-{request_id}] Code preview: {code_preview}")
|
| 187 |
+
logger.info(f"[REQ-{request_id}] Timeout: {timeout}s" if timeout else f"[REQ-{request_id}] Timeout: default")
|
| 188 |
+
|
| 189 |
+
start_time = time.time()
|
| 190 |
+
|
| 191 |
+
# Single execution: Run core_code + test_code together (if test_code provided)
|
| 192 |
+
if action.test_code:
|
| 193 |
+
combined_code = action.core_code + "\n\n" + action.test_code
|
| 194 |
+
else:
|
| 195 |
+
combined_code = action.core_code
|
| 196 |
+
|
| 197 |
+
# Pre-execution check: detect infinite loops to avoid timeout
|
| 198 |
+
has_infinite_loop, loop_reason = _detect_infinite_loop(action.core_code)
|
| 199 |
+
if has_infinite_loop:
|
| 200 |
+
logger.warning(f"[REQ-{request_id}] INFINITE LOOP DETECTED: {loop_reason}")
|
| 201 |
+
|
| 202 |
+
# Update environment state
|
| 203 |
+
self._state.step_count += 1
|
| 204 |
+
self._state.last_exit_code = 1
|
| 205 |
+
self._state.last_code_compiles = True # Code compiles but has infinite loop
|
| 206 |
+
self._state.total_tests_passed = 0
|
| 207 |
+
self._state.total_tests_failed = 0
|
| 208 |
+
|
| 209 |
+
# Build observation with penalty
|
| 210 |
+
observation = JuliaObservation(
|
| 211 |
+
stdout="",
|
| 212 |
+
stderr=f"Infinite loop detected (pre-execution check): {loop_reason}",
|
| 213 |
+
exit_code=1,
|
| 214 |
+
reward=-1.0, # Penalize infinite loops
|
| 215 |
+
metadata={
|
| 216 |
+
"core_code": action.core_code,
|
| 217 |
+
"test_code": action.test_code or "",
|
| 218 |
+
"infinite_loop_detected": True,
|
| 219 |
+
"infinite_loop_reason": loop_reason,
|
| 220 |
+
},
|
| 221 |
+
tests_passed=0,
|
| 222 |
+
tests_failed=0,
|
| 223 |
+
code_compiles=True, # Code would compile, but not run
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
logger.info(
|
| 227 |
+
f"[REQ-{request_id}] RESULT: infinite_loop=True, "
|
| 228 |
+
f"tests_passed=0, tests_failed=0, reward=-1.00"
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
observation = self._apply_transform(observation)
|
| 232 |
+
return observation
|
| 233 |
+
|
| 234 |
+
try:
|
| 235 |
+
full_result = self._executor.run(combined_code, timeout=timeout)
|
| 236 |
+
execution_time = time.time() - start_time
|
| 237 |
+
|
| 238 |
+
logger.info(f"[REQ-{request_id}] Execution completed in {execution_time:.2f}s, exit_code={full_result.exit_code}")
|
| 239 |
+
|
| 240 |
+
# Log stderr if present (often contains errors or test output)
|
| 241 |
+
if full_result.stderr:
|
| 242 |
+
stderr_preview = full_result.stderr[:500] + "..." if len(full_result.stderr) > 500 else full_result.stderr
|
| 243 |
+
logger.debug(f"[REQ-{request_id}] Stderr: {stderr_preview}")
|
| 244 |
+
|
| 245 |
+
except Exception as e:
|
| 246 |
+
execution_time = time.time() - start_time
|
| 247 |
+
logger.error(f"[REQ-{request_id}] EXECUTION FAILED after {execution_time:.2f}s: {e}")
|
| 248 |
+
raise
|
| 249 |
+
|
| 250 |
+
# Parse test results from execution output
|
| 251 |
+
tests_passed, tests_failed = self._parse_test_results(
|
| 252 |
+
full_result.stdout, full_result.stderr
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
# Infer compilation status from execution
|
| 256 |
+
# If tests ran, code compiled successfully
|
| 257 |
+
# If exit_code != 0 and no tests ran, code didn't compile
|
| 258 |
+
code_compiles = (
|
| 259 |
+
full_result.exit_code == 0 # Clean execution
|
| 260 |
+
or tests_passed > 0 # Some tests passed (code must have compiled)
|
| 261 |
+
or tests_failed > 0 # Some tests failed (code compiled but tests failed)
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
# If no tests detected and non-zero exit, check for compilation errors
|
| 265 |
+
if not code_compiles and tests_passed == 0 and tests_failed == 0:
|
| 266 |
+
# Check stderr for compilation errors
|
| 267 |
+
stderr_lower = full_result.stderr.lower()
|
| 268 |
+
if any(
|
| 269 |
+
err in stderr_lower
|
| 270 |
+
for err in ["error", "syntax", "undefined", "loadError"]
|
| 271 |
+
):
|
| 272 |
+
code_compiles = False
|
| 273 |
+
else:
|
| 274 |
+
# If no clear compilation error, assume it compiled
|
| 275 |
+
code_compiles = True
|
| 276 |
+
|
| 277 |
+
# Calculate reward based on compilation and test results
|
| 278 |
+
reward = self._calculate_reward(code_compiles, tests_passed, tests_failed)
|
| 279 |
+
|
| 280 |
+
# Log final results
|
| 281 |
+
logger.info(
|
| 282 |
+
f"[REQ-{request_id}] RESULT: compiles={code_compiles}, "
|
| 283 |
+
f"tests_passed={tests_passed}, tests_failed={tests_failed}, reward={reward:.2f}"
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
# Update environment state
|
| 287 |
+
self._state.step_count += 1
|
| 288 |
+
self._state.last_exit_code = full_result.exit_code
|
| 289 |
+
self._state.last_code_compiles = code_compiles
|
| 290 |
+
self._state.total_tests_passed = tests_passed
|
| 291 |
+
self._state.total_tests_failed = tests_failed
|
| 292 |
+
|
| 293 |
+
# Build observation
|
| 294 |
+
observation = JuliaObservation(
|
| 295 |
+
stdout=full_result.stdout,
|
| 296 |
+
stderr=full_result.stderr,
|
| 297 |
+
exit_code=full_result.exit_code,
|
| 298 |
+
reward=reward,
|
| 299 |
+
metadata={
|
| 300 |
+
"core_code": action.core_code,
|
| 301 |
+
"test_code": action.test_code or "",
|
| 302 |
+
},
|
| 303 |
+
tests_passed=tests_passed,
|
| 304 |
+
tests_failed=tests_failed,
|
| 305 |
+
code_compiles=code_compiles,
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
# Apply safety and quality transforms
|
| 309 |
+
observation = self._apply_transform(observation)
|
| 310 |
+
|
| 311 |
+
return observation
|
| 312 |
+
|
| 313 |
+
def _parse_test_results(self, stdout: str, stderr: str) -> tuple[int, int]:
|
| 314 |
+
"""
|
| 315 |
+
Parse Julia test output to count passed/failed tests.
|
| 316 |
+
|
| 317 |
+
Julia's Test module outputs results like:
|
| 318 |
+
"Test Summary: | Pass Fail Total Time"
|
| 319 |
+
"Add function Tests | 1 1 2 1.5s"
|
| 320 |
+
|
| 321 |
+
Also checks error messages:
|
| 322 |
+
"Some tests did not pass: 1 passed, 1 failed, 0 errored, 0 broken."
|
| 323 |
+
|
| 324 |
+
Args:
|
| 325 |
+
stdout: Standard output from Julia execution
|
| 326 |
+
stderr: Standard error from Julia execution
|
| 327 |
+
|
| 328 |
+
Returns:
|
| 329 |
+
Tuple of (tests_passed, tests_failed)
|
| 330 |
+
"""
|
| 331 |
+
# Combine stdout and stderr for analysis
|
| 332 |
+
passed = 0
|
| 333 |
+
failed = 0
|
| 334 |
+
output = stdout + "\n" + stderr
|
| 335 |
+
|
| 336 |
+
# Method 1: Look for "Some tests did not pass" error message
|
| 337 |
+
# Pattern: "Some tests did not pass: X passed, Y failed, Z errored, W broken."
|
| 338 |
+
error_pattern = r"Some tests did not pass:\s*(\d+)\s+passed,\s*(\d+)\s+failed,\s*(\d+)\s+errored"
|
| 339 |
+
match = re.search(error_pattern, output)
|
| 340 |
+
|
| 341 |
+
if match:
|
| 342 |
+
passed = int(match.group(1))
|
| 343 |
+
failed = int(match.group(2))
|
| 344 |
+
errored = int(match.group(3))
|
| 345 |
+
return passed, failed + errored # Treat errors as failures
|
| 346 |
+
|
| 347 |
+
# Method 2: Look for Test Summary table
|
| 348 |
+
# Multiple possible formats:
|
| 349 |
+
# All pass: "Test Summary: | Pass Total Time"
|
| 350 |
+
# "My Tests | 3 3 0.5s"
|
| 351 |
+
# Some fail: "Test Summary: | Pass Fail Total Time"
|
| 352 |
+
# "My Tests | 2 1 3 0.5s"
|
| 353 |
+
# All error: "Test Summary: | Error Total Time"
|
| 354 |
+
# "My Tests | 3 3 0.9s"
|
| 355 |
+
# Mixed: "Test Summary: | Pass Fail Error Total Time"
|
| 356 |
+
# "My Tests | 1 1 1 3 0.5s"
|
| 357 |
+
summary_lines = output.split("\n")
|
| 358 |
+
for i, line in enumerate(summary_lines):
|
| 359 |
+
if "Test Summary:" in line and i + 1 < len(summary_lines):
|
| 360 |
+
header_line = line
|
| 361 |
+
next_line = summary_lines[i + 1]
|
| 362 |
+
|
| 363 |
+
# Determine which columns are present
|
| 364 |
+
has_pass = "Pass" in header_line
|
| 365 |
+
has_fail = "Fail" in header_line
|
| 366 |
+
has_error = "Error" in header_line
|
| 367 |
+
|
| 368 |
+
# Extract all numbers from the line
|
| 369 |
+
all_numbers = re.findall(r"\d+", next_line)
|
| 370 |
+
if not all_numbers:
|
| 371 |
+
continue
|
| 372 |
+
|
| 373 |
+
# Last number is always Total, second to last is Time (skip it)
|
| 374 |
+
# Extract based on which columns exist
|
| 375 |
+
if has_pass and has_fail and has_error:
|
| 376 |
+
# Pass Fail Error Total Time
|
| 377 |
+
if len(all_numbers) >= 5:
|
| 378 |
+
passed = int(all_numbers[0])
|
| 379 |
+
failed = int(all_numbers[1]) + int(
|
| 380 |
+
all_numbers[2]
|
| 381 |
+
) # Fail + Error
|
| 382 |
+
return passed, failed
|
| 383 |
+
elif has_pass and has_fail:
|
| 384 |
+
# Pass Fail Total Time
|
| 385 |
+
if len(all_numbers) >= 4:
|
| 386 |
+
passed = int(all_numbers[0])
|
| 387 |
+
failed = int(all_numbers[1])
|
| 388 |
+
return passed, failed
|
| 389 |
+
elif has_pass and has_error:
|
| 390 |
+
# Pass Error Total Time
|
| 391 |
+
if len(all_numbers) >= 4:
|
| 392 |
+
passed = int(all_numbers[0])
|
| 393 |
+
failed = int(all_numbers[1]) # Treat errors as failures
|
| 394 |
+
return passed, failed
|
| 395 |
+
elif has_fail and has_error:
|
| 396 |
+
# Fail Error Total Time (no passes)
|
| 397 |
+
if len(all_numbers) >= 4:
|
| 398 |
+
passed = 0
|
| 399 |
+
failed = int(all_numbers[0]) + int(all_numbers[1])
|
| 400 |
+
return passed, failed
|
| 401 |
+
elif has_pass:
|
| 402 |
+
# Pass Total Time (no failures/errors)
|
| 403 |
+
if len(all_numbers) >= 3:
|
| 404 |
+
passed = int(all_numbers[0])
|
| 405 |
+
failed = 0
|
| 406 |
+
return passed, failed
|
| 407 |
+
elif has_error:
|
| 408 |
+
# Error Total Time (all errors, no passes)
|
| 409 |
+
if len(all_numbers) >= 3:
|
| 410 |
+
passed = 0
|
| 411 |
+
failed = int(all_numbers[0]) # Treat all errors as failures
|
| 412 |
+
return passed, failed
|
| 413 |
+
elif has_fail:
|
| 414 |
+
# Fail Total Time (all failures, no passes)
|
| 415 |
+
if len(all_numbers) >= 3:
|
| 416 |
+
passed = 0
|
| 417 |
+
failed = int(all_numbers[0])
|
| 418 |
+
return passed, failed
|
| 419 |
+
|
| 420 |
+
return passed, failed
|
| 421 |
+
|
| 422 |
+
def _calculate_reward(
|
| 423 |
+
self, code_compiles: bool, tests_passed: int, tests_failed: int
|
| 424 |
+
) -> float:
|
| 425 |
+
"""
|
| 426 |
+
Normalized percentage-based reward for Julia GRPO.
|
| 427 |
+
Returns rewards in [-1, 1.5] range for comparability across problems.
|
| 428 |
+
"""
|
| 429 |
+
if not code_compiles:
|
| 430 |
+
return -1.0
|
| 431 |
+
|
| 432 |
+
total_tests = tests_passed + tests_failed
|
| 433 |
+
if total_tests == 0:
|
| 434 |
+
return 0.0 # No signal when no tests run
|
| 435 |
+
|
| 436 |
+
pass_rate = tests_passed / total_tests
|
| 437 |
+
|
| 438 |
+
# Scaled 0-1 with bonus for perfection
|
| 439 |
+
if pass_rate == 1.0:
|
| 440 |
+
return 1.5 # Bonus for passing all tests
|
| 441 |
+
return pass_rate
|
| 442 |
+
|
| 443 |
+
def _apply_transform(self, observation: JuliaObservation) -> JuliaObservation:
|
| 444 |
+
"""Apply safety and quality transforms to observation."""
|
| 445 |
+
if self.transform:
|
| 446 |
+
observation = self.transform(observation)
|
| 447 |
+
return observation
|
| 448 |
+
|
| 449 |
+
@property
|
| 450 |
+
def state(self) -> JuliaState:
|
| 451 |
+
"""Return current environment state."""
|
| 452 |
+
return self._state
|
server/julia_executor.py
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
Local Julia Executor with Process Pool Support.
|
| 9 |
+
|
| 10 |
+
This module provides a Julia code executor with:
|
| 11 |
+
- Proper process cleanup on timeout (no zombie processes)
|
| 12 |
+
- Robust error handling and logging
|
| 13 |
+
- Process group management for complete cleanup
|
| 14 |
+
- Automatic retry on transient failures
|
| 15 |
+
- Optional process pool for 50-100x speedup on repeated executions
|
| 16 |
+
|
| 17 |
+
Performance Modes:
|
| 18 |
+
- Standard mode: Spawn new process for each execution (default for single executions)
|
| 19 |
+
- Pool mode: Reuse persistent Julia processes (recommended for repeated executions)
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import logging
|
| 25 |
+
import os
|
| 26 |
+
import shutil
|
| 27 |
+
import signal
|
| 28 |
+
import subprocess
|
| 29 |
+
import tempfile
|
| 30 |
+
import threading
|
| 31 |
+
import time
|
| 32 |
+
from dataclasses import dataclass
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
from typing import Optional
|
| 35 |
+
|
| 36 |
+
# Use julia_env hierarchy to inherit handlers from app.py's setup_logging()
|
| 37 |
+
logger = logging.getLogger("julia_env.executor")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class CodeExecResult:
|
| 42 |
+
"""Result of code execution."""
|
| 43 |
+
|
| 44 |
+
stdout: str
|
| 45 |
+
stderr: str
|
| 46 |
+
exit_code: int
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# Try to import process pool (optional dependency)
|
| 50 |
+
try:
|
| 51 |
+
from .julia_process_pool import JuliaProcessPool
|
| 52 |
+
|
| 53 |
+
POOL_AVAILABLE = True
|
| 54 |
+
except ImportError:
|
| 55 |
+
POOL_AVAILABLE = False
|
| 56 |
+
JuliaProcessPool = None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class JuliaExecutor:
|
| 60 |
+
"""
|
| 61 |
+
Executor for running Julia code with robust process management.
|
| 62 |
+
|
| 63 |
+
This class provides a safe interface to execute Julia code in isolation
|
| 64 |
+
and capture the results including stdout, stderr, and exit code.
|
| 65 |
+
|
| 66 |
+
Features:
|
| 67 |
+
- Proper timeout handling without zombie processes
|
| 68 |
+
- Process group cleanup for nested processes
|
| 69 |
+
- Automatic retry on transient failures
|
| 70 |
+
- Comprehensive logging for debugging
|
| 71 |
+
- Optional process pool for 50-100x speedup on repeated executions
|
| 72 |
+
|
| 73 |
+
Example:
|
| 74 |
+
>>> executor = JuliaExecutor()
|
| 75 |
+
>>> result = executor.run('println("Hello, Julia!")')
|
| 76 |
+
>>> print(result.stdout) # "Hello, Julia!\\n"
|
| 77 |
+
>>> print(result.exit_code) # 0
|
| 78 |
+
>>>
|
| 79 |
+
>>> # With process pool (recommended for repeated executions)
|
| 80 |
+
>>> JuliaExecutor.enable_process_pool(size=4)
|
| 81 |
+
>>> executor = JuliaExecutor(use_process_pool=True)
|
| 82 |
+
>>> for i in range(100):
|
| 83 |
+
... result = executor.run(f'println({i})') # 50-100x faster!
|
| 84 |
+
>>> JuliaExecutor.shutdown_pool() # Clean up when done
|
| 85 |
+
"""
|
| 86 |
+
|
| 87 |
+
# Class-level process pool (shared across all instances if enabled)
|
| 88 |
+
_shared_pool: Optional["JuliaProcessPool"] = None
|
| 89 |
+
_pool_lock = threading.Lock()
|
| 90 |
+
_pool_size: int = 0
|
| 91 |
+
_pool_timeout: int = 120
|
| 92 |
+
|
| 93 |
+
def __init__(
|
| 94 |
+
self,
|
| 95 |
+
timeout: Optional[int] = None,
|
| 96 |
+
max_retries: int = 0,
|
| 97 |
+
use_optimization_flags: bool = True,
|
| 98 |
+
use_process_pool: bool = True,
|
| 99 |
+
):
|
| 100 |
+
"""
|
| 101 |
+
Initialize the JuliaExecutor.
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
timeout: Maximum execution time in seconds. If None, reads from
|
| 105 |
+
JULIA_EXECUTION_TIMEOUT env var (default: 120 if not set)
|
| 106 |
+
max_retries: Number of retry attempts on transient failures (default: 0)
|
| 107 |
+
use_optimization_flags: Enable Julia performance flags (default: True)
|
| 108 |
+
use_process_pool: Use process pool if available (default: True)
|
| 109 |
+
|
| 110 |
+
Raises:
|
| 111 |
+
RuntimeError: If Julia executable is not found in PATH
|
| 112 |
+
"""
|
| 113 |
+
# Read timeout from env var if not explicitly provided
|
| 114 |
+
if timeout is None:
|
| 115 |
+
timeout = int(os.getenv("JULIA_EXECUTION_TIMEOUT", "120"))
|
| 116 |
+
logger.debug(f"Executor timeout from JULIA_EXECUTION_TIMEOUT env var: {timeout}s")
|
| 117 |
+
self.timeout = timeout
|
| 118 |
+
self.max_retries = max_retries
|
| 119 |
+
self.use_optimization_flags = use_optimization_flags
|
| 120 |
+
self._use_process_pool = use_process_pool
|
| 121 |
+
|
| 122 |
+
# Find Julia executable in PATH
|
| 123 |
+
self.julia_path = shutil.which("julia")
|
| 124 |
+
|
| 125 |
+
if not self.julia_path:
|
| 126 |
+
# Try common installation paths
|
| 127 |
+
common_paths = [
|
| 128 |
+
os.path.expanduser("~/.juliaup/bin/julia"),
|
| 129 |
+
os.path.expanduser("~/.julia/bin/julia"),
|
| 130 |
+
"/usr/local/bin/julia",
|
| 131 |
+
"/usr/bin/julia",
|
| 132 |
+
]
|
| 133 |
+
|
| 134 |
+
for path in common_paths:
|
| 135 |
+
if os.path.isfile(path) and os.access(path, os.X_OK):
|
| 136 |
+
self.julia_path = path
|
| 137 |
+
break
|
| 138 |
+
|
| 139 |
+
if not self.julia_path:
|
| 140 |
+
logger.warning(
|
| 141 |
+
"Julia executable not found in PATH or common locations. "
|
| 142 |
+
"Please install Julia: https://julialang.org/downloads/"
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# Build optimized Julia command with performance flags
|
| 146 |
+
self.base_cmd = [self.julia_path] if self.julia_path else ["julia"]
|
| 147 |
+
|
| 148 |
+
if self.use_optimization_flags:
|
| 149 |
+
self.base_cmd.extend(
|
| 150 |
+
[
|
| 151 |
+
"--compile=min",
|
| 152 |
+
"--optimize=2",
|
| 153 |
+
"--startup-file=no",
|
| 154 |
+
"--history-file=no",
|
| 155 |
+
]
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
logger.debug(f"JuliaExecutor initialized with Julia at: {self.julia_path}")
|
| 159 |
+
logger.debug(f"Timeout: {self.timeout}s, Max retries: {self.max_retries}")
|
| 160 |
+
|
| 161 |
+
def _kill_process_tree(
|
| 162 |
+
self, proc: subprocess.Popen, script_file: Optional[str] = None
|
| 163 |
+
) -> None:
|
| 164 |
+
"""
|
| 165 |
+
Terminate a process and all its children.
|
| 166 |
+
|
| 167 |
+
This prevents zombie processes by ensuring complete cleanup.
|
| 168 |
+
|
| 169 |
+
Args:
|
| 170 |
+
proc: The subprocess.Popen instance to terminate
|
| 171 |
+
script_file: Optional script file path (for logging)
|
| 172 |
+
"""
|
| 173 |
+
if proc.poll() is None: # Process is still running
|
| 174 |
+
try:
|
| 175 |
+
# Try graceful termination first
|
| 176 |
+
logger.warning(f"Terminating process {proc.pid} gracefully...")
|
| 177 |
+
proc.terminate()
|
| 178 |
+
|
| 179 |
+
# Wait up to 2 seconds for graceful termination
|
| 180 |
+
try:
|
| 181 |
+
proc.wait(timeout=2.0)
|
| 182 |
+
logger.debug(f"Process {proc.pid} terminated gracefully")
|
| 183 |
+
return
|
| 184 |
+
except subprocess.TimeoutExpired:
|
| 185 |
+
logger.warning(
|
| 186 |
+
f"Process {proc.pid} did not terminate, forcing kill..."
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
# Force kill if still running
|
| 190 |
+
proc.kill()
|
| 191 |
+
try:
|
| 192 |
+
proc.wait(timeout=2.0)
|
| 193 |
+
logger.debug(f"Process {proc.pid} killed forcefully")
|
| 194 |
+
except subprocess.TimeoutExpired:
|
| 195 |
+
pass
|
| 196 |
+
|
| 197 |
+
except Exception as e:
|
| 198 |
+
logger.error(f"Error killing process {proc.pid}: {e}")
|
| 199 |
+
|
| 200 |
+
# Last resort: try killing via process group
|
| 201 |
+
try:
|
| 202 |
+
if hasattr(os, "killpg"):
|
| 203 |
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
| 204 |
+
logger.debug(f"Killed process group for {proc.pid}")
|
| 205 |
+
except Exception as pg_error:
|
| 206 |
+
logger.error(f"Failed to kill process group: {pg_error}")
|
| 207 |
+
|
| 208 |
+
def run(self, code: str, timeout: Optional[int] = None) -> CodeExecResult:
|
| 209 |
+
"""
|
| 210 |
+
Execute Julia code and return the result with robust error handling.
|
| 211 |
+
|
| 212 |
+
This method provides:
|
| 213 |
+
- Automatic retry on transient failures
|
| 214 |
+
- Proper timeout handling without zombie processes
|
| 215 |
+
- Process group cleanup for nested processes
|
| 216 |
+
- Comprehensive error logging
|
| 217 |
+
- Optional process pool for 50-100x speedup
|
| 218 |
+
|
| 219 |
+
Args:
|
| 220 |
+
code: Julia code string to execute
|
| 221 |
+
timeout: Override default timeout (seconds). If None, uses pool's
|
| 222 |
+
configured timeout (when using pool) or instance timeout.
|
| 223 |
+
|
| 224 |
+
Returns:
|
| 225 |
+
CodeExecResult containing stdout, stderr, and exit_code
|
| 226 |
+
"""
|
| 227 |
+
# Use process pool if enabled and available
|
| 228 |
+
# Pass timeout as-is (None means use pool's configured default)
|
| 229 |
+
if self._use_process_pool and JuliaExecutor._shared_pool is not None:
|
| 230 |
+
try:
|
| 231 |
+
return JuliaExecutor._shared_pool.execute(code, timeout=timeout)
|
| 232 |
+
except Exception as e:
|
| 233 |
+
logger.warning(
|
| 234 |
+
f"Process pool execution failed: {e}, falling back to subprocess"
|
| 235 |
+
)
|
| 236 |
+
# Fall through to standard execution
|
| 237 |
+
|
| 238 |
+
# For subprocess fallback, apply instance default if timeout not specified
|
| 239 |
+
if timeout is None:
|
| 240 |
+
timeout = self.timeout
|
| 241 |
+
|
| 242 |
+
# Check if Julia is available
|
| 243 |
+
if not self.julia_path:
|
| 244 |
+
return CodeExecResult(
|
| 245 |
+
stdout="",
|
| 246 |
+
stderr="Julia not found in PATH. Please install Julia.",
|
| 247 |
+
exit_code=127,
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
code_file = None
|
| 251 |
+
|
| 252 |
+
for attempt in range(self.max_retries + 1):
|
| 253 |
+
proc = None
|
| 254 |
+
|
| 255 |
+
try:
|
| 256 |
+
# Create temporary file for Julia code
|
| 257 |
+
with tempfile.NamedTemporaryFile(
|
| 258 |
+
mode="w", suffix=".jl", delete=False, encoding="utf-8"
|
| 259 |
+
) as f:
|
| 260 |
+
f.write(code)
|
| 261 |
+
code_file = f.name
|
| 262 |
+
|
| 263 |
+
script_name = Path(code_file).name
|
| 264 |
+
logger.debug(
|
| 265 |
+
f"[Attempt {attempt + 1}/{self.max_retries + 1}] Executing: {script_name}"
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
# Start process with Popen for better control
|
| 269 |
+
start_time = time.time()
|
| 270 |
+
|
| 271 |
+
# On Unix systems, use process groups for better cleanup
|
| 272 |
+
kwargs = {
|
| 273 |
+
"stdout": subprocess.PIPE,
|
| 274 |
+
"stderr": subprocess.PIPE,
|
| 275 |
+
"text": True,
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
# Create new process group on Unix systems
|
| 279 |
+
if hasattr(os, "setpgrp"):
|
| 280 |
+
kwargs["preexec_fn"] = os.setpgrp
|
| 281 |
+
|
| 282 |
+
proc = subprocess.Popen(self.base_cmd + [code_file], **kwargs)
|
| 283 |
+
|
| 284 |
+
logger.debug(f"Started Julia process {proc.pid}")
|
| 285 |
+
|
| 286 |
+
# Wait for process with timeout
|
| 287 |
+
try:
|
| 288 |
+
stdout, stderr = proc.communicate(timeout=timeout)
|
| 289 |
+
exit_code = proc.returncode
|
| 290 |
+
elapsed = time.time() - start_time
|
| 291 |
+
|
| 292 |
+
logger.debug(
|
| 293 |
+
f"Julia execution completed in {elapsed:.2f}s (exit: {exit_code})"
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
# Clean up temp file
|
| 297 |
+
self._cleanup_temp_file(code_file)
|
| 298 |
+
|
| 299 |
+
return CodeExecResult(
|
| 300 |
+
stdout=stdout,
|
| 301 |
+
stderr=stderr,
|
| 302 |
+
exit_code=exit_code,
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
except subprocess.TimeoutExpired:
|
| 306 |
+
logger.error(
|
| 307 |
+
f"Julia execution timed out after {timeout}s "
|
| 308 |
+
f"(attempt {attempt + 1}/{self.max_retries + 1})"
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
# CRITICAL: Kill the process AND all its children
|
| 312 |
+
self._kill_process_tree(proc, code_file)
|
| 313 |
+
|
| 314 |
+
# If this was our last retry, return timeout error
|
| 315 |
+
if attempt >= self.max_retries:
|
| 316 |
+
self._cleanup_temp_file(code_file)
|
| 317 |
+
return CodeExecResult(
|
| 318 |
+
stdout="",
|
| 319 |
+
stderr=f"Execution timed out after {timeout}s",
|
| 320 |
+
exit_code=124, # Standard timeout exit code
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
# Wait before retry
|
| 324 |
+
time.sleep(1.0)
|
| 325 |
+
continue
|
| 326 |
+
|
| 327 |
+
except FileNotFoundError:
|
| 328 |
+
logger.error(f"Julia executable not found at {self.julia_path}")
|
| 329 |
+
return CodeExecResult(
|
| 330 |
+
stdout="",
|
| 331 |
+
stderr=f"Julia executable not found: {self.julia_path}",
|
| 332 |
+
exit_code=127,
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
except Exception as e:
|
| 336 |
+
logger.error(
|
| 337 |
+
f"Error executing Julia (attempt {attempt + 1}/{self.max_retries + 1}): {e}"
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
# Try to kill process if it exists
|
| 341 |
+
if proc is not None and proc.poll() is None:
|
| 342 |
+
self._kill_process_tree(proc, code_file)
|
| 343 |
+
|
| 344 |
+
# If this was our last retry, return error
|
| 345 |
+
if attempt >= self.max_retries:
|
| 346 |
+
self._cleanup_temp_file(code_file)
|
| 347 |
+
return CodeExecResult(
|
| 348 |
+
stdout="",
|
| 349 |
+
stderr=f"Error executing Julia code: {str(e)}",
|
| 350 |
+
exit_code=1,
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
# Wait before retry
|
| 354 |
+
time.sleep(1.0)
|
| 355 |
+
continue
|
| 356 |
+
|
| 357 |
+
finally:
|
| 358 |
+
# Always ensure temp file is cleaned up
|
| 359 |
+
self._cleanup_temp_file(code_file)
|
| 360 |
+
|
| 361 |
+
# Should never reach here
|
| 362 |
+
return CodeExecResult(
|
| 363 |
+
stdout="",
|
| 364 |
+
stderr="Unexpected error: all retries exhausted",
|
| 365 |
+
exit_code=1,
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
def _cleanup_temp_file(self, code_file: Optional[str]) -> None:
|
| 369 |
+
"""Clean up temporary file safely."""
|
| 370 |
+
if code_file and Path(code_file).exists():
|
| 371 |
+
try:
|
| 372 |
+
Path(code_file).unlink()
|
| 373 |
+
except Exception as e:
|
| 374 |
+
logger.debug(f"Could not delete temp file {code_file}: {e}")
|
| 375 |
+
|
| 376 |
+
@staticmethod
|
| 377 |
+
def enable_process_pool(size: int = 4, timeout: Optional[int] = None) -> bool:
|
| 378 |
+
"""
|
| 379 |
+
Enable the shared Julia process pool for all JuliaExecutor instances.
|
| 380 |
+
|
| 381 |
+
This provides 50-100x speedup for repeated code executions by reusing
|
| 382 |
+
persistent Julia processes instead of spawning new ones.
|
| 383 |
+
|
| 384 |
+
Args:
|
| 385 |
+
size: Number of worker processes to create (default: 4)
|
| 386 |
+
timeout: Default timeout for code execution in seconds.
|
| 387 |
+
If None, reads from JULIA_EXECUTION_TIMEOUT env var (default: 120)
|
| 388 |
+
|
| 389 |
+
Returns:
|
| 390 |
+
True if pool was created successfully, False otherwise
|
| 391 |
+
"""
|
| 392 |
+
if not POOL_AVAILABLE:
|
| 393 |
+
logger.warning(
|
| 394 |
+
"Process pool not available (julia_process_pool module not found). "
|
| 395 |
+
"Falling back to subprocess execution."
|
| 396 |
+
)
|
| 397 |
+
return False
|
| 398 |
+
|
| 399 |
+
# Read timeout from env var if not explicitly provided
|
| 400 |
+
if timeout is None:
|
| 401 |
+
timeout = int(os.getenv("JULIA_EXECUTION_TIMEOUT", "120"))
|
| 402 |
+
|
| 403 |
+
with JuliaExecutor._pool_lock:
|
| 404 |
+
if JuliaExecutor._shared_pool is not None:
|
| 405 |
+
logger.debug("Process pool already enabled")
|
| 406 |
+
return True
|
| 407 |
+
|
| 408 |
+
try:
|
| 409 |
+
logger.info(f"Enabling Julia process pool with {size} workers")
|
| 410 |
+
JuliaExecutor._shared_pool = JuliaProcessPool(size=size, timeout=timeout)
|
| 411 |
+
JuliaExecutor._pool_size = size
|
| 412 |
+
JuliaExecutor._pool_timeout = timeout
|
| 413 |
+
logger.info("Julia process pool enabled successfully")
|
| 414 |
+
return True
|
| 415 |
+
except Exception as e:
|
| 416 |
+
logger.error(f"Failed to enable process pool: {e}")
|
| 417 |
+
return False
|
| 418 |
+
|
| 419 |
+
@staticmethod
|
| 420 |
+
def shutdown_pool() -> None:
|
| 421 |
+
"""
|
| 422 |
+
Shutdown the shared Julia process pool.
|
| 423 |
+
|
| 424 |
+
This should be called when you're done with all Julia executions
|
| 425 |
+
to properly clean up worker processes.
|
| 426 |
+
"""
|
| 427 |
+
with JuliaExecutor._pool_lock:
|
| 428 |
+
if JuliaExecutor._shared_pool is not None:
|
| 429 |
+
logger.info("Shutting down Julia process pool")
|
| 430 |
+
try:
|
| 431 |
+
JuliaExecutor._shared_pool.shutdown()
|
| 432 |
+
except Exception as e:
|
| 433 |
+
logger.error(f"Error shutting down pool: {e}")
|
| 434 |
+
finally:
|
| 435 |
+
JuliaExecutor._shared_pool = None
|
| 436 |
+
|
| 437 |
+
@staticmethod
|
| 438 |
+
def is_pool_enabled() -> bool:
|
| 439 |
+
"""Check if the process pool is currently enabled."""
|
| 440 |
+
with JuliaExecutor._pool_lock:
|
| 441 |
+
return JuliaExecutor._shared_pool is not None
|
| 442 |
+
|
| 443 |
+
@staticmethod
|
| 444 |
+
def get_pool_metrics() -> dict:
|
| 445 |
+
"""Get metrics about the process pool."""
|
| 446 |
+
if JuliaExecutor._shared_pool is None:
|
| 447 |
+
return {
|
| 448 |
+
"enabled": False,
|
| 449 |
+
"pool_size": 0,
|
| 450 |
+
"available_workers": 0,
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
return {
|
| 454 |
+
"enabled": True,
|
| 455 |
+
"pool_size": JuliaExecutor._pool_size,
|
| 456 |
+
"timeout": JuliaExecutor._pool_timeout,
|
| 457 |
+
"available_workers": JuliaExecutor._pool_size,
|
| 458 |
+
}
|
server/julia_process_pool.py
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
Julia Process Pool for high-performance code execution.
|
| 9 |
+
|
| 10 |
+
This module provides a pool of persistent Julia processes that can be reused
|
| 11 |
+
for multiple code executions, eliminating the overhead of spawning new processes.
|
| 12 |
+
|
| 13 |
+
Expected speedup: 50-100x for repeated executions compared to spawning new processes.
|
| 14 |
+
|
| 15 |
+
Features:
|
| 16 |
+
- Persistent Julia processes (no startup overhead)
|
| 17 |
+
- Thread-safe process allocation
|
| 18 |
+
- Automatic recovery from process failures
|
| 19 |
+
- Proper cleanup on shutdown
|
| 20 |
+
- Timeout handling per execution
|
| 21 |
+
|
| 22 |
+
Example:
|
| 23 |
+
>>> pool = JuliaProcessPool(size=4, timeout=30)
|
| 24 |
+
>>> result = pool.execute("println('Hello, Julia!')")
|
| 25 |
+
>>> print(result.stdout) # "Hello, Julia!\\n"
|
| 26 |
+
>>> pool.shutdown() # Clean up all processes
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import atexit
|
| 32 |
+
import logging
|
| 33 |
+
import os
|
| 34 |
+
import shutil
|
| 35 |
+
import subprocess
|
| 36 |
+
import threading
|
| 37 |
+
import time
|
| 38 |
+
from collections import deque
|
| 39 |
+
from dataclasses import dataclass
|
| 40 |
+
from pathlib import Path
|
| 41 |
+
from typing import Optional
|
| 42 |
+
|
| 43 |
+
# Use julia_env hierarchy to inherit handlers from app.py's setup_logging()
|
| 44 |
+
logger = logging.getLogger("julia_env.pool")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass
|
| 48 |
+
class CodeExecResult:
|
| 49 |
+
"""Result of code execution."""
|
| 50 |
+
|
| 51 |
+
stdout: str
|
| 52 |
+
stderr: str
|
| 53 |
+
exit_code: int
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class JuliaWorkerProcess:
|
| 57 |
+
"""
|
| 58 |
+
Single Julia worker process that can execute code repeatedly.
|
| 59 |
+
|
| 60 |
+
This class manages communication with a persistent Julia REPL process
|
| 61 |
+
using a delimiter-based protocol.
|
| 62 |
+
"""
|
| 63 |
+
|
| 64 |
+
# Communication protocol delimiters
|
| 65 |
+
START_OUTPUT = "<<<START_OUTPUT>>>"
|
| 66 |
+
START_ERROR = "<<<START_ERROR>>>"
|
| 67 |
+
EXIT_CODE_PREFIX = "<<<EXIT_CODE:"
|
| 68 |
+
END_EXECUTION = "<<<END_EXECUTION>>>"
|
| 69 |
+
END_CODE = "<<<END_CODE>>>"
|
| 70 |
+
|
| 71 |
+
def __init__(
|
| 72 |
+
self,
|
| 73 |
+
worker_id: int,
|
| 74 |
+
julia_path: str,
|
| 75 |
+
worker_script: str,
|
| 76 |
+
optimization_flags: bool = True,
|
| 77 |
+
):
|
| 78 |
+
"""
|
| 79 |
+
Initialize a Julia worker process.
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
worker_id: Unique identifier for this worker
|
| 83 |
+
julia_path: Path to Julia executable
|
| 84 |
+
worker_script: Path to julia_repl_worker.jl script
|
| 85 |
+
optimization_flags: Enable Julia optimization flags
|
| 86 |
+
"""
|
| 87 |
+
self.worker_id = worker_id
|
| 88 |
+
self.julia_path = julia_path
|
| 89 |
+
self.worker_script = worker_script
|
| 90 |
+
self.optimization_flags = optimization_flags
|
| 91 |
+
self.process: Optional[subprocess.Popen] = None
|
| 92 |
+
self.is_busy = False
|
| 93 |
+
self.is_healthy = True
|
| 94 |
+
self.lock = threading.Lock()
|
| 95 |
+
|
| 96 |
+
# Start the worker process
|
| 97 |
+
self._start_process()
|
| 98 |
+
|
| 99 |
+
def _start_process(self) -> None:
|
| 100 |
+
"""Start the Julia worker process."""
|
| 101 |
+
cmd = [self.julia_path]
|
| 102 |
+
|
| 103 |
+
if self.optimization_flags:
|
| 104 |
+
cmd.extend(
|
| 105 |
+
[
|
| 106 |
+
"--compile=min",
|
| 107 |
+
"--optimize=2",
|
| 108 |
+
"--startup-file=no",
|
| 109 |
+
"--history-file=no",
|
| 110 |
+
]
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
cmd.append(self.worker_script)
|
| 114 |
+
|
| 115 |
+
try:
|
| 116 |
+
self.process = subprocess.Popen(
|
| 117 |
+
cmd,
|
| 118 |
+
stdin=subprocess.PIPE,
|
| 119 |
+
stdout=subprocess.PIPE,
|
| 120 |
+
stderr=subprocess.PIPE,
|
| 121 |
+
text=True,
|
| 122 |
+
bufsize=1, # Line buffered
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
# Wait for "Julia worker ready" message on stderr
|
| 126 |
+
ready_msg = self.process.stderr.readline()
|
| 127 |
+
if "ready" not in ready_msg.lower():
|
| 128 |
+
raise RuntimeError(
|
| 129 |
+
f"Worker {self.worker_id} did not start properly: {ready_msg}"
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
self.is_healthy = True
|
| 133 |
+
logger.info(f"Worker {self.worker_id} started (PID: {self.process.pid})")
|
| 134 |
+
|
| 135 |
+
except Exception as e:
|
| 136 |
+
self.is_healthy = False
|
| 137 |
+
logger.error(f"Failed to start worker {self.worker_id}: {e}")
|
| 138 |
+
raise
|
| 139 |
+
|
| 140 |
+
def execute(self, code: str, timeout: Optional[int] = None) -> CodeExecResult:
|
| 141 |
+
"""
|
| 142 |
+
Execute Julia code in this worker process.
|
| 143 |
+
|
| 144 |
+
Args:
|
| 145 |
+
code: Julia code to execute
|
| 146 |
+
timeout: Maximum execution time in seconds.
|
| 147 |
+
If None, reads from JULIA_EXECUTION_TIMEOUT env var (default: 120)
|
| 148 |
+
|
| 149 |
+
Returns:
|
| 150 |
+
CodeExecResult with stdout, stderr, and exit_code
|
| 151 |
+
"""
|
| 152 |
+
# Read timeout from env var if not explicitly provided
|
| 153 |
+
if timeout is None:
|
| 154 |
+
timeout = int(os.getenv("JULIA_EXECUTION_TIMEOUT", "120"))
|
| 155 |
+
|
| 156 |
+
with self.lock:
|
| 157 |
+
if not self.is_healthy or self.process is None:
|
| 158 |
+
raise RuntimeError(f"Worker {self.worker_id} is not healthy")
|
| 159 |
+
|
| 160 |
+
self.is_busy = True
|
| 161 |
+
|
| 162 |
+
try:
|
| 163 |
+
# Send code to worker
|
| 164 |
+
self.process.stdin.write(code + "\n")
|
| 165 |
+
self.process.stdin.write(self.END_CODE + "\n")
|
| 166 |
+
self.process.stdin.flush()
|
| 167 |
+
|
| 168 |
+
# Use threading for proper timeout handling
|
| 169 |
+
# The blocking readline() would otherwise prevent timeout detection
|
| 170 |
+
result_container: dict = {
|
| 171 |
+
"stdout_lines": [],
|
| 172 |
+
"stderr_lines": [],
|
| 173 |
+
"exit_code": -1,
|
| 174 |
+
"completed": False,
|
| 175 |
+
"error": None,
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
def read_output():
|
| 179 |
+
"""Read output in a separate thread."""
|
| 180 |
+
stdout_lines = []
|
| 181 |
+
stderr_lines = []
|
| 182 |
+
exit_code = -1
|
| 183 |
+
current_section = None
|
| 184 |
+
|
| 185 |
+
try:
|
| 186 |
+
while True:
|
| 187 |
+
line = self.process.stdout.readline()
|
| 188 |
+
|
| 189 |
+
if not line:
|
| 190 |
+
# EOF - process died
|
| 191 |
+
result_container["error"] = "Worker process died unexpectedly"
|
| 192 |
+
return
|
| 193 |
+
|
| 194 |
+
line = line.rstrip("\n")
|
| 195 |
+
|
| 196 |
+
# Check for delimiters
|
| 197 |
+
if line == self.START_OUTPUT:
|
| 198 |
+
current_section = "stdout"
|
| 199 |
+
continue
|
| 200 |
+
elif line == self.START_ERROR:
|
| 201 |
+
current_section = "stderr"
|
| 202 |
+
continue
|
| 203 |
+
elif line.startswith(self.EXIT_CODE_PREFIX):
|
| 204 |
+
# Parse exit code
|
| 205 |
+
exit_code_str = line[
|
| 206 |
+
len(self.EXIT_CODE_PREFIX) : -3
|
| 207 |
+
] # Remove prefix and ">>>"
|
| 208 |
+
exit_code = int(exit_code_str)
|
| 209 |
+
continue
|
| 210 |
+
elif line == self.END_EXECUTION:
|
| 211 |
+
# Execution complete
|
| 212 |
+
break
|
| 213 |
+
|
| 214 |
+
# Accumulate output
|
| 215 |
+
if current_section == "stdout":
|
| 216 |
+
stdout_lines.append(line)
|
| 217 |
+
elif current_section == "stderr":
|
| 218 |
+
stderr_lines.append(line)
|
| 219 |
+
|
| 220 |
+
result_container["stdout_lines"] = stdout_lines
|
| 221 |
+
result_container["stderr_lines"] = stderr_lines
|
| 222 |
+
result_container["exit_code"] = exit_code
|
| 223 |
+
result_container["completed"] = True
|
| 224 |
+
|
| 225 |
+
except Exception as e:
|
| 226 |
+
result_container["error"] = f"Error reading from worker: {str(e)}"
|
| 227 |
+
|
| 228 |
+
# Start reader thread
|
| 229 |
+
reader_thread = threading.Thread(target=read_output, daemon=True)
|
| 230 |
+
reader_thread.start()
|
| 231 |
+
|
| 232 |
+
# Wait for completion with timeout
|
| 233 |
+
reader_thread.join(timeout=timeout)
|
| 234 |
+
|
| 235 |
+
if reader_thread.is_alive():
|
| 236 |
+
# Timeout - kill the process
|
| 237 |
+
logger.error(f"Worker {self.worker_id} execution timed out after {timeout}s")
|
| 238 |
+
self.is_healthy = False
|
| 239 |
+
self._kill_process()
|
| 240 |
+
return CodeExecResult(
|
| 241 |
+
stdout="",
|
| 242 |
+
stderr=f"Execution timed out after {timeout} seconds",
|
| 243 |
+
exit_code=-1,
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
# Check for errors
|
| 247 |
+
if result_container["error"]:
|
| 248 |
+
logger.error(f"Worker {self.worker_id}: {result_container['error']}")
|
| 249 |
+
self.is_healthy = False
|
| 250 |
+
return CodeExecResult(
|
| 251 |
+
stdout="".join(result_container["stdout_lines"]),
|
| 252 |
+
stderr=result_container["error"],
|
| 253 |
+
exit_code=-1,
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
# Reconstruct output (add newlines back)
|
| 257 |
+
stdout_lines = result_container["stdout_lines"]
|
| 258 |
+
stderr_lines = result_container["stderr_lines"]
|
| 259 |
+
stdout_str = "\n".join(stdout_lines) + ("\n" if stdout_lines else "")
|
| 260 |
+
stderr_str = "\n".join(stderr_lines) + ("\n" if stderr_lines else "")
|
| 261 |
+
|
| 262 |
+
return CodeExecResult(
|
| 263 |
+
stdout=stdout_str,
|
| 264 |
+
stderr=stderr_str,
|
| 265 |
+
exit_code=result_container["exit_code"],
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
finally:
|
| 269 |
+
self.is_busy = False
|
| 270 |
+
|
| 271 |
+
def _kill_process(self) -> None:
|
| 272 |
+
"""Kill the worker process."""
|
| 273 |
+
if self.process is not None:
|
| 274 |
+
try:
|
| 275 |
+
self.process.terminate()
|
| 276 |
+
self.process.wait(timeout=2.0)
|
| 277 |
+
except Exception:
|
| 278 |
+
try:
|
| 279 |
+
self.process.kill()
|
| 280 |
+
self.process.wait(timeout=1.0)
|
| 281 |
+
except Exception:
|
| 282 |
+
pass
|
| 283 |
+
|
| 284 |
+
def shutdown(self) -> None:
|
| 285 |
+
"""Shutdown the worker process gracefully."""
|
| 286 |
+
with self.lock:
|
| 287 |
+
if self.process is not None:
|
| 288 |
+
logger.info(f"Shutting down worker {self.worker_id}")
|
| 289 |
+
self._kill_process()
|
| 290 |
+
self.process = None
|
| 291 |
+
self.is_healthy = False
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
class JuliaProcessPool:
|
| 295 |
+
"""
|
| 296 |
+
Pool of persistent Julia processes for high-performance code execution.
|
| 297 |
+
|
| 298 |
+
This class manages multiple Julia worker processes and distributes
|
| 299 |
+
code execution among them, providing significant speedup by eliminating
|
| 300 |
+
process startup overhead.
|
| 301 |
+
|
| 302 |
+
Thread-safe for concurrent access from multiple threads.
|
| 303 |
+
|
| 304 |
+
Example:
|
| 305 |
+
>>> pool = JuliaProcessPool(size=4)
|
| 306 |
+
>>>
|
| 307 |
+
>>> # Execute code
|
| 308 |
+
>>> result = pool.execute("println('Hello')")
|
| 309 |
+
>>>
|
| 310 |
+
>>> # Pool automatically manages workers
|
| 311 |
+
>>> results = [pool.execute(f"println({i})") for i in range(100)]
|
| 312 |
+
>>>
|
| 313 |
+
>>> # Cleanup when done
|
| 314 |
+
>>> pool.shutdown()
|
| 315 |
+
"""
|
| 316 |
+
|
| 317 |
+
def __init__(
|
| 318 |
+
self,
|
| 319 |
+
size: int = 4,
|
| 320 |
+
timeout: Optional[int] = None,
|
| 321 |
+
julia_path: Optional[str] = None,
|
| 322 |
+
optimization_flags: bool = True,
|
| 323 |
+
auto_recover: bool = True,
|
| 324 |
+
):
|
| 325 |
+
"""
|
| 326 |
+
Initialize the Julia process pool.
|
| 327 |
+
|
| 328 |
+
Args:
|
| 329 |
+
size: Number of worker processes to create (default: 4)
|
| 330 |
+
timeout: Default timeout for code execution in seconds.
|
| 331 |
+
If None, reads from JULIA_EXECUTION_TIMEOUT env var (default: 120)
|
| 332 |
+
julia_path: Path to Julia executable (auto-detected if None)
|
| 333 |
+
optimization_flags: Enable Julia optimization flags (default: True)
|
| 334 |
+
auto_recover: Automatically restart failed workers (default: True)
|
| 335 |
+
|
| 336 |
+
Raises:
|
| 337 |
+
RuntimeError: If Julia executable is not found
|
| 338 |
+
"""
|
| 339 |
+
self.size = size
|
| 340 |
+
# Read timeout from env var if not explicitly provided
|
| 341 |
+
if timeout is None:
|
| 342 |
+
timeout = int(os.getenv("JULIA_EXECUTION_TIMEOUT", "120"))
|
| 343 |
+
logger.info(f"Pool timeout from JULIA_EXECUTION_TIMEOUT env var: {timeout}s")
|
| 344 |
+
else:
|
| 345 |
+
logger.info(f"Pool timeout explicitly set: {timeout}s")
|
| 346 |
+
self.timeout = timeout
|
| 347 |
+
self.optimization_flags = optimization_flags
|
| 348 |
+
self.auto_recover = auto_recover
|
| 349 |
+
|
| 350 |
+
# Find Julia executable
|
| 351 |
+
if julia_path is None:
|
| 352 |
+
julia_path = self._find_julia_executable()
|
| 353 |
+
|
| 354 |
+
self.julia_path = julia_path
|
| 355 |
+
|
| 356 |
+
# Find worker script
|
| 357 |
+
self.worker_script = self._find_worker_script()
|
| 358 |
+
|
| 359 |
+
# Initialize workers
|
| 360 |
+
self.workers: list[JuliaWorkerProcess] = []
|
| 361 |
+
self.available_workers: deque[JuliaWorkerProcess] = deque()
|
| 362 |
+
self.pool_lock = threading.Lock()
|
| 363 |
+
self.shutdown_flag = False
|
| 364 |
+
|
| 365 |
+
# Create worker processes
|
| 366 |
+
logger.info(f"Creating Julia process pool with {size} workers")
|
| 367 |
+
for i in range(size):
|
| 368 |
+
try:
|
| 369 |
+
worker = JuliaWorkerProcess(
|
| 370 |
+
worker_id=i,
|
| 371 |
+
julia_path=self.julia_path,
|
| 372 |
+
worker_script=self.worker_script,
|
| 373 |
+
optimization_flags=self.optimization_flags,
|
| 374 |
+
)
|
| 375 |
+
self.workers.append(worker)
|
| 376 |
+
self.available_workers.append(worker)
|
| 377 |
+
except Exception as e:
|
| 378 |
+
logger.error(f"Failed to create worker {i}: {e}")
|
| 379 |
+
# Clean up partially created pool
|
| 380 |
+
self.shutdown()
|
| 381 |
+
raise RuntimeError(f"Failed to create worker pool: {e}")
|
| 382 |
+
|
| 383 |
+
logger.info(f"Julia process pool initialized with {len(self.workers)} workers")
|
| 384 |
+
|
| 385 |
+
# Register cleanup on exit
|
| 386 |
+
atexit.register(self.shutdown)
|
| 387 |
+
|
| 388 |
+
def _find_julia_executable(self) -> str:
|
| 389 |
+
"""Find Julia executable in PATH or common locations."""
|
| 390 |
+
# Try shutil.which first
|
| 391 |
+
julia_path = shutil.which("julia")
|
| 392 |
+
if julia_path:
|
| 393 |
+
return julia_path
|
| 394 |
+
|
| 395 |
+
# Try common locations
|
| 396 |
+
common_paths = [
|
| 397 |
+
os.path.expanduser("~/.juliaup/bin/julia"),
|
| 398 |
+
os.path.expanduser("~/.julia/bin/julia"),
|
| 399 |
+
"/usr/local/bin/julia",
|
| 400 |
+
"/usr/bin/julia",
|
| 401 |
+
]
|
| 402 |
+
|
| 403 |
+
for path in common_paths:
|
| 404 |
+
if os.path.isfile(path) and os.access(path, os.X_OK):
|
| 405 |
+
return path
|
| 406 |
+
|
| 407 |
+
raise RuntimeError(
|
| 408 |
+
"Julia executable not found. Please install Julia: "
|
| 409 |
+
"https://julialang.org/downloads/"
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
def _find_worker_script(self) -> str:
|
| 413 |
+
"""Find the julia_repl_worker.jl script."""
|
| 414 |
+
# Try relative to this file
|
| 415 |
+
this_dir = Path(__file__).parent
|
| 416 |
+
worker_script = this_dir / "julia_repl_worker.jl"
|
| 417 |
+
|
| 418 |
+
if worker_script.exists():
|
| 419 |
+
return str(worker_script)
|
| 420 |
+
|
| 421 |
+
raise RuntimeError(
|
| 422 |
+
f"Worker script not found at {worker_script}. "
|
| 423 |
+
"Please ensure julia_repl_worker.jl is in the same directory."
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
def _get_available_worker(
|
| 427 |
+
self, timeout: float = 30.0
|
| 428 |
+
) -> Optional[JuliaWorkerProcess]:
|
| 429 |
+
"""
|
| 430 |
+
Get an available worker from the pool.
|
| 431 |
+
|
| 432 |
+
Args:
|
| 433 |
+
timeout: Maximum time to wait for a worker (seconds)
|
| 434 |
+
|
| 435 |
+
Returns:
|
| 436 |
+
Available worker or None if timeout
|
| 437 |
+
"""
|
| 438 |
+
start_time = time.time()
|
| 439 |
+
|
| 440 |
+
while time.time() - start_time < timeout:
|
| 441 |
+
with self.pool_lock:
|
| 442 |
+
# Try to get healthy worker
|
| 443 |
+
while self.available_workers:
|
| 444 |
+
worker = self.available_workers.popleft()
|
| 445 |
+
|
| 446 |
+
if worker.is_healthy:
|
| 447 |
+
return worker
|
| 448 |
+
|
| 449 |
+
# Worker is unhealthy, try to recover
|
| 450 |
+
if self.auto_recover and not self.shutdown_flag:
|
| 451 |
+
worker = self._recover_worker(worker)
|
| 452 |
+
if worker.is_healthy:
|
| 453 |
+
return worker
|
| 454 |
+
# Recovery failed, continue to next worker
|
| 455 |
+
|
| 456 |
+
# No workers available, wait a bit
|
| 457 |
+
time.sleep(0.1)
|
| 458 |
+
|
| 459 |
+
logger.error("Timeout waiting for available worker")
|
| 460 |
+
return None
|
| 461 |
+
|
| 462 |
+
def _recover_worker(self, worker: JuliaWorkerProcess) -> JuliaWorkerProcess:
|
| 463 |
+
"""
|
| 464 |
+
Attempt to recover an unhealthy worker by restarting it.
|
| 465 |
+
|
| 466 |
+
Args:
|
| 467 |
+
worker: The unhealthy worker to recover
|
| 468 |
+
|
| 469 |
+
Returns:
|
| 470 |
+
The recovered worker (new instance) or the original if recovery fails
|
| 471 |
+
"""
|
| 472 |
+
logger.warning(
|
| 473 |
+
f"Worker {worker.worker_id} is unhealthy, attempting recovery"
|
| 474 |
+
)
|
| 475 |
+
try:
|
| 476 |
+
worker.shutdown()
|
| 477 |
+
new_worker = JuliaWorkerProcess(
|
| 478 |
+
worker_id=worker.worker_id,
|
| 479 |
+
julia_path=self.julia_path,
|
| 480 |
+
worker_script=self.worker_script,
|
| 481 |
+
optimization_flags=self.optimization_flags,
|
| 482 |
+
)
|
| 483 |
+
# Update in workers list
|
| 484 |
+
self.workers[new_worker.worker_id] = new_worker
|
| 485 |
+
logger.info(f"Worker {new_worker.worker_id} recovered successfully")
|
| 486 |
+
return new_worker
|
| 487 |
+
except Exception as e:
|
| 488 |
+
logger.error(
|
| 489 |
+
f"Failed to recover worker {worker.worker_id}: {e}"
|
| 490 |
+
)
|
| 491 |
+
# Return original worker - it will be retried next time
|
| 492 |
+
return worker
|
| 493 |
+
|
| 494 |
+
def _return_worker(self, worker: JuliaWorkerProcess) -> None:
|
| 495 |
+
"""
|
| 496 |
+
Return a worker to the available pool.
|
| 497 |
+
|
| 498 |
+
If the worker is unhealthy and auto_recover is enabled, attempts to
|
| 499 |
+
recover the worker before returning it to the pool. This ensures
|
| 500 |
+
workers are not leaked when they fail during execution.
|
| 501 |
+
"""
|
| 502 |
+
with self.pool_lock:
|
| 503 |
+
if self.shutdown_flag:
|
| 504 |
+
return
|
| 505 |
+
|
| 506 |
+
# If worker is unhealthy, try to recover it immediately
|
| 507 |
+
if not worker.is_healthy and self.auto_recover:
|
| 508 |
+
worker = self._recover_worker(worker)
|
| 509 |
+
|
| 510 |
+
# Always return the worker to the pool (healthy or not)
|
| 511 |
+
# Unhealthy workers will be recovered when next acquired
|
| 512 |
+
self.available_workers.append(worker)
|
| 513 |
+
|
| 514 |
+
def execute(self, code: str, timeout: Optional[int] = None) -> CodeExecResult:
|
| 515 |
+
"""
|
| 516 |
+
Execute Julia code using an available worker from the pool.
|
| 517 |
+
|
| 518 |
+
Args:
|
| 519 |
+
code: Julia code to execute
|
| 520 |
+
timeout: Execution timeout in seconds (uses pool default if None)
|
| 521 |
+
|
| 522 |
+
Returns:
|
| 523 |
+
CodeExecResult with stdout, stderr, and exit_code
|
| 524 |
+
"""
|
| 525 |
+
if self.shutdown_flag:
|
| 526 |
+
return CodeExecResult(
|
| 527 |
+
stdout="",
|
| 528 |
+
stderr="Process pool has been shut down",
|
| 529 |
+
exit_code=-1,
|
| 530 |
+
)
|
| 531 |
+
|
| 532 |
+
if timeout is None:
|
| 533 |
+
timeout = self.timeout
|
| 534 |
+
|
| 535 |
+
# Get available worker
|
| 536 |
+
worker = self._get_available_worker()
|
| 537 |
+
|
| 538 |
+
if worker is None:
|
| 539 |
+
return CodeExecResult(
|
| 540 |
+
stdout="",
|
| 541 |
+
stderr="No available worker (timeout waiting for worker)",
|
| 542 |
+
exit_code=-1,
|
| 543 |
+
)
|
| 544 |
+
|
| 545 |
+
try:
|
| 546 |
+
# Execute code in worker
|
| 547 |
+
result = worker.execute(code, timeout=timeout)
|
| 548 |
+
return result
|
| 549 |
+
|
| 550 |
+
finally:
|
| 551 |
+
# Return worker to pool
|
| 552 |
+
self._return_worker(worker)
|
| 553 |
+
|
| 554 |
+
def shutdown(self) -> None:
|
| 555 |
+
"""
|
| 556 |
+
Shutdown all worker processes gracefully.
|
| 557 |
+
|
| 558 |
+
This method is automatically called on exit via atexit.
|
| 559 |
+
"""
|
| 560 |
+
if self.shutdown_flag:
|
| 561 |
+
return
|
| 562 |
+
|
| 563 |
+
logger.info("Shutting down Julia process pool")
|
| 564 |
+
self.shutdown_flag = True
|
| 565 |
+
|
| 566 |
+
with self.pool_lock:
|
| 567 |
+
for worker in self.workers:
|
| 568 |
+
try:
|
| 569 |
+
worker.shutdown()
|
| 570 |
+
except Exception as e:
|
| 571 |
+
logger.error(f"Error shutting down worker: {e}")
|
| 572 |
+
|
| 573 |
+
self.workers.clear()
|
| 574 |
+
self.available_workers.clear()
|
| 575 |
+
|
| 576 |
+
logger.info("Julia process pool shutdown complete")
|
| 577 |
+
|
| 578 |
+
def get_stats(self) -> dict:
|
| 579 |
+
"""Get pool statistics."""
|
| 580 |
+
with self.pool_lock:
|
| 581 |
+
healthy_workers = sum(1 for w in self.workers if w.is_healthy)
|
| 582 |
+
available = len(self.available_workers)
|
| 583 |
+
|
| 584 |
+
return {
|
| 585 |
+
"total_workers": self.size,
|
| 586 |
+
"healthy_workers": healthy_workers,
|
| 587 |
+
"available_workers": available,
|
| 588 |
+
"shutdown": self.shutdown_flag,
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
def __enter__(self):
|
| 592 |
+
"""Context manager entry."""
|
| 593 |
+
return self
|
| 594 |
+
|
| 595 |
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
| 596 |
+
"""Context manager exit."""
|
| 597 |
+
self.shutdown()
|
| 598 |
+
|
| 599 |
+
def __del__(self):
|
| 600 |
+
"""Ensure cleanup on garbage collection."""
|
| 601 |
+
self.shutdown()
|
server/julia_repl_worker.jl
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env julia
|
| 2 |
+
|
| 3 |
+
"""
|
| 4 |
+
Julia REPL Worker for Process Pool
|
| 5 |
+
|
| 6 |
+
This script runs as a persistent Julia process that accepts code via stdin,
|
| 7 |
+
executes it, and returns results via stdout with delimiters.
|
| 8 |
+
|
| 9 |
+
Protocol:
|
| 10 |
+
- Input: Code block followed by "<<<END_CODE>>>"
|
| 11 |
+
- Output: Results with status markers:
|
| 12 |
+
- "<<<START_OUTPUT>>>" - stdout begins
|
| 13 |
+
- "<<<START_ERROR>>>" - stderr begins
|
| 14 |
+
- "<<<EXIT_CODE:N>>>" - exit code (0 = success, 1 = error)
|
| 15 |
+
- "<<<END_EXECUTION>>>" - execution complete
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
# Preload commonly used modules to reduce JIT compilation overhead
|
| 19 |
+
# This is done once at worker startup, so subsequent code executions are faster
|
| 20 |
+
using Test
|
| 21 |
+
|
| 22 |
+
# Warm up the Test framework and common operations by running simple tests
|
| 23 |
+
# This pre-compiles the @test and @testset macros plus common Julia operations
|
| 24 |
+
let
|
| 25 |
+
# Warm up Test framework
|
| 26 |
+
@testset "warmup" begin
|
| 27 |
+
@test 1 + 1 == 2
|
| 28 |
+
@test "hello" == "hello"
|
| 29 |
+
@test [1, 2, 3] == [1, 2, 3]
|
| 30 |
+
end
|
| 31 |
+
|
| 32 |
+
# Warm up common string operations
|
| 33 |
+
occursin("a", "abc")
|
| 34 |
+
replace("hello", "l" => "x")
|
| 35 |
+
split("a,b,c", ",")
|
| 36 |
+
join(["a", "b"], ",")
|
| 37 |
+
lowercase("ABC")
|
| 38 |
+
uppercase("abc")
|
| 39 |
+
strip(" hello ")
|
| 40 |
+
|
| 41 |
+
# Warm up common array operations
|
| 42 |
+
arr = [3, 1, 2]
|
| 43 |
+
push!(arr, 4)
|
| 44 |
+
pop!(arr)
|
| 45 |
+
append!(arr, [5, 6])
|
| 46 |
+
sort(arr)
|
| 47 |
+
reverse(arr)
|
| 48 |
+
length(arr)
|
| 49 |
+
isempty(Int[])
|
| 50 |
+
|
| 51 |
+
# Warm up common math operations
|
| 52 |
+
div(10, 3)
|
| 53 |
+
mod(10, 3)
|
| 54 |
+
abs(-5)
|
| 55 |
+
sqrt(4.0)
|
| 56 |
+
floor(Int, 3.7)
|
| 57 |
+
ceil(Int, 3.2)
|
| 58 |
+
|
| 59 |
+
# Warm up Dict operations
|
| 60 |
+
d = Dict("a" => 1, "b" => 2)
|
| 61 |
+
get(d, "a", 0)
|
| 62 |
+
haskey(d, "a")
|
| 63 |
+
keys(d)
|
| 64 |
+
values(d)
|
| 65 |
+
end
|
| 66 |
+
|
| 67 |
+
# Delimiters for communication protocol
|
| 68 |
+
const START_OUTPUT = "<<<START_OUTPUT>>>"
|
| 69 |
+
const START_ERROR = "<<<START_ERROR>>>"
|
| 70 |
+
const EXIT_CODE_PREFIX = "<<<EXIT_CODE:"
|
| 71 |
+
const END_EXECUTION = "<<<END_EXECUTION>>>"
|
| 72 |
+
const END_CODE = "<<<END_CODE>>>"
|
| 73 |
+
|
| 74 |
+
"""
|
| 75 |
+
Execute code and capture output using pipes
|
| 76 |
+
"""
|
| 77 |
+
function execute_code(code::String)
|
| 78 |
+
# Initialize return values
|
| 79 |
+
stdout_str = ""
|
| 80 |
+
stderr_str = ""
|
| 81 |
+
exit_code = 0
|
| 82 |
+
|
| 83 |
+
# Create pipes for output capture
|
| 84 |
+
out_pipe = Pipe()
|
| 85 |
+
err_pipe = Pipe()
|
| 86 |
+
|
| 87 |
+
try
|
| 88 |
+
# Execute with output redirected to pipes
|
| 89 |
+
redirect_stdout(out_pipe) do
|
| 90 |
+
redirect_stderr(err_pipe) do
|
| 91 |
+
try
|
| 92 |
+
# Execute the code using include_string which properly handles
|
| 93 |
+
# multiple statements including 'using' statements
|
| 94 |
+
include_string(Main, code)
|
| 95 |
+
catch e
|
| 96 |
+
# Execution error - write to stderr
|
| 97 |
+
exit_code = 1
|
| 98 |
+
showerror(stderr, e, catch_backtrace())
|
| 99 |
+
println(stderr)
|
| 100 |
+
end
|
| 101 |
+
end
|
| 102 |
+
end
|
| 103 |
+
|
| 104 |
+
# Close write ends to signal EOF to readers
|
| 105 |
+
Base.close(out_pipe.in)
|
| 106 |
+
Base.close(err_pipe.in)
|
| 107 |
+
|
| 108 |
+
# Read captured output
|
| 109 |
+
stdout_str = read(out_pipe.out, String)
|
| 110 |
+
stderr_str = read(err_pipe.out, String)
|
| 111 |
+
|
| 112 |
+
# Close read ends
|
| 113 |
+
Base.close(out_pipe.out)
|
| 114 |
+
Base.close(err_pipe.out)
|
| 115 |
+
|
| 116 |
+
catch e
|
| 117 |
+
# Worker error
|
| 118 |
+
exit_code = 1
|
| 119 |
+
|
| 120 |
+
# Try to close pipes
|
| 121 |
+
try
|
| 122 |
+
Base.close(out_pipe)
|
| 123 |
+
Base.close(err_pipe)
|
| 124 |
+
catch
|
| 125 |
+
end
|
| 126 |
+
|
| 127 |
+
stderr_str = "Worker error: " * sprint(showerror, e)
|
| 128 |
+
end
|
| 129 |
+
|
| 130 |
+
return (stdout_str, stderr_str, exit_code)
|
| 131 |
+
end
|
| 132 |
+
|
| 133 |
+
"""
|
| 134 |
+
Main loop: read code, execute, return results
|
| 135 |
+
"""
|
| 136 |
+
function main()
|
| 137 |
+
# Signal that worker is ready
|
| 138 |
+
println(stderr, "Julia worker ready")
|
| 139 |
+
flush(stderr)
|
| 140 |
+
|
| 141 |
+
while true
|
| 142 |
+
try
|
| 143 |
+
# Read code until END_CODE delimiter
|
| 144 |
+
code_lines = String[]
|
| 145 |
+
|
| 146 |
+
while true
|
| 147 |
+
if eof(stdin)
|
| 148 |
+
println(stderr, "Worker received EOF, shutting down")
|
| 149 |
+
return
|
| 150 |
+
end
|
| 151 |
+
|
| 152 |
+
line = readline(stdin)
|
| 153 |
+
|
| 154 |
+
# Check for end of code
|
| 155 |
+
if line == END_CODE
|
| 156 |
+
break
|
| 157 |
+
end
|
| 158 |
+
|
| 159 |
+
push!(code_lines, line)
|
| 160 |
+
end
|
| 161 |
+
|
| 162 |
+
# If no code received, continue
|
| 163 |
+
if isempty(code_lines)
|
| 164 |
+
# Send empty response
|
| 165 |
+
println(START_OUTPUT)
|
| 166 |
+
println(START_ERROR)
|
| 167 |
+
println(EXIT_CODE_PREFIX, 0, ">>>")
|
| 168 |
+
println(END_EXECUTION)
|
| 169 |
+
flush(stdout)
|
| 170 |
+
continue
|
| 171 |
+
end
|
| 172 |
+
|
| 173 |
+
code = join(code_lines, "\n")
|
| 174 |
+
|
| 175 |
+
# Execute code and capture output
|
| 176 |
+
(stdout_str, stderr_str, exit_code) = execute_code(code)
|
| 177 |
+
|
| 178 |
+
# Send results with delimiters
|
| 179 |
+
println(START_OUTPUT)
|
| 180 |
+
print(stdout_str)
|
| 181 |
+
flush(stdout)
|
| 182 |
+
|
| 183 |
+
println(START_ERROR)
|
| 184 |
+
print(stderr_str)
|
| 185 |
+
flush(stdout)
|
| 186 |
+
|
| 187 |
+
println(EXIT_CODE_PREFIX, exit_code, ">>>")
|
| 188 |
+
println(END_EXECUTION)
|
| 189 |
+
flush(stdout)
|
| 190 |
+
|
| 191 |
+
catch e
|
| 192 |
+
# Worker error - report and continue
|
| 193 |
+
println(stderr, "Worker error: ", e)
|
| 194 |
+
flush(stderr)
|
| 195 |
+
|
| 196 |
+
# Send error response
|
| 197 |
+
println(START_OUTPUT)
|
| 198 |
+
println(START_ERROR)
|
| 199 |
+
println("Worker internal error: ", e)
|
| 200 |
+
println(EXIT_CODE_PREFIX, 1, ">>>")
|
| 201 |
+
println(END_EXECUTION)
|
| 202 |
+
flush(stdout)
|
| 203 |
+
end
|
| 204 |
+
end
|
| 205 |
+
end
|
| 206 |
+
|
| 207 |
+
# Run main loop
|
| 208 |
+
main()
|
| 209 |
+
|
server/julia_transforms.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
envs/julia_env/julia_transforms.py
|
| 9 |
+
--------------------------------
|
| 10 |
+
Safety and quality transforms for Julia code.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import re
|
| 14 |
+
|
| 15 |
+
# Support both in-repo and standalone imports
|
| 16 |
+
try:
|
| 17 |
+
# In-repo imports
|
| 18 |
+
from openenv.core.env_server.interfaces import Transform
|
| 19 |
+
from ..models import JuliaObservation
|
| 20 |
+
except ImportError:
|
| 21 |
+
# Standalone imports
|
| 22 |
+
from openenv.core.env_server.interfaces import Transform
|
| 23 |
+
from models import JuliaObservation
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# -------------------------
|
| 27 |
+
# Safety Transform
|
| 28 |
+
# -------------------------
|
| 29 |
+
class JuliaSafetyTransform(Transform):
|
| 30 |
+
"""Detects dangerous Julia operations and penalizes them with a negative reward."""
|
| 31 |
+
|
| 32 |
+
def __init__(self, penalty: float = -3.0):
|
| 33 |
+
self.penalty = penalty
|
| 34 |
+
self.dangerous_patterns = [
|
| 35 |
+
r"run\(",
|
| 36 |
+
r"read\(",
|
| 37 |
+
r"write\(",
|
| 38 |
+
r"unsafe_",
|
| 39 |
+
r"ccall\(",
|
| 40 |
+
r"Base\.exit",
|
| 41 |
+
r"Base\.kill",
|
| 42 |
+
r"rm\(", # file deletion
|
| 43 |
+
r"download\(" # downloading
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
def __call__(self, observation):
|
| 47 |
+
# Only act on JuliaObservation objects
|
| 48 |
+
if not isinstance(observation, JuliaObservation):
|
| 49 |
+
return observation
|
| 50 |
+
|
| 51 |
+
# Extract executed code from metadata (core_code + test_code)
|
| 52 |
+
if observation.metadata:
|
| 53 |
+
code = observation.metadata.get("core_code", "") + "\n" + observation.metadata.get("test_code", "")
|
| 54 |
+
else:
|
| 55 |
+
code = ""
|
| 56 |
+
|
| 57 |
+
for pattern in self.dangerous_patterns:
|
| 58 |
+
if re.search(pattern, code):
|
| 59 |
+
# Apply penalty and record violation
|
| 60 |
+
observation.reward = (observation.reward or 0.0) + self.penalty
|
| 61 |
+
observation.metadata = observation.metadata or {}
|
| 62 |
+
observation.metadata["safety_violation"] = pattern
|
| 63 |
+
return observation
|
| 64 |
+
|
| 65 |
+
# Safe code gets neutral reward
|
| 66 |
+
observation.reward = observation.reward or 0.0
|
| 67 |
+
return observation
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# -------------------------
|
| 71 |
+
# Factory
|
| 72 |
+
# -------------------------
|
| 73 |
+
def create_safe_julia_transform():
|
| 74 |
+
"""Creates safety transform for Julia code."""
|
| 75 |
+
return JuliaSafetyTransform()
|
server/requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core OpenEnv dependencies
|
| 2 |
+
fastapi>=0.115.0
|
| 3 |
+
pydantic>=2.0.0
|
| 4 |
+
uvicorn[standard]>=0.24.0
|
| 5 |
+
requests>=2.31.0
|
| 6 |
+
websockets>=12.0
|
test_julia_env.sh
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Julia Environment Test Script
|
| 3 |
+
# Tests infinite loop timeout handling and worker recovery
|
| 4 |
+
#
|
| 5 |
+
# Usage: ./test_julia_env.sh [HOST] [PORT]
|
| 6 |
+
# Default: localhost:8000
|
| 7 |
+
|
| 8 |
+
set -e
|
| 9 |
+
|
| 10 |
+
HOST="${1:-localhost}"
|
| 11 |
+
PORT="${2:-8000}"
|
| 12 |
+
BASE_URL="http://${HOST}:${PORT}"
|
| 13 |
+
|
| 14 |
+
GREEN='\033[0;32m'
|
| 15 |
+
RED='\033[0;31m'
|
| 16 |
+
YELLOW='\033[1;33m'
|
| 17 |
+
NC='\033[0m' # No Color
|
| 18 |
+
|
| 19 |
+
echo "=============================================="
|
| 20 |
+
echo "Julia Environment Test Suite"
|
| 21 |
+
echo "Testing: ${BASE_URL}"
|
| 22 |
+
echo "=============================================="
|
| 23 |
+
echo ""
|
| 24 |
+
|
| 25 |
+
# Function to print test results
|
| 26 |
+
pass() {
|
| 27 |
+
echo -e "${GREEN}✓ PASS${NC}: $1"
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
fail() {
|
| 31 |
+
echo -e "${RED}✗ FAIL${NC}: $1"
|
| 32 |
+
FAILED=1
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
warn() {
|
| 36 |
+
echo -e "${YELLOW}! WARN${NC}: $1"
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
FAILED=0
|
| 40 |
+
|
| 41 |
+
# Test 1: Health Check
|
| 42 |
+
echo "--- Test 1: Health Check ---"
|
| 43 |
+
HEALTH=$(curl -s --max-time 10 "${BASE_URL}/health" 2>/dev/null || echo "TIMEOUT")
|
| 44 |
+
if [[ "$HEALTH" == *"healthy"* ]]; then
|
| 45 |
+
pass "Server is healthy"
|
| 46 |
+
echo " Response: $HEALTH"
|
| 47 |
+
else
|
| 48 |
+
fail "Health check failed: $HEALTH"
|
| 49 |
+
fi
|
| 50 |
+
echo ""
|
| 51 |
+
|
| 52 |
+
# Test 2: Pool Status
|
| 53 |
+
echo "--- Test 2: Pool Status ---"
|
| 54 |
+
POOL=$(curl -s --max-time 10 "${BASE_URL}/pool_status" 2>/dev/null || echo "TIMEOUT")
|
| 55 |
+
if [[ "$POOL" == *"pool_enabled"* ]]; then
|
| 56 |
+
pass "Pool status retrieved"
|
| 57 |
+
echo " Response: $POOL"
|
| 58 |
+
else
|
| 59 |
+
fail "Pool status failed: $POOL"
|
| 60 |
+
fi
|
| 61 |
+
echo ""
|
| 62 |
+
|
| 63 |
+
# Test 3: Basic Execution
|
| 64 |
+
echo "--- Test 3: Basic Julia Execution ---"
|
| 65 |
+
BASIC_RESULT=$(curl -s --max-time 30 -X POST "${BASE_URL}/step" \
|
| 66 |
+
-H "Content-Type: application/json" \
|
| 67 |
+
-d '{"action": {"core_code": "println(\"Hello from Julia!\")", "test_code": ""}}' 2>/dev/null || echo "TIMEOUT")
|
| 68 |
+
if [[ "$BASIC_RESULT" == *"Hello from Julia"* ]]; then
|
| 69 |
+
pass "Basic execution works"
|
| 70 |
+
echo " Response: $BASIC_RESULT"
|
| 71 |
+
else
|
| 72 |
+
fail "Basic execution failed: $BASIC_RESULT"
|
| 73 |
+
fi
|
| 74 |
+
echo ""
|
| 75 |
+
|
| 76 |
+
# Test 4: Tests Passing
|
| 77 |
+
echo "--- Test 4: Test Execution (Passing) ---"
|
| 78 |
+
TEST_RESULT=$(curl -s --max-time 30 -X POST "${BASE_URL}/step" \
|
| 79 |
+
-H "Content-Type: application/json" \
|
| 80 |
+
-d '{
|
| 81 |
+
"action": {
|
| 82 |
+
"core_code": "function fib(n)\n n <= 1 ? n : fib(n-1) + fib(n-2)\nend",
|
| 83 |
+
"test_code": "using Test\n@testset \"Fibonacci\" begin\n @test fib(0) == 0\n @test fib(1) == 1\n @test fib(5) == 5\n @test fib(10) == 55\nend"
|
| 84 |
+
}
|
| 85 |
+
}' 2>/dev/null || echo "TIMEOUT")
|
| 86 |
+
if [[ "$TEST_RESULT" == *"tests_passed"* ]] && [[ "$TEST_RESULT" == *"4"* ]]; then
|
| 87 |
+
pass "Test execution works (4 tests passed)"
|
| 88 |
+
echo " Response: $TEST_RESULT"
|
| 89 |
+
else
|
| 90 |
+
fail "Test execution failed: $TEST_RESULT"
|
| 91 |
+
fi
|
| 92 |
+
echo ""
|
| 93 |
+
|
| 94 |
+
# Test 5: Syntax Error Detection
|
| 95 |
+
echo "--- Test 5: Syntax Error Detection ---"
|
| 96 |
+
SYNTAX_RESULT=$(curl -s --max-time 30 -X POST "${BASE_URL}/step" \
|
| 97 |
+
-H "Content-Type: application/json" \
|
| 98 |
+
-d '{"action": {"core_code": "function broken(\n # missing end", "test_code": ""}}' 2>/dev/null || echo "TIMEOUT")
|
| 99 |
+
if [[ "$SYNTAX_RESULT" == *"code_compiles"*"false"* ]] || [[ "$SYNTAX_RESULT" == *"error"* ]] || [[ "$SYNTAX_RESULT" == *"Error"* ]]; then
|
| 100 |
+
pass "Syntax error detected"
|
| 101 |
+
echo " Response: $SYNTAX_RESULT"
|
| 102 |
+
else
|
| 103 |
+
warn "Syntax error may not be properly detected: $SYNTAX_RESULT"
|
| 104 |
+
fi
|
| 105 |
+
echo ""
|
| 106 |
+
|
| 107 |
+
# Test 6: Infinite Loop Timeout (CRITICAL TEST)
|
| 108 |
+
echo "--- Test 6: Infinite Loop Timeout (CRITICAL) ---"
|
| 109 |
+
echo " This test checks if infinite loops are properly terminated."
|
| 110 |
+
echo " Expected: Request should timeout and return an error within pool timeout."
|
| 111 |
+
echo " Starting..."
|
| 112 |
+
START_TIME=$(date +%s)
|
| 113 |
+
TIMEOUT_RESULT=$(curl -s --max-time 300 -X POST "${BASE_URL}/step" \
|
| 114 |
+
-H "Content-Type: application/json" \
|
| 115 |
+
-d '{"action": {"core_code": "while true\n sleep(1)\nend", "test_code": ""}}' 2>/dev/null || echo "CURL_TIMEOUT")
|
| 116 |
+
END_TIME=$(date +%s)
|
| 117 |
+
ELAPSED=$((END_TIME - START_TIME))
|
| 118 |
+
|
| 119 |
+
if [[ "$TIMEOUT_RESULT" == "CURL_TIMEOUT" ]]; then
|
| 120 |
+
fail "Request timed out after ${ELAPSED}s (curl timeout). Server may be stuck."
|
| 121 |
+
elif [[ "$TIMEOUT_RESULT" == *"timed out"* ]] || [[ "$TIMEOUT_RESULT" == *"Execution timed out"* ]]; then
|
| 122 |
+
pass "Infinite loop properly timed out after ${ELAPSED}s"
|
| 123 |
+
echo " Response: $TIMEOUT_RESULT"
|
| 124 |
+
elif [[ "$TIMEOUT_RESULT" == *"exit_code"*"-1"* ]]; then
|
| 125 |
+
pass "Infinite loop terminated with exit_code -1 after ${ELAPSED}s"
|
| 126 |
+
echo " Response: $TIMEOUT_RESULT"
|
| 127 |
+
else
|
| 128 |
+
warn "Unexpected response after ${ELAPSED}s: $TIMEOUT_RESULT"
|
| 129 |
+
fi
|
| 130 |
+
echo ""
|
| 131 |
+
|
| 132 |
+
# Test 7: Worker Recovery After Timeout
|
| 133 |
+
echo "--- Test 7: Worker Recovery After Timeout ---"
|
| 134 |
+
echo " Testing if the server can handle requests after a timeout..."
|
| 135 |
+
RECOVERY_RESULT=$(curl -s --max-time 30 -X POST "${BASE_URL}/step" \
|
| 136 |
+
-H "Content-Type: application/json" \
|
| 137 |
+
-d '{"action": {"core_code": "println(\"Recovery test successful!\")", "test_code": ""}}' 2>/dev/null || echo "TIMEOUT")
|
| 138 |
+
if [[ "$RECOVERY_RESULT" == *"Recovery test successful"* ]]; then
|
| 139 |
+
pass "Worker recovery successful - server still operational"
|
| 140 |
+
echo " Response: $RECOVERY_RESULT"
|
| 141 |
+
else
|
| 142 |
+
fail "Worker recovery failed: $RECOVERY_RESULT"
|
| 143 |
+
fi
|
| 144 |
+
echo ""
|
| 145 |
+
|
| 146 |
+
# Test 8: Pool Status After Timeout
|
| 147 |
+
echo "--- Test 8: Pool Status After Timeout ---"
|
| 148 |
+
POOL_AFTER=$(curl -s --max-time 10 "${BASE_URL}/pool_status" 2>/dev/null || echo "TIMEOUT")
|
| 149 |
+
if [[ "$POOL_AFTER" == *"pool_enabled"*"true"* ]]; then
|
| 150 |
+
pass "Pool is still enabled after timeout"
|
| 151 |
+
echo " Response: $POOL_AFTER"
|
| 152 |
+
else
|
| 153 |
+
fail "Pool status check failed: $POOL_AFTER"
|
| 154 |
+
fi
|
| 155 |
+
echo ""
|
| 156 |
+
|
| 157 |
+
# Test 9: Multiple Consecutive Timeouts
|
| 158 |
+
echo "--- Test 9: Multiple Consecutive Timeouts (3x) ---"
|
| 159 |
+
echo " Testing if multiple timeouts exhaust the worker pool..."
|
| 160 |
+
for i in 1 2 3; do
|
| 161 |
+
echo " Timeout request $i/3..."
|
| 162 |
+
curl -s --max-time 300 -X POST "${BASE_URL}/step" \
|
| 163 |
+
-H "Content-Type: application/json" \
|
| 164 |
+
-d '{"action": {"core_code": "while true; sleep(1); end", "test_code": ""}}' >/dev/null 2>&1
|
| 165 |
+
done
|
| 166 |
+
|
| 167 |
+
echo " Testing normal operation after 3 timeouts..."
|
| 168 |
+
AFTER_MULTI=$(curl -s --max-time 30 -X POST "${BASE_URL}/step" \
|
| 169 |
+
-H "Content-Type: application/json" \
|
| 170 |
+
-d '{"action": {"core_code": "println(\"Still working after 3 timeouts!\")", "test_code": ""}}' 2>/dev/null || echo "TIMEOUT")
|
| 171 |
+
if [[ "$AFTER_MULTI" == *"Still working after 3 timeouts"* ]]; then
|
| 172 |
+
pass "Server handles multiple consecutive timeouts correctly"
|
| 173 |
+
else
|
| 174 |
+
fail "Server failed after multiple timeouts: $AFTER_MULTI"
|
| 175 |
+
fi
|
| 176 |
+
echo ""
|
| 177 |
+
|
| 178 |
+
# Test 10: Concurrent Request Handling
|
| 179 |
+
echo "--- Test 10: Concurrent Request Handling ---"
|
| 180 |
+
echo " Sending 4 requests in parallel..."
|
| 181 |
+
# Create temp files for results
|
| 182 |
+
TMPDIR=$(mktemp -d)
|
| 183 |
+
for i in 1 2 3 4; do
|
| 184 |
+
curl -s --max-time 60 -X POST "${BASE_URL}/step" \
|
| 185 |
+
-H "Content-Type: application/json" \
|
| 186 |
+
-d "{\"action\": {\"core_code\": \"println(\\\"Request $i\\\")\", \"test_code\": \"\"}}" > "${TMPDIR}/result_${i}.txt" 2>&1 &
|
| 187 |
+
done
|
| 188 |
+
wait
|
| 189 |
+
|
| 190 |
+
CONCURRENT_SUCCESS=0
|
| 191 |
+
for i in 1 2 3 4; do
|
| 192 |
+
if grep -q "Request $i" "${TMPDIR}/result_${i}.txt" 2>/dev/null; then
|
| 193 |
+
((CONCURRENT_SUCCESS++))
|
| 194 |
+
fi
|
| 195 |
+
done
|
| 196 |
+
rm -rf "$TMPDIR"
|
| 197 |
+
|
| 198 |
+
if [[ $CONCURRENT_SUCCESS -eq 4 ]]; then
|
| 199 |
+
pass "All 4 concurrent requests succeeded"
|
| 200 |
+
else
|
| 201 |
+
fail "Only $CONCURRENT_SUCCESS/4 concurrent requests succeeded"
|
| 202 |
+
fi
|
| 203 |
+
echo ""
|
| 204 |
+
|
| 205 |
+
# Final Summary
|
| 206 |
+
echo "=============================================="
|
| 207 |
+
echo "Test Summary"
|
| 208 |
+
echo "=============================================="
|
| 209 |
+
if [[ $FAILED -eq 0 ]]; then
|
| 210 |
+
echo -e "${GREEN}All tests passed!${NC}"
|
| 211 |
+
exit 0
|
| 212 |
+
else
|
| 213 |
+
echo -e "${RED}Some tests failed. Please review the output above.${NC}"
|
| 214 |
+
exit 1
|
| 215 |
+
fi
|