Skip to content

Commit 96fa466

Browse files
committed
Consolidated the Boriel BASIC container into this project
1 parent 6a081a5 commit 96fa466

21 files changed

Lines changed: 1563 additions & 1 deletion

.github/workflows/publish-containers.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ jobs:
2727
- context: ./apps/auth/
2828
dockerfile: ./apps/auth/Dockerfile
2929
image: ghcr.io/stever/zxcode-auth
30+
# Boriel ZX Basic compile API (migrated from the standalone
31+
# zxcode-api-zxbasic repo). Keeps the original image name so the
32+
# deploy is unchanged; needs this repo's Actions write access to the
33+
# stever/zxcode-api-zxbasic GHCR package.
34+
- context: ./apps/zxbasic/
35+
dockerfile: ./apps/zxbasic/Dockerfile
36+
image: ghcr.io/stever/zxcode-api-zxbasic
3037
- context: ./
3138
dockerfile: ./apps/gif-service/Dockerfile
3239
image: ghcr.io/stever/zxcode-gif-service

apps/zxbasic/.dockerignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**/.idea/
2+
/**/.pytest_cache/
3+
/**/__pycache__/
4+
/**/venv/
5+
.git/
6+
.gitignore
7+
test/
8+
img/
9+
*.md
10+
CLAUDE.md
11+
LICENSE.txt
12+
Dockerfile
13+
.dockerignore

apps/zxbasic/.gitattributes

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
2+
* text=false
3+
4+
*.bas -crlf
5+
*.asm -crlf
6+
*.bi -crlf
7+
8+
*.txt text
9+
*.md text
10+
*.py text
11+
*.ini text
12+
*.yml text
13+
14+
*.png binary
15+
16+
*.bas linguist-vendored
17+
*.asm linguist-vendored
18+

apps/zxbasic/.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.idea/
2+
.pytest_cache/
3+
__pycache__/
4+
venv/
5+
CLAUDE.md
6+
plan_*
7+

apps/zxbasic/Dockerfile

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Build Python deps into an isolated venv so the runtime image carries no
2+
# compiler toolchain (gcc stays in this stage only).
3+
FROM python:3.12-slim-bookworm AS builder
4+
RUN apt-get update \
5+
&& apt-get install -y --no-install-recommends gcc \
6+
&& rm -rf /var/lib/apt/lists/*
7+
RUN python -m venv /opt/venv
8+
ENV PATH="/opt/venv/bin:${PATH}"
9+
COPY requirements.txt .
10+
RUN pip install --no-cache-dir -r requirements.txt
11+
12+
# Runtime: minimal official slim base, no build tools.
13+
# Python 3.12 is required by the bundled ZX Basic compiler (1.18.7 needs >=3.11)
14+
# and runs the current FastAPI / Pydantic v2 stack. Keep both stages on the same
15+
# version.
16+
FROM python:3.12-slim-bookworm
17+
18+
COPY --from=builder /opt/venv /opt/venv
19+
ENV PATH="/opt/venv/bin:${PATH}" \
20+
PYTHONUNBUFFERED=1
21+
22+
# Non-root, no login shell.
23+
RUN groupadd -g 1000 zxbasic \
24+
&& useradd -r -u 1000 -g zxbasic -m -d /home/zxbasic -s /usr/sbin/nologin zxbasic
25+
26+
WORKDIR /app
27+
COPY --chown=zxbasic:zxbasic . /app/
28+
# zxbc writes the compiled .tap into the working dir, so /app itself must be
29+
# writable by the non-root user (WORKDIR creates the dir owned by root).
30+
RUN chown zxbasic:zxbasic /app
31+
USER zxbasic
32+
33+
EXPOSE 80
34+
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
35+
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:80/health', timeout=3).status==200 else 1)"
36+
37+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]

apps/zxbasic/LICENSE.txt

Lines changed: 675 additions & 0 deletions
Large diffs are not rendered by default.

apps/zxbasic/README.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
![Boriel ZX Basic](img/zxbasic_logo.png)
2+
3+
# ZX Play API for ZX Basic
4+
5+
## Development start
6+
7+
### Initial project setup
8+
9+
```bash
10+
git clone https://github.com/stever/zxcoder-api-zxbasic.git
11+
cd zxcoder-api-zxbasic/
12+
virtualenv venv
13+
source ./venv/bin/activate
14+
pip install -r requirements.txt
15+
```
16+
17+
### Run app
18+
19+
```bash
20+
uvicorn app.main:app --reload
21+
```
22+
23+
## Docker Build & Push
24+
25+
```bash
26+
docker build -t ghcr.io/stever/zxcoder-api-zxbasic .
27+
docker push ghcr.io/stever/zxcoder-api-zxbasic
28+
```
29+
30+
## Run Locally
31+
32+
```bash
33+
docker run \
34+
--env=API_URL=https://zxcoder.org/api/v1/graphql \
35+
--publish=80:8000 \
36+
--detach=true \
37+
--name=zxcoder-api-zxbasic \
38+
ghcr.io/stever/zxcoder-api-zxbasic
39+
```
40+
41+
## Hasura Deployment Configuration
42+
43+
### Compile Action Service
44+
45+
Tick option to "Forward client headers to webhook".
46+
47+
#### Action definition
48+
49+
```graphql
50+
type Mutation {
51+
compile (
52+
basic: String!
53+
): CompileResult
54+
}
55+
```
56+
57+
#### New types definition
58+
59+
```graphql
60+
type CompileResult {
61+
base64Encoded: String!
62+
}
63+
```
64+
65+
#### Handler
66+
67+
```
68+
http://zxbasic/compile/
69+
```

apps/zxbasic/app/__init__.py

Whitespace-only changes.

apps/zxbasic/app/main.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from contextlib import asynccontextmanager
2+
from fastapi import FastAPI, status, Request
3+
from fastapi.exceptions import RequestValidationError
4+
from fastapi.encoders import jsonable_encoder
5+
from fastapi.middleware.cors import CORSMiddleware
6+
from starlette.responses import JSONResponse
7+
from starlette.middleware.base import BaseHTTPMiddleware
8+
from app.routes.compile import compile_endpoint
9+
from app.process_monitor import process_monitor
10+
11+
12+
@asynccontextmanager
13+
async def lifespan(app: FastAPI):
14+
# Start the process monitor when the app starts.
15+
process_monitor.start()
16+
print("Process monitor started - will kill compilation processes older than 8 seconds")
17+
yield
18+
process_monitor.stop()
19+
20+
21+
app = FastAPI(lifespan=lifespan)
22+
23+
24+
# Security Headers Middleware
25+
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
26+
async def dispatch(self, request: Request, call_next):
27+
response = await call_next(request)
28+
29+
# Add security headers
30+
response.headers["X-Content-Type-Options"] = "nosniff"
31+
response.headers["X-Frame-Options"] = "DENY"
32+
response.headers["X-XSS-Protection"] = "1; mode=block"
33+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
34+
35+
# Only add HSTS for HTTPS connections
36+
if request.url.scheme == "https":
37+
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
38+
39+
return response
40+
41+
42+
# Add security headers middleware
43+
app.add_middleware(SecurityHeadersMiddleware)
44+
45+
# Configure CORS - permissive for compatibility with Hasura
46+
# You can restrict allow_origins later if needed
47+
app.add_middleware(
48+
CORSMiddleware,
49+
allow_origins=["*"], # Allow all origins for now - restrict this in production
50+
allow_credentials=True,
51+
allow_methods=["*"], # Allow all methods
52+
allow_headers=["*"], # Hasura sends various headers
53+
max_age=3600,
54+
)
55+
56+
57+
@app.exception_handler(RequestValidationError)
58+
async def validation_exception_handler(request: Request, exc: RequestValidationError):
59+
return JSONResponse(
60+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
61+
content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}),
62+
)
63+
64+
65+
# Health check endpoint for monitoring
66+
@app.get("/health")
67+
async def health_check():
68+
return {"status": "healthy", "service": "zxbasic-compiler"}
69+
70+
71+
app.include_router(compile_endpoint, prefix='/compile')
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""
2+
Background thread that monitors and kills long-running compilation processes.
3+
Runs as part of the FastAPI application.
4+
"""
5+
import os
6+
import time
7+
import signal
8+
import threading
9+
import subprocess
10+
from datetime import datetime
11+
12+
# Configuration
13+
MAX_PROCESS_AGE = 8 # Kill compilation processes older than 8 seconds
14+
CHECK_INTERVAL = 2 # Check every 2 seconds
15+
16+
17+
class ProcessMonitor:
18+
def __init__(self):
19+
self.running = False
20+
self.thread = None
21+
self.processes = {} # Track subprocess PIDs we start
22+
23+
def register_process(self, pid):
24+
"""Register a compilation process to monitor"""
25+
self.processes[pid] = time.time()
26+
print(f"[MONITOR] Tracking compilation process PID {pid}")
27+
28+
def start(self):
29+
"""Start the monitor thread"""
30+
if not self.running:
31+
self.running = True
32+
self.thread = threading.Thread(target=self._monitor_loop, daemon=True)
33+
self.thread.start()
34+
print("[MONITOR] Process monitor started")
35+
36+
def stop(self):
37+
"""Stop the monitor thread"""
38+
self.running = False
39+
40+
def _monitor_loop(self):
41+
"""Main monitoring loop that runs in background"""
42+
while self.running:
43+
try:
44+
current_time = time.time()
45+
pids_to_remove = []
46+
47+
# Check tracked processes
48+
for pid, start_time in list(self.processes.items()):
49+
age = current_time - start_time
50+
51+
try:
52+
# Check if process still exists
53+
os.kill(pid, 0) # Signal 0 just checks if process exists
54+
55+
if age > MAX_PROCESS_AGE:
56+
print(f"[MONITOR] Killing stuck process PID {pid} (age: {age:.1f}s)")
57+
try:
58+
# Try graceful termination first
59+
os.kill(pid, signal.SIGTERM)
60+
time.sleep(0.5)
61+
# Check if still alive
62+
os.kill(pid, 0)
63+
# If still alive, force kill
64+
os.kill(pid, signal.SIGKILL)
65+
print(f"[MONITOR] Force killed PID {pid}")
66+
except OSError:
67+
pass # Process already dead
68+
pids_to_remove.append(pid)
69+
70+
except OSError:
71+
# Process doesn't exist anymore
72+
pids_to_remove.append(pid)
73+
74+
# Clean up dead processes from tracking
75+
for pid in pids_to_remove:
76+
del self.processes[pid]
77+
78+
# Also check for any zxbc processes we didn't start
79+
# (in case of threading issues)
80+
self._check_orphan_processes()
81+
82+
except Exception as e:
83+
print(f"[MONITOR] Error in monitor loop: {e}")
84+
85+
time.sleep(CHECK_INTERVAL)
86+
87+
def _check_orphan_processes(self):
88+
"""Check for compilation processes we might not be tracking"""
89+
try:
90+
# Use ps to find zxbc processes
91+
result = subprocess.run(
92+
["ps", "aux"],
93+
capture_output=True,
94+
text=True,
95+
timeout=1
96+
)
97+
98+
for line in result.stdout.split('\n'):
99+
# The app only ever spawns the zxbc compiler for compilation, so
100+
# match on the executable name rather than specific flags. The
101+
# zxbasic console script appears in ps as ".../bin/zxbc".
102+
if 'zxbc' in line:
103+
parts = line.split()
104+
if len(parts) > 1:
105+
pid = int(parts[1])
106+
# If we're not tracking this PID, it might be orphaned
107+
if pid not in self.processes:
108+
# Check process age using /proc if available
109+
try:
110+
stat_path = f"/proc/{pid}/stat"
111+
if os.path.exists(stat_path):
112+
with open(stat_path, 'r') as f:
113+
stat_data = f.read().split()
114+
# Field 21 is start time in jiffies
115+
start_jiffies = int(stat_data[21])
116+
# Rough age calculation
117+
age_seconds = (time.time() - os.path.getmtime(stat_path))
118+
if age_seconds > MAX_PROCESS_AGE:
119+
print(f"[MONITOR] Found orphan zxbc process PID {pid}, killing...")
120+
os.kill(pid, signal.SIGKILL)
121+
except:
122+
pass
123+
except:
124+
pass # PS might not be available or fail
125+
126+
127+
# Global monitor instance
128+
process_monitor = ProcessMonitor()

0 commit comments

Comments
 (0)