From 13ef3125b905df27fd5542fc9a73a440cb17dbc9 Mon Sep 17 00:00:00 2001 From: "agentfarmx[bot]" <198411105+agentfarmx[bot]@users.noreply.github.com> Date: Sat, 1 Mar 2025 16:55:59 +0000 Subject: [PATCH 1/3] feat: add ForeverVM minimal implementation This adds a minimal implementation of ForeverVM, a system that provides persistent Python REPL sessions using custom serialization with Flask-based HTTP API. --- forevervm_minimal/README.md | 89 ++++++++++++++++++++++ forevervm_minimal/__init__.py | 2 + forevervm_minimal/custom_serializer.py | 6 ++ forevervm_minimal/http_server.py | 26 +++++++ forevervm_minimal/main.py | 38 ++++++++++ forevervm_minimal/requirements.txt | 2 + forevervm_minimal/run.sh | 23 ++++++ forevervm_minimal/session_data.py | 9 +++ forevervm_minimal/session_manager.py | 100 +++++++++++++++++++++++++ forevervm_minimal/snapshot_storage.py | 51 +++++++++++++ forevervm_minimal/test.sh | 23 ++++++ forevervm_minimal/test_client.py | 53 +++++++++++++ forevervm_minimal/worker.py | 66 ++++++++++++++++ forevervm_minimal/worker_manager.py | 37 +++++++++ 14 files changed, 525 insertions(+) create mode 100644 forevervm_minimal/README.md create mode 100644 forevervm_minimal/__init__.py create mode 100644 forevervm_minimal/custom_serializer.py create mode 100644 forevervm_minimal/http_server.py create mode 100644 forevervm_minimal/main.py create mode 100644 forevervm_minimal/requirements.txt create mode 100755 forevervm_minimal/run.sh create mode 100644 forevervm_minimal/session_data.py create mode 100644 forevervm_minimal/session_manager.py create mode 100644 forevervm_minimal/snapshot_storage.py create mode 100755 forevervm_minimal/test.sh create mode 100644 forevervm_minimal/test_client.py create mode 100644 forevervm_minimal/worker.py create mode 100644 forevervm_minimal/worker_manager.py diff --git a/forevervm_minimal/README.md b/forevervm_minimal/README.md new file mode 100644 index 0000000..afcabbe --- /dev/null +++ b/forevervm_minimal/README.md @@ -0,0 +1,89 @@ +# ForeverVM Minimal + +A minimal implementation of ForeverVM, a system that provides persistent Python REPL sessions using custom serialization. + +## Overview + +ForeverVM allows you to create Python REPL sessions that persist even after periods of inactivity. It uses custom serialization (pickle) to save the state of the Python environment and restore it when needed. + +## Features + +- **Session Persistence**: Sessions are automatically saved after a period of inactivity (10 minutes by default) and restored when needed. +- **Custom Serialization**: Uses Python's pickle module to serialize the session state. +- **HTTP API**: Provides a simple HTTP API for creating sessions and executing code. + +## Installation + +1. Clone the repository: + ```bash + git clone https://github.com/yourusername/forevervm-minimal.git + cd forevervm-minimal + ``` + +2. Install the required dependencies: + ```bash + pip install flask + ``` + +## Usage + +### Running the Server + +```bash +python -m forevervm_minimal.main +``` + +This will start the HTTP server on port 8000. + +### API Endpoints + +#### Create a Session + +```bash +curl -X POST http://localhost:8000/session +``` + +Response: +```json +{ + "session_id": "unique-session-id" +} +``` + +#### Execute Code in a Session + +```bash +curl -X POST -H "Content-Type: application/json" \ + -d '{"code": "x = 1\nprint(x*2)"}' \ + http://localhost:8000/session/your-session-id/execute +``` + +Response: +```json +{ + "status": "ok", + "output": "Executed: x = 1\nprint(x*2)\n" +} +``` + +## Architecture + +The system consists of several components: + +- **SessionManager**: Manages session lifecycle (create, execute, snapshot, restore). +- **WorkerManager**: Manages a pool of workers (each worker is a separate Python REPL environment). +- **Worker**: Represents a single Python REPL environment. +- **SnapshotStorage**: Handles saving and loading session snapshots. +- **HTTP Server**: Provides a simple HTTP API for interacting with the system. + +## Customization + +You can customize the system by modifying the following parameters: + +- **Inactivity Timeout**: Change the `inactivity_timeout` parameter in `SessionManager` to adjust how long a session can be inactive before it's snapshotted. +- **Worker Pool Size**: Change the `pool_size` parameter in `WorkerManager` to adjust the number of pre-spawned workers. +- **Snapshot Storage Location**: Change the `base_dir` parameter in `LocalFileStorage` to adjust where snapshots are stored. + +## Security Considerations + +This is a minimal implementation and does not include security features like authentication or rate limiting. In a production environment, you should add these features and run each worker in a secure environment (e.g., using gVisor or another container sandbox). \ No newline at end of file diff --git a/forevervm_minimal/__init__.py b/forevervm_minimal/__init__.py new file mode 100644 index 0000000..8019ef1 --- /dev/null +++ b/forevervm_minimal/__init__.py @@ -0,0 +1,2 @@ +# __init__.py +# This file makes the directory a proper Python package \ No newline at end of file diff --git a/forevervm_minimal/custom_serializer.py b/forevervm_minimal/custom_serializer.py new file mode 100644 index 0000000..129dd24 --- /dev/null +++ b/forevervm_minimal/custom_serializer.py @@ -0,0 +1,6 @@ +# custom_serializer.py + +class Serializer: + """Optional: A wrapper around pickle or dill for easy swapping.""" + # For now we do everything directly in worker.py + pass \ No newline at end of file diff --git a/forevervm_minimal/http_server.py b/forevervm_minimal/http_server.py new file mode 100644 index 0000000..ca8a444 --- /dev/null +++ b/forevervm_minimal/http_server.py @@ -0,0 +1,26 @@ +# http_server.py + +from flask import Flask, request, jsonify +import json + +app = Flask(__name__) + +session_manager = None # we'll set this from main.py + +@app.route("/session", methods=["POST"]) +def create_session(): + session_id = session_manager.create_session() + return jsonify({"session_id": session_id}) + +@app.route("/session//execute", methods=["POST"]) +def execute_code(session_id): + data = request.json + code = data.get("code", "") + try: + output = session_manager.execute_code(session_id, code) + return jsonify({"status": "ok", "output": output}) + except Exception as e: + return jsonify({"status": "error", "error": str(e)}), 400 + +def run_http_server(host="0.0.0.0", port=8000): + app.run(host=host, port=port) \ No newline at end of file diff --git a/forevervm_minimal/main.py b/forevervm_minimal/main.py new file mode 100644 index 0000000..fbd6e30 --- /dev/null +++ b/forevervm_minimal/main.py @@ -0,0 +1,38 @@ +# main.py + +import threading +import time +import sys +import os + +# Add the parent directory to sys.path to allow absolute imports +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from forevervm_minimal.session_manager import SessionManager +from forevervm_minimal.snapshot_storage import LocalFileStorage +from forevervm_minimal.worker_manager import WorkerManager +from forevervm_minimal.http_server import run_http_server, app, session_manager as global_session_manager + +def main(): + storage = LocalFileStorage(base_dir="/var/forevervm/snapshots") + worker_manager = WorkerManager(pool_size=2) + session_manager = SessionManager(snapshot_storage=storage, worker_manager=worker_manager) + + # Provide session_manager to the Flask app + global global_session_manager + global_session_manager = session_manager + + # Start background thread for idle checking + def idle_check_loop(): + while True: + time.sleep(60) # check every minute + session_manager.checkpoint_idle_sessions() + + t = threading.Thread(target=idle_check_loop, daemon=True) + t.start() + + # Start the HTTP server + run_http_server() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/forevervm_minimal/requirements.txt b/forevervm_minimal/requirements.txt new file mode 100644 index 0000000..5ceec5e --- /dev/null +++ b/forevervm_minimal/requirements.txt @@ -0,0 +1,2 @@ +flask==2.0.1 +requests==2.26.0 \ No newline at end of file diff --git a/forevervm_minimal/run.sh b/forevervm_minimal/run.sh new file mode 100755 index 0000000..dcf015b --- /dev/null +++ b/forevervm_minimal/run.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# run.sh - Script to run the ForeverVM system + +# Check if Python is installed +if ! command -v python3 &> /dev/null; then + echo "Error: Python 3 is required but not installed." + exit 1 +fi + +# Check if pip is installed +if ! command -v pip3 &> /dev/null; then + echo "Error: pip3 is required but not installed." + exit 1 +fi + +# Install dependencies +echo "Installing dependencies..." +pip3 install -r requirements.txt + +# Run the server +echo "Starting the ForeverVM server..." +python3 -m forevervm_minimal.main \ No newline at end of file diff --git a/forevervm_minimal/session_data.py b/forevervm_minimal/session_data.py new file mode 100644 index 0000000..3556149 --- /dev/null +++ b/forevervm_minimal/session_data.py @@ -0,0 +1,9 @@ +# session_data.py + +class SessionData: + def __init__(self, session_id, status, last_activity, worker, snapshot_path=None): + self.session_id = session_id + self.status = status # active, snapshotted, snapshotting, restoring + self.last_activity = last_activity + self.worker = worker + self.snapshot_path = snapshot_path \ No newline at end of file diff --git a/forevervm_minimal/session_manager.py b/forevervm_minimal/session_manager.py new file mode 100644 index 0000000..081e099 --- /dev/null +++ b/forevervm_minimal/session_manager.py @@ -0,0 +1,100 @@ +# session_manager.py + +import threading +import time +import uuid + +from forevervm_minimal.worker_manager import WorkerManager +from forevervm_minimal.snapshot_storage import SnapshotStorage +from forevervm_minimal.custom_serializer import Serializer +from forevervm_minimal.session_data import SessionData + +class SessionManager: + def __init__(self, snapshot_storage: SnapshotStorage, worker_manager: WorkerManager): + self.snapshot_storage = snapshot_storage + self.worker_manager = worker_manager + + self.sessions = {} # dict: session_id -> SessionData + self.lock = threading.Lock() + + self.inactivity_timeout = 600 # 10 minutes, in seconds + + def create_session(self): + session_id = str(uuid.uuid4()) + # create a new worker + worker = self.worker_manager.get_worker() + + # create SessionData object + session_data = SessionData( + session_id=session_id, + status="active", + last_activity=time.time(), + worker=worker, + snapshot_path=None + ) + + with self.lock: + self.sessions[session_id] = session_data + + return session_id + + def execute_code(self, session_id, code): + with self.lock: + session_data = self.sessions.get(session_id) + if not session_data: + raise ValueError(f"Session {session_id} not found.") + + # If session is snapshotted => restore + if session_data.status == "snapshotted": + self._restore_session(session_data) + + # Now session should be active and have a worker + output = session_data.worker.execute_code(code) + + # Update last activity + session_data.last_activity = time.time() + + return output + + def checkpoint_idle_sessions(self): + """Called periodically by a background thread.""" + with self.lock: + now = time.time() + for session_data in self.sessions.values(): + if session_data.status == "active": + if now - session_data.last_activity > self.inactivity_timeout: + self._checkpoint_session(session_data) + + def _checkpoint_session(self, session_data): + # Mark status -> 'snapshotting' to avoid concurrency issues + session_data.status = "snapshotting" + + # Instruct the worker to produce a serialized environment + pickled_env = session_data.worker.serialize_environment() + + # Store the pickled data in snapshot_storage + snapshot_path = self.snapshot_storage.save_snapshot(session_data.session_id, pickled_env) + session_data.snapshot_path = snapshot_path + + # Release the worker + self.worker_manager.release_worker(session_data.worker) + session_data.worker = None + session_data.status = "snapshotted" + + def _restore_session(self, session_data): + # Mark status -> 'restoring' + session_data.status = "restoring" + + # get a new worker + worker = self.worker_manager.get_worker() + session_data.worker = worker + + # load the pickled environment + pickled_env = self.snapshot_storage.load_snapshot(session_data.session_id) + + # inject environment into worker + worker.restore_environment(pickled_env) + + # mark active + session_data.status = "active" + session_data.last_activity = time.time() \ No newline at end of file diff --git a/forevervm_minimal/snapshot_storage.py b/forevervm_minimal/snapshot_storage.py new file mode 100644 index 0000000..6779980 --- /dev/null +++ b/forevervm_minimal/snapshot_storage.py @@ -0,0 +1,51 @@ +# snapshot_storage.py + +import abc +import os + +class SnapshotStorage(abc.ABC): + @abc.abstractmethod + def save_snapshot(self, session_id: str, snapshot_data: bytes) -> str: + """Save pickled environment. Return path or reference.""" + pass + + @abc.abstractmethod + def load_snapshot(self, session_id: str) -> bytes: + """Load pickled environment from storage.""" + pass + + @abc.abstractmethod + def delete_snapshot(self, session_id: str) -> None: + """Remove snapshot from storage.""" + pass + + +class LocalFileStorage(SnapshotStorage): + def __init__(self, base_dir="/var/forevervm/snapshots"): + self.base_dir = base_dir + os.makedirs(self.base_dir, exist_ok=True) + + def save_snapshot(self, session_id: str, snapshot_data: bytes) -> str: + path = os.path.join(self.base_dir, session_id) + os.makedirs(path, exist_ok=True) + + snapshot_file = os.path.join(path, "env.pkl") + with open(snapshot_file, "wb") as f: + f.write(snapshot_data) + + return snapshot_file + + def load_snapshot(self, session_id: str) -> bytes: + snapshot_file = os.path.join(self.base_dir, session_id, "env.pkl") + with open(snapshot_file, "rb") as f: + data = f.read() + return data + + def delete_snapshot(self, session_id: str) -> None: + snapshot_file = os.path.join(self.base_dir, session_id, "env.pkl") + if os.path.exists(snapshot_file): + os.remove(snapshot_file) + # optionally remove the directory as well + session_dir = os.path.join(self.base_dir, session_id) + if os.path.isdir(session_dir): + os.rmdir(session_dir) \ No newline at end of file diff --git a/forevervm_minimal/test.sh b/forevervm_minimal/test.sh new file mode 100755 index 0000000..5cd3914 --- /dev/null +++ b/forevervm_minimal/test.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# test.sh - Script to test the ForeverVM system + +# Check if Python is installed +if ! command -v python3 &> /dev/null; then + echo "Error: Python 3 is required but not installed." + exit 1 +fi + +# Check if pip is installed +if ! command -v pip3 &> /dev/null; then + echo "Error: pip3 is required but not installed." + exit 1 +fi + +# Install dependencies +echo "Installing dependencies..." +pip3 install -r requirements.txt + +# Run the test client +echo "Running the test client..." +python3 -m forevervm_minimal.test_client \ No newline at end of file diff --git a/forevervm_minimal/test_client.py b/forevervm_minimal/test_client.py new file mode 100644 index 0000000..0dc6a38 --- /dev/null +++ b/forevervm_minimal/test_client.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +# test_client.py + +import requests +import json +import time + +def main(): + base_url = "http://localhost:8000" + + # Create a new session + print("Creating a new session...") + response = requests.post(f"{base_url}/session") + session_data = response.json() + session_id = session_data["session_id"] + print(f"Session created with ID: {session_id}") + + # Execute some code in the session + print("\nExecuting code to define a variable...") + code1 = "x = 42\nprint(f'x = {x}')" + response = requests.post( + f"{base_url}/session/{session_id}/execute", + json={"code": code1} + ) + print(f"Response: {response.json()}") + + # Execute more code that uses the previously defined variable + print("\nExecuting code that uses the previously defined variable...") + code2 = "y = x * 2\nprint(f'y = {y}')" + response = requests.post( + f"{base_url}/session/{session_id}/execute", + json={"code": code2} + ) + print(f"Response: {response.json()}") + + # Simulate inactivity (in a real scenario, you'd wait for the inactivity_timeout) + print("\nSimulating session inactivity...") + print("In a real scenario, you'd wait for the inactivity_timeout (10 minutes by default)") + print("For testing, you can modify the inactivity_timeout in session_manager.py to a smaller value") + + # Execute code after the "inactivity period" to demonstrate session persistence + print("\nExecuting code after the 'inactivity period'...") + code3 = "z = x + y\nprint(f'z = {z}')" + response = requests.post( + f"{base_url}/session/{session_id}/execute", + json={"code": code3} + ) + print(f"Response: {response.json()}") + + print("\nTest completed successfully!") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/forevervm_minimal/worker.py b/forevervm_minimal/worker.py new file mode 100644 index 0000000..2feebe5 --- /dev/null +++ b/forevervm_minimal/worker.py @@ -0,0 +1,66 @@ +# worker.py + +import pickle +import time +import threading +import os +import io +import sys +import traceback + +class Worker: + def __init__(self): + # We'll store the environment as a dictionary (like 'globals()') + # that the user code interacts with + self.env = {} + + def execute_code(self, code): + # Execute code in self.env context and capture stdout + old_stdout = sys.stdout + redirected_output = io.StringIO() + sys.stdout = redirected_output + + result = None + + try: + # Try to compile as an expression first + try: + compiled_code = compile(code, "", "eval") + result = eval(compiled_code, self.env) + except SyntaxError: + # If it's not an expression, compile as a statement + compiled_code = compile(code, "", "exec") + exec(compiled_code, self.env) + + # Get the stdout output + output = redirected_output.getvalue() + + # If there was a result from eval, add it to the output + if result is not None: + if output and not output.endswith('\n'): + output += '\n' + output += f"Result: {result}\n" + + return f"Output:\n{output}" if output else "No output" + + except Exception as e: + # Get the traceback + error_traceback = traceback.format_exc() + return f"Error:\n{error_traceback}" + + finally: + # Restore stdout + sys.stdout = old_stdout + + def serialize_environment(self): + # Convert self.env into a pickle/dill + # We can store only the parts we want + return pickle.dumps(self.env) + + def restore_environment(self, pickled_env): + # Unpickle into self.env + self.env = pickle.loads(pickled_env) + + def terminate(self): + # If we had an external container, we'd do `docker stop` or similar + pass \ No newline at end of file diff --git a/forevervm_minimal/worker_manager.py b/forevervm_minimal/worker_manager.py new file mode 100644 index 0000000..968bfce --- /dev/null +++ b/forevervm_minimal/worker_manager.py @@ -0,0 +1,37 @@ +# worker_manager.py + +import queue +import threading + +from forevervm_minimal.worker import Worker + +class WorkerManager: + def __init__(self, pool_size=2): + self.pool_size = pool_size + self.idle_workers = queue.Queue(maxsize=pool_size) + self.lock = threading.Lock() + + # Optionally pre-spawn a few workers + for _ in range(pool_size): + w = self._spawn_worker() + self.idle_workers.put(w) + + def get_worker(self): + try: + worker = self.idle_workers.get_nowait() + except queue.Empty: + # spawn on demand + worker = self._spawn_worker() + return worker + + def release_worker(self, worker): + # if there's room in idle queue, keep it + with self.lock: + if not self.idle_workers.full(): + self.idle_workers.put(worker) + else: + # or tear down if no space + worker.terminate() + + def _spawn_worker(self): + return Worker() \ No newline at end of file From 0913642bcd70823b39b634d8e7427e8d03f44073 Mon Sep 17 00:00:00 2001 From: Abhishek Tripathi Date: Thu, 13 Mar 2025 15:49:01 +0530 Subject: [PATCH 2/3] feat: move basic implementation in its own folder --- basic_impl/README.md | 151 +++++++ basic_impl/TEST_OUTPUT.md | 103 +++++ basic_impl/plan.md | 491 +++++++++++++++++++++++ basic_impl/prompt.md | 433 ++++++++++++++++++++ basic_impl/run.sh | 5 + basic_impl/run_with_session_snapshots.sh | 13 + basic_impl/scratchpad.md | 85 ++++ basic_impl/server.py | 171 ++++++++ basic_impl/test_concurrent_clients.py | 339 ++++++++++++++++ basic_impl/test_tcp.py | 149 +++++++ basic_impl/test_timeouts.py | 177 ++++++++ 11 files changed, 2117 insertions(+) create mode 100644 basic_impl/README.md create mode 100644 basic_impl/TEST_OUTPUT.md create mode 100644 basic_impl/plan.md create mode 100644 basic_impl/prompt.md create mode 100755 basic_impl/run.sh create mode 100644 basic_impl/run_with_session_snapshots.sh create mode 100644 basic_impl/scratchpad.md create mode 100644 basic_impl/server.py create mode 100644 basic_impl/test_concurrent_clients.py create mode 100755 basic_impl/test_tcp.py create mode 100644 basic_impl/test_timeouts.py diff --git a/basic_impl/README.md b/basic_impl/README.md new file mode 100644 index 0000000..503fd5b --- /dev/null +++ b/basic_impl/README.md @@ -0,0 +1,151 @@ +# gVisor-based Python REPL + +A secure, isolated Python REPL (Read-Eval-Print Loop) environment for executing untrusted code in LLM-based workflows. + +## Setup Instructions + +### Prerequisites + +- Docker +- gVisor (runsc) + +### Installation + +1. Install [gVisor](https://gvisor.dev/docs/user_guide/install/): + ```bash + sudo apt-get update && \ + sudo apt-get install -y \ + apt-transport-https \ + ca-certificates \ + curl \ + gnupg + + # Install runsc + curl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases release main" | sudo tee /etc/apt/sources.list.d/gvisor.list > /dev/null + sudo apt-get update && sudo apt-get install -y runsc + ``` + +2. Configure Docker to use gVisor: + ```bash + sudo runsc install + sudo systemctl restart docker + ``` + +3. Clone the repository: + ```bash + git clone https://github.com/username/gvisor-based-python-repl.git + cd gvisor-based-python-repl + ``` + +## Usage + +### Running the Server + +Execute the run.sh script to start the server: + +```bash +./run.sh +``` + +This will start a Docker container with the Python server running on port 8000. + +### Testing the Server + +You can test the server using the provided test.sh script: + +```bash +python test_concurrent_clients.py +``` + +This will run the `test_concurrent_clients.py` script, which connects to the server, sends Python code to execute, and demonstrates session persistence. See the output in [TEST_OUTPUT.md](./TEST_OUTPUT.md) for details. + +### TCP Protocol + +The server uses a simple protocol for communication: + +1. **Message Format**: Each message (request or response) is prefixed with a 4-byte length field (big-endian), followed by the actual message content encoded as UTF-8 JSON. + +2. **Request Format**: + ```json + { + "code": "Python code to execute", + "session_id": "optional-session-id" + } + ``` + +3. **Response Format**: + ```json + { + "status": "ok|error", + "output": "execution output (if status is ok)", + "error": "error message (if status is error)", + "session_id": "session-id" + } + ``` + +4. **Session Management**: + - If no `session_id` is provided in the request, a new session is created with a unique ID. + - If a `session_id` is provided, the code is executed in the context of that session. + - If the provided `session_id` doesn't exist, an error is returned. + + +## Introduction + +This project provides a secure execution environment for running Python code in the context of Large Language Model (LLM) applications. It leverages gVisor, a container sandbox technology, to create an isolated execution environment that protects the host system from potentially malicious or unintended code execution. + +The primary goal is to enable safe execution of user-provided or LLM-generated code while maintaining strong security boundaries. This is particularly important in AI applications where models might generate or execute code that could potentially harm the underlying system. + +## Architecture + +The system consists of several key components: + +1. **TCP Server**: A Python TCP server that accepts code execution requests and maintains stateful sessions. +2. **Docker Container**: Provides containerization for the Python environment. +3. **gVisor Runtime**: Adds an additional layer of isolation by intercepting and filtering system calls. + +The architecture follows a defense-in-depth approach, with multiple layers of isolation to prevent security breaches. + +## File Descriptions + +- `server.py`: The main Python file that implements a TCP server which executes Python code sent via TCP connections. It maintains stateful sessions with unique IDs, allowing variables and functions defined in one execution to be available in subsequent executions within the same session. +- `run.sh`: A shell script that runs the Python server inside a Docker container using gVisor's runsc runtime for isolation. It mounts the server.py file into the container and exposes port 8000. +- `test.sh`: A shell script that runs the test_tcp.py script to test the server. +- `test_tcp.py`: A Python script that tests the TCP server by connecting to it, sending Python code to execute, and demonstrating session persistence. +- `.gitignore`: A configuration file that specifies files to be ignored by version control. + +## Significance in LLM-based Workflows + +This project addresses several key challenges in LLM-based workflows: + +1. **Code Execution Safety**: Provides a secure environment for executing potentially untrusted code generated by LLMs. + +2. **Persistent State**: Maintains state between executions through session management, allowing for multi-step code generation and execution workflows. + +3. **Isolation**: Ensures that code execution cannot affect the host system, even if the code is malicious or contains vulnerabilities. + +4. **Agentic Workflows**: Enables longer-running agentic workflows where LLMs can generate, execute, and iterate on code based on results. + +5. **Reduced Context Window Usage**: By maintaining state between executions, there's no need to include the entire execution history in the LLM's context window. + +## Security Considerations + +This project implements several layers of security: + +1. **Container Isolation**: Docker provides basic isolation from the host system. +2. **gVisor Sandbox**: Adds an additional layer of security by intercepting and filtering system calls. +3. **TCP Interface**: Limits interaction to a simple TCP API, reducing attack surface. + +### Security Limitations + +While this system provides strong isolation, it is not perfect: + +- Side-channel attacks might still be possible +- Resource exhaustion could affect container performance +- New vulnerabilities in gVisor or Docker could compromise security + +Regular updates and security audits are recommended. + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. \ No newline at end of file diff --git a/basic_impl/TEST_OUTPUT.md b/basic_impl/TEST_OUTPUT.md new file mode 100644 index 0000000..78d6758 --- /dev/null +++ b/basic_impl/TEST_OUTPUT.md @@ -0,0 +1,103 @@ +in first terminal + +```bash +bash run.sh +``` + +--- + +concurrent clients are stateful but isolated + +```bash +❯ python test_concurrent_clients.py +╭──────────────────────────────────────────────╮ +│ Python REPL Server - Concurrent Clients Test │ +╰──────────────────────────────────────────────╯ +Starting client threads... +⠦ Client A: ✅ Completed 0:00:01 +⠦ Client B: ✅ Completed 0:00:01 +✓ Both client threads completed + +╭──────────────────────────────╮ +│ Testing Cross-Session Access │ +╰──────────────────────────────╯ +Attempting to access Client A's variable 'unique_value_A' from Client B's +session +Result: Error: name 'unique_value_A' is not defined + + +Verifying 'common_variable' in Client B's session has Client B's value +Result: common_variable in B session: value_from_client_B_ef1d52 +Is it B's value? True +Is it A's value? False + + +Verifying 'common_variable' in Client A's session has Client A's value +Result: common_variable in A session: value_from_client_A_25686d +Is it A's value? True +Is it B's value? False + + +Attempting to access Client B's variable 'unique_value_B' from Client B's +session (should succeed) +Result: Client B variable unique_value_B: 13734060 + +╭──────────────────────╮ +│ Test Results Summary │ +╰──────────────────────╯ + Client A +╭───────────────────────┬───────────────────────────────────────────────────╮ +│ Property │ Value │ +├───────────────────────┼───────────────────────────────────────────────────┤ +│ Session ID │ d7b97db3-2255-49de-bd45-f7e7bde7414c │ +│ Variable Name │ unique_value_A │ +│ Unique Value │ fef5e358 │ +│ Common Variable Value │ value_from_client_A_25686d │ +│ Step 1 Output │ Client A initialized │ +│ Step 2 Output │ │ +│ Step 3 Output │ Set unique_value_A to fef5e358 │ +│ Step 4 Output │ Set common_variable to value_from_client_A_25686d │ +│ Step 5 Output │ unique_value_A is fef5e358 │ +│ │ common_variable is value_from_client_A_25686d │ +╰───────────────────────┴───────────────────────────────────────────────────╯ + Client B +╭───────────────────────┬───────────────────────────────────────────────────╮ +│ Property │ Value │ +├───────────────────────┼───────────────────────────────────────────────────┤ +│ Session ID │ 270781c9-e2ec-4a5d-862f-e754139de04f │ +│ Variable Name │ unique_value_B │ +│ Unique Value │ 13734060 │ +│ Common Variable Value │ value_from_client_B_ef1d52 │ +│ Step 1 Output │ Client B initialized │ +│ Step 2 Output │ │ +│ Step 3 Output │ Set unique_value_B to 13734060 │ +│ Step 4 Output │ Set common_variable to value_from_client_B_ef1d52 │ +│ Step 5 Output │ unique_value_B is 13734060 │ +│ │ common_variable is value_from_client_B_ef1d52 │ +╰───────────────────────┴───────────────────────────────────────────────────╯ + Cross-Session Test Results +╭─────────────────────────────────┬────────────────────────────────────────────╮ +│ Test │ Result │ +├─────────────────────────────────┼────────────────────────────────────────────┤ +│ B trying to access A's variable │ Error: name 'unique_value_A' is not │ +│ │ defined │ +│ common_variable in B's session │ common_variable in B session: │ +│ │ value_from_client_B_ef1d52 │ +│ │ Is it B's value? True │ +│ │ Is it A's value? False │ +│ common_variable in A's session │ common_variable in A session: │ +│ │ value_from_client_A_25686d │ +│ │ Is it A's value? True │ +│ │ Is it B's value? False │ +│ B accessing its own variable │ Client B variable unique_value_B: 13734060 │ +╰─────────────────────────────────┴────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────────────────────────╮ +│ ✓ Session isolation verified: Clients have independent environments with │ +│ isolated variables │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` + + +--- + +SIMPLER example: diff --git a/basic_impl/plan.md b/basic_impl/plan.md new file mode 100644 index 0000000..4d5ab16 --- /dev/null +++ b/basic_impl/plan.md @@ -0,0 +1,491 @@ +Below is an updated **ForeverVM** code plan that relies on a **custom serialization** approach rather than CRIU. The overall architecture remains similar (managing sessions, worker processes, and snapshots), but our **snapshot/restore** logic will revolve around **serializing Python’s REPL state** (global environment) instead of checkpointing an entire process. This means that only Python objects (variables, functions, classes, etc.) are preserved—not OS-level resources like open file descriptors or running threads. + +--- + +## 1. High-Level Changes + +1. **No CRIU Dependency** + - We will **not** attempt to snapshot the entire Python process at the OS level. + - Instead, we’ll serialize Python objects (e.g., the `globals()` dict used by each session’s REPL). + +2. **Python Environment Serialization** + - We can use `pickle` or `dill` (a superset of pickle that handles more Python object types) to persist the session’s Python state to disk. + - On restore, we start a fresh Python interpreter in a new worker container/process, then **load** (un-pickle) the environment and inject it into that interpreter’s `globals()`. + +3. **Implications** + - Not all Python objects can be pickled (e.g., open file handles, certain C-extensions). + - This approach should be sufficient for typical REPL use cases (variables, functions, etc.), but any code using un-picklable resources might break upon restore. + - We still use **gVisor** for security and resource isolation. + +4. **Architecture** + - Almost the same as the previous plan, except we replace the “CRIU” module with a “custom serializer” module. + - The rest of the system (session manager, worker manager, storage, transport) remains very similar. + +--- + +## 2. Overview of the New Flow + +1. **User Code Execution** + - When the user executes code for session S, we run it in a Python REPL environment inside a worker. This environment is basically a dictionary of `globals()`, plus some helper code. + +2. **Snapshot** (after 10 minutes inactivity) + - The session manager instructs the worker to **serialize** all relevant state (the environment dictionary, i.e., `globals()` or a specialized data structure) into a pickle/dill file. + - We store that file on disk using our `SnapshotStorage` interface, e.g. in `/var/forevervm/snapshots//session.pkl`. + - The worker is terminated or returned to a pool (with a fresh interpreter, not tied to the old environment). + +3. **Restore** (on next request) + - Session manager obtains a new worker instance (fresh Python interpreter). + - Loads the saved pickle file from disk and unpickles it to obtain the environment dictionary. + - Injects that dictionary into the new Python REPL’s `globals()` so that the session picks up exactly where it left off in terms of Python variables, function definitions, etc. + +4. **Execution Continues** + - The user sees the same session state. + +--- + +## 3. Detailed Module Structure + +Below is the revised module layout, focusing on custom serialization in place of CRIU calls. + +### 3.1 `session_manager.py` +Manages session lifecycle (create, execute, snapshot, restore). + +```python +# session_manager.py + +import threading +import time +import uuid + +from .worker_manager import WorkerManager +from .snapshot_storage import SnapshotStorage +from .custom_serializer import Serializer +from .session_data import SessionData + +class SessionManager: + def __init__(self, snapshot_storage: SnapshotStorage, worker_manager: WorkerManager): + self.snapshot_storage = snapshot_storage + self.worker_manager = worker_manager + + self.sessions = {} # dict: session_id -> SessionData + self.lock = threading.Lock() + + self.inactivity_timeout = 600 # 10 minutes, in seconds + + def create_session(self): + session_id = str(uuid.uuid4()) + # create a new worker + worker = self.worker_manager.get_worker() + + # create SessionData object + session_data = SessionData( + session_id=session_id, + status="active", + last_activity=time.time(), + worker=worker, + snapshot_path=None + ) + + with self.lock: + self.sessions[session_id] = session_data + + return session_id + + def execute_code(self, session_id, code): + with self.lock: + session_data = self.sessions.get(session_id) + if not session_data: + raise ValueError(f"Session {session_id} not found.") + + # If session is snapshotted => restore + if session_data.status == "snapshotted": + self._restore_session(session_data) + + # Now session should be active and have a worker + output = session_data.worker.execute_code(code) + + # Update last activity + session_data.last_activity = time.time() + + return output + + def checkpoint_idle_sessions(self): + """Called periodically by a background thread.""" + with self.lock: + now = time.time() + for session_data in self.sessions.values(): + if session_data.status == "active": + if now - session_data.last_activity > self.inactivity_timeout: + self._checkpoint_session(session_data) + + def _checkpoint_session(self, session_data): + # Mark status -> 'snapshotting' to avoid concurrency issues + session_data.status = "snapshotting" + + # Instruct the worker to produce a serialized environment + pickled_env = session_data.worker.serialize_environment() + + # Store the pickled data in snapshot_storage + snapshot_path = self.snapshot_storage.save_snapshot(session_data.session_id, pickled_env) + session_data.snapshot_path = snapshot_path + + # Release the worker + self.worker_manager.release_worker(session_data.worker) + session_data.worker = None + session_data.status = "snapshotted" + + def _restore_session(self, session_data): + # Mark status -> 'restoring' + session_data.status = "restoring" + + # get a new worker + worker = self.worker_manager.get_worker() + session_data.worker = worker + + # load the pickled environment + pickled_env = self.snapshot_storage.load_snapshot(session_data.session_id) + + # inject environment into worker + worker.restore_environment(pickled_env) + + # mark active + session_data.status = "active" + session_data.last_activity = time.time() +``` + +### 3.2 `session_data.py` +A simple data class to hold session metadata. + +```python +# session_data.py + +class SessionData: + def __init__(self, session_id, status, last_activity, worker, snapshot_path=None): + self.session_id = session_id + self.status = status # active, snapshotted, snapshotting, restoring + self.last_activity = last_activity + self.worker = worker + self.snapshot_path = snapshot_path +``` + +### 3.3 `worker_manager.py` +Manages a pool of workers (each worker is a separate Python REPL environment under gVisor). + +```python +# worker_manager.py + +import queue +import threading + +from .worker import Worker + +class WorkerManager: + def __init__(self, pool_size=2): + self.pool_size = pool_size + self.idle_workers = queue.Queue(maxsize=pool_size) + self.lock = threading.Lock() + + # Optionally pre-spawn a few workers + for _ in range(pool_size): + w = self._spawn_worker() + self.idle_workers.put(w) + + def get_worker(self): + try: + worker = self.idle_workers.get_nowait() + except queue.Empty: + # spawn on demand + worker = self._spawn_worker() + return worker + + def release_worker(self, worker): + # if there's room in idle queue, keep it + with self.lock: + if not self.idle_workers.full(): + self.idle_workers.put(worker) + else: + # or tear down if no space + worker.terminate() + + def _spawn_worker(self): + return Worker() + +``` + +### 3.4 `worker.py` +Represents a single sandboxed Python REPL environment (using gVisor for isolation). +Instead of CRIU, we have `serialize_environment()` and `restore_environment()`. + +```python +# worker.py + +import subprocess +import pickle +import time +import threading +import os + +# For a real gVisor approach, you'd run a Docker container with --runtime=runsc. +# However, here we’ll conceptually wrap it. +# We'll assume we have a local Python process started in a container, and we can talk to it +# via a small server or direct method calls if they're in the same process (for demonstration). +# +# For a production approach, you'd talk to a container via RPC, e.g.: +# docker run --rm --runtime=runsc python:3.9 ... +# Then attach to a small server inside that container that runs code. +# This example is a simplified single-process approach. + +class Worker: + def __init__(self): + # We'll store the environment as a dictionary (like 'globals()') + # that the user code interacts with + self.env = {} + + def execute_code(self, code): + # Execute code in self.env context + try: + output_buffer = [] + + # We can redirect stdout, etc. for capturing + exec_locals = {} + exec(code, self.env, exec_locals) + + # gather any print statements or returned values as needed + # for simplicity, we'll assume the code prints to stdout or modifies self.env + # We'll return the final environment's string representation or something + # In practice, you'd capture prints via io.StringIO or something + return f"Executed: {code}\n" + + except Exception as e: + return f"Error: {str(e)}\n" + + def serialize_environment(self): + # Convert self.env into a pickle/dill + # We can store only the parts we want + return pickle.dumps(self.env) + + def restore_environment(self, pickled_env): + # Unpickle into self.env + self.env = pickle.loads(pickled_env) + + def terminate(self): + # If we had an external container, we'd do `docker stop` or similar + pass +``` + +### 3.5 `snapshot_storage.py` +The snapshot interface now deals with **binary data** (pickled environment) instead of CRIU dump files. + +```python +# snapshot_storage.py + +import abc +import os + +class SnapshotStorage(abc.ABC): + @abc.abstractmethod + def save_snapshot(self, session_id: str, snapshot_data: bytes) -> str: + """Save pickled environment. Return path or reference.""" + pass + + @abc.abstractmethod + def load_snapshot(self, session_id: str) -> bytes: + """Load pickled environment from storage.""" + pass + + @abc.abstractmethod + def delete_snapshot(self, session_id: str) -> None: + """Remove snapshot from storage.""" + pass + + +class LocalFileStorage(SnapshotStorage): + def __init__(self, base_dir="/var/forevervm/snapshots"): + self.base_dir = base_dir + os.makedirs(self.base_dir, exist_ok=True) + + def save_snapshot(self, session_id: str, snapshot_data: bytes) -> str: + path = os.path.join(self.base_dir, session_id) + os.makedirs(path, exist_ok=True) + + snapshot_file = os.path.join(path, "env.pkl") + with open(snapshot_file, "wb") as f: + f.write(snapshot_data) + + return snapshot_file + + def load_snapshot(self, session_id: str) -> bytes: + snapshot_file = os.path.join(self.base_dir, session_id, "env.pkl") + with open(snapshot_file, "rb") as f: + data = f.read() + return data + + def delete_snapshot(self, session_id: str) -> None: + snapshot_file = os.path.join(self.base_dir, session_id, "env.pkl") + if os.path.exists(snapshot_file): + os.remove(snapshot_file) + # optionally remove the directory as well + session_dir = os.path.join(self.base_dir, session_id) + if os.path.isdir(session_dir): + os.rmdir(session_dir) +``` + +### 3.6 `custom_serializer.py` +*(Optional)* If you want a dedicated module for controlling pickling, especially if you might switch from `pickle` to `dill`, or add custom logic: + +```python +# custom_serializer.py + +class Serializer: + """Optional: A wrapper around pickle or dill for easy swapping.""" + # For now we do everything directly in worker.py + pass +``` + +### 3.7 `http_server.py` (Transport Layer - HTTP) +Uses a simple Flask app to expose `POST /session` and `POST /session//execute`. + +```python +# http_server.py + +from flask import Flask, request, jsonify +import json + +app = Flask(__name__) + +session_manager = None # we’ll set this from main.py + +@app.route("/session", methods=["POST"]) +def create_session(): + session_id = session_manager.create_session() + return jsonify({"session_id": session_id}) + +@app.route("/session//execute", methods=["POST"]) +def execute_code(session_id): + data = request.json + code = data.get("code", "") + try: + output = session_manager.execute_code(session_id, code) + return jsonify({"status": "ok", "output": output}) + except Exception as e: + return jsonify({"status": "error", "error": str(e)}), 400 + +def run_http_server(host="0.0.0.0", port=8000): + app.run(host=host, port=port) +``` + +### 3.8 `main.py` +Initializes everything and starts the system. + +```python +# main.py + +import threading +import time + +from .session_manager import SessionManager +from .snapshot_storage import LocalFileStorage +from .worker_manager import WorkerManager +from .http_server import run_http_server, app + +def main(): + storage = LocalFileStorage(base_dir="/var/forevervm/snapshots") + worker_manager = WorkerManager(pool_size=2) + session_manager = SessionManager(snapshot_storage=storage, worker_manager=worker_manager) + + # Provide session_manager to the Flask app + from .http_server import session_manager as global_session_manager + global_session_manager = session_manager + + # Start background thread for idle checking + def idle_check_loop(): + while True: + time.sleep(60) # check every minute + session_manager.checkpoint_idle_sessions() + + t = threading.Thread(target=idle_check_loop, daemon=True) + t.start() + + # Start the HTTP server + run_http_server() + +if __name__ == "__main__": + main() +``` + +--- + +## 4. Implementation Instructions for an AI Agent or Developer + +Below are step-by-step instructions to build and run this custom serialization approach: + +1. **Set Up Your Python Environment** + - Create a virtual environment with Python 3.9+ (preferably). + - `pip install flask dill` (or just `pip install flask` and rely on `pickle` if that suffices). + +2. **Create the Package Structure** + - Make a folder `forevervm/` containing the Python modules (`session_manager.py`, `worker_manager.py`, `worker.py`, `snapshot_storage.py`, `session_data.py`, `custom_serializer.py`, and `http_server.py`). + - In `main.py`, import these modules and assemble them as shown. + +3. **Use gVisor** (Optional for Now) + - If you want to run each worker inside a gVisor container, you need to orchestrate Docker with `--runtime=runsc`. + - A full integration involves a real container-based `Worker` that communicates with a small server or uses `exec` to run code inside the container. + - For proof-of-concept, you can keep `Worker` as a local object. + +4. **Implement Worker-Container Integration** (If needed) + - If you want each `Worker` to be an actual container, you might have: + ```python + def _spawn_worker(self): + # Launch container with runsc: + # e.g., docker run -d --runtime=runsc --name=... python:3.9-slim tail -f /dev/null + # Then return a Worker object with container_id = ... + ``` + - For executing code inside it, you might do: + ```python + def execute_code(self, code): + # docker exec python -c + # capture output + ``` + - For serialization, you might store the environment in a volume or a shared path the container can read/write. + +5. **Test the Basic Flow** + - Run `python main.py` (or however you orchestrate it). + - `POST /session` to create a session: + ```bash + curl -X POST http://localhost:8000/session + ``` + - Copy the returned `session_id`. + - `POST /session//execute` with a JSON body: + ```bash + curl -X POST -H "Content-Type: application/json" \ + -d '{"code": "x = 1\nprint(x*2)"}' \ + http://localhost:8000/session//execute + ``` + - Wait 10+ minutes (or lower inactivity threshold for quick testing), then call `execute` again. The session manager should have snapshotted the environment, killed the worker, and now must restore it. Check logs to ensure environment is reloaded. + +6. **Enhance & Debug** + - Ensure the pickling approach works for typical Python code (variables, functions). If you need advanced features (like lambdas, classes, closures), consider `dill` instead of `pickle`. + - If code references un-picklable state (like open sockets), you’ll need to handle that gracefully (the user’s code might break upon restore). + +7. **Security** + - For a real deployment, ensure you run each worker in a secure environment (e.g. gVisor container). + - Add authentication, rate limiting, etc. to the HTTP layer if needed. + +8. **Scale & Productionize** + - Implement logging, metrics, and error handling. + - Consider a more robust storage solution (like S3) if needed. + - Add concurrency controls if many sessions are created or executed in parallel. + +--- + +## 5. Summary + +This plan removes the dependency on CRIU and **instead** uses Python’s built-in (or extended) serialization to store and restore the session’s dictionary-based environment. While this won’t preserve true OS-level process state, it will preserve Python variables, functions, and modules well enough for most REPL-driven logic. The rest of the architecture (session management, transport interface, worker pool, etc.) remains effectively the same. + +By following these steps, an AI agent or any developer can implement and test a custom Python-based serialization approach for “ForeverVM,” giving a persistent REPL experience—while still leveraging gVisor for sandboxing and an HTTP server as a transport. + +----- + +CREATE A NEW FOLDER - forevervm-minimal +AND +implement the above instructions. \ No newline at end of file diff --git a/basic_impl/prompt.md b/basic_impl/prompt.md new file mode 100644 index 0000000..533ad16 --- /dev/null +++ b/basic_impl/prompt.md @@ -0,0 +1,433 @@ +<__init__.py> +L1: # __init__.py +L2: # This file makes the directory a proper Python package + + + +L1: # custom_serializer.py +L2: +L3: class Serializer: +L4: """Optional: A wrapper around pickle or dill for easy swapping.""" +L5: # For now we do everything directly in worker.py +L6: pass + + + +L1: # http_server.py +L2: +L3: from flask import Flask, request, jsonify +L5: +L6: app = Flask(__name__) +L7: +L8: session_manager = None # we'll set this from main.py +L9: +L10: @app.route("/session", methods=["POST"]) +L11: def create_session(): +L12: session_id = session_manager.create_session() +L13: return jsonify({"session_id": session_id}) +L14: +L15: @app.route("/session//execute", methods=["POST"]) +L16: def execute_code(session_id): +L17: data = request.json +L18: code = data.get("code", "") +L19: try: +L20: output = session_manager.execute_code(session_id, code) +L21: return jsonify({"status": "ok", "output": output}) +L22: except Exception as e: +L23: return jsonify({"status": "error", "error": str(e)}), 400 +L24: +L25: def run_http_server(host="0.0.0.0", port=8000): +L26: app.run(host=host, port=port) + + + +L1: # main.py +L2: +L7: +L8: # Add the parent directory to sys.path to allow absolute imports +L9: sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +L10: +L11: from forevervm_minimal.session_manager import SessionManager +L12: from forevervm_minimal.snapshot_storage import LocalFileStorage +L13: from forevervm_minimal.worker_manager import WorkerManager +L14: from forevervm_minimal.http_server import run_http_server, app, session_manager as global_session_manager +L15: +L16: def main(): +L17: storage = LocalFileStorage(base_dir="/var/forevervm/snapshots") +L18: worker_manager = WorkerManager(pool_size=2) +L19: session_manager = SessionManager(snapshot_storage=storage, worker_manager=worker_manager) +L20: +L21: # Provide session_manager to the Flask app +L22: global global_session_manager +L23: global_session_manager = session_manager +L24: +L25: # Start background thread for idle checking +L26: def idle_check_loop(): +L27: while True: +L28: time.sleep(60) # check every minute +L29: session_manager.checkpoint_idle_sessions() +L30: +L31: t = threading.Thread(target=idle_check_loop, daemon=True) +L32: t.start() +L33: +L34: # Start the HTTP server +L35: run_http_server() +L36: +L37: if __name__ == "__main__": +L38: main() + + + +L1: # session_data.py +L2: +L3: class SessionData: +L4: def __init__(self, session_id, status, last_activity, worker, snapshot_path=None): +L5: self.session_id = session_id +L6: self.status = status # active, snapshotted, snapshotting, restoring +L7: self.last_activity = last_activity +L8: self.worker = worker +L9: self.snapshot_path = snapshot_path + + + +L1: # session_manager.py +L2: +L6: +L7: from forevervm_minimal.worker_manager import WorkerManager +L8: from forevervm_minimal.snapshot_storage import SnapshotStorage +L9: from forevervm_minimal.custom_serializer import Serializer +L10: from forevervm_minimal.session_data import SessionData +L11: +L12: class SessionManager: +L13: def __init__(self, snapshot_storage: SnapshotStorage, worker_manager: WorkerManager): +L14: self.snapshot_storage = snapshot_storage +L15: self.worker_manager = worker_manager +L16: +L17: self.sessions = {} # dict: session_id -> SessionData +L18: self.lock = threading.Lock() +L19: +L20: self.inactivity_timeout = 600 # 10 minutes, in seconds +L21: +L22: def create_session(self): +L23: session_id = str(uuid.uuid4()) +L24: # create a new worker +L25: worker = self.worker_manager.get_worker() +L26: +L27: # create SessionData object +L28: session_data = SessionData( +L29: session_id=session_id, +L30: status="active", +L31: last_activity=time.time(), +L32: worker=worker, +L33: snapshot_path=None +L34: ) +L35: +L36: with self.lock: +L37: self.sessions[session_id] = session_data +L38: +L39: return session_id +L40: +L41: def execute_code(self, session_id, code): +L42: with self.lock: +L43: session_data = self.sessions.get(session_id) +L44: if not session_data: +L45: raise ValueError(f"Session {session_id} not found.") +L46: +L47: # If session is snapshotted => restore +L48: if session_data.status == "snapshotted": +L49: self._restore_session(session_data) +L50: +L51: # Now session should be active and have a worker +L52: output = session_data.worker.execute_code(code) +L53: +L54: # Update last activity +L55: session_data.last_activity = time.time() +L56: +L57: return output +L58: +L59: def checkpoint_idle_sessions(self): +L60: """Called periodically by a background thread.""" +L61: with self.lock: +L62: now = time.time() +L63: for session_data in self.sessions.values(): +L64: if session_data.status == "active": +L65: if now - session_data.last_activity > self.inactivity_timeout: +L66: self._checkpoint_session(session_data) +L67: +L68: def _checkpoint_session(self, session_data): +L69: # Mark status -> 'snapshotting' to avoid concurrency issues +L70: session_data.status = "snapshotting" +L71: +L72: # Instruct the worker to produce a serialized environment +L73: pickled_env = session_data.worker.serialize_environment() +L74: +L75: # Store the pickled data in snapshot_storage +L76: snapshot_path = self.snapshot_storage.save_snapshot(session_data.session_id, pickled_env) +L77: session_data.snapshot_path = snapshot_path +L78: +L79: # Release the worker +L80: self.worker_manager.release_worker(session_data.worker) +L81: session_data.worker = None +L82: session_data.status = "snapshotted" +L83: +L84: def _restore_session(self, session_data): +L85: # Mark status -> 'restoring' +L86: session_data.status = "restoring" +L87: +L88: # get a new worker +L89: worker = self.worker_manager.get_worker() +L90: session_data.worker = worker +L91: +L92: # load the pickled environment +L93: pickled_env = self.snapshot_storage.load_snapshot(session_data.session_id) +L94: +L95: # inject environment into worker +L96: worker.restore_environment(pickled_env) +L97: +L98: # mark active +L99: session_data.status = "active" +L100: session_data.last_activity = time.time() + + + +L1: # snapshot_storage.py +L2: +L5: +L6: class SnapshotStorage(abc.ABC): +L7: @abc.abstractmethod +L8: def save_snapshot(self, session_id: str, snapshot_data: bytes) -> str: +L9: """Save pickled environment. Return path or reference.""" +L10: pass +L11: +L12: @abc.abstractmethod +L13: def load_snapshot(self, session_id: str) -> bytes: +L14: """Load pickled environment from storage.""" +L15: pass +L16: +L17: @abc.abstractmethod +L18: def delete_snapshot(self, session_id: str) -> None: +L19: """Remove snapshot from storage.""" +L20: pass +L21: +L22: +L23: class LocalFileStorage(SnapshotStorage): +L24: def __init__(self, base_dir="/var/forevervm/snapshots"): +L25: self.base_dir = base_dir +L26: os.makedirs(self.base_dir, exist_ok=True) +L27: +L28: def save_snapshot(self, session_id: str, snapshot_data: bytes) -> str: +L29: path = os.path.join(self.base_dir, session_id) +L30: os.makedirs(path, exist_ok=True) +L31: +L32: snapshot_file = os.path.join(path, "env.pkl") +L33: with open(snapshot_file, "wb") as f: +L34: f.write(snapshot_data) +L35: +L36: return snapshot_file +L37: +L38: def load_snapshot(self, session_id: str) -> bytes: +L39: snapshot_file = os.path.join(self.base_dir, session_id, "env.pkl") +L40: with open(snapshot_file, "rb") as f: +L41: data = f.read() +L42: return data +L43: +L44: def delete_snapshot(self, session_id: str) -> None: +L45: snapshot_file = os.path.join(self.base_dir, session_id, "env.pkl") +L46: if os.path.exists(snapshot_file): +L47: os.remove(snapshot_file) +L48: # optionally remove the directory as well +L49: session_dir = os.path.join(self.base_dir, session_id) +L50: if os.path.isdir(session_dir): +L51: os.rmdir(session_dir) + + + +L1: #!/usr/bin/env python3 +L2: # test_client.py +L3: +L7: +L8: def main(): +L9: base_url = "http://localhost:8000" +L10: +L11: # Create a new session +L12: print("Creating a new session...") +L13: response = requests.post(f"{base_url}/session") +L14: session_data = response.json() +L15: session_id = session_data["session_id"] +L16: print(f"Session created with ID: {session_id}") +L17: +L18: # Execute some code in the session +L19: print("\nExecuting code to define a variable...") +L20: code1 = "x = 42\nprint(f'x = {x}')" +L21: response = requests.post( +L22: f"{base_url}/session/{session_id}/execute", +L23: json={"code": code1} +L24: ) +L25: print(f"Response: {response.json()}") +L26: +L27: # Execute more code that uses the previously defined variable +L28: print("\nExecuting code that uses the previously defined variable...") +L29: code2 = "y = x * 2\nprint(f'y = {y}')" +L30: response = requests.post( +L31: f"{base_url}/session/{session_id}/execute", +L32: json={"code": code2} +L33: ) +L34: print(f"Response: {response.json()}") +L35: +L36: # Simulate inactivity (in a real scenario, you'd wait for the inactivity_timeout) +L37: print("\nSimulating session inactivity...") +L38: print("In a real scenario, you'd wait for the inactivity_timeout (10 minutes by default)") +L39: print("For testing, you can modify the inactivity_timeout in session_manager.py to a smaller value") +L40: +L41: # Execute code after the "inactivity period" to demonstrate session persistence +L42: print("\nExecuting code after the 'inactivity period'...") +L43: code3 = "z = x + y\nprint(f'z = {z}')" +L44: response = requests.post( +L45: f"{base_url}/session/{session_id}/execute", +L46: json={"code": code3} +L47: ) +L48: print(f"Response: {response.json()}") +L49: +L50: print("\nTest completed successfully!") +L51: +L52: if __name__ == "__main__": +L53: main() + + + +L1: # worker.py +L2: +L10: +L11: class Worker: +L12: def __init__(self): +L13: # We'll store the environment as a dictionary (like 'globals()') +L14: # that the user code interacts with +L15: self.env = {} +L16: +L17: def execute_code(self, code): +L18: # Execute code in self.env context and capture stdout +L19: old_stdout = sys.stdout +L20: redirected_output = io.StringIO() +L21: sys.stdout = redirected_output +L22: +L23: result = None +L24: +L25: try: +L26: # Try to compile as an expression first +L27: try: +L28: compiled_code = compile(code, "", "eval") +L29: result = eval(compiled_code, self.env) +L30: except SyntaxError: +L31: # If it's not an expression, compile as a statement +L32: compiled_code = compile(code, "", "exec") +L33: exec(compiled_code, self.env) +L34: +L35: # Get the stdout output +L36: output = redirected_output.getvalue() +L37: +L38: # If there was a result from eval, add it to the output +L39: if result is not None: +L40: if output and not output.endswith('\n'): +L41: output += '\n' +L42: output += f"Result: {result}\n" +L43: +L44: return f"Output:\n{output}" if output else "No output" +L45: +L46: except Exception as e: +L47: # Get the traceback +L48: error_traceback = traceback.format_exc() +L49: return f"Error:\n{error_traceback}" +L50: +L51: finally: +L52: # Restore stdout +L53: sys.stdout = old_stdout +L54: +L55: def serialize_environment(self): +L56: # Convert self.env into a pickle/dill +L57: # We can store only the parts we want +L58: return pickle.dumps(self.env) +L59: +L60: def restore_environment(self, pickled_env): +L61: # Unpickle into self.env +L62: self.env = pickle.loads(pickled_env) +L63: +L64: def terminate(self): +L65: # If we had an external container, we'd do `docker stop` or similar +L66: pass + + + +L1: # worker_manager.py +L2: +L5: +L6: from forevervm_minimal.worker import Worker +L7: +L8: class WorkerManager: +L9: def __init__(self, pool_size=2): +L10: self.pool_size = pool_size +L11: self.idle_workers = queue.Queue(maxsize=pool_size) +L12: self.lock = threading.Lock() +L13: +L14: # Optionally pre-spawn a few workers +L15: for _ in range(pool_size): +L16: w = self._spawn_worker() +L17: self.idle_workers.put(w) +L18: +L19: def get_worker(self): +L20: try: +L21: worker = self.idle_workers.get_nowait() +L22: except queue.Empty: +L23: # spawn on demand +L24: worker = self._spawn_worker() +L25: return worker +L26: +L27: def release_worker(self, worker): +L28: # if there's room in idle queue, keep it +L29: with self.lock: +L30: if not self.idle_workers.full(): +L31: self.idle_workers.put(worker) +L32: else: +L33: # or tear down if no space +L34: worker.terminate() +L35: +L36: def _spawn_worker(self): +L37: return Worker() + + +------- + +What we want to achieve? + +## 2. Overview of the Flow + +1. **User Code Execution** + - When the user executes code for session S, we run it in a Python REPL environment inside a worker. This environment is basically a dictionary of `globals()`, plus some helper code. + +2. **Snapshot** (after 10 minutes inactivity) + - The session manager instructs the worker to **serialize** all relevant state (the environment dictionary, i.e., `globals()` or a specialized data structure) into a pickle/dill file. + - We store that file on disk using our `SnapshotStorage` interface, e.g. in `/var/forevervm/snapshots//session.pkl`. + - The worker is terminated or returned to a pool (with a fresh interpreter, not tied to the old environment). + +3. **Restore** (on next request) + - Session manager obtains a new worker instance (fresh Python interpreter). + - Loads the saved pickle file from disk and unpickles it to obtain the environment dictionary. + - Injects that dictionary into the new Python REPL’s `globals()` so that the session picks up exactly where it left off in terms of Python variables, function definitions, etc. + +4. **Execution Continues** + - The user sees the same session state. + +------ +INSTRUCTIONS: + +you have to take a cue from the command + +docker run --runtime=runsc --rm -it \ + -v "$(pwd)/server.py:/server.py" \ + -p 8000:8000 \ + python:3.9.21-alpine3.21 \ + python /server.py + +So that the main.py you wrote can be run inside a docker (inside gvisor) + + +------- diff --git a/basic_impl/run.sh b/basic_impl/run.sh new file mode 100755 index 0000000..6ac0a23 --- /dev/null +++ b/basic_impl/run.sh @@ -0,0 +1,5 @@ +docker run --runtime=runsc --rm -it \ + -v "$(pwd)/server.py:/server.py" \ + -p 8000:8000 \ + python:3.9.21-alpine3.21 \ + python /server.py diff --git a/basic_impl/run_with_session_snapshots.sh b/basic_impl/run_with_session_snapshots.sh new file mode 100644 index 0000000..8901aa0 --- /dev/null +++ b/basic_impl/run_with_session_snapshots.sh @@ -0,0 +1,13 @@ +# docker run --runtime=runsc --rm -it \ +# -v "$(pwd)/forevervm_minimal:/app" \ +# -v "$(pwd)/snapshots:/var/forevervm/snapshots" \ +# -p 8000:8000 \ +# python:3.9.21-alpine3.21 \ +# sh -c "cd /app && python main.py" + +docker run --runtime=runsc --rm -it \ + -v "$(pwd)/forevervm_minimal:/app/forevervm_minimal" \ + -v "$(pwd)/snapshots:/var/forevervm/snapshots" \ + -p 8000:8000 \ + python:3.9.21-alpine3.21 \ + sh -c "cd /app/forevervm_minimal && pip install flask && python main.py" \ No newline at end of file diff --git a/basic_impl/scratchpad.md b/basic_impl/scratchpad.md new file mode 100644 index 0000000..e63a964 --- /dev/null +++ b/basic_impl/scratchpad.md @@ -0,0 +1,85 @@ +# Scratchpad + +## Current Task +Debugging and fixing the TCP client test script for the Python REPL server, creating a concurrent clients test, and enhancing the output with Rich library. + +## Progress +[X] Identified the issue: The client was not properly receiving the initial greeting using the length-prefixed protocol +[X] Fixed the client code by adding a `receive_response` function that follows the same protocol as the server +[X] Successfully tested the client against the server +[X] Created a new test script to simulate concurrent clients with different sessions +[X] Improved the concurrent clients test to clearly demonstrate session isolation +[X] Enhanced the test script with Rich library for better console output +[X] Added a common variable name test to verify that both clients can set independent values to the same variable name + +## Lessons +- The server uses a length-prefixed protocol for all communication (both sending and receiving) +- Each message is prefixed with a 4-byte integer (big-endian) indicating the length of the following JSON message +- All messages are JSON-encoded and UTF-8 encoded +- The initial greeting from the server follows the same protocol as all other messages +- When working with custom TCP protocols, it's important to ensure both client and server follow the same message format for all communications +- The server maintains isolated session environments for different clients, ensuring that variables defined in one session are not accessible from another +- When testing session isolation, it's important to use distinct variable names to clearly demonstrate that variables from one session cannot be accessed from another +- The Rich library provides excellent tools for creating visually appealing and more readable console output in Python applications +- Variables with the same name can have different values in different sessions, demonstrating complete session isolation + +## Technical Details +- The server implements a Python REPL that maintains separate session environments +- Each session is identified by a UUID +- The server accepts JSON requests with the following fields: + - `code`: Python code to execute + - `session_id` (optional): ID of an existing session +- The server responds with JSON containing: + - `status`: "ok" or "error" + - `output` or `error`: The output of the code execution or error message + - `session_id`: The ID of the session used + +## Concurrent Clients Test +I created a new test script `test_concurrent_clients.py` that simulates two clients connecting to the server concurrently, each with their own session. The test does the following: + +1. Creates two client threads (Client A and Client B) that connect to the server simultaneously +2. Each client: + - Creates a new session + - Defines a function in its session + - Creates a unique variable with a client-specific name (e.g., `unique_value_A` for Client A) + - Creates a variable with the same name (`common_variable`) but different values in each session + - Verifies the variables are still correct after a delay +3. After both client threads complete, a third connection: + - Attempts to access Client A's variable from Client B's session (should fail) + - Verifies that `common_variable` in Client B's session has Client B's value + - Verifies that `common_variable` in Client A's session has Client A's value + - Attempts to access Client B's variable from Client B's session (should succeed) +4. The test verifies that session isolation is maintained by confirming that: + - Client B cannot access Client A's variables (NameError is raised) + - Client B can access its own variables + - The `common_variable` in each session has a different value, specific to that session + +This test helps verify: +- The server can handle multiple concurrent clients +- Each client maintains its own isolated session state +- Variables defined in one session are not accessible from another session +- Variables with the same name can have different values in different sessions +- The server correctly manages session IDs and their associated environments + +## Rich Library Enhancements +I enhanced the test script with the Rich library to provide a more visually appealing and readable console output. The enhancements include: + +1. Progress tracking with spinners for each client thread +2. Colorful panels to separate different sections of the test +3. Tables to display test results in a structured format +4. Color-coded status messages and results +5. Clear visual indication of test success/failure + +These enhancements make it easier to: +- Monitor the progress of concurrent client threads +- Understand the relationships between sessions and variables +- Quickly identify whether session isolation is properly maintained +- View the test results in a well-organized format + +To run the test: +```bash +python test_concurrent_clients.py +``` + + +https://claude.ai/chat/ad32c8cf-d589-4844-a85f-1084139269ea \ No newline at end of file diff --git a/basic_impl/server.py b/basic_impl/server.py new file mode 100644 index 0000000..bc11ad9 --- /dev/null +++ b/basic_impl/server.py @@ -0,0 +1,171 @@ +import socketserver +import socket +import json +import sys +import traceback +import io +import logging +import uuid +import threading +from concurrent.futures import ThreadPoolExecutor + +# Dictionary to store session environments +sessions = {} +sessions_lock = threading.Lock() + +class PythonREPLHandler(socketserver.BaseRequestHandler): + def handle(self): + """Handle incoming TCP connections.""" + self.request.settimeout(300) # 5-minute timeout + logging.info(f"Connection established from {self.client_address}") + + try: + # Initial greeting with protocol info + self.send_response({ + "status": "ok", + "message": "Python REPL Server. Send JSON with 'code' to execute. Optional 'session_id' to continue a session." + }) + + while True: + # Receive data from client + data = self.receive_data() + if not data: + break + + try: + request = json.loads(data) + + # Extract code and optional session_id + code = request.get('code', '') + session_id = request.get('session_id', None) + + # Process the request + response = self.process_request(code, session_id) + self.send_response(response) + + except json.JSONDecodeError: + self.send_response({ + "status": "error", + "error": "Invalid JSON format" + }) + except Exception as e: + self.send_response({ + "status": "error", + "error": str(e) + }) + except socket.timeout: + logging.info(f"Connection from {self.client_address} timed out") + except ConnectionError: + logging.info(f"Connection from {self.client_address} closed by client") + except Exception as e: + logging.error(f"Error handling connection from {self.client_address}: {str(e)}") + finally: + logging.info(f"Connection from {self.client_address} closed") + + def receive_data(self): + """Receive data from the client.""" + try: + # First receive the message length (4 bytes) + length_bytes = self.request.recv(4) + if not length_bytes: + return None + + # Convert bytes to integer + message_length = int.from_bytes(length_bytes, byteorder='big') + + # Receive the actual message + chunks = [] + bytes_received = 0 + while bytes_received < message_length: + chunk = self.request.recv(min(4096, message_length - bytes_received)) + if not chunk: + raise ConnectionError("Connection closed while receiving data") + chunks.append(chunk) + bytes_received += len(chunk) + + return b''.join(chunks).decode('utf-8') + except Exception as e: + logging.error(f"Error receiving data: {str(e)}") + return None + + def send_response(self, response_dict): + """Send a response to the client.""" + try: + # Convert response to JSON string + response_json = json.dumps(response_dict) + response_bytes = response_json.encode('utf-8') + + # Send message length first (4 bytes) + length = len(response_bytes) + self.request.sendall(length.to_bytes(4, byteorder='big')) + + # Send the actual message + self.request.sendall(response_bytes) + except Exception as e: + logging.error(f"Error sending response: {str(e)}") + + def process_request(self, code, session_id=None): + """Process a code execution request.""" + # If no session_id provided, create a new session + if not session_id: + session_id = str(uuid.uuid4()) + with sessions_lock: + sessions[session_id] = {} + logging.info(f"Created new session: {session_id}") + # If session_id provided but doesn't exist, return error + elif session_id not in sessions: + return { + "status": "error", + "error": f"Session {session_id} not found" + } + + # Execute the code in the session's environment + output = io.StringIO() + try: + old_stdout = sys.stdout + try: + sys.stdout = output + with sessions_lock: + exec(code, sessions[session_id]) + finally: + sys.stdout = old_stdout + + result = output.getvalue() + return { + "status": "ok", + "output": result, + "session_id": session_id + } + except Exception: + tb = traceback.format_exc() + return { + "status": "error", + "error": tb, + "session_id": session_id + } + +class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + daemon_threads = True + +def main(): + # Use TCP configuration + host = "0.0.0.0" # Listen on all interfaces + port = 8000 + + logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") + + # Create and start the server + server = ThreadedTCPServer((host, port), PythonREPLHandler) + + logging.info(f"Python REPL server listening on TCP {host}:{port}") + try: + server.serve_forever() + except KeyboardInterrupt: + logging.info("Server is shutting down") + finally: + server.server_close() + logging.info("Server shut down") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/basic_impl/test_concurrent_clients.py b/basic_impl/test_concurrent_clients.py new file mode 100644 index 0000000..2989af8 --- /dev/null +++ b/basic_impl/test_concurrent_clients.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +import socket +import json +import sys +import threading +import time +import uuid +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text +from rich import box +from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn + +# Initialize Rich console +console = Console() + +def send_receive(sock, request_dict): + """Send a request to the server and receive the response.""" + # Convert request to JSON and encode + request_json = json.dumps(request_dict) + request_bytes = request_json.encode('utf-8') + + # Send message length first (4 bytes) + length = len(request_bytes) + sock.sendall(length.to_bytes(4, byteorder='big')) + + # Send the actual message + sock.sendall(request_bytes) + + # Receive response length (4 bytes) + length_bytes = sock.recv(4) + if not length_bytes: + return None + + # Convert bytes to integer + message_length = int.from_bytes(length_bytes, byteorder='big') + + # Receive the actual response + chunks = [] + bytes_received = 0 + while bytes_received < message_length: + chunk = sock.recv(min(4096, message_length - bytes_received)) + if not chunk: + raise ConnectionError("Connection closed while receiving data") + chunks.append(chunk) + bytes_received += len(chunk) + + response_json = b''.join(chunks).decode('utf-8') + return json.loads(response_json) + +def receive_response(sock): + """Receive a response from the server using the length-prefixed protocol.""" + # Receive response length (4 bytes) + length_bytes = sock.recv(4) + if not length_bytes: + return None + + # Convert bytes to integer + message_length = int.from_bytes(length_bytes, byteorder='big') + + # Receive the actual response + chunks = [] + bytes_received = 0 + while bytes_received < message_length: + chunk = sock.recv(min(4096, message_length - bytes_received)) + if not chunk: + raise ConnectionError("Connection closed while receiving data") + chunks.append(chunk) + bytes_received += len(chunk) + + response_json = b''.join(chunks).decode('utf-8') + return json.loads(response_json) + +def client_session(client_id, results, progress): + """Run a client session that connects to the server and executes code.""" + # Server connection details + host = "localhost" + port = 8000 + + # Create a socket and connect to the server + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + session_id = None + + task_id = progress.add_task(f"[cyan]Client {client_id}", total=6) # Increased total for new step + + try: + sock.connect((host, port)) + progress.update(task_id, description=f"[cyan]Client {client_id}: Connected to {host}:{port}", advance=0.2) + + # Receive initial greeting + greeting = receive_response(sock) + progress.update(task_id, description=f"[cyan]Client {client_id}: Server greeting received", advance=0.2) + + # Step 1: Create a new session by executing code + progress.update(task_id, description=f"[cyan]Client {client_id}: Creating a new session", advance=0.2) + response = send_receive(sock, { + "code": f"client_id = '{client_id}'\nprint(f'Client {client_id} initialized')" + }) + session_id = response.get("session_id") + progress.update(task_id, description=f"[cyan]Client {client_id}: Session ID: {session_id[:8]}...", advance=0.2) + results[client_id]["session_id"] = session_id + results[client_id]["outputs"].append(response.get("output", "")) + + # Step 2: Define a function in the session + progress.update(task_id, description=f"[cyan]Client {client_id}: Defining a function", advance=0.2) + response = send_receive(sock, { + "code": f""" +def get_client_info(): + return f"This is client {client_id} with session {{{session_id}}}" +print(get_client_info()) +""", + "session_id": session_id + }) + results[client_id]["outputs"].append(response.get("output", "")) + + # Step 3: Create a unique variable for this client with a client-specific name + progress.update(task_id, description=f"[cyan]Client {client_id}: Creating a unique variable", advance=0.2) + unique_value = uuid.uuid4().hex[:8] + var_name = f"unique_value_{client_id}" # Use client-specific variable names + response = send_receive(sock, { + "code": f"{var_name} = '{unique_value}'\nprint(f'Set {var_name} to {{{var_name}}}')", + "session_id": session_id + }) + results[client_id]["unique_value"] = unique_value + results[client_id]["var_name"] = var_name + results[client_id]["outputs"].append(response.get("output", "")) + + # Step 4: Create a common variable name with client-specific value + progress.update(task_id, description=f"[cyan]Client {client_id}: Setting common variable", advance=0.2) + common_value = f"value_from_client_{client_id}_{uuid.uuid4().hex[:6]}" + response = send_receive(sock, { + "code": f"common_variable = '{common_value}'\nprint(f'Set common_variable to {{common_variable}}')", + "session_id": session_id + }) + results[client_id]["common_value"] = common_value + results[client_id]["outputs"].append(response.get("output", "")) + + # Step 5: Sleep to simulate concurrent work + progress.update(task_id, description=f"[cyan]Client {client_id}: Simulating work...", advance=0.2) + time.sleep(1) + + # Step 6: Verify the variables are still correct + progress.update(task_id, description=f"[cyan]Client {client_id}: Verifying variables", advance=0.2) + response = send_receive(sock, { + "code": f"print(f'{var_name} is {{{var_name}}}\\ncommon_variable is {{common_variable}}')", + "session_id": session_id + }) + results[client_id]["outputs"].append(response.get("output", "")) + progress.update(task_id, description=f"[cyan]Client {client_id}: ✅ Completed", advance=0.2) + + except Exception as e: + results[client_id]["error"] = str(e) + progress.update(task_id, description=f"[red]Client {client_id}: Error: {str(e)}", advance=1.0) + finally: + sock.close() + if not results[client_id]["error"]: + progress.update(task_id, completed=True) + +def main(): + console.clear() + console.print(Panel.fit( + "[bold cyan]Python REPL Server - Concurrent Clients Test[/bold cyan]", + border_style="cyan" + )) + + # Dictionary to store results from both clients + results = { + "A": {"session_id": None, "unique_value": None, "var_name": None, "common_value": None, "outputs": [], "error": None}, + "B": {"session_id": None, "unique_value": None, "var_name": None, "common_value": None, "outputs": [], "error": None} + } + + # Create and start two client threads with progress bars + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + TimeElapsedColumn(), + console=console + ) as progress: + console.print("[bold]Starting client threads...[/bold]") + + thread_a = threading.Thread(target=client_session, args=("A", results, progress)) + thread_b = threading.Thread(target=client_session, args=("B", results, progress)) + + thread_a.start() + thread_b.start() + + # Wait for both threads to complete + thread_a.join() + thread_b.join() + + console.print("[bold green]✓[/bold green] Both client threads completed\n") + + # Now test cross-session access with a third connection + console.print(Panel.fit( + "[bold yellow]Testing Cross-Session Access[/bold yellow]", + border_style="yellow" + )) + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + with console.status("[cyan]Connecting to server for cross-session test...[/cyan]"): + sock.connect(("localhost", 8000)) + + # Skip greeting + receive_response(sock) + + # Try to access Client A's unique value from Client B's session + session_a = results["A"]["session_id"] + session_b = results["B"]["session_id"] + unique_value_a = results["A"]["unique_value"] + unique_value_b = results["B"]["unique_value"] + var_name_a = results["A"]["var_name"] + var_name_b = results["B"]["var_name"] + common_value_a = results["A"]["common_value"] + common_value_b = results["B"]["common_value"] + + console.print(f"[yellow]Attempting to access Client A's variable [bold]'{var_name_a}'[/bold] from Client B's session[/yellow]") + response = send_receive(sock, { + "code": f"try:\n print(f'Client A variable {var_name_a}: {{{var_name_a}}}')\nexcept NameError as e:\n print(f'Error: {{e}}')", + "session_id": session_b + }) + cross_session_result_a = response.get("output", "") + console.print(f"[cyan]Result:[/cyan] {cross_session_result_a}") + + # Try to access common_variable from Client B's session and verify it has Client B's value + console.print(f"\n[yellow]Verifying [bold]'common_variable'[/bold] in Client B's session has Client B's value[/yellow]") + response = send_receive(sock, { + "code": f"try:\n print(f'common_variable in B session: {{common_variable}}')\n print(f'Is it B\\'s value? {{common_variable == \"{common_value_b}\"}}')\n print(f'Is it A\\'s value? {{common_variable == \"{common_value_a}\"}}')\nexcept NameError as e:\n print(f'Error: {{e}}')", + "session_id": session_b + }) + common_var_b_result = response.get("output", "") + console.print(f"[cyan]Result:[/cyan] {common_var_b_result}") + + # Try to access common_variable from Client A's session and verify it has Client A's value + console.print(f"\n[yellow]Verifying [bold]'common_variable'[/bold] in Client A's session has Client A's value[/yellow]") + response = send_receive(sock, { + "code": f"try:\n print(f'common_variable in A session: {{common_variable}}')\n print(f'Is it A\\'s value? {{common_variable == \"{common_value_a}\"}}')\n print(f'Is it B\\'s value? {{common_variable == \"{common_value_b}\"}}')\nexcept NameError as e:\n print(f'Error: {{e}}')", + "session_id": session_a + }) + common_var_a_result = response.get("output", "") + console.print(f"[cyan]Result:[/cyan] {common_var_a_result}") + + # Also try to access Client B's variable from Client B's session (should succeed) + console.print(f"\n[yellow]Attempting to access Client B's variable [bold]'{var_name_b}'[/bold] from Client B's session (should succeed)[/yellow]") + response = send_receive(sock, { + "code": f"try:\n print(f'Client B variable {var_name_b}: {{{var_name_b}}}')\nexcept NameError as e:\n print(f'Error: {{e}}')", + "session_id": session_b + }) + same_session_result = response.get("output", "") + console.print(f"[cyan]Result:[/cyan] {same_session_result}") + + # Print summary of results in a table + console.print(Panel.fit( + "[bold green]Test Results Summary[/bold green]", + border_style="green" + )) + + # Create a table for Client A + table_a = Table(title="Client A", box=box.ROUNDED, show_header=True, header_style="bold cyan") + table_a.add_column("Property", style="dim") + table_a.add_column("Value") + + table_a.add_row("Session ID", session_a) + table_a.add_row("Variable Name", var_name_a) + table_a.add_row("Unique Value", unique_value_a) + table_a.add_row("Common Variable Value", common_value_a) + + for i, output in enumerate(results["A"]["outputs"]): + table_a.add_row(f"Step {i+1} Output", output.strip()) + + console.print(table_a) + + # Create a table for Client B + table_b = Table(title="Client B", box=box.ROUNDED, show_header=True, header_style="bold cyan") + table_b.add_column("Property", style="dim") + table_b.add_column("Value") + + table_b.add_row("Session ID", session_b) + table_b.add_row("Variable Name", var_name_b) + table_b.add_row("Unique Value", unique_value_b) + table_b.add_row("Common Variable Value", common_value_b) + + for i, output in enumerate(results["B"]["outputs"]): + table_b.add_row(f"Step {i+1} Output", output.strip()) + + console.print(table_b) + + # Create a table for cross-session test results + table_cross = Table(title="Cross-Session Test Results", box=box.ROUNDED, show_header=True, header_style="bold yellow") + table_cross.add_column("Test", style="dim") + table_cross.add_column("Result") + + table_cross.add_row( + "B trying to access A's variable", + cross_session_result_a.strip() + ) + table_cross.add_row( + "common_variable in B's session", + common_var_b_result.strip() + ) + table_cross.add_row( + "common_variable in A's session", + common_var_a_result.strip() + ) + table_cross.add_row( + "B accessing its own variable", + same_session_result.strip() + ) + + console.print(table_cross) + + # Verify session isolation + isolation_verified = ( + "Error: name '" + var_name_a + "' is not defined" in cross_session_result_a and + "Is it A's value? True" in common_var_a_result and + "Is it B's value? False" in common_var_a_result and + "Is it B's value? True" in common_var_b_result and + "Is it A's value? False" in common_var_b_result + ) + + if isolation_verified: + console.print(Panel.fit( + "[bold green]✓ Session isolation verified: Clients have independent environments with isolated variables[/bold green]", + border_style="green" + )) + else: + console.print(Panel.fit( + "[bold red]⚠ WARNING: Session isolation may be compromised![/bold red]", + border_style="red" + )) + + except Exception as e: + console.print(f"[bold red]Error in cross-session test: {e}[/bold red]") + finally: + sock.close() + +if __name__ == "__main__": + main() diff --git a/basic_impl/test_tcp.py b/basic_impl/test_tcp.py new file mode 100755 index 0000000..505fd3e --- /dev/null +++ b/basic_impl/test_tcp.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +import socket +import json +import sys + +def send_receive(sock, request_dict): + """Send a request to the server and receive the response.""" + # Convert request to JSON and encode + request_json = json.dumps(request_dict) + request_bytes = request_json.encode('utf-8') + + # Send message length first (4 bytes) + length = len(request_bytes) + sock.sendall(length.to_bytes(4, byteorder='big')) + + # Send the actual message + sock.sendall(request_bytes) + + # Receive response length (4 bytes) + length_bytes = sock.recv(4) + if not length_bytes: + return None + + # Convert bytes to integer + message_length = int.from_bytes(length_bytes, byteorder='big') + + # Receive the actual response + chunks = [] + bytes_received = 0 + while bytes_received < message_length: + chunk = sock.recv(min(4096, message_length - bytes_received)) + if not chunk: + raise ConnectionError("Connection closed while receiving data") + chunks.append(chunk) + bytes_received += len(chunk) + + response_json = b''.join(chunks).decode('utf-8') + return json.loads(response_json) + +def receive_response(sock): + """Receive a response from the server using the length-prefixed protocol.""" + # Receive response length (4 bytes) + length_bytes = sock.recv(4) + if not length_bytes: + return None + + # Convert bytes to integer + message_length = int.from_bytes(length_bytes, byteorder='big') + + # Receive the actual response + chunks = [] + bytes_received = 0 + while bytes_received < message_length: + chunk = sock.recv(min(4096, message_length - bytes_received)) + if not chunk: + raise ConnectionError("Connection closed while receiving data") + chunks.append(chunk) + bytes_received += len(chunk) + + response_json = b''.join(chunks).decode('utf-8') + return json.loads(response_json) + +def main(): + # Server connection details + host = "localhost" + port = 8000 + + # Create a socket and connect to the server + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.connect((host, port)) + print(f"Connected to {host}:{port}") + + # Receive initial greeting using the length-prefixed protocol + greeting = receive_response(sock) + print(f"Server greeting: {json.dumps(greeting, indent=2)}") + + # Test 1: Execute code without a session ID (creates a new session) + print("\n--- Test 1: Execute code without a session ID ---") + response = send_receive(sock, { + "code": "x = 42\nprint(f'x = {x}')" + }) + print(f"Response: {json.dumps(response, indent=2)}") + + # Save the session ID for later use + session_id = response.get("session_id") + print(f"Session ID: {session_id}") + + # Test 2: Execute code in the same session (using the session ID) + print("\n--- Test 2: Execute code in the same session ---") + response = send_receive(sock, { + "code": "y = x * 2\nprint(f'y = {y}')", + "session_id": session_id + }) + print(f"Response: {json.dumps(response, indent=2)}") + + # Test 3: Define a function in the session + print("\n--- Test 3: Define a function in the session ---") + response = send_receive(sock, { + "code": """ +def greet(name): + return f"Hello, {name}!" +print(greet("World")) +""", + "session_id": session_id + }) + print(f"Response: {json.dumps(response, indent=2)}") + + # Test 4: Call the function defined in the previous request + print("\n--- Test 4: Call the function defined in the previous request ---") + response = send_receive(sock, { + "code": "print(greet('Python'))", + "session_id": session_id + }) + print(f"Response: {json.dumps(response, indent=2)}") + + # Test 5: Create a new session + print("\n--- Test 5: Create a new session ---") + response = send_receive(sock, { + "code": "print('This is a new session')" + }) + print(f"Response: {json.dumps(response, indent=2)}") + new_session_id = response.get("session_id") + print(f"New Session ID: {new_session_id}") + + # Test 6: Verify the new session doesn't have access to variables from the first session + print("\n--- Test 6: Verify session isolation ---") + response = send_receive(sock, { + "code": "try:\n print(f'x = {x}')\nexcept NameError as e:\n print(f'Error: {e}')", + "session_id": new_session_id + }) + print(f"Response: {json.dumps(response, indent=2)}") + + # Test 7: Try to access a non-existent session + print("\n--- Test 7: Try to access a non-existent session ---") + response = send_receive(sock, { + "code": "print('This should fail')", + "session_id": "non-existent-session-id" + }) + print(f"Response: {json.dumps(response, indent=2)}") + + except Exception as e: + print(f"Error: {e}") + finally: + sock.close() + print("Connection closed") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/basic_impl/test_timeouts.py b/basic_impl/test_timeouts.py new file mode 100644 index 0000000..6de0a97 --- /dev/null +++ b/basic_impl/test_timeouts.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +import socket +import json +import sys +import threading +import time +import uuid +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich import box +from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn + +console = Console() + +def send_receive(sock, request_dict): + """Send a request to the server and receive the response (length-prefixed JSON).""" + request_json = json.dumps(request_dict) + request_bytes = request_json.encode('utf-8') + + # Send the length first (4 bytes, big-endian) + length = len(request_bytes) + sock.sendall(length.to_bytes(4, 'big')) + # Then send the actual data + sock.sendall(request_bytes) + + # Read response length + length_bytes = sock.recv(4) + if not length_bytes: + return {} + response_length = int.from_bytes(length_bytes, 'big') + + # Read response data + chunks = [] + bytes_received = 0 + while bytes_received < response_length: + chunk = sock.recv(min(4096, response_length - bytes_received)) + if not chunk: + break + chunks.append(chunk) + bytes_received += len(chunk) + + response_str = b''.join(chunks).decode('utf-8') + return json.loads(response_str) + +def receive_greeting(sock): + """Receive a single greeting message from the server (length-prefixed JSON).""" + length_bytes = sock.recv(4) + if not length_bytes: + return None + message_length = int.from_bytes(length_bytes, 'big') + chunks = [] + bytes_received = 0 + while bytes_received < message_length: + chunk = sock.recv(min(4096, message_length - bytes_received)) + if not chunk: + break + chunks.append(chunk) + bytes_received += len(chunk) + data = b''.join(chunks).decode('utf-8') + return json.loads(data) + +def client_thread(name, results, progress): + """ + Each client: + 1. Connects to the server, receives greeting. + 2. Creates a session by sending code with no session_id (server should create new). + 3. Defines a variable, prints it. + 4. Sleeps long enough to trigger session timeout (6s). + 5. Executes code again to see if the session was snapshotted+restored. + """ + host = "localhost" + port = 8000 + + task_id = progress.add_task(f"[green]{name} Starting", total=5) + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + session_id = None + try: + sock.connect((host, port)) + progress.update(task_id, description=f"[green]{name}: Connected", advance=1) + + # 1) Receive initial greeting + greeting = receive_greeting(sock) + results[name]["greeting"] = greeting + + # 2) Create a new session by sending some code (omitting session_id => server should create it) + progress.update(task_id, description=f"[green]{name}: Creating session", advance=1) + resp_create = send_receive(sock, { + "code": f"client_name = '{name}'\nprint('Hello from {name}!')" + }) + # The server should respond with something like: {"session_id": "...", "output": "..."} + session_id = resp_create.get("session_id") + results[name]["session_id"] = session_id + results[name]["outputs"].append(resp_create.get("output", "")) + + # 3) Define a variable and print it + progress.update(task_id, description=f"[green]{name}: Defining variable", advance=1) + myvar = f"value_{uuid.uuid4().hex[:6]}" + code_str = f"""myvar_{name} = '{myvar}'\nprint('Set myvar_{name} = ' + myvar_{name})""" + resp_define = send_receive(sock, { + "session_id": session_id, + "code": code_str + }) + results[name]["outputs"].append(resp_define.get("output", "")) + + # 4) Sleep 7s to exceed the 6s inactivity timeout + progress.update(task_id, description=f"[yellow]{name}: Sleeping 7s (timeout test)", advance=1) + time.sleep(7) + + # 5) Execute new code => triggers restore if session was snapshotted + progress.update(task_id, description=f"[green]{name}: Checking session restore", advance=1) + resp_after_sleep = send_receive(sock, { + "session_id": session_id, + "code": f"print('After timeout, myvar_{name} is ' + myvar_{name})" + }) + results[name]["outputs"].append(resp_after_sleep.get("output", "")) + + progress.update(task_id, description=f"[green]{name}: Finished", completed=True) + + except Exception as e: + results[name]["error"] = str(e) + finally: + sock.close() + +def main(): + console.clear() + console.print(Panel.fit("[bold cyan]Timeout & Concurrency Test (6s Inactivity)[/bold cyan]", border_style="cyan")) + + # We'll track results from multiple clients + # For demonstration, let's spin up two concurrent clients + results = { + "ClientA": {"session_id": None, "greeting": None, "outputs": [], "error": None}, + "ClientB": {"session_id": None, "greeting": None, "outputs": [], "error": None} + } + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + TimeElapsedColumn(), + console=console + ) as progress: + console.print("[bold]Starting two client threads...[/bold]\n") + + tA = threading.Thread(target=client_thread, args=("ClientA", results, progress)) + tB = threading.Thread(target=client_thread, args=("ClientB", results, progress)) + + tA.start() + tB.start() + + tA.join() + tB.join() + + console.print("\n[bold green]All client threads completed![/bold green]\n") + + # Present a brief results summary table + table = Table(title="Results Summary", box=box.ROUNDED, show_header=True, header_style="bold magenta") + table.add_column("Client") + table.add_column("Session ID") + table.add_column("Error") + table.add_column("Outputs") + + for client_name, data in results.items(): + session_id = data["session_id"] or "[None]" + error = data["error"] or "[None]" + outputs_joined = "\n---\n".join(data["outputs"]) if data["outputs"] else "[No outputs]" + table.add_row(client_name, session_id, error, outputs_joined) + + console.print(table) + + console.print(Panel.fit( + "[bold green]✓ Test completed. Check the logs above or your server logs to confirm sessions got snapshotted/restored after 6s of inactivity.[/bold green]", + border_style="green" + )) + +if __name__ == "__main__": + main() From f0a7db4b3381d5ceaa3799eba633c2a984a4344b Mon Sep 17 00:00:00 2001 From: Abhishek Tripathi Date: Thu, 13 Mar 2025 15:49:30 +0530 Subject: [PATCH 3/3] feat: forevervm_minimal implementation with session - idle_session_checker - restore session --- README.md | 151 --------- TEST_OUTPUT.md | 103 ------ forevervm_minimal/.gitignore | 4 + forevervm_minimal/.python-version | 1 + forevervm_minimal/README.md | 31 +- forevervm_minimal/component_factory.py | 56 ++++ forevervm_minimal/config.py | 31 ++ forevervm_minimal/http_server.py | 23 +- forevervm_minimal/main.py | 45 ++- forevervm_minimal/pyproject.toml | 11 + forevervm_minimal/requirements.txt | 2 - forevervm_minimal/scratchpad.md | 156 +++++++++ forevervm_minimal/session_manager.py | 5 +- forevervm_minimal/snapshot_storage.py | 2 +- forevervm_minimal/task_manager.py | 98 ++++++ forevervm_minimal/test.sh | 23 -- forevervm_minimal/test_client.py | 70 ++-- forevervm_minimal/test_client_concurrent.py | 248 ++++++++++++++ forevervm_minimal/uv.lock | 233 ++++++++++++++ run.sh | 5 - server.py | 171 ---------- test_concurrent_clients.py | 339 -------------------- test_tcp.py | 149 --------- 23 files changed, 960 insertions(+), 997 deletions(-) delete mode 100644 README.md delete mode 100644 TEST_OUTPUT.md create mode 100644 forevervm_minimal/.gitignore create mode 100644 forevervm_minimal/.python-version create mode 100644 forevervm_minimal/component_factory.py create mode 100644 forevervm_minimal/config.py create mode 100644 forevervm_minimal/pyproject.toml delete mode 100644 forevervm_minimal/requirements.txt create mode 100644 forevervm_minimal/scratchpad.md create mode 100644 forevervm_minimal/task_manager.py delete mode 100755 forevervm_minimal/test.sh create mode 100644 forevervm_minimal/test_client_concurrent.py create mode 100644 forevervm_minimal/uv.lock delete mode 100755 run.sh delete mode 100644 server.py delete mode 100644 test_concurrent_clients.py delete mode 100755 test_tcp.py diff --git a/README.md b/README.md deleted file mode 100644 index 503fd5b..0000000 --- a/README.md +++ /dev/null @@ -1,151 +0,0 @@ -# gVisor-based Python REPL - -A secure, isolated Python REPL (Read-Eval-Print Loop) environment for executing untrusted code in LLM-based workflows. - -## Setup Instructions - -### Prerequisites - -- Docker -- gVisor (runsc) - -### Installation - -1. Install [gVisor](https://gvisor.dev/docs/user_guide/install/): - ```bash - sudo apt-get update && \ - sudo apt-get install -y \ - apt-transport-https \ - ca-certificates \ - curl \ - gnupg - - # Install runsc - curl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases release main" | sudo tee /etc/apt/sources.list.d/gvisor.list > /dev/null - sudo apt-get update && sudo apt-get install -y runsc - ``` - -2. Configure Docker to use gVisor: - ```bash - sudo runsc install - sudo systemctl restart docker - ``` - -3. Clone the repository: - ```bash - git clone https://github.com/username/gvisor-based-python-repl.git - cd gvisor-based-python-repl - ``` - -## Usage - -### Running the Server - -Execute the run.sh script to start the server: - -```bash -./run.sh -``` - -This will start a Docker container with the Python server running on port 8000. - -### Testing the Server - -You can test the server using the provided test.sh script: - -```bash -python test_concurrent_clients.py -``` - -This will run the `test_concurrent_clients.py` script, which connects to the server, sends Python code to execute, and demonstrates session persistence. See the output in [TEST_OUTPUT.md](./TEST_OUTPUT.md) for details. - -### TCP Protocol - -The server uses a simple protocol for communication: - -1. **Message Format**: Each message (request or response) is prefixed with a 4-byte length field (big-endian), followed by the actual message content encoded as UTF-8 JSON. - -2. **Request Format**: - ```json - { - "code": "Python code to execute", - "session_id": "optional-session-id" - } - ``` - -3. **Response Format**: - ```json - { - "status": "ok|error", - "output": "execution output (if status is ok)", - "error": "error message (if status is error)", - "session_id": "session-id" - } - ``` - -4. **Session Management**: - - If no `session_id` is provided in the request, a new session is created with a unique ID. - - If a `session_id` is provided, the code is executed in the context of that session. - - If the provided `session_id` doesn't exist, an error is returned. - - -## Introduction - -This project provides a secure execution environment for running Python code in the context of Large Language Model (LLM) applications. It leverages gVisor, a container sandbox technology, to create an isolated execution environment that protects the host system from potentially malicious or unintended code execution. - -The primary goal is to enable safe execution of user-provided or LLM-generated code while maintaining strong security boundaries. This is particularly important in AI applications where models might generate or execute code that could potentially harm the underlying system. - -## Architecture - -The system consists of several key components: - -1. **TCP Server**: A Python TCP server that accepts code execution requests and maintains stateful sessions. -2. **Docker Container**: Provides containerization for the Python environment. -3. **gVisor Runtime**: Adds an additional layer of isolation by intercepting and filtering system calls. - -The architecture follows a defense-in-depth approach, with multiple layers of isolation to prevent security breaches. - -## File Descriptions - -- `server.py`: The main Python file that implements a TCP server which executes Python code sent via TCP connections. It maintains stateful sessions with unique IDs, allowing variables and functions defined in one execution to be available in subsequent executions within the same session. -- `run.sh`: A shell script that runs the Python server inside a Docker container using gVisor's runsc runtime for isolation. It mounts the server.py file into the container and exposes port 8000. -- `test.sh`: A shell script that runs the test_tcp.py script to test the server. -- `test_tcp.py`: A Python script that tests the TCP server by connecting to it, sending Python code to execute, and demonstrating session persistence. -- `.gitignore`: A configuration file that specifies files to be ignored by version control. - -## Significance in LLM-based Workflows - -This project addresses several key challenges in LLM-based workflows: - -1. **Code Execution Safety**: Provides a secure environment for executing potentially untrusted code generated by LLMs. - -2. **Persistent State**: Maintains state between executions through session management, allowing for multi-step code generation and execution workflows. - -3. **Isolation**: Ensures that code execution cannot affect the host system, even if the code is malicious or contains vulnerabilities. - -4. **Agentic Workflows**: Enables longer-running agentic workflows where LLMs can generate, execute, and iterate on code based on results. - -5. **Reduced Context Window Usage**: By maintaining state between executions, there's no need to include the entire execution history in the LLM's context window. - -## Security Considerations - -This project implements several layers of security: - -1. **Container Isolation**: Docker provides basic isolation from the host system. -2. **gVisor Sandbox**: Adds an additional layer of security by intercepting and filtering system calls. -3. **TCP Interface**: Limits interaction to a simple TCP API, reducing attack surface. - -### Security Limitations - -While this system provides strong isolation, it is not perfect: - -- Side-channel attacks might still be possible -- Resource exhaustion could affect container performance -- New vulnerabilities in gVisor or Docker could compromise security - -Regular updates and security audits are recommended. - -## License - -This project is licensed under the MIT License - see the LICENSE file for details. \ No newline at end of file diff --git a/TEST_OUTPUT.md b/TEST_OUTPUT.md deleted file mode 100644 index 78d6758..0000000 --- a/TEST_OUTPUT.md +++ /dev/null @@ -1,103 +0,0 @@ -in first terminal - -```bash -bash run.sh -``` - ---- - -concurrent clients are stateful but isolated - -```bash -❯ python test_concurrent_clients.py -╭──────────────────────────────────────────────╮ -│ Python REPL Server - Concurrent Clients Test │ -╰──────────────────────────────────────────────╯ -Starting client threads... -⠦ Client A: ✅ Completed 0:00:01 -⠦ Client B: ✅ Completed 0:00:01 -✓ Both client threads completed - -╭──────────────────────────────╮ -│ Testing Cross-Session Access │ -╰──────────────────────────────╯ -Attempting to access Client A's variable 'unique_value_A' from Client B's -session -Result: Error: name 'unique_value_A' is not defined - - -Verifying 'common_variable' in Client B's session has Client B's value -Result: common_variable in B session: value_from_client_B_ef1d52 -Is it B's value? True -Is it A's value? False - - -Verifying 'common_variable' in Client A's session has Client A's value -Result: common_variable in A session: value_from_client_A_25686d -Is it A's value? True -Is it B's value? False - - -Attempting to access Client B's variable 'unique_value_B' from Client B's -session (should succeed) -Result: Client B variable unique_value_B: 13734060 - -╭──────────────────────╮ -│ Test Results Summary │ -╰──────────────────────╯ - Client A -╭───────────────────────┬───────────────────────────────────────────────────╮ -│ Property │ Value │ -├───────────────────────┼───────────────────────────────────────────────────┤ -│ Session ID │ d7b97db3-2255-49de-bd45-f7e7bde7414c │ -│ Variable Name │ unique_value_A │ -│ Unique Value │ fef5e358 │ -│ Common Variable Value │ value_from_client_A_25686d │ -│ Step 1 Output │ Client A initialized │ -│ Step 2 Output │ │ -│ Step 3 Output │ Set unique_value_A to fef5e358 │ -│ Step 4 Output │ Set common_variable to value_from_client_A_25686d │ -│ Step 5 Output │ unique_value_A is fef5e358 │ -│ │ common_variable is value_from_client_A_25686d │ -╰───────────────────────┴───────────────────────────────────────────────────╯ - Client B -╭───────────────────────┬───────────────────────────────────────────────────╮ -│ Property │ Value │ -├───────────────────────┼───────────────────────────────────────────────────┤ -│ Session ID │ 270781c9-e2ec-4a5d-862f-e754139de04f │ -│ Variable Name │ unique_value_B │ -│ Unique Value │ 13734060 │ -│ Common Variable Value │ value_from_client_B_ef1d52 │ -│ Step 1 Output │ Client B initialized │ -│ Step 2 Output │ │ -│ Step 3 Output │ Set unique_value_B to 13734060 │ -│ Step 4 Output │ Set common_variable to value_from_client_B_ef1d52 │ -│ Step 5 Output │ unique_value_B is 13734060 │ -│ │ common_variable is value_from_client_B_ef1d52 │ -╰───────────────────────┴───────────────────────────────────────────────────╯ - Cross-Session Test Results -╭─────────────────────────────────┬────────────────────────────────────────────╮ -│ Test │ Result │ -├─────────────────────────────────┼────────────────────────────────────────────┤ -│ B trying to access A's variable │ Error: name 'unique_value_A' is not │ -│ │ defined │ -│ common_variable in B's session │ common_variable in B session: │ -│ │ value_from_client_B_ef1d52 │ -│ │ Is it B's value? True │ -│ │ Is it A's value? False │ -│ common_variable in A's session │ common_variable in A session: │ -│ │ value_from_client_A_25686d │ -│ │ Is it A's value? True │ -│ │ Is it B's value? False │ -│ B accessing its own variable │ Client B variable unique_value_B: 13734060 │ -╰─────────────────────────────────┴────────────────────────────────────────────╯ -╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ Session isolation verified: Clients have independent environments with │ -│ isolated variables │ -╰──────────────────────────────────────────────────────────────────────────────╯ -``` - - ---- - -SIMPLER example: diff --git a/forevervm_minimal/.gitignore b/forevervm_minimal/.gitignore new file mode 100644 index 0000000..48eb7f8 --- /dev/null +++ b/forevervm_minimal/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ + +.venv/ +data_dir/ \ No newline at end of file diff --git a/forevervm_minimal/.python-version b/forevervm_minimal/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/forevervm_minimal/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/forevervm_minimal/README.md b/forevervm_minimal/README.md index afcabbe..f2c7cdb 100644 --- a/forevervm_minimal/README.md +++ b/forevervm_minimal/README.md @@ -16,13 +16,13 @@ ForeverVM allows you to create Python REPL sessions that persist even after peri 1. Clone the repository: ```bash - git clone https://github.com/yourusername/forevervm-minimal.git - cd forevervm-minimal + git clone https://github.com/tripathi456/forevervm-minimal.git + cd forevervm-minimal/forevervm_minimal ``` -2. Install the required dependencies: +2. just run using `uv` (astral's python package manager) ```bash - pip install flask + uv run main.py ``` ## Usage @@ -35,6 +35,21 @@ python -m forevervm_minimal.main This will start the HTTP server on port 8000. +### Testing the Server + +The repository includes a test client that demonstrates session creation, code execution, and session persistence: + +```bash +python test_client.py +``` + +The test client performs the following steps: +1. Creates a new session +2. Executes code to define variables +3. Modifies the session state +4. Waits for session inactivity timeout (configurable in config.py) +5. Verifies session restoration after inactivity + ### API Endpoints #### Create a Session @@ -66,6 +81,14 @@ Response: } ``` +### Configuration + +Key settings can be configured through environment variables or in `config.py`: +- `FOREVERVM_SESSION_TIMEOUT`: Session inactivity timeout (default: 6 seconds) +- `FOREVERVM_CLEANUP_INTERVAL`: Interval to check for inactive sessions (default: 2 seconds) +- `FOREVERVM_PORT`: Server port (default: 8000) +- `FOREVERVM_HOST`: Server host (default: 0.0.0.0) + ## Architecture The system consists of several components: diff --git a/forevervm_minimal/component_factory.py b/forevervm_minimal/component_factory.py new file mode 100644 index 0000000..8d4c3f9 --- /dev/null +++ b/forevervm_minimal/component_factory.py @@ -0,0 +1,56 @@ +"""Component factory for ForeverVM service. + +This module handles the initialization and lifecycle of core components +while maintaining proper dependency injection and separation of concerns. +""" + +from typing import Optional +import threading +from dataclasses import dataclass + +from forevervm_minimal.session_manager import SessionManager +from forevervm_minimal.snapshot_storage import LocalFileStorage +from forevervm_minimal.worker_manager import WorkerManager +from forevervm_minimal.task_manager import TaskManager +from forevervm_minimal.config import ( + SNAPSHOT_DIR, + WORKER_POOL_SIZE, + SESSION_CLEANUP_INTERVAL, +) + +@dataclass +class ServiceComponents: + """Container for core service components.""" + storage: LocalFileStorage + worker_manager: WorkerManager + session_manager: SessionManager + task_manager: TaskManager + +def create_components() -> ServiceComponents: + """Create and wire up all required service components.""" + # Initialize storage first as it has no dependencies + storage = LocalFileStorage(base_dir=SNAPSHOT_DIR) + + # Initialize worker manager next + worker_manager = WorkerManager(pool_size=WORKER_POOL_SIZE) + + # Initialize session manager with its dependencies + session_manager = SessionManager( + snapshot_storage=storage, + worker_manager=worker_manager + ) + + # Initialize task manager and add background tasks + task_manager = TaskManager() + task_manager.add_task( + name="idle_session_checker", + interval=SESSION_CLEANUP_INTERVAL, + callback=session_manager.checkpoint_idle_sessions + ) + + return ServiceComponents( + storage=storage, + worker_manager=worker_manager, + session_manager=session_manager, + task_manager=task_manager + ) diff --git a/forevervm_minimal/config.py b/forevervm_minimal/config.py new file mode 100644 index 0000000..e0b8d3b --- /dev/null +++ b/forevervm_minimal/config.py @@ -0,0 +1,31 @@ +"""Configuration management for the ForeverVM service. + +Following the Zen of Python: +- Explicit is better than implicit +- Simple is better than complex +- Configuration should be discoverable +""" + +import os +from pathlib import Path + +# Base Paths +BASE_DIR = Path(os.path.dirname(os.path.abspath(__file__))) +DATA_DIR = os.getenv('FOREVERVM_DATA_DIR', BASE_DIR / 'data_dir') +SNAPSHOT_DIR = os.getenv('FOREVERVM_SNAPSHOT_DIR', DATA_DIR / 'snapshots') + +# Worker Configuration +WORKER_POOL_SIZE = int(os.getenv('FOREVERVM_WORKER_POOL_SIZE', '2')) +WORKER_SPAWN_TIMEOUT = int(os.getenv('FOREVERVM_WORKER_SPAWN_TIMEOUT', '5')) # seconds + +# Session Configuration +SESSION_INACTIVITY_TIMEOUT = int(os.getenv('FOREVERVM_SESSION_TIMEOUT', '6')) # 6 seconds for testing +SESSION_CLEANUP_INTERVAL = int(os.getenv('FOREVERVM_CLEANUP_INTERVAL', '2')) # check every 2 seconds + +# Server Configuration +SERVER_HOST = os.getenv('FOREVERVM_HOST', '0.0.0.0') +SERVER_PORT = int(os.getenv('FOREVERVM_PORT', '8000')) +SERVER_DEBUG = os.getenv('FOREVERVM_DEBUG', 'true').lower() == 'true' + +# Create required directories +os.makedirs(SNAPSHOT_DIR, exist_ok=True) diff --git a/forevervm_minimal/http_server.py b/forevervm_minimal/http_server.py index ca8a444..0ed6117 100644 --- a/forevervm_minimal/http_server.py +++ b/forevervm_minimal/http_server.py @@ -5,11 +5,23 @@ app = Flask(__name__) -session_manager = None # we'll set this from main.py +# Singleton instance of session manager +_session_manager = None + +def init_session_manager(manager): + """Initialize the session manager singleton.""" + global _session_manager + _session_manager = manager + +def get_session_manager(): + """Get the session manager instance.""" + if _session_manager is None: + raise RuntimeError("Session manager not initialized") + return _session_manager @app.route("/session", methods=["POST"]) def create_session(): - session_id = session_manager.create_session() + session_id = get_session_manager().create_session() return jsonify({"session_id": session_id}) @app.route("/session//execute", methods=["POST"]) @@ -17,10 +29,11 @@ def execute_code(session_id): data = request.json code = data.get("code", "") try: - output = session_manager.execute_code(session_id, code) + output = get_session_manager().execute_code(session_id, code) return jsonify({"status": "ok", "output": output}) except Exception as e: return jsonify({"status": "error", "error": str(e)}), 400 -def run_http_server(host="0.0.0.0", port=8000): - app.run(host=host, port=port) \ No newline at end of file +def run_http_server(session_mgr, host="0.0.0.0", port=8000): + init_session_manager(session_mgr) + app.run(host=host, debug=True, port=port) \ No newline at end of file diff --git a/forevervm_minimal/main.py b/forevervm_minimal/main.py index fbd6e30..b601f38 100644 --- a/forevervm_minimal/main.py +++ b/forevervm_minimal/main.py @@ -1,38 +1,35 @@ # main.py -import threading -import time import sys import os +import logging # Add the parent directory to sys.path to allow absolute imports sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from forevervm_minimal.session_manager import SessionManager -from forevervm_minimal.snapshot_storage import LocalFileStorage -from forevervm_minimal.worker_manager import WorkerManager -from forevervm_minimal.http_server import run_http_server, app, session_manager as global_session_manager +from forevervm_minimal.http_server import run_http_server +from forevervm_minimal.component_factory import create_components +from forevervm_minimal.config import ( + SERVER_HOST, + SERVER_PORT, +) + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) def main(): - storage = LocalFileStorage(base_dir="/var/forevervm/snapshots") - worker_manager = WorkerManager(pool_size=2) - session_manager = SessionManager(snapshot_storage=storage, worker_manager=worker_manager) - - # Provide session_manager to the Flask app - global global_session_manager - global_session_manager = session_manager - - # Start background thread for idle checking - def idle_check_loop(): - while True: - time.sleep(60) # check every minute - session_manager.checkpoint_idle_sessions() - - t = threading.Thread(target=idle_check_loop, daemon=True) - t.start() + # Initialize all components using the factory + components = create_components() - # Start the HTTP server - run_http_server() + try: + # Start the HTTP server with the session manager + run_http_server(components.session_manager, host=SERVER_HOST, port=SERVER_PORT) + finally: + # Ensure clean shutdown of background tasks + components.task_manager.stop_all_tasks() if __name__ == "__main__": main() \ No newline at end of file diff --git a/forevervm_minimal/pyproject.toml b/forevervm_minimal/pyproject.toml new file mode 100644 index 0000000..48fe8ff --- /dev/null +++ b/forevervm_minimal/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "forevervm-minimal" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "flask>=3.1.0", + "requests>=2.32.3", + "rich>=13.9.4", +] diff --git a/forevervm_minimal/requirements.txt b/forevervm_minimal/requirements.txt deleted file mode 100644 index 5ceec5e..0000000 --- a/forevervm_minimal/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -flask==2.0.1 -requests==2.26.0 \ No newline at end of file diff --git a/forevervm_minimal/scratchpad.md b/forevervm_minimal/scratchpad.md new file mode 100644 index 0000000..a32c926 --- /dev/null +++ b/forevervm_minimal/scratchpad.md @@ -0,0 +1,156 @@ +# Scratchpad + +## Current Task: Debug Session Isolation Warning in Concurrent Tests + +### Analysis of Test Results + +1. Session Behavior: + - Client A and B each created their own sessions successfully + - Each session maintained its own unique variables + - Each session had independent shared_list modifications + - Cross-session access properly failed with NameError + +2. Test Output Analysis: + ``` + Client A: + - unique_value_A = 486f78fd + - shared_list = [1, 2, 3, 'A'] + + Client B: + - unique_value_B = 7bf1c6eb + - shared_list = [1, 2, 3, 'B'] + + Cross-Session Test: + - B cannot access A's variable (Expected NameError) ✓ + - A's session maintains its value ✓ + ``` + +3. Bug Found: False Warning + The isolation warning was triggered incorrectly. Looking at the old code: + ```python + isolation_verified = ( + "NameError" in cross_session_results["cross_session_access"] and + results["A"]["unique_value"] in cross_session_results["original_session_access"] + ) + ``` + The test actually shows proper isolation: + - Cross-session access fails with NameError (good) + - Original session maintains its value (good) + - Each session has independent shared_list (good) + +### Fix Applied +[X] Identified false warning issue +[X] Updated verification logic in test: + ```python + isolation_verified = all([ + # Check that B cannot access A's variable (should get NameError) + "name 'unique_value_A' is not defined" in cross_session_results["cross_session_access"], + # Check that A can still access its own variable + f"Value: {results['A']['unique_value']}" in cross_session_results["original_session_access"], + # Verify both sessions completed without errors + not results["A"]["error"], + not results["B"]["error"] + ]) + ``` +[X] Added more comprehensive isolation checks: + - Exact error message matching + - Exact value verification + - Error-free execution check + +### Lessons +1. Session Isolation Works Correctly: + - Variables are properly isolated between sessions + - Cross-session access is properly prevented + - State is maintained independently per session + +2. Test Verification Improvements: + - Use exact string pattern matching for errors + - Check for specific variable values + - Verify error-free execution + - Consider multiple aspects of isolation + +3. Best Practices for Testing Session Isolation: + - Test both positive and negative cases + - Verify exact error messages + - Check state persistence + - Ensure clean execution + +### Progress +[X] Analyzed test results +[X] Identified false positive in isolation warning +[X] Fixed verification logic +[X] Added comprehensive checks +[X] Updated documentation + +Task completed successfully! The test now correctly verifies session isolation. + +### Architecture Analysis +Current components and their responsibilities: +1. `Worker`: Individual Python execution environment + - Manages code execution in isolated environment + - Handles environment serialization/restoration + - Captures stdout and handles errors + +2. `WorkerManager`: Pool of worker instances + - Manages worker lifecycle (spawn/release) + - Handles worker pool sizing + - Thread-safe worker allocation + +3. `SessionManager`: Core session orchestrator + - Manages session lifecycle + - Handles code execution through workers + - Coordinates snapshots and restoration + - Manages inactivity timeouts + +4. `LocalFileStorage`: Persistence layer + - Handles snapshot storage and retrieval + - File-based persistence implementation + +5. `TaskManager` (New): + - Manages background tasks lifecycle + - Handles graceful shutdown + - Provides task monitoring + - Implements signal handling + +### Issues Identified +1. Component initialization is tightly coupled in main.py +2. No clear configuration management +3. Background tasks (idle checking) mixed with initialization +4. Hard-coded values (pool_size, timeouts) +5. No graceful shutdown handling + +### Refactoring Plan +[X] Phase 1: Configuration Management + - Created config.py for centralized configuration + - Moved hardcoded values to config + - Added environment variable support + - Implemented path management with pathlib + +[X] Phase 2: Component Factory + - Created component factory for clean initialization + - Implemented proper dependency injection + - Added component lifecycle tracking + - Organized components by dependency order + +[X] Phase 3: Background Tasks + - Created TaskManager for background task handling + - Implemented proper task lifecycle management + - Added graceful shutdown support + - Added signal handling for clean termination + +### Progress +[X] Analyzed current architecture +[X] Identified improvement areas +[X] Created refactoring plan +[X] Completed Phase 1: Configuration Management +[X] Completed Phase 2: Component Factory +[X] Completed Phase 3: Background Tasks + +All planned improvements have been completed! The architecture now follows Python best practices with: +- Clear separation of concerns +- Proper dependency management +- Centralized configuration +- Clean background task handling +- Graceful shutdown support + +Would you like to test the improved implementation or make any additional improvements? diff --git a/forevervm_minimal/session_manager.py b/forevervm_minimal/session_manager.py index 081e099..3ff9a00 100644 --- a/forevervm_minimal/session_manager.py +++ b/forevervm_minimal/session_manager.py @@ -8,6 +8,7 @@ from forevervm_minimal.snapshot_storage import SnapshotStorage from forevervm_minimal.custom_serializer import Serializer from forevervm_minimal.session_data import SessionData +from forevervm_minimal.config import SESSION_INACTIVITY_TIMEOUT class SessionManager: def __init__(self, snapshot_storage: SnapshotStorage, worker_manager: WorkerManager): @@ -17,8 +18,8 @@ def __init__(self, snapshot_storage: SnapshotStorage, worker_manager: WorkerMana self.sessions = {} # dict: session_id -> SessionData self.lock = threading.Lock() - self.inactivity_timeout = 600 # 10 minutes, in seconds - + self.inactivity_timeout = SESSION_INACTIVITY_TIMEOUT + def create_session(self): session_id = str(uuid.uuid4()) # create a new worker diff --git a/forevervm_minimal/snapshot_storage.py b/forevervm_minimal/snapshot_storage.py index 6779980..c00ad82 100644 --- a/forevervm_minimal/snapshot_storage.py +++ b/forevervm_minimal/snapshot_storage.py @@ -21,7 +21,7 @@ def delete_snapshot(self, session_id: str) -> None: class LocalFileStorage(SnapshotStorage): - def __init__(self, base_dir="/var/forevervm/snapshots"): + def __init__(self, base_dir="./data_dir/snapshots"): self.base_dir = base_dir os.makedirs(self.base_dir, exist_ok=True) diff --git a/forevervm_minimal/task_manager.py b/forevervm_minimal/task_manager.py new file mode 100644 index 0000000..a538581 --- /dev/null +++ b/forevervm_minimal/task_manager.py @@ -0,0 +1,98 @@ +"""Task manager for ForeverVM service. + +Handles background tasks and their lifecycle management. +Follows the principles: +- Single Responsibility: Each task has one clear purpose +- Proper Lifecycle: Tasks can be started, stopped, and monitored +- Clean Shutdown: All tasks can be gracefully terminated +""" + +import threading +import time +from typing import Callable, Dict, Optional +from dataclasses import dataclass +import signal +import logging + +logger = logging.getLogger(__name__) + +@dataclass +class Task: + """Represents a background task.""" + name: str + interval: float # seconds + callback: Callable + thread: Optional[threading.Thread] = None + should_stop: Optional[threading.Event] = None + +class TaskManager: + """Manages background tasks for the service.""" + + def __init__(self): + self.tasks: Dict[str, Task] = {} + self._shutdown_event = threading.Event() + self._setup_signal_handlers() + + def _setup_signal_handlers(self): + """Setup handlers for graceful shutdown.""" + signal.signal(signal.SIGINT, self._handle_shutdown_signal) + signal.signal(signal.SIGTERM, self._handle_shutdown_signal) + + def _handle_shutdown_signal(self, signum, frame): + """Handle shutdown signals gracefully.""" + logger.info(f"Received shutdown signal {signum}") + self.stop_all_tasks() + + def _task_loop(self, task: Task): + """Main loop for a background task.""" + logger.info(f"Starting task: {task.name}") + while not task.should_stop.is_set(): + try: + task.callback() + except Exception as e: + logger.error(f"Error in task {task.name}: {e}") + time.sleep(task.interval) + logger.info(f"Task stopped: {task.name}") + + def add_task(self, name: str, interval: float, callback: Callable): + """Add a new background task.""" + if name in self.tasks: + raise ValueError(f"Task {name} already exists") + + task = Task( + name=name, + interval=interval, + callback=callback, + should_stop=threading.Event() + ) + + thread = threading.Thread( + target=self._task_loop, + args=(task,), + daemon=True, + name=f"Task-{name}" + ) + task.thread = thread + self.tasks[name] = task + thread.start() + + def stop_task(self, name: str): + """Stop a specific task.""" + if task := self.tasks.get(name): + logger.info(f"Stopping task: {name}") + task.should_stop.set() + if task.thread and task.thread.is_alive(): + task.thread.join(timeout=5.0) + del self.tasks[name] + + def stop_all_tasks(self): + """Stop all running tasks gracefully.""" + logger.info("Stopping all tasks") + task_names = list(self.tasks.keys()) + for name in task_names: + self.stop_task(name) + + def is_task_running(self, name: str) -> bool: + """Check if a task is currently running.""" + task = self.tasks.get(name) + return bool(task and task.thread and task.thread.is_alive()) diff --git a/forevervm_minimal/test.sh b/forevervm_minimal/test.sh deleted file mode 100755 index 5cd3914..0000000 --- a/forevervm_minimal/test.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash - -# test.sh - Script to test the ForeverVM system - -# Check if Python is installed -if ! command -v python3 &> /dev/null; then - echo "Error: Python 3 is required but not installed." - exit 1 -fi - -# Check if pip is installed -if ! command -v pip3 &> /dev/null; then - echo "Error: pip3 is required but not installed." - exit 1 -fi - -# Install dependencies -echo "Installing dependencies..." -pip3 install -r requirements.txt - -# Run the test client -echo "Running the test client..." -python3 -m forevervm_minimal.test_client \ No newline at end of file diff --git a/forevervm_minimal/test_client.py b/forevervm_minimal/test_client.py index 0dc6a38..8f8b878 100644 --- a/forevervm_minimal/test_client.py +++ b/forevervm_minimal/test_client.py @@ -4,50 +4,84 @@ import requests import json import time +import logging + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) def main(): base_url = "http://localhost:8000" # Create a new session - print("Creating a new session...") + logger.info("Creating a new session...") response = requests.post(f"{base_url}/session") session_data = response.json() session_id = session_data["session_id"] - print(f"Session created with ID: {session_id}") + logger.info(f"Session created with ID: {session_id}") # Execute some code in the session - print("\nExecuting code to define a variable...") - code1 = "x = 42\nprint(f'x = {x}')" + logger.info("\nStep 1: Defining initial variables...") + code1 = """ +x = 42 +y = [1, 2, 3] +print(f'Initial state: x = {x}, y = {y}') +""" response = requests.post( f"{base_url}/session/{session_id}/execute", json={"code": code1} ) - print(f"Response: {response.json()}") + logger.info(f"Response: {response.json()}") - # Execute more code that uses the previously defined variable - print("\nExecuting code that uses the previously defined variable...") - code2 = "y = x * 2\nprint(f'y = {y}')" + # Execute more code to verify state + logger.info("\nStep 2: Verifying variable state...") + code2 = """ +y.append(x) +print(f'Updated state: y = {y}') +""" response = requests.post( f"{base_url}/session/{session_id}/execute", json={"code": code2} ) - print(f"Response: {response.json()}") + logger.info(f"Response: {response.json()}") - # Simulate inactivity (in a real scenario, you'd wait for the inactivity_timeout) - print("\nSimulating session inactivity...") - print("In a real scenario, you'd wait for the inactivity_timeout (10 minutes by default)") - print("For testing, you can modify the inactivity_timeout in session_manager.py to a smaller value") + # Wait for session inactivity timeout + inactivity_period = 8 # seconds (longer than SESSION_INACTIVITY_TIMEOUT) + logger.info(f"\nStep 3: Simulating inactivity for {inactivity_period} seconds...") + logger.info("Session should be checkpointed during this time") + time.sleep(inactivity_period) - # Execute code after the "inactivity period" to demonstrate session persistence - print("\nExecuting code after the 'inactivity period'...") - code3 = "z = x + y\nprint(f'z = {z}')" + # Execute code after inactivity to verify session restoration + logger.info("\nStep 4: Testing session restoration...") + code3 = """ +print(f'Restored state: x = {x}, y = {y}') +z = sum(y) +print(f'New computation: sum(y) = {z}') +""" response = requests.post( f"{base_url}/session/{session_id}/execute", json={"code": code3} ) - print(f"Response: {response.json()}") + logger.info(f"Response: {response.json()}") + + # Final verification + logger.info("\nStep 5: Final state verification...") + code4 = """ +print(f'Final state check:') +print(f'x = {x}') +print(f'y = {y}') +print(f'z = {z}') +""" + response = requests.post( + f"{base_url}/session/{session_id}/execute", + json={"code": code4} + ) + logger.info(f"Response: {response.json()}") - print("\nTest completed successfully!") + logger.info("\nTest completed successfully!") if __name__ == "__main__": main() \ No newline at end of file diff --git a/forevervm_minimal/test_client_concurrent.py b/forevervm_minimal/test_client_concurrent.py new file mode 100644 index 0000000..7cf8579 --- /dev/null +++ b/forevervm_minimal/test_client_concurrent.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +# test_client_concurrent.py + +import requests +import json +import time +import logging +import threading +import uuid +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text +from rich import box +from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn + +# Initialize Rich console +console = Console() + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +def client_session(client_id, results, progress): + """Run a client session that connects to the server and executes code.""" + base_url = "http://localhost:8000" + task_id = progress.add_task(f"[cyan]Client {client_id}", total=6) + + try: + # Step 1: Create a new session + progress.update(task_id, description=f"[cyan]Client {client_id}: Creating session", advance=0.2) + response = requests.post(f"{base_url}/session") + session_data = response.json() + session_id = session_data["session_id"] + results[client_id]["session_id"] = session_id + + # Step 2: Initialize client-specific variables + progress.update(task_id, description=f"[cyan]Client {client_id}: Initializing variables", advance=0.2) + unique_value = uuid.uuid4().hex[:8] + var_name = f"unique_value_{client_id}" + code = f""" +{var_name} = '{unique_value}' +shared_list = [1, 2, 3] +print(f'Initial state: {var_name} = {{{var_name}}}, shared_list = {{shared_list}}') +""" + response = requests.post( + f"{base_url}/session/{session_id}/execute", + json={"code": code} + ) + results[client_id]["outputs"].append(response.json().get("output", "")) + results[client_id]["unique_value"] = unique_value + results[client_id]["var_name"] = var_name + + # Step 3: Modify shared data structure + progress.update(task_id, description=f"[cyan]Client {client_id}: Modifying shared list", advance=0.2) + code = f""" +client_marker = '{client_id}' +shared_list.append(client_marker) +print(f'Modified shared_list = {{shared_list}}') +""" + response = requests.post( + f"{base_url}/session/{session_id}/execute", + json={"code": code} + ) + results[client_id]["outputs"].append(response.json().get("output", "")) + + # Step 4: Simulate some work + progress.update(task_id, description=f"[cyan]Client {client_id}: Simulating work", advance=0.2) + time.sleep(2) # Simulate work that might trigger checkpointing + + # Step 5: Verify session state after potential checkpoint + progress.update(task_id, description=f"[cyan]Client {client_id}: Verifying state", advance=0.2) + code = f""" +print(f'State after work:') +print(f'{var_name} = {{{var_name}}}') +print(f'shared_list = {{shared_list}}') +""" + response = requests.post( + f"{base_url}/session/{session_id}/execute", + json={"code": code} + ) + results[client_id]["outputs"].append(response.json().get("output", "")) + + # Step 6: Final computation + progress.update(task_id, description=f"[cyan]Client {client_id}: Final computation", advance=0.2) + code = f""" +result = sum([int(x) if str(x).isdigit() else 0 for x in shared_list]) +print(f'Final computation: sum(shared_list) = {{result}}') +""" + response = requests.post( + f"{base_url}/session/{session_id}/execute", + json={"code": code} + ) + results[client_id]["outputs"].append(response.json().get("output", "")) + progress.update(task_id, description=f"[cyan]Client {client_id}: ✅ Completed", completed=True) + + except Exception as e: + results[client_id]["error"] = str(e) + progress.update(task_id, description=f"[red]Client {client_id}: Error: {str(e)}", advance=1.0) + +def test_cross_session_access(results): + """Test isolation between sessions by attempting cross-session variable access.""" + base_url = "http://localhost:8000" + + session_a = results["A"]["session_id"] + session_b = results["B"]["session_id"] + var_name_a = results["A"]["var_name"] + unique_value_a = results["A"]["unique_value"] + + # Try to access Client A's variable from Client B's session + code = f""" +try: + print(f'Attempting to access {var_name_a} from session B') + print(f'Value: {{{var_name_a}}}') +except NameError as e: + print(f'Expected error: {{e}}') +""" + response = requests.post( + f"{base_url}/session/{session_b}/execute", + json={"code": code} + ) + cross_session_result = response.json().get("output", "") + + # Verify the variable in original session + code = f""" +print(f'Verifying {var_name_a} in original session A') +print(f'Value: {{{var_name_a}}}') +""" + response = requests.post( + f"{base_url}/session/{session_a}/execute", + json={"code": code} + ) + original_session_result = response.json().get("output", "") + + return { + "cross_session_access": cross_session_result, + "original_session_access": original_session_result + } + +def main(): + console.clear() + console.print(Panel.fit( + "[bold cyan]ForeverVM Python REPL - Concurrent Clients Test[/bold cyan]", + border_style="cyan" + )) + + # Dictionary to store results from both clients + results = { + "A": {"session_id": None, "unique_value": None, "var_name": None, "outputs": [], "error": None}, + "B": {"session_id": None, "unique_value": None, "var_name": None, "outputs": [], "error": None} + } + + # Create and start two client threads with progress bars + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + TimeElapsedColumn(), + console=console + ) as progress: + console.print("[bold]Starting client threads...[/bold]") + + thread_a = threading.Thread(target=client_session, args=("A", results, progress)) + thread_b = threading.Thread(target=client_session, args=("B", results, progress)) + + thread_a.start() + thread_b.start() + + thread_a.join() + thread_b.join() + + console.print("[bold green]✓[/bold green] Both client threads completed\n") + + # Test cross-session access + console.print(Panel.fit( + "[bold yellow]Testing Cross-Session Access[/bold yellow]", + border_style="yellow" + )) + + with console.status("[cyan]Testing session isolation...[/cyan]"): + cross_session_results = test_cross_session_access(results) + + # Create results tables + for client_id in ["A", "B"]: + table = Table(title=f"Client {client_id}", box=box.ROUNDED, show_header=True, header_style="bold cyan") + table.add_column("Property", style="dim") + table.add_column("Value") + + table.add_row("Session ID", results[client_id]["session_id"]) + table.add_row("Variable Name", results[client_id]["var_name"]) + table.add_row("Unique Value", results[client_id]["unique_value"]) + + for i, output in enumerate(results[client_id]["outputs"]): + table.add_row(f"Step {i+1} Output", output.strip()) + + if results[client_id]["error"]: + table.add_row("Error", results[client_id]["error"]) + + console.print(table) + console.print("") + + # Create cross-session test results table + table_cross = Table( + title="Cross-Session Test Results", + box=box.ROUNDED, + show_header=True, + header_style="bold yellow" + ) + table_cross.add_column("Test", style="dim") + table_cross.add_column("Result") + + table_cross.add_row( + "Access from other session", + cross_session_results["cross_session_access"].strip() + ) + table_cross.add_row( + "Access from original session", + cross_session_results["original_session_access"].strip() + ) + + console.print(table_cross) + + # Verify session isolation + isolation_verified = all([ + # Check that B cannot access A's variable (should get NameError) + "name 'unique_value_A' is not defined" in cross_session_results["cross_session_access"], + # Check that A can still access its own variable + f"Value: {results['A']['unique_value']}" in cross_session_results["original_session_access"], + # Verify both sessions completed without errors + not results["A"]["error"], + not results["B"]["error"] + ]) + + if isolation_verified: + console.print(Panel.fit( + "[bold green]✓ Session isolation verified: Sessions have independent environments[/bold green]", + border_style="green" + )) + else: + console.print(Panel.fit( + "[bold red]⚠ WARNING: Session isolation may be compromised![/bold red]", + border_style="red" + )) + +if __name__ == "__main__": + main() diff --git a/forevervm_minimal/uv.lock b/forevervm_minimal/uv.lock new file mode 100644 index 0000000..d87fb4f --- /dev/null +++ b/forevervm_minimal/uv.lock @@ -0,0 +1,233 @@ +version = 1 +requires-python = ">=3.13" + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, + { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, + { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, + { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, + { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, + { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, + { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, + { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, + { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, + { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, + { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, + { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, + { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, + { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "flask" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/50/dff6380f1c7f84135484e176e0cac8690af72fa90e932ad2a0a60e28c69b/flask-3.1.0.tar.gz", hash = "sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac", size = 680824 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/47/93213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a/flask-3.1.0-py3-none-any.whl", hash = "sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136", size = 102979 }, +] + +[[package]] +name = "forevervm-minimal" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "flask" }, + { name = "requests" }, + { name = "rich" }, +] + +[package.metadata] +requires-dist = [ + { name = "flask", specifier = ">=3.1.0" }, + { name = "requests", specifier = ">=2.32.3" }, + { name = "rich", specifier = ">=13.9.4" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +] + +[[package]] +name = "urllib3" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, +] + +[[package]] +name = "werkzeug" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 }, +] diff --git a/run.sh b/run.sh deleted file mode 100755 index 6ac0a23..0000000 --- a/run.sh +++ /dev/null @@ -1,5 +0,0 @@ -docker run --runtime=runsc --rm -it \ - -v "$(pwd)/server.py:/server.py" \ - -p 8000:8000 \ - python:3.9.21-alpine3.21 \ - python /server.py diff --git a/server.py b/server.py deleted file mode 100644 index bc11ad9..0000000 --- a/server.py +++ /dev/null @@ -1,171 +0,0 @@ -import socketserver -import socket -import json -import sys -import traceback -import io -import logging -import uuid -import threading -from concurrent.futures import ThreadPoolExecutor - -# Dictionary to store session environments -sessions = {} -sessions_lock = threading.Lock() - -class PythonREPLHandler(socketserver.BaseRequestHandler): - def handle(self): - """Handle incoming TCP connections.""" - self.request.settimeout(300) # 5-minute timeout - logging.info(f"Connection established from {self.client_address}") - - try: - # Initial greeting with protocol info - self.send_response({ - "status": "ok", - "message": "Python REPL Server. Send JSON with 'code' to execute. Optional 'session_id' to continue a session." - }) - - while True: - # Receive data from client - data = self.receive_data() - if not data: - break - - try: - request = json.loads(data) - - # Extract code and optional session_id - code = request.get('code', '') - session_id = request.get('session_id', None) - - # Process the request - response = self.process_request(code, session_id) - self.send_response(response) - - except json.JSONDecodeError: - self.send_response({ - "status": "error", - "error": "Invalid JSON format" - }) - except Exception as e: - self.send_response({ - "status": "error", - "error": str(e) - }) - except socket.timeout: - logging.info(f"Connection from {self.client_address} timed out") - except ConnectionError: - logging.info(f"Connection from {self.client_address} closed by client") - except Exception as e: - logging.error(f"Error handling connection from {self.client_address}: {str(e)}") - finally: - logging.info(f"Connection from {self.client_address} closed") - - def receive_data(self): - """Receive data from the client.""" - try: - # First receive the message length (4 bytes) - length_bytes = self.request.recv(4) - if not length_bytes: - return None - - # Convert bytes to integer - message_length = int.from_bytes(length_bytes, byteorder='big') - - # Receive the actual message - chunks = [] - bytes_received = 0 - while bytes_received < message_length: - chunk = self.request.recv(min(4096, message_length - bytes_received)) - if not chunk: - raise ConnectionError("Connection closed while receiving data") - chunks.append(chunk) - bytes_received += len(chunk) - - return b''.join(chunks).decode('utf-8') - except Exception as e: - logging.error(f"Error receiving data: {str(e)}") - return None - - def send_response(self, response_dict): - """Send a response to the client.""" - try: - # Convert response to JSON string - response_json = json.dumps(response_dict) - response_bytes = response_json.encode('utf-8') - - # Send message length first (4 bytes) - length = len(response_bytes) - self.request.sendall(length.to_bytes(4, byteorder='big')) - - # Send the actual message - self.request.sendall(response_bytes) - except Exception as e: - logging.error(f"Error sending response: {str(e)}") - - def process_request(self, code, session_id=None): - """Process a code execution request.""" - # If no session_id provided, create a new session - if not session_id: - session_id = str(uuid.uuid4()) - with sessions_lock: - sessions[session_id] = {} - logging.info(f"Created new session: {session_id}") - # If session_id provided but doesn't exist, return error - elif session_id not in sessions: - return { - "status": "error", - "error": f"Session {session_id} not found" - } - - # Execute the code in the session's environment - output = io.StringIO() - try: - old_stdout = sys.stdout - try: - sys.stdout = output - with sessions_lock: - exec(code, sessions[session_id]) - finally: - sys.stdout = old_stdout - - result = output.getvalue() - return { - "status": "ok", - "output": result, - "session_id": session_id - } - except Exception: - tb = traceback.format_exc() - return { - "status": "error", - "error": tb, - "session_id": session_id - } - -class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): - allow_reuse_address = True - daemon_threads = True - -def main(): - # Use TCP configuration - host = "0.0.0.0" # Listen on all interfaces - port = 8000 - - logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") - - # Create and start the server - server = ThreadedTCPServer((host, port), PythonREPLHandler) - - logging.info(f"Python REPL server listening on TCP {host}:{port}") - try: - server.serve_forever() - except KeyboardInterrupt: - logging.info("Server is shutting down") - finally: - server.server_close() - logging.info("Server shut down") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/test_concurrent_clients.py b/test_concurrent_clients.py deleted file mode 100644 index 2989af8..0000000 --- a/test_concurrent_clients.py +++ /dev/null @@ -1,339 +0,0 @@ -#!/usr/bin/env python3 -import socket -import json -import sys -import threading -import time -import uuid -from rich.console import Console -from rich.panel import Panel -from rich.table import Table -from rich.text import Text -from rich import box -from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn - -# Initialize Rich console -console = Console() - -def send_receive(sock, request_dict): - """Send a request to the server and receive the response.""" - # Convert request to JSON and encode - request_json = json.dumps(request_dict) - request_bytes = request_json.encode('utf-8') - - # Send message length first (4 bytes) - length = len(request_bytes) - sock.sendall(length.to_bytes(4, byteorder='big')) - - # Send the actual message - sock.sendall(request_bytes) - - # Receive response length (4 bytes) - length_bytes = sock.recv(4) - if not length_bytes: - return None - - # Convert bytes to integer - message_length = int.from_bytes(length_bytes, byteorder='big') - - # Receive the actual response - chunks = [] - bytes_received = 0 - while bytes_received < message_length: - chunk = sock.recv(min(4096, message_length - bytes_received)) - if not chunk: - raise ConnectionError("Connection closed while receiving data") - chunks.append(chunk) - bytes_received += len(chunk) - - response_json = b''.join(chunks).decode('utf-8') - return json.loads(response_json) - -def receive_response(sock): - """Receive a response from the server using the length-prefixed protocol.""" - # Receive response length (4 bytes) - length_bytes = sock.recv(4) - if not length_bytes: - return None - - # Convert bytes to integer - message_length = int.from_bytes(length_bytes, byteorder='big') - - # Receive the actual response - chunks = [] - bytes_received = 0 - while bytes_received < message_length: - chunk = sock.recv(min(4096, message_length - bytes_received)) - if not chunk: - raise ConnectionError("Connection closed while receiving data") - chunks.append(chunk) - bytes_received += len(chunk) - - response_json = b''.join(chunks).decode('utf-8') - return json.loads(response_json) - -def client_session(client_id, results, progress): - """Run a client session that connects to the server and executes code.""" - # Server connection details - host = "localhost" - port = 8000 - - # Create a socket and connect to the server - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - session_id = None - - task_id = progress.add_task(f"[cyan]Client {client_id}", total=6) # Increased total for new step - - try: - sock.connect((host, port)) - progress.update(task_id, description=f"[cyan]Client {client_id}: Connected to {host}:{port}", advance=0.2) - - # Receive initial greeting - greeting = receive_response(sock) - progress.update(task_id, description=f"[cyan]Client {client_id}: Server greeting received", advance=0.2) - - # Step 1: Create a new session by executing code - progress.update(task_id, description=f"[cyan]Client {client_id}: Creating a new session", advance=0.2) - response = send_receive(sock, { - "code": f"client_id = '{client_id}'\nprint(f'Client {client_id} initialized')" - }) - session_id = response.get("session_id") - progress.update(task_id, description=f"[cyan]Client {client_id}: Session ID: {session_id[:8]}...", advance=0.2) - results[client_id]["session_id"] = session_id - results[client_id]["outputs"].append(response.get("output", "")) - - # Step 2: Define a function in the session - progress.update(task_id, description=f"[cyan]Client {client_id}: Defining a function", advance=0.2) - response = send_receive(sock, { - "code": f""" -def get_client_info(): - return f"This is client {client_id} with session {{{session_id}}}" -print(get_client_info()) -""", - "session_id": session_id - }) - results[client_id]["outputs"].append(response.get("output", "")) - - # Step 3: Create a unique variable for this client with a client-specific name - progress.update(task_id, description=f"[cyan]Client {client_id}: Creating a unique variable", advance=0.2) - unique_value = uuid.uuid4().hex[:8] - var_name = f"unique_value_{client_id}" # Use client-specific variable names - response = send_receive(sock, { - "code": f"{var_name} = '{unique_value}'\nprint(f'Set {var_name} to {{{var_name}}}')", - "session_id": session_id - }) - results[client_id]["unique_value"] = unique_value - results[client_id]["var_name"] = var_name - results[client_id]["outputs"].append(response.get("output", "")) - - # Step 4: Create a common variable name with client-specific value - progress.update(task_id, description=f"[cyan]Client {client_id}: Setting common variable", advance=0.2) - common_value = f"value_from_client_{client_id}_{uuid.uuid4().hex[:6]}" - response = send_receive(sock, { - "code": f"common_variable = '{common_value}'\nprint(f'Set common_variable to {{common_variable}}')", - "session_id": session_id - }) - results[client_id]["common_value"] = common_value - results[client_id]["outputs"].append(response.get("output", "")) - - # Step 5: Sleep to simulate concurrent work - progress.update(task_id, description=f"[cyan]Client {client_id}: Simulating work...", advance=0.2) - time.sleep(1) - - # Step 6: Verify the variables are still correct - progress.update(task_id, description=f"[cyan]Client {client_id}: Verifying variables", advance=0.2) - response = send_receive(sock, { - "code": f"print(f'{var_name} is {{{var_name}}}\\ncommon_variable is {{common_variable}}')", - "session_id": session_id - }) - results[client_id]["outputs"].append(response.get("output", "")) - progress.update(task_id, description=f"[cyan]Client {client_id}: ✅ Completed", advance=0.2) - - except Exception as e: - results[client_id]["error"] = str(e) - progress.update(task_id, description=f"[red]Client {client_id}: Error: {str(e)}", advance=1.0) - finally: - sock.close() - if not results[client_id]["error"]: - progress.update(task_id, completed=True) - -def main(): - console.clear() - console.print(Panel.fit( - "[bold cyan]Python REPL Server - Concurrent Clients Test[/bold cyan]", - border_style="cyan" - )) - - # Dictionary to store results from both clients - results = { - "A": {"session_id": None, "unique_value": None, "var_name": None, "common_value": None, "outputs": [], "error": None}, - "B": {"session_id": None, "unique_value": None, "var_name": None, "common_value": None, "outputs": [], "error": None} - } - - # Create and start two client threads with progress bars - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - TimeElapsedColumn(), - console=console - ) as progress: - console.print("[bold]Starting client threads...[/bold]") - - thread_a = threading.Thread(target=client_session, args=("A", results, progress)) - thread_b = threading.Thread(target=client_session, args=("B", results, progress)) - - thread_a.start() - thread_b.start() - - # Wait for both threads to complete - thread_a.join() - thread_b.join() - - console.print("[bold green]✓[/bold green] Both client threads completed\n") - - # Now test cross-session access with a third connection - console.print(Panel.fit( - "[bold yellow]Testing Cross-Session Access[/bold yellow]", - border_style="yellow" - )) - - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - with console.status("[cyan]Connecting to server for cross-session test...[/cyan]"): - sock.connect(("localhost", 8000)) - - # Skip greeting - receive_response(sock) - - # Try to access Client A's unique value from Client B's session - session_a = results["A"]["session_id"] - session_b = results["B"]["session_id"] - unique_value_a = results["A"]["unique_value"] - unique_value_b = results["B"]["unique_value"] - var_name_a = results["A"]["var_name"] - var_name_b = results["B"]["var_name"] - common_value_a = results["A"]["common_value"] - common_value_b = results["B"]["common_value"] - - console.print(f"[yellow]Attempting to access Client A's variable [bold]'{var_name_a}'[/bold] from Client B's session[/yellow]") - response = send_receive(sock, { - "code": f"try:\n print(f'Client A variable {var_name_a}: {{{var_name_a}}}')\nexcept NameError as e:\n print(f'Error: {{e}}')", - "session_id": session_b - }) - cross_session_result_a = response.get("output", "") - console.print(f"[cyan]Result:[/cyan] {cross_session_result_a}") - - # Try to access common_variable from Client B's session and verify it has Client B's value - console.print(f"\n[yellow]Verifying [bold]'common_variable'[/bold] in Client B's session has Client B's value[/yellow]") - response = send_receive(sock, { - "code": f"try:\n print(f'common_variable in B session: {{common_variable}}')\n print(f'Is it B\\'s value? {{common_variable == \"{common_value_b}\"}}')\n print(f'Is it A\\'s value? {{common_variable == \"{common_value_a}\"}}')\nexcept NameError as e:\n print(f'Error: {{e}}')", - "session_id": session_b - }) - common_var_b_result = response.get("output", "") - console.print(f"[cyan]Result:[/cyan] {common_var_b_result}") - - # Try to access common_variable from Client A's session and verify it has Client A's value - console.print(f"\n[yellow]Verifying [bold]'common_variable'[/bold] in Client A's session has Client A's value[/yellow]") - response = send_receive(sock, { - "code": f"try:\n print(f'common_variable in A session: {{common_variable}}')\n print(f'Is it A\\'s value? {{common_variable == \"{common_value_a}\"}}')\n print(f'Is it B\\'s value? {{common_variable == \"{common_value_b}\"}}')\nexcept NameError as e:\n print(f'Error: {{e}}')", - "session_id": session_a - }) - common_var_a_result = response.get("output", "") - console.print(f"[cyan]Result:[/cyan] {common_var_a_result}") - - # Also try to access Client B's variable from Client B's session (should succeed) - console.print(f"\n[yellow]Attempting to access Client B's variable [bold]'{var_name_b}'[/bold] from Client B's session (should succeed)[/yellow]") - response = send_receive(sock, { - "code": f"try:\n print(f'Client B variable {var_name_b}: {{{var_name_b}}}')\nexcept NameError as e:\n print(f'Error: {{e}}')", - "session_id": session_b - }) - same_session_result = response.get("output", "") - console.print(f"[cyan]Result:[/cyan] {same_session_result}") - - # Print summary of results in a table - console.print(Panel.fit( - "[bold green]Test Results Summary[/bold green]", - border_style="green" - )) - - # Create a table for Client A - table_a = Table(title="Client A", box=box.ROUNDED, show_header=True, header_style="bold cyan") - table_a.add_column("Property", style="dim") - table_a.add_column("Value") - - table_a.add_row("Session ID", session_a) - table_a.add_row("Variable Name", var_name_a) - table_a.add_row("Unique Value", unique_value_a) - table_a.add_row("Common Variable Value", common_value_a) - - for i, output in enumerate(results["A"]["outputs"]): - table_a.add_row(f"Step {i+1} Output", output.strip()) - - console.print(table_a) - - # Create a table for Client B - table_b = Table(title="Client B", box=box.ROUNDED, show_header=True, header_style="bold cyan") - table_b.add_column("Property", style="dim") - table_b.add_column("Value") - - table_b.add_row("Session ID", session_b) - table_b.add_row("Variable Name", var_name_b) - table_b.add_row("Unique Value", unique_value_b) - table_b.add_row("Common Variable Value", common_value_b) - - for i, output in enumerate(results["B"]["outputs"]): - table_b.add_row(f"Step {i+1} Output", output.strip()) - - console.print(table_b) - - # Create a table for cross-session test results - table_cross = Table(title="Cross-Session Test Results", box=box.ROUNDED, show_header=True, header_style="bold yellow") - table_cross.add_column("Test", style="dim") - table_cross.add_column("Result") - - table_cross.add_row( - "B trying to access A's variable", - cross_session_result_a.strip() - ) - table_cross.add_row( - "common_variable in B's session", - common_var_b_result.strip() - ) - table_cross.add_row( - "common_variable in A's session", - common_var_a_result.strip() - ) - table_cross.add_row( - "B accessing its own variable", - same_session_result.strip() - ) - - console.print(table_cross) - - # Verify session isolation - isolation_verified = ( - "Error: name '" + var_name_a + "' is not defined" in cross_session_result_a and - "Is it A's value? True" in common_var_a_result and - "Is it B's value? False" in common_var_a_result and - "Is it B's value? True" in common_var_b_result and - "Is it A's value? False" in common_var_b_result - ) - - if isolation_verified: - console.print(Panel.fit( - "[bold green]✓ Session isolation verified: Clients have independent environments with isolated variables[/bold green]", - border_style="green" - )) - else: - console.print(Panel.fit( - "[bold red]⚠ WARNING: Session isolation may be compromised![/bold red]", - border_style="red" - )) - - except Exception as e: - console.print(f"[bold red]Error in cross-session test: {e}[/bold red]") - finally: - sock.close() - -if __name__ == "__main__": - main() diff --git a/test_tcp.py b/test_tcp.py deleted file mode 100755 index 505fd3e..0000000 --- a/test_tcp.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env python3 -import socket -import json -import sys - -def send_receive(sock, request_dict): - """Send a request to the server and receive the response.""" - # Convert request to JSON and encode - request_json = json.dumps(request_dict) - request_bytes = request_json.encode('utf-8') - - # Send message length first (4 bytes) - length = len(request_bytes) - sock.sendall(length.to_bytes(4, byteorder='big')) - - # Send the actual message - sock.sendall(request_bytes) - - # Receive response length (4 bytes) - length_bytes = sock.recv(4) - if not length_bytes: - return None - - # Convert bytes to integer - message_length = int.from_bytes(length_bytes, byteorder='big') - - # Receive the actual response - chunks = [] - bytes_received = 0 - while bytes_received < message_length: - chunk = sock.recv(min(4096, message_length - bytes_received)) - if not chunk: - raise ConnectionError("Connection closed while receiving data") - chunks.append(chunk) - bytes_received += len(chunk) - - response_json = b''.join(chunks).decode('utf-8') - return json.loads(response_json) - -def receive_response(sock): - """Receive a response from the server using the length-prefixed protocol.""" - # Receive response length (4 bytes) - length_bytes = sock.recv(4) - if not length_bytes: - return None - - # Convert bytes to integer - message_length = int.from_bytes(length_bytes, byteorder='big') - - # Receive the actual response - chunks = [] - bytes_received = 0 - while bytes_received < message_length: - chunk = sock.recv(min(4096, message_length - bytes_received)) - if not chunk: - raise ConnectionError("Connection closed while receiving data") - chunks.append(chunk) - bytes_received += len(chunk) - - response_json = b''.join(chunks).decode('utf-8') - return json.loads(response_json) - -def main(): - # Server connection details - host = "localhost" - port = 8000 - - # Create a socket and connect to the server - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - sock.connect((host, port)) - print(f"Connected to {host}:{port}") - - # Receive initial greeting using the length-prefixed protocol - greeting = receive_response(sock) - print(f"Server greeting: {json.dumps(greeting, indent=2)}") - - # Test 1: Execute code without a session ID (creates a new session) - print("\n--- Test 1: Execute code without a session ID ---") - response = send_receive(sock, { - "code": "x = 42\nprint(f'x = {x}')" - }) - print(f"Response: {json.dumps(response, indent=2)}") - - # Save the session ID for later use - session_id = response.get("session_id") - print(f"Session ID: {session_id}") - - # Test 2: Execute code in the same session (using the session ID) - print("\n--- Test 2: Execute code in the same session ---") - response = send_receive(sock, { - "code": "y = x * 2\nprint(f'y = {y}')", - "session_id": session_id - }) - print(f"Response: {json.dumps(response, indent=2)}") - - # Test 3: Define a function in the session - print("\n--- Test 3: Define a function in the session ---") - response = send_receive(sock, { - "code": """ -def greet(name): - return f"Hello, {name}!" -print(greet("World")) -""", - "session_id": session_id - }) - print(f"Response: {json.dumps(response, indent=2)}") - - # Test 4: Call the function defined in the previous request - print("\n--- Test 4: Call the function defined in the previous request ---") - response = send_receive(sock, { - "code": "print(greet('Python'))", - "session_id": session_id - }) - print(f"Response: {json.dumps(response, indent=2)}") - - # Test 5: Create a new session - print("\n--- Test 5: Create a new session ---") - response = send_receive(sock, { - "code": "print('This is a new session')" - }) - print(f"Response: {json.dumps(response, indent=2)}") - new_session_id = response.get("session_id") - print(f"New Session ID: {new_session_id}") - - # Test 6: Verify the new session doesn't have access to variables from the first session - print("\n--- Test 6: Verify session isolation ---") - response = send_receive(sock, { - "code": "try:\n print(f'x = {x}')\nexcept NameError as e:\n print(f'Error: {e}')", - "session_id": new_session_id - }) - print(f"Response: {json.dumps(response, indent=2)}") - - # Test 7: Try to access a non-existent session - print("\n--- Test 7: Try to access a non-existent session ---") - response = send_receive(sock, { - "code": "print('This should fail')", - "session_id": "non-existent-session-id" - }) - print(f"Response: {json.dumps(response, indent=2)}") - - except Exception as e: - print(f"Error: {e}") - finally: - sock.close() - print("Connection closed") - -if __name__ == "__main__": - main() \ No newline at end of file