diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 64b9d5b..a48e49d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,8 +14,8 @@ jobs:
strategy:
fail-fast: false
matrix:
- otp: ['27.0', '27.1']
- python: ['3.12', '3.13']
+ otp: ['27.0', '27.1', '28.0']
+ python: ['3.12', '3.13', '3.14']
steps:
- uses: actions/checkout@v4
@@ -66,7 +66,7 @@ jobs:
- name: Set up Erlang
uses: erlef/setup-beam@v1
with:
- otp-version: '27.1'
+ otp-version: '28.0'
rebar3-version: '3.24'
- name: Build ex_doc
@@ -82,7 +82,7 @@ jobs:
- name: Set up Erlang
uses: erlef/setup-beam@v1
with:
- otp-version: '27.1'
+ otp-version: '28.0'
rebar3-version: '3.24'
- name: Compile
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 97ed177..9c4f4a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Changed
+
+- **Shared Context Pool**: All mounts now share the default `py_context_router` pool
+ - Removed per-mount `workers` option (use global pool size instead)
+ - Better resource utilization across multiple mounted apps
+ - Simplified architecture with cached NIF refs
+
+- **ASGI Performance Optimizations**:
+ - Event loop pool for parallel ASGI task distribution
+ - Cached state proxies per mount_id (avoid allocation per request)
+ - Preloaded app modules via `py_import:ensure_imported`
+ - Lazy state proxy with ETS-backed callbacks
+
+### Performance
+
+- ASGI now outperforms WSGI by 11-16% across test scenarios:
+ - Simple requests (100 conc): ~70k req/s (+13%)
+ - High concurrency (500 conc): ~64k req/s (+16%)
+ - Sustained load (200 conc): ~71k req/s (+14%)
+
+### Removed
+
+- `workers` option from per-mount configuration (use shared pool)
+
## [1.4.1] - 2026-02-25
### Fixed
diff --git a/benchmarks/asgi_large_response_app.py b/benchmarks/asgi_large_response_app.py
new file mode 100644
index 0000000..2c62b16
--- /dev/null
+++ b/benchmarks/asgi_large_response_app.py
@@ -0,0 +1,27 @@
+# ASGI app that returns large responses to test streaming
+
+SMALL_BODY = b'x' * 1000 # 1KB - should buffer
+LARGE_BODY = b'x' * 100000 # 100KB - should stream
+
+async def app(scope, receive, send):
+ """ASGI app with configurable response size."""
+ if scope['type'] == 'http':
+ path = scope.get('path', '/')
+
+ if path == '/large':
+ body = LARGE_BODY
+ else:
+ body = SMALL_BODY
+
+ await send({
+ 'type': 'http.response.start',
+ 'status': 200,
+ 'headers': [
+ [b'content-type', b'text/plain'],
+ [b'content-length', str(len(body)).encode()],
+ ],
+ })
+ await send({
+ 'type': 'http.response.body',
+ 'body': body,
+ })
diff --git a/benchmarks/asgi_noop_app.py b/benchmarks/asgi_noop_app.py
new file mode 100644
index 0000000..8d8c886
--- /dev/null
+++ b/benchmarks/asgi_noop_app.py
@@ -0,0 +1,17 @@
+# Minimal ASGI app - no processing
+
+async def app(scope, receive, send):
+ """Minimal ASGI app that returns Hello World."""
+ if scope['type'] == 'http':
+ await send({
+ 'type': 'http.response.start',
+ 'status': 200,
+ 'headers': [
+ [b'content-type', b'text/plain'],
+ [b'content-length', b'13'],
+ ],
+ })
+ await send({
+ 'type': 'http.response.body',
+ 'body': b'Hello, World!',
+ })
diff --git a/benchmarks/bench_pooled_comparison.sh b/benchmarks/bench_pooled_comparison.sh
new file mode 100755
index 0000000..14a8add
--- /dev/null
+++ b/benchmarks/bench_pooled_comparison.sh
@@ -0,0 +1,213 @@
+#!/bin/bash
+# Benchmark comparison: pooled vs non-pooled workers
+#
+# This script compares performance between:
+# - Non-pooled: Traditional per-request Python invocation
+# - Pooled: Persistent worker pool with channel-based dispatch
+
+set -e
+
+cd "$(dirname "$0")/.."
+
+# Check if ab is available
+if ! command -v ab &> /dev/null; then
+ echo "Error: 'ab' (Apache Bench) not found."
+ echo " macOS: brew install httpd"
+ echo " Linux: apt-get install apache2-utils"
+ exit 1
+fi
+
+# Check if project is compiled
+if [ ! -d "_build/default/lib" ]; then
+ echo "Compiling hornbeam..."
+ rebar3 compile
+fi
+
+# Configuration
+REQUESTS=10000
+CONCURRENCY=100
+PORT_NONPOOLED=8765
+PORT_POOLED=8766
+WORKERS=4
+
+cleanup() {
+ echo "Cleaning up..."
+ kill $PID_NONPOOLED 2>/dev/null || true
+ kill $PID_POOLED 2>/dev/null || true
+ wait $PID_NONPOOLED 2>/dev/null || true
+ wait $PID_POOLED 2>/dev/null || true
+}
+trap cleanup EXIT
+
+echo "=============================================="
+echo " Hornbeam Pooled vs Non-Pooled Benchmark"
+echo "=============================================="
+echo ""
+echo "Configuration:"
+echo " Requests: $REQUESTS"
+echo " Concurrency: $CONCURRENCY"
+echo " Workers (pooled): $WORKERS"
+echo ""
+
+# Start non-pooled server (single-app mode)
+echo "Starting non-pooled server on port $PORT_NONPOOLED..."
+erl -pa _build/default/lib/*/ebin \
+ -noshell \
+ -eval "
+ application:ensure_all_started(hornbeam),
+ hornbeam:start(<<\"simple_app:application\">>, #{
+ bind => <<\"127.0.0.1:$PORT_NONPOOLED\">>,
+ worker_class => wsgi,
+ pythonpath => [<<\"benchmarks\">>]
+ }).
+ " > /dev/null 2>&1 &
+PID_NONPOOLED=$!
+
+# Start pooled server (multi-app mode with pool_enabled)
+echo "Starting pooled server on port $PORT_POOLED..."
+erl -pa _build/default/lib/*/ebin \
+ -noshell \
+ -eval "
+ application:ensure_all_started(hornbeam),
+ py:exec(<<\"import sys; sys.path.insert(0, 'benchmarks')\">>),
+ hornbeam:start(#{
+ bind => <<\"127.0.0.1:$PORT_POOLED\">>,
+ mounts => [
+ {<<\"/\">>, <<\"simple_app:application\">>, #{
+ worker_class => wsgi,
+ workers => $WORKERS,
+ pool_enabled => true,
+ timeout => 30000
+ }}
+ ]
+ }).
+ " > /dev/null 2>&1 &
+PID_POOLED=$!
+
+# Wait for servers to be ready
+echo "Waiting for servers to start..."
+sleep 4
+
+# Verify servers are running
+for port in $PORT_NONPOOLED $PORT_POOLED; do
+ for i in {1..10}; do
+ if curl -s http://127.0.0.1:$port/ > /dev/null 2>&1; then
+ echo " Server on port $port is ready"
+ break
+ fi
+ if [ $i -eq 10 ]; then
+ echo " WARNING: Server on port $port may not be ready"
+ fi
+ sleep 0.5
+ done
+done
+
+echo ""
+
+# Warmup
+echo "Warming up servers..."
+ab -n 1000 -c 50 -k http://127.0.0.1:$PORT_NONPOOLED/ > /dev/null 2>&1 || true
+ab -n 1000 -c 50 -k http://127.0.0.1:$PORT_POOLED/ > /dev/null 2>&1 || true
+sleep 1
+
+echo ""
+echo "=============================================="
+echo " Test 1: Simple Requests ($REQUESTS req, $CONCURRENCY concurrent)"
+echo "=============================================="
+
+echo ""
+echo "--- Non-Pooled ---"
+RESULT_NP1=$(ab -n $REQUESTS -c $CONCURRENCY -k http://127.0.0.1:$PORT_NONPOOLED/ 2>&1)
+RPS_NP1=$(echo "$RESULT_NP1" | grep "Requests per second" | awk '{print $4}')
+LAT_NP1=$(echo "$RESULT_NP1" | grep "Time per request" | head -1 | awk '{print $4}')
+FAIL_NP1=$(echo "$RESULT_NP1" | grep "Failed requests" | awk '{print $3}')
+echo " Requests/sec: $RPS_NP1"
+echo " Latency: ${LAT_NP1}ms"
+echo " Failed: $FAIL_NP1"
+
+echo ""
+echo "--- Pooled ---"
+RESULT_P1=$(ab -n $REQUESTS -c $CONCURRENCY -k http://127.0.0.1:$PORT_POOLED/ 2>&1)
+RPS_P1=$(echo "$RESULT_P1" | grep "Requests per second" | awk '{print $4}')
+LAT_P1=$(echo "$RESULT_P1" | grep "Time per request" | head -1 | awk '{print $4}')
+FAIL_P1=$(echo "$RESULT_P1" | grep "Failed requests" | awk '{print $3}')
+echo " Requests/sec: $RPS_P1"
+echo " Latency: ${LAT_P1}ms"
+echo " Failed: $FAIL_P1"
+
+echo ""
+echo "=============================================="
+echo " Test 2: High Concurrency (5000 req, 500 concurrent)"
+echo "=============================================="
+
+echo ""
+echo "--- Non-Pooled ---"
+RESULT_NP2=$(ab -n 5000 -c 500 -k http://127.0.0.1:$PORT_NONPOOLED/ 2>&1)
+RPS_NP2=$(echo "$RESULT_NP2" | grep "Requests per second" | awk '{print $4}')
+LAT_NP2=$(echo "$RESULT_NP2" | grep "Time per request" | head -1 | awk '{print $4}')
+FAIL_NP2=$(echo "$RESULT_NP2" | grep "Failed requests" | awk '{print $3}')
+echo " Requests/sec: $RPS_NP2"
+echo " Latency: ${LAT_NP2}ms"
+echo " Failed: $FAIL_NP2"
+
+echo ""
+echo "--- Pooled ---"
+RESULT_P2=$(ab -n 5000 -c 500 -k http://127.0.0.1:$PORT_POOLED/ 2>&1)
+RPS_P2=$(echo "$RESULT_P2" | grep "Requests per second" | awk '{print $4}')
+LAT_P2=$(echo "$RESULT_P2" | grep "Time per request" | head -1 | awk '{print $4}')
+FAIL_P2=$(echo "$RESULT_P2" | grep "Failed requests" | awk '{print $3}')
+echo " Requests/sec: $RPS_P2"
+echo " Latency: ${LAT_P2}ms"
+echo " Failed: $FAIL_P2"
+
+echo ""
+echo "=============================================="
+echo " Test 3: Sustained Load (20000 req, 200 concurrent)"
+echo "=============================================="
+
+echo ""
+echo "--- Non-Pooled ---"
+RESULT_NP3=$(ab -n 20000 -c 200 -k http://127.0.0.1:$PORT_NONPOOLED/ 2>&1)
+RPS_NP3=$(echo "$RESULT_NP3" | grep "Requests per second" | awk '{print $4}')
+LAT_NP3=$(echo "$RESULT_NP3" | grep "Time per request" | head -1 | awk '{print $4}')
+FAIL_NP3=$(echo "$RESULT_NP3" | grep "Failed requests" | awk '{print $3}')
+echo " Requests/sec: $RPS_NP3"
+echo " Latency: ${LAT_NP3}ms"
+echo " Failed: $FAIL_NP3"
+
+echo ""
+echo "--- Pooled ---"
+RESULT_P3=$(ab -n 20000 -c 200 -k http://127.0.0.1:$PORT_POOLED/ 2>&1)
+RPS_P3=$(echo "$RESULT_P3" | grep "Requests per second" | awk '{print $4}')
+LAT_P3=$(echo "$RESULT_P3" | grep "Time per request" | head -1 | awk '{print $4}')
+FAIL_P3=$(echo "$RESULT_P3" | grep "Failed requests" | awk '{print $3}')
+echo " Requests/sec: $RPS_P3"
+echo " Latency: ${LAT_P3}ms"
+echo " Failed: $FAIL_P3"
+
+echo ""
+echo "=============================================="
+echo " Summary"
+echo "=============================================="
+echo ""
+printf "%-25s %15s %15s %10s\n" "Test" "Non-Pooled" "Pooled" "Diff"
+printf "%-25s %15s %15s %10s\n" "-------------------------" "---------------" "---------------" "----------"
+
+# Calculate differences (using awk for floating point)
+if [ -n "$RPS_NP1" ] && [ -n "$RPS_P1" ]; then
+ DIFF1=$(echo "$RPS_P1 $RPS_NP1" | awk '{if($2>0) printf "%.1f%%", (($1-$2)/$2)*100; else print "N/A"}')
+ printf "%-25s %12s/s %12s/s %10s\n" "Simple (100 conc)" "$RPS_NP1" "$RPS_P1" "$DIFF1"
+fi
+
+if [ -n "$RPS_NP2" ] && [ -n "$RPS_P2" ]; then
+ DIFF2=$(echo "$RPS_P2 $RPS_NP2" | awk '{if($2>0) printf "%.1f%%", (($1-$2)/$2)*100; else print "N/A"}')
+ printf "%-25s %12s/s %12s/s %10s\n" "High conc (500 conc)" "$RPS_NP2" "$RPS_P2" "$DIFF2"
+fi
+
+if [ -n "$RPS_NP3" ] && [ -n "$RPS_P3" ]; then
+ DIFF3=$(echo "$RPS_P3 $RPS_NP3" | awk '{if($2>0) printf "%.1f%%", (($1-$2)/$2)*100; else print "N/A"}')
+ printf "%-25s %12s/s %12s/s %10s\n" "Sustained (200 conc)" "$RPS_NP3" "$RPS_P3" "$DIFF3"
+fi
+
+echo ""
+echo "Done!"
diff --git a/benchmarks/bench_wsgi_vs_asgi.sh b/benchmarks/bench_wsgi_vs_asgi.sh
new file mode 100755
index 0000000..15ea773
--- /dev/null
+++ b/benchmarks/bench_wsgi_vs_asgi.sh
@@ -0,0 +1,353 @@
+#!/bin/bash
+# Benchmark comparison: WSGI vs WSGI (owngil) vs ASGI
+#
+# Compares performance between WSGI, WSGI with owngil, and ASGI worker classes
+#
+# For owngil mode (per-interpreter GIL), set PYTHON_CONFIG to point to
+# a Python 3.14+ python-config script:
+# export PYTHON_CONFIG=/path/to/python3.14-config
+# ./benchmarks/bench_wsgi_vs_asgi.sh
+
+set -e
+
+cd "$(dirname "$0")/.."
+
+# Check if ab is available
+if ! command -v ab &> /dev/null; then
+ echo "Error: 'ab' (Apache Bench) not found."
+ exit 1
+fi
+
+# Configuration
+REQUESTS=10000
+CONCURRENCY=100
+PORT_WSGI=8765
+PORT_WSGI_OWNGIL=8767
+PORT_ASGI=8766
+
+# Check for Python 3.12+ for owngil mode and rebuild if PYTHON_CONFIG is set
+RUN_OWNGIL=false
+if [ -n "$PYTHON_CONFIG" ]; then
+ if [ -x "$PYTHON_CONFIG" ]; then
+ PY_VERSION=$("$PYTHON_CONFIG" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+' | head -1)
+ PY_MAJOR=$(echo "$PY_VERSION" | cut -d. -f1)
+ PY_MINOR=$(echo "$PY_VERSION" | cut -d. -f2)
+ if [ "$PY_MAJOR" -ge 3 ] && [ "$PY_MINOR" -ge 12 ]; then
+ RUN_OWNGIL=true
+ echo "Found Python $PY_VERSION for owngil mode"
+ echo "Rebuilding with PYTHON_CONFIG=$PYTHON_CONFIG..."
+ rm -rf _build/default/lib/erlang_python
+ PYTHON_CONFIG="$PYTHON_CONFIG" rebar3 compile
+ else
+ echo "Warning: PYTHON_CONFIG points to Python $PY_VERSION, owngil requires 3.12+"
+ echo "Compiling hornbeam..."
+ rebar3 compile
+ fi
+ else
+ echo "Warning: PYTHON_CONFIG=$PYTHON_CONFIG is not executable"
+ echo "Compiling hornbeam..."
+ rebar3 compile
+ fi
+else
+ # Check if project is compiled
+ if [ ! -d "_build/default/lib" ]; then
+ echo "Compiling hornbeam..."
+ rebar3 compile
+ fi
+fi
+
+cleanup() {
+ echo "Cleaning up..."
+ kill $PID_WSGI 2>/dev/null || true
+ [ "$RUN_OWNGIL" = true ] && kill $PID_WSGI_OWNGIL 2>/dev/null || true
+ kill $PID_ASGI 2>/dev/null || true
+ wait $PID_WSGI 2>/dev/null || true
+ [ "$RUN_OWNGIL" = true ] && wait $PID_WSGI_OWNGIL 2>/dev/null || true
+ wait $PID_ASGI 2>/dev/null || true
+}
+trap cleanup EXIT
+
+echo "=============================================="
+echo " Hornbeam WSGI vs WSGI (owngil) vs ASGI"
+echo "=============================================="
+echo ""
+echo "Configuration:"
+echo " Requests: $REQUESTS"
+echo " Concurrency: $CONCURRENCY"
+echo " owngil mode: $RUN_OWNGIL"
+echo ""
+
+# Start WSGI server
+echo "Starting WSGI server on port $PORT_WSGI..."
+erl -pa _build/default/lib/*/ebin \
+ -noshell \
+ -eval "
+ application:ensure_all_started(hornbeam),
+ hornbeam:start(<<\"simple_app:application\">>, #{
+ bind => <<\"127.0.0.1:$PORT_WSGI\">>,
+ worker_class => wsgi,
+ pythonpath => [<<\"benchmarks\">>]
+ }).
+ " > /dev/null 2>&1 &
+PID_WSGI=$!
+
+# Start WSGI server with owngil (per-interpreter GIL) if Python 3.12+ available
+if [ "$RUN_OWNGIL" = true ]; then
+ echo "Starting WSGI (owngil) server on port $PORT_WSGI_OWNGIL..."
+ PYTHON_CONFIG="$PYTHON_CONFIG" erl -pa _build/default/lib/*/ebin \
+ -noshell \
+ -eval "
+ application:ensure_all_started(hornbeam),
+ hornbeam:start(<<\"simple_app:application\">>, #{
+ bind => <<\"127.0.0.1:$PORT_WSGI_OWNGIL\">>,
+ worker_class => wsgi,
+ context_mode => owngil,
+ pythonpath => [<<\"benchmarks\">>]
+ }).
+ " > /dev/null 2>&1 &
+ PID_WSGI_OWNGIL=$!
+fi
+
+# Start ASGI server
+echo "Starting ASGI server on port $PORT_ASGI..."
+erl -pa _build/default/lib/*/ebin \
+ -noshell \
+ -eval "
+ application:ensure_all_started(hornbeam),
+ hornbeam:start(<<\"simple_asgi_app:application\">>, #{
+ bind => <<\"127.0.0.1:$PORT_ASGI\">>,
+ worker_class => asgi,
+ pythonpath => [<<\"benchmarks\">>]
+ }).
+ " > /dev/null 2>&1 &
+PID_ASGI=$!
+
+# Wait for servers to be ready
+echo "Waiting for servers to start..."
+sleep 4
+
+# Build list of ports to check
+PORTS_TO_CHECK="$PORT_WSGI $PORT_ASGI"
+[ "$RUN_OWNGIL" = true ] && PORTS_TO_CHECK="$PORT_WSGI $PORT_WSGI_OWNGIL $PORT_ASGI"
+
+# Verify servers are running
+for port in $PORTS_TO_CHECK; do
+ for i in {1..10}; do
+ if curl -s http://127.0.0.1:$port/ > /dev/null 2>&1; then
+ echo " Server on port $port is ready"
+ break
+ fi
+ if [ $i -eq 10 ]; then
+ echo " WARNING: Server on port $port may not be ready"
+ fi
+ sleep 0.5
+ done
+done
+
+echo ""
+
+# Warmup
+echo "Warming up servers..."
+ab -n 1000 -c 50 -k http://127.0.0.1:$PORT_WSGI/ > /dev/null 2>&1 || true
+[ "$RUN_OWNGIL" = true ] && ab -n 1000 -c 50 -k http://127.0.0.1:$PORT_WSGI_OWNGIL/ > /dev/null 2>&1 || true
+ab -n 1000 -c 50 -k http://127.0.0.1:$PORT_ASGI/ > /dev/null 2>&1 || true
+sleep 1
+
+echo ""
+echo "=============================================="
+echo " Test 1: Simple Requests ($REQUESTS req, $CONCURRENCY concurrent)"
+echo "=============================================="
+
+echo ""
+echo "--- WSGI ---"
+RESULT_W1=$(ab -n $REQUESTS -c $CONCURRENCY -k http://127.0.0.1:$PORT_WSGI/ 2>&1)
+RPS_W1=$(echo "$RESULT_W1" | grep "Requests per second" | awk '{print $4}')
+LAT_W1=$(echo "$RESULT_W1" | grep "Time per request" | head -1 | awk '{print $4}')
+FAIL_W1=$(echo "$RESULT_W1" | grep "Failed requests" | awk '{print $3}')
+echo " Requests/sec: $RPS_W1"
+echo " Latency: ${LAT_W1}ms"
+echo " Failed: $FAIL_W1"
+
+if [ "$RUN_OWNGIL" = true ]; then
+ echo ""
+ echo "--- WSGI (owngil) ---"
+ RESULT_O1=$(ab -n $REQUESTS -c $CONCURRENCY -k http://127.0.0.1:$PORT_WSGI_OWNGIL/ 2>&1)
+ RPS_O1=$(echo "$RESULT_O1" | grep "Requests per second" | awk '{print $4}')
+ LAT_O1=$(echo "$RESULT_O1" | grep "Time per request" | head -1 | awk '{print $4}')
+ FAIL_O1=$(echo "$RESULT_O1" | grep "Failed requests" | awk '{print $3}')
+ echo " Requests/sec: $RPS_O1"
+ echo " Latency: ${LAT_O1}ms"
+ echo " Failed: $FAIL_O1"
+fi
+
+echo ""
+echo "--- ASGI ---"
+RESULT_A1=$(ab -n $REQUESTS -c $CONCURRENCY -k http://127.0.0.1:$PORT_ASGI/ 2>&1)
+RPS_A1=$(echo "$RESULT_A1" | grep "Requests per second" | awk '{print $4}')
+LAT_A1=$(echo "$RESULT_A1" | grep "Time per request" | head -1 | awk '{print $4}')
+FAIL_A1=$(echo "$RESULT_A1" | grep "Failed requests" | awk '{print $3}')
+echo " Requests/sec: $RPS_A1"
+echo " Latency: ${LAT_A1}ms"
+echo " Failed: $FAIL_A1"
+
+echo ""
+echo "=============================================="
+echo " Test 2: High Concurrency (5000 req, 500 concurrent)"
+echo "=============================================="
+
+echo ""
+echo "--- WSGI ---"
+RESULT_W2=$(ab -n 5000 -c 500 -k http://127.0.0.1:$PORT_WSGI/ 2>&1)
+RPS_W2=$(echo "$RESULT_W2" | grep "Requests per second" | awk '{print $4}')
+LAT_W2=$(echo "$RESULT_W2" | grep "Time per request" | head -1 | awk '{print $4}')
+FAIL_W2=$(echo "$RESULT_W2" | grep "Failed requests" | awk '{print $3}')
+echo " Requests/sec: $RPS_W2"
+echo " Latency: ${LAT_W2}ms"
+echo " Failed: $FAIL_W2"
+
+if [ "$RUN_OWNGIL" = true ]; then
+ echo ""
+ echo "--- WSGI (owngil) ---"
+ RESULT_O2=$(ab -n 5000 -c 500 -k http://127.0.0.1:$PORT_WSGI_OWNGIL/ 2>&1)
+ RPS_O2=$(echo "$RESULT_O2" | grep "Requests per second" | awk '{print $4}')
+ LAT_O2=$(echo "$RESULT_O2" | grep "Time per request" | head -1 | awk '{print $4}')
+ FAIL_O2=$(echo "$RESULT_O2" | grep "Failed requests" | awk '{print $3}')
+ echo " Requests/sec: $RPS_O2"
+ echo " Latency: ${LAT_O2}ms"
+ echo " Failed: $FAIL_O2"
+fi
+
+echo ""
+echo "--- ASGI ---"
+RESULT_A2=$(ab -n 5000 -c 500 -k http://127.0.0.1:$PORT_ASGI/ 2>&1)
+RPS_A2=$(echo "$RESULT_A2" | grep "Requests per second" | awk '{print $4}')
+LAT_A2=$(echo "$RESULT_A2" | grep "Time per request" | head -1 | awk '{print $4}')
+FAIL_A2=$(echo "$RESULT_A2" | grep "Failed requests" | awk '{print $3}')
+echo " Requests/sec: $RPS_A2"
+echo " Latency: ${LAT_A2}ms"
+echo " Failed: $FAIL_A2"
+
+echo ""
+echo "=============================================="
+echo " Test 3: Sustained Load (20000 req, 200 concurrent)"
+echo "=============================================="
+
+echo ""
+echo "--- WSGI ---"
+RESULT_W3=$(ab -n 20000 -c 200 -k http://127.0.0.1:$PORT_WSGI/ 2>&1)
+RPS_W3=$(echo "$RESULT_W3" | grep "Requests per second" | awk '{print $4}')
+LAT_W3=$(echo "$RESULT_W3" | grep "Time per request" | head -1 | awk '{print $4}')
+FAIL_W3=$(echo "$RESULT_W3" | grep "Failed requests" | awk '{print $3}')
+echo " Requests/sec: $RPS_W3"
+echo " Latency: ${LAT_W3}ms"
+echo " Failed: $FAIL_W3"
+
+if [ "$RUN_OWNGIL" = true ]; then
+ echo ""
+ echo "--- WSGI (owngil) ---"
+ RESULT_O3=$(ab -n 20000 -c 200 -k http://127.0.0.1:$PORT_WSGI_OWNGIL/ 2>&1)
+ RPS_O3=$(echo "$RESULT_O3" | grep "Requests per second" | awk '{print $4}')
+ LAT_O3=$(echo "$RESULT_O3" | grep "Time per request" | head -1 | awk '{print $4}')
+ FAIL_O3=$(echo "$RESULT_O3" | grep "Failed requests" | awk '{print $3}')
+ echo " Requests/sec: $RPS_O3"
+ echo " Latency: ${LAT_O3}ms"
+ echo " Failed: $FAIL_O3"
+fi
+
+echo ""
+echo "--- ASGI ---"
+RESULT_A3=$(ab -n 20000 -c 200 -k http://127.0.0.1:$PORT_ASGI/ 2>&1)
+RPS_A3=$(echo "$RESULT_A3" | grep "Requests per second" | awk '{print $4}')
+LAT_A3=$(echo "$RESULT_A3" | grep "Time per request" | head -1 | awk '{print $4}')
+FAIL_A3=$(echo "$RESULT_A3" | grep "Failed requests" | awk '{print $3}')
+echo " Requests/sec: $RPS_A3"
+echo " Latency: ${LAT_A3}ms"
+echo " Failed: $FAIL_A3"
+
+echo ""
+echo "=============================================="
+echo " Test 4: Large Response (1000 req, 50 concurrent)"
+echo "=============================================="
+
+echo ""
+echo "--- WSGI ---"
+RESULT_W4=$(ab -n 1000 -c 50 -k http://127.0.0.1:$PORT_WSGI/large 2>&1)
+RPS_W4=$(echo "$RESULT_W4" | grep "Requests per second" | awk '{print $4}')
+LAT_W4=$(echo "$RESULT_W4" | grep "Time per request" | head -1 | awk '{print $4}')
+FAIL_W4=$(echo "$RESULT_W4" | grep "Failed requests" | awk '{print $3}')
+echo " Requests/sec: $RPS_W4"
+echo " Latency: ${LAT_W4}ms"
+echo " Failed: $FAIL_W4"
+
+if [ "$RUN_OWNGIL" = true ]; then
+ echo ""
+ echo "--- WSGI (owngil) ---"
+ RESULT_O4=$(ab -n 1000 -c 50 -k http://127.0.0.1:$PORT_WSGI_OWNGIL/large 2>&1)
+ RPS_O4=$(echo "$RESULT_O4" | grep "Requests per second" | awk '{print $4}')
+ LAT_O4=$(echo "$RESULT_O4" | grep "Time per request" | head -1 | awk '{print $4}')
+ FAIL_O4=$(echo "$RESULT_O4" | grep "Failed requests" | awk '{print $3}')
+ echo " Requests/sec: $RPS_O4"
+ echo " Latency: ${LAT_O4}ms"
+ echo " Failed: $FAIL_O4"
+fi
+
+echo ""
+echo "--- ASGI ---"
+RESULT_A4=$(ab -n 1000 -c 50 -k http://127.0.0.1:$PORT_ASGI/large 2>&1)
+RPS_A4=$(echo "$RESULT_A4" | grep "Requests per second" | awk '{print $4}')
+LAT_A4=$(echo "$RESULT_A4" | grep "Time per request" | head -1 | awk '{print $4}')
+FAIL_A4=$(echo "$RESULT_A4" | grep "Failed requests" | awk '{print $3}')
+echo " Requests/sec: $RPS_A4"
+echo " Latency: ${LAT_A4}ms"
+echo " Failed: $FAIL_A4"
+
+echo ""
+echo "=============================================="
+echo " Summary"
+echo "=============================================="
+echo ""
+
+if [ "$RUN_OWNGIL" = true ]; then
+ printf "%-25s %12s %12s %12s\n" "Test" "WSGI" "WSGI(owngil)" "ASGI"
+ printf "%-25s %12s %12s %12s\n" "-------------------------" "------------" "------------" "------------"
+
+ if [ -n "$RPS_W1" ] && [ -n "$RPS_O1" ] && [ -n "$RPS_A1" ]; then
+ printf "%-25s %9s/s %9s/s %9s/s\n" "Simple (100 conc)" "$RPS_W1" "$RPS_O1" "$RPS_A1"
+ fi
+ if [ -n "$RPS_W2" ] && [ -n "$RPS_O2" ] && [ -n "$RPS_A2" ]; then
+ printf "%-25s %9s/s %9s/s %9s/s\n" "High conc (500 conc)" "$RPS_W2" "$RPS_O2" "$RPS_A2"
+ fi
+ if [ -n "$RPS_W3" ] && [ -n "$RPS_O3" ] && [ -n "$RPS_A3" ]; then
+ printf "%-25s %9s/s %9s/s %9s/s\n" "Sustained (200 conc)" "$RPS_W3" "$RPS_O3" "$RPS_A3"
+ fi
+ if [ -n "$RPS_W4" ] && [ -n "$RPS_O4" ] && [ -n "$RPS_A4" ]; then
+ printf "%-25s %9s/s %9s/s %9s/s\n" "Large response (50 conc)" "$RPS_W4" "$RPS_O4" "$RPS_A4"
+ fi
+else
+ printf "%-25s %15s %15s %10s\n" "Test" "WSGI" "ASGI" "Diff"
+ printf "%-25s %15s %15s %10s\n" "-------------------------" "---------------" "---------------" "----------"
+
+ # Calculate differences
+ if [ -n "$RPS_W1" ] && [ -n "$RPS_A1" ]; then
+ DIFF1=$(echo "$RPS_A1 $RPS_W1" | awk '{if($2>0) printf "%.1f%%", (($1-$2)/$2)*100; else print "N/A"}')
+ printf "%-25s %12s/s %12s/s %10s\n" "Simple (100 conc)" "$RPS_W1" "$RPS_A1" "$DIFF1"
+ fi
+
+ if [ -n "$RPS_W2" ] && [ -n "$RPS_A2" ]; then
+ DIFF2=$(echo "$RPS_A2 $RPS_W2" | awk '{if($2>0) printf "%.1f%%", (($1-$2)/$2)*100; else print "N/A"}')
+ printf "%-25s %12s/s %12s/s %10s\n" "High conc (500 conc)" "$RPS_W2" "$RPS_A2" "$DIFF2"
+ fi
+
+ if [ -n "$RPS_W3" ] && [ -n "$RPS_A3" ]; then
+ DIFF3=$(echo "$RPS_A3 $RPS_W3" | awk '{if($2>0) printf "%.1f%%", (($1-$2)/$2)*100; else print "N/A"}')
+ printf "%-25s %12s/s %12s/s %10s\n" "Sustained (200 conc)" "$RPS_W3" "$RPS_A3" "$DIFF3"
+ fi
+
+ if [ -n "$RPS_W4" ] && [ -n "$RPS_A4" ]; then
+ DIFF4=$(echo "$RPS_A4 $RPS_W4" | awk '{if($2>0) printf "%.1f%%", (($1-$2)/$2)*100; else print "N/A"}')
+ printf "%-25s %12s/s %12s/s %10s\n" "Large response (50 conc)" "$RPS_W4" "$RPS_A4" "$DIFF4"
+ fi
+fi
+
+echo ""
+echo "Done!"
diff --git a/benchmarks/noop_asgi.py b/benchmarks/noop_asgi.py
new file mode 100644
index 0000000..896fbec
--- /dev/null
+++ b/benchmarks/noop_asgi.py
@@ -0,0 +1,12 @@
+# Minimal ASGI app - no processing
+async def application(scope, receive, send):
+ if scope['type'] == 'http':
+ await send({
+ 'type': 'http.response.start',
+ 'status': 200,
+ 'headers': [(b'content-type', b'text/plain'), (b'content-length', b'13')],
+ })
+ await send({
+ 'type': 'http.response.body',
+ 'body': b'Hello, World!',
+ })
diff --git a/benchmarks/simple_asgi_app.py b/benchmarks/simple_asgi_app.py
index 7e6e8db..902d005 100644
--- a/benchmarks/simple_asgi_app.py
+++ b/benchmarks/simple_asgi_app.py
@@ -1,5 +1,4 @@
# Simple ASGI app for benchmarking
-# Async version of the WSGI benchmark app
async def application(scope, receive, send):
diff --git a/config/sys.config b/config/sys.config
index 8873334..01bfa52 100644
--- a/config/sys.config
+++ b/config/sys.config
@@ -7,9 +7,13 @@
{keepalive, 2},
{max_requests, 1000},
{preload_app, false},
- {pythonpath, [".", "examples"]}
+ {pythonpath, [".", "examples"]},
+ %% WSGI body streaming settings
+ {wsgi_body_chunk_size, 65536}, %% 64KB chunks
+ {wsgi_streaming_threshold, 65536} %% Stream if > 64KB
]},
{erlang_python, [
- {num_workers, 4}
+ {num_workers, 4},
+ {max_concurrent, 10000}
]}
].
diff --git a/docs/guides/multi-app.md b/docs/guides/multi-app.md
index 24acc65..f2f8046 100644
--- a/docs/guides/multi-app.md
+++ b/docs/guides/multi-app.md
@@ -24,7 +24,7 @@ Each mount is a tuple of `{Prefix, AppSpec, Options}`:
- **Prefix** - URL path prefix (must start with `/`)
- **AppSpec** - Python module:callable (e.g., `"myapp:application"`)
-- **Options** - Per-mount options (worker_class, workers, timeout)
+- **Options** - Per-mount options (worker_class, timeout)
## Routing Behavior
@@ -93,24 +93,21 @@ Each mount can have its own configuration:
```erlang
hornbeam:start(#{
mounts => [
- %% High-performance async API with more workers
+ %% High-performance async API
{"/api", "api:app", #{
worker_class => asgi,
- workers => 8,
timeout => 60000
}},
- %% Admin panel - fewer workers needed
+ %% Admin panel
{"/admin", "admin:app", #{
worker_class => wsgi,
- workers => 2,
timeout => 30000
}},
%% Static frontend
{"/", "frontend:app", #{
- worker_class => wsgi,
- workers => 4
+ worker_class => wsgi
}}
],
bind => "0.0.0.0:8000"
@@ -122,9 +119,10 @@ hornbeam:start(#{
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `worker_class` | atom | `wsgi` | Protocol: `wsgi` or `asgi` |
-| `workers` | integer | `4` | Number of Python workers |
| `timeout` | integer | `30000` | Request timeout in ms |
+> **Note:** All mounts share the global `py_context_router` pool. Configure pool size at the application level rather than per-mount.
+
## Global Options
Global options apply to all mounts:
@@ -184,20 +182,17 @@ hornbeam:start(#{
mounts => [
%% FastAPI for real-time API
{"/api/v2", "api_v2:app", #{
- worker_class => asgi,
- workers => 8
+ worker_class => asgi
}},
%% Legacy Flask API
{"/api/v1", "api_v1:app", #{
- worker_class => wsgi,
- workers => 4
+ worker_class => wsgi
}},
%% Django admin
{"/admin", "myproject.wsgi:application", #{
- worker_class => wsgi,
- workers => 2
+ worker_class => wsgi
}},
%% React frontend (served by Flask)
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index 394ef73..a78c125 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -50,9 +50,10 @@ Each mount is a tuple: `{Prefix, AppSpec, Options}`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `worker_class` | atom | `wsgi` | Protocol: `wsgi` or `asgi` |
-| `workers` | integer | `4` | Number of Python workers for this mount |
| `timeout` | integer | `30000` | Request timeout in ms |
+> **Note:** All mounts share the global `py_context_router` pool. Configure pool size at the application level.
+
### Multi-App Example
```erlang
@@ -60,12 +61,10 @@ hornbeam:start(#{
mounts => [
{"/api/v2", "api_v2:app", #{
worker_class => asgi,
- workers => 8,
timeout => 60000
}},
{"/api/v1", "api_v1:app", #{
- worker_class => wsgi,
- workers => 4
+ worker_class => wsgi
}},
{"/", "frontend:app", #{worker_class => wsgi}}
],
diff --git a/priv/hornbeam_asgi_runner.py b/priv/hornbeam_asgi_runner.py
index 2a54f35..3b795ca 100644
--- a/priv/hornbeam_asgi_runner.py
+++ b/priv/hornbeam_asgi_runner.py
@@ -337,6 +337,12 @@ def reload_app(module_name: str, callable_name: str):
return app
+# Response object pool for reuse
+_RESPONSE_POOL = []
+_RESPONSE_POOL_SIZE = 100
+_RESPONSE_POOL_LOCK = threading.Lock()
+
+
class ASGIResponse:
"""Collects ASGI response messages.
@@ -358,6 +364,16 @@ def __init__(self):
self.trailers = []
self.early_hints = []
+ def reset(self):
+ """Reset response for reuse from pool."""
+ self.status = None
+ self.headers = []
+ self.body_parts = []
+ self.more_body = False
+ self.informational = []
+ self.trailers = []
+ self.early_hints = []
+
async def send(self, message: dict) -> None:
"""ASGI send callable."""
msg_type = message['type'] if 'type' in message else ''
@@ -410,6 +426,23 @@ def to_dict(self) -> dict:
return result
+def _get_response() -> ASGIResponse:
+ """Get an ASGIResponse from pool or create new."""
+ with _RESPONSE_POOL_LOCK:
+ if _RESPONSE_POOL:
+ resp = _RESPONSE_POOL.pop()
+ resp.reset()
+ return resp
+ return ASGIResponse()
+
+
+def _return_response(resp: ASGIResponse) -> None:
+ """Return an ASGIResponse to the pool."""
+ with _RESPONSE_POOL_LOCK:
+ if len(_RESPONSE_POOL) < _RESPONSE_POOL_SIZE:
+ _RESPONSE_POOL.append(resp)
+
+
async def _run_asgi_async(module_name: str, callable_name: str,
scope: dict, body: bytes) -> dict:
"""Internal async runner for ASGI apps."""
@@ -561,220 +594,3 @@ def _run_asgi_sync(module_name: str, callable_name: str,
result.get('headers', []),
result.get('body', b'')
)
-
-
-# Streaming support for real-time responses
-
-# Thread-safe streaming session storage
-_streaming_sessions: Dict[str, 'StreamingASGIRunner'] = {}
-_streaming_sessions_lock = threading.Lock()
-
-
-class StreamingASGIRunner:
- """Runner for streaming ASGI responses.
-
- This class supports:
- - Server-Sent Events (SSE)
- - Chunked transfer encoding
- - Real-time response streaming
- """
-
- def __init__(self, module_name: str, callable_name: str, scope: dict):
- self.module_name = module_name
- self.callable_name = callable_name
- self.scope = scope
- self.app = None
- self.status = None
- self.headers = []
- self.body_queue: asyncio.Queue = None
- self.finished = False
- self.loop = None
- self._response_started_event: asyncio.Event = None
- self._error: Optional[Exception] = None
-
- def start(self, body: bytes, timeout_ms: int = 5000) -> dict:
- """Start the streaming response.
-
- Args:
- body: Request body bytes
- timeout_ms: Max time to wait for response headers (default 5s)
-
- Returns the initial response headers.
- """
- self.app = load_app(self.module_name, self.callable_name)
-
- # Inject lifespan state if available
- if _get_lifespan_state is not None:
- self.scope['state'] = _get_lifespan_state()
-
- self.loop = asyncio.new_event_loop()
- asyncio.set_event_loop(self.loop)
- self.body_queue = asyncio.Queue()
- self._response_started_event = asyncio.Event()
-
- # Create receive/send callables
- body_sent = False
-
- async def receive():
- nonlocal body_sent
- if not body_sent:
- body_sent = True
- return {
- 'type': 'http.request',
- 'body': body,
- 'more_body': False
- }
- return _DISCONNECT_MSG
-
- async def send(message):
- msg_type = message.get('type', '')
- if msg_type == 'http.response.start':
- self.status = message.get('status', 200)
- self.headers = message.get('headers', [])
- self._response_started_event.set()
- elif msg_type == 'http.response.body':
- body_part = message.get('body', b'')
- more_body = message.get('more_body', False)
- await self.body_queue.put((body_part, more_body))
- if not more_body:
- self.finished = True
-
- # Start app in background
- async def run_app():
- try:
- await self.app(self.scope, receive, send)
- except Exception as e:
- self._error = e
- self._response_started_event.set() # Unblock waiter on error
- await self.body_queue.put((b'', False))
- self.finished = True
-
- self.loop.create_task(run_app())
-
- # Wait for response to start using event (not polling)
- async def wait_for_start():
- await asyncio.wait_for(
- self._response_started_event.wait(),
- timeout=timeout_ms / 1000.0
- )
-
- try:
- self.loop.run_until_complete(wait_for_start())
- except asyncio.TimeoutError:
- # Timeout waiting for response headers
- self.finished = True
- return {'status': 504, 'headers': [], 'error': 'timeout'}
-
- if self._error is not None:
- return {
- 'status': 500,
- 'headers': [],
- 'error': str(self._error)
- }
-
- return {
- 'status': self.status or 500,
- 'headers': self.headers
- }
-
- def next_chunk(self, timeout_ms: int = 30000) -> tuple:
- """Get the next body chunk.
-
- Returns (chunk_bytes, more_body_bool)
- """
- if self.finished or self.loop is None:
- return (b'', False)
-
- try:
- timeout_sec = timeout_ms / 1000.0
- future = asyncio.wait_for(
- self.body_queue.get(),
- timeout=timeout_sec
- )
- chunk, more_body = self.loop.run_until_complete(future)
- return (chunk, more_body)
- except asyncio.TimeoutError:
- return (b'', True) # Timeout, but may have more
- except Exception:
- return (b'', False)
-
- def close(self):
- """Clean up resources."""
- if self.loop:
- try:
- self.loop.close()
- except Exception:
- pass
- self.loop = None
-
-
-def start_streaming(session_id: str, module_name: str, callable_name: str,
- scope: dict, body: bytes, timeout_ms: int = 5000) -> dict:
- """Start a streaming ASGI response session.
-
- Args:
- session_id: Unique identifier for this streaming session
- module_name: Python module containing the ASGI app
- callable_name: Name of the ASGI callable
- scope: ASGI scope dict
- body: Request body bytes
- timeout_ms: Max time to wait for response headers
-
- Returns initial response headers.
- """
- runner = StreamingASGIRunner(module_name, callable_name, scope)
-
- with _streaming_sessions_lock:
- _streaming_sessions[session_id] = runner
-
- if body.__class__ is str:
- body = body.encode('utf-8')
- elif body.__class__ is not bytes:
- body = b''
-
- return runner.start(body, timeout_ms)
-
-
-def get_streaming_chunk(session_id: str, timeout_ms: int = 30000) -> dict:
- """Get the next chunk from a streaming session.
-
- Returns {'chunk': bytes, 'more_body': bool}
- """
- with _streaming_sessions_lock:
- runner = _streaming_sessions.get(session_id)
-
- if runner is None:
- return {'chunk': b'', 'more_body': False, 'error': 'session_not_found'}
-
- chunk, more_body = runner.next_chunk(timeout_ms)
- return {'chunk': chunk, 'more_body': more_body}
-
-
-def end_streaming(session_id: str) -> None:
- """End a streaming session and clean up resources."""
- with _streaming_sessions_lock:
- runner = _streaming_sessions.pop(session_id, None)
-
- if runner:
- runner.close()
-
-
-def cleanup_streaming_sessions() -> int:
- """Clean up finished streaming sessions.
-
- Call this periodically to prevent memory leaks from abandoned sessions.
-
- Returns the number of sessions cleaned up.
- """
- cleaned = 0
- with _streaming_sessions_lock:
- finished_ids = [
- sid for sid, runner in _streaming_sessions.items()
- if runner.finished or runner.loop is None
- ]
- for sid in finished_ids:
- runner = _streaming_sessions.pop(sid, None)
- if runner:
- runner.close()
- cleaned += 1
- return cleaned
diff --git a/priv/hornbeam_asgi_worker.py b/priv/hornbeam_asgi_worker.py
new file mode 100644
index 0000000..b02904a
--- /dev/null
+++ b/priv/hornbeam_asgi_worker.py
@@ -0,0 +1,450 @@
+# Copyright 2026 Benoit Chesneau
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""ASGI handler using Cowboy loop handler with direct message passing.
+
+Design inspired by gunicorn's ASGI worker:
+- BodyReceiver: Handles request body with Future-based waiting
+- ASGIProtocol: Manages request lifecycle and response batching
+- Response sent via erlang.send() directly to Cowboy handler
+"""
+
+import asyncio
+from typing import Callable, Optional
+
+try:
+ import erlang
+ from erlang import ByteChannel, ByteChannelClosed
+ HAS_ERLANG = True
+ _erlang_send = erlang.send
+except ImportError:
+ HAS_ERLANG = False
+ erlang = None
+ _erlang_send = None
+ ByteChannel = None
+ ByteChannelClosed = Exception
+
+# Pre-allocated message constants (avoid dict allocation per request)
+_EMPTY_BODY_MSG = {'type': 'http.request', 'body': b'', 'more_body': False}
+_DISCONNECT_MSG = {'type': 'http.disconnect'}
+
+
+class BodyReceiver:
+ """Body receiver with Future-based waiting (gunicorn pattern).
+
+ Handles three body modes:
+ - empty: No body expected
+ - inline: Small body passed directly from Erlang
+ - channel: Large/streaming body via ByteChannel
+
+ Uses asyncio.Future for efficient async waiting without polling.
+ """
+
+ __slots__ = ('_mode', '_data', '_channel', '_complete', '_disconnected',
+ '_waiter', '_chunks')
+
+ def __init__(self, body_ref):
+ self._complete = False
+ self._disconnected = False
+ self._waiter = None
+ self._chunks = []
+
+ # Detect body mode from ref
+ if body_ref == b'empty' or body_ref == 'empty':
+ self._mode = 'empty'
+ self._data = None
+ self._channel = None
+ self._complete = True
+ elif isinstance(body_ref, tuple):
+ tag = body_ref[0]
+ if tag == b'body' or tag == 'body':
+ self._mode = 'inline'
+ data = body_ref[1]
+ # Ensure bytes (Erlang binary may decode as str)
+ if isinstance(data, str):
+ data = data.encode('latin-1')
+ self._data = data
+ self._channel = None
+ elif tag == b'channel' or tag == 'channel':
+ self._mode = 'channel'
+ self._data = None
+ self._channel = ByteChannel(body_ref[1])
+ else:
+ # Unknown tuple, treat as channel ref
+ self._mode = 'channel'
+ self._data = None
+ self._channel = ByteChannel(body_ref)
+ else:
+ # Legacy: raw channel ref
+ self._mode = 'channel'
+ self._data = None
+ self._channel = ByteChannel(body_ref)
+
+ def signal_disconnect(self):
+ """Signal client disconnection."""
+ self._disconnected = True
+ self._wake_waiter()
+
+ def _wake_waiter(self):
+ """Wake pending receive() call."""
+ if self._waiter is not None and not self._waiter.done():
+ self._waiter.set_result(None)
+
+ async def receive(self) -> dict:
+ """ASGI receive callable with fast paths."""
+ # Already disconnected
+ if self._disconnected:
+ return _DISCONNECT_MSG
+
+ # Fast path: empty body
+ if self._mode == 'empty':
+ return _EMPTY_BODY_MSG
+
+ # Fast path: inline body (complete body passed directly)
+ if self._mode == 'inline':
+ self._mode = 'done'
+ return {'type': 'http.request', 'body': self._data, 'more_body': False}
+
+ # Body already consumed
+ if self._mode == 'done' or self._complete:
+ return _EMPTY_BODY_MSG
+
+ # Fast path: chunks already buffered
+ if self._chunks:
+ return self._pop_chunk()
+
+ # Channel mode: read from ByteChannel
+ return await self._receive_from_channel()
+
+ def _pop_chunk(self) -> dict:
+ """Pop buffered chunk and return message."""
+ chunk = self._chunks.pop(0)
+ more = bool(self._chunks) or not self._complete
+ if not more:
+ self._mode = 'done'
+ return {'type': 'http.request', 'body': chunk, 'more_body': more}
+
+ async def _receive_from_channel(self) -> dict:
+ """Read body chunk from ByteChannel."""
+ try:
+ chunk = await self._channel.async_receive_bytes()
+ if chunk:
+ return {'type': 'http.request', 'body': chunk, 'more_body': True}
+ # Empty chunk but channel still open - wait for more
+ return {'type': 'http.request', 'body': b'', 'more_body': True}
+ except ByteChannelClosed:
+ self._complete = True
+ self._mode = 'done'
+ return _EMPTY_BODY_MSG
+
+
+class ASGIProtocol:
+ """ASGI protocol handler with response batching (gunicorn-inspired).
+
+ Optimizations:
+ - Uses __slots__ to reduce memory and attribute access overhead
+ - Delegates body handling to BodyReceiver class
+ - Batches headers + body for simple responses (single message)
+ - Structured response state tracking
+ """
+
+ __slots__ = ('_caller_pid', '_app', '_scope', '_body_receiver', '_send_fn',
+ '_status', '_headers', '_response_started', '_response_finished')
+
+ def __init__(self, caller_pid, app: Callable, scope: dict, body_receiver: BodyReceiver):
+ self._caller_pid = caller_pid
+ self._app = app
+ self._scope = scope
+ self._body_receiver = body_receiver
+ self._send_fn = _erlang_send
+
+ # Response state (buffer headers for batching)
+ self._status = None
+ self._headers = None
+ self._response_started = False
+ self._response_finished = False
+
+ async def receive(self) -> dict:
+ """ASGI receive - delegates to BodyReceiver."""
+ return await self._body_receiver.receive()
+
+ async def send(self, message: dict) -> None:
+ """ASGI send callable with simplified protocol.
+
+ Uses 3 message types:
+ - start_response: headers + first chunk
+ - chunk: subsequent body chunks
+ - fin: end of response
+ """
+ if self._response_finished:
+ raise RuntimeError("Response already completed")
+
+ msg_type = message.get('type', '')
+
+ if msg_type == 'http.response.start':
+ if self._response_started:
+ raise RuntimeError("http.response.start already sent")
+
+ # Buffer headers for batching with first body
+ self._status = message.get('status', 200)
+ self._headers = message.get('headers', [])
+ self._response_started = True
+
+ elif msg_type == 'http.response.body':
+ if not self._response_started:
+ raise RuntimeError("http.response.start must be sent first")
+
+ body = message.get('body', b'')
+ if isinstance(body, str):
+ body = body.encode('utf-8')
+
+ more_body = message.get('more_body', False)
+
+ if self._headers is not None:
+ # First body - send start_response with headers + first chunk
+ self._send_fn(self._caller_pid,
+ (b'start_response', self._status, self._headers, body))
+ self._headers = None
+ else:
+ # Subsequent chunk
+ if body:
+ self._send_fn(self._caller_pid, (b'chunk', body))
+
+ if not more_body:
+ # Send fin to terminate
+ self._send_fn(self._caller_pid, b'fin')
+ self._response_finished = True
+
+ elif msg_type == 'http.response.informational':
+ status = message.get('status', 100)
+ headers = message.get('headers', [])
+ if status == 103:
+ self._send_fn(self._caller_pid, (b'early_hints', headers))
+
+ elif msg_type == 'http.disconnect':
+ self._response_finished = True
+
+ async def run(self):
+ """Run the ASGI application."""
+ try:
+ await self._app(self._scope, self.receive, self.send)
+
+ # Ensure response is completed
+ if not self._response_finished:
+ if not self._response_started:
+ # No response started - send error response
+ self._send_fn(self._caller_pid,
+ (b'start_response', 500, [], b''))
+ self._send_fn(self._caller_pid, b'fin')
+ elif self._headers is not None:
+ # Headers buffered but no body sent - send empty response
+ self._send_fn(self._caller_pid,
+ (b'start_response', self._status, self._headers, b''))
+ self._send_fn(self._caller_pid, b'fin')
+ else:
+ # Streaming was started but not finished - send fin
+ self._send_fn(self._caller_pid, b'fin')
+
+ except asyncio.CancelledError:
+ # Client disconnected - signal to body receiver
+ self._body_receiver.signal_disconnect()
+ raise
+ except Exception as e:
+ self._send_fn(self._caller_pid, (b'error', str(e).encode('utf-8')))
+
+
+# =============================================================================
+# Entry point
+# =============================================================================
+
+async def handle_asgi(caller_pid, app_module: str, app_callable: str,
+ scope: dict, req_body_ref):
+ """Handle ASGI request.
+
+ Entry point called from hornbeam_asgi.erl.
+ Response is sent directly via erlang.send().
+
+ req_body_ref can be:
+ - 'empty' or b'empty': no request body
+ - (b'body', data): small body passed inline (< 64KB)
+ - (b'channel', channel_ref): large/streaming body via channel
+ """
+ if not HAS_ERLANG:
+ return
+
+ # Get app (cached)
+ app = _get_app(app_module, app_callable)
+
+ # Use cached state proxy (shared per mount_id)
+ mount_id = scope.get('mount_id')
+ scope['state'] = _get_state_proxy(mount_id)
+
+ # Create body receiver (handles body mode detection)
+ body_receiver = BodyReceiver(req_body_ref)
+
+ # Create and run protocol
+ protocol = ASGIProtocol(caller_pid, app, scope, body_receiver)
+ await protocol.run()
+
+
+# =============================================================================
+# Helpers
+# =============================================================================
+
+import sys
+
+# Cache apps by (module, callable) key - modules already imported by Erlang
+_app_cache: dict = {}
+
+# Cache state proxies by mount_id - shared across requests for same mount
+_state_cache: dict = {}
+
+
+def _get_app(module_name: str, callable_name: str) -> Callable:
+ """Get ASGI application from cache or sys.modules.
+
+ Module is already imported by Erlang via ensure_all_imported,
+ so we just look it up in sys.modules - no importlib needed.
+ """
+ key = (module_name, callable_name)
+ app = _app_cache.get(key)
+ if app is not None:
+ return app
+
+ # Module already imported by Erlang - just get from sys.modules
+ module = sys.modules.get(module_name)
+ if module is None:
+ # Fallback: import if somehow not in sys.modules
+ import importlib
+ module = importlib.import_module(module_name)
+
+ app = getattr(module, callable_name)
+ _app_cache[key] = app
+ return app
+
+
+def preload_app(app_module: str, app_callable: str) -> bytes:
+ """Preload ASGI application at startup."""
+ _get_app(app_module, app_callable)
+ return b'ok'
+
+
+def _get_state_proxy(mount_id):
+ """Get cached state proxy for mount_id, creating if needed."""
+ if mount_id is None:
+ return _LazyStateProxy(None)
+ proxy = _state_cache.get(mount_id)
+ if proxy is None:
+ proxy = _LazyStateProxy(mount_id)
+ _state_cache[mount_id] = proxy
+ return proxy
+
+
+class _LazyStateProxy(dict):
+ """Dict that lazily fetches from ETS and syncs mutations via callback.
+
+ Optimizations:
+ - No erlang.whereis() on construction (callbacks are pre-registered)
+ - Lazy loading: state only fetched when accessed
+ - Direct ETS access via callbacks (no message passing for reads)
+ """
+
+ __slots__ = ('_mount_id', '_loaded')
+
+ def __init__(self, mount_id=None):
+ super().__init__()
+ self._mount_id = mount_id
+ self._loaded = False
+
+ def _ensure_loaded(self):
+ """Load full state on first access if needed."""
+ if self._loaded:
+ return
+ if not HAS_ERLANG:
+ self._loaded = True
+ return
+ try:
+ if self._mount_id is not None:
+ state = erlang.call('lifespan_state_get', self._mount_id, None)
+ else:
+ state = erlang.call('lifespan_state_get')
+ if state and isinstance(state, dict):
+ super().update(state)
+ except Exception:
+ pass
+ self._loaded = True
+
+ def __getitem__(self, key):
+ # Fast path: check local cache first
+ try:
+ return super().__getitem__(key)
+ except KeyError:
+ pass
+ # Fetch specific key from ETS
+ if HAS_ERLANG:
+ try:
+ if self._mount_id is not None:
+ value = erlang.call('lifespan_state_get', self._mount_id, key)
+ else:
+ value = erlang.call('lifespan_state_get', key)
+ if value is not None:
+ super().__setitem__(key, value)
+ return value
+ except Exception:
+ pass
+ raise KeyError(key)
+
+ def __setitem__(self, key, value):
+ super().__setitem__(key, value)
+ # Sync to ETS via callback (no whereis needed)
+ if HAS_ERLANG:
+ try:
+ if self._mount_id is not None:
+ erlang.call('lifespan_state_set', self._mount_id, key, value)
+ else:
+ erlang.call('lifespan_state_set', key, value)
+ except Exception:
+ pass
+
+ def __contains__(self, key):
+ if super().__contains__(key):
+ return True
+ self._ensure_loaded()
+ return super().__contains__(key)
+
+ def keys(self):
+ self._ensure_loaded()
+ return super().keys()
+
+ def values(self):
+ self._ensure_loaded()
+ return super().values()
+
+ def items(self):
+ self._ensure_loaded()
+ return super().items()
+
+ def get(self, key, default=None):
+ try:
+ return self[key]
+ except KeyError:
+ return default
+
+ def __iter__(self):
+ self._ensure_loaded()
+ return super().__iter__()
+
+ def __len__(self):
+ self._ensure_loaded()
+ return super().__len__()
diff --git a/priv/hornbeam_lifespan_runner.py b/priv/hornbeam_lifespan_runner.py
index 8e29fb4..7761dc0 100644
--- a/priv/hornbeam_lifespan_runner.py
+++ b/priv/hornbeam_lifespan_runner.py
@@ -48,7 +48,13 @@ def _install_erlang_loop() -> bool:
_install_erlang_loop()
-# Global lifespan state shared across requests
+# Per-mount lifespan state for multi-app mode
+_lifespan_states: Dict[str, Dict[str, Any]] = {}
+
+# Per-mount lifespan tracking for multi-app mode
+_mount_lifespans: Dict[str, dict] = {}
+
+# Global lifespan state for single-app mode (backward compat)
_lifespan_state: Dict[str, Any] = {}
_lifespan_app = None
_lifespan_task = None
@@ -239,6 +245,214 @@ def shutdown(app_module: str, app_callable: str) -> dict:
_cleanup()
+def startup_mount(mount_id: str, app_module: str, app_callable: str,
+ timeout_ms: int = 30000) -> dict:
+ """Run lifespan startup protocol for a specific mount.
+
+ This is used in multi-app mode to run lifespan per mounted app.
+ Each mount gets its own isolated state dict.
+
+ Args:
+ mount_id: Unique identifier for this mount
+ app_module: Python module containing the ASGI app
+ app_callable: Name of the ASGI callable
+ timeout_ms: Timeout for startup in milliseconds (default: 30000)
+
+ Returns:
+ Response dict with type and optional state
+ """
+ global _lifespan_states, _mount_lifespans
+
+ # erlang_python converts binaries to str in C - no decode needed
+
+ # Load the app
+ try:
+ app = load_app(app_module, app_callable)
+ except Exception as e:
+ return {'type': 'lifespan.startup.failed', 'message': str(e)}
+
+ # Create per-mount event loop and queues
+ loop = asyncio.new_event_loop()
+ receive_queue = asyncio.Queue()
+ send_queue = asyncio.Queue()
+
+ # Initialize state dict for this mount
+ mount_state: Dict[str, Any] = {}
+ _lifespan_states[mount_id] = mount_state
+
+ # Build lifespan scope with per-mount state
+ scope = {
+ 'type': 'lifespan',
+ 'asgi': {
+ 'version': '3.0',
+ 'spec_version': '2.4'
+ },
+ 'state': mount_state
+ }
+
+ # Create lifespan runner for this mount
+ async def run_mount_lifespan():
+ async def receive():
+ return await receive_queue.get()
+ async def send(message):
+ await send_queue.put(message)
+ try:
+ await app(scope, receive, send)
+ except Exception as e:
+ await send_queue.put({
+ 'type': 'lifespan.startup.failed',
+ 'message': str(e)
+ })
+
+ # Start the lifespan task
+ task = loop.create_task(run_mount_lifespan())
+
+ # Store mount lifespan tracking
+ _mount_lifespans[mount_id] = {
+ 'app': app,
+ 'task': task,
+ 'loop': loop,
+ 'receive_queue': receive_queue,
+ 'send_queue': send_queue
+ }
+
+ # Send startup event
+ loop.run_until_complete(receive_queue.put({'type': 'lifespan.startup'}))
+
+ # Wait for response
+ async def wait_for_response():
+ await asyncio.sleep(0.01)
+ if task.done():
+ return None
+ total_timeout = timeout_ms / 1000.0
+ check_interval = 0.5
+ elapsed = 0.0
+ while elapsed < total_timeout:
+ try:
+ response = await asyncio.wait_for(
+ send_queue.get(),
+ timeout=check_interval
+ )
+ return response
+ except asyncio.TimeoutError:
+ if task.done():
+ return None
+ elapsed += check_interval
+ raise asyncio.TimeoutError()
+
+ try:
+ response = loop.run_until_complete(wait_for_response())
+ if response is None:
+ _cleanup_mount(mount_id)
+ return {'type': 'lifespan.not_supported'}
+ except asyncio.TimeoutError:
+ return {'type': 'lifespan.startup.failed',
+ 'message': 'Startup timeout'}
+ except Exception as e:
+ return {'type': 'lifespan.startup.failed', 'message': str(e)}
+
+ msg_type = response.get('type', '')
+
+ if msg_type == 'lifespan.startup.complete':
+ # Store state for access by requests
+ _lifespan_states[mount_id] = scope.get('state', {})
+ return {
+ 'type': 'lifespan.startup.complete',
+ 'state': _lifespan_states[mount_id]
+ }
+ elif msg_type == 'lifespan.startup.failed':
+ _cleanup_mount(mount_id)
+ return response
+ else:
+ _cleanup_mount(mount_id)
+ return {'type': 'lifespan.not_supported'}
+
+
+def shutdown_mount(mount_id: str, app_module: str, app_callable: str) -> dict:
+ """Run lifespan shutdown protocol for a specific mount.
+
+ Args:
+ mount_id: Unique identifier for this mount
+ app_module: Python module (for reference)
+ app_callable: ASGI callable name (for reference)
+
+ Returns:
+ Response dict with shutdown status
+ """
+ global _mount_lifespans, _lifespan_states
+
+ # erlang_python converts binaries to str in C - no decode needed
+
+ if mount_id not in _mount_lifespans:
+ return {'type': 'lifespan.shutdown.complete'}
+
+ mount = _mount_lifespans[mount_id]
+ task = mount['task']
+ loop = mount['loop']
+ receive_queue = mount['receive_queue']
+ send_queue = mount['send_queue']
+
+ try:
+ # Send shutdown event
+ loop.run_until_complete(
+ receive_queue.put({'type': 'lifespan.shutdown'})
+ )
+
+ # Wait for response
+ try:
+ response = loop.run_until_complete(
+ asyncio.wait_for(send_queue.get(), timeout=10.0)
+ )
+ except asyncio.TimeoutError:
+ response = {'type': 'lifespan.shutdown.complete'}
+
+ # Wait for task to finish
+ try:
+ loop.run_until_complete(
+ asyncio.wait_for(task, timeout=5.0)
+ )
+ except asyncio.TimeoutError:
+ task.cancel()
+ try:
+ loop.run_until_complete(task)
+ except asyncio.CancelledError:
+ pass
+
+ return response
+
+ except Exception as e:
+ return {'type': 'lifespan.shutdown.complete',
+ 'error': str(e)}
+ finally:
+ _cleanup_mount(mount_id)
+
+
+def _cleanup_mount(mount_id: str):
+ """Clean up lifespan state for a specific mount."""
+ global _mount_lifespans, _lifespan_states
+
+ if mount_id in _mount_lifespans:
+ mount = _mount_lifespans[mount_id]
+ task = mount.get('task')
+ loop = mount.get('loop')
+
+ if task and not task.done():
+ task.cancel()
+ if loop:
+ try:
+ loop.run_until_complete(task)
+ except asyncio.CancelledError:
+ pass
+
+ if loop:
+ loop.close()
+
+ del _mount_lifespans[mount_id]
+
+ if mount_id in _lifespan_states:
+ del _lifespan_states[mount_id]
+
+
def _cleanup():
"""Clean up lifespan state."""
global _lifespan_app, _lifespan_task, _receive_queue, _send_queue, _loop
@@ -261,13 +475,24 @@ def _cleanup():
_loop = None
-def get_state() -> dict:
+def get_state(mount_id: Optional[str] = None) -> dict:
"""Get the lifespan state dict.
This returns the actual dict (not a copy) so that modifications
by request handlers persist across requests - as per ASGI spec.
+
+ Args:
+ mount_id: Optional mount identifier for multi-app mode.
+ If None, returns single-app mode state.
+
+ Returns:
+ The lifespan state dict for the specified mount (or global state).
"""
- return _lifespan_state
+ if mount_id is None:
+ return _lifespan_state
+
+ # erlang_python converts binaries to str in C - no decode needed
+ return _lifespan_states.get(mount_id, {})
def set_state(key: str, value: Any) -> None:
diff --git a/priv/hornbeam_wsgi_runner.py b/priv/hornbeam_wsgi_runner.py
index 957f367..c8b8389 100644
--- a/priv/hornbeam_wsgi_runner.py
+++ b/priv/hornbeam_wsgi_runner.py
@@ -254,12 +254,50 @@ def reload_app(module_name, callable_name):
# Cached WSGI version tuple
_WSGI_VERSION = (1, 0)
+# BytesIO pool for wsgi.input - reduces allocation overhead
+_BYTESIO_POOL = []
+_BYTESIO_POOL_SIZE = 100
+_BYTESIO_POOL_LOCK = threading.Lock()
+
+
+def _get_bytesio(data: bytes) -> io.BytesIO:
+ """Get a BytesIO from pool or create new one."""
+ bio = None
+ with _BYTESIO_POOL_LOCK:
+ if _BYTESIO_POOL:
+ bio = _BYTESIO_POOL.pop()
+ if bio is not None:
+ bio.seek(0)
+ bio.truncate()
+ bio.write(data)
+ bio.seek(0)
+ return bio
+ return io.BytesIO(data)
+
+
+def _return_bytesio(bio: io.BytesIO) -> None:
+ """Return a BytesIO to the pool for reuse."""
+ with _BYTESIO_POOL_LOCK:
+ if len(_BYTESIO_POOL) < _BYTESIO_POOL_SIZE:
+ _BYTESIO_POOL.append(bio)
+
+
+# Pre-computed environ template - shared base for all requests
+_ENVIRON_TEMPLATE = {
+ 'wsgi.version': _WSGI_VERSION,
+ 'wsgi.multithread': True,
+ 'wsgi.multiprocess': True,
+ 'wsgi.run_once': False,
+ 'wsgi.file_wrapper': FileWrapper,
+ 'wsgi.input_terminated': True,
+}
+
def create_environ(raw_environ):
"""Create a complete WSGI environ dict from raw environ.
Ensures all required WSGI variables are present and properly typed.
- Optimized for minimal overhead on hot path.
+ Optimized for minimal overhead on hot path using template and BytesIO pool.
Args:
raw_environ: Raw environ dict from Erlang
@@ -273,47 +311,39 @@ def create_environ(raw_environ):
def early_hints_callback(headers):
early_hints_list.append(headers)
- # Handle wsgi.input - most common case is bytes
+ # Handle wsgi.input - use pooled BytesIO for common bytes case
wsgi_input = raw_environ.get('wsgi.input', b'')
wsgi_input_type = type(wsgi_input)
if wsgi_input_type is bytes:
- wsgi_input_stream = io.BytesIO(wsgi_input)
+ wsgi_input_stream = _get_bytesio(wsgi_input)
elif wsgi_input_type is str:
- wsgi_input_stream = io.BytesIO(wsgi_input.encode('utf-8'))
+ wsgi_input_stream = _get_bytesio(wsgi_input.encode('utf-8'))
elif hasattr(wsgi_input, 'read'):
wsgi_input_stream = wsgi_input
else:
- wsgi_input_stream = io.BytesIO(b'')
-
- # Build environ with proper types
- # Use direct access for speed on known keys
- environ = {
- # Required CGI variables with defaults
- 'REQUEST_METHOD': raw_environ.get('REQUEST_METHOD', 'GET'),
- 'SCRIPT_NAME': raw_environ.get('SCRIPT_NAME', ''),
- 'PATH_INFO': raw_environ.get('PATH_INFO', '/'),
- 'QUERY_STRING': raw_environ.get('QUERY_STRING', ''),
- 'SERVER_NAME': raw_environ.get('SERVER_NAME', 'localhost'),
- 'SERVER_PORT': str(raw_environ.get('SERVER_PORT', '80')),
- 'SERVER_PROTOCOL': raw_environ.get('SERVER_PROTOCOL', 'HTTP/1.1'),
-
- # Required WSGI variables (use cached/shared where possible)
- 'wsgi.version': _WSGI_VERSION,
- 'wsgi.url_scheme': raw_environ.get('wsgi.url_scheme', 'http'),
- 'wsgi.input': wsgi_input_stream,
- 'wsgi.errors': _SHARED_ERRORS,
- 'wsgi.multithread': True,
- 'wsgi.multiprocess': True,
- 'wsgi.run_once': False,
-
- # Recommended extensions
- 'wsgi.file_wrapper': FileWrapper,
- 'wsgi.input_terminated': True,
- 'wsgi.early_hints': early_hints_callback,
-
- # Store early hints list reference
- '_hornbeam.early_hints': early_hints_list,
- }
+ wsgi_input_stream = _get_bytesio(b'')
+
+ # Start from template copy (faster than inline dict creation)
+ environ = _ENVIRON_TEMPLATE.copy()
+
+ # Update with request-specific required CGI variables
+ environ['REQUEST_METHOD'] = raw_environ.get('REQUEST_METHOD', 'GET')
+ environ['SCRIPT_NAME'] = raw_environ.get('SCRIPT_NAME', '')
+ environ['PATH_INFO'] = raw_environ.get('PATH_INFO', '/')
+ environ['QUERY_STRING'] = raw_environ.get('QUERY_STRING', '')
+ environ['SERVER_NAME'] = raw_environ.get('SERVER_NAME', 'localhost')
+ environ['SERVER_PORT'] = str(raw_environ.get('SERVER_PORT', '80'))
+ environ['SERVER_PROTOCOL'] = raw_environ.get('SERVER_PROTOCOL', 'HTTP/1.1')
+
+ # Request-specific WSGI variables
+ environ['wsgi.url_scheme'] = raw_environ.get('wsgi.url_scheme', 'http')
+ environ['wsgi.input'] = wsgi_input_stream
+ environ['wsgi.errors'] = _SHARED_ERRORS
+ environ['wsgi.early_hints'] = early_hints_callback
+
+ # Store references for cleanup and early hints
+ environ['_hornbeam.early_hints'] = early_hints_list
+ environ['_hornbeam.wsgi_input'] = wsgi_input_stream
# Copy remaining keys (HTTP_*, CONTENT_TYPE, CONTENT_LENGTH, etc.)
# Use pre-computed set for O(1) membership test
@@ -383,6 +413,10 @@ def run_wsgi(module_name, callable_name, raw_environ):
finally:
if hasattr(result, 'close'):
result.close()
+ # Return BytesIO to pool
+ wsgi_input = environ.get('_hornbeam.wsgi_input')
+ if wsgi_input is not None:
+ _return_bytesio(wsgi_input)
body = b''.join(body_parts)
@@ -427,3 +461,134 @@ def _run_wsgi_sync(module_name: str, callable_name: str,
result.get('headers', []),
result.get('body', b'')
)
+
+
+def create_environ_from_tuple(req_tuple):
+ """Create WSGI environ from pre-parsed Erlang tuple - O(1) operations only.
+
+ This is the fast path for WSGI requests. Erlang pre-parses all headers
+ into WSGI format so Python only does dict updates (no loops).
+
+ Args:
+ req_tuple: Pre-parsed request tuple from hornbeam_request:build_wsgi_tuple/2
+ (method, script_name, path_info, query_string, wsgi_headers,
+ content_type, content_length, body, server, client, scheme,
+ protocol, lifespan_state)
+
+ Returns:
+ Complete WSGI environ dict
+ """
+ (method, script_name, path_info, query_string, wsgi_headers,
+ content_type, content_length, body, server, client, scheme,
+ protocol, lifespan_state) = req_tuple
+
+ # Create early hints callback (must be per-request)
+ early_hints_list = []
+
+ def early_hints_callback(headers):
+ early_hints_list.append(headers)
+
+ # Get BytesIO from pool
+ wsgi_input = _get_bytesio(body if body.__class__ is bytes else b'')
+
+ # Start with template copy (O(1) - shallow copy of small dict)
+ environ = _ENVIRON_TEMPLATE.copy()
+
+ # Update with request-specific values (no loops!)
+ environ['REQUEST_METHOD'] = method
+ environ['SCRIPT_NAME'] = script_name if script_name else ''
+ environ['PATH_INFO'] = path_info
+ environ['QUERY_STRING'] = query_string
+ environ['SERVER_NAME'] = server[0]
+ environ['SERVER_PORT'] = str(server[1])
+ environ['SERVER_PROTOCOL'] = protocol
+ environ['wsgi.url_scheme'] = scheme
+ environ['wsgi.input'] = wsgi_input
+ environ['wsgi.errors'] = _SHARED_ERRORS
+ environ['wsgi.early_hints'] = early_hints_callback
+ environ['REMOTE_ADDR'] = client[0]
+ environ['REMOTE_PORT'] = str(client[1])
+ environ['_hornbeam.early_hints'] = early_hints_list
+ environ['_hornbeam.wsgi_input'] = wsgi_input # For pool return
+ environ['_hornbeam.lifespan_state'] = lifespan_state
+
+ # Add pre-converted HTTP_* headers (already in correct format from Erlang)
+ environ.update(wsgi_headers)
+
+ # Add content-type/length if present
+ if content_type is not None:
+ environ['CONTENT_TYPE'] = content_type
+ if content_length is not None:
+ environ['CONTENT_LENGTH'] = content_length
+
+ return environ
+
+
+def run_wsgi_fast(module_name, callable_name, req_tuple):
+ """Run WSGI app with pre-parsed request tuple - fastest path.
+
+ Args:
+ module_name: Python module containing the WSGI app
+ callable_name: Name of the WSGI callable
+ req_tuple: Pre-parsed request tuple from Erlang
+
+ Returns:
+ Dict with status, headers, body, and optional early_hints
+ """
+ # Load the application
+ app = load_app(module_name, callable_name)
+
+ # Create environ from pre-parsed tuple
+ environ = create_environ_from_tuple(req_tuple)
+
+ # Create response handler
+ response = Response(environ)
+
+ # Call the WSGI app
+ result = app(environ, response.start_response)
+
+ # Collect body
+ body_parts = []
+
+ # Add any write() buffer content first
+ body_parts.extend(response._write_buffer)
+
+ # Check if result is a FileWrapper (for optimized file serving)
+ is_file_wrapper = isinstance(result, FileWrapper)
+
+ try:
+ if isinstance(result, (bytes, bytearray)):
+ body_parts.append(bytes(result))
+ else:
+ for chunk in result:
+ if isinstance(chunk, (bytes, bytearray)):
+ body_parts.append(bytes(chunk))
+ elif isinstance(chunk, str):
+ body_parts.append(chunk.encode('utf-8'))
+ finally:
+ if hasattr(result, 'close'):
+ result.close()
+ # Return BytesIO to pool
+ wsgi_input = environ.get('_hornbeam.wsgi_input')
+ if wsgi_input is not None:
+ _return_bytesio(wsgi_input)
+
+ body = b''.join(body_parts)
+
+ # Build response dict
+ result_dict = {
+ 'status': response.status or '500 Internal Server Error',
+ 'headers': response.headers,
+ 'body': body,
+ }
+
+ # Include early hints if any were sent
+ early_hints = environ.get('_hornbeam.early_hints', [])
+ if early_hints:
+ result_dict['early_hints'] = early_hints
+
+ # Include file wrapper info for potential sendfile optimization
+ if is_file_wrapper:
+ result_dict['file_wrapper'] = True
+
+ return result_dict
diff --git a/priv/hornbeam_wsgi_worker.py b/priv/hornbeam_wsgi_worker.py
new file mode 100644
index 0000000..92496c1
--- /dev/null
+++ b/priv/hornbeam_wsgi_worker.py
@@ -0,0 +1,439 @@
+# Copyright 2026 Benoit Chesneau
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""WSGI worker using py_buffer for zero-copy request body streaming.
+
+This module provides a WSGI worker with:
+- Single entry point for all requests
+- py_buffer as wsgi.input (zero-copy shared memory)
+- erlang.send for responses
+
+Architecture:
+1. Erlang creates py_buffer and writes body data
+2. Python uses buffer directly as wsgi.input (file-like interface)
+3. Python sends responses via erlang.send()
+"""
+
+import io
+from typing import Callable, Tuple
+
+try:
+ import erlang
+ HAS_ERLANG = True
+except ImportError:
+ HAS_ERLANG = False
+ erlang = None
+
+
+# ============================================================================
+# Constants and shared instances
+# ============================================================================
+
+_WSGI_VERSION = (1, 0)
+CHUNKS_PER_BATCH = 10
+
+
+class _WSGIErrorsWrapper:
+ """Minimal wsgi.errors wrapper that routes to logging."""
+
+ def write(self, msg):
+ if msg and msg.strip():
+ try:
+ import logging
+ logging.getLogger('hornbeam.wsgi').error(msg.rstrip())
+ except Exception:
+ pass
+
+ def writelines(self, lines):
+ for line in lines:
+ self.write(line)
+
+ def flush(self):
+ pass
+
+
+class FileWrapper:
+ """Efficient file serving wrapper for WSGI."""
+
+ def __init__(self, filelike, blksize=8192):
+ self.filelike = filelike
+ self.blksize = blksize
+ if hasattr(filelike, 'close'):
+ self.close = filelike.close
+
+ def __iter__(self):
+ return self
+
+ def __next__(self):
+ data = self.filelike.read(self.blksize)
+ if data:
+ return data
+ raise StopIteration
+
+
+_SHARED_ERRORS = _WSGIErrorsWrapper()
+
+_ENVIRON_TEMPLATE = {
+ 'wsgi.version': _WSGI_VERSION,
+ 'wsgi.multithread': True,
+ 'wsgi.multiprocess': True,
+ 'wsgi.run_once': False,
+ 'wsgi.file_wrapper': FileWrapper,
+ 'wsgi.input_terminated': True,
+}
+
+
+# ============================================================================
+# App loading and preloading
+# ============================================================================
+
+# Cached app reference - set by preload_app() for fast access
+_preloaded_app: Callable = None
+_preloaded_key: Tuple[str, str] = None
+
+
+def preload_app(app_module: bytes, app_callable: bytes) -> bytes:
+ """Preload WSGI application at startup for zero-overhead access.
+
+ Called from Erlang during context initialization.
+ """
+ global _preloaded_app, _preloaded_key
+
+ # erlang_python converts binaries to str in C
+ import importlib
+ module = importlib.import_module(app_module)
+ app = getattr(module, app_callable)
+
+ _preloaded_app = app
+ _preloaded_key = (app_module, app_callable)
+
+ return b'ok'
+
+
+def _get_app(module_name: str, callable_name: str) -> Callable:
+ """Get WSGI application - uses preloaded app if available."""
+ # Fast path: use preloaded app if it matches
+ if _preloaded_key == (module_name, callable_name):
+ return _preloaded_app
+
+ # Fallback: import on demand
+ import importlib
+ module = importlib.import_module(module_name)
+ return getattr(module, callable_name)
+
+
+# ============================================================================
+# Helpers
+# ============================================================================
+
+def _to_bytes(val) -> bytes:
+ """Convert value to bytes."""
+ if isinstance(val, bytes):
+ return val
+ if isinstance(val, bytearray):
+ return bytes(val)
+ if isinstance(val, str):
+ return val.encode('utf-8')
+ return b''
+
+
+def _parse_status(status_str) -> int:
+ """Parse WSGI status string to integer."""
+ try:
+ if isinstance(status_str, bytes):
+ status_str = status_str.decode('utf-8')
+ parts = status_str.split(' ', 1)
+ return int(parts[0])
+ except (ValueError, IndexError, AttributeError):
+ return 500
+
+
+# ============================================================================
+# Response class
+# ============================================================================
+
+class _Response:
+ """WSGI response handler."""
+ __slots__ = ('status', 'status_code', 'headers', '_write_buffer')
+
+ def __init__(self):
+ self.status = None
+ self.status_code = 500
+ self.headers = []
+ self._write_buffer = []
+
+ def start_response(self, status, response_headers, exc_info=None):
+ if exc_info:
+ try:
+ if self.status is not None:
+ raise exc_info[1].with_traceback(exc_info[2])
+ finally:
+ exc_info = None
+ elif self.status is not None:
+ raise RuntimeError("start_response already called")
+
+ self.status = status
+ self.status_code = _parse_status(status)
+ self.headers = list(response_headers)
+ return self._write
+
+ def _write(self, data):
+ if not self.status:
+ raise RuntimeError("write() called before start_response()")
+ self._write_buffer.append(data)
+
+
+def _call_app(caller_pid, module_name: str, callable_name: str, environ: dict):
+ """Call WSGI app and iterate response.
+
+ Args:
+ caller_pid: Erlang PID to send response to
+ module_name: Python module name
+ callable_name: WSGI callable name
+ environ: Prepared environ dict with wsgi.input
+
+ Returns:
+ 'done' on success, or schedule_inline marker for continuation
+ """
+ try:
+ # Get app (preloaded or import on demand)
+ app = _get_app(module_name, callable_name)
+ response = _Response()
+ result = app(environ, response.start_response)
+
+ # Build state for response iteration
+ state = {
+ 'caller': caller_pid,
+ 'status': response.status_code,
+ 'headers': response.headers,
+ 'result': result,
+ 'result_iter': iter(result) if hasattr(result, '__iter__') else None,
+ 'write_buffer': list(response._write_buffer),
+ 'headers_sent': False,
+ }
+
+ # Call iterate_response directly
+ return _iterate_response(state)
+
+ except Exception as e:
+ try:
+ erlang.send(caller_pid, (b'error', str(e).encode('utf-8')))
+ except Exception:
+ pass
+ return b'error'
+
+
+# ============================================================================
+# Iterate response
+# ============================================================================
+
+def _iterate_response(state: dict):
+ """Send response chunks, yielding every CHUNKS_PER_BATCH.
+
+ Args:
+ state: Dict containing caller, status, headers, result iterator, etc.
+
+ Returns:
+ 'done' or schedule_inline marker for continuation
+ """
+ caller = state['caller']
+ result = state['result']
+ result_iter = state['result_iter']
+ write_buffer = state['write_buffer']
+ headers_sent = state['headers_sent']
+
+ try:
+ # Handle single bytes response
+ if result_iter is None or isinstance(result, (bytes, bytearray)):
+ body = _to_bytes(result) if isinstance(result, (bytes, bytearray)) else b''
+ if write_buffer:
+ body = b''.join(_to_bytes(p) for p in write_buffer) + body
+ erlang.send(caller, (b'response', state['status'], state['headers'], body))
+ _cleanup_result(result)
+ return b'done'
+
+ # Fast path: list with small number of items - collect and send as single response
+ if isinstance(result, list) and len(result) <= 2 and not write_buffer:
+ body = b''.join(_to_bytes(chunk) for chunk in result if chunk)
+ erlang.send(caller, (b'response', state['status'], state['headers'], body))
+ _cleanup_result(result)
+ return b'done'
+
+ # Send any buffered write() data first
+ if write_buffer and not headers_sent:
+ erlang.send(caller, (b'start_response', state['status'], state['headers']))
+ state['headers_sent'] = True
+ for part in write_buffer:
+ erlang.send(caller, (b'chunk', _to_bytes(part)))
+ state['write_buffer'] = []
+
+ # Process chunks from iterator
+ chunks_processed = 0
+
+ while True:
+ try:
+ chunk = next(result_iter)
+ except StopIteration:
+ break
+
+ if chunk:
+ chunk = _to_bytes(chunk)
+
+ # Send headers on first chunk
+ if not state['headers_sent']:
+ erlang.send(caller, (b'start_response', state['status'], state['headers']))
+ state['headers_sent'] = True
+
+ erlang.send(caller, (b'chunk', chunk))
+ chunks_processed += 1
+
+ # Yield after batch to release scheduler
+ if chunks_processed >= CHUNKS_PER_BATCH:
+ return erlang.schedule_inline(
+ 'hornbeam_wsgi_worker', '_iterate_response',
+ args=[state]
+ )
+
+ # Done iterating
+ if state['headers_sent']:
+ erlang.send(caller, b'done')
+ else:
+ # Empty response (no chunks produced)
+ erlang.send(caller, (b'response', state['status'], state['headers'], b''))
+
+ _cleanup_result(result)
+ return b'done'
+
+ except Exception as e:
+ _cleanup_result(state.get('result'))
+ try:
+ erlang.send(caller, (b'error', str(e).encode('utf-8')))
+ except Exception:
+ pass
+ return b'error'
+
+
+def _cleanup_result(result):
+ """Close result iterator if it has a close method."""
+ if result and hasattr(result, 'close'):
+ try:
+ result.close()
+ except Exception:
+ pass
+
+
+# ============================================================================
+# Tuple fast path - O(1) environ creation
+# ============================================================================
+
+def handle_request_tuple(caller_pid, buffer, app_module: bytes, app_callable: bytes, req_tuple):
+ """Fast path entry point using pre-parsed tuple from Erlang.
+
+ This avoids per-key iteration in Python by having Erlang pre-convert
+ all headers to WSGI format (HTTP_*).
+
+ Args:
+ caller_pid: Erlang PID to send response to
+ buffer: py_buffer for request body, or 'empty' atom for bodyless requests
+ app_module: Python module containing WSGI app (bytes)
+ app_callable: Name of WSGI callable in module (bytes)
+ req_tuple: Pre-parsed request tuple from hornbeam_request:build_wsgi_tuple/2
+ (method, script_name, path_info, query_string, wsgi_headers,
+ content_type, content_length, body, server, client, scheme,
+ protocol, lifespan_state)
+
+ Returns:
+ 'done' on success, or schedule_inline marker for continuation
+ """
+ if not HAS_ERLANG:
+ return b'error'
+
+ try:
+ # Convert bytes to strings
+ # erlang_python converts binaries to str in C
+ module_name = app_module
+ callable_name = app_callable
+
+ # Create environ from pre-parsed tuple (O(1) operations only)
+ environ = _create_environ_from_tuple(req_tuple, buffer)
+
+ # Call app directly
+ return _call_app(caller_pid, module_name, callable_name, environ)
+
+ except Exception as e:
+ try:
+ erlang.send(caller_pid, (b'error', str(e).encode('utf-8')))
+ except Exception:
+ pass
+ return b'error'
+
+
+def _create_environ_from_tuple(req_tuple, buffer):
+ """Create WSGI environ from pre-parsed Erlang tuple - O(1) operations only.
+
+ Erlang pre-parses all headers into WSGI format so Python only does
+ dict updates (no loops over headers).
+
+ Note: erlang_python converts Erlang binaries to Python str in C,
+ so no decode() calls are needed here.
+
+ Args:
+ req_tuple: Pre-parsed request tuple from hornbeam_request:build_wsgi_tuple/2
+ buffer: py_buffer for request body, or 'empty' atom for bodyless requests
+
+ Returns:
+ Complete WSGI environ dict
+ """
+ (method, script_name, path_info, query_string, wsgi_headers,
+ content_type, content_length, _body, server, client, scheme,
+ protocol, lifespan_state) = req_tuple
+
+ # Start with template copy (O(1) - shallow copy of small dict)
+ environ = _ENVIRON_TEMPLATE.copy()
+
+ # Update with request-specific values
+ # All values are already str (erlang_python converts binaries to str in C)
+ environ['REQUEST_METHOD'] = method
+ environ['SCRIPT_NAME'] = script_name if script_name else ''
+ environ['PATH_INFO'] = path_info
+ environ['QUERY_STRING'] = query_string
+ environ['SERVER_NAME'] = server[0]
+ environ['SERVER_PORT'] = str(server[1])
+ environ['SERVER_PROTOCOL'] = protocol
+ environ['wsgi.url_scheme'] = scheme
+ environ['REMOTE_ADDR'] = client[0]
+ environ['wsgi.errors'] = _SHARED_ERRORS
+
+ # Use buffer as wsgi.input, or empty BytesIO for bodyless requests
+ if buffer == b'empty' or (hasattr(buffer, '__class__') and buffer.__class__.__name__ == 'Atom'):
+ environ['wsgi.input'] = io.BytesIO()
+ else:
+ environ['wsgi.input'] = buffer
+
+ # Add pre-converted HTTP_* headers (already str from erlang_python)
+ # Direct dict.update() - O(n) but no per-item function calls
+ if wsgi_headers:
+ environ.update(wsgi_headers)
+
+ # Add content-type/length if present
+ if content_type is not None:
+ environ['CONTENT_TYPE'] = content_type
+ if content_length is not None:
+ environ['CONTENT_LENGTH'] = content_length
+
+ # Store lifespan state
+ if lifespan_state:
+ environ['_hornbeam.lifespan_state'] = lifespan_state
+
+ return environ
diff --git a/rebar.config b/rebar.config
index 0043d41..c7fa496 100644
--- a/rebar.config
+++ b/rebar.config
@@ -20,7 +20,7 @@
{deps, [
{cowboy, "2.12.0"},
- {erlang_python, "1.8.1"}
+ {erlang_python, "2.2.0"}
]}.
{shell, [
@@ -39,7 +39,7 @@
{profiles, [
{test, [
{deps, [
- {hackney, "3.0.2"},
+ {hackney, "3.2.1"},
{jsx, "3.1.0"}
]}
]}
diff --git a/rebar.lock b/rebar.lock
deleted file mode 100644
index ecd87bb..0000000
--- a/rebar.lock
+++ /dev/null
@@ -1,17 +0,0 @@
-{"1.2.0",
-[{<<"cowboy">>,{pkg,<<"cowboy">>,<<"2.12.0">>},0},
- {<<"cowlib">>,{pkg,<<"cowlib">>,<<"2.13.0">>},1},
- {<<"erlang_python">>,{pkg,<<"erlang_python">>,<<"1.8.1">>},0},
- {<<"ranch">>,{pkg,<<"ranch">>,<<"1.8.0">>},1}]}.
-[
-{pkg_hash,[
- {<<"cowboy">>, <<"F276D521A1FF88B2B9B4C54D0E753DA6C66DD7BE6C9FCA3D9418B561828A3731">>},
- {<<"cowlib">>, <<"DB8F7505D8332D98EF50A3EF34B34C1AFDDEC7506E4EE4DD4A3A266285D282CA">>},
- {<<"erlang_python">>, <<"4DAFC7AFD315F0D5D45792F722364D8721CED438E396B7D14F19518DC78D198B">>},
- {<<"ranch">>, <<"8C7A100A139FD57F17327B6413E4167AC559FBC04CA7448E9BE9057311597A1D">>}]},
-{pkg_hash_ext,[
- {<<"cowboy">>, <<"8A7ABE6D183372CEB21CAA2709BEC928AB2B72E18A3911AA1771639BEF82651E">>},
- {<<"cowlib">>, <<"E1E1284DC3FC030A64B1AD0D8382AE7E99DA46C3246B815318A4B848873800A4">>},
- {<<"erlang_python">>, <<"0F5893100A92285096519F8111D3A6D3F5DCD18668545ADF9F4144899D5997C2">>},
- {<<"ranch">>, <<"49FBCFD3682FAB1F5D109351B61257676DA1A2FDBE295904176D5E521A2DDFE5">>}]}
-].
diff --git a/src/hornbeam.erl b/src/hornbeam.erl
index f13bd75..3611b98 100644
--- a/src/hornbeam.erl
+++ b/src/hornbeam.erl
@@ -26,7 +26,7 @@
%%% %% Start with options
%%% hornbeam:start("myapp:application", #{
%%% bind => "0.0.0.0:8000",
-%%% workers => 4
+%%% num_contexts => 4
%%% }).
%%%
%%% %% Start ASGI app with lifespan
@@ -38,7 +38,7 @@
%%% %% Multi-app mode - mount different apps at different prefixes
%%% hornbeam:start(#{
%%% mounts => [
-%%% {"/api", "api:app", #{worker_class => asgi, workers => 4}},
+%%% {"/api", "api:app", #{worker_class => asgi, num_contexts => 4}},
%%% {"/admin", "admin:app", #{worker_class => wsgi}},
%%% {"/", "frontend:app", #{worker_class => wsgi}}
%%% ],
@@ -66,9 +66,10 @@
-type mount_spec() :: {Prefix :: string() | binary(), AppSpec :: app_spec(), Opts :: map()}.
-type options() :: #{
bind => string() | binary(),
- workers => pos_integer(),
+ num_contexts => pos_integer(),
num_acceptors => pos_integer(),
worker_class => wsgi | asgi,
+ context_mode => worker | owngil,
timeout => pos_integer(),
keepalive => pos_integer(),
max_requests => pos_integer(),
@@ -133,14 +134,14 @@ start(AppSpec) when is_list(AppSpec); is_binary(AppSpec) ->
%% Options:
%%
%% - `bind' - Address to bind to (default: "127.0.0.1:8000")
-%% - `workers' - Number of Python workers (default: 4)
+%% - `num_contexts' - Number of Python contexts (default: schedulers)
%% - `num_acceptors' - Number of Cowboy acceptor processes (default: 100)
%% - `worker_class' - wsgi or asgi (default: wsgi)
%% - `timeout' - Request timeout in ms (default: 30000)
%% - `keepalive' - Keep-alive timeout in seconds (default: 2)
%% - `max_requests' - Max requests per worker before restart (default: 1000)
%% - `max_concurrent' - Max concurrent requests queued (default: 10000)
-%% - `preload_app' - Preload app before forking workers (default: false)
+%% - `preload_app' - Preload app in all contexts at startup (default: true)
%% - `pythonpath' - Additional Python paths (default: ["."])
%% - `venv' - Virtual environment path (default: undefined)
%% - `lifespan' - Lifespan protocol: auto, on, off (default: auto)
@@ -167,7 +168,7 @@ start(AppSpec, Options) ->
hornbeam_http_hooks:set_hooks(Hooks),
%% Ensure Python runtime matches requested worker count.
- %% This may restart erlang_python when workers changed.
+ %% This may restart erlang_python when num_contexts changed.
case ensure_python_runtime(Config1) of
ok ->
%% Register hornbeam functions for Python callbacks
@@ -180,8 +181,13 @@ start(AppSpec, Options) ->
%% Setup Python paths
setup_python_paths(Config1),
- %% Run lifespan startup for ASGI apps
+ %% Preload app in all contexts for fast access
WorkerClass = maps:get(worker_class, Config1),
+ AppModule = maps:get(app_module, Config1),
+ AppCallable = maps:get(app_callable, Config1),
+ hornbeam_context_pool:preload_app(WorkerClass, AppModule, AppCallable),
+
+ %% Run lifespan startup for ASGI apps
case maybe_run_lifespan_startup(WorkerClass, Config1) of
ok ->
%% Start the HTTP listener
@@ -255,13 +261,13 @@ start_multi(Config) ->
Hooks = maps:get(hooks, GlobalConfig, #{}),
hornbeam_http_hooks:set_hooks(Hooks),
- %% Calculate max workers needed (max across all mounts)
- MaxWorkers = lists:foldl(fun(Mount, Max) ->
- max(Max, maps:get(workers, Mount, 4))
+ %% Calculate max contexts needed (max across all mounts)
+ MaxContexts = lists:foldl(fun(Mount, Max) ->
+ max(Max, maps:get(num_contexts, Mount, 4))
end, 4, NormalizedMounts),
- %% Ensure Python runtime with max workers
- case ensure_python_runtime(GlobalConfig#{workers => MaxWorkers}) of
+ %% Ensure Python runtime with max contexts
+ case ensure_python_runtime(GlobalConfig#{num_contexts => MaxContexts}) of
ok ->
%% Register hornbeam functions for Python callbacks
register_python_callbacks(),
@@ -360,7 +366,7 @@ validate_mount({Prefix, AppSpec, Opts}, GlobalConfig) ->
%% Merge global defaults with mount-specific opts
DefaultOpts = #{
worker_class => wsgi,
- workers => maps:get(workers, GlobalConfig, 4),
+ num_contexts => maps:get(num_contexts, GlobalConfig, 4),
timeout => maps:get(timeout, GlobalConfig, 30000)
},
MountOpts = maps:merge(DefaultOpts, Opts),
@@ -372,7 +378,7 @@ validate_mount({Prefix, AppSpec, Opts}, GlobalConfig) ->
app_module => Module,
app_callable => Callable,
worker_class => maps:get(worker_class, MountOpts),
- workers => maps:get(workers, MountOpts),
+ num_contexts => maps:get(num_contexts, MountOpts),
timeout => maps:get(timeout, MountOpts),
pythonpath => MountPythonpath
};
@@ -448,12 +454,14 @@ maybe_run_multi_lifespan_startup(Mounts, Config) ->
run_lifespan_for_mounts([], _Opts) ->
ok;
run_lifespan_for_mounts([Mount | Rest], Opts) ->
- %% Store mount info in config temporarily for lifespan
- hornbeam_config:set_config(#{
+ %% Get mount_id for per-mount state isolation
+ MountId = maps:get(mount_id, Mount),
+ %% Build mount-specific options for lifespan startup
+ MountOpts = Opts#{
app_module => maps:get(app_module, Mount),
app_callable => maps:get(app_callable, Mount)
- }),
- case hornbeam_lifespan:startup(Opts) of
+ },
+ case hornbeam_lifespan:startup(MountId, MountOpts) of
ok -> run_lifespan_for_mounts(Rest, Opts);
{error, _} = Error -> Error
end.
@@ -468,8 +476,10 @@ start_listener_multi(Config) ->
CustomRoutes = maps:get(routes, Config, []),
%% Multi-app handler state - lookups mount per request
+ %% Lifespan state cached at startup (shared across mounts)
HandlerState = #{
- multi_app => true
+ multi_app => true,
+ lifespan_state => hornbeam_lifespan:get_state()
},
%% Default catchall route for Python apps (routes to mounts)
@@ -509,14 +519,14 @@ start_listener_multi(Config) ->
default_config() ->
#{
bind => <<"127.0.0.1:8000">>,
- workers => 4,
+ %% num_contexts defaults to erlang:system_info(schedulers) in ensure_python_runtime
num_acceptors => 100,
worker_class => wsgi,
timeout => 30000,
keepalive => 2,
max_requests => 1000,
max_concurrent => 10000, % High limit for concurrent requests queued
- preload_app => false,
+ preload_app => true,
pythonpath => [<<".">>, <<"examples">>],
venv => undefined,
lifespan => auto,
@@ -537,69 +547,41 @@ parse_app_spec(AppSpec) when is_binary(AppSpec) ->
end.
ensure_python_runtime(Config) ->
- Workers = maps:get(workers, Config, 4),
- ok = application:set_env(erlang_python, num_workers, Workers),
- case current_python_workers() of
- {ok, Workers} ->
+ NumContexts = maps:get(num_contexts, Config, erlang:system_info(schedulers)),
+ ContextMode = maps:get(context_mode, Config, worker),
+ ok = application:set_env(hornbeam, context_pool_size, NumContexts),
+ ok = application:set_env(hornbeam, context_mode, ContextMode),
+ case current_context_count() of
+ {ok, NumContexts} ->
ok;
_ ->
- restart_python_runtime()
- end.
-
-current_python_workers() ->
- try py_pool:get_stats() of
- #{num_workers := NumWorkers} when is_integer(NumWorkers), NumWorkers > 0 ->
- {ok, NumWorkers};
- _ ->
- {error, unknown}
- catch
- _:_ ->
- {error, unavailable}
- end.
-
-restart_python_runtime() ->
- case application:stop(erlang_python) of
- ok ->
- start_python_runtime();
- {error, {not_started, erlang_python}} ->
- start_python_runtime();
- {error, Reason} ->
- {error, {python_stop_failed, Reason}}
+ restart_context_pool()
end.
-start_python_runtime() ->
- case application:start(erlang_python) of
+restart_context_pool() ->
+ %% Restart context pool with new size
+ case supervisor:terminate_child(hornbeam_sup, hornbeam_context_pool) of
ok ->
- refresh_lifespan_manager();
- {error, {already_started, erlang_python}} ->
- refresh_lifespan_manager();
+ case supervisor:restart_child(hornbeam_sup, hornbeam_context_pool) of
+ {ok, _} -> ok;
+ {ok, _, _} -> ok;
+ {error, Reason} -> {error, {context_pool_restart_failed, Reason}}
+ end;
+ {error, not_found} ->
+ ok;
{error, Reason} ->
- {error, {python_start_failed, Reason}}
+ {error, {context_pool_terminate_failed, Reason}}
end.
-refresh_lifespan_manager() ->
- case whereis(hornbeam_lifespan) of
- undefined ->
- ok;
+current_context_count() ->
+ try hornbeam_context_pool:pool_size() of
+ N when is_integer(N), N > 0 ->
+ {ok, N};
_ ->
- case supervisor:terminate_child(hornbeam_sup, hornbeam_lifespan) of
- ok ->
- restart_lifespan_manager();
- {error, not_found} ->
- ok;
- {error, Reason} ->
- {error, {lifespan_terminate_failed, Reason}}
- end
- end.
-
-restart_lifespan_manager() ->
- case supervisor:restart_child(hornbeam_sup, hornbeam_lifespan) of
- {ok, _Pid} ->
- ok;
- {ok, _Pid, _Info} ->
- ok;
- {error, Reason} ->
- {error, {lifespan_restart_failed, Reason}}
+ {error, unknown}
+ catch
+ _:_ ->
+ {error, unavailable}
end.
setup_python_paths(Config) ->
@@ -647,7 +629,11 @@ setup_python_paths(Config) ->
"import sys; sys.path.insert(0, '~s') if '~s' not in sys.path else None",
[AbsPath, AbsPath]),
py:exec(Code)
- end, AbsPaths).
+ end, AbsPaths),
+
+ %% Also add paths to all contexts in the pool
+ %% This is needed because context_call uses separate Python contexts
+ hornbeam_context_pool:add_paths(AbsPaths).
maybe_run_lifespan_startup(asgi, Config) ->
LifespanMode = maps:get(lifespan, Config, auto),
@@ -669,11 +655,13 @@ start_listener(Config) ->
%% Cache frequently accessed config values in handler state to avoid
%% repeated ETS lookups per request
+ %% Lifespan state is fetched once here since it doesn't change after startup
HandlerState = #{
worker_class => WorkerClass,
app_module => maps:get(app_module, Config),
app_callable => maps:get(app_callable, Config),
- timeout => maps:get(timeout, Config, 30000)
+ timeout => maps:get(timeout, Config, 30000),
+ lifespan_state => hornbeam_lifespan:get_state()
},
%% Default catchall route for Python app
diff --git a/src/hornbeam_asgi.erl b/src/hornbeam_asgi.erl
index 003c853..6ba63a0 100644
--- a/src/hornbeam_asgi.erl
+++ b/src/hornbeam_asgi.erl
@@ -12,142 +12,297 @@
%% See the License for the specific language governing permissions and
%% limitations under the License.
-%%% @doc ASGI scope builder and protocol handling.
+%%% @doc ASGI handler with fast synchronous path.
%%%
-%%% Builds ASGI 3.0 compliant scope dictionaries from Cowboy requests.
-%%% Supports HTTP/1.1, HTTP/2, and WebSocket scopes with proper extensions.
+%%% This module implements ASGI request handling with two paths:
+%%% 1. Fast sync path: For simple requests (no body or small body), uses
+%%% py_nif:context_call() with hornbeam_asgi_runner for WSGI-like performance
+%%% 2. Async path: For streaming/large bodies, uses cowboy_loop handler
+%%% with py_event_loop_pool for full async support
%%%
-%%% == HTTP Scope ==
-%%% Contains: type, asgi, http_version, method, scheme, path, query_string,
-%%% headers, server, client, root_path, state, extensions
-%%%
-%%% == Extensions ==
-%%% - http.response.trailers: Trailer support for HTTP/2
-%%% - http.response.push: Server push for HTTP/2
+%%% @end
-module(hornbeam_asgi).
--export([
- build_scope/1,
- build_scope/2
-]).
+-behaviour(cowboy_loop).
--type scope_opts() :: #{
- root_path => binary(),
- state => map(),
- extensions => map()
-}.
+-export([init/2, info/3, terminate/3]).
-%% @doc Build an ASGI scope dictionary from a Cowboy request.
--spec build_scope(cowboy_req:req()) -> map().
-build_scope(Req) ->
- build_scope(Req, #{}).
+%% Threshold for fast synchronous path (64KB)
+%% Requests with bodies smaller than this use the fast sync path
+-define(ASGI_BODY_BUFFER_THRESHOLD, 65536).
-%% @doc Build an ASGI scope dictionary with options.
-%%
-%% Options:
-%% - root_path: ASGI root_path (default: empty binary)
-%% - state: Shared state dict from lifespan (default: empty map)
-%% - extensions: Additional extensions to include
--spec build_scope(cowboy_req:req(), scope_opts()) -> map().
-build_scope(Req, Opts) ->
- %% Get basic request info
+%% Internal state
+-record(state, {
+ req_info,
+ app_module,
+ app_callable,
+ timeout_ms,
+ scope,
+ req_body_ch,
+ body_ref, %% Reference for async body reading
+ has_body,
+ %% Cached for direct send (avoid map lookups per body chunk)
+ cowboy_pid,
+ cowboy_streamid,
+ handler_state %% Original handler state from init
+}).
+
+%%% ============================================================================
+%%% Cowboy Loop Handler Callbacks
+%%% ============================================================================
+
+init(Req, HandlerState) ->
+ ReqInfo = build_request_info(Req),
+ ReqInfo1 = hornbeam_http_hooks:run_on_request(ReqInfo),
+
+ AppModule = maps:get(app_module, HandlerState),
+ AppCallable = maps:get(app_callable, HandlerState),
+ TimeoutMs = maps:get(timeout, HandlerState, 30000),
+
+ %% Cache pid/streamid for direct send (avoid map lookups per body chunk)
+ Pid = maps:get(pid, Req),
+ StreamID = maps:get(streamid, Req),
+
+ %% Build ASGI scope
+ Scope = hornbeam_request:build_asgi_scope(Req, HandlerState),
+
+ %% Check if request has a body
+ %% Body exists if: Content-Length > 0, or Transfer-Encoding is present
Method = cowboy_req:method(Req),
- Path = cowboy_req:path(Req),
- RawPath = cowboy_req:path(Req),
- Qs = cowboy_req:qs(Req),
- Headers = cowboy_req:headers(Req),
- Host = cowboy_req:host(Req),
- Port = cowboy_req:port(Req),
- Scheme = cowboy_req:scheme(Req),
- Version = cowboy_req:version(Req),
-
- %% Get client info
- {ClientIp, ClientPort} = cowboy_req:peer(Req),
-
- %% Convert headers to list of [name, value] pairs
- HeaderList = maps:fold(fun(Name, Value, Acc) ->
- [[Name, Value] | Acc]
- end, [], Headers),
-
- %% Get root_path and state from options or lifespan
- RootPath = maps:get(root_path, Opts, get_root_path()),
- State = maps:get(state, Opts, get_lifespan_state()),
-
- %% Build extensions based on HTTP version
- Extensions = build_extensions(Version, Opts),
+ ContentLength = get_content_length(Req),
+ TransferEncoding = cowboy_req:header(<<"transfer-encoding">>, Req),
+ HasBody = has_request_body(Method, ContentLength, TransferEncoding),
- #{
- <<"type">> => <<"http">>,
- <<"asgi">> => #{
- <<"version">> => <<"3.0">>,
- <<"spec_version">> => <<"2.4">>
- },
- <<"http_version">> => format_http_version(Version),
- <<"method">> => Method,
- <<"scheme">> => Scheme,
- <<"path">> => Path,
- <<"raw_path">> => RawPath,
- <<"query_string">> => Qs,
- <<"root_path">> => RootPath,
- <<"headers">> => HeaderList,
- <<"server">> => [Host, Port],
- <<"client">> => [format_ip(ClientIp), ClientPort],
- <<"state">> => State,
- <<"extensions">> => Extensions
- }.
+ %% Create request body channel only if body exists (skip for GET/no-body)
+ %% For small bodies with known Content-Length, read synchronously and pass directly
+ {ReqBodyRef, BodyRef} = case HasBody of
+ true when is_integer(ContentLength), ContentLength =< ?ASGI_BODY_BUFFER_THRESHOLD ->
+ %% Small body with known size - read synchronously, pass binary directly
+ {ok, Body, _Req2} = cowboy_req:read_body(Req),
+ {{body, Body}, undefined};
+ true ->
+ %% Large/streaming body - use channel + async reading
+ {ok, Ch} = py_byte_channel:new(),
+ Ref = make_ref(),
+ %% Direct send instead of cowboy_req:cast
+ Pid ! {{Pid, StreamID}, {read_body, self(), Ref, auto, infinity}},
+ {{channel, Ch}, Ref};
+ false ->
+ %% No body - pass empty marker, skip channel
+ {empty, undefined}
+ end,
-%% @private
-get_root_path() ->
- case hornbeam_config:get_config(root_path) of
- undefined -> <<>>;
- Path -> ensure_binary(Path)
+ %% Submit task to event loop pool for parallel distribution
+ {ok, LoopRef} = py_event_loop_pool:get_loop(),
+ TaskRef = make_ref(),
+ ok = py_nif:submit_task(LoopRef, self(), TaskRef,
+ <<"hornbeam_asgi_worker">>, <<"handle_asgi">>,
+ [self(), AppModule, AppCallable, Scope, ReqBodyRef], #{}),
+
+ %% Extract channel ref for state (if using channel mode)
+ ReqBodyCh = case ReqBodyRef of
+ {channel, ChannelRef} -> ChannelRef;
+ _ -> ReqBodyRef
+ end,
+
+ State = #state{
+ req_info = ReqInfo1,
+ app_module = AppModule,
+ app_callable = AppCallable,
+ timeout_ms = TimeoutMs,
+ scope = Scope,
+ req_body_ch = ReqBodyCh,
+ body_ref = BodyRef,
+ has_body = HasBody,
+ cowboy_pid = Pid,
+ cowboy_streamid = StreamID,
+ handler_state = HandlerState
+ },
+
+ %% Return cowboy_loop to enable loop handler
+ {cowboy_loop, Req, State, TimeoutMs}.
+
+%% Handle async body chunks from Cowboy - more data coming
+info({request_body, Ref, nofin, Data}, Req,
+ #state{body_ref = Ref, req_body_ch = Ch, cowboy_pid = Pid,
+ cowboy_streamid = StreamID} = State) ->
+ ok = push_to_channel(Ch, Data),
+ %% Direct send instead of cowboy_req:cast
+ Pid ! {{Pid, StreamID}, {read_body, self(), Ref, auto, infinity}},
+ {ok, Req, State};
+
+%% Handle async body chunks from Cowboy - final chunk
+info({request_body, Ref, fin, _BodyLen, Data}, Req,
+ #state{body_ref = Ref, req_body_ch = Ch} = State) ->
+ case Data of
+ <<>> -> ok;
+ _ -> ok = push_to_channel(Ch, Data)
+ end,
+ py_byte_channel:close(Ch),
+ {ok, Req, State#state{body_ref = undefined}};
+
+%% New simplified protocol: start_response (headers + first chunk)
+info({<<"start_response">>, StatusCode, Headers, FirstChunk}, Req, State) ->
+ CowboyHeaders = convert_headers(filter_hop_by_hop(Headers)),
+ Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req),
+ case to_binary(FirstChunk) of
+ <<>> -> ok;
+ Body -> ok = cowboy_req:stream_body(Body, nofin, Req2)
+ end,
+ {ok, Req2, State};
+
+%% New simplified protocol: subsequent chunk
+info({<<"chunk">>, Data}, Req, State) ->
+ ok = cowboy_req:stream_body(to_binary(Data), nofin, Req),
+ {ok, Req, State};
+
+%% New simplified protocol: end of response
+info(<<"fin">>, Req, #state{handler_state = HS} = State) ->
+ ok = cowboy_req:stream_body(<<>>, fin, Req),
+ maybe_close_channel(State#state.req_body_ch),
+ {stop, Req, HS};
+
+%% Handle early hints from Python
+info({<<"early_hints">>, Headers}, Req, State) ->
+ HintHeaders = convert_headers(Headers),
+ Req2 = cowboy_req:inform(103, HintHeaders, Req),
+ {ok, Req2, State};
+
+%% Handle error from Python
+info({<<"error">>, Reason}, Req, #state{req_info = ReqInfo, handler_state = HandlerState} = State) ->
+ maybe_close_channel(State#state.req_body_ch),
+ {StatusCode, Body} = hornbeam_http_hooks:run_on_error(Reason, ReqInfo),
+ Req2 = cowboy_req:reply(StatusCode,
+ #{<<"content-type">> => <<"text/plain">>},
+ Body, Req),
+ {stop, Req2, HandlerState};
+
+%% Handle async task completion
+info({async_result, _Ref, {ok, _}}, Req, State) ->
+ {ok, Req, State};
+
+info({async_result, _Ref, {error, Reason}}, Req, #state{req_info = ReqInfo, handler_state = HandlerState} = State) ->
+ maybe_close_channel(State#state.req_body_ch),
+ {StatusCode, Body} = hornbeam_http_hooks:run_on_error(Reason, ReqInfo),
+ Req2 = cowboy_req:reply(StatusCode,
+ #{<<"content-type">> => <<"text/plain">>},
+ Body, Req),
+ {stop, Req2, HandlerState};
+
+%% Handle timeout
+info(timeout, Req, #state{req_info = ReqInfo, handler_state = HandlerState} = State) ->
+ maybe_close_channel(State#state.req_body_ch),
+ {StatusCode, Body} = hornbeam_http_hooks:run_on_error(timeout, ReqInfo),
+ Req2 = cowboy_req:reply(StatusCode,
+ #{<<"content-type">> => <<"text/plain">>},
+ Body, Req),
+ {stop, Req2, HandlerState};
+
+%% Unknown message
+info(_Msg, Req, State) ->
+ {ok, Req, State}.
+
+terminate(_Reason, _Req, _State) ->
+ ok.
+
+%%% ============================================================================
+%%% Internal Functions
+%%% ============================================================================
+
+push_to_channel(Channel, Data) ->
+ case py_byte_channel:send(Channel, Data) of
+ ok -> ok;
+ busy ->
+ %% Channel full - wait a bit and retry
+ timer:sleep(1),
+ push_to_channel(Channel, Data);
+ {error, closed} ->
+ ok
end.
-%% @private
-get_lifespan_state() ->
- case catch hornbeam_lifespan:get_state() of
- State when is_map(State) -> State;
- _ -> #{}
+maybe_close_channel(undefined) -> ok;
+maybe_close_channel(empty) -> ok;
+maybe_close_channel({body, _}) -> ok; %% Small body passed inline, no channel
+maybe_close_channel(Channel) ->
+ try
+ case py_byte_channel:info(Channel) of
+ #{closed := true} -> ok;
+ _ ->
+ catch py_byte_channel:close(Channel),
+ ok
+ end
+ catch
+ _:_ -> ok
end.
%% @private
-%% Build ASGI extensions based on HTTP version
-build_extensions(Version, Opts) ->
- BaseExtensions = maps:get(extensions, Opts, #{}),
-
- %% Add HTTP/2 specific extensions
- case Version of
- 'HTTP/2' ->
- BaseExtensions#{
- <<"http.response.trailers">> => #{},
- <<"http.response.early_hints">> => #{}
- };
- _ ->
- %% HTTP/1.1 still supports early hints
- BaseExtensions#{
- <<"http.response.early_hints">> => #{}
- }
+get_content_length(Req) ->
+ case cowboy_req:header(<<"content-length">>, Req) of
+ undefined -> undefined;
+ CLBin ->
+ try binary_to_integer(CLBin)
+ catch _:_ -> undefined
+ end
end.
%% @private
-format_http_version('HTTP/1.0') -> <<"1.0">>;
-format_http_version('HTTP/1.1') -> <<"1.1">>;
-format_http_version('HTTP/2') -> <<"2">>.
+%% Check if request has a body based on method, content-length, and transfer-encoding
+%% Per HTTP spec: body exists if Content-Length > 0 OR Transfer-Encoding is present
+has_request_body(_, _, TE) when TE =/= undefined -> true; %% Transfer-Encoding present
+has_request_body(_, 0, _) -> false; %% Content-Length: 0
+has_request_body(_, CL, _) when is_integer(CL), CL > 0 -> true; %% Content-Length > 0
+has_request_body(<<"GET">>, _, _) -> false;
+has_request_body(<<"HEAD">>, _, _) -> false;
+has_request_body(<<"DELETE">>, _, _) -> false;
+has_request_body(<<"OPTIONS">>, _, _) -> false;
+has_request_body(_, undefined, undefined) -> false. %% No CL, no TE = no body
+
+%% @private
+build_request_info(Req) ->
+ #{
+ method => cowboy_req:method(Req),
+ path => cowboy_req:path(Req),
+ query_string => cowboy_req:qs(Req),
+ headers => cowboy_req:headers(Req),
+ host => cowboy_req:host(Req),
+ port => cowboy_req:port(Req),
+ scheme => cowboy_req:scheme(Req),
+ peer => cowboy_req:peer(Req)
+ }.
%% @private
-%% IPv4 - optimized to avoid io_lib:format overhead
-format_ip({A, B, C, D}) ->
- list_to_binary([
- integer_to_list(A), $.,
- integer_to_list(B), $.,
- integer_to_list(C), $.,
- integer_to_list(D)
- ]);
-%% IPv6 - use inet:ntoa which is implemented in C
-format_ip(Addr = {_, _, _, _, _, _, _, _}) ->
- list_to_binary(inet:ntoa(Addr)).
+filter_hop_by_hop(Headers) ->
+ HopByHop = [<<"connection">>, <<"keep-alive">>, <<"proxy-authenticate">>,
+ <<"proxy-authorization">>, <<"te">>, <<"trailers">>,
+ <<"transfer-encoding">>, <<"upgrade">>],
+ lists:filter(fun(Header) ->
+ Name = case Header of
+ [N, _] -> N;
+ {N, _} -> N
+ end,
+ LowerName = string:lowercase(to_binary(Name)),
+ not lists:member(LowerName, HopByHop)
+ end, Headers).
%% @private
-ensure_binary(V) when is_binary(V) -> V;
-ensure_binary(V) when is_list(V) -> list_to_binary(V);
-ensure_binary(V) when is_atom(V) -> atom_to_binary(V, utf8).
+convert_headers(Headers) ->
+ lists:foldl(fun(Header, Acc) ->
+ case Header of
+ [Name, Value] ->
+ Acc#{to_lower_binary(Name) => to_binary(Value)};
+ {Name, Value} ->
+ Acc#{to_lower_binary(Name) => to_binary(Value)};
+ _ ->
+ Acc
+ end
+ end, #{}, Headers).
+
+to_binary(V) when is_binary(V) -> V;
+to_binary(V) when is_list(V) -> list_to_binary(V);
+to_binary(V) when is_atom(V) -> atom_to_binary(V, utf8);
+to_binary(V) -> iolist_to_binary(io_lib:format("~p", [V])).
+
+to_lower_binary(V) when is_binary(V) -> string:lowercase(V);
+to_lower_binary(V) when is_list(V) -> string:lowercase(list_to_binary(V));
+to_lower_binary(V) when is_atom(V) -> string:lowercase(atom_to_binary(V, utf8));
+to_lower_binary(V) -> string:lowercase(to_binary(V)).
diff --git a/src/hornbeam_config.erl b/src/hornbeam_config.erl
index 5ac7214..ba14934 100644
--- a/src/hornbeam_config.erl
+++ b/src/hornbeam_config.erl
@@ -29,12 +29,12 @@
%%% - worker_class: wsgi or asgi (default: wsgi)
%%% - http_version: List of supported HTTP versions (default: ['HTTP/1.1', 'HTTP/2'])
%%%
-%%% === Workers ===
-%%% - workers: Number of Python workers (default: 4)
+%%% === Contexts ===
+%%% - num_contexts: Number of Python contexts (default: schedulers)
%%% - timeout: Request timeout in ms (default: 30000)
%%% - keepalive: Keep-alive timeout in seconds (default: 2)
-%%% - max_requests: Max requests per worker before restart (default: 1000)
-%%% - preload_app: Preload app before forking workers (default: false)
+%%% - max_requests: Max requests per context before restart (default: 1000)
+%%% - preload_app: Preload app in all contexts at startup (default: true)
%%%
%%% === Request Limits ===
%%% - max_request_line_size: Max request line size (default: 4094)
@@ -138,12 +138,12 @@ defaults() ->
%% Protocol
worker_class => wsgi,
- %% Workers
- workers => 4,
+ %% Contexts
+ %% num_contexts defaults to schedulers in hornbeam.erl
timeout => 30000,
keepalive => 2,
max_requests => 1000,
- preload_app => false,
+ preload_app => true,
%% Request limits
max_request_line_size => 4094,
@@ -228,8 +228,8 @@ load_app_env() ->
bind, ssl, certfile, keyfile, cacertfile,
%% Protocol
worker_class,
- %% Workers
- workers, timeout, keepalive, max_requests, preload_app,
+ %% Contexts
+ num_contexts, timeout, keepalive, max_requests, preload_app,
%% Request limits
max_request_line_size, max_header_size, max_headers,
%% ASGI
diff --git a/src/hornbeam_context_pool.erl b/src/hornbeam_context_pool.erl
new file mode 100644
index 0000000..1e39e10
--- /dev/null
+++ b/src/hornbeam_context_pool.erl
@@ -0,0 +1,234 @@
+%% Copyright 2026 Benoit Chesneau
+%%
+%% Licensed under the Apache License, Version 2.0 (the "License");
+%% you may not use this file except in compliance with the License.
+%% You may obtain a copy of the License at
+%%
+%% http://www.apache.org/licenses/LICENSE-2.0
+%%
+%% Unless required by applicable law or agreed to in writing, software
+%% distributed under the License is distributed on an "AS IS" BASIS,
+%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+%% See the License for the specific language governing permissions and
+%% limitations under the License.
+
+%%% @doc Cached NIF refs for the default py_context_router pool.
+%%%
+%%% Caches NIF references from py_context_router in persistent_term
+%%% for O(1) lookup without message passing. Does not create contexts -
+%%% uses the default pool started by erlang_python.
+%%%
+%%% @end
+-module(hornbeam_context_pool).
+
+-behaviour(gen_server).
+
+-export([
+ start_link/0,
+ start_link/1,
+ get_context/0,
+ get_context_ref/0,
+ pool_size/0,
+ add_paths/1,
+ preload_app/3
+]).
+
+%% gen_server callbacks
+-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
+
+-record(state, {
+ pool_size :: pos_integer(),
+ nif_refs :: #{pos_integer() => reference()} %% Cached NIF refs
+}).
+
+%% ============================================================================
+%% API
+%% ============================================================================
+
+%% @doc Start the context pool cache.
+-spec start_link() -> {ok, pid()} | {error, term()}.
+start_link() ->
+ start_link(#{}).
+
+%% @doc Start the context pool cache.
+-spec start_link(map()) -> {ok, pid()} | {error, term()}.
+start_link(_Opts) ->
+ gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
+
+%% @doc Get a context using scheduler affinity.
+%%
+%% Returns {Ref, InterpId} for the context assigned to the current scheduler.
+%% Zero-copy via persistent_term.
+-spec get_context() -> {reference(), non_neg_integer()}.
+get_context() ->
+ N = persistent_term:get(hornbeam_context_pool_size),
+ Id = erlang:system_info(scheduler_id) rem N,
+ persistent_term:get({hornbeam_context, Id}).
+
+%% @doc Get only the context reference (most common use case).
+-spec get_context_ref() -> reference().
+get_context_ref() ->
+ {Ref, _InterpId} = get_context(),
+ Ref.
+
+%% @doc Get the pool size.
+-spec pool_size() -> pos_integer().
+pool_size() ->
+ persistent_term:get(hornbeam_context_pool_size).
+
+%% @doc Add paths to sys.path in all contexts.
+%%
+%% Call this after starting the context pool to add user-specified paths
+%% (like pythonpath from config) to all Python contexts.
+-spec add_paths([string() | binary()]) -> ok.
+add_paths(Paths) when is_list(Paths) ->
+ gen_server:call(?MODULE, {add_paths, Paths}).
+
+%% @doc Preload WSGI/ASGI application in all contexts.
+%%
+%% Imports the app module and caches the callable for fast access.
+-spec preload_app(wsgi | asgi, binary(), binary()) -> ok.
+preload_app(WorkerClass, AppModule, AppCallable) ->
+ gen_server:call(?MODULE, {preload_app, WorkerClass, AppModule, AppCallable}).
+
+%% ============================================================================
+%% gen_server callbacks
+%% ============================================================================
+
+init([]) ->
+ process_flag(trap_exit, true),
+
+ %% Wait for py_context_router to be ready
+ wait_for_context_router(),
+
+ %% Get pool size and contexts from default py_context_router pool
+ PoolSize = py_context_router:num_contexts(),
+ Contexts = py_context_router:contexts(),
+ NifRefs = cache_nif_refs(Contexts),
+
+ %% Setup hornbeam-specific modules in each context
+ setup_contexts(NifRefs),
+
+ %% Create atomic counter for round-robin
+ Counter = atomics:new(1, [{signed, false}]),
+ persistent_term:put(hornbeam_context_counter, Counter),
+ persistent_term:put(hornbeam_context_pool_size, PoolSize),
+
+ {ok, #state{pool_size = PoolSize, nif_refs = NifRefs}}.
+
+handle_call({add_paths, Paths}, _From, #state{nif_refs = NifRefs} = State) ->
+ %% Add paths to all contexts
+ maps:foreach(fun(_Id, Ref) ->
+ add_paths_to_context(Ref, Paths)
+ end, NifRefs),
+ {reply, ok, State};
+
+handle_call({preload_app, WorkerClass, AppModule, AppCallable}, _From,
+ #state{nif_refs = NifRefs} = State) ->
+ %% Preload app in all contexts
+ WorkerModule = case WorkerClass of
+ wsgi -> <<"hornbeam_wsgi_worker">>;
+ asgi -> <<"hornbeam_asgi_worker">>
+ end,
+ maps:foreach(fun(_Id, Ref) ->
+ case py_nif:context_call(Ref, WorkerModule, <<"preload_app">>,
+ [AppModule, AppCallable], #{}) of
+ {ok, <<"ok">>} -> ok;
+ {error, Err} ->
+ error_logger:warning_msg(
+ "hornbeam: failed to preload app in context: ~p~n", [Err])
+ end
+ end, NifRefs),
+ {reply, ok, State};
+
+handle_call(_Request, _From, State) ->
+ {reply, {error, unknown_request}, State}.
+
+handle_cast(_Request, State) ->
+ {noreply, State}.
+
+handle_info(_Info, State) ->
+ {noreply, State}.
+
+terminate(_Reason, #state{pool_size = PoolSize}) ->
+ %% py_context_router manages context lifecycle, just clean up our cached refs
+ lists:foreach(fun(Id) ->
+ catch persistent_term:erase({hornbeam_context, Id})
+ end, lists:seq(0, PoolSize - 1)),
+ catch persistent_term:erase(hornbeam_context_pool_size),
+ catch persistent_term:erase(hornbeam_context_counter),
+ ok.
+
+%% ============================================================================
+%% Internal functions
+%% ============================================================================
+
+%% @private
+%% Cache NIF refs from py_context_router contexts in persistent_term
+cache_nif_refs(Contexts) ->
+ {NifRefs, _} = lists:foldl(fun(Ctx, {Acc, Id}) ->
+ Ref = py_context:get_nif_ref(Ctx),
+ InterpId = case py_context:get_interp_id(Ctx) of
+ {ok, IId} -> IId;
+ _ -> Id
+ end,
+ persistent_term:put({hornbeam_context, Id}, {Ref, InterpId}),
+ {maps:put(Id, Ref, Acc), Id + 1}
+ end, {#{}, 0}, Contexts),
+ NifRefs.
+
+%% @private
+%% Setup hornbeam-specific modules in each context
+setup_contexts(NifRefs) ->
+ PrivDir = code:priv_dir(hornbeam),
+ PrivDirBin = list_to_binary(PrivDir),
+ SetupCode = <<"
+import sys
+priv_dir = '", PrivDirBin/binary, "'
+if priv_dir not in sys.path:
+ sys.path.insert(0, priv_dir)
+import hornbeam_wsgi_worker
+import hornbeam_asgi_worker
+">>,
+ maps:foreach(fun(Id, Ref) ->
+ case py_nif:context_exec(Ref, SetupCode) of
+ ok -> ok;
+ {error, SetupError} ->
+ error_logger:warning_msg(
+ "hornbeam_context_pool: context ~p setup warning: ~p~n",
+ [Id, SetupError])
+ end
+ end, NifRefs).
+
+%% @private
+%% Add paths to a context's sys.path
+add_paths_to_context(Ref, Paths) ->
+ lists:foreach(fun(Path) ->
+ PathBin = if
+ is_binary(Path) -> Path;
+ is_list(Path) -> list_to_binary(Path);
+ true -> Path
+ end,
+ AbsPath = list_to_binary(filename:absname(binary_to_list(PathBin))),
+ Code = <<"import sys; sys.path.insert(0, '", AbsPath/binary, "') if '", AbsPath/binary, "' not in sys.path else None">>,
+ case py_nif:context_exec(Ref, Code) of
+ ok -> ok;
+ {error, Err} ->
+ error_logger:warning_msg("Failed to add path ~s to context: ~p~n", [AbsPath, Err])
+ end
+ end, Paths).
+
+%% @private
+%% Wait for py_context_router to be started with contexts
+wait_for_context_router() ->
+ wait_for_context_router(50). %% 50 * 100ms = 5s max
+
+wait_for_context_router(0) ->
+ error(py_context_router_not_ready);
+wait_for_context_router(Retries) ->
+ case py_context_router:is_started() andalso py_context_router:num_contexts() > 0 of
+ true -> ok;
+ false ->
+ timer:sleep(100),
+ wait_for_context_router(Retries - 1)
+ end.
diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl
index 8621741..481a732 100644
--- a/src/hornbeam_handler.erl
+++ b/src/hornbeam_handler.erl
@@ -15,13 +15,22 @@
%%% @doc Cowboy HTTP handler for hornbeam.
%%%
%%% This module handles HTTP requests and routes them to either WSGI or ASGI
-%%% handlers based on configuration. It also handles WebSocket upgrades
-%%% for ASGI applications.
+%%% handlers based on configuration.
+%%%
+%%% Architecture:
+%%% - WSGI: Uses context_call with schedule_inline for yielding
+%%% - ASGI: Uses py_event_loop for full async execution
+%%% - Both stream responses via erlang.reply()/send()
+%%%
+%%% @end
-module(hornbeam_handler).
-behaviour(cowboy_websocket).
+-behaviour(cowboy_loop).
-export([init/2]).
+%% Loop handler callback (for ASGI)
+-export([info/3]).
%% WebSocket callbacks (delegate to hornbeam_websocket)
-export([websocket_init/1, websocket_handle/2, websocket_info/2, terminate/3]).
@@ -30,8 +39,9 @@ init(Req, #{multi_app := true} = State) ->
Path = cowboy_req:path(Req),
case hornbeam_mounts:lookup(Path) of
{ok, Mount, PathInfo} ->
- %% Setup mount's pythonpath if specified
- setup_mount_pythonpath(Mount),
+ %% pythonpath is setup at mount registration time (hornbeam_mounts.erl)
+ %% Get mount_id for per-mount lifespan state isolation
+ MountId = maps:get(mount_id, Mount),
%% Build new state from mount config
NewState = State#{
app_module => maps:get(app_module, Mount),
@@ -39,7 +49,10 @@ init(Req, #{multi_app := true} = State) ->
worker_class => maps:get(worker_class, Mount),
timeout => maps:get(timeout, Mount),
script_name => maps:get(prefix, Mount),
- path_info => PathInfo
+ path_info => PathInfo,
+ mount_id => MountId,
+ %% Get per-mount lifespan state (not global)
+ lifespan_state => hornbeam_lifespan:get_state(MountId)
},
WorkerClass = maps:get(worker_class, Mount),
handle_request(WorkerClass, Req, NewState);
@@ -64,7 +77,7 @@ handle_request(asgi, Req, State) ->
true ->
handle_websocket_upgrade(Req, State);
false ->
- handle_asgi(Req, State)
+ hornbeam_asgi:init(Req, State)
end.
%% @private
@@ -81,168 +94,181 @@ handle_websocket_upgrade(Req, State) ->
hornbeam_websocket:init(Req, State).
%%% ============================================================================
-%%% WSGI Handler
+%%% WSGI Handler - unified channel-based approach with schedule_inline
%%% ============================================================================
+%% Streaming threshold: bodies larger than this are streamed via channel
+-define(WSGI_STREAMING_THRESHOLD, 65536). %% 64KB
+-define(WSGI_BODY_CHUNK_SIZE, 65536). %% 64KB chunks
+
+%% Hop-by-hop headers that should not be forwarded
+-define(HOP_BY_HOP_HEADERS, [
+ <<"connection">>, <<"keep-alive">>, <<"proxy-authenticate">>,
+ <<"proxy-authorization">>, <<"te">>, <<"trailers">>,
+ <<"transfer-encoding">>, <<"upgrade">>
+]).
+
handle_wsgi(Req, State) ->
- %% Build initial request map for hooks
ReqInfo = build_request_info(Req),
-
- %% Run on_request hook
ReqInfo1 = hornbeam_http_hooks:run_on_request(ReqInfo),
try
- %% Get app module and callable from cached state (avoids ETS lookups)
AppModule = maps:get(app_module, State),
AppCallable = maps:get(app_callable, State),
TimeoutMs = maps:get(timeout, State, 30000),
- %% Check if context affinity is required (for module-level state sharing)
- %% Use optimized NIF path by default, fall back to ctx_call when needed
- UseContextAffinity = maps:get(context_affinity, State, false),
- PyContext = case UseContextAffinity of
- true -> hornbeam_lifespan:get_context();
- false -> undefined
+ %% Build pre-parsed WSGI tuple (O(1) environ creation in Python)
+ ReqTuple = hornbeam_request:build_wsgi_tuple(Req, State),
+
+ %% Create buffer for request body (skip for bodyless requests)
+ ContentLength = get_content_length(Req),
+ Method = cowboy_req:method(Req),
+ Buffer = case has_request_body(Method, ContentLength) of
+ false ->
+ %% No body expected - use empty buffer marker
+ empty;
+ true ->
+ {ok, Buf} = create_body_buffer(ContentLength),
+ write_body_to_buffer(Req, Buf, ContentLength),
+ Buf
end,
- Result = case PyContext of
- undefined ->
- %% Optimized py_wsgi:run/4 path (NIF-based marshalling)
- run_wsgi_optimized(Req, AppModule, AppCallable, State);
- Ctx ->
- %% Context affinity path - uses same worker as lifespan
- run_wsgi_with_context(Req, AppModule, AppCallable, Ctx, TimeoutMs, State)
- end,
-
- case Result of
- {ok, Response} ->
- %% Run on_response hook
- Response1 = hornbeam_http_hooks:run_on_response(Response),
- send_wsgi_response(Req, Response1, State);
- {error, {overloaded, Current, Max}} ->
- overload_response(Req, Current, Max, State);
- {error, Error} ->
- handle_error(Req, Error, ReqInfo1, State)
+ %% Call Python with tuple fast path
+ CtxRef = hornbeam_context_pool:get_context_ref(),
+ case py_nif:context_call(CtxRef,
+ <<"hornbeam_wsgi_worker">>, <<"handle_request_tuple">>,
+ [self(), Buffer, AppModule, AppCallable, ReqTuple], #{}) of
+ {ok, <<"done">>} ->
+ %% Enter receive loop
+ wsgi_receive_loop(Req, ReqInfo1, TimeoutMs, State);
+ {ok, <<"error">>} ->
+ handle_error(Req, wsgi_error, ReqInfo1, State);
+ {error, Reason} ->
+ handle_error(Req, Reason, ReqInfo1, State)
end
catch
- Class:Reason:Stack ->
+ Class:Error:Stack ->
error_logger:error_msg("WSGI handler error: ~p:~p~n~p~n",
- [Class, Reason, Stack]),
- handle_error(Req, {Class, Reason}, ReqInfo1, State)
+ [Class, Error, Stack]),
+ handle_error(Req, {Class, Error}, ReqInfo1, State)
end.
%% @private
-%% Optimized path using py_wsgi:run/4 with NIF marshalling
-run_wsgi_optimized(Req, AppModule, AppCallable, State) ->
- Environ = build_environ_for_nif(Req, State),
- case py_wsgi:run(AppModule, AppCallable, Environ,
- #{runner => <<"hornbeam_wsgi_runner">>}) of
- {ok, {Status, Headers, Body}} ->
- {ok, #{<<"status">> => Status,
- <<"headers">> => Headers,
- <<"body">> => Body}};
- {error, _} = Error ->
- Error
+%% Get content-length as integer, or undefined if not present/invalid
+get_content_length(Req) ->
+ case cowboy_req:header(<<"content-length">>, Req) of
+ undefined -> undefined;
+ CLBin ->
+ try binary_to_integer(CLBin)
+ catch _:_ -> undefined
+ end
end.
%% @private
-%% Context-aware fallback path using py:ctx_call
-run_wsgi_with_context(Req, AppModule, AppCallable, PyContext, TimeoutMs, State) ->
- %% Build environ options from state (for multi-app mode)
- EnvOpts = case maps:get(script_name, State, undefined) of
- undefined -> #{};
- ScriptName -> #{script_name => ScriptName}
- end,
- Environ = hornbeam_wsgi:build_environ(Req, EnvOpts),
- %% Override PATH_INFO if set in state (from mount lookup)
- Environ1 = case maps:get(path_info, State, undefined) of
- undefined -> Environ;
- PathInfo -> Environ#{<<"PATH_INFO">> => PathInfo}
- end,
- py:ctx_call(PyContext, hornbeam_wsgi_runner, run_wsgi,
- [AppModule, AppCallable, Environ1], #{}, TimeoutMs).
+%% Check if request has a body (based on method and content-length)
+has_request_body(<<"GET">>, undefined) -> false;
+has_request_body(<<"HEAD">>, undefined) -> false;
+has_request_body(<<"DELETE">>, undefined) -> false;
+has_request_body(<<"OPTIONS">>, undefined) -> false;
+has_request_body(_, 0) -> false;
+has_request_body(_, _) -> true.
%% @private
-%% Build environ dict for NIF optimization.
-%% Uses binary keys which the NIF optimizes with interned strings.
-%% State may contain script_name and path_info from mount lookup.
-build_environ_for_nif(Req, State) ->
- Method = cowboy_req:method(Req),
- Path = cowboy_req:path(Req),
- Qs = cowboy_req:qs(Req),
- Headers = cowboy_req:headers(Req),
- Host = cowboy_req:host(Req),
- Port = cowboy_req:port(Req),
- Scheme = cowboy_req:scheme(Req),
- Version = cowboy_req:version(Req),
- {ClientIp, _ClientPort} = cowboy_req:peer(Req),
-
- %% Get SCRIPT_NAME and PATH_INFO from state (multi-app) or defaults
- ScriptName = maps:get(script_name, State, <<>>),
- PathInfo = maps:get(path_info, State, Path),
-
- %% Read body
+%% Create buffer for body - pre-allocate if content-length known
+create_body_buffer(undefined) ->
+ py_buffer:new();
+create_body_buffer(ContentLength) when is_integer(ContentLength), ContentLength > 0 ->
+ py_buffer:new(ContentLength);
+create_body_buffer(_) ->
+ py_buffer:new().
+
+%% @private
+%% Write body to buffer - unified for small and large bodies
+write_body_to_buffer(Req, Buffer, ContentLength) when
+ ContentLength =:= undefined; ContentLength < ?WSGI_STREAMING_THRESHOLD ->
+ %% Small body: read all, write once, close
{ok, Body, _Req2} = cowboy_req:read_body(Req),
+ py_buffer:write(Buffer, Body),
+ py_buffer:close(Buffer);
+write_body_to_buffer(Req, Buffer, _ContentLength) ->
+ %% Large body: spawn process to stream chunks
+ spawn_link(fun() -> stream_body_to_buffer(Req, Buffer, ?WSGI_BODY_CHUNK_SIZE) end).
+
+%% @private
+%% Stream request body to buffer in chunks
+stream_body_to_buffer(Req, Buffer, ChunkSize) ->
+ case cowboy_req:read_body(Req, #{length => ChunkSize}) of
+ {ok, Chunk, _Req2} ->
+ %% Last chunk
+ py_buffer:write(Buffer, Chunk),
+ py_buffer:close(Buffer);
+ {more, Chunk, Req2} ->
+ %% More data available
+ py_buffer:write(Buffer, Chunk),
+ stream_body_to_buffer(Req2, Buffer, ChunkSize)
+ end.
- %% Build HTTP_* headers
- HttpHeaders = maps:fold(fun(Name, Value, Acc) ->
- HeaderKey = header_to_wsgi_key(Name),
- Acc#{HeaderKey => Value}
- end, #{}, Headers),
-
- %% Build base environ
- BaseEnviron = #{
- <<"REQUEST_METHOD">> => Method,
- <<"SCRIPT_NAME">> => ScriptName,
- <<"PATH_INFO">> => PathInfo,
- <<"QUERY_STRING">> => Qs,
- <<"SERVER_NAME">> => Host,
- <<"SERVER_PORT">> => integer_to_binary(Port),
- <<"SERVER_PROTOCOL">> => format_protocol(Version),
- <<"REMOTE_ADDR">> => format_ip(ClientIp),
- <<"wsgi.version">> => {1, 0},
- <<"wsgi.url_scheme">> => Scheme,
- <<"wsgi.input">> => Body,
- <<"wsgi.multithread">> => true,
- <<"wsgi.multiprocess">> => true,
- <<"wsgi.run_once">> => false
- },
-
- %% Merge HTTP headers
- Environ1 = maps:merge(BaseEnviron, HttpHeaders),
-
- %% Add CONTENT_TYPE and CONTENT_LENGTH if present
- ContentType = maps:get(<<"content-type">>, Headers, undefined),
- ContentLength = maps:get(<<"content-length">>, Headers, undefined),
- Environ2 = case ContentType of
- undefined -> Environ1;
- CT -> Environ1#{<<"CONTENT_TYPE">> => CT}
- end,
- case ContentLength of
- undefined -> Environ2;
- CL -> Environ2#{<<"CONTENT_LENGTH">> => CL}
+%% @private
+%% Main receive loop for WSGI responses from Python
+wsgi_receive_loop(Req, ReqInfo, TimeoutMs, State) ->
+ receive
+ {<<"start_response">>, StatusCode, Headers} ->
+ %% Streaming response - filter hop-by-hop and start streaming
+ SafeHeaders = filter_hop_by_hop(Headers),
+ CowboyHeaders = convert_headers(SafeHeaders),
+ Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req),
+ stream_response_loop(Req2, TimeoutMs, State);
+ {<<"response">>, StatusCode, Headers, Body} ->
+ %% Complete response
+ SafeHeaders = filter_hop_by_hop(Headers),
+ Response = #{
+ <<"status">> => StatusCode,
+ <<"headers">> => SafeHeaders,
+ <<"body">> => Body
+ },
+ Response1 = hornbeam_http_hooks:run_on_response(Response),
+ send_response(Req, Response1, State);
+ {<<"error">>, Reason} ->
+ handle_error(Req, Reason, ReqInfo, State)
+ after TimeoutMs ->
+ handle_error(Req, timeout, ReqInfo, State)
end.
%% @private
-header_to_wsgi_key(Name) ->
- %% Convert header name to WSGI HTTP_* format
- %% e.g., "content-type" -> "CONTENT_TYPE" (but CONTENT_TYPE is special)
- %% "accept" -> "HTTP_ACCEPT"
- case Name of
- <<"content-type">> -> <<"CONTENT_TYPE">>;
- <<"content-length">> -> <<"CONTENT_LENGTH">>;
- _ ->
- Upper = string:uppercase(Name),
- Underscored = binary:replace(Upper, <<"-">>, <<"_">>, [global]),
- <<"HTTP_", Underscored/binary>>
+%% Receive and stream response chunks to client
+stream_response_loop(Req, TimeoutMs, State) ->
+ receive
+ {<<"chunk">>, Chunk} ->
+ ok = cowboy_req:stream_body(Chunk, nofin, Req),
+ stream_response_loop(Req, TimeoutMs, State);
+ <<"done">> ->
+ ok = cowboy_req:stream_body(<<>>, fin, Req),
+ {ok, Req, State};
+ {<<"error">>, _Reason} ->
+ ok = cowboy_req:stream_body(<<>>, fin, Req),
+ {ok, Req, State}
+ after TimeoutMs ->
+ ok = cowboy_req:stream_body(<<>>, fin, Req),
+ {ok, Req, State}
end.
%% @private
-format_protocol('HTTP/1.0') -> <<"HTTP/1.0">>;
-format_protocol('HTTP/1.1') -> <<"HTTP/1.1">>;
-format_protocol('HTTP/2') -> <<"HTTP/2">>.
+%% Filter hop-by-hop headers from response
+filter_hop_by_hop(Headers) ->
+ lists:filter(fun(Header) ->
+ Name = case Header of
+ [N, _] -> N;
+ {N, _} -> N
+ end,
+ LowerName = string:lowercase(to_binary(Name)),
+ not lists:member(LowerName, ?HOP_BY_HOP_HEADERS)
+ end, Headers).
-send_wsgi_response(Req, Response, State) ->
+%%% ============================================================================
+%%% Response sending
+%%% ============================================================================
+
+send_response(Req, Response, State) ->
Status = maps:get(<<"status">>, Response),
Headers = maps:get(<<"headers">>, Response),
Body = maps:get(<<"body">>, Response),
@@ -265,9 +291,7 @@ send_wsgi_response(Req, Response, State) ->
send_early_hints(Req, []) ->
Req;
send_early_hints(Req, [Hints | Rest]) ->
- %% Convert hints to cowboy headers format
HintHeaders = convert_headers(Hints),
- %% Send 103 Early Hints informational response
Req1 = cowboy_req:inform(103, HintHeaders, Req),
send_early_hints(Req1, Rest).
@@ -281,216 +305,10 @@ parse_status_code(Status) when is_list(Status) ->
parse_status_code(Status) when is_integer(Status) ->
Status.
-%%% ============================================================================
-%%% ASGI Handler
-%%% ============================================================================
-
-handle_asgi(Req, State) ->
- %% Build initial request map for hooks
- ReqInfo = build_request_info(Req),
-
- %% Run on_request hook
- ReqInfo1 = hornbeam_http_hooks:run_on_request(ReqInfo),
-
- try
- %% Get app module and callable from cached state (avoids ETS lookups)
- AppModule = maps:get(app_module, State),
- AppCallable = maps:get(app_callable, State),
- TimeoutMs = maps:get(timeout, State, 30000),
-
- %% Read request body
- {ok, ReqBody, Req2} = cowboy_req:read_body(Req),
-
- %% Determine ASGI execution mode:
- %% - context_affinity: Use lifespan context (for shared module state)
- %% - bind_context: Bind a fresh context per-request (reduces GIL overhead)
- %% - default: Use optimized NIF path (fastest for simple apps)
- UseContextAffinity = maps:get(context_affinity, State, false),
- BindContext = maps:get(bind_context, State, false),
-
- Result = case {UseContextAffinity, BindContext} of
- {true, _} ->
- %% Context affinity path - uses same worker as lifespan
- %% Required when app stores resources in module-level variables
- PyContext = hornbeam_lifespan:get_context(),
- run_asgi_with_context(Req, AppModule, AppCallable, ReqBody, PyContext, TimeoutMs, State);
- {false, true} ->
- %% Bound context path - binds worker for request duration
- %% Better for apps with multiple async operations
- run_asgi_bound(Req, AppModule, AppCallable, ReqBody, TimeoutMs, State);
- {false, false} ->
- %% Optimized py_asgi:run/5 path (NIF-based marshalling)
- %% Best for simple request/response apps
- run_asgi_optimized(Req, AppModule, AppCallable, ReqBody, State)
- end,
-
- case Result of
- {ok, Response} ->
- %% Run on_response hook
- Response1 = hornbeam_http_hooks:run_on_response(Response),
- send_asgi_response(Req2, Response1, State);
- {error, {overloaded, Current, Max}} ->
- overload_response(Req2, Current, Max, State);
- {error, Error} ->
- handle_error(Req2, Error, ReqInfo1, State)
- end
- catch
- Class:Reason:Stack ->
- error_logger:error_msg("ASGI handler error: ~p:~p~n~p~n",
- [Class, Reason, Stack]),
- handle_error(Req, {Class, Reason}, ReqInfo1, State)
- end.
-
-%% @private
-%% Optimized path using py_asgi:run/5 with NIF marshalling
-run_asgi_optimized(Req, AppModule, AppCallable, ReqBody, State) ->
- Scope = build_scope_for_nif(Req, State),
- case py_asgi:run(AppModule, AppCallable, Scope, ReqBody,
- #{runner => <<"hornbeam_asgi_runner">>}) of
- {ok, {Status, Headers, Body}} ->
- {ok, #{<<"status">> => Status,
- <<"headers">> => Headers,
- <<"body">> => Body}};
- {error, _} = Error ->
- Error
- end.
-
-%% @private
-%% Context-aware fallback path using py:ctx_call
-run_asgi_with_context(Req, AppModule, AppCallable, ReqBody, PyContext, TimeoutMs, State) ->
- %% Build scope options from state (for multi-app mode)
- ScopeOpts = case maps:get(script_name, State, undefined) of
- undefined -> #{};
- ScriptName -> #{root_path => ScriptName}
- end,
- Scope = hornbeam_asgi:build_scope(Req, ScopeOpts),
- %% Override path if set in state (from mount lookup)
- Scope1 = case maps:get(path_info, State, undefined) of
- undefined -> Scope;
- PathInfo -> Scope#{<<"path">> => PathInfo, <<"raw_path">> => PathInfo}
- end,
- py:ctx_call(PyContext, hornbeam_asgi_runner, run_asgi,
- [AppModule, AppCallable, Scope1, ReqBody], #{}, TimeoutMs).
-
-%% @private
-%% Bound context path - binds a worker for the request duration.
-%% This reduces overhead for apps with multiple async operations by
-%% keeping the same Python worker/GIL for the entire request.
-run_asgi_bound(Req, AppModule, AppCallable, ReqBody, TimeoutMs, State) ->
- %% Build scope options from state (for multi-app mode)
- ScopeOpts = case maps:get(script_name, State, undefined) of
- undefined -> #{};
- ScriptName -> #{root_path => ScriptName}
- end,
- Scope = hornbeam_asgi:build_scope(Req, ScopeOpts),
- %% Override path if set in state (from mount lookup)
- Scope1 = case maps:get(path_info, State, undefined) of
- undefined -> Scope;
- PathInfo -> Scope#{<<"path">> => PathInfo, <<"raw_path">> => PathInfo}
- end,
- %% Use with_context to bind a worker for the request duration
- py:with_context(fun() ->
- py:call(hornbeam_asgi_runner, run_asgi,
- [AppModule, AppCallable, Scope1, ReqBody], #{}, TimeoutMs)
- end).
-
-%% @private
-%% Build scope with atom keys for NIF optimization.
-%% The NIF uses asgi_get_key_for_term which optimizes atom key lookups.
-%% State may contain script_name (root_path) and path_info from mount lookup.
-build_scope_for_nif(Req, State) ->
- Method = cowboy_req:method(Req),
- Path = cowboy_req:path(Req),
- Qs = cowboy_req:qs(Req),
- Headers = cowboy_req:headers(Req),
- Host = cowboy_req:host(Req),
- Port = cowboy_req:port(Req),
- Scheme = cowboy_req:scheme(Req),
- Version = cowboy_req:version(Req),
- {ClientIp, ClientPort} = cowboy_req:peer(Req),
-
- %% Get root_path and path from state (multi-app) or defaults
- RootPath = maps:get(script_name, State, <<>>),
- ScopePath = maps:get(path_info, State, Path),
-
- HeaderList = maps:fold(fun(Name, Value, Acc) ->
- [[Name, Value] | Acc]
- end, [], Headers),
-
- LifespanState = hornbeam_lifespan:get_state(),
-
- #{
- type => <<"http">>,
- asgi => #{<<"version">> => <<"3.0">>, <<"spec_version">> => <<"2.4">>},
- http_version => format_http_version(Version),
- method => Method,
- scheme => Scheme,
- path => ScopePath,
- raw_path => ScopePath,
- query_string => Qs,
- root_path => RootPath,
- headers => HeaderList,
- server => {Host, Port},
- client => {format_ip(ClientIp), ClientPort},
- state => LifespanState,
- extensions => build_extensions(Version)
- }.
-
-%% @private
-format_http_version('HTTP/1.0') -> <<"1.0">>;
-format_http_version('HTTP/1.1') -> <<"1.1">>;
-format_http_version('HTTP/2') -> <<"2">>.
-
-%% @private
-format_ip({A, B, C, D}) ->
- list_to_binary([
- integer_to_list(A), $.,
- integer_to_list(B), $.,
- integer_to_list(C), $.,
- integer_to_list(D)
- ]);
-format_ip(Addr = {_, _, _, _, _, _, _, _}) ->
- list_to_binary(inet:ntoa(Addr)).
-
-%% @private
-build_extensions('HTTP/2') ->
- #{
- <<"http.response.trailers">> => #{},
- <<"http.response.early_hints">> => #{}
- };
-build_extensions(_) ->
- #{
- <<"http.response.early_hints">> => #{}
- }.
-
-send_asgi_response(Req, Response, State) ->
- Status = maps:get(<<"status">>, Response),
- Headers = maps:get(<<"headers">>, Response),
- Body = maps:get(<<"body">>, Response),
- EarlyHints = maps:get(<<"early_hints">>, Response, []),
-
- %% Convert status
- StatusCode = case Status of
- undefined -> 500;
- S when is_integer(S) -> S;
- S -> parse_status_code(S)
- end,
-
- %% Convert headers to cowboy format
- CowboyHeaders = convert_headers(Headers),
-
- %% Send early hints if any
- Req1 = send_early_hints(Req, EarlyHints),
-
- Req2 = cowboy_req:reply(StatusCode, CowboyHeaders, Body, Req1),
- {ok, Req2, State}.
-
%%% ============================================================================
%%% Error handling
%%% ============================================================================
-%% @private
-%% Handle errors using the on_error hook if configured
handle_error(Req, Error, ReqInfo, State) ->
{StatusCode, Body} = hornbeam_http_hooks:run_on_error(Error, ReqInfo),
Req2 = cowboy_req:reply(StatusCode,
@@ -500,19 +318,6 @@ handle_error(Req, Error, ReqInfo, State) ->
{ok, Req2, State}.
%% @private
-%% Return 503 Service Unavailable when Python workers are overloaded
-overload_response(Req, Current, Max, State) ->
- ErrorMsg = io_lib:format("Service temporarily unavailable: ~p/~p workers busy",
- [Current, Max]),
- Req2 = cowboy_req:reply(503,
- #{<<"content-type">> => <<"text/plain">>,
- <<"retry-after">> => <<"1">>},
- iolist_to_binary(ErrorMsg),
- Req),
- {ok, Req2, State}.
-
-%% @private
-%% Build request info map for hooks
build_request_info(Req) ->
#{
method => cowboy_req:method(Req),
@@ -530,7 +335,6 @@ build_request_info(Req) ->
%%% ============================================================================
%% @private
-%% Convert headers from various formats to cowboy map format
convert_headers(Headers) ->
lists:foldl(fun(Header, Acc) ->
case Header of
@@ -554,30 +358,17 @@ to_lower_binary(V) when is_atom(V) -> string:lowercase(atom_to_binary(V, utf8));
to_lower_binary(V) -> string:lowercase(to_binary(V)).
%%% ============================================================================
-%%% WebSocket callbacks (delegate to hornbeam_websocket)
+%%% Loop handler callback (for ASGI)
%%% ============================================================================
%% @private
-%% Setup pythonpath for a mount before executing the app.
-%% This ensures mount-specific dependencies are available.
-%% Note: paths are added at startup too, but this ensures they're
-%% at the front of sys.path for this request.
-setup_mount_pythonpath(Mount) ->
- case maps:get(pythonpath, Mount, []) of
- [] ->
- ok;
- Paths when is_list(Paths) ->
- %% Add each path to sys.path if not already present
- lists:foreach(fun(Path) ->
- PathBin = if
- is_binary(Path) -> Path;
- is_list(Path) -> list_to_binary(Path);
- true -> Path
- end,
- py:eval(<<"__import__('sys').path.insert(0, p) if p not in __import__('sys').path else None">>,
- #{p => PathBin})
- end, Paths)
- end.
+%% Delegate to hornbeam_asgi for ASGI loop handler messages
+info(Msg, Req, State) ->
+ hornbeam_asgi:info(Msg, Req, State).
+
+%%% ============================================================================
+%%% WebSocket callbacks (delegate to hornbeam_websocket)
+%%% ============================================================================
websocket_init(State) ->
hornbeam_websocket:websocket_init(State).
diff --git a/src/hornbeam_http_hooks.erl b/src/hornbeam_http_hooks.erl
index 5f9d9dd..d7eb390 100644
--- a/src/hornbeam_http_hooks.erl
+++ b/src/hornbeam_http_hooks.erl
@@ -50,30 +50,40 @@
run_on_error/2
]).
--define(HOOKS_KEY, {?MODULE, hooks}).
+%% Individual hook keys for direct persistent_term access
+%% Avoids try/catch on get_hooks() and maps:get() on every request
+-define(HOOK_ON_REQUEST, {?MODULE, on_request}).
+-define(HOOK_ON_RESPONSE, {?MODULE, on_response}).
+-define(HOOK_ON_ERROR, {?MODULE, on_error}).
%% @doc Set the HTTP hooks configuration.
-%% Hooks are stored in persistent_term for fast access.
+%% Stores individual hooks in persistent_term with direct keys.
+%% Stores undefined when no hook is configured for zero-overhead check.
-spec set_hooks(map()) -> ok.
set_hooks(Hooks) when is_map(Hooks) ->
- persistent_term:put(?HOOKS_KEY, Hooks),
+ persistent_term:put(?HOOK_ON_REQUEST, maps:get(on_request, Hooks, undefined)),
+ persistent_term:put(?HOOK_ON_RESPONSE, maps:get(on_response, Hooks, undefined)),
+ persistent_term:put(?HOOK_ON_ERROR, maps:get(on_error, Hooks, undefined)),
ok.
%% @doc Get the current hooks configuration.
+%% Reconstructs the map from individual persistent_term keys.
-spec get_hooks() -> map().
get_hooks() ->
- try
- persistent_term:get(?HOOKS_KEY)
- catch
- error:badarg -> #{}
- end.
+ OnRequest = persistent_term:get(?HOOK_ON_REQUEST, undefined),
+ OnResponse = persistent_term:get(?HOOK_ON_RESPONSE, undefined),
+ OnError = persistent_term:get(?HOOK_ON_ERROR, undefined),
+ lists:foldl(fun
+ ({_, undefined}, Acc) -> Acc;
+ ({Key, Value}, Acc) -> Acc#{Key => Value}
+ end, #{}, [{on_request, OnRequest}, {on_response, OnResponse}, {on_error, OnError}]).
%% @doc Run the on_request hook.
%% The hook receives a request map and should return a (possibly modified) request map.
%% If no hook is configured, returns the request unchanged.
-spec run_on_request(map()) -> map().
run_on_request(Request) when is_map(Request) ->
- case maps:get(on_request, get_hooks(), undefined) of
+ case persistent_term:get(?HOOK_ON_REQUEST, undefined) of
undefined ->
Request;
Hook when is_function(Hook, 1) ->
@@ -92,7 +102,7 @@ run_on_request(Request) when is_map(Request) ->
%% If no hook is configured, returns the response unchanged.
-spec run_on_response(map()) -> map().
run_on_response(Response) when is_map(Response) ->
- case maps:get(on_response, get_hooks(), undefined) of
+ case persistent_term:get(?HOOK_ON_RESPONSE, undefined) of
undefined ->
Response;
Hook when is_function(Hook, 1) ->
@@ -112,7 +122,7 @@ run_on_response(Response) when is_map(Response) ->
%% If no hook is configured, returns a default 500 error.
-spec run_on_error(term(), map()) -> {integer(), binary()}.
run_on_error(Error, Request) ->
- case maps:get(on_error, get_hooks(), undefined) of
+ case persistent_term:get(?HOOK_ON_ERROR, undefined) of
undefined ->
%% Default error response
{500, <<"Internal Server Error">>};
diff --git a/src/hornbeam_lifespan.erl b/src/hornbeam_lifespan.erl
index c6ba3e7..6366220 100644
--- a/src/hornbeam_lifespan.erl
+++ b/src/hornbeam_lifespan.erl
@@ -53,10 +53,17 @@
start_link/1,
startup/0,
startup/1,
+ startup/2,
shutdown/0,
+ shutdown/1,
get_state/0,
+ get_state/1,
+ set_state/2,
+ update_state/2,
+ update_state/3,
get_context/0,
- is_running/0
+ is_running/0,
+ is_running/1
]).
-export([
@@ -72,6 +79,15 @@
-define(DEFAULT_TIMEOUT, 30000).
-define(CACHE_TABLE, hornbeam_lifespan_cache).
+%% Per-mount lifespan tracking
+-record(mount_lifespan, {
+ mount_id :: binary(),
+ app_module :: binary(),
+ app_callable :: binary(),
+ started = false :: boolean(),
+ supported = unknown :: boolean() | unknown
+}).
+
-record(state, {
app_module :: binary() | undefined,
app_callable :: binary() | undefined,
@@ -79,7 +95,9 @@
lifespan_state :: map(),
py_context :: term() | undefined, %% Python context for affinity
started = false :: boolean(),
- supported = unknown :: boolean() | unknown
+ supported = unknown :: boolean() | unknown,
+ %% Per-mount tracking for multi-app mode
+ mounts = #{} :: #{binary() => #mount_lifespan{}}
}).
%%% ============================================================================
@@ -107,12 +125,23 @@ startup() ->
startup(Opts) ->
gen_server:call(?SERVER, {startup, Opts}, infinity).
+%% @doc Run lifespan startup for a specific mount.
+%% Used in multi-app mode to run lifespan per mounted app.
+-spec startup(binary(), map()) -> ok | {error, term()}.
+startup(MountId, Opts) when is_binary(MountId) ->
+ gen_server:call(?SERVER, {startup_mount, MountId, Opts}, infinity).
+
%% @doc Run lifespan shutdown protocol.
-spec shutdown() -> ok | {error, term()}.
shutdown() ->
gen_server:call(?SERVER, shutdown, infinity).
-%% @doc Get the lifespan state (shared across requests).
+%% @doc Run lifespan shutdown for a specific mount.
+-spec shutdown(binary()) -> ok | {error, term()}.
+shutdown(MountId) when is_binary(MountId) ->
+ gen_server:call(?SERVER, {shutdown_mount, MountId}, infinity).
+
+%% @doc Get the lifespan state for single-app mode (backward compat).
%% Uses ETS cache for fast concurrent reads.
-spec get_state() -> map().
get_state() ->
@@ -121,7 +150,40 @@ get_state() ->
[] -> #{}
end.
-%% @doc Check if lifespan is running.
+%% @doc Get the lifespan state for a specific mount.
+%% Used in multi-app mode to get per-mount state.
+-spec get_state(binary()) -> map().
+get_state(MountId) when is_binary(MountId) ->
+ case ets:lookup(?CACHE_TABLE, {lifespan_state, MountId}) of
+ [{{lifespan_state, MountId}, State}] -> State;
+ [] -> #{}
+ end.
+
+%% @doc Set the lifespan state for a specific mount.
+%% Used after startup to store per-mount state in ETS.
+-spec set_state(binary(), map()) -> ok.
+set_state(MountId, State) when is_binary(MountId), is_map(State) ->
+ ets:insert(?CACHE_TABLE, {{lifespan_state, MountId}, State}),
+ ok.
+
+%% @doc Update a single key in the lifespan state (single-app mode).
+%% This is called from Python to persist state changes.
+-spec update_state(binary(), term()) -> ok.
+update_state(Key, Value) ->
+ CurrentState = get_state(),
+ NewState = CurrentState#{Key => Value},
+ ets:insert(?CACHE_TABLE, {lifespan_state, NewState}),
+ ok.
+
+%% @doc Update a single key in the lifespan state for a specific mount.
+-spec update_state(binary(), binary(), term()) -> ok.
+update_state(MountId, Key, Value) when is_binary(MountId) ->
+ CurrentState = get_state(MountId),
+ NewState = CurrentState#{Key => Value},
+ ets:insert(?CACHE_TABLE, {{lifespan_state, MountId}, NewState}),
+ ok.
+
+%% @doc Check if lifespan is running (single-app mode).
-spec is_running() -> boolean().
is_running() ->
case ets:lookup(?CACHE_TABLE, started) of
@@ -129,6 +191,14 @@ is_running() ->
[] -> false
end.
+%% @doc Check if lifespan is running for a specific mount.
+-spec is_running(binary()) -> boolean().
+is_running(MountId) when is_binary(MountId) ->
+ case ets:lookup(?CACHE_TABLE, {started, MountId}) of
+ [{{started, MountId}, Started}] -> Started;
+ [] -> false
+ end.
+
%% @doc Get the Python context for ASGI calls.
%%
%% This context provides affinity - all calls using this context
@@ -155,11 +225,13 @@ init(Opts) ->
{read_concurrency, true}
]),
- %% Create a dedicated Python context for ASGI affinity
+ %% Get a Python context from hornbeam_context_pool for ASGI affinity
%% This ensures module-level state persists across requests
- PyContext = case py:bind(new) of
- {ok, Ctx} -> Ctx;
- _ -> undefined
+ %% Using hornbeam_context_pool ensures priv/ is in sys.path
+ PyContext = try
+ hornbeam_context_pool:get_context_ref()
+ catch
+ _:_ -> undefined
end,
%% Cache initial values
@@ -169,6 +241,9 @@ init(Opts) ->
{started, false}
]),
+ %% Register state callbacks for direct Python access (no whereis needed)
+ register_state_callbacks(),
+
{ok, #state{
lifespan_mode = LifespanMode,
lifespan_state = #{},
@@ -221,6 +296,81 @@ handle_call({startup, Opts}, _From, #state{py_context = PyContext} = State) ->
end
end;
+%% Per-mount startup handler
+handle_call({startup_mount, MountId, Opts}, _From, #state{py_context = PyContext, mounts = Mounts} = State) ->
+ AppModule = maps:get(app_module, Opts),
+ AppCallable = maps:get(app_callable, Opts),
+ LifespanMode = maps:get(lifespan, Opts, auto),
+
+ case LifespanMode of
+ off ->
+ %% Update ETS cache for this mount
+ ets:insert(?CACHE_TABLE, {{started, MountId}, true}),
+ MountLifespan = #mount_lifespan{
+ mount_id = MountId,
+ app_module = AppModule,
+ app_callable = AppCallable,
+ started = true,
+ supported = false
+ },
+ {reply, ok, State#state{mounts = Mounts#{MountId => MountLifespan}}};
+ _ ->
+ %% Try to run lifespan startup with mount_id
+ case run_startup_mount(MountId, AppModule, AppCallable, PyContext) of
+ {ok, LifespanState} ->
+ %% Update ETS cache for this mount
+ ets:insert(?CACHE_TABLE, [
+ {{lifespan_state, MountId}, LifespanState},
+ {{started, MountId}, true}
+ ]),
+ MountLifespan = #mount_lifespan{
+ mount_id = MountId,
+ app_module = AppModule,
+ app_callable = AppCallable,
+ started = true,
+ supported = true
+ },
+ {reply, ok, State#state{mounts = Mounts#{MountId => MountLifespan}}};
+ {error, not_supported} when LifespanMode =:= auto ->
+ %% Lifespan not supported, but that's OK in auto mode
+ ets:insert(?CACHE_TABLE, {{started, MountId}, true}),
+ MountLifespan = #mount_lifespan{
+ mount_id = MountId,
+ app_module = AppModule,
+ app_callable = AppCallable,
+ started = true,
+ supported = false
+ },
+ {reply, ok, State#state{mounts = Mounts#{MountId => MountLifespan}}};
+ {error, not_supported} when LifespanMode =:= on ->
+ {reply, {error, lifespan_not_supported}, State};
+ {error, Reason} ->
+ {reply, {error, Reason}, State}
+ end
+ end;
+
+%% Per-mount shutdown handler
+handle_call({shutdown_mount, MountId}, _From, #state{py_context = PyContext, mounts = Mounts} = State) ->
+ case maps:get(MountId, Mounts, undefined) of
+ undefined ->
+ {reply, ok, State};
+ #mount_lifespan{started = false} ->
+ {reply, ok, State};
+ #mount_lifespan{supported = false} = ML ->
+ ets:insert(?CACHE_TABLE, [
+ {{started, MountId}, false},
+ {{lifespan_state, MountId}, #{}}
+ ]),
+ {reply, ok, State#state{mounts = Mounts#{MountId => ML#mount_lifespan{started = false}}}};
+ #mount_lifespan{app_module = AppModule, app_callable = AppCallable} = ML ->
+ Result = run_shutdown_mount(MountId, AppModule, AppCallable, PyContext),
+ ets:insert(?CACHE_TABLE, [
+ {{started, MountId}, false},
+ {{lifespan_state, MountId}, #{}}
+ ]),
+ {reply, Result, State#state{mounts = Mounts#{MountId => ML#mount_lifespan{started = false}}}}
+ end;
+
handle_call(shutdown, _From, #state{started = false} = State) ->
{reply, ok, State};
@@ -252,6 +402,18 @@ handle_call(_Request, _From, State) ->
handle_cast(_Request, State) ->
{noreply, State}.
+%% Handle state update messages from Python (via erlang.send)
+%% Python sends binaries, so we match on <<"update_state">>
+handle_info({<<"update_state">>, Key, Value}, State) ->
+ %% Single-app mode state update
+ update_state(Key, Value),
+ {noreply, State};
+
+handle_info({<<"update_state">>, MountId, Key, Value}, State) ->
+ %% Multi-app mode state update
+ update_state(MountId, Key, Value),
+ {noreply, State};
+
handle_info(_Info, State) ->
{noreply, State}.
@@ -261,12 +423,8 @@ terminate(_Reason, #state{started = true, supported = true,
py_context = PyContext}) ->
%% Run shutdown on terminate
_ = run_shutdown(AppModule, AppCallable, PyContext),
- %% Unbind the context
- catch py:unbind(PyContext),
ok;
-terminate(_Reason, #state{py_context = PyContext}) ->
- %% Just unbind context if lifespan not started
- catch py:unbind(PyContext),
+terminate(_Reason, _State) ->
ok.
code_change(_OldVsn, State, _Extra) ->
@@ -293,10 +451,10 @@ run_startup(AppModule, AppCallable, PyContext) ->
Result = case PyContext of
undefined ->
py:call(hornbeam_lifespan_runner, startup,
- [AppModule, AppCallable, TimeoutMs], #{}, TimeoutMs + 5000);
- Ctx ->
- py:ctx_call(Ctx, hornbeam_lifespan_runner, startup,
- [AppModule, AppCallable, TimeoutMs], #{}, TimeoutMs + 5000)
+ [AppModule, AppCallable, TimeoutMs], #{});
+ CtxRef ->
+ py_nif:context_call(CtxRef, <<"hornbeam_lifespan_runner">>, <<"startup">>,
+ [AppModule, AppCallable, TimeoutMs], #{})
end,
case Result of
{ok, Response} ->
@@ -326,21 +484,15 @@ handle_startup_response(Response) ->
end.
run_shutdown(AppModule, AppCallable, PyContext) ->
- Timeout = hornbeam_config:get_config(timeout),
- TimeoutMs = case Timeout of
- undefined -> ?DEFAULT_TIMEOUT;
- T -> min(T, 10000) % Cap shutdown timeout
- end,
-
try
%% Use context-aware call for affinity
Result = case PyContext of
undefined ->
py:call(hornbeam_lifespan_runner, shutdown,
- [AppModule, AppCallable], #{}, TimeoutMs);
- Ctx ->
- py:ctx_call(Ctx, hornbeam_lifespan_runner, shutdown,
- [AppModule, AppCallable], #{}, TimeoutMs)
+ [AppModule, AppCallable], #{});
+ CtxRef ->
+ py_nif:context_call(CtxRef, <<"hornbeam_lifespan_runner">>, <<"shutdown">>,
+ [AppModule, AppCallable], #{})
end,
case Result of
{ok, Response} ->
@@ -355,6 +507,67 @@ run_shutdown(AppModule, AppCallable, PyContext) ->
{error, {Class, CatchReason}}
end.
+%% @private
+%% Run lifespan startup for a specific mount (multi-app mode)
+run_startup_mount(MountId, AppModule, AppCallable, PyContext) ->
+ TimeoutMs = case hornbeam_config:get_config(lifespan_timeout) of
+ undefined ->
+ case hornbeam_config:get_config(timeout) of
+ undefined -> ?DEFAULT_TIMEOUT;
+ T -> T
+ end;
+ LT -> LT
+ end,
+
+ try
+ %% Pass mount_id to Python so it can store state per mount
+ Result = case PyContext of
+ undefined ->
+ py:call(hornbeam_lifespan_runner, startup_mount,
+ [MountId, AppModule, AppCallable, TimeoutMs], #{});
+ CtxRef ->
+ py_nif:context_call(CtxRef, <<"hornbeam_lifespan_runner">>, <<"startup_mount">>,
+ [MountId, AppModule, AppCallable, TimeoutMs], #{})
+ end,
+ case Result of
+ {ok, Response} ->
+ handle_startup_response(Response);
+ {error, StartupError} ->
+ {error, StartupError}
+ end
+ catch
+ Class:CatchReason ->
+ error_logger:error_msg("Lifespan mount startup error (~s): ~p:~p~n",
+ [MountId, Class, CatchReason]),
+ {error, {Class, CatchReason}}
+ end.
+
+%% @private
+%% Run lifespan shutdown for a specific mount (multi-app mode)
+run_shutdown_mount(MountId, AppModule, AppCallable, PyContext) ->
+ try
+ %% Pass mount_id to Python for per-mount cleanup
+ Result = case PyContext of
+ undefined ->
+ py:call(hornbeam_lifespan_runner, shutdown_mount,
+ [MountId, AppModule, AppCallable], #{});
+ CtxRef ->
+ py_nif:context_call(CtxRef, <<"hornbeam_lifespan_runner">>, <<"shutdown_mount">>,
+ [MountId, AppModule, AppCallable], #{})
+ end,
+ case Result of
+ {ok, Response} ->
+ handle_shutdown_response(Response);
+ {error, ShutdownError} ->
+ {error, ShutdownError}
+ end
+ catch
+ Class:CatchReason ->
+ error_logger:error_msg("Lifespan mount shutdown error (~s): ~p:~p~n",
+ [MountId, Class, CatchReason]),
+ {error, {Class, CatchReason}}
+ end.
+
handle_shutdown_response(Response) ->
case maps:get(<<"type">>, Response, undefined) of
<<"lifespan.shutdown.complete">> ->
@@ -363,3 +576,40 @@ handle_shutdown_response(Response) ->
%% Accept any response during shutdown
ok
end.
+
+%% @private
+%% Register callbacks for direct Python state access via erlang.call()
+%% This avoids erlang.whereis() on every request
+register_state_callbacks() ->
+ py:register_function(lifespan_state_get, fun state_get_callback/1),
+ py:register_function(lifespan_state_set, fun state_set_callback/1),
+ ok.
+
+%% @private
+%% Callback: get state value
+%% Args: [] -> full state, [Key] -> single value, [MountId, Key] -> per-mount value
+state_get_callback([]) ->
+ get_state();
+state_get_callback([Key]) ->
+ State = get_state(),
+ maps:get(Key, State, undefined);
+state_get_callback([MountId, Key]) when is_binary(MountId) ->
+ State = get_state(MountId),
+ maps:get(Key, State, undefined);
+state_get_callback([MountId, _Key]) when MountId =:= undefined; MountId =:= none ->
+ %% No mount_id, fall back to single-app mode
+ get_state().
+
+%% @private
+%% Callback: set state value
+%% Args: [Key, Value] -> single-app mode, [MountId, Key, Value] -> per-mount
+state_set_callback([Key, Value]) ->
+ update_state(Key, Value),
+ ok;
+state_set_callback([MountId, Key, Value]) when is_binary(MountId) ->
+ update_state(MountId, Key, Value),
+ ok;
+state_set_callback([MountId, Key, Value]) when MountId =:= undefined; MountId =:= none ->
+ %% No mount_id, fall back to single-app mode
+ update_state(Key, Value),
+ ok.
diff --git a/src/hornbeam_mounts.erl b/src/hornbeam_mounts.erl
index 698c964..9a5c5b5 100644
--- a/src/hornbeam_mounts.erl
+++ b/src/hornbeam_mounts.erl
@@ -22,9 +22,9 @@
%%% %% Register mounts
%%% hornbeam_mounts:register([
%%% #{prefix => <<"/api">>, app_module => <<"api">>, app_callable => <<"app">>,
-%%% worker_class => asgi, workers => 4, timeout => 30000},
+%%% worker_class => asgi, timeout => 30000},
%%% #{prefix => <<"/">>, app_module => <<"frontend">>, app_callable => <<"app">>,
-%%% worker_class => wsgi, workers => 2, timeout => 30000}
+%%% worker_class => wsgi, timeout => 30000}
%%% ]).
%%%
%%% %% Lookup a path
@@ -55,8 +55,9 @@
app_module := binary(),
app_callable := binary(),
worker_class := wsgi | asgi,
- workers := pos_integer(),
- timeout := pos_integer()
+ timeout := pos_integer(),
+ mount_id => binary(), %% 6-char random ID for routing
+ pythonpath => [binary()] %% Additional Python paths for this mount
}.
-export_type([mount/0]).
@@ -111,14 +112,30 @@ init([]) ->
{ok, #{}}.
handle_call({register, Mounts}, _From, State) ->
+ %% Generate mount_id for each mount and sort by prefix length
+ MountsWithIds = lists:map(fun(Mount) ->
+ MountId = case maps:get(mount_id, Mount, undefined) of
+ undefined -> generate_mount_id();
+ Existing -> Existing
+ end,
+ Mount#{mount_id => MountId}
+ end, Mounts),
+
+ %% Setup pythonpath and preload apps at registration time (not per-request)
+ lists:foreach(fun(Mount) ->
+ setup_mount_pythonpath(Mount),
+ setup_mount_imports(Mount)
+ end, MountsWithIds),
+
%% Sort mounts by prefix length descending (longest first)
SortedMounts = lists:sort(
fun(#{prefix := P1}, #{prefix := P2}) ->
byte_size(P1) >= byte_size(P2)
end,
- Mounts
+ MountsWithIds
),
ets:insert(?TABLE, {sorted_mounts, SortedMounts}),
+
{reply, ok, State};
handle_call(clear, _From, State) ->
@@ -203,3 +220,37 @@ strip_prefix(Path, Prefix) ->
end
end
end.
+
+%% @private
+%% Generate a 6-character URL-safe random ID for mount routing.
+%% Uses 3 random bytes encoded as URL-safe base64 (no padding).
+generate_mount_id() ->
+ Bytes = crypto:strong_rand_bytes(3),
+ %% URL-safe base64 encoding (replaces + with - and / with _)
+ B64 = base64:encode(Bytes),
+ %% Replace unsafe characters and remove padding
+ binary:replace(binary:replace(B64, <<"+">>, <<"-">>), <<"/">>, <<"_">>).
+
+%% @private
+%% Setup mount's pythonpath at registration time (called once, not per-request).
+setup_mount_pythonpath(Mount) ->
+ case maps:get(pythonpath, Mount, []) of
+ [] ->
+ ok;
+ Paths when is_list(Paths) ->
+ lists:foreach(fun(Path) ->
+ PathBin = if
+ is_binary(Path) -> Path;
+ is_list(Path) -> list_to_binary(Path);
+ true -> Path
+ end,
+ py_import:add_path(PathBin)
+ end, Paths)
+ end.
+
+%% @private
+%% Preload app module at registration time for both WSGI and ASGI.
+setup_mount_imports(Mount) ->
+ AppModule = maps:get(app_module, Mount),
+ AppCallable = maps:get(app_callable, Mount),
+ py_import:ensure_imported(AppModule, AppCallable).
diff --git a/src/hornbeam_request.erl b/src/hornbeam_request.erl
new file mode 100644
index 0000000..1a7c4c7
--- /dev/null
+++ b/src/hornbeam_request.erl
@@ -0,0 +1,193 @@
+%% Copyright 2026 Benoit Chesneau
+%%
+%% Licensed under the Apache License, Version 2.0 (the "License");
+%% you may not use this file except in compliance with the License.
+%% You may obtain a copy of the License at
+%%
+%% http://www.apache.org/licenses/LICENSE-2.0
+%%
+%% Unless required by applicable law or agreed to in writing, software
+%% distributed under the License is distributed on an "AS IS" BASIS,
+%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+%% See the License for the specific language governing permissions and
+%% limitations under the License.
+
+%%% @doc Request data structure builder for WSGI/ASGI performance optimization.
+%%%
+%%% This module pre-parses HTTP requests in Erlang to minimize Python-side
+%%% processing. Headers are pre-converted to WSGI HTTP_* format so Python
+%%% only needs to do dict.update() without loops.
+%%%
+%%% Key optimizations:
+%%% - Headers pre-converted to WSGI format (HTTP_ACCEPT_ENCODING, etc.)
+%%% - Content-Type and Content-Length extracted separately
+%%% - All string conversions done in Erlang (native binary operations)
+%%% - Single tuple passed to Python for minimal marshalling overhead
+-module(hornbeam_request).
+
+-export([build_wsgi_tuple/2, build_asgi_scope/2]).
+-export([to_wsgi_header_key/1, format_ip/1, format_http_version/1]).
+
+%% @doc Build a pre-parsed WSGI request tuple for Python.
+%%
+%% Returns a tuple with all values pre-converted for WSGI:
+%% {Method, ScriptName, PathInfo, QueryString, WsgiHeaders,
+%% ContentType, ContentLength, Body, Server, Client, Scheme, Protocol, State}
+%%
+%% WsgiHeaders is a map with HTTP_* keys already formatted.
+-spec build_wsgi_tuple(cowboy_req:req(), map()) -> tuple().
+build_wsgi_tuple(Req, State) ->
+ Method = cowboy_req:method(Req),
+ Path = cowboy_req:path(Req),
+ Qs = cowboy_req:qs(Req),
+ Headers = cowboy_req:headers(Req),
+ Host = cowboy_req:host(Req),
+ Port = cowboy_req:port(Req),
+ Scheme = cowboy_req:scheme(Req),
+ Version = cowboy_req:version(Req),
+ {ClientIp, ClientPort} = cowboy_req:peer(Req),
+
+ %% Get SCRIPT_NAME and PATH_INFO from state (multi-app) or defaults
+ ScriptName = maps:get(script_name, State, <<>>),
+ PathInfo = maps:get(path_info, State, Path),
+
+ %% Convert headers to WSGI format with Content-Type/Length extracted
+ {WsgiHeaders, ContentType, ContentLength} = convert_headers_wsgi(Headers),
+
+ %% Get lifespan state
+ LifespanState = hornbeam_lifespan:get_state(),
+
+ {
+ Method, % REQUEST_METHOD
+ ScriptName, % SCRIPT_NAME
+ PathInfo, % PATH_INFO
+ Qs, % QUERY_STRING
+ WsgiHeaders, % HTTP_* headers (pre-converted map)
+ ContentType, % CONTENT_TYPE (or undefined)
+ ContentLength, % CONTENT_LENGTH (or undefined)
+ undefined, % Body placeholder (passed via buffer)
+ {Host, Port}, % SERVER_NAME, SERVER_PORT
+ {format_ip(ClientIp), ClientPort}, % REMOTE_ADDR, REMOTE_PORT
+ Scheme, % wsgi.url_scheme
+ format_protocol(Version), % SERVER_PROTOCOL
+ LifespanState % Lifespan state
+ }.
+
+%% @doc Build an optimized ASGI scope map.
+%%
+%% Headers are pre-formatted as [[name, value], ...] list.
+%% All binary conversions done in Erlang.
+%% Handles mount_id and per-mount lifespan state for multi-app mode.
+-spec build_asgi_scope(cowboy_req:req(), map()) -> map().
+build_asgi_scope(Req, State) ->
+ Path = cowboy_req:path(Req),
+ Version = cowboy_req:version(Req),
+ {ClientIp, ClientPort} = cowboy_req:peer(Req),
+
+ %% Get root_path and path from state (multi-app) or defaults
+ RootPath = maps:get(script_name, State, <<>>),
+ ScopePath = maps:get(path_info, State, Path),
+
+ %% Convert headers to ASGI format [[name, value], ...]
+ HeaderList = maps:fold(fun(Name, Value, Acc) ->
+ [[Name, Value] | Acc]
+ end, [], cowboy_req:headers(Req)),
+
+ %% Get mount_id for per-mount state isolation (multi-app mode)
+ MountId = maps:get(mount_id, State, undefined),
+
+ %% Build scope map with all fields
+ %% Note: state is NOT included here - Python fetches lazily via callback
+ %% This avoids copying state dict on every request
+ BaseScope = #{
+ type => <<"http">>,
+ asgi => #{<<"version">> => <<"3.0">>, <<"spec_version">> => <<"2.4">>},
+ http_version => format_http_version(Version),
+ method => cowboy_req:method(Req),
+ scheme => cowboy_req:scheme(Req),
+ path => ScopePath,
+ raw_path => ScopePath,
+ query_string => cowboy_req:qs(Req),
+ root_path => RootPath,
+ headers => HeaderList,
+ server => {cowboy_req:host(Req), cowboy_req:port(Req)},
+ client => {format_ip(ClientIp), ClientPort},
+ extensions => build_extensions(Version)
+ },
+
+ %% Add mount_id to scope if in multi-app mode (used by Python to get correct state)
+ case MountId of
+ undefined -> BaseScope;
+ _ -> BaseScope#{mount_id => MountId}
+ end.
+
+%% @doc Convert header name to WSGI HTTP_* format.
+%% Example: "accept-encoding" becomes "HTTP_ACCEPT_ENCODING"
+-spec to_wsgi_header_key(binary()) -> binary().
+to_wsgi_header_key(Name) ->
+ Upper = to_upper_underscore(Name),
+ <<"HTTP_", Upper/binary>>.
+
+%% @doc Format IP address as binary string.
+-spec format_ip(inet:ip_address()) -> binary().
+format_ip({A, B, C, D}) ->
+ iolist_to_binary([
+ integer_to_list(A), $.,
+ integer_to_list(B), $.,
+ integer_to_list(C), $.,
+ integer_to_list(D)
+ ]);
+format_ip(Addr = {_, _, _, _, _, _, _, _}) ->
+ list_to_binary(inet:ntoa(Addr)).
+
+%% @doc Format HTTP version for ASGI.
+-spec format_http_version(atom()) -> binary().
+format_http_version('HTTP/1.0') -> <<"1.0">>;
+format_http_version('HTTP/1.1') -> <<"1.1">>;
+format_http_version('HTTP/2') -> <<"2">>.
+
+%%% ============================================================================
+%%% Internal functions
+%%% ============================================================================
+
+%% @private
+%% Convert headers to WSGI format, extracting Content-Type and Content-Length.
+%% Returns {WsgiHeadersMap, ContentType, ContentLength}
+convert_headers_wsgi(Headers) ->
+ maps:fold(fun(Name, Value, {Acc, CT, CL}) ->
+ case Name of
+ <<"content-type">> ->
+ {Acc, Value, CL};
+ <<"content-length">> ->
+ {Acc, CT, Value};
+ _ ->
+ Key = to_wsgi_header_key(Name),
+ {Acc#{Key => Value}, CT, CL}
+ end
+ end, {#{}, undefined, undefined}, Headers).
+
+%% @private
+%% Convert lowercase header to uppercase with underscores.
+%% Example: "accept-encoding" becomes "ACCEPT_ENCODING"
+to_upper_underscore(Bin) ->
+ << <<(upper_char(C))>> || <> <= Bin >>.
+
+upper_char(C) when C >= $a, C =< $z -> C - 32;
+upper_char($-) -> $_;
+upper_char(C) -> C.
+
+%% @private
+format_protocol('HTTP/1.0') -> <<"HTTP/1.0">>;
+format_protocol('HTTP/1.1') -> <<"HTTP/1.1">>;
+format_protocol('HTTP/2') -> <<"HTTP/2">>.
+
+%% @private
+build_extensions('HTTP/2') ->
+ #{
+ <<"http.response.trailers">> => #{},
+ <<"http.response.early_hints">> => #{}
+ };
+build_extensions(_) ->
+ #{
+ <<"http.response.early_hints">> => #{}
+ }.
diff --git a/src/hornbeam_sup.erl b/src/hornbeam_sup.erl
index 593f9f5..a6267a5 100644
--- a/src/hornbeam_sup.erl
+++ b/src/hornbeam_sup.erl
@@ -22,6 +22,7 @@
%%% - hornbeam_callbacks: Erlang callback registry
%%% - hornbeam_pubsub: Pub/sub messaging
%%% - hornbeam_lifespan: ASGI lifespan management
+%%% - hornbeam_context_pool: Python context pool
%%% - hornbeam_hooks: Hooks-style execution API
%%% - hornbeam_channel_registry: Channel topic pattern matching
%%% - hornbeam_presence: Distributed presence tracking (CRDT)
@@ -101,6 +102,14 @@ init([]) ->
type => worker,
modules => [hornbeam_lifespan]
},
+ #{
+ id => hornbeam_context_pool,
+ start => {hornbeam_context_pool, start_link, []},
+ restart => permanent,
+ shutdown => 10000,
+ type => worker,
+ modules => [hornbeam_context_pool]
+ },
#{
id => hornbeam_hooks,
start => {hornbeam_hooks, start_link, []},
diff --git a/test/test_apps/lifespan_test_app.py b/test/test_apps/lifespan_test_app.py
index 6b01682..48406b0 100644
--- a/test/test_apps/lifespan_test_app.py
+++ b/test/test_apps/lifespan_test_app.py
@@ -87,6 +87,9 @@ async def handle_lifespan(scope, receive, send):
scope["state"]["db_connection"] = "simulated_connection"
scope["state"]["cache"] = {}
scope["state"]["request_count"] = 0
+ # Store startup tracking in scope state (passed via Erlang ETS)
+ scope["state"]["startup_called"] = True
+ scope["state"]["startup_complete"] = True
_lifespan_state["startup_complete"] = True
@@ -137,13 +140,17 @@ async def handle_state(scope, receive, send):
_lifespan_state["request_count"] += 1
- # Collect state information
+ # Prefer scope state (passed via Erlang ETS) over module-level state
+ # since module-level state may not be shared across Python contexts
+ scope_state = scope.get("state", {})
+
+ # Collect state information - use scope state if available, fall back to module state
state_info = {
"module_state": {
- "startup_called": _lifespan_state["startup_called"],
- "startup_complete": _lifespan_state["startup_complete"],
+ "startup_called": scope_state.get("startup_called", _lifespan_state["startup_called"]),
+ "startup_complete": scope_state.get("startup_complete", _lifespan_state["startup_complete"]),
"shutdown_called": _lifespan_state["shutdown_called"],
- "startup_time": _lifespan_state["startup_time"],
+ "startup_time": scope_state.get("startup_time", _lifespan_state["startup_time"]),
"startup_count": _lifespan_state["startup_count"],
"request_count": _lifespan_state["request_count"],
},
@@ -183,15 +190,19 @@ async def handle_lifespan_info(scope, receive, send):
"""Return lifespan-specific information."""
await drain_body(receive)
+ # Prefer scope state (passed via Erlang ETS) over module-level state
+ scope_state = scope.get("state", {})
+
info = {
"lifespan_supported": True,
- "startup_complete": _lifespan_state["startup_complete"],
+ "startup_complete": scope_state.get("startup_complete", _lifespan_state["startup_complete"]),
"scope_state_present": "state" in scope,
"uptime_seconds": None,
}
- if _lifespan_state["startup_time"]:
- info["uptime_seconds"] = time.time() - _lifespan_state["startup_time"]
+ startup_time = scope_state.get("startup_time", _lifespan_state["startup_time"])
+ if startup_time:
+ info["uptime_seconds"] = time.time() - startup_time
if "state" in scope:
info["state_keys"] = list(scope["state"].keys())
@@ -252,7 +263,11 @@ async def handle_health(scope, receive, send):
"""Health check that verifies lifespan startup completed."""
await drain_body(receive)
- if not _lifespan_state["startup_complete"]:
+ # Prefer scope state (passed via Erlang ETS) over module-level state
+ scope_state = scope.get("state", {})
+ startup_complete = scope_state.get("startup_complete", _lifespan_state["startup_complete"])
+
+ if not startup_complete:
body = b"Lifespan not started"
status = 503
else: