From b40953a298671c449749b50c21277b1498636755 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 12 Mar 2026 12:38:00 +0100 Subject: [PATCH 01/52] Update erlang_python to latest main branch --- rebar.config | 2 +- rebar.lock | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/rebar.config b/rebar.config index 0043d41..88a2b0b 100644 --- a/rebar.config +++ b/rebar.config @@ -20,7 +20,7 @@ {deps, [ {cowboy, "2.12.0"}, - {erlang_python, "1.8.1"} + {erlang_python, {git, "https://github.com/benoitc/erlang_python.git", {branch, "main"}}} ]}. {shell, [ diff --git a/rebar.lock b/rebar.lock index ecd87bb..d0509f1 100644 --- a/rebar.lock +++ b/rebar.lock @@ -1,17 +1,18 @@ {"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}, + {<<"erlang_python">>, + {git,"https://github.com/benoitc/erlang_python.git", + {ref,"f37dfbe894b6d1c74e44b9cbe1720bb949467caa"}}, + 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">>}]} ]. From d56b266a2e5c4ec5cb7e23467c943af0d7da570f Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 12 Mar 2026 12:41:39 +0100 Subject: [PATCH 02/52] Update hackney to 3.2.1 --- rebar.config | 2 +- rebar.lock | 18 ------------------ 2 files changed, 1 insertion(+), 19 deletions(-) delete mode 100644 rebar.lock diff --git a/rebar.config b/rebar.config index 88a2b0b..3d6fdba 100644 --- a/rebar.config +++ b/rebar.config @@ -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 d0509f1..0000000 --- a/rebar.lock +++ /dev/null @@ -1,18 +0,0 @@ -{"1.2.0", -[{<<"cowboy">>,{pkg,<<"cowboy">>,<<"2.12.0">>},0}, - {<<"cowlib">>,{pkg,<<"cowlib">>,<<"2.13.0">>},1}, - {<<"erlang_python">>, - {git,"https://github.com/benoitc/erlang_python.git", - {ref,"f37dfbe894b6d1c74e44b9cbe1720bb949467caa"}}, - 0}, - {<<"ranch">>,{pkg,<<"ranch">>,<<"1.8.0">>},1}]}. -[ -{pkg_hash,[ - {<<"cowboy">>, <<"F276D521A1FF88B2B9B4C54D0E753DA6C66DD7BE6C9FCA3D9418B561828A3731">>}, - {<<"cowlib">>, <<"DB8F7505D8332D98EF50A3EF34B34C1AFDDEC7506E4EE4DD4A3A266285D282CA">>}, - {<<"ranch">>, <<"8C7A100A139FD57F17327B6413E4167AC559FBC04CA7448E9BE9057311597A1D">>}]}, -{pkg_hash_ext,[ - {<<"cowboy">>, <<"8A7ABE6D183372CEB21CAA2709BEC928AB2B72E18A3911AA1771639BEF82651E">>}, - {<<"cowlib">>, <<"E1E1284DC3FC030A64B1AD0D8382AE7E99DA46C3246B815318A4B848873800A4">>}, - {<<"ranch">>, <<"49FBCFD3682FAB1F5D109351B61257676DA1A2FDBE295904176D5E521A2DDFE5">>}]} -]. From 83460311d3e7d91daf642368fcfa79da1d4cb365 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 12 Mar 2026 14:44:21 +0100 Subject: [PATCH 03/52] Update to erlang_python new API - Fix erlang_python git repo URL (erlang-python not erlang_python) - Replace py:bind/py:unbind with py:context/py:contexts_started - Replace py:ctx_call with py:call - Replace py:with_context with direct py:call - Update py:call signatures to use options map for timeout --- rebar.config | 2 +- src/hornbeam_handler.erl | 20 +++++++++----------- src/hornbeam_lifespan.erl | 26 +++++++++++--------------- 3 files changed, 21 insertions(+), 27 deletions(-) diff --git a/rebar.config b/rebar.config index 3d6fdba..f840efa 100644 --- a/rebar.config +++ b/rebar.config @@ -20,7 +20,7 @@ {deps, [ {cowboy, "2.12.0"}, - {erlang_python, {git, "https://github.com/benoitc/erlang_python.git", {branch, "main"}}} + {erlang_python, {git, "https://github.com/benoitc/erlang-python.git", {branch, "main"}}} ]}. {shell, [ diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl index 8621741..a5526ee 100644 --- a/src/hornbeam_handler.erl +++ b/src/hornbeam_handler.erl @@ -146,7 +146,7 @@ run_wsgi_optimized(Req, AppModule, AppCallable, State) -> end. %% @private -%% Context-aware fallback path using py:ctx_call +%% Context-aware fallback path using py: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 @@ -159,8 +159,8 @@ run_wsgi_with_context(Req, AppModule, AppCallable, PyContext, TimeoutMs, State) undefined -> Environ; PathInfo -> Environ#{<<"PATH_INFO">> => PathInfo} end, - py:ctx_call(PyContext, hornbeam_wsgi_runner, run_wsgi, - [AppModule, AppCallable, Environ1], #{}, TimeoutMs). + py:call(PyContext, hornbeam_wsgi_runner, run_wsgi, + [AppModule, AppCallable, Environ1], #{timeout => TimeoutMs}). %% @private %% Build environ dict for NIF optimization. @@ -356,7 +356,7 @@ run_asgi_optimized(Req, AppModule, AppCallable, ReqBody, State) -> end. %% @private -%% Context-aware fallback path using py:ctx_call +%% Context-aware fallback path using py: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 @@ -369,8 +369,8 @@ run_asgi_with_context(Req, AppModule, AppCallable, ReqBody, PyContext, TimeoutMs undefined -> Scope; PathInfo -> Scope#{<<"path">> => PathInfo, <<"raw_path">> => PathInfo} end, - py:ctx_call(PyContext, hornbeam_asgi_runner, run_asgi, - [AppModule, AppCallable, Scope1, ReqBody], #{}, TimeoutMs). + py:call(PyContext, hornbeam_asgi_runner, run_asgi, + [AppModule, AppCallable, Scope1, ReqBody], #{timeout => TimeoutMs}). %% @private %% Bound context path - binds a worker for the request duration. @@ -388,11 +388,9 @@ run_asgi_bound(Req, AppModule, AppCallable, ReqBody, TimeoutMs, State) -> 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). + %% Call ASGI runner - context routing handled automatically by py:call + py:call(hornbeam_asgi_runner, run_asgi, + [AppModule, AppCallable, Scope1, ReqBody], #{}, TimeoutMs). %% @private %% Build scope with atom keys for NIF optimization. diff --git a/src/hornbeam_lifespan.erl b/src/hornbeam_lifespan.erl index c6ba3e7..b735d2b 100644 --- a/src/hornbeam_lifespan.erl +++ b/src/hornbeam_lifespan.erl @@ -155,11 +155,11 @@ init(Opts) -> {read_concurrency, true} ]), - %% Create a dedicated Python context for ASGI affinity + %% Get a Python context for ASGI affinity %% This ensures module-level state persists across requests - PyContext = case py:bind(new) of - {ok, Ctx} -> Ctx; - _ -> undefined + PyContext = case py:contexts_started() of + true -> py:context(); + false -> undefined end, %% Cache initial values @@ -261,12 +261,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 +289,10 @@ run_startup(AppModule, AppCallable, PyContext) -> Result = case PyContext of undefined -> py:call(hornbeam_lifespan_runner, startup, - [AppModule, AppCallable, TimeoutMs], #{}, TimeoutMs + 5000); + [AppModule, AppCallable, TimeoutMs], #{timeout => TimeoutMs + 5000}); Ctx -> - py:ctx_call(Ctx, hornbeam_lifespan_runner, startup, - [AppModule, AppCallable, TimeoutMs], #{}, TimeoutMs + 5000) + py:call(Ctx, hornbeam_lifespan_runner, startup, + [AppModule, AppCallable, TimeoutMs], #{timeout => TimeoutMs + 5000}) end, case Result of {ok, Response} -> @@ -337,10 +333,10 @@ run_shutdown(AppModule, AppCallable, PyContext) -> Result = case PyContext of undefined -> py:call(hornbeam_lifespan_runner, shutdown, - [AppModule, AppCallable], #{}, TimeoutMs); + [AppModule, AppCallable], #{timeout => TimeoutMs}); Ctx -> - py:ctx_call(Ctx, hornbeam_lifespan_runner, shutdown, - [AppModule, AppCallable], #{}, TimeoutMs) + py:call(Ctx, hornbeam_lifespan_runner, shutdown, + [AppModule, AppCallable], #{timeout => TimeoutMs}) end, case Result of {ok, Response} -> From e83367e302166f7e762159438fb6e1d54879374b Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 12 Mar 2026 14:48:04 +0100 Subject: [PATCH 04/52] Update erlang_python to hex 2.1.0 --- rebar.config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rebar.config b/rebar.config index f840efa..1899428 100644 --- a/rebar.config +++ b/rebar.config @@ -20,7 +20,7 @@ {deps, [ {cowboy, "2.12.0"}, - {erlang_python, {git, "https://github.com/benoitc/erlang-python.git", {branch, "main"}}} + {erlang_python, "2.1.0"} ]}. {shell, [ From 02a0ce3afe1c862c7eba544cbab658a36972da14 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 12 Mar 2026 15:47:51 +0100 Subject: [PATCH 05/52] Add request pre-parsing and BytesIO pooling for WSGI - Create hornbeam_request.erl for pre-parsing HTTP requests in Erlang - Add to_wsgi_header_key/1 for header format conversion - Add build_wsgi_tuple/2 and build_asgi_scope/2 functions - Add BytesIO pool to WSGI runner to reduce allocation overhead - Add environ template for O(1) environ creation - Add run_wsgi_fast/3 and create_environ_from_tuple/1 for fast path --- priv/hornbeam_wsgi_runner.py | 169 ++++++++++++++++++++++++++++++ src/hornbeam_request.erl | 192 +++++++++++++++++++++++++++++++++++ 2 files changed, 361 insertions(+) create mode 100644 src/hornbeam_request.erl diff --git a/priv/hornbeam_wsgi_runner.py b/priv/hornbeam_wsgi_runner.py index 957f367..94ec280 100644 --- a/priv/hornbeam_wsgi_runner.py +++ b/priv/hornbeam_wsgi_runner.py @@ -254,6 +254,44 @@ 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. @@ -427,3 +465,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/src/hornbeam_request.erl b/src/hornbeam_request.erl new file mode 100644 index 0000000..15daf69 --- /dev/null +++ b/src/hornbeam_request.erl @@ -0,0 +1,192 @@ +%% 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), + + %% Read body + {ok, Body, _Req2} = cowboy_req:read_body(Req), + + %% 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) + Body, % wsgi.input (raw bytes) + {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. +-spec build_asgi_scope(cowboy_req:req(), map()) -> map(). +build_asgi_scope(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), + + %% Convert headers to ASGI format [[name, value], ...] + 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) + }. + +%% @doc Convert header name to WSGI HTTP_* format. +%% "accept-encoding" -> <<"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. +%% "accept-encoding" -> <<"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">> => #{} + }. From fbc16996ca4443ab86efc72416e7548628d780ce Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 12 Mar 2026 15:50:09 +0100 Subject: [PATCH 06/52] Add ASGIResponse pooling for reduced allocation overhead - Add response object pool with reset() method - Add _get_response() and _return_response() pool functions - Pool size of 100 responses for high-throughput scenarios --- priv/hornbeam_asgi_runner.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/priv/hornbeam_asgi_runner.py b/priv/hornbeam_asgi_runner.py index 2a54f35..2909632 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.""" From c704e48c45eca4bcf1bb1a45c576d509641b0321 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 12 Mar 2026 15:53:33 +0100 Subject: [PATCH 07/52] Optimize create_environ to use template and BytesIO pool - Use _ENVIRON_TEMPLATE.copy() instead of inline dict creation - Use pooled BytesIO for wsgi.input - Return BytesIO to pool after request completion --- priv/hornbeam_wsgi_runner.py | 66 +++++++++++++++++------------------- 1 file changed, 31 insertions(+), 35 deletions(-) diff --git a/priv/hornbeam_wsgi_runner.py b/priv/hornbeam_wsgi_runner.py index 94ec280..c8b8389 100644 --- a/priv/hornbeam_wsgi_runner.py +++ b/priv/hornbeam_wsgi_runner.py @@ -297,7 +297,7 @@ 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 @@ -311,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 @@ -421,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) From 6902c37827ced8c64e26a4f2794f8979cef22f52 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 12 Mar 2026 19:59:54 +0100 Subject: [PATCH 08/52] Add persistent worker pool for WSGI/ASGI mounts Workers receive requests via channels and loop continuously, reducing Python startup overhead. Features heartbeat monitoring, scheduler affinity routing, and automatic restart on failure. Enable via mount config: pool_enabled => true --- benchmarks/bench_pooled_comparison.sh | 213 +++++++++ benchmarks/bench_wsgi_vs_asgi.sh | 230 +++++++++ priv/hornbeam_asgi_worker.py | 654 ++++++++++++++++++++++++++ priv/hornbeam_wsgi_worker.py | 640 +++++++++++++++++++++++++ src/hornbeam_handler.erl | 296 +++++++++++- src/hornbeam_mounts.erl | 62 ++- src/hornbeam_sup.erl | 8 + src/hornbeam_worker_arbiter.erl | 313 ++++++++++++ src/hornbeam_worker_pool.erl | 122 +++++ 9 files changed, 2535 insertions(+), 3 deletions(-) create mode 100755 benchmarks/bench_pooled_comparison.sh create mode 100755 benchmarks/bench_wsgi_vs_asgi.sh create mode 100644 priv/hornbeam_asgi_worker.py create mode 100644 priv/hornbeam_wsgi_worker.py create mode 100644 src/hornbeam_worker_arbiter.erl create mode 100644 src/hornbeam_worker_pool.erl 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..98d45ac --- /dev/null +++ b/benchmarks/bench_wsgi_vs_asgi.sh @@ -0,0 +1,230 @@ +#!/bin/bash +# Benchmark comparison: WSGI vs ASGI +# +# Compares performance between WSGI and ASGI worker classes + +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 + +# Check if project is compiled +if [ ! -d "_build/default/lib" ]; then + echo "Compiling hornbeam..." + rebar3 compile +fi + +# Configuration +REQUESTS=10000 +CONCURRENCY=100 +PORT_WSGI=8765 +PORT_ASGI=8766 + +cleanup() { + echo "Cleaning up..." + kill $PID_WSGI 2>/dev/null || true + kill $PID_ASGI 2>/dev/null || true + wait $PID_WSGI 2>/dev/null || true + wait $PID_ASGI 2>/dev/null || true +} +trap cleanup EXIT + +echo "==============================================" +echo " Hornbeam WSGI vs ASGI Benchmark" +echo "==============================================" +echo "" +echo "Configuration:" +echo " Requests: $REQUESTS" +echo " Concurrency: $CONCURRENCY" +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 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 + +# Verify servers are running +for port in $PORT_WSGI $PORT_ASGI; 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 +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" + +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" + +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" + +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" + +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 "" +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 + +echo "" +echo "Done!" diff --git a/priv/hornbeam_asgi_worker.py b/priv/hornbeam_asgi_worker.py new file mode 100644 index 0000000..223e053 --- /dev/null +++ b/priv/hornbeam_asgi_worker.py @@ -0,0 +1,654 @@ +# 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. + +"""Channel-based ASGI worker for hornbeam. + +This module provides a high-performance ASGI worker that uses channels +for communication with Erlang. It supports: + +- Async task execution with event loop integration +- Streaming responses via channel +- Long-lived tasks handling multiple requests + +Flow: +1. Erlang creates channel, sends scope/body, calls handle_request +2. Python receives request from channel +3. Python processes request with ASGI app +4. Python sends response back via reply() to caller +""" + +import asyncio +import importlib +import sys +import threading +from typing import Any, Callable, Dict, List, Optional, Tuple + +# Install erlang event loop policy +try: + from erlang_loop import get_event_loop_policy + asyncio.set_event_loop_policy(get_event_loop_policy()) +except (ImportError, RuntimeError): + pass + +try: + import erlang + from erlang import Channel, ChannelClosed, reply + HAS_ERLANG = True +except ImportError: + HAS_ERLANG = False + + +# Thread-safe app cache +_app_cache: Dict[Tuple[str, str], Callable] = {} +_app_cache_lock = threading.Lock() + +# Thread-local event loop +_thread_local = threading.local() + + +def _get_event_loop() -> asyncio.AbstractEventLoop: + """Get or create a persistent event loop for this thread.""" + loop = getattr(_thread_local, 'loop', None) + if loop is None or loop.is_closed(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + _thread_local.loop = loop + return loop + + +def _load_app(module_name: str, callable_name: str) -> Callable: + """Load an ASGI application with thread-safe caching.""" + cache_key = (module_name, callable_name) + + if cache_key in _app_cache: + return _app_cache[cache_key] + + with _app_cache_lock: + if cache_key in _app_cache: + return _app_cache[cache_key] + + if module_name not in sys.modules: + module = importlib.import_module(module_name) + else: + module = sys.modules[module_name] + + app = getattr(module, callable_name) + _app_cache[cache_key] = app + return app + + +def _to_str(val) -> str: + """Convert bytes/None to string.""" + if val is None: + return '' + if isinstance(val, bytes): + return val.decode('utf-8', errors='replace') + return str(val) if not isinstance(val, str) else val + + +# Pre-allocated message +_DISCONNECT_MSG = {'type': 'http.disconnect'} + + +class _ASGIResponse: + """Collects ASGI response messages.""" + __slots__ = ('status', 'headers', 'body_parts', 'more_body', + 'early_hints', 'streaming') + + def __init__(self): + self.status = None + self.headers = [] + self.body_parts = [] + self.more_body = False + self.early_hints = [] + self.streaming = False + + async def send(self, message: dict) -> None: + """ASGI send callable.""" + msg_type = message.get('type', '') + + if msg_type == 'http.response.start': + self.status = message.get('status', 200) + self.headers = message.get('headers', []) + + elif msg_type == 'http.response.body': + body_part = message.get('body', b'') + if isinstance(body_part, str): + body_part = body_part.encode('utf-8') + if body_part: + self.body_parts.append(body_part) + self.more_body = message.get('more_body', False) + + elif msg_type == 'http.response.informational': + status = message.get('status', 100) + headers = message.get('headers', []) + if status == 103: + self.early_hints.append(headers) + + +class _ReceiveCallable: + """Optimized receive callable for ASGI.""" + __slots__ = ('body', 'body_sent', '_request_msg') + + def __init__(self, body: bytes): + self.body = body + self.body_sent = False + self._request_msg = { + 'type': 'http.request', + 'body': body, + 'more_body': False + } + + async def __call__(self): + if not self.body_sent: + self.body_sent = True + return self._request_msg + return _DISCONNECT_MSG + + +class _StreamingResponse: + """Response handler that streams chunks to Erlang.""" + __slots__ = ('caller_pid', 'status', 'headers', 'headers_sent', 'early_hints') + + def __init__(self, caller_pid): + self.caller_pid = caller_pid + self.status = None + self.headers = [] + self.headers_sent = False + self.early_hints = [] + + async def send(self, message: dict) -> None: + """Stream response messages to Erlang via reply().""" + msg_type = message.get('type', '') + + if msg_type == 'http.response.start': + self.status = message.get('status', 200) + self.headers = message.get('headers', []) + # Send headers immediately for streaming + reply(self.caller_pid, ('headers', self.status, self.headers)) + self.headers_sent = True + + elif msg_type == 'http.response.body': + body_part = message.get('body', b'') + if isinstance(body_part, str): + body_part = body_part.encode('utf-8') + + if body_part: + reply(self.caller_pid, ('chunk', body_part)) + + more_body = message.get('more_body', False) + if not more_body: + reply(self.caller_pid, 'done') + + elif msg_type == 'http.response.informational': + status = message.get('status', 100) + headers = message.get('headers', []) + if status == 103: + self.early_hints.append(headers) + + +def handle_request(channel_ref, caller_pid, app_module: str, + app_callable: str) -> None: + """Handle an ASGI request using channel-based I/O. + + Args: + channel_ref: Reference to py_channel for receiving scope/body + caller_pid: Erlang PID to send response to + app_module: Python module containing ASGI app + app_callable: Name of ASGI callable in module + """ + if not HAS_ERLANG: + return + + ch = Channel(channel_ref) + + try: + # 1. Receive request from channel + msg = ch.receive() + + if not isinstance(msg, tuple) or len(msg) < 5: + reply(caller_pid, ('error', 'invalid request tuple')) + return + + tag = msg[0] + if tag != 'request': + reply(caller_pid, ('error', f'expected request, got {tag}')) + return + + # Unpack: (request, app_module, app_callable, scope, body) + _, _, _, scope, body = msg + + # 2. Ensure body is bytes + if isinstance(body, str): + body = body.encode('utf-8') + elif not isinstance(body, bytes): + body = b'' + + # 3. Load app + app = _load_app(app_module, app_callable) + + # 4. Create response collector and receive callable + response = _ASGIResponse() + receive = _ReceiveCallable(body) + + # 5. Run the app + loop = _get_event_loop() + coro = app(scope, receive, response.send) + loop.run_until_complete(coro) + + # 6. Send response to Erlang + status = response.status or 500 + headers = response.headers + body_bytes = b''.join(response.body_parts) + + if response.early_hints: + reply(caller_pid, ('response', status, headers, body_bytes, + response.early_hints)) + else: + reply(caller_pid, ('response', status, headers, body_bytes)) + + except ChannelClosed: + pass + except Exception as e: + try: + reply(caller_pid, ('error', str(e))) + except Exception: + pass + + +def handle_request_streaming(channel_ref, caller_pid, app_module: str, + app_callable: str) -> None: + """Handle an ASGI request with streaming response. + + This variant sends response chunks directly to Erlang as they're + produced, enabling real-time streaming (SSE, etc). + + Args: + channel_ref: Reference to py_channel for receiving scope/body + caller_pid: Erlang PID to send response to + app_module: Python module containing ASGI app + app_callable: Name of ASGI callable in module + """ + if not HAS_ERLANG: + return + + ch = Channel(channel_ref) + + try: + # 1. Receive request from channel + msg = ch.receive() + + if not isinstance(msg, tuple) or len(msg) < 5: + reply(caller_pid, ('error', 'invalid request tuple')) + return + + tag = msg[0] + if tag != 'request': + reply(caller_pid, ('error', f'expected request, got {tag}')) + return + + _, _, _, scope, body = msg + + # 2. Ensure body is bytes + if isinstance(body, str): + body = body.encode('utf-8') + elif not isinstance(body, bytes): + body = b'' + + # 3. Load app + app = _load_app(app_module, app_callable) + + # 4. Create streaming response handler + response = _StreamingResponse(caller_pid) + receive = _ReceiveCallable(body) + + # 5. Run the app - responses stream as they're produced + loop = _get_event_loop() + coro = app(scope, receive, response.send) + loop.run_until_complete(coro) + + # 6. Ensure completion is signaled if not already + if not response.headers_sent: + reply(caller_pid, ('headers', 500, [])) + reply(caller_pid, 'done') + + except ChannelClosed: + pass + except Exception as e: + try: + reply(caller_pid, ('error', str(e))) + except Exception: + pass + + +def handle_request_fast(caller_pid, app_module: str, app_callable: str, + scope: dict, body: bytes) -> None: + """Handle an ASGI request directly (no channel). + + This is the fastest path when channel overhead isn't needed. + + Args: + caller_pid: Erlang PID to send response to + app_module: Python module containing ASGI app + app_callable: Name of ASGI callable in module + scope: ASGI scope dict + body: Request body bytes + """ + if not HAS_ERLANG: + return + + try: + # 1. Ensure body is bytes + if isinstance(body, str): + body = body.encode('utf-8') + elif not isinstance(body, bytes): + body = b'' + + # 2. Load app + app = _load_app(app_module, app_callable) + + # 3. Create response collector and receive callable + response = _ASGIResponse() + receive = _ReceiveCallable(body) + + # 4. Run the app + loop = _get_event_loop() + coro = app(scope, receive, response.send) + loop.run_until_complete(coro) + + # 5. Send response to Erlang + status = response.status or 500 + headers = response.headers + body_bytes = b''.join(response.body_parts) + + if response.early_hints: + reply(caller_pid, ('response', status, headers, body_bytes, + response.early_hints)) + else: + reply(caller_pid, ('response', status, headers, body_bytes)) + + except Exception as e: + try: + reply(caller_pid, ('error', str(e))) + except Exception: + pass + + +# ============================================================================= +# Long-lived task support (for handling multiple requests per context) +# ============================================================================= + +class ASGITask: + """Long-lived async task that processes multiple requests. + + This task stays alive and receives work from a work channel, + processing requests concurrently using asyncio. + """ + + def __init__(self, work_channel_ref): + self.work_channel = Channel(work_channel_ref) + self.running = True + + async def run(self): + """Main task loop - receives and processes requests.""" + while self.running: + try: + # Wait for work from channel + msg = self.work_channel.receive() + + if msg == 'stop': + self.running = False + break + + if isinstance(msg, tuple) and msg[0] == 'work': + # (work, request_channel_ref) + request_ch_ref = msg[1] + # Process concurrently + asyncio.create_task(self._handle_request(request_ch_ref)) + + except ChannelClosed: + self.running = False + break + + async def _handle_request(self, request_ch_ref): + """Handle a single request from its channel.""" + ch = Channel(request_ch_ref) + + try: + msg = ch.receive() + + if not isinstance(msg, tuple) or len(msg) < 5: + return + + tag, app_module, app_callable, scope, body, caller_pid = msg + + if isinstance(body, str): + body = body.encode('utf-8') + elif not isinstance(body, bytes): + body = b'' + + app = _load_app(app_module, app_callable) + response = _ASGIResponse() + receive = _ReceiveCallable(body) + + await app(scope, receive, response.send) + + status = response.status or 500 + headers = response.headers + body_bytes = b''.join(response.body_parts) + + reply(caller_pid, ('response', status, headers, body_bytes)) + + except Exception as e: + try: + # Best effort error reporting + if 'caller_pid' in dir(): + reply(caller_pid, ('error', str(e))) + except Exception: + pass + + +def run_asgi_task(work_channel_ref) -> None: + """Start a long-lived ASGI task. + + This function runs until the work channel is closed or 'stop' is received. + + Args: + work_channel_ref: Channel reference for receiving work items + """ + if not HAS_ERLANG: + return + + task = ASGITask(work_channel_ref) + loop = _get_event_loop() + loop.run_until_complete(task.run()) + + +# ============================================================================= +# Persistent worker loop (for hornbeam_worker_pool) +# ============================================================================= + +def worker_loop(channel_ref, arbiter_pid, worker_id: str, + app_module: str, app_callable: str) -> str: + """Persistent ASGI worker - loops until stopped. + + This worker receives requests via a channel and processes them continuously, + reducing Python startup overhead for each request. + + Args: + channel_ref: Reference to the channel for receiving requests + arbiter_pid: Erlang PID of the arbiter for heartbeat messages + app_module: Python module containing ASGI app + app_callable: Name of ASGI callable in module + worker_id: Unique identifier for this worker (format: mount_id_idx) + + Returns: + 'stopped' when the worker exits cleanly + """ + if not HAS_ERLANG: + return 'no_erlang' + + loop = _get_event_loop() + result = loop.run_until_complete( + _async_worker_loop(channel_ref, arbiter_pid, worker_id, + app_module, app_callable) + ) + return result + + +async def _async_worker_loop(channel_ref, arbiter_pid, worker_id: str, + app_module: str, app_callable: str) -> str: + """Async implementation of the persistent ASGI worker loop.""" + import time + + ch = Channel(channel_ref) + app = _load_app(app_module, app_callable) + last_heartbeat = time.monotonic() + heartbeat_interval = 5.0 # seconds + + while True: + # Maybe send heartbeat + now = time.monotonic() + if now - last_heartbeat >= heartbeat_interval: + try: + reply(arbiter_pid, ('heartbeat', worker_id)) + except Exception: + pass # Arbiter might be restarting + last_heartbeat = now + + # Receive next message (blocking in executor to release event loop) + try: + msg = await asyncio.get_event_loop().run_in_executor(None, ch.receive) + except ChannelClosed: + break + + # Handle control messages + if msg == 'stop': + break + + # Handle request messages + if isinstance(msg, tuple) and len(msg) >= 2: + tag = msg[0] + + if tag == 'start_request': + # (start_request, caller_pid, scope, body) + _, caller_pid, scope, body = msg + await _process_asgi_request(caller_pid, app, scope, body) + + elif tag == 'start_request_streaming': + # (start_request_streaming, caller_pid, scope) + _, caller_pid, scope = msg + body = await _receive_streaming_body_async(ch) + await _process_asgi_request(caller_pid, app, scope, body) + + return 'stopped' + + +async def _receive_streaming_body_async(ch) -> bytes: + """Receive streaming body chunks from channel asynchronously.""" + chunks = [] + loop = asyncio.get_event_loop() + + while True: + try: + msg = await loop.run_in_executor(None, ch.receive) + except ChannelClosed: + break + + if msg == 'body_done': + break + elif isinstance(msg, tuple) and msg[0] == 'body_chunk': + chunk = msg[1] + if isinstance(chunk, bytes): + chunks.append(chunk) + elif isinstance(chunk, str): + chunks.append(chunk.encode('utf-8')) + + return b''.join(chunks) + + +async def _process_asgi_request(caller_pid, app, scope: dict, body: bytes) -> None: + """Process a single ASGI request and send response to caller.""" + try: + # Ensure body is bytes + if isinstance(body, str): + body = body.encode('utf-8') + elif not isinstance(body, bytes): + body = b'' + + # Create response collector and receive callable + response = _ASGIResponse() + receive = _ReceiveCallable(body) + + # Run the ASGI app + await app(scope, receive, response.send) + + # Collect response + status = response.status or 500 + headers = response.headers + body_bytes = b''.join(response.body_parts) + + # Send buffered response (small responses) + if response.early_hints: + reply(caller_pid, ('response', status, headers, body_bytes, + response.early_hints)) + else: + reply(caller_pid, ('response', status, headers, body_bytes)) + + except Exception as e: + try: + reply(caller_pid, ('error', str(e))) + except Exception: + pass + + +class _StreamingASGIResponse: + """Response handler that streams chunks to Erlang for pooled workers.""" + __slots__ = ('caller_pid', 'status', 'headers', 'headers_sent', 'early_hints') + + def __init__(self, caller_pid): + self.caller_pid = caller_pid + self.status = None + self.headers = [] + self.headers_sent = False + self.early_hints = [] + + async def send(self, message: dict) -> None: + """Stream response messages to Erlang via reply().""" + msg_type = message.get('type', '') + + if msg_type == 'http.response.start': + self.status = message.get('status', 200) + self.headers = message.get('headers', []) + # Send headers immediately + reply(self.caller_pid, ('headers', self.status, self.headers)) + self.headers_sent = True + + elif msg_type == 'http.response.body': + body_part = message.get('body', b'') + if isinstance(body_part, str): + body_part = body_part.encode('utf-8') + + if body_part: + reply(self.caller_pid, ('chunk', body_part)) + + more_body = message.get('more_body', False) + if not more_body: + reply(self.caller_pid, 'done') + + elif msg_type == 'http.response.informational': + status = message.get('status', 100) + headers = message.get('headers', []) + if status == 103: + self.early_hints.append(headers) diff --git a/priv/hornbeam_wsgi_worker.py b/priv/hornbeam_wsgi_worker.py new file mode 100644 index 0000000..92f5c6e --- /dev/null +++ b/priv/hornbeam_wsgi_worker.py @@ -0,0 +1,640 @@ +# 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. + +"""Channel-based WSGI worker for hornbeam. + +This module provides a high-performance WSGI worker that uses channels +for communication with Erlang. All I/O flows through the channel, +enabling efficient streaming and backpressure handling. + +Flow: +1. Erlang creates channel, sends environ, calls handle_request +2. Python receives environ from channel +3. Python processes request with WSGI app +4. Python sends headers/body chunks back via reply() to caller pid +5. Python signals completion with 'done' +""" + +import io +import threading +from typing import Any, Callable, Dict, List, Optional, Tuple + +try: + import erlang + from erlang import Channel, ChannelClosed, reply + HAS_ERLANG = True +except ImportError: + HAS_ERLANG = False + + +# Pre-allocated error wrapper for wsgi.errors +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 + + +# Shared instances +_SHARED_ERRORS = _WSGIErrorsWrapper() +_WSGI_VERSION = (1, 0) + + +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 + + +# BytesIO pool for wsgi.input +_BYTESIO_POOL: List[io.BytesIO] = [] +_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.""" + with _BYTESIO_POOL_LOCK: + if len(_BYTESIO_POOL) < _BYTESIO_POOL_SIZE: + _BYTESIO_POOL.append(bio) + + +# Environ template (shared base) +_ENVIRON_TEMPLATE = { + 'wsgi.version': _WSGI_VERSION, + 'wsgi.multithread': True, + 'wsgi.multiprocess': True, + 'wsgi.run_once': False, + 'wsgi.file_wrapper': FileWrapper, + 'wsgi.input_terminated': True, +} + + +# Thread-safe app cache +_app_cache: Dict[Tuple[str, str], Callable] = {} +_app_cache_lock = threading.Lock() + + +def _load_app(module_name: str, callable_name: str) -> Callable: + """Load a WSGI application with thread-safe caching.""" + cache_key = (module_name, callable_name) + + if cache_key in _app_cache: + return _app_cache[cache_key] + + with _app_cache_lock: + if cache_key in _app_cache: + return _app_cache[cache_key] + + import importlib + import sys + + if module_name not in sys.modules: + module = importlib.import_module(module_name) + else: + module = sys.modules[module_name] + + app = getattr(module, callable_name) + _app_cache[cache_key] = app + return app + + +def _to_str(val) -> str: + """Convert bytes/None to string.""" + if val is None: + return '' + if val.__class__.__name__ == 'Atom' or val == b'undefined': + return '' + if isinstance(val, bytes): + return val.decode('utf-8', errors='replace') + return str(val) if not isinstance(val, str) else val + + +def _is_none(val) -> bool: + """Check if value is None or Erlang's undefined atom.""" + return val is None or val == b'undefined' or ( + val.__class__.__name__ == 'Atom' and str(val) == 'undefined' + ) + + +def _create_environ(req_tuple) -> dict: + """Create WSGI environ from pre-parsed Erlang tuple. + + Args: + req_tuple: (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 + + # Handle body + if body is None or body == b'' or body == '': + body_bytes = b'' + elif isinstance(body, bytes): + body_bytes = body + elif isinstance(body, str): + body_bytes = body.encode('utf-8') + else: + try: + body_bytes = bytes(body) + except (TypeError, ValueError): + body_bytes = b'' + + wsgi_input = _get_bytesio(body_bytes) + + # Early hints callback + early_hints_list = [] + + def early_hints_callback(headers): + early_hints_list.append(headers) + + # Build environ from template + environ = _ENVIRON_TEMPLATE.copy() + + environ['REQUEST_METHOD'] = _to_str(method) + environ['SCRIPT_NAME'] = _to_str(script_name) if script_name else '' + environ['PATH_INFO'] = _to_str(path_info) + environ['QUERY_STRING'] = _to_str(query_string) + environ['SERVER_NAME'] = _to_str(server[0]) + environ['SERVER_PORT'] = str(server[1]) + environ['SERVER_PROTOCOL'] = _to_str(protocol) + environ['wsgi.url_scheme'] = _to_str(scheme) + environ['wsgi.input'] = wsgi_input + environ['wsgi.errors'] = _SHARED_ERRORS + environ['wsgi.early_hints'] = early_hints_callback + environ['REMOTE_ADDR'] = _to_str(client[0]) + environ['REMOTE_PORT'] = str(client[1]) + environ['_hornbeam.early_hints'] = early_hints_list + environ['_hornbeam.wsgi_input'] = wsgi_input + environ['_hornbeam.lifespan_state'] = lifespan_state + + # Add HTTP_* headers (pre-converted by Erlang) + if wsgi_headers: + for key, value in wsgi_headers.items(): + environ[_to_str(key)] = _to_str(value) + + # Add content-type/length + if not _is_none(content_type): + environ['CONTENT_TYPE'] = _to_str(content_type) + if not _is_none(content_length): + environ['CONTENT_LENGTH'] = _to_str(content_length) + + return environ + + +class _Response: + """WSGI response handler.""" + __slots__ = ('status', 'headers', '_write_buffer') + + def __init__(self): + self.status = None + 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.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 handle_request(channel_ref, caller_pid, app_module: str, app_callable: str) -> None: + """Handle a WSGI request using channel-based I/O. + + This is the main entry point called from Erlang. It: + 1. Receives environ from channel + 2. Processes request with WSGI app + 3. Sends response back via reply() to caller + + Args: + channel_ref: Reference to py_channel for receiving environ + caller_pid: Erlang PID to send response to + app_module: Python module containing WSGI app + app_callable: Name of WSGI callable in module + """ + if not HAS_ERLANG: + return + + ch = Channel(channel_ref) + wsgi_input = None + + try: + # 1. Receive environ from channel + msg = ch.receive() + + if not isinstance(msg, tuple) or len(msg) < 2: + reply(caller_pid, ('error', 'expected environ tuple')) + return + + tag, req_tuple = msg[0], msg[1] + if tag != 'environ': + reply(caller_pid, ('error', f'expected environ, got {tag}')) + return + + # 2. Build WSGI environ + environ = _create_environ(req_tuple) + wsgi_input = environ.get('_hornbeam.wsgi_input') + + # 3. Load and call WSGI app + app = _load_app(app_module, app_callable) + response = _Response() + + result = app(environ, response.start_response) + + # 4. Parse status code + status_code = 500 + if response.status: + try: + status_str = response.status + if isinstance(status_str, bytes): + status_str = status_str.decode('utf-8') + parts = status_str.split(' ', 1) + status_code = int(parts[0]) + except (ValueError, IndexError): + pass + + # 5. Send headers via reply + reply(caller_pid, ('headers', status_code, response.headers)) + + # 6. Send any write() buffer content first + for chunk in response._write_buffer: + if chunk: + if isinstance(chunk, str): + chunk = chunk.encode('utf-8') + reply(caller_pid, ('chunk', chunk)) + + # 7. Stream body chunks + try: + if isinstance(result, (bytes, bytearray)): + reply(caller_pid, ('chunk', bytes(result))) + else: + for chunk in result: + if chunk: + if isinstance(chunk, str): + chunk = chunk.encode('utf-8') + elif isinstance(chunk, bytearray): + chunk = bytes(chunk) + reply(caller_pid, ('chunk', chunk)) + finally: + if hasattr(result, 'close'): + result.close() + + # 8. Signal completion + reply(caller_pid, 'done') + + except ChannelClosed: + # Channel was closed, nothing to do + pass + except Exception as e: + try: + reply(caller_pid, ('error', str(e))) + except Exception: + pass + finally: + # Return BytesIO to pool + if wsgi_input is not None: + _return_bytesio(wsgi_input) + + +def worker_loop(channel_ref, arbiter_pid, worker_id: str, + app_module: str, app_callable: str) -> str: + """Persistent WSGI worker - loops until stopped. + + This worker receives requests via a channel and processes them continuously, + reducing Python startup overhead for each request. + + Args: + channel_ref: Reference to the channel for receiving requests + arbiter_pid: Erlang PID of the arbiter for heartbeat messages + app_module: Python module containing WSGI app + app_callable: Name of WSGI callable in module + worker_id: Unique identifier for this worker (format: mount_id_idx) + + Returns: + 'stopped' when the worker exits cleanly + """ + if not HAS_ERLANG: + return 'no_erlang' + + import time + + ch = Channel(channel_ref) + app = _load_app(app_module, app_callable) + last_heartbeat = time.monotonic() + heartbeat_interval = 5.0 # seconds + + while True: + # Maybe send heartbeat + now = time.monotonic() + if now - last_heartbeat >= heartbeat_interval: + try: + reply(arbiter_pid, ('heartbeat', worker_id)) + except Exception: + pass # Arbiter might be restarting + last_heartbeat = now + + # Receive next message (blocking, releases GIL) + try: + msg = ch.receive() + except ChannelClosed: + break + + # Handle control messages + if msg == 'stop': + break + + # Handle request messages + if isinstance(msg, tuple) and len(msg) >= 2: + tag = msg[0] + + if tag == 'start_request': + # (start_request, caller_pid, req_info, body) + _, caller_pid, req_info, body = msg + _process_wsgi_request(caller_pid, app, req_info, body) + + elif tag == 'start_request_streaming': + # (start_request_streaming, caller_pid, req_info) + _, caller_pid, req_info = msg + body = _receive_streaming_body(ch) + _process_wsgi_request(caller_pid, app, req_info, body) + + return 'stopped' + + +def _receive_streaming_body(ch) -> bytes: + """Receive streaming body chunks from channel.""" + chunks = [] + while True: + try: + msg = ch.receive() + except ChannelClosed: + break + + if msg == 'body_done': + break + elif isinstance(msg, tuple) and msg[0] == 'body_chunk': + chunk = msg[1] + if isinstance(chunk, bytes): + chunks.append(chunk) + elif isinstance(chunk, str): + chunks.append(chunk.encode('utf-8')) + + return b''.join(chunks) + + +def _process_wsgi_request(caller_pid, app, req_info, body) -> None: + """Process a single WSGI request and send response to caller.""" + wsgi_input = None + + try: + # Build environ with body + req_tuple = _build_req_tuple_with_body(req_info, body) + environ = _create_environ(req_tuple) + wsgi_input = environ.get('_hornbeam.wsgi_input') + + # Create response handler + response = _Response() + + # Call WSGI app + result = app(environ, response.start_response) + + # Parse status code + status_code = 500 + if response.status: + try: + status_str = response.status + if isinstance(status_str, bytes): + status_str = status_str.decode('utf-8') + parts = status_str.split(' ', 1) + status_code = int(parts[0]) + except (ValueError, IndexError): + pass + + # Send headers + reply(caller_pid, ('headers', status_code, response.headers)) + + # Send write() buffer content first + for chunk in response._write_buffer: + if chunk: + if isinstance(chunk, str): + chunk = chunk.encode('utf-8') + reply(caller_pid, ('chunk', chunk)) + + # Stream body chunks + try: + if isinstance(result, (bytes, bytearray)): + reply(caller_pid, ('chunk', bytes(result))) + else: + for chunk in result: + if chunk: + if isinstance(chunk, str): + chunk = chunk.encode('utf-8') + elif isinstance(chunk, bytearray): + chunk = bytes(chunk) + reply(caller_pid, ('chunk', chunk)) + finally: + if hasattr(result, 'close'): + result.close() + + # Signal completion + reply(caller_pid, 'done') + + except Exception as e: + try: + reply(caller_pid, ('error', str(e))) + except Exception: + pass + finally: + # Return BytesIO to pool + if wsgi_input is not None: + _return_bytesio(wsgi_input) + + +def _build_req_tuple_with_body(req_info, body) -> tuple: + """Build request tuple from req_info dict and body. + + Args: + req_info: Dict with request metadata (method, path, headers, etc.) + body: Request body bytes + + Returns: + Tuple in the format expected by _create_environ + """ + # Handle body + if body is None or body == b'': + body_bytes = b'' + elif isinstance(body, bytes): + body_bytes = body + elif isinstance(body, str): + body_bytes = body.encode('utf-8') + else: + body_bytes = b'' + + # Extract fields from req_info (map from Erlang) + method = req_info.get(b'method', b'GET') + script_name = req_info.get(b'script_name', b'') + path_info = req_info.get(b'path_info', b'/') + query_string = req_info.get(b'query_string', b'') + wsgi_headers = req_info.get(b'wsgi_headers', {}) + content_type = req_info.get(b'content_type') + content_length = req_info.get(b'content_length') + server = req_info.get(b'server', (b'localhost', 80)) + client = req_info.get(b'client', (b'127.0.0.1', 0)) + scheme = req_info.get(b'scheme', b'http') + protocol = req_info.get(b'protocol', b'HTTP/1.1') + lifespan_state = req_info.get(b'lifespan_state', {}) + + return ( + method, script_name, path_info, query_string, wsgi_headers, + content_type, content_length, body_bytes, server, client, scheme, + protocol, lifespan_state + ) + + +def handle_request_fast(caller_pid, app_module: str, app_callable: str, + req_tuple) -> None: + """Handle a WSGI request with pre-parsed tuple (no channel). + + This is an alternative entry point that receives the request tuple + directly, bypassing channel overhead for simple requests. + + Args: + caller_pid: Erlang PID to send response to + app_module: Python module containing WSGI app + app_callable: Name of WSGI callable in module + req_tuple: Pre-parsed request tuple from Erlang + """ + if not HAS_ERLANG: + return + + wsgi_input = None + + try: + # 1. Build WSGI environ directly from tuple + environ = _create_environ(req_tuple) + wsgi_input = environ.get('_hornbeam.wsgi_input') + + # 2. Load and call WSGI app + app = _load_app(app_module, app_callable) + response = _Response() + + result = app(environ, response.start_response) + + # 3. Parse status code + status_code = 500 + if response.status: + try: + status_str = response.status + if isinstance(status_str, bytes): + status_str = status_str.decode('utf-8') + parts = status_str.split(' ', 1) + status_code = int(parts[0]) + except (ValueError, IndexError): + pass + + # 4. Send headers + reply(caller_pid, ('headers', status_code, response.headers)) + + # 5. Send write() buffer + for chunk in response._write_buffer: + if chunk: + if isinstance(chunk, str): + chunk = chunk.encode('utf-8') + reply(caller_pid, ('chunk', chunk)) + + # 6. Stream body + try: + if isinstance(result, (bytes, bytearray)): + reply(caller_pid, ('chunk', bytes(result))) + else: + for chunk in result: + if chunk: + if isinstance(chunk, str): + chunk = chunk.encode('utf-8') + elif isinstance(chunk, bytearray): + chunk = bytes(chunk) + reply(caller_pid, ('chunk', chunk)) + finally: + if hasattr(result, 'close'): + result.close() + + # 7. Signal completion + reply(caller_pid, 'done') + + except Exception as e: + try: + reply(caller_pid, ('error', str(e))) + except Exception: + pass + finally: + if wsgi_input is not None: + _return_bytesio(wsgi_input) diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl index a5526ee..03dd7fa 100644 --- a/src/hornbeam_handler.erl +++ b/src/hornbeam_handler.erl @@ -39,7 +39,11 @@ 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, + %% Pool settings (for persistent worker dispatch) + pool_enabled => maps:get(pool_enabled, Mount, false), + mount_id => maps:get(mount_id, Mount, undefined), + workers => maps:get(workers, Mount, 4) }, WorkerClass = maps:get(worker_class, Mount), handle_request(WorkerClass, Req, NewState); @@ -84,7 +88,53 @@ handle_websocket_upgrade(Req, State) -> %%% WSGI Handler %%% ============================================================================ +handle_wsgi(Req, #{pool_enabled := true, mount_id := MountId} = State) -> + %% Pooled worker path - dispatch to persistent worker via channel + handle_wsgi_pooled(Req, MountId, State); handle_wsgi(Req, State) -> + %% Non-pooled path - existing behavior + handle_wsgi_direct(Req, State). + +%% @private +%% Dispatch WSGI request to persistent worker pool via channel +handle_wsgi_pooled(Req, MountId, State) -> + ReqInfo = build_request_info(Req), + ReqInfo1 = hornbeam_http_hooks:run_on_request(ReqInfo), + + try + TimeoutMs = maps:get(timeout, State, 30000), + + %% Get channel via scheduler affinity + SchedId = erlang:system_info(scheduler_id), + Channel = hornbeam_worker_pool:get_channel(MountId, SchedId), + + %% Build request info for Python worker + ReqTuple = build_pooled_request_info(Req, State), + + %% Check content-length to decide streaming vs buffered + ContentLength = cowboy_req:header(<<"content-length">>, Req), + case should_buffer_body(ContentLength) of + true -> + %% Small body - read fully and send in single message + {ok, Body, Req2} = cowboy_req:read_body(Req), + py_channel:send(Channel, {start_request, self(), ReqTuple, Body}), + receive_wsgi_response(Req2, ReqInfo1, TimeoutMs, State); + false -> + %% Large/unknown body - stream chunks + py_channel:send(Channel, {start_request_streaming, self(), ReqTuple}), + Req2 = stream_body_to_channel(Channel, Req), + receive_wsgi_response(Req2, ReqInfo1, TimeoutMs, State) + end + catch + Class:Reason:Stack -> + error_logger:error_msg("WSGI pooled handler error: ~p:~p~n~p~n", + [Class, Reason, Stack]), + handle_error(Req, {Class, Reason}, ReqInfo1, State) + end. + +%% @private +%% Original direct WSGI handler (non-pooled path) +handle_wsgi_direct(Req, State) -> %% Build initial request map for hooks ReqInfo = build_request_info(Req), @@ -285,7 +335,53 @@ parse_status_code(Status) when is_integer(Status) -> %%% ASGI Handler %%% ============================================================================ +handle_asgi(Req, #{pool_enabled := true, mount_id := MountId} = State) -> + %% Pooled worker path - dispatch to persistent worker via channel + handle_asgi_pooled(Req, MountId, State); handle_asgi(Req, State) -> + %% Non-pooled path - existing behavior + handle_asgi_direct(Req, State). + +%% @private +%% Dispatch ASGI request to persistent worker pool via channel +handle_asgi_pooled(Req, MountId, State) -> + ReqInfo = build_request_info(Req), + ReqInfo1 = hornbeam_http_hooks:run_on_request(ReqInfo), + + try + TimeoutMs = maps:get(timeout, State, 30000), + + %% Get channel via scheduler affinity + SchedId = erlang:system_info(scheduler_id), + Channel = hornbeam_worker_pool:get_channel(MountId, SchedId), + + %% Build ASGI scope for Python worker + Scope = build_scope_for_nif(Req, State), + + %% Check content-length to decide streaming vs buffered + ContentLength = cowboy_req:header(<<"content-length">>, Req), + case should_buffer_body(ContentLength) of + true -> + %% Small body - read fully and send in single message + {ok, Body, Req2} = cowboy_req:read_body(Req), + py_channel:send(Channel, {start_request, self(), Scope, Body}), + receive_asgi_response(Req2, ReqInfo1, TimeoutMs, State); + false -> + %% Large/unknown body - stream chunks + py_channel:send(Channel, {start_request_streaming, self(), Scope}), + Req2 = stream_body_to_channel(Channel, Req), + receive_asgi_response(Req2, ReqInfo1, TimeoutMs, State) + end + catch + Class:Reason:Stack -> + error_logger:error_msg("ASGI pooled handler error: ~p:~p~n~p~n", + [Class, Reason, Stack]), + handle_error(Req, {Class, Reason}, ReqInfo1, State) + end. + +%% @private +%% Original direct ASGI handler (non-pooled path) +handle_asgi_direct(Req, State) -> %% Build initial request map for hooks ReqInfo = build_request_info(Req), @@ -551,6 +647,204 @@ 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)). +%%% ============================================================================ +%%% Pooled Worker Dispatch Helpers +%%% ============================================================================ + +%% @private +%% Determine if body should be buffered (small) or streamed (large/unknown) +%% Buffer threshold is 16KB +should_buffer_body(undefined) -> + %% Unknown length - stream it + false; +should_buffer_body(ContentLengthBin) -> + try + ContentLength = binary_to_integer(ContentLengthBin), + ContentLength =< 16384 + catch + _:_ -> false + end. + +%% @private +%% Stream request body chunks to the worker channel +stream_body_to_channel(Channel, Req) -> + case cowboy_req:read_body(Req) of + {ok, Data, Req2} -> + py_channel:send(Channel, {body_chunk, Data}), + py_channel:send(Channel, body_done), + Req2; + {more, Data, Req2} -> + py_channel:send(Channel, {body_chunk, Data}), + stream_body_to_channel(Channel, Req2) + end. + +%% @private +%% Build request info map for WSGI pooled workers +build_pooled_request_info(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), + + %% Build HTTP_* headers (pre-converted for Python) + WsgiHeaders = maps:fold(fun(Name, Value, Acc) -> + HeaderKey = header_to_wsgi_key(Name), + Acc#{HeaderKey => Value} + end, #{}, Headers), + + %% Extract content-type and content-length + ContentType = maps:get(<<"content-type">>, Headers, undefined), + ContentLength = maps:get(<<"content-length">>, Headers, undefined), + + %% Get lifespan state + LifespanState = hornbeam_lifespan:get_state(), + + #{ + method => Method, + script_name => ScriptName, + path_info => PathInfo, + query_string => Qs, + wsgi_headers => WsgiHeaders, + content_type => ContentType, + content_length => ContentLength, + server => {Host, Port}, + client => {format_ip(ClientIp), ClientPort}, + scheme => Scheme, + protocol => format_protocol(Version), + lifespan_state => LifespanState + }. + +%% @private +%% Receive WSGI response from pooled worker +receive_wsgi_response(Req, ReqInfo, TimeoutMs, State) -> + receive + {headers, StatusCode, Headers} -> + %% Got headers - now receive body chunks + CowboyHeaders = convert_headers(Headers), + receive_wsgi_body(Req, StatusCode, CowboyHeaders, [], TimeoutMs, State); + {response, StatusCode, Headers, Body} -> + %% Buffered response (single message) + Response = #{ + <<"status">> => StatusCode, + <<"headers">> => Headers, + <<"body">> => Body + }, + Response1 = hornbeam_http_hooks:run_on_response(Response), + send_wsgi_response(Req, Response1, State); + {error, Reason} -> + handle_error(Req, Reason, ReqInfo, State) + after TimeoutMs -> + handle_error(Req, timeout, ReqInfo, State) + end. + +%% @private +%% Receive WSGI body chunks from pooled worker +receive_wsgi_body(Req, StatusCode, CowboyHeaders, BodyParts, TimeoutMs, State) -> + receive + {chunk, Chunk} -> + receive_wsgi_body(Req, StatusCode, CowboyHeaders, [Chunk | BodyParts], TimeoutMs, State); + done -> + Body = iolist_to_binary(lists:reverse(BodyParts)), + Req2 = cowboy_req:reply(StatusCode, CowboyHeaders, Body, Req), + {ok, Req2, State}; + {error, Reason} -> + Body = iolist_to_binary(lists:reverse(BodyParts)), + if + Body =/= <<>> -> + %% Partial response - send what we have + Req2 = cowboy_req:reply(StatusCode, CowboyHeaders, Body, Req), + {ok, Req2, State}; + true -> + ReqInfo = build_request_info(Req), + handle_error(Req, Reason, ReqInfo, State) + end + after TimeoutMs -> + %% Timeout - send what we have + Body = iolist_to_binary(lists:reverse(BodyParts)), + if + Body =/= <<>> -> + Req2 = cowboy_req:reply(StatusCode, CowboyHeaders, Body, Req), + {ok, Req2, State}; + true -> + ReqInfo = build_request_info(Req), + handle_error(Req, timeout, ReqInfo, State) + end + end. + +%% @private +%% Receive ASGI response from pooled worker +receive_asgi_response(Req, ReqInfo, TimeoutMs, State) -> + receive + {response, StatusCode, Headers, Body} -> + %% Buffered response (single message) + Response = #{ + <<"status">> => StatusCode, + <<"headers">> => Headers, + <<"body">> => Body + }, + Response1 = hornbeam_http_hooks:run_on_response(Response), + send_asgi_response(Req, Response1, State); + {response, StatusCode, Headers, Body, EarlyHints} -> + %% Buffered response with early hints + Response = #{ + <<"status">> => StatusCode, + <<"headers">> => Headers, + <<"body">> => Body, + <<"early_hints">> => EarlyHints + }, + Response1 = hornbeam_http_hooks:run_on_response(Response), + send_asgi_response(Req, Response1, State); + {headers, StatusCode, Headers} -> + %% Streaming response - receive body chunks + CowboyHeaders = convert_headers(Headers), + receive_asgi_body(Req, StatusCode, CowboyHeaders, [], TimeoutMs, State); + {error, Reason} -> + handle_error(Req, Reason, ReqInfo, State) + after TimeoutMs -> + handle_error(Req, timeout, ReqInfo, State) + end. + +%% @private +%% Receive ASGI body chunks from pooled worker +receive_asgi_body(Req, StatusCode, CowboyHeaders, BodyParts, TimeoutMs, State) -> + receive + {chunk, Chunk} -> + receive_asgi_body(Req, StatusCode, CowboyHeaders, [Chunk | BodyParts], TimeoutMs, State); + done -> + Body = iolist_to_binary(lists:reverse(BodyParts)), + Req2 = cowboy_req:reply(StatusCode, CowboyHeaders, Body, Req), + {ok, Req2, State}; + {error, Reason} -> + Body = iolist_to_binary(lists:reverse(BodyParts)), + if + Body =/= <<>> -> + Req2 = cowboy_req:reply(StatusCode, CowboyHeaders, Body, Req), + {ok, Req2, State}; + true -> + ReqInfo = build_request_info(Req), + handle_error(Req, Reason, ReqInfo, State) + end + after TimeoutMs -> + Body = iolist_to_binary(lists:reverse(BodyParts)), + if + Body =/= <<>> -> + Req2 = cowboy_req:reply(StatusCode, CowboyHeaders, Body, Req), + {ok, Req2, State}; + true -> + ReqInfo = build_request_info(Req), + handle_error(Req, timeout, ReqInfo, State) + end + end. + %%% ============================================================================ %%% WebSocket callbacks (delegate to hornbeam_websocket) %%% ============================================================================ diff --git a/src/hornbeam_mounts.erl b/src/hornbeam_mounts.erl index 698c964..d5b6026 100644 --- a/src/hornbeam_mounts.erl +++ b/src/hornbeam_mounts.erl @@ -56,7 +56,11 @@ app_callable := binary(), worker_class := wsgi | asgi, workers := pos_integer(), - timeout := pos_integer() + timeout := pos_integer(), + mount_id => binary(), %% 6-char random ID for pool routing + pool_enabled => boolean(), %% Enable persistent worker pool + heartbeat_interval => pos_integer(), %% Worker heartbeat interval (ms) + heartbeat_timeout => pos_integer() %% Max time without heartbeat (ms) }. -export_type([mount/0]). @@ -111,17 +115,61 @@ 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), + %% 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}), + + %% Start worker pools for mounts with pool_enabled = true + lists:foreach(fun(Mount) -> + case maps:get(pool_enabled, Mount, false) of + true -> + MountId = maps:get(mount_id, Mount), + case hornbeam_worker_pool:start_mount_pool(MountId, Mount) of + {ok, _Pid} -> + ok; + {error, {already_started, _}} -> + ok; + {error, Reason} -> + error_logger:error_msg("hornbeam_mounts: Failed to start pool for ~s: ~p~n", + [MountId, Reason]) + end; + false -> + ok + end + end, SortedMounts), + {reply, ok, State}; handle_call(clear, _From, State) -> + %% Stop all worker pools first + case ets:lookup(?TABLE, sorted_mounts) of + [{sorted_mounts, Mounts}] -> + lists:foreach(fun(Mount) -> + case maps:get(pool_enabled, Mount, false) of + true -> + MountId = maps:get(mount_id, Mount), + catch hornbeam_worker_pool:stop_mount_pool(MountId); + false -> + ok + end + end, Mounts); + [] -> + ok + end, ets:delete_all_objects(?TABLE), {reply, ok, State}; @@ -203,3 +251,13 @@ 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, <<"+">>, <<"-">>), <<"/">>, <<"_">>). diff --git a/src/hornbeam_sup.erl b/src/hornbeam_sup.erl index 593f9f5..c632780 100644 --- a/src/hornbeam_sup.erl +++ b/src/hornbeam_sup.erl @@ -124,6 +124,14 @@ init([]) -> shutdown => 5000, type => worker, modules => [hornbeam_presence] + }, + #{ + id => hornbeam_worker_pool, + start => {hornbeam_worker_pool, start_link, []}, + restart => permanent, + shutdown => infinity, + type => supervisor, + modules => [hornbeam_worker_pool] } ], diff --git a/src/hornbeam_worker_arbiter.erl b/src/hornbeam_worker_arbiter.erl new file mode 100644 index 0000000..d155bf2 --- /dev/null +++ b/src/hornbeam_worker_arbiter.erl @@ -0,0 +1,313 @@ +%% 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 Per-mount gen_server managing persistent Python workers. +%%% +%%% Each arbiter manages N workers for a single mount. Workers are persistent +%%% Python processes that receive requests via channels and loop continuously, +%%% reducing Python startup overhead. +%%% +%%% Features: +%%% - O(1) channel lookup via persistent_term +%%% - Heartbeat monitoring with automatic restart +%%% - Scheduler affinity for cache locality +-module(hornbeam_worker_arbiter). + +-behaviour(gen_server). + +-export([ + start_link/2, + get_channel/2, + get_worker_count/1, + worker_status/1 +]). + +-export([ + init/1, + handle_call/3, + handle_cast/2, + handle_info/2, + terminate/2, + code_change/3 +]). + +-define(HEARTBEAT_INTERVAL, 5000). %% Check heartbeats every 5 seconds +-define(HEARTBEAT_TIMEOUT, 15000). %% Restart worker if no heartbeat for 15 seconds + +-record(worker_info, { + idx :: non_neg_integer(), + channel :: term(), + task_ref :: reference() | undefined, + last_heartbeat :: integer(), + status :: starting | running | stopping +}). + +-record(state, { + mount_id :: binary(), + mount_config :: map(), + num_workers :: pos_integer(), + workers :: #{non_neg_integer() => #worker_info{}}, + heartbeat_timer :: reference() | undefined +}). + +%%% ============================================================================ +%%% API +%%% ============================================================================ + +%% @doc Start the arbiter for a mount. +%% +%% Config should contain: +%% - app_module: Python module name +%% - app_callable: Python callable name +%% - worker_class: wsgi | asgi +%% - workers: Number of workers (default: number of schedulers) +-spec start_link(MountId :: binary(), Config :: map()) -> + {ok, pid()} | {error, term()}. +start_link(MountId, Config) -> + gen_server:start_link(?MODULE, {MountId, Config}, []). + +%% @doc Get channel for a specific worker index. +%% Uses persistent_term for O(1) lookup. +-spec get_channel(MountId :: binary(), WorkerIdx :: non_neg_integer()) -> term(). +get_channel(MountId, WorkerIdx) -> + persistent_term:get({hornbeam_worker, MountId, WorkerIdx}). + +%% @doc Get number of workers for a mount. +-spec get_worker_count(MountId :: binary()) -> pos_integer(). +get_worker_count(MountId) -> + persistent_term:get({hornbeam_worker_count, MountId}). + +%% @doc Get status of all workers managed by this arbiter. +-spec worker_status(pid()) -> map(). +worker_status(Pid) -> + gen_server:call(Pid, get_status). + +%%% ============================================================================ +%%% gen_server callbacks +%%% ============================================================================ + +init({MountId, Config}) -> + process_flag(trap_exit, true), + + NumWorkers = maps:get(workers, Config, erlang:system_info(schedulers)), + + %% Store worker count for routing + persistent_term:put({hornbeam_worker_count, MountId}, NumWorkers), + + %% Start all workers + Workers = start_all_workers(MountId, Config, NumWorkers), + + %% Start heartbeat timer + HeartbeatTimer = erlang:send_after(?HEARTBEAT_INTERVAL, self(), check_heartbeats), + + State = #state{ + mount_id = MountId, + mount_config = Config, + num_workers = NumWorkers, + workers = Workers, + heartbeat_timer = HeartbeatTimer + }, + + {ok, State}. + +handle_call(get_status, _From, #state{workers = Workers, mount_id = MountId} = State) -> + Now = erlang:system_time(millisecond), + WorkerList = maps:fold(fun(Idx, #worker_info{last_heartbeat = LastHB, status = Status}, Acc) -> + [#{ + idx => Idx, + status => Status, + last_heartbeat_ms_ago => Now - LastHB + } | Acc] + end, [], Workers), + Reply = #{ + mount_id => MountId, + num_workers => maps:size(Workers), + workers => WorkerList + }, + {reply, Reply, State}; + +handle_call(_Request, _From, State) -> + {reply, {error, unknown_request}, State}. + +handle_cast(_Request, State) -> + {noreply, State}. + +handle_info({heartbeat, WorkerId}, #state{workers = Workers} = State) -> + %% Extract worker index from WorkerId (format: "mount_id_idx") + Idx = parse_worker_idx(WorkerId), + case maps:get(Idx, Workers, undefined) of + undefined -> + {noreply, State}; + WorkerInfo -> + Now = erlang:system_time(millisecond), + UpdatedWorker = WorkerInfo#worker_info{ + last_heartbeat = Now, + status = running + }, + {noreply, State#state{workers = Workers#{Idx => UpdatedWorker}}} + end; + +handle_info(check_heartbeats, #state{workers = Workers, mount_id = MountId, + mount_config = Config} = State) -> + Now = erlang:system_time(millisecond), + HeartbeatTimeout = maps:get(heartbeat_timeout, Config, ?HEARTBEAT_TIMEOUT), + + %% Check each worker for heartbeat timeout + UpdatedWorkers = maps:fold(fun(Idx, WorkerInfo, Acc) -> + #worker_info{last_heartbeat = LastHB, status = Status} = WorkerInfo, + case Status of + running when (Now - LastHB) > HeartbeatTimeout -> + %% Worker missed heartbeats - restart it + error_logger:warning_msg("hornbeam_worker_arbiter: Worker ~s_~p missed heartbeats, restarting~n", + [MountId, Idx]), + NewWorker = restart_worker(MountId, Config, Idx, WorkerInfo), + Acc#{Idx => NewWorker}; + _ -> + Acc#{Idx => WorkerInfo} + end + end, #{}, Workers), + + %% Schedule next heartbeat check + HeartbeatInterval = maps:get(heartbeat_interval, Config, ?HEARTBEAT_INTERVAL), + NewTimer = erlang:send_after(HeartbeatInterval, self(), check_heartbeats), + + {noreply, State#state{workers = UpdatedWorkers, heartbeat_timer = NewTimer}}; + +handle_info({'EXIT', _Pid, normal}, State) -> + %% Normal exit, likely during shutdown + {noreply, State}; + +handle_info({'EXIT', _Pid, Reason}, State) -> + %% Unexpected exit - will be handled by heartbeat timeout + error_logger:warning_msg("hornbeam_worker_arbiter: Worker exited with reason: ~p~n", [Reason]), + {noreply, State}; + +handle_info(_Info, State) -> + {noreply, State}. + +terminate(_Reason, #state{workers = Workers, mount_id = MountId, num_workers = NumWorkers}) -> + %% Send stop to all workers + maps:foreach(fun(_Idx, #worker_info{channel = Channel}) -> + catch py_channel:send(Channel, stop) + end, Workers), + + %% Give workers time to stop gracefully + timer:sleep(100), + + %% Close all channels + maps:foreach(fun(_Idx, #worker_info{channel = Channel}) -> + catch py_channel:close(Channel) + end, Workers), + + %% Clean up persistent_term entries + lists:foreach(fun(Idx) -> + persistent_term:erase({hornbeam_worker, MountId, Idx}) + end, lists:seq(0, NumWorkers - 1)), + persistent_term:erase({hornbeam_worker_count, MountId}), + + ok. + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +%%% ============================================================================ +%%% Internal functions +%%% ============================================================================ + +start_all_workers(MountId, Config, NumWorkers) -> + lists:foldl(fun(Idx, Acc) -> + WorkerInfo = start_worker(MountId, Config, Idx), + Acc#{Idx => WorkerInfo} + end, #{}, lists:seq(0, NumWorkers - 1)). + +start_worker(MountId, Config, Idx) -> + %% Create channel for this worker + {ok, Channel} = py_channel:new(), + + %% Store in persistent_term for O(1) lookup + persistent_term:put({hornbeam_worker, MountId, Idx}, Channel), + + %% Build worker ID + WorkerId = <>, + + %% Get app config + AppModule = maps:get(app_module, Config), + AppCallable = maps:get(app_callable, Config), + WorkerClass = maps:get(worker_class, Config, wsgi), + + %% Get arbiter PID for heartbeat messages + ArbiterPid = self(), + + %% Spawn Python worker task + TaskRef = spawn_python_worker(Channel, ArbiterPid, WorkerId, + AppModule, AppCallable, WorkerClass), + + Now = erlang:system_time(millisecond), + #worker_info{ + idx = Idx, + channel = Channel, + task_ref = TaskRef, + last_heartbeat = Now, + status = starting + }. + +restart_worker(MountId, Config, Idx, #worker_info{channel = OldChannel}) -> + %% Close old channel (will cause Python worker to exit) + catch py_channel:close(OldChannel), + + %% Start new worker + start_worker(MountId, Config, Idx). + +spawn_python_worker(Channel, ArbiterPid, WorkerId, AppModule, AppCallable, WorkerClass) -> + %% Get the channel reference for Python + ChannelRef = py_channel:get_ref(Channel), + + %% Determine which Python module/function to call + {Module, Function} = case WorkerClass of + wsgi -> {<<"hornbeam_wsgi_worker">>, <<"worker_loop">>}; + asgi -> {<<"hornbeam_asgi_worker">>, <<"worker_loop">>} + end, + + %% Create a reference for tracking + Ref = make_ref(), + + %% Schedule the Python task + %% The worker_loop function will run until it receives 'stop' + case py_event_loop:get_loop() of + {ok, LoopRef} -> + py_event_loop:run_async(LoopRef, #{ + ref => Ref, + caller => self(), + module => Module, + func => Function, + args => [ChannelRef, ArbiterPid, WorkerId, AppModule, AppCallable] + }), + Ref; + {error, _} -> + %% Fallback: use py:spawn if event loop not available + py:spawn(Module, Function, [ChannelRef, ArbiterPid, WorkerId, AppModule, AppCallable]), + Ref + end. + +parse_worker_idx(WorkerId) when is_binary(WorkerId) -> + %% Parse "mount_id_idx" to get idx + case binary:split(WorkerId, <<"_">>, [global]) of + Parts when length(Parts) >= 2 -> + IdxBin = lists:last(Parts), + try binary_to_integer(IdxBin) + catch _:_ -> 0 + end; + _ -> + 0 + end. diff --git a/src/hornbeam_worker_pool.erl b/src/hornbeam_worker_pool.erl new file mode 100644 index 0000000..87042ab --- /dev/null +++ b/src/hornbeam_worker_pool.erl @@ -0,0 +1,122 @@ +%% 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 Top-level supervisor for persistent worker pools. +%%% +%%% This supervisor manages hornbeam_worker_arbiter processes, one per mount +%%% with pool_enabled = true. Each arbiter manages N persistent Python workers. +%%% +%%% Architecture: +%%% ``` +%%% hornbeam_worker_pool (supervisor, one_for_one) +%%% +-- hornbeam_worker_arbiter (mount: "abc123") +%%% | +-- manages N Python workers via channels +%%% +-- hornbeam_worker_arbiter (mount: "def456") +%%% +-- manages N Python workers via channels +%%% ''' +-module(hornbeam_worker_pool). + +-behaviour(supervisor). + +-export([ + start_link/0, + start_mount_pool/2, + stop_mount_pool/1, + get_channel/2, + list_pools/0 +]). + +-export([init/1]). + +-define(SERVER, ?MODULE). + +%%% ============================================================================ +%%% API +%%% ============================================================================ + +%% @doc Start the worker pool supervisor. +-spec start_link() -> {ok, pid()} | ignore | {error, term()}. +start_link() -> + supervisor:start_link({local, ?SERVER}, ?MODULE, []). + +%% @doc Start a worker pool for a mount. +%% +%% Config should contain: +%% - app_module: Python module name +%% - app_callable: Python callable name +%% - worker_class: wsgi | asgi +%% - workers: Number of workers (default: schedulers count) +%% - heartbeat_interval: Heartbeat check interval in ms (default: 5000) +%% - heartbeat_timeout: Max time without heartbeat in ms (default: 15000) +-spec start_mount_pool(MountId :: binary(), Config :: map()) -> + {ok, pid()} | {error, term()}. +start_mount_pool(MountId, Config) -> + ChildSpec = #{ + id => {hornbeam_worker_arbiter, MountId}, + start => {hornbeam_worker_arbiter, start_link, [MountId, Config]}, + restart => permanent, + shutdown => 10000, + type => worker, + modules => [hornbeam_worker_arbiter] + }, + supervisor:start_child(?SERVER, ChildSpec). + +%% @doc Stop a worker pool for a mount. +-spec stop_mount_pool(MountId :: binary()) -> ok | {error, term()}. +stop_mount_pool(MountId) -> + ChildId = {hornbeam_worker_arbiter, MountId}, + case supervisor:terminate_child(?SERVER, ChildId) of + ok -> + supervisor:delete_child(?SERVER, ChildId); + {error, not_found} -> + {error, not_found}; + Error -> + Error + end. + +%% @doc Get channel for a specific worker in a mount's pool. +%% +%% Uses scheduler affinity by default: the current scheduler ID is hashed +%% to select a worker, providing cache locality. +%% +%% For explicit worker selection, pass the worker index directly. +-spec get_channel(MountId :: binary(), SchedIdOrIdx :: pos_integer()) -> term(). +get_channel(MountId, SchedIdOrIdx) -> + NumWorkers = hornbeam_worker_arbiter:get_worker_count(MountId), + WorkerIdx = erlang:phash2(SchedIdOrIdx, NumWorkers), + hornbeam_worker_arbiter:get_channel(MountId, WorkerIdx). + +%% @doc List all active worker pools. +-spec list_pools() -> [#{mount_id := binary(), pid := pid()}]. +list_pools() -> + Children = supervisor:which_children(?SERVER), + lists:filtermap(fun + ({{hornbeam_worker_arbiter, MountId}, Pid, worker, _}) when is_pid(Pid) -> + {true, #{mount_id => MountId, pid => Pid}}; + (_) -> + false + end, Children). + +%%% ============================================================================ +%%% supervisor callbacks +%%% ============================================================================ + +init([]) -> + SupFlags = #{ + strategy => one_for_one, + intensity => 10, + period => 60 + }, + %% Start with no children - mounts are added dynamically + {ok, {SupFlags, []}}. From 4e9170c5444107843410f8ab1529b3caa0061ac0 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 12 Mar 2026 20:09:17 +0100 Subject: [PATCH 09/52] Stream pooled responses directly to client Use cowboy stream_reply/stream_body instead of collecting chunks before sending. Reduces memory usage and latency for large streaming responses. --- src/hornbeam_handler.erl | 96 +++++++++++++++++----------------------- 1 file changed, 40 insertions(+), 56 deletions(-) diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl index 03dd7fa..bf59aa2 100644 --- a/src/hornbeam_handler.erl +++ b/src/hornbeam_handler.erl @@ -728,9 +728,9 @@ build_pooled_request_info(Req, State) -> receive_wsgi_response(Req, ReqInfo, TimeoutMs, State) -> receive {headers, StatusCode, Headers} -> - %% Got headers - now receive body chunks + %% Got headers - stream body directly to client CowboyHeaders = convert_headers(Headers), - receive_wsgi_body(Req, StatusCode, CowboyHeaders, [], TimeoutMs, State); + receive_wsgi_body(Req, StatusCode, CowboyHeaders, TimeoutMs, State); {response, StatusCode, Headers, Body} -> %% Buffered response (single message) Response = #{ @@ -747,37 +747,28 @@ receive_wsgi_response(Req, ReqInfo, TimeoutMs, State) -> end. %% @private -%% Receive WSGI body chunks from pooled worker -receive_wsgi_body(Req, StatusCode, CowboyHeaders, BodyParts, TimeoutMs, State) -> +%% Receive WSGI body chunks from pooled worker - streams directly to client +receive_wsgi_body(Req, StatusCode, CowboyHeaders, TimeoutMs, State) -> + %% Start streaming response immediately + Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req), + stream_wsgi_body(Req2, TimeoutMs, State). + +stream_wsgi_body(Req, TimeoutMs, State) -> receive {chunk, Chunk} -> - receive_wsgi_body(Req, StatusCode, CowboyHeaders, [Chunk | BodyParts], TimeoutMs, State); + ok = cowboy_req:stream_body(Chunk, nofin, Req), + stream_wsgi_body(Req, TimeoutMs, State); done -> - Body = iolist_to_binary(lists:reverse(BodyParts)), - Req2 = cowboy_req:reply(StatusCode, CowboyHeaders, Body, Req), - {ok, Req2, State}; - {error, Reason} -> - Body = iolist_to_binary(lists:reverse(BodyParts)), - if - Body =/= <<>> -> - %% Partial response - send what we have - Req2 = cowboy_req:reply(StatusCode, CowboyHeaders, Body, Req), - {ok, Req2, State}; - true -> - ReqInfo = build_request_info(Req), - handle_error(Req, Reason, ReqInfo, State) - end + ok = cowboy_req:stream_body(<<>>, fin, Req), + {ok, Req, State}; + {error, _Reason} -> + %% Error mid-stream - close connection + ok = cowboy_req:stream_body(<<>>, fin, Req), + {ok, Req, State} after TimeoutMs -> - %% Timeout - send what we have - Body = iolist_to_binary(lists:reverse(BodyParts)), - if - Body =/= <<>> -> - Req2 = cowboy_req:reply(StatusCode, CowboyHeaders, Body, Req), - {ok, Req2, State}; - true -> - ReqInfo = build_request_info(Req), - handle_error(Req, timeout, ReqInfo, State) - end + %% Timeout - close stream + ok = cowboy_req:stream_body(<<>>, fin, Req), + {ok, Req, State} end. %% @private @@ -804,9 +795,9 @@ receive_asgi_response(Req, ReqInfo, TimeoutMs, State) -> Response1 = hornbeam_http_hooks:run_on_response(Response), send_asgi_response(Req, Response1, State); {headers, StatusCode, Headers} -> - %% Streaming response - receive body chunks + %% Streaming response - stream directly to client CowboyHeaders = convert_headers(Headers), - receive_asgi_body(Req, StatusCode, CowboyHeaders, [], TimeoutMs, State); + receive_asgi_body(Req, StatusCode, CowboyHeaders, TimeoutMs, State); {error, Reason} -> handle_error(Req, Reason, ReqInfo, State) after TimeoutMs -> @@ -814,35 +805,28 @@ receive_asgi_response(Req, ReqInfo, TimeoutMs, State) -> end. %% @private -%% Receive ASGI body chunks from pooled worker -receive_asgi_body(Req, StatusCode, CowboyHeaders, BodyParts, TimeoutMs, State) -> +%% Receive ASGI body chunks from pooled worker - streams directly to client +receive_asgi_body(Req, StatusCode, CowboyHeaders, TimeoutMs, State) -> + %% Start streaming response immediately + Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req), + stream_asgi_body(Req2, TimeoutMs, State). + +stream_asgi_body(Req, TimeoutMs, State) -> receive {chunk, Chunk} -> - receive_asgi_body(Req, StatusCode, CowboyHeaders, [Chunk | BodyParts], TimeoutMs, State); + ok = cowboy_req:stream_body(Chunk, nofin, Req), + stream_asgi_body(Req, TimeoutMs, State); done -> - Body = iolist_to_binary(lists:reverse(BodyParts)), - Req2 = cowboy_req:reply(StatusCode, CowboyHeaders, Body, Req), - {ok, Req2, State}; - {error, Reason} -> - Body = iolist_to_binary(lists:reverse(BodyParts)), - if - Body =/= <<>> -> - Req2 = cowboy_req:reply(StatusCode, CowboyHeaders, Body, Req), - {ok, Req2, State}; - true -> - ReqInfo = build_request_info(Req), - handle_error(Req, Reason, ReqInfo, State) - end + ok = cowboy_req:stream_body(<<>>, fin, Req), + {ok, Req, State}; + {error, _Reason} -> + %% Error mid-stream - close connection + ok = cowboy_req:stream_body(<<>>, fin, Req), + {ok, Req, State} after TimeoutMs -> - Body = iolist_to_binary(lists:reverse(BodyParts)), - if - Body =/= <<>> -> - Req2 = cowboy_req:reply(StatusCode, CowboyHeaders, Body, Req), - {ok, Req2, State}; - true -> - ReqInfo = build_request_info(Req), - handle_error(Req, timeout, ReqInfo, State) - end + %% Timeout - close stream + ok = cowboy_req:stream_body(<<>>, fin, Req), + {ok, Req, State} end. %%% ============================================================================ From 1e56c33b1b2e149dcfea040a7eeb8dc8d4cff676 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 12 Mar 2026 20:14:58 +0100 Subject: [PATCH 10/52] Simplify heartbeat config to global constants Remove per-mount heartbeat_interval and heartbeat_timeout options. Use module constants (5s interval, 15s timeout) for all workers. --- src/hornbeam_mounts.erl | 4 +--- src/hornbeam_worker_arbiter.erl | 6 ++---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/hornbeam_mounts.erl b/src/hornbeam_mounts.erl index d5b6026..caabe51 100644 --- a/src/hornbeam_mounts.erl +++ b/src/hornbeam_mounts.erl @@ -58,9 +58,7 @@ workers := pos_integer(), timeout := pos_integer(), mount_id => binary(), %% 6-char random ID for pool routing - pool_enabled => boolean(), %% Enable persistent worker pool - heartbeat_interval => pos_integer(), %% Worker heartbeat interval (ms) - heartbeat_timeout => pos_integer() %% Max time without heartbeat (ms) + pool_enabled => boolean() %% Enable persistent worker pool }. -export_type([mount/0]). diff --git a/src/hornbeam_worker_arbiter.erl b/src/hornbeam_worker_arbiter.erl index d155bf2..8bac6cc 100644 --- a/src/hornbeam_worker_arbiter.erl +++ b/src/hornbeam_worker_arbiter.erl @@ -161,13 +161,12 @@ handle_info({heartbeat, WorkerId}, #state{workers = Workers} = State) -> handle_info(check_heartbeats, #state{workers = Workers, mount_id = MountId, mount_config = Config} = State) -> Now = erlang:system_time(millisecond), - HeartbeatTimeout = maps:get(heartbeat_timeout, Config, ?HEARTBEAT_TIMEOUT), %% Check each worker for heartbeat timeout UpdatedWorkers = maps:fold(fun(Idx, WorkerInfo, Acc) -> #worker_info{last_heartbeat = LastHB, status = Status} = WorkerInfo, case Status of - running when (Now - LastHB) > HeartbeatTimeout -> + running when (Now - LastHB) > ?HEARTBEAT_TIMEOUT -> %% Worker missed heartbeats - restart it error_logger:warning_msg("hornbeam_worker_arbiter: Worker ~s_~p missed heartbeats, restarting~n", [MountId, Idx]), @@ -179,8 +178,7 @@ handle_info(check_heartbeats, #state{workers = Workers, mount_id = MountId, end, #{}, Workers), %% Schedule next heartbeat check - HeartbeatInterval = maps:get(heartbeat_interval, Config, ?HEARTBEAT_INTERVAL), - NewTimer = erlang:send_after(HeartbeatInterval, self(), check_heartbeats), + NewTimer = erlang:send_after(?HEARTBEAT_INTERVAL, self(), check_heartbeats), {noreply, State#state{workers = UpdatedWorkers, heartbeat_timer = NewTimer}}; From 56cd3de83e058abfc44a83ed763c45ea64eab3da Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 12 Mar 2026 20:22:50 +0100 Subject: [PATCH 11/52] Use single persistent_term lookup for channel routing Store channels as tuple instead of individual entries. One lookup instead of two: get tuple, then element(). --- src/hornbeam_worker_arbiter.erl | 60 +++++++++++++++++---------------- src/hornbeam_worker_pool.erl | 17 ++++------ 2 files changed, 38 insertions(+), 39 deletions(-) diff --git a/src/hornbeam_worker_arbiter.erl b/src/hornbeam_worker_arbiter.erl index 8bac6cc..e7976c8 100644 --- a/src/hornbeam_worker_arbiter.erl +++ b/src/hornbeam_worker_arbiter.erl @@ -28,8 +28,7 @@ -export([ start_link/2, - get_channel/2, - get_worker_count/1, + get_channels/1, worker_status/1 ]). @@ -77,16 +76,10 @@ start_link(MountId, Config) -> gen_server:start_link(?MODULE, {MountId, Config}, []). -%% @doc Get channel for a specific worker index. -%% Uses persistent_term for O(1) lookup. --spec get_channel(MountId :: binary(), WorkerIdx :: non_neg_integer()) -> term(). -get_channel(MountId, WorkerIdx) -> - persistent_term:get({hornbeam_worker, MountId, WorkerIdx}). - -%% @doc Get number of workers for a mount. --spec get_worker_count(MountId :: binary()) -> pos_integer(). -get_worker_count(MountId) -> - persistent_term:get({hornbeam_worker_count, MountId}). +%% @doc Get channels tuple for a mount. Single O(1) lookup. +-spec get_channels(MountId :: binary()) -> tuple(). +get_channels(MountId) -> + persistent_term:get({hornbeam_channels, MountId}). %% @doc Get status of all workers managed by this arbiter. -spec worker_status(pid()) -> map(). @@ -102,12 +95,12 @@ init({MountId, Config}) -> NumWorkers = maps:get(workers, Config, erlang:system_info(schedulers)), - %% Store worker count for routing - persistent_term:put({hornbeam_worker_count, MountId}, NumWorkers), - %% Start all workers Workers = start_all_workers(MountId, Config, NumWorkers), + %% Store channels tuple for O(1) lookup (single persistent_term entry) + update_channels_tuple(MountId, Workers, NumWorkers), + %% Start heartbeat timer HeartbeatTimer = erlang:send_after(?HEARTBEAT_INTERVAL, self(), check_heartbeats), @@ -159,11 +152,11 @@ handle_info({heartbeat, WorkerId}, #state{workers = Workers} = State) -> end; handle_info(check_heartbeats, #state{workers = Workers, mount_id = MountId, - mount_config = Config} = State) -> + mount_config = Config, num_workers = NumWorkers} = State) -> Now = erlang:system_time(millisecond), %% Check each worker for heartbeat timeout - UpdatedWorkers = maps:fold(fun(Idx, WorkerInfo, Acc) -> + {UpdatedWorkers, Changed} = maps:fold(fun(Idx, WorkerInfo, {Acc, Ch}) -> #worker_info{last_heartbeat = LastHB, status = Status} = WorkerInfo, case Status of running when (Now - LastHB) > ?HEARTBEAT_TIMEOUT -> @@ -171,11 +164,17 @@ handle_info(check_heartbeats, #state{workers = Workers, mount_id = MountId, error_logger:warning_msg("hornbeam_worker_arbiter: Worker ~s_~p missed heartbeats, restarting~n", [MountId, Idx]), NewWorker = restart_worker(MountId, Config, Idx, WorkerInfo), - Acc#{Idx => NewWorker}; + {Acc#{Idx => NewWorker}, true}; _ -> - Acc#{Idx => WorkerInfo} + {Acc#{Idx => WorkerInfo}, Ch} end - end, #{}, Workers), + end, {#{}, false}, Workers), + + %% Update channels tuple if any worker was restarted + case Changed of + true -> update_channels_tuple(MountId, UpdatedWorkers, NumWorkers); + false -> ok + end, %% Schedule next heartbeat check NewTimer = erlang:send_after(?HEARTBEAT_INTERVAL, self(), check_heartbeats), @@ -194,7 +193,7 @@ handle_info({'EXIT', _Pid, Reason}, State) -> handle_info(_Info, State) -> {noreply, State}. -terminate(_Reason, #state{workers = Workers, mount_id = MountId, num_workers = NumWorkers}) -> +terminate(_Reason, #state{workers = Workers, mount_id = MountId}) -> %% Send stop to all workers maps:foreach(fun(_Idx, #worker_info{channel = Channel}) -> catch py_channel:send(Channel, stop) @@ -208,11 +207,8 @@ terminate(_Reason, #state{workers = Workers, mount_id = MountId, num_workers = N catch py_channel:close(Channel) end, Workers), - %% Clean up persistent_term entries - lists:foreach(fun(Idx) -> - persistent_term:erase({hornbeam_worker, MountId, Idx}) - end, lists:seq(0, NumWorkers - 1)), - persistent_term:erase({hornbeam_worker_count, MountId}), + %% Clean up persistent_term entry + persistent_term:erase({hornbeam_channels, MountId}), ok. @@ -233,9 +229,6 @@ start_worker(MountId, Config, Idx) -> %% Create channel for this worker {ok, Channel} = py_channel:new(), - %% Store in persistent_term for O(1) lookup - persistent_term:put({hornbeam_worker, MountId, Idx}, Channel), - %% Build worker ID WorkerId = <>, @@ -309,3 +302,12 @@ parse_worker_idx(WorkerId) when is_binary(WorkerId) -> _ -> 0 end. + +%% @private +%% Build and store channels tuple for O(1) lookup. +%% Tuple is indexed 1..N, element((SchedId rem N) + 1, Tuple) gives channel. +update_channels_tuple(MountId, Workers, NumWorkers) -> + %% Build list of channels in index order + ChannelsList = [maps:get(Idx, Workers) || Idx <- lists:seq(0, NumWorkers - 1)], + Channels = list_to_tuple([Ch#worker_info.channel || Ch <- ChannelsList]), + persistent_term:put({hornbeam_channels, MountId}, Channels). diff --git a/src/hornbeam_worker_pool.erl b/src/hornbeam_worker_pool.erl index 87042ab..cf62b8f 100644 --- a/src/hornbeam_worker_pool.erl +++ b/src/hornbeam_worker_pool.erl @@ -85,17 +85,14 @@ stop_mount_pool(MountId) -> Error end. -%% @doc Get channel for a specific worker in a mount's pool. +%% @doc Get channel for a worker in a mount's pool. %% -%% Uses scheduler affinity by default: the current scheduler ID is hashed -%% to select a worker, providing cache locality. -%% -%% For explicit worker selection, pass the worker index directly. --spec get_channel(MountId :: binary(), SchedIdOrIdx :: pos_integer()) -> term(). -get_channel(MountId, SchedIdOrIdx) -> - NumWorkers = hornbeam_worker_arbiter:get_worker_count(MountId), - WorkerIdx = erlang:phash2(SchedIdOrIdx, NumWorkers), - hornbeam_worker_arbiter:get_channel(MountId, WorkerIdx). +%% Uses scheduler affinity: the scheduler ID selects a worker via +%% modulo on the channels tuple size. +-spec get_channel(MountId :: binary(), SchedId :: pos_integer()) -> term(). +get_channel(MountId, SchedId) -> + Channels = hornbeam_worker_arbiter:get_channels(MountId), + element((SchedId rem tuple_size(Channels)) + 1, Channels). %% @doc List all active worker pools. -spec list_pools() -> [#{mount_id := binary(), pid := pid()}]. From f7d21421f542b297ecd7c65236fd1936a06c5d0f Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 12 Mar 2026 20:39:08 +0100 Subject: [PATCH 12/52] Use single ETS lookup_element for channel routing Store channels as {{pool, MountId}, Ch1, Ch2, ...} in ETS. Handler gets channel via single lookup_element call. Remove persistent_term usage entirely. --- src/hornbeam_handler.erl | 29 ++++++++++++----------------- src/hornbeam_mounts.erl | 21 ++++++++++++++++++++- src/hornbeam_worker_arbiter.erl | 14 ++++---------- src/hornbeam_worker_pool.erl | 9 --------- 4 files changed, 36 insertions(+), 37 deletions(-) diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl index bf59aa2..22bd70d 100644 --- a/src/hornbeam_handler.erl +++ b/src/hornbeam_handler.erl @@ -40,7 +40,6 @@ init(Req, #{multi_app := true} = State) -> timeout => maps:get(timeout, Mount), script_name => maps:get(prefix, Mount), path_info => PathInfo, - %% Pool settings (for persistent worker dispatch) pool_enabled => maps:get(pool_enabled, Mount, false), mount_id => maps:get(mount_id, Mount, undefined), workers => maps:get(workers, Mount, 4) @@ -88,26 +87,24 @@ handle_websocket_upgrade(Req, State) -> %%% WSGI Handler %%% ============================================================================ -handle_wsgi(Req, #{pool_enabled := true, mount_id := MountId} = State) -> - %% Pooled worker path - dispatch to persistent worker via channel - handle_wsgi_pooled(Req, MountId, State); +handle_wsgi(Req, #{pool_enabled := true, mount_id := MountId, workers := NumWorkers} = State) -> + %% Pooled worker path - single ETS lookup_element + SchedId = erlang:system_info(scheduler_id), + Channel = hornbeam_mounts:get_channel(MountId, SchedId rem NumWorkers), + handle_wsgi_pooled(Req, Channel, State); handle_wsgi(Req, State) -> %% Non-pooled path - existing behavior handle_wsgi_direct(Req, State). %% @private %% Dispatch WSGI request to persistent worker pool via channel -handle_wsgi_pooled(Req, MountId, State) -> +handle_wsgi_pooled(Req, Channel, State) -> ReqInfo = build_request_info(Req), ReqInfo1 = hornbeam_http_hooks:run_on_request(ReqInfo), try TimeoutMs = maps:get(timeout, State, 30000), - %% Get channel via scheduler affinity - SchedId = erlang:system_info(scheduler_id), - Channel = hornbeam_worker_pool:get_channel(MountId, SchedId), - %% Build request info for Python worker ReqTuple = build_pooled_request_info(Req, State), @@ -335,26 +332,24 @@ parse_status_code(Status) when is_integer(Status) -> %%% ASGI Handler %%% ============================================================================ -handle_asgi(Req, #{pool_enabled := true, mount_id := MountId} = State) -> - %% Pooled worker path - dispatch to persistent worker via channel - handle_asgi_pooled(Req, MountId, State); +handle_asgi(Req, #{pool_enabled := true, mount_id := MountId, workers := NumWorkers} = State) -> + %% Pooled worker path - single ETS lookup_element + SchedId = erlang:system_info(scheduler_id), + Channel = hornbeam_mounts:get_channel(MountId, SchedId rem NumWorkers), + handle_asgi_pooled(Req, Channel, State); handle_asgi(Req, State) -> %% Non-pooled path - existing behavior handle_asgi_direct(Req, State). %% @private %% Dispatch ASGI request to persistent worker pool via channel -handle_asgi_pooled(Req, MountId, State) -> +handle_asgi_pooled(Req, Channel, State) -> ReqInfo = build_request_info(Req), ReqInfo1 = hornbeam_http_hooks:run_on_request(ReqInfo), try TimeoutMs = maps:get(timeout, State, 30000), - %% Get channel via scheduler affinity - SchedId = erlang:system_info(scheduler_id), - Channel = hornbeam_worker_pool:get_channel(MountId, SchedId), - %% Build ASGI scope for Python worker Scope = build_scope_for_nif(Req, State), diff --git a/src/hornbeam_mounts.erl b/src/hornbeam_mounts.erl index caabe51..fdd909c 100644 --- a/src/hornbeam_mounts.erl +++ b/src/hornbeam_mounts.erl @@ -41,7 +41,9 @@ register/1, lookup/1, list/0, - clear/0 + clear/0, + update_channels/2, + get_channel/2 ]). %% gen_server callbacks @@ -90,6 +92,23 @@ lookup(Path) -> {error, no_match} end. +%% @doc Update channels for a mount (called by arbiter). +%% Stores as {{pool, MountId}, Ch1, Ch2, ...} for direct lookup_element access. +-spec update_channels(MountId :: binary(), Channels :: tuple() | undefined) -> ok. +update_channels(MountId, undefined) -> + ets:delete(?TABLE, {pool, MountId}), + ok; +update_channels(MountId, Channels) -> + Entry = list_to_tuple([{pool, MountId} | tuple_to_list(Channels)]), + ets:insert(?TABLE, Entry), + ok. + +%% @doc Get channel for a mount by worker index (0-based). +%% Single lookup_element call - O(1). +-spec get_channel(MountId :: binary(), WorkerIdx :: non_neg_integer()) -> term(). +get_channel(MountId, WorkerIdx) -> + ets:lookup_element(?TABLE, {pool, MountId}, WorkerIdx + 2). + %% @doc List all registered mounts. -spec list() -> [mount()]. list() -> diff --git a/src/hornbeam_worker_arbiter.erl b/src/hornbeam_worker_arbiter.erl index e7976c8..6b018ed 100644 --- a/src/hornbeam_worker_arbiter.erl +++ b/src/hornbeam_worker_arbiter.erl @@ -28,7 +28,6 @@ -export([ start_link/2, - get_channels/1, worker_status/1 ]). @@ -76,10 +75,6 @@ start_link(MountId, Config) -> gen_server:start_link(?MODULE, {MountId, Config}, []). -%% @doc Get channels tuple for a mount. Single O(1) lookup. --spec get_channels(MountId :: binary()) -> tuple(). -get_channels(MountId) -> - persistent_term:get({hornbeam_channels, MountId}). %% @doc Get status of all workers managed by this arbiter. -spec worker_status(pid()) -> map(). @@ -207,8 +202,8 @@ terminate(_Reason, #state{workers = Workers, mount_id = MountId}) -> catch py_channel:close(Channel) end, Workers), - %% Clean up persistent_term entry - persistent_term:erase({hornbeam_channels, MountId}), + %% Clear channels from mount + hornbeam_mounts:update_channels(MountId, undefined), ok. @@ -304,10 +299,9 @@ parse_worker_idx(WorkerId) when is_binary(WorkerId) -> end. %% @private -%% Build and store channels tuple for O(1) lookup. +%% Build and store channels tuple in mount record. %% Tuple is indexed 1..N, element((SchedId rem N) + 1, Tuple) gives channel. update_channels_tuple(MountId, Workers, NumWorkers) -> - %% Build list of channels in index order ChannelsList = [maps:get(Idx, Workers) || Idx <- lists:seq(0, NumWorkers - 1)], Channels = list_to_tuple([Ch#worker_info.channel || Ch <- ChannelsList]), - persistent_term:put({hornbeam_channels, MountId}, Channels). + hornbeam_mounts:update_channels(MountId, Channels). diff --git a/src/hornbeam_worker_pool.erl b/src/hornbeam_worker_pool.erl index cf62b8f..e2b426b 100644 --- a/src/hornbeam_worker_pool.erl +++ b/src/hornbeam_worker_pool.erl @@ -33,7 +33,6 @@ start_link/0, start_mount_pool/2, stop_mount_pool/1, - get_channel/2, list_pools/0 ]). @@ -85,14 +84,6 @@ stop_mount_pool(MountId) -> Error end. -%% @doc Get channel for a worker in a mount's pool. -%% -%% Uses scheduler affinity: the scheduler ID selects a worker via -%% modulo on the channels tuple size. --spec get_channel(MountId :: binary(), SchedId :: pos_integer()) -> term(). -get_channel(MountId, SchedId) -> - Channels = hornbeam_worker_arbiter:get_channels(MountId), - element((SchedId rem tuple_size(Channels)) + 1, Channels). %% @doc List all active worker pools. -spec list_pools() -> [#{mount_id := binary(), pid := pid()}]. From e415220548cdbca3a8254e274a6ae83cfd83a9bb Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 12 Mar 2026 20:43:17 +0100 Subject: [PATCH 13/52] Simplify channel storage to one key per channel Store as {{MountId, Idx}, Channel} for direct lookup. No tuple manipulation needed. --- src/hornbeam_mounts.erl | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/hornbeam_mounts.erl b/src/hornbeam_mounts.erl index fdd909c..975a78e 100644 --- a/src/hornbeam_mounts.erl +++ b/src/hornbeam_mounts.erl @@ -93,21 +93,21 @@ lookup(Path) -> end. %% @doc Update channels for a mount (called by arbiter). -%% Stores as {{pool, MountId}, Ch1, Ch2, ...} for direct lookup_element access. -spec update_channels(MountId :: binary(), Channels :: tuple() | undefined) -> ok. update_channels(MountId, undefined) -> - ets:delete(?TABLE, {pool, MountId}), + ets:match_delete(?TABLE, {{MountId, '_'}, '_'}), ok; update_channels(MountId, Channels) -> - Entry = list_to_tuple([{pool, MountId} | tuple_to_list(Channels)]), - ets:insert(?TABLE, Entry), + lists:foreach(fun(Idx) -> + Ch = element(Idx + 1, Channels), + ets:insert(?TABLE, {{MountId, Idx}, Ch}) + end, lists:seq(0, tuple_size(Channels) - 1)), ok. %% @doc Get channel for a mount by worker index (0-based). -%% Single lookup_element call - O(1). -spec get_channel(MountId :: binary(), WorkerIdx :: non_neg_integer()) -> term(). get_channel(MountId, WorkerIdx) -> - ets:lookup_element(?TABLE, {pool, MountId}, WorkerIdx + 2). + ets:lookup_element(?TABLE, {MountId, WorkerIdx}, 2). %% @doc List all registered mounts. -spec list() -> [mount()]. From 209d7b24eb316af9ffbc48ef88bb46932305be14 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 12 Mar 2026 22:06:15 +0100 Subject: [PATCH 14/52] Split channel storage into set_channel and clear_channels --- src/hornbeam_mounts.erl | 21 +++++++++++---------- src/hornbeam_worker_arbiter.erl | 26 +++++++++++++------------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/hornbeam_mounts.erl b/src/hornbeam_mounts.erl index 975a78e..1577572 100644 --- a/src/hornbeam_mounts.erl +++ b/src/hornbeam_mounts.erl @@ -42,7 +42,8 @@ lookup/1, list/0, clear/0, - update_channels/2, + set_channel/3, + clear_channels/1, get_channel/2 ]). @@ -92,16 +93,16 @@ lookup(Path) -> {error, no_match} end. -%% @doc Update channels for a mount (called by arbiter). --spec update_channels(MountId :: binary(), Channels :: tuple() | undefined) -> ok. -update_channels(MountId, undefined) -> +%% @doc Set a channel for a mount worker. +-spec set_channel(MountId :: binary(), WorkerIdx :: non_neg_integer(), Channel :: term()) -> ok. +set_channel(MountId, WorkerIdx, Channel) -> + ets:insert(?TABLE, {{MountId, WorkerIdx}, Channel}), + ok. + +%% @doc Clear all channels for a mount. +-spec clear_channels(MountId :: binary()) -> ok. +clear_channels(MountId) -> ets:match_delete(?TABLE, {{MountId, '_'}, '_'}), - ok; -update_channels(MountId, Channels) -> - lists:foreach(fun(Idx) -> - Ch = element(Idx + 1, Channels), - ets:insert(?TABLE, {{MountId, Idx}, Ch}) - end, lists:seq(0, tuple_size(Channels) - 1)), ok. %% @doc Get channel for a mount by worker index (0-based). diff --git a/src/hornbeam_worker_arbiter.erl b/src/hornbeam_worker_arbiter.erl index 6b018ed..dc2ae36 100644 --- a/src/hornbeam_worker_arbiter.erl +++ b/src/hornbeam_worker_arbiter.erl @@ -19,7 +19,7 @@ %%% reducing Python startup overhead. %%% %%% Features: -%%% - O(1) channel lookup via persistent_term +%%% - O(1) channel lookup via ETS %%% - Heartbeat monitoring with automatic restart %%% - Scheduler affinity for cache locality -module(hornbeam_worker_arbiter). @@ -93,8 +93,8 @@ init({MountId, Config}) -> %% Start all workers Workers = start_all_workers(MountId, Config, NumWorkers), - %% Store channels tuple for O(1) lookup (single persistent_term entry) - update_channels_tuple(MountId, Workers, NumWorkers), + %% Store channels in ETS for O(1) lookup + store_channels(MountId, Workers, NumWorkers), %% Start heartbeat timer HeartbeatTimer = erlang:send_after(?HEARTBEAT_INTERVAL, self(), check_heartbeats), @@ -165,9 +165,9 @@ handle_info(check_heartbeats, #state{workers = Workers, mount_id = MountId, end end, {#{}, false}, Workers), - %% Update channels tuple if any worker was restarted + %% Update channels in ETS if any worker was restarted case Changed of - true -> update_channels_tuple(MountId, UpdatedWorkers, NumWorkers); + true -> store_channels(MountId, UpdatedWorkers, NumWorkers); false -> ok end, @@ -202,8 +202,8 @@ terminate(_Reason, #state{workers = Workers, mount_id = MountId}) -> catch py_channel:close(Channel) end, Workers), - %% Clear channels from mount - hornbeam_mounts:update_channels(MountId, undefined), + %% Clear channels from ETS + hornbeam_mounts:clear_channels(MountId), ok. @@ -299,9 +299,9 @@ parse_worker_idx(WorkerId) when is_binary(WorkerId) -> end. %% @private -%% Build and store channels tuple in mount record. -%% Tuple is indexed 1..N, element((SchedId rem N) + 1, Tuple) gives channel. -update_channels_tuple(MountId, Workers, NumWorkers) -> - ChannelsList = [maps:get(Idx, Workers) || Idx <- lists:seq(0, NumWorkers - 1)], - Channels = list_to_tuple([Ch#worker_info.channel || Ch <- ChannelsList]), - hornbeam_mounts:update_channels(MountId, Channels). +%% Store each channel in ETS with key {MountId, Idx}. +store_channels(MountId, Workers, NumWorkers) -> + lists:foreach(fun(Idx) -> + #worker_info{channel = Ch} = maps:get(Idx, Workers), + hornbeam_mounts:set_channel(MountId, Idx, Ch) + end, lists:seq(0, NumWorkers - 1)). From 4bdba701f98d78ee2fcea1c098fc7ba755982277 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 12 Mar 2026 22:56:55 +0100 Subject: [PATCH 15/52] Add static Python context pool with scheduler affinity --- src/hornbeam_pool.erl | 218 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 src/hornbeam_pool.erl diff --git a/src/hornbeam_pool.erl b/src/hornbeam_pool.erl new file mode 100644 index 0000000..7023d26 --- /dev/null +++ b/src/hornbeam_pool.erl @@ -0,0 +1,218 @@ +%% 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 Static Python context pool using ETS for O(1) lookup. +%%% +%%% This module provides scheduler-affinity based context assignment for +%%% optimal cache locality and reduced contention. Each scheduler gets +%%% a pre-assigned context via phash2(scheduler_id). +%%% +%%% Key features: +%%% - O(1) context lookup via ETS lookup_element +%%% - Scheduler affinity for cache locality +%%% - No gen_server overhead for hot path +%%% - Atomic call counters via ETS update_counter +%%% - Automatic restart on context crash +-module(hornbeam_pool). + +-behaviour(gen_server). + +-export([ + start_link/0, + start_link/1, + get_context/0, + call/4, + call/5, + pool_size/0, + stats/0 +]). + +-export([ + init/1, + handle_call/3, + handle_cast/2, + handle_info/2, + terminate/2, + code_change/3 +]). + +-define(SERVER, ?MODULE). +-define(TABLE, hornbeam_pool). +-define(DEFAULT_POOL_SIZE, erlang:system_info(schedulers)). + +-record(state, { + pool_size :: non_neg_integer(), + monitors :: #{pid() => {pos_integer(), reference()}} % pid -> {idx, mref} +}). + +%%% ============================================================================ +%%% API +%%% ============================================================================ + +%% @doc Start the context pool with default size (one per scheduler). +-spec start_link() -> {ok, pid()} | {error, term()}. +start_link() -> + start_link(#{}). + +%% @doc Start the context pool with options. +%% +%% Options: +%% - pool_size: Number of contexts (default: number of schedulers) +-spec start_link(map()) -> {ok, pid()} | {error, term()}. +start_link(Opts) -> + gen_server:start_link({local, ?SERVER}, ?MODULE, Opts, []). + +%% @doc Get a context from the pool using scheduler affinity. +%% +%% Returns the context assigned to the current scheduler for optimal +%% cache locality and reduced contention. O(1) lookup via ETS. +-spec get_context() -> pid(). +get_context() -> + SchedId = erlang:system_info(scheduler_id), + N = ets:lookup_element(?TABLE, pool_size, 2), + Idx = (SchedId - 1) rem N, + ets:lookup_element(?TABLE, {context, Idx}, 2). + +%% @doc Call a Python function using a pooled context. +%% +%% Automatically selects a context using scheduler affinity. +%% Falls back to py:call if pool is not enabled. +-spec call(atom() | binary(), atom() | binary(), list(), map()) -> + {ok, term()} | {error, term()}. +call(Module, Func, Args, Kwargs) -> + call(Module, Func, Args, Kwargs, 30000). + +%% @doc Call a Python function with timeout. +-spec call(atom() | binary(), atom() | binary(), list(), map(), timeout()) -> + {ok, term()} | {error, term()}. +call(Module, Func, Args, Kwargs, Timeout) -> + SchedId = erlang:system_info(scheduler_id), + N = ets:lookup_element(?TABLE, pool_size, 2), + Idx = (SchedId - 1) rem N, + Ctx = ets:lookup_element(?TABLE, {context, Idx}, 2), + ets:update_counter(?TABLE, {counter, Idx}, 1), + py_context:call(Ctx, Module, Func, Args, Kwargs, Timeout). + +%% @doc Get the pool size. +-spec pool_size() -> pos_integer(). +pool_size() -> + ets:lookup_element(?TABLE, pool_size, 2). + +%% @doc Get pool statistics. +-spec stats() -> map(). +stats() -> + PoolSize = ets:lookup_element(?TABLE, pool_size, 2), + CallCounts = [ets:lookup_element(?TABLE, {counter, Idx}, 2) + || Idx <- lists:seq(0, PoolSize - 1)], + #{ + pool_size => PoolSize, + call_counts => CallCounts, + total_calls => lists:sum(CallCounts) + }. + +%%% ============================================================================ +%%% gen_server callbacks +%%% ============================================================================ + +init(Opts) -> + process_flag(trap_exit, true), + + %% Create ETS table for pool data + _ = ets:new(?TABLE, [named_table, public, set, {read_concurrency, true}]), + + PoolSize = maps:get(pool_size, Opts, ?DEFAULT_POOL_SIZE), + + %% Try to create contexts (may fail if Python not started) + {Monitors, ActualSize} = try + create_contexts(PoolSize) + catch + _:_ -> + %% Python not ready - start with empty pool + error_logger:info_msg("hornbeam_pool: Python not ready, starting without contexts~n"), + {#{}, 0} + end, + + %% Initialize counters to 0 + lists:foreach(fun(Idx) -> + ets:insert(?TABLE, {{counter, Idx}, 0}) + end, lists:seq(0, max(0, ActualSize - 1))), + + %% Store pool size + ets:insert(?TABLE, {pool_size, ActualSize}), + + {ok, #state{ + pool_size = ActualSize, + monitors = Monitors + }}. + +handle_call(get_pool_size, _From, #state{pool_size = PoolSize} = State) -> + {reply, PoolSize, State}; + +handle_call(_Request, _From, State) -> + {reply, {error, unknown_request}, State}. + +handle_cast(_Request, State) -> + {noreply, State}. + +handle_info({'DOWN', MRef, process, Pid, Reason}, #state{monitors = Monitors} = State) -> + %% A context died - find and restart it + case maps:get(Pid, Monitors, undefined) of + {Idx, MRef} -> + error_logger:warning_msg("hornbeam_pool: Context ~p died: ~p, restarting~n", + [Idx, Reason]), + {NewCtx, NewMRef} = start_context(Idx), + ets:insert(?TABLE, {{context, Idx}, NewCtx}), + NewMonitors = maps:remove(Pid, Monitors), + NewMonitors2 = maps:put(NewCtx, {Idx, NewMRef}, NewMonitors), + {noreply, State#state{monitors = NewMonitors2}}; + undefined -> + {noreply, State} + end; + +handle_info(_Info, State) -> + {noreply, State}. + +terminate(_Reason, #state{pool_size = PoolSize}) -> + %% Stop all contexts + lists:foreach(fun(Idx) -> + case ets:lookup(?TABLE, {context, Idx}) of + [] -> ok; + [{_, Ctx}] -> + catch py_context:stop(Ctx) + end + end, lists:seq(0, PoolSize - 1)), + %% Delete ETS table + catch ets:delete(?TABLE), + ok. + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +%%% ============================================================================ +%%% Internal functions +%%% ============================================================================ + +create_contexts(PoolSize) -> + Monitors = lists:foldl(fun(Idx, Acc) -> + {Ctx, MRef} = start_context(Idx), + ets:insert(?TABLE, {{context, Idx}, Ctx}), + maps:put(Ctx, {Idx, MRef}, Acc) + end, #{}, lists:seq(0, PoolSize - 1)), + {Monitors, PoolSize}. + +start_context(Id) -> + %% Use 'auto' mode - detects subinterp on Python 3.12+, worker otherwise + {ok, Ctx} = py_context:start_link(Id, auto), + MRef = erlang:monitor(process, Ctx), + {Ctx, MRef}. From 42ec53dd258d03dc1bd02999ccad14a61be4eea2 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sat, 14 Mar 2026 15:32:59 +0100 Subject: [PATCH 16/52] Fix HAS_ERLANG initialization in Python workers Call py_context:extend_erlang_module_in_context/1 before importing hornbeam_wsgi_worker and hornbeam_asgi_worker. This ensures the erlang module is fully extended with send/call/schedule_inline before the workers check HAS_ERLANG at import time. Also adds noop_asgi.py benchmark app for ASGI testing. Performance results: - WSGI single context: 76K req/sec, 13us latency - WSGI 14 workers: 62-64K req/sec - ASGI single worker: 60K req/sec - ASGI 14 workers: 113K req/sec, 9us latency --- benchmarks/noop_asgi.py | 12 +++ src/hornbeam_context_pool.erl | 196 ++++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 benchmarks/noop_asgi.py create mode 100644 src/hornbeam_context_pool.erl 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/src/hornbeam_context_pool.erl b/src/hornbeam_context_pool.erl new file mode 100644 index 0000000..115b0a8 --- /dev/null +++ b/src/hornbeam_context_pool.erl @@ -0,0 +1,196 @@ +%% 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 Context pool using persistent_term for zero-copy access. +%%% +%%% Stores Python context references in persistent_term for O(1) lookup +%%% with no message passing or copying overhead. +%%% +%%% Each context is a sub-interpreter (Python 3.12+) or worker thread. +%%% With free-threading Python (3.13+), contexts execute truly in parallel. +%%% +%%% @end +-module(hornbeam_context_pool). + +-behaviour(gen_server). + +-export([ + start_link/0, + start_link/1, + get_context/0, + get_context_ref/0, + get_context_rr/0, + pool_size/0, + stats/0 +]). + +%% gen_server callbacks +-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). + +-record(state, { + pool_size :: pos_integer(), + contexts :: #{pos_integer() => reference()} +}). + +-define(DEFAULT_POOL_SIZE, erlang:system_info(schedulers)). + +%% ============================================================================ +%% API +%% ============================================================================ + +%% @doc Start the context pool with default size (one per scheduler). +-spec start_link() -> {ok, pid()} | {error, term()}. +start_link() -> + start_link(#{}). + +%% @doc Start the context pool with options. +%% +%% Options: +%% - pool_size: Number of contexts (default: number of schedulers) +-spec start_link(map()) -> {ok, pid()} | {error, term()}. +start_link(Opts) -> + gen_server:start_link({local, ?MODULE}, ?MODULE, Opts, []). + +%% @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 a context using round-robin selection. +%% +%% Uses an atomic counter for fair distribution across all contexts. +-spec get_context_rr() -> {reference(), non_neg_integer()}. +get_context_rr() -> + N = persistent_term:get(hornbeam_context_pool_size), + Counter = atomics:add_get(persistent_term:get(hornbeam_context_counter), 1, 1), + Id = (Counter - 1) rem N, + persistent_term:get({hornbeam_context, Id}). + +%% @doc Get the pool size. +-spec pool_size() -> pos_integer(). +pool_size() -> + persistent_term:get(hornbeam_context_pool_size). + +%% @doc Get pool statistics. +-spec stats() -> map(). +stats() -> + gen_server:call(?MODULE, stats). + +%% ============================================================================ +%% gen_server callbacks +%% ============================================================================ + +init(Opts) -> + process_flag(trap_exit, true), + + PoolSize = maps:get(pool_size, Opts, + application:get_env(hornbeam, context_pool_size, ?DEFAULT_POOL_SIZE)), + + %% Create atomic counter for round-robin + Counter = atomics:new(1, [{signed, false}]), + persistent_term:put(hornbeam_context_counter, Counter), + + %% Create contexts and store in persistent_term + Contexts = create_contexts(PoolSize), + + persistent_term:put(hornbeam_context_pool_size, PoolSize), + + {ok, #state{pool_size = PoolSize, contexts = Contexts}}. + +handle_call(stats, _From, #state{pool_size = PoolSize} = State) -> + Stats = #{ + pool_size => PoolSize, + execution_mode => py_nif:execution_mode() + }, + {reply, Stats, 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, contexts = Contexts}) -> + %% Destroy contexts + maps:foreach(fun(_Id, Ref) -> + catch py_nif:context_destroy(Ref) + end, Contexts), + + %% Clean up persistent_term entries + 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 +%% ============================================================================ + +create_contexts(PoolSize) -> + PrivDir = code:priv_dir(hornbeam), + PrivDirBin = list_to_binary(PrivDir), + + lists:foldl(fun(Id, Acc) -> + {Ref, InterpId} = create_context(Id, PrivDirBin), + persistent_term:put({hornbeam_context, Id}, {Ref, InterpId}), + maps:put(Id, Ref, Acc) + end, #{}, lists:seq(0, PoolSize - 1)). + +create_context(Id, PrivDir) -> + case py_nif:context_create(auto) of + {ok, Ref, InterpId} -> + %% Set up callback handler (for erlang.call from Python) + py_nif:context_set_callback_handler(Ref, self()), + + %% Extend erlang module first (must happen before importing workers) + %% This makes erlang.send, erlang.call, erlang.schedule_inline available + py_context:extend_erlang_module_in_context(Ref), + + %% Add priv dir to sys.path and preload worker modules + SetupCode = <<" +import sys +priv_dir = '", PrivDir/binary, "' +if priv_dir not in sys.path: + sys.path.insert(0, priv_dir) +import hornbeam_wsgi_worker +import hornbeam_asgi_worker +">>, + 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, + + {Ref, InterpId}; + {error, Reason} -> + error({context_create_failed, Id, Reason}) + end. From fed349760498e79adb25c204c0187f0e0d033348 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sat, 14 Mar 2026 17:13:05 +0100 Subject: [PATCH 17/52] Refactor WSGI/ASGI to use context_call with schedule_inline - Replace pooled worker architecture with context_call + schedule_inline - WSGI now uses py_nif:context_call() with schedule_inline for yielding - ASGI uses py_event_loop for async execution - Remove hornbeam_worker_arbiter and hornbeam_worker_pool (obsolete) - Simplify hornbeam_handler to single codepath - Update Python workers for schedule_inline continuation pattern - Add max_concurrent config for erlang_python --- config/sys.config | 3 +- priv/hornbeam_asgi_worker.py | 784 +++++++++++++------------------ priv/hornbeam_wsgi_worker.py | 794 ++++++++++++++++---------------- rebar.config | 2 +- src/hornbeam_handler.erl | 748 ++++++++---------------------- src/hornbeam_mounts.erl | 56 +-- src/hornbeam_pool.erl | 22 +- src/hornbeam_sup.erl | 17 +- src/hornbeam_worker_arbiter.erl | 307 ------------ src/hornbeam_worker_pool.erl | 110 ----- 10 files changed, 933 insertions(+), 1910 deletions(-) delete mode 100644 src/hornbeam_worker_arbiter.erl delete mode 100644 src/hornbeam_worker_pool.erl diff --git a/config/sys.config b/config/sys.config index 8873334..633a589 100644 --- a/config/sys.config +++ b/config/sys.config @@ -10,6 +10,7 @@ {pythonpath, [".", "examples"]} ]}, {erlang_python, [ - {num_workers, 4} + {num_workers, 4}, + {max_concurrent, 10000} ]} ]. diff --git a/priv/hornbeam_asgi_worker.py b/priv/hornbeam_asgi_worker.py index 223e053..cd7e7a1 100644 --- a/priv/hornbeam_asgi_worker.py +++ b/priv/hornbeam_asgi_worker.py @@ -12,20 +12,21 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Channel-based ASGI worker for hornbeam. +"""High-performance ASGI worker using py_event_loop. -This module provides a high-performance ASGI worker that uses channels -for communication with Erlang. It supports: +This module provides an ASGI worker that uses the Erlang event loop +for true async execution. It supports: -- Async task execution with event loop integration -- Streaming responses via channel -- Long-lived tasks handling multiple requests +- Full async execution via py_event_loop +- Streaming responses via erlang.send() +- Sub-millisecond latency using Erlang timers +- No message passing overhead for continuations -Flow: -1. Erlang creates channel, sends scope/body, calls handle_request -2. Python receives request from channel -3. Python processes request with ASGI app -4. Python sends response back via reply() to caller +Architecture: +1. Erlang submits task to py_event_loop:create_task() +2. Python runs async app with Erlang-backed event loop +3. Response streamed via erlang.send() as chunks arrive +4. Event-driven - no polling, no busy waiting """ import asyncio @@ -34,38 +35,21 @@ import threading from typing import Any, Callable, Dict, List, Optional, Tuple -# Install erlang event loop policy -try: - from erlang_loop import get_event_loop_policy - asyncio.set_event_loop_policy(get_event_loop_policy()) -except (ImportError, RuntimeError): - pass - try: import erlang - from erlang import Channel, ChannelClosed, reply HAS_ERLANG = True except ImportError: HAS_ERLANG = False + erlang = None + +# ============================================================================ +# App loading +# ============================================================================ -# Thread-safe app cache _app_cache: Dict[Tuple[str, str], Callable] = {} _app_cache_lock = threading.Lock() -# Thread-local event loop -_thread_local = threading.local() - - -def _get_event_loop() -> asyncio.AbstractEventLoop: - """Get or create a persistent event loop for this thread.""" - loop = getattr(_thread_local, 'loop', None) - if loop is None or loop.is_closed(): - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - _thread_local.loop = loop - return loop - def _load_app(module_name: str, callable_name: str) -> Callable: """Load an ASGI application with thread-safe caching.""" @@ -88,567 +72,419 @@ def _load_app(module_name: str, callable_name: str) -> Callable: return app +# ============================================================================ +# Helpers +# ============================================================================ + +def _to_bytes(val) -> bytes: + """Convert string to bytes.""" + if isinstance(val, bytes): + return val + if isinstance(val, str): + return val.encode('utf-8') + return b'' + + def _to_str(val) -> str: - """Convert bytes/None to string.""" - if val is None: - return '' + """Convert bytes to string.""" if isinstance(val, bytes): return val.decode('utf-8', errors='replace') - return str(val) if not isinstance(val, str) else val + if isinstance(val, str): + return val + return str(val) if val is not None else '' + +# ============================================================================ +# Pre-allocated messages +# ============================================================================ -# Pre-allocated message _DISCONNECT_MSG = {'type': 'http.disconnect'} -class _ASGIResponse: - """Collects ASGI response messages.""" - __slots__ = ('status', 'headers', 'body_parts', 'more_body', - 'early_hints', 'streaming') +# ============================================================================ +# Main entry point: handle_asgi (async) +# ============================================================================ - def __init__(self): - self.status = None - self.headers = [] - self.body_parts = [] - self.more_body = False - self.early_hints = [] - self.streaming = False +async def handle_asgi(caller_pid, app_module: bytes, app_callable: bytes, + scope: dict, body: bytes): + """Handle an ASGI request asynchronously. - async def send(self, message: dict) -> None: - """ASGI send callable.""" - msg_type = message.get('type', '') + This is the main entry point called from Erlang via py_event_loop. + Uses erlang.send() to stream response directly to caller. - if msg_type == 'http.response.start': - self.status = message.get('status', 200) - self.headers = message.get('headers', []) + Args: + caller_pid: Erlang PID to send response to + app_module: Python module containing ASGI app (bytes) + app_callable: Name of ASGI callable in module (bytes) + scope: ASGI scope dict + body: Request body bytes - elif msg_type == 'http.response.body': - body_part = message.get('body', b'') - if isinstance(body_part, str): - body_part = body_part.encode('utf-8') - if body_part: - self.body_parts.append(body_part) - self.more_body = message.get('more_body', False) + Note: This function MUST be called via py_event_loop:create_task() + or erlang.run() for proper async execution. + """ + if not HAS_ERLANG: + return - elif msg_type == 'http.response.informational': - status = message.get('status', 100) - headers = message.get('headers', []) - if status == 103: - self.early_hints.append(headers) + # Convert bytes to strings + module_name = _to_str(app_module) + callable_name = _to_str(app_callable) + + # Ensure body is bytes + if isinstance(body, str): + body = body.encode('utf-8') + elif not isinstance(body, bytes): + body = b'' + + try: + # Load app + app = _load_app(module_name, callable_name) + # Create receive/send callables + receive = _ASGIReceive(body) + send = _ASGISend(caller_pid) -class _ReceiveCallable: - """Optimized receive callable for ASGI.""" - __slots__ = ('body', 'body_sent', '_request_msg') + # Run ASGI app + await app(scope, receive, send) - def __init__(self, body: bytes): + # Ensure completion is signaled + if not send.finished: + if not send.headers_sent: + erlang.send(caller_pid, (b'headers', 500, [])) + erlang.send(caller_pid, b'done') + + except Exception as e: + try: + erlang.send(caller_pid, (b'error', str(e).encode('utf-8'))) + except Exception: + pass + + +class _ASGIReceive: + """ASGI receive callable with streaming support.""" + __slots__ = ('body', 'body_sent', 'channel', 'disconnected') + + def __init__(self, body: bytes, channel=None): self.body = body self.body_sent = False - self._request_msg = { - 'type': 'http.request', - 'body': body, - 'more_body': False - } + self.channel = channel # Optional channel for streaming body + self.disconnected = False + + async def __call__(self) -> dict: + if self.disconnected: + return _DISCONNECT_MSG - async def __call__(self): if not self.body_sent: self.body_sent = True - return self._request_msg + + # If we have a channel, read body from it + if self.channel is not None: + body, more_body = await self._read_from_channel() + if not more_body: + self.body_sent = True + return {'type': 'http.request', 'body': body, 'more_body': more_body} + + # Use pre-loaded body + return {'type': 'http.request', 'body': self.body, 'more_body': False} + return _DISCONNECT_MSG + async def _read_from_channel(self) -> Tuple[bytes, bool]: + """Read body chunk from channel (for streaming uploads).""" + from erlang import Channel, ChannelClosed + + try: + msg = self.channel.receive(timeout=30000) + + if msg == b'body_done' or msg == 'body_done': + return b'', False + + if isinstance(msg, tuple) and len(msg) == 2: + tag, chunk = msg + if tag == b'body_chunk' or tag == 'body_chunk': + return _to_bytes(chunk), True + + except ChannelClosed: + self.disconnected = True + + return b'', False + + +class _ASGISend: + """ASGI send callable that streams to Erlang via erlang.send().""" + __slots__ = ('caller_pid', 'status', 'headers', 'headers_sent', + 'body_parts', 'finished', 'buffering') -class _StreamingResponse: - """Response handler that streams chunks to Erlang.""" - __slots__ = ('caller_pid', 'status', 'headers', 'headers_sent', 'early_hints') + # Buffer small responses before sending + BUFFER_THRESHOLD = 65536 - def __init__(self, caller_pid): + def __init__(self, caller_pid, buffering: bool = True): self.caller_pid = caller_pid self.status = None self.headers = [] self.headers_sent = False - self.early_hints = [] + self.body_parts = [] + self.finished = False + self.buffering = buffering - async def send(self, message: dict) -> None: - """Stream response messages to Erlang via reply().""" + async def __call__(self, message: dict) -> None: msg_type = message.get('type', '') if msg_type == 'http.response.start': self.status = message.get('status', 200) self.headers = message.get('headers', []) - # Send headers immediately for streaming - reply(self.caller_pid, ('headers', self.status, self.headers)) - self.headers_sent = True + + if not self.buffering: + # Send headers immediately for streaming + erlang.send(self.caller_pid, (b'headers', self.status, self.headers)) + self.headers_sent = True elif msg_type == 'http.response.body': body_part = message.get('body', b'') if isinstance(body_part, str): body_part = body_part.encode('utf-8') - if body_part: - reply(self.caller_pid, ('chunk', body_part)) - more_body = message.get('more_body', False) - if not more_body: - reply(self.caller_pid, 'done') + + if self.buffering and not self.headers_sent: + # Buffer body parts + if body_part: + self.body_parts.append(body_part) + + total_size = sum(len(p) for p in self.body_parts) + + if not more_body: + # Done - send complete response + body = b''.join(self.body_parts) + erlang.send(self.caller_pid, + (b'response', self.status or 500, self.headers, body)) + self.finished = True + + elif total_size >= self.BUFFER_THRESHOLD: + # Switch to streaming + erlang.send(self.caller_pid, (b'headers', self.status or 500, self.headers)) + self.headers_sent = True + for part in self.body_parts: + erlang.send(self.caller_pid, (b'chunk', part)) + self.body_parts.clear() + self.buffering = False + + else: + # Streaming mode + if not self.headers_sent: + erlang.send(self.caller_pid, (b'headers', self.status or 500, self.headers)) + self.headers_sent = True + + if body_part: + erlang.send(self.caller_pid, (b'chunk', body_part)) + + if not more_body: + erlang.send(self.caller_pid, b'done') + self.finished = True elif msg_type == 'http.response.informational': + # Early hints (103) status = message.get('status', 100) headers = message.get('headers', []) if status == 103: - self.early_hints.append(headers) - - -def handle_request(channel_ref, caller_pid, app_module: str, - app_callable: str) -> None: - """Handle an ASGI request using channel-based I/O. + erlang.send(self.caller_pid, (b'early_hints', headers)) - Args: - channel_ref: Reference to py_channel for receiving scope/body - caller_pid: Erlang PID to send response to - app_module: Python module containing ASGI app - app_callable: Name of ASGI callable in module - """ - if not HAS_ERLANG: - return - - ch = Channel(channel_ref) - - try: - # 1. Receive request from channel - msg = ch.receive() - - if not isinstance(msg, tuple) or len(msg) < 5: - reply(caller_pid, ('error', 'invalid request tuple')) - return - - tag = msg[0] - if tag != 'request': - reply(caller_pid, ('error', f'expected request, got {tag}')) - return - - # Unpack: (request, app_module, app_callable, scope, body) - _, _, _, scope, body = msg - - # 2. Ensure body is bytes - if isinstance(body, str): - body = body.encode('utf-8') - elif not isinstance(body, bytes): - body = b'' - - # 3. Load app - app = _load_app(app_module, app_callable) - - # 4. Create response collector and receive callable - response = _ASGIResponse() - receive = _ReceiveCallable(body) - - # 5. Run the app - loop = _get_event_loop() - coro = app(scope, receive, response.send) - loop.run_until_complete(coro) - - # 6. Send response to Erlang - status = response.status or 500 - headers = response.headers - body_bytes = b''.join(response.body_parts) - - if response.early_hints: - reply(caller_pid, ('response', status, headers, body_bytes, - response.early_hints)) - else: - reply(caller_pid, ('response', status, headers, body_bytes)) + elif msg_type == 'http.disconnect': + self.finished = True - except ChannelClosed: - pass - except Exception as e: - try: - reply(caller_pid, ('error', str(e))) - except Exception: - pass +# ============================================================================ +# Streaming request body support +# ============================================================================ -def handle_request_streaming(channel_ref, caller_pid, app_module: str, - app_callable: str) -> None: - """Handle an ASGI request with streaming response. +async def handle_asgi_streaming(caller_pid, app_module: bytes, app_callable: bytes, + scope: dict, channel_ref): + """Handle an ASGI request with streaming request body. - This variant sends response chunks directly to Erlang as they're - produced, enabling real-time streaming (SSE, etc). + This variant reads the request body from a channel, enabling + streaming uploads without buffering the entire body. Args: - channel_ref: Reference to py_channel for receiving scope/body caller_pid: Erlang PID to send response to - app_module: Python module containing ASGI app - app_callable: Name of ASGI callable in module + app_module: Python module containing ASGI app (bytes) + app_callable: Name of ASGI callable in module (bytes) + scope: ASGI scope dict + channel_ref: Channel reference for receiving body chunks """ if not HAS_ERLANG: return - ch = Channel(channel_ref) - - try: - # 1. Receive request from channel - msg = ch.receive() - - if not isinstance(msg, tuple) or len(msg) < 5: - reply(caller_pid, ('error', 'invalid request tuple')) - return - - tag = msg[0] - if tag != 'request': - reply(caller_pid, ('error', f'expected request, got {tag}')) - return + from erlang import Channel - _, _, _, scope, body = msg + module_name = _to_str(app_module) + callable_name = _to_str(app_callable) - # 2. Ensure body is bytes - if isinstance(body, str): - body = body.encode('utf-8') - elif not isinstance(body, bytes): - body = b'' - - # 3. Load app - app = _load_app(app_module, app_callable) + try: + app = _load_app(module_name, callable_name) - # 4. Create streaming response handler - response = _StreamingResponse(caller_pid) - receive = _ReceiveCallable(body) + channel = Channel(channel_ref) + receive = _ASGIReceive(b'', channel=channel) + send = _ASGISend(caller_pid, buffering=False) # Always stream response - # 5. Run the app - responses stream as they're produced - loop = _get_event_loop() - coro = app(scope, receive, response.send) - loop.run_until_complete(coro) + await app(scope, receive, send) - # 6. Ensure completion is signaled if not already - if not response.headers_sent: - reply(caller_pid, ('headers', 500, [])) - reply(caller_pid, 'done') + if not send.finished: + if not send.headers_sent: + erlang.send(caller_pid, (b'headers', 500, [])) + erlang.send(caller_pid, b'done') - except ChannelClosed: - pass except Exception as e: try: - reply(caller_pid, ('error', str(e))) + erlang.send(caller_pid, (b'error', str(e).encode('utf-8'))) except Exception: pass -def handle_request_fast(caller_pid, app_module: str, app_callable: str, - scope: dict, body: bytes) -> None: - """Handle an ASGI request directly (no channel). +# ============================================================================ +# Synchronous wrapper for context_call +# ============================================================================ + +def handle_asgi_sync(caller_pid, app_module: bytes, app_callable: bytes, + scope: dict, body: bytes): + """Synchronous wrapper that runs handle_asgi with erlang.run(). - This is the fastest path when channel overhead isn't needed. + This is used when calling from py_nif:context_call() which expects + a synchronous function. Uses erlang.run() to get proper event loop. Args: caller_pid: Erlang PID to send response to - app_module: Python module containing ASGI app - app_callable: Name of ASGI callable in module + app_module: Python module containing ASGI app (bytes) + app_callable: Name of ASGI callable in module (bytes) scope: ASGI scope dict body: Request body bytes """ if not HAS_ERLANG: - return + return b'error' try: - # 1. Ensure body is bytes - if isinstance(body, str): - body = body.encode('utf-8') - elif not isinstance(body, bytes): - body = b'' - - # 2. Load app - app = _load_app(app_module, app_callable) - - # 3. Create response collector and receive callable - response = _ASGIResponse() - receive = _ReceiveCallable(body) - - # 4. Run the app - loop = _get_event_loop() - coro = app(scope, receive, response.send) - loop.run_until_complete(coro) - - # 5. Send response to Erlang - status = response.status or 500 - headers = response.headers - body_bytes = b''.join(response.body_parts) - - if response.early_hints: - reply(caller_pid, ('response', status, headers, body_bytes, - response.early_hints)) - else: - reply(caller_pid, ('response', status, headers, body_bytes)) - + # Use erlang.run() for proper Erlang event loop integration + erlang.run(handle_asgi(caller_pid, app_module, app_callable, scope, body)) + return b'done' except Exception as e: try: - reply(caller_pid, ('error', str(e))) + erlang.send(caller_pid, (b'error', str(e).encode('utf-8'))) except Exception: pass + return b'error' -# ============================================================================= -# Long-lived task support (for handling multiple requests per context) -# ============================================================================= - -class ASGITask: - """Long-lived async task that processes multiple requests. - - This task stays alive and receives work from a work channel, - processing requests concurrently using asyncio. - """ - - def __init__(self, work_channel_ref): - self.work_channel = Channel(work_channel_ref) - self.running = True - - async def run(self): - """Main task loop - receives and processes requests.""" - while self.running: - try: - # Wait for work from channel - msg = self.work_channel.receive() - - if msg == 'stop': - self.running = False - break - - if isinstance(msg, tuple) and msg[0] == 'work': - # (work, request_channel_ref) - request_ch_ref = msg[1] - # Process concurrently - asyncio.create_task(self._handle_request(request_ch_ref)) - - except ChannelClosed: - self.running = False - break - - async def _handle_request(self, request_ch_ref): - """Handle a single request from its channel.""" - ch = Channel(request_ch_ref) - - try: - msg = ch.receive() - - if not isinstance(msg, tuple) or len(msg) < 5: - return +# ============================================================================ +# WebSocket support +# ============================================================================ - tag, app_module, app_callable, scope, body, caller_pid = msg +async def handle_websocket(caller_pid, app_module: bytes, app_callable: bytes, + scope: dict, channel_ref): + """Handle a WebSocket connection. - if isinstance(body, str): - body = body.encode('utf-8') - elif not isinstance(body, bytes): - body = b'' - - app = _load_app(app_module, app_callable) - response = _ASGIResponse() - receive = _ReceiveCallable(body) - - await app(scope, receive, response.send) - - status = response.status or 500 - headers = response.headers - body_bytes = b''.join(response.body_parts) - - reply(caller_pid, ('response', status, headers, body_bytes)) - - except Exception as e: - try: - # Best effort error reporting - if 'caller_pid' in dir(): - reply(caller_pid, ('error', str(e))) - except Exception: - pass - - -def run_asgi_task(work_channel_ref) -> None: - """Start a long-lived ASGI task. - - This function runs until the work channel is closed or 'stop' is received. + WebSocket messages are received from and sent to the channel. + The app can send/receive messages asynchronously. Args: - work_channel_ref: Channel reference for receiving work items + caller_pid: Erlang PID for control messages + app_module: Python module containing ASGI app (bytes) + app_callable: Name of ASGI callable in module (bytes) + scope: ASGI scope dict with type='websocket' + channel_ref: Channel reference for WebSocket messages """ if not HAS_ERLANG: return - task = ASGITask(work_channel_ref) - loop = _get_event_loop() - loop.run_until_complete(task.run()) + from erlang import Channel, ChannelClosed + module_name = _to_str(app_module) + callable_name = _to_str(app_callable) -# ============================================================================= -# Persistent worker loop (for hornbeam_worker_pool) -# ============================================================================= + channel = Channel(channel_ref) + connected = False + closed = False -def worker_loop(channel_ref, arbiter_pid, worker_id: str, - app_module: str, app_callable: str) -> str: - """Persistent ASGI worker - loops until stopped. + async def receive() -> dict: + nonlocal connected, closed - This worker receives requests via a channel and processes them continuously, - reducing Python startup overhead for each request. + if closed: + return {'type': 'websocket.disconnect', 'code': 1000} - Args: - channel_ref: Reference to the channel for receiving requests - arbiter_pid: Erlang PID of the arbiter for heartbeat messages - app_module: Python module containing ASGI app - app_callable: Name of ASGI callable in module - worker_id: Unique identifier for this worker (format: mount_id_idx) - - Returns: - 'stopped' when the worker exits cleanly - """ - if not HAS_ERLANG: - return 'no_erlang' - - loop = _get_event_loop() - result = loop.run_until_complete( - _async_worker_loop(channel_ref, arbiter_pid, worker_id, - app_module, app_callable) - ) - return result - - -async def _async_worker_loop(channel_ref, arbiter_pid, worker_id: str, - app_module: str, app_callable: str) -> str: - """Async implementation of the persistent ASGI worker loop.""" - import time - - ch = Channel(channel_ref) - app = _load_app(app_module, app_callable) - last_heartbeat = time.monotonic() - heartbeat_interval = 5.0 # seconds - - while True: - # Maybe send heartbeat - now = time.monotonic() - if now - last_heartbeat >= heartbeat_interval: - try: - reply(arbiter_pid, ('heartbeat', worker_id)) - except Exception: - pass # Arbiter might be restarting - last_heartbeat = now - - # Receive next message (blocking in executor to release event loop) try: - msg = await asyncio.get_event_loop().run_in_executor(None, ch.receive) - except ChannelClosed: - break + msg = channel.receive(timeout=60000) - # Handle control messages - if msg == 'stop': - break + if isinstance(msg, tuple): + tag = msg[0] - # Handle request messages - if isinstance(msg, tuple) and len(msg) >= 2: - tag = msg[0] + if tag == b'connect' or tag == 'connect': + connected = True + return {'type': 'websocket.connect'} - if tag == 'start_request': - # (start_request, caller_pid, scope, body) - _, caller_pid, scope, body = msg - await _process_asgi_request(caller_pid, app, scope, body) + elif tag == b'text' or tag == 'text': + return {'type': 'websocket.receive', 'text': _to_str(msg[1])} - elif tag == 'start_request_streaming': - # (start_request_streaming, caller_pid, scope) - _, caller_pid, scope = msg - body = await _receive_streaming_body_async(ch) - await _process_asgi_request(caller_pid, app, scope, body) + elif tag == b'bytes' or tag == 'bytes': + return {'type': 'websocket.receive', 'bytes': _to_bytes(msg[1])} - return 'stopped' + elif tag == b'disconnect' or tag == 'disconnect': + code = msg[1] if len(msg) > 1 else 1000 + closed = True + return {'type': 'websocket.disconnect', 'code': code} + elif msg == b'connect' or msg == 'connect': + connected = True + return {'type': 'websocket.connect'} -async def _receive_streaming_body_async(ch) -> bytes: - """Receive streaming body chunks from channel asynchronously.""" - chunks = [] - loop = asyncio.get_event_loop() - - while True: - try: - msg = await loop.run_in_executor(None, ch.receive) except ChannelClosed: - break + closed = True + return {'type': 'websocket.disconnect', 'code': 1006} + + return {'type': 'websocket.disconnect', 'code': 1000} + + async def send(message: dict) -> None: + nonlocal connected, closed + + msg_type = message.get('type', '') - if msg == 'body_done': - break - elif isinstance(msg, tuple) and msg[0] == 'body_chunk': - chunk = msg[1] - if isinstance(chunk, bytes): - chunks.append(chunk) - elif isinstance(chunk, str): - chunks.append(chunk.encode('utf-8')) + if msg_type == 'websocket.accept': + connected = True + subprotocol = message.get('subprotocol') + headers = message.get('headers', []) + erlang.send(caller_pid, (b'accept', subprotocol, headers)) - return b''.join(chunks) + elif msg_type == 'websocket.send': + if 'text' in message: + erlang.send(caller_pid, (b'text', message['text'])) + elif 'bytes' in message: + erlang.send(caller_pid, (b'bytes', message['bytes'])) + elif msg_type == 'websocket.close': + code = message.get('code', 1000) + reason = message.get('reason', '') + erlang.send(caller_pid, (b'close', code, reason)) + closed = True -async def _process_asgi_request(caller_pid, app, scope: dict, body: bytes) -> None: - """Process a single ASGI request and send response to caller.""" try: - # Ensure body is bytes - if isinstance(body, str): - body = body.encode('utf-8') - elif not isinstance(body, bytes): - body = b'' - - # Create response collector and receive callable - response = _ASGIResponse() - receive = _ReceiveCallable(body) - - # Run the ASGI app - await app(scope, receive, response.send) - - # Collect response - status = response.status or 500 - headers = response.headers - body_bytes = b''.join(response.body_parts) - - # Send buffered response (small responses) - if response.early_hints: - reply(caller_pid, ('response', status, headers, body_bytes, - response.early_hints)) - else: - reply(caller_pid, ('response', status, headers, body_bytes)) + app = _load_app(module_name, callable_name) + await app(scope, receive, send) + + # Ensure close is sent + if not closed: + erlang.send(caller_pid, (b'close', 1000, b'')) except Exception as e: try: - reply(caller_pid, ('error', str(e))) + erlang.send(caller_pid, (b'error', str(e).encode('utf-8'))) except Exception: pass -class _StreamingASGIResponse: - """Response handler that streams chunks to Erlang for pooled workers.""" - __slots__ = ('caller_pid', 'status', 'headers', 'headers_sent', 'early_hints') - - def __init__(self, caller_pid): - self.caller_pid = caller_pid - self.status = None - self.headers = [] - self.headers_sent = False - self.early_hints = [] - - async def send(self, message: dict) -> None: - """Stream response messages to Erlang via reply().""" - msg_type = message.get('type', '') - - if msg_type == 'http.response.start': - self.status = message.get('status', 200) - self.headers = message.get('headers', []) - # Send headers immediately - reply(self.caller_pid, ('headers', self.status, self.headers)) - self.headers_sent = True - - elif msg_type == 'http.response.body': - body_part = message.get('body', b'') - if isinstance(body_part, str): - body_part = body_part.encode('utf-8') - - if body_part: - reply(self.caller_pid, ('chunk', body_part)) +# ============================================================================ +# Legacy entry points (for backwards compatibility) +# ============================================================================ - more_body = message.get('more_body', False) - if not more_body: - reply(self.caller_pid, 'done') +def handle_request_direct(args_tuple) -> None: + """Legacy entry point - uses sync wrapper.""" + if not HAS_ERLANG: + return - elif msg_type == 'http.response.informational': - status = message.get('status', 100) - headers = message.get('headers', []) - if status == 103: - self.early_hints.append(headers) + channel_ref, caller_pid, app_module, app_callable, scope, body = args_tuple + handle_asgi_sync(caller_pid, app_module, app_callable, scope, body) diff --git a/priv/hornbeam_wsgi_worker.py b/priv/hornbeam_wsgi_worker.py index 92f5c6e..faa3810 100644 --- a/priv/hornbeam_wsgi_worker.py +++ b/priv/hornbeam_wsgi_worker.py @@ -12,18 +12,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Channel-based WSGI worker for hornbeam. - -This module provides a high-performance WSGI worker that uses channels -for communication with Erlang. All I/O flows through the channel, -enabling efficient streaming and backpressure handling. - -Flow: -1. Erlang creates channel, sends environ, calls handle_request -2. Python receives environ from channel -3. Python processes request with WSGI app -4. Python sends headers/body chunks back via reply() to caller pid -5. Python signals completion with 'done' +"""High-performance WSGI worker using schedule_inline. + +This module provides a WSGI worker that uses erlang.schedule_inline() +to release the dirty scheduler between processing steps, enabling +better concurrency. + +Architecture: +1. Erlang calls handle_wsgi() with request data +2. Python processes request, yielding via schedule_inline when needed +3. Response sent via erlang.reply() - streaming or buffered +4. schedule_inline continues processing without message passing overhead + +Key optimizations: +- No channel overhead for simple requests +- schedule_inline releases dirty scheduler (~3x faster than messaging) +- Streaming support for large bodies +- BytesIO pooling for request bodies """ import io @@ -32,13 +37,21 @@ try: import erlang - from erlang import Channel, ChannelClosed, reply + from erlang import reply HAS_ERLANG = True except ImportError: HAS_ERLANG = False + erlang = None + reply = None + + +# ============================================================================ +# Constants and shared instances +# ============================================================================ + +_WSGI_VERSION = (1, 0) -# Pre-allocated error wrapper for wsgi.errors class _WSGIErrorsWrapper: """Minimal wsgi.errors wrapper that routes to logging.""" @@ -58,11 +71,6 @@ def flush(self): pass -# Shared instances -_SHARED_ERRORS = _WSGIErrorsWrapper() -_WSGI_VERSION = (1, 0) - - class FileWrapper: """Efficient file serving wrapper for WSGI.""" @@ -82,7 +90,22 @@ def __next__(self): raise StopIteration -# BytesIO pool for wsgi.input +_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, +} + + +# ============================================================================ +# BytesIO pool +# ============================================================================ + _BYTESIO_POOL: List[io.BytesIO] = [] _BYTESIO_POOL_SIZE = 100 _BYTESIO_POOL_LOCK = threading.Lock() @@ -107,21 +130,15 @@ def _return_bytesio(bio: io.BytesIO) -> None: """Return a BytesIO to the pool.""" with _BYTESIO_POOL_LOCK: if len(_BYTESIO_POOL) < _BYTESIO_POOL_SIZE: + bio.seek(0) + bio.truncate() _BYTESIO_POOL.append(bio) -# Environ template (shared base) -_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 +# ============================================================================ - -# Thread-safe app cache _app_cache: Dict[Tuple[str, str], Callable] = {} _app_cache_lock = threading.Lock() @@ -150,6 +167,10 @@ def _load_app(module_name: str, callable_name: str) -> Callable: return app +# ============================================================================ +# Helpers +# ============================================================================ + def _to_str(val) -> str: """Convert bytes/None to string.""" if val is None: @@ -168,82 +189,28 @@ def _is_none(val) -> bool: ) -def _create_environ(req_tuple) -> dict: - """Create WSGI environ from pre-parsed Erlang tuple. - - Args: - req_tuple: (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 - - # Handle body - if body is None or body == b'' or body == '': - body_bytes = b'' - elif isinstance(body, bytes): - body_bytes = body - elif isinstance(body, str): - body_bytes = body.encode('utf-8') - else: - try: - body_bytes = bytes(body) - except (TypeError, ValueError): - body_bytes = b'' - - wsgi_input = _get_bytesio(body_bytes) - - # Early hints callback - early_hints_list = [] - - def early_hints_callback(headers): - early_hints_list.append(headers) - - # Build environ from template - environ = _ENVIRON_TEMPLATE.copy() - - environ['REQUEST_METHOD'] = _to_str(method) - environ['SCRIPT_NAME'] = _to_str(script_name) if script_name else '' - environ['PATH_INFO'] = _to_str(path_info) - environ['QUERY_STRING'] = _to_str(query_string) - environ['SERVER_NAME'] = _to_str(server[0]) - environ['SERVER_PORT'] = str(server[1]) - environ['SERVER_PROTOCOL'] = _to_str(protocol) - environ['wsgi.url_scheme'] = _to_str(scheme) - environ['wsgi.input'] = wsgi_input - environ['wsgi.errors'] = _SHARED_ERRORS - environ['wsgi.early_hints'] = early_hints_callback - environ['REMOTE_ADDR'] = _to_str(client[0]) - environ['REMOTE_PORT'] = str(client[1]) - environ['_hornbeam.early_hints'] = early_hints_list - environ['_hornbeam.wsgi_input'] = wsgi_input - environ['_hornbeam.lifespan_state'] = lifespan_state - - # Add HTTP_* headers (pre-converted by Erlang) - if wsgi_headers: - for key, value in wsgi_headers.items(): - environ[_to_str(key)] = _to_str(value) - - # Add content-type/length - if not _is_none(content_type): - environ['CONTENT_TYPE'] = _to_str(content_type) - if not _is_none(content_length): - environ['CONTENT_LENGTH'] = _to_str(content_length) +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 - return environ +# ============================================================================ +# Response class +# ============================================================================ class _Response: """WSGI response handler.""" - __slots__ = ('status', 'headers', '_write_buffer') + __slots__ = ('status', 'status_code', 'headers', '_write_buffer') def __init__(self): self.status = None + self.status_code = 500 self.headers = [] self._write_buffer = [] @@ -258,6 +225,7 @@ def start_response(self, status, response_headers, exc_info=None): raise RuntimeError("start_response already called") self.status = status + self.status_code = _parse_status(status) self.headers = list(response_headers) return self._write @@ -267,268 +235,258 @@ def _write(self, data): self._write_buffer.append(data) -def handle_request(channel_ref, caller_pid, app_module: str, app_callable: str) -> None: - """Handle a WSGI request using channel-based I/O. +# ============================================================================ +# Main entry point: handle_wsgi with schedule_inline +# ============================================================================ - This is the main entry point called from Erlang. It: - 1. Receives environ from channel - 2. Processes request with WSGI app - 3. Sends response back via reply() to caller +def handle_wsgi(caller_pid, app_module: bytes, app_callable: bytes, + environ_map: dict, body: bytes): + """Handle a WSGI request using schedule_inline for yielding. + + This is the main entry point called from Erlang via context_call. + Uses schedule_inline to release the dirty scheduler between steps. Args: - channel_ref: Reference to py_channel for receiving environ caller_pid: Erlang PID to send response to - app_module: Python module containing WSGI app - app_callable: Name of WSGI callable in module + app_module: Python module containing WSGI app (bytes) + app_callable: Name of WSGI callable in module (bytes) + environ_map: Pre-built environ dict from Erlang + body: Request body bytes + + Returns: + 'done' on success, or schedule_inline marker for continuation """ if not HAS_ERLANG: - return + return b'error' - ch = Channel(channel_ref) - wsgi_input = None + # Convert bytes to strings + module_name = app_module.decode('utf-8') if isinstance(app_module, bytes) else app_module + callable_name = app_callable.decode('utf-8') if isinstance(app_callable, bytes) else app_callable - try: - # 1. Receive environ from channel - msg = ch.receive() + # Build state for potential continuation + state = { + 'caller': caller_pid, + 'module': module_name, + 'callable': callable_name, + 'environ_map': environ_map, + 'body': body, + 'phase': 'init', + } - if not isinstance(msg, tuple) or len(msg) < 2: - reply(caller_pid, ('error', 'expected environ tuple')) - return + return _wsgi_process(state) - tag, req_tuple = msg[0], msg[1] - if tag != 'environ': - reply(caller_pid, ('error', f'expected environ, got {tag}')) - return - # 2. Build WSGI environ - environ = _create_environ(req_tuple) - wsgi_input = environ.get('_hornbeam.wsgi_input') +def _wsgi_process(state: dict): + """Process WSGI request with schedule_inline continuation. - # 3. Load and call WSGI app - app = _load_app(app_module, app_callable) - response = _Response() + This function handles the actual WSGI processing and can yield + via schedule_inline to release the dirty scheduler. + """ + phase = state.get('phase', 'init') + caller = state['caller'] + wsgi_input = None - result = app(environ, response.start_response) + try: + if phase == 'init': + # Build environ + environ = _build_environ(state['environ_map'], state['body']) + wsgi_input = environ.get('_hornbeam.wsgi_input') + state['wsgi_input'] = wsgi_input - # 4. Parse status code - status_code = 500 - if response.status: - try: - status_str = response.status - if isinstance(status_str, bytes): - status_str = status_str.decode('utf-8') - parts = status_str.split(' ', 1) - status_code = int(parts[0]) - except (ValueError, IndexError): - pass + # Load app + app = _load_app(state['module'], state['callable']) + response = _Response() - # 5. Send headers via reply - reply(caller_pid, ('headers', status_code, response.headers)) + # Call WSGI app + result = app(environ, response.start_response) - # 6. Send any write() buffer content first - for chunk in response._write_buffer: - if chunk: - if isinstance(chunk, str): - chunk = chunk.encode('utf-8') - reply(caller_pid, ('chunk', chunk)) + # Store result iterator for streaming + state['response'] = response + state['result'] = result + state['result_iter'] = iter(result) if not isinstance(result, (bytes, bytearray)) else None + state['body_parts'] = list(response._write_buffer) # Start with write() buffer + state['total_size'] = sum(len(p) for p in state['body_parts']) + state['streaming'] = False + state['phase'] = 'collect' - # 7. Stream body chunks - try: - if isinstance(result, (bytes, bytearray)): - reply(caller_pid, ('chunk', bytes(result))) - else: - for chunk in result: - if chunk: - if isinstance(chunk, str): - chunk = chunk.encode('utf-8') - elif isinstance(chunk, bytearray): - chunk = bytes(chunk) - reply(caller_pid, ('chunk', chunk)) - finally: - if hasattr(result, 'close'): - result.close() + # Continue to collection phase + return _wsgi_collect(state) - # 8. Signal completion - reply(caller_pid, 'done') + elif phase == 'collect': + return _wsgi_collect(state) + + elif phase == 'stream': + return _wsgi_stream(state) - except ChannelClosed: - # Channel was closed, nothing to do - pass except Exception as e: try: - reply(caller_pid, ('error', str(e))) + reply(caller, (b'error', str(e).encode('utf-8'))) except Exception: pass - finally: - # Return BytesIO to pool - if wsgi_input is not None: - _return_bytesio(wsgi_input) - - -def worker_loop(channel_ref, arbiter_pid, worker_id: str, - app_module: str, app_callable: str) -> str: - """Persistent WSGI worker - loops until stopped. - - This worker receives requests via a channel and processes them continuously, - reducing Python startup overhead for each request. + return b'error' - Args: - channel_ref: Reference to the channel for receiving requests - arbiter_pid: Erlang PID of the arbiter for heartbeat messages - app_module: Python module containing WSGI app - app_callable: Name of WSGI callable in module - worker_id: Unique identifier for this worker (format: mount_id_idx) + finally: + # Return BytesIO to pool if we're done + if phase == 'done' and 'wsgi_input' in state: + wsgi_input = state.get('wsgi_input') + if wsgi_input is not None: + _return_bytesio(wsgi_input) + + +def _wsgi_collect(state: dict): + """Collect response body, switching to streaming if needed.""" + BUFFER_THRESHOLD = 65536 + CHUNK_COUNT_YIELD = 10 # Yield after this many chunks + + caller = state['caller'] + response = state['response'] + result = state['result'] + result_iter = state.get('result_iter') + body_parts = state['body_parts'] + total_size = state['total_size'] + streaming = state['streaming'] + chunks_processed = 0 - Returns: - 'stopped' when the worker exits cleanly - """ - if not HAS_ERLANG: - return 'no_erlang' + try: + if isinstance(result, (bytes, bytearray)): + # Single bytes result + chunk = bytes(result) + if total_size + len(chunk) < BUFFER_THRESHOLD: + body_parts.append(chunk) + # Send buffered response + body = b''.join(body_parts) + reply(caller, (b'response', response.status_code, response.headers, body)) + state['phase'] = 'done' + return b'done' + else: + # Switch to streaming + reply(caller, (b'headers', response.status_code, response.headers)) + for part in body_parts: + reply(caller, (b'chunk', part)) + reply(caller, (b'chunk', chunk)) + reply(caller, b'done') + state['phase'] = 'done' + return b'done' + else: + # Iterable result - process chunks + while True: + try: + chunk = next(result_iter) + except StopIteration: + break + + if chunk: + if isinstance(chunk, str): + chunk = chunk.encode('utf-8') + elif isinstance(chunk, bytearray): + chunk = bytes(chunk) + + chunks_processed += 1 + + if not streaming and total_size + len(chunk) < BUFFER_THRESHOLD: + body_parts.append(chunk) + total_size += len(chunk) + else: + # Switch to streaming mode + if not streaming: + reply(caller, (b'headers', response.status_code, response.headers)) + for part in body_parts: + reply(caller, (b'chunk', part)) + body_parts.clear() + streaming = True + state['streaming'] = True + + reply(caller, (b'chunk', chunk)) + + # Yield periodically to let other work run + if chunks_processed >= CHUNK_COUNT_YIELD: + state['body_parts'] = body_parts + state['total_size'] = total_size + state['phase'] = 'collect' + # Use schedule_inline to release scheduler and continue + return erlang.schedule_inline( + 'hornbeam_wsgi_worker', '_wsgi_collect', + args=[state] + ) + + # Done iterating + if hasattr(result, 'close'): + result.close() - import time + if streaming: + reply(caller, b'done') + else: + body = b''.join(body_parts) + reply(caller, (b'response', response.status_code, response.headers, body)) - ch = Channel(channel_ref) - app = _load_app(app_module, app_callable) - last_heartbeat = time.monotonic() - heartbeat_interval = 5.0 # seconds + state['phase'] = 'done' + return b'done' - while True: - # Maybe send heartbeat - now = time.monotonic() - if now - last_heartbeat >= heartbeat_interval: + except Exception as e: + if hasattr(result, 'close'): try: - reply(arbiter_pid, ('heartbeat', worker_id)) + result.close() except Exception: - pass # Arbiter might be restarting - last_heartbeat = now - - # Receive next message (blocking, releases GIL) - try: - msg = ch.receive() - except ChannelClosed: - break - - # Handle control messages - if msg == 'stop': - break - - # Handle request messages - if isinstance(msg, tuple) and len(msg) >= 2: - tag = msg[0] - - if tag == 'start_request': - # (start_request, caller_pid, req_info, body) - _, caller_pid, req_info, body = msg - _process_wsgi_request(caller_pid, app, req_info, body) - - elif tag == 'start_request_streaming': - # (start_request_streaming, caller_pid, req_info) - _, caller_pid, req_info = msg - body = _receive_streaming_body(ch) - _process_wsgi_request(caller_pid, app, req_info, body) - - return 'stopped' - - -def _receive_streaming_body(ch) -> bytes: - """Receive streaming body chunks from channel.""" - chunks = [] - while True: - try: - msg = ch.receive() - except ChannelClosed: - break - - if msg == 'body_done': - break - elif isinstance(msg, tuple) and msg[0] == 'body_chunk': - chunk = msg[1] - if isinstance(chunk, bytes): - chunks.append(chunk) - elif isinstance(chunk, str): - chunks.append(chunk.encode('utf-8')) + pass + reply(caller, (b'error', str(e).encode('utf-8'))) + state['phase'] = 'done' + return b'error' - return b''.join(chunks) - -def _process_wsgi_request(caller_pid, app, req_info, body) -> None: - """Process a single WSGI request and send response to caller.""" - wsgi_input = None +def _wsgi_stream(state: dict): + """Stream remaining response body chunks.""" + # This is called when we've already sent headers and are streaming + caller = state['caller'] + result_iter = state.get('result_iter') + CHUNK_COUNT_YIELD = 10 + chunks_processed = 0 try: - # Build environ with body - req_tuple = _build_req_tuple_with_body(req_info, body) - environ = _create_environ(req_tuple) - wsgi_input = environ.get('_hornbeam.wsgi_input') - - # Create response handler - response = _Response() - - # Call WSGI app - result = app(environ, response.start_response) - - # Parse status code - status_code = 500 - if response.status: + while True: try: - status_str = response.status - if isinstance(status_str, bytes): - status_str = status_str.decode('utf-8') - parts = status_str.split(' ', 1) - status_code = int(parts[0]) - except (ValueError, IndexError): - pass - - # Send headers - reply(caller_pid, ('headers', status_code, response.headers)) + chunk = next(result_iter) + except StopIteration: + break - # Send write() buffer content first - for chunk in response._write_buffer: if chunk: if isinstance(chunk, str): chunk = chunk.encode('utf-8') - reply(caller_pid, ('chunk', chunk)) + elif isinstance(chunk, bytearray): + chunk = bytes(chunk) - # Stream body chunks - try: - if isinstance(result, (bytes, bytearray)): - reply(caller_pid, ('chunk', bytes(result))) - else: - for chunk in result: - if chunk: - if isinstance(chunk, str): - chunk = chunk.encode('utf-8') - elif isinstance(chunk, bytearray): - chunk = bytes(chunk) - reply(caller_pid, ('chunk', chunk)) - finally: - if hasattr(result, 'close'): - result.close() + reply(caller, (b'chunk', chunk)) + chunks_processed += 1 - # Signal completion - reply(caller_pid, 'done') + if chunks_processed >= CHUNK_COUNT_YIELD: + state['phase'] = 'stream' + return erlang.schedule_inline( + 'hornbeam_wsgi_worker', '_wsgi_stream', + args=[state] + ) - except Exception as e: - try: - reply(caller_pid, ('error', str(e))) - except Exception: - pass - finally: - # Return BytesIO to pool - if wsgi_input is not None: - _return_bytesio(wsgi_input) + # Done + result = state['result'] + if hasattr(result, 'close'): + result.close() + reply(caller, b'done') + state['phase'] = 'done' + return b'done' -def _build_req_tuple_with_body(req_info, body) -> tuple: - """Build request tuple from req_info dict and body. + except Exception as e: + result = state.get('result') + if result and hasattr(result, 'close'): + try: + result.close() + except Exception: + pass + reply(caller, (b'error', str(e).encode('utf-8'))) + state['phase'] = 'done' + return b'error' - Args: - req_info: Dict with request metadata (method, path, headers, etc.) - body: Request body bytes - Returns: - Tuple in the format expected by _create_environ - """ +def _build_environ(environ_map: dict, body: bytes) -> dict: + """Build WSGI environ from Erlang map and body.""" # Handle body if body is None or body == b'': body_bytes = b'' @@ -539,102 +497,140 @@ def _build_req_tuple_with_body(req_info, body) -> tuple: else: body_bytes = b'' - # Extract fields from req_info (map from Erlang) - method = req_info.get(b'method', b'GET') - script_name = req_info.get(b'script_name', b'') - path_info = req_info.get(b'path_info', b'/') - query_string = req_info.get(b'query_string', b'') - wsgi_headers = req_info.get(b'wsgi_headers', {}) - content_type = req_info.get(b'content_type') - content_length = req_info.get(b'content_length') - server = req_info.get(b'server', (b'localhost', 80)) - client = req_info.get(b'client', (b'127.0.0.1', 0)) - scheme = req_info.get(b'scheme', b'http') - protocol = req_info.get(b'protocol', b'HTTP/1.1') - lifespan_state = req_info.get(b'lifespan_state', {}) - - return ( - method, script_name, path_info, query_string, wsgi_headers, - content_type, content_length, body_bytes, server, client, scheme, - protocol, lifespan_state - ) + wsgi_input = _get_bytesio(body_bytes) + # Build environ from template + environ = _ENVIRON_TEMPLATE.copy() -def handle_request_fast(caller_pid, app_module: str, app_callable: str, - req_tuple) -> None: - """Handle a WSGI request with pre-parsed tuple (no channel). + # Copy from environ_map, converting bytes to strings + for key, value in environ_map.items(): + str_key = key.decode('utf-8') if isinstance(key, bytes) else str(key) + if isinstance(value, bytes): + str_value = value.decode('utf-8', errors='replace') + elif value is None or (hasattr(value, '__class__') and value.__class__.__name__ == 'Atom'): + str_value = '' + else: + str_value = value + environ[str_key] = str_value - This is an alternative entry point that receives the request tuple - directly, bypassing channel overhead for simple requests. + # Set required WSGI keys + environ['wsgi.input'] = wsgi_input + environ['wsgi.errors'] = _SHARED_ERRORS + environ['_hornbeam.wsgi_input'] = wsgi_input - Args: - caller_pid: Erlang PID to send response to - app_module: Python module containing WSGI app - app_callable: Name of WSGI callable in module - req_tuple: Pre-parsed request tuple from Erlang - """ - if not HAS_ERLANG: - return + return environ - wsgi_input = None - try: - # 1. Build WSGI environ directly from tuple - environ = _create_environ(req_tuple) - wsgi_input = environ.get('_hornbeam.wsgi_input') +# ============================================================================ +# Streaming request body support +# ============================================================================ - # 2. Load and call WSGI app - app = _load_app(app_module, app_callable) - response = _Response() +class StreamingBodyReader: + """File-like object that reads body chunks from Erlang channel.""" - result = app(environ, response.start_response) + def __init__(self, channel, content_length: Optional[int] = None): + self.channel = channel + self.content_length = content_length + self._buffer = b'' + self._eof = False + self._bytes_read = 0 - # 3. Parse status code - status_code = 500 - if response.status: - try: - status_str = response.status - if isinstance(status_str, bytes): - status_str = status_str.decode('utf-8') - parts = status_str.split(' ', 1) - status_code = int(parts[0]) - except (ValueError, IndexError): - pass + def read(self, size: int = -1) -> bytes: + """Read up to size bytes from the body.""" + if self._eof: + return b'' - # 4. Send headers - reply(caller_pid, ('headers', status_code, response.headers)) + # If we have enough in buffer, return it + if size > 0 and len(self._buffer) >= size: + result = self._buffer[:size] + self._buffer = self._buffer[size:] + self._bytes_read += len(result) + return result - # 5. Send write() buffer - for chunk in response._write_buffer: - if chunk: - if isinstance(chunk, str): - chunk = chunk.encode('utf-8') - reply(caller_pid, ('chunk', chunk)) + # Need to read more from channel + from erlang import Channel, ChannelClosed - # 6. Stream body try: - if isinstance(result, (bytes, bytearray)): - reply(caller_pid, ('chunk', bytes(result))) - else: - for chunk in result: - if chunk: - if isinstance(chunk, str): - chunk = chunk.encode('utf-8') - elif isinstance(chunk, bytearray): - chunk = bytes(chunk) - reply(caller_pid, ('chunk', chunk)) - finally: - if hasattr(result, 'close'): - result.close() + while not self._eof: + if size > 0 and len(self._buffer) >= size: + break + + msg = self.channel.receive(timeout=30000) + + if msg == b'body_done' or msg == 'body_done': + self._eof = True + break + elif isinstance(msg, tuple) and len(msg) == 2: + tag, chunk = msg + if tag == b'body_chunk' or tag == 'body_chunk': + if isinstance(chunk, bytes): + self._buffer += chunk + elif isinstance(chunk, str): + self._buffer += chunk.encode('utf-8') - # 7. Signal completion - reply(caller_pid, 'done') + except ChannelClosed: + self._eof = True - except Exception as e: - try: - reply(caller_pid, ('error', str(e))) - except Exception: - pass - finally: - if wsgi_input is not None: - _return_bytesio(wsgi_input) + # Return requested amount + if size < 0: + result = self._buffer + self._buffer = b'' + else: + result = self._buffer[:size] + self._buffer = self._buffer[size:] + + self._bytes_read += len(result) + return result + + def readline(self, size: int = -1) -> bytes: + """Read a line from the body.""" + # Simple implementation - read until newline + result = b'' + while True: + if size > 0 and len(result) >= size: + break + chunk = self.read(1) + if not chunk: + break + result += chunk + if chunk == b'\n': + break + return result + + def readlines(self, hint: int = -1) -> List[bytes]: + """Read all lines from the body.""" + lines = [] + total = 0 + while True: + line = self.readline() + if not line: + break + lines.append(line) + total += len(line) + if hint > 0 and total >= hint: + break + return lines + + def __iter__(self): + return self + + def __next__(self): + line = self.readline() + if not line: + raise StopIteration + return line + + +# ============================================================================ +# Legacy entry points (for backwards compatibility) +# ============================================================================ + +def handle_request_direct(args_tuple) -> None: + """Legacy entry point - redirects to handle_wsgi.""" + if not HAS_ERLANG: + return + + channel_ref, caller_pid, app_module, app_callable, environ, body = args_tuple + + # Call new implementation + handle_wsgi(caller_pid, app_module, app_callable, environ, body) diff --git a/rebar.config b/rebar.config index 1899428..f840efa 100644 --- a/rebar.config +++ b/rebar.config @@ -20,7 +20,7 @@ {deps, [ {cowboy, "2.12.0"}, - {erlang_python, "2.1.0"} + {erlang_python, {git, "https://github.com/benoitc/erlang-python.git", {branch, "main"}}} ]}. {shell, [ diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl index 22bd70d..09e70a4 100644 --- a/src/hornbeam_handler.erl +++ b/src/hornbeam_handler.erl @@ -15,8 +15,14 @@ %%% @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). @@ -39,10 +45,7 @@ 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, - pool_enabled => maps:get(pool_enabled, Mount, false), - mount_id => maps:get(mount_id, Mount, undefined), - workers => maps:get(workers, Mount, 4) + path_info => PathInfo }, WorkerClass = maps:get(worker_class, Mount), handle_request(WorkerClass, Req, NewState); @@ -84,136 +87,94 @@ handle_websocket_upgrade(Req, State) -> hornbeam_websocket:init(Req, State). %%% ============================================================================ -%%% WSGI Handler +%%% WSGI Handler - uses context_call with schedule_inline %%% ============================================================================ -handle_wsgi(Req, #{pool_enabled := true, mount_id := MountId, workers := NumWorkers} = State) -> - %% Pooled worker path - single ETS lookup_element - SchedId = erlang:system_info(scheduler_id), - Channel = hornbeam_mounts:get_channel(MountId, SchedId rem NumWorkers), - handle_wsgi_pooled(Req, Channel, State); handle_wsgi(Req, State) -> - %% Non-pooled path - existing behavior - handle_wsgi_direct(Req, State). - -%% @private -%% Dispatch WSGI request to persistent worker pool via channel -handle_wsgi_pooled(Req, Channel, State) -> ReqInfo = build_request_info(Req), ReqInfo1 = hornbeam_http_hooks:run_on_request(ReqInfo), try - TimeoutMs = maps:get(timeout, State, 30000), - - %% Build request info for Python worker - ReqTuple = build_pooled_request_info(Req, State), - - %% Check content-length to decide streaming vs buffered - ContentLength = cowboy_req:header(<<"content-length">>, Req), - case should_buffer_body(ContentLength) of - true -> - %% Small body - read fully and send in single message - {ok, Body, Req2} = cowboy_req:read_body(Req), - py_channel:send(Channel, {start_request, self(), ReqTuple, Body}), - receive_wsgi_response(Req2, ReqInfo1, TimeoutMs, State); - false -> - %% Large/unknown body - stream chunks - py_channel:send(Channel, {start_request_streaming, self(), ReqTuple}), - Req2 = stream_body_to_channel(Channel, Req), - receive_wsgi_response(Req2, ReqInfo1, TimeoutMs, State) - end - catch - Class:Reason:Stack -> - error_logger:error_msg("WSGI pooled handler error: ~p:~p~n~p~n", - [Class, Reason, Stack]), - handle_error(Req, {Class, Reason}, ReqInfo1, State) - end. - -%% @private -%% Original direct WSGI handler (non-pooled path) -handle_wsgi_direct(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 - 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) + %% Build environ map + Environ = build_environ_map(Req, State), + + %% Read request body + {ok, Body, _Req2} = cowboy_req:read_body(Req), + + %% Get context ref from pool (zero-copy via persistent_term) + CtxRef = hornbeam_context_pool:get_context_ref(), + + %% Call Python via context - uses schedule_inline internally + case py_nif:context_call(CtxRef, + <<"hornbeam_wsgi_worker">>, <<"handle_wsgi">>, + [self(), AppModule, AppCallable, Environ, Body], #{}) of + {ok, <<"done">>} -> + %% Response sent via receive loop + receive_wsgi_response(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 +%% Receive WSGI response from Python worker +receive_wsgi_response(Req, ReqInfo, TimeoutMs, State) -> + receive + {<<"headers">>, StatusCode, Headers} -> + %% Streaming response - stream directly to client + CowboyHeaders = convert_headers(Headers), + receive_wsgi_body(Req, StatusCode, CowboyHeaders, TimeoutMs, State); + {<<"response">>, StatusCode, Headers, Body} -> + %% Buffered response (single message) + Response = #{ + <<"status">> => StatusCode, + <<"headers">> => Headers, + <<"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 -%% Context-aware fallback path using py: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:call(PyContext, hornbeam_wsgi_runner, run_wsgi, - [AppModule, AppCallable, Environ1], #{timeout => TimeoutMs}). +%% Receive body chunks - stream directly to client +receive_wsgi_body(Req, StatusCode, CowboyHeaders, TimeoutMs, State) -> + Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req), + stream_body(Req2, TimeoutMs, State). + +stream_body(Req, TimeoutMs, State) -> + receive + {<<"chunk">>, Chunk} -> + ok = cowboy_req:stream_body(Chunk, nofin, Req), + stream_body(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 -%% 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) -> +%% Build environ map for WSGI +build_environ_map(Req, State) -> Method = cowboy_req:method(Req), Path = cowboy_req:path(Req), Qs = cowboy_req:qs(Req), @@ -228,9 +189,6 @@ build_environ_for_nif(Req, State) -> ScriptName = maps:get(script_name, State, <<>>), PathInfo = maps:get(path_info, State, Path), - %% Read body - {ok, Body, _Req2} = cowboy_req:read_body(Req), - %% Build HTTP_* headers HttpHeaders = maps:fold(fun(Name, Value, Acc) -> HeaderKey = header_to_wsgi_key(Name), @@ -247,12 +205,7 @@ build_environ_for_nif(Req, State) -> <<"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 + <<"wsgi.url_scheme">> => Scheme }, %% Merge HTTP headers @@ -270,161 +223,33 @@ build_environ_for_nif(Req, State) -> CL -> Environ2#{<<"CONTENT_LENGTH">> => CL} 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>> - end. - -%% @private -format_protocol('HTTP/1.0') -> <<"HTTP/1.0">>; -format_protocol('HTTP/1.1') -> <<"HTTP/1.1">>; -format_protocol('HTTP/2') -> <<"HTTP/2">>. - -send_wsgi_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, []), - - %% Parse status code - StatusCode = parse_status_code(Status), - - %% Convert headers to cowboy format - CowboyHeaders = convert_headers(Headers), - - %% Send early hints if any (103 responses) - Req1 = send_early_hints(Req, EarlyHints), - - %% Send response - Req2 = cowboy_req:reply(StatusCode, CowboyHeaders, Body, Req1), - {ok, Req2, State}. - -%% @private -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). - -parse_status_code(Status) when is_binary(Status) -> - case binary:split(Status, <<" ">>) of - [CodeBin | _] -> binary_to_integer(CodeBin); - _ -> 500 - end; -parse_status_code(Status) when is_list(Status) -> - parse_status_code(list_to_binary(Status)); -parse_status_code(Status) when is_integer(Status) -> - Status. - %%% ============================================================================ -%%% ASGI Handler +%%% ASGI Handler - uses py_event_loop for full async %%% ============================================================================ -handle_asgi(Req, #{pool_enabled := true, mount_id := MountId, workers := NumWorkers} = State) -> - %% Pooled worker path - single ETS lookup_element - SchedId = erlang:system_info(scheduler_id), - Channel = hornbeam_mounts:get_channel(MountId, SchedId rem NumWorkers), - handle_asgi_pooled(Req, Channel, State); handle_asgi(Req, State) -> - %% Non-pooled path - existing behavior - handle_asgi_direct(Req, State). - -%% @private -%% Dispatch ASGI request to persistent worker pool via channel -handle_asgi_pooled(Req, Channel, State) -> - ReqInfo = build_request_info(Req), - ReqInfo1 = hornbeam_http_hooks:run_on_request(ReqInfo), - - try - TimeoutMs = maps:get(timeout, State, 30000), - - %% Build ASGI scope for Python worker - Scope = build_scope_for_nif(Req, State), - - %% Check content-length to decide streaming vs buffered - ContentLength = cowboy_req:header(<<"content-length">>, Req), - case should_buffer_body(ContentLength) of - true -> - %% Small body - read fully and send in single message - {ok, Body, Req2} = cowboy_req:read_body(Req), - py_channel:send(Channel, {start_request, self(), Scope, Body}), - receive_asgi_response(Req2, ReqInfo1, TimeoutMs, State); - false -> - %% Large/unknown body - stream chunks - py_channel:send(Channel, {start_request_streaming, self(), Scope}), - Req2 = stream_body_to_channel(Channel, Req), - receive_asgi_response(Req2, ReqInfo1, TimeoutMs, State) - end - catch - Class:Reason:Stack -> - error_logger:error_msg("ASGI pooled handler error: ~p:~p~n~p~n", - [Class, Reason, Stack]), - handle_error(Req, {Class, Reason}, ReqInfo1, State) - end. - -%% @private -%% Original direct ASGI handler (non-pooled path) -handle_asgi_direct(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), + %% Build ASGI scope + Scope = build_scope(Req, State), + %% 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 + {ok, Body, _Req2} = cowboy_req:read_body(Req), + + %% Submit to event loop (non-blocking, async execution) + %% Python will send response via erlang.send() + _Ref = py_event_loop:create_task( + <<"hornbeam_asgi_worker">>, <<"handle_asgi">>, + [self(), AppModule, AppCallable, Scope, Body]), + + %% Receive response from async Python + receive_asgi_response(Req, ReqInfo1, TimeoutMs, State) catch Class:Reason:Stack -> error_logger:error_msg("ASGI handler error: ~p:~p~n~p~n", @@ -433,61 +258,41 @@ handle_asgi_direct(Req, 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 +%% Receive ASGI response from Python worker +receive_asgi_response(Req, ReqInfo, TimeoutMs, State) -> + receive + {<<"response">>, StatusCode, Headers, Body} -> + %% Buffered response (single message) + Response = #{ + <<"status">> => StatusCode, + <<"headers">> => Headers, + <<"body">> => Body + }, + Response1 = hornbeam_http_hooks:run_on_response(Response), + send_response(Req, Response1, State); + {<<"headers">>, StatusCode, Headers} -> + %% Streaming response + CowboyHeaders = convert_headers(Headers), + receive_asgi_body(Req, StatusCode, CowboyHeaders, TimeoutMs, State); + {<<"early_hints">>, Headers} -> + %% Early hints (103) + HintHeaders = convert_headers(Headers), + Req2 = cowboy_req:inform(103, HintHeaders, Req), + receive_asgi_response(Req2, ReqInfo, TimeoutMs, State); + {<<"error">>, Reason} -> + handle_error(Req, Reason, ReqInfo, State) + after TimeoutMs -> + handle_error(Req, timeout, ReqInfo, State) end. %% @private -%% Context-aware fallback path using py: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:call(PyContext, hornbeam_asgi_runner, run_asgi, - [AppModule, AppCallable, Scope1, ReqBody], #{timeout => 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, - %% Call ASGI runner - context routing handled automatically by py:call - py:call(hornbeam_asgi_runner, run_asgi, - [AppModule, AppCallable, Scope1, ReqBody], #{}, TimeoutMs). +receive_asgi_body(Req, StatusCode, CowboyHeaders, TimeoutMs, State) -> + Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req), + stream_body(Req2, TimeoutMs, State). %% @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) -> +%% Build ASGI scope +build_scope(Req, State) -> Method = cowboy_req:method(Req), Path = cowboy_req:path(Req), Qs = cowboy_req:qs(Req), @@ -525,61 +330,51 @@ build_scope_for_nif(Req, State) -> 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">> => #{} - }. +%%% ============================================================================ +%%% Response sending +%%% ============================================================================ -send_asgi_response(Req, Response, State) -> +send_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, + %% Parse status code + StatusCode = parse_status_code(Status), %% Convert headers to cowboy format CowboyHeaders = convert_headers(Headers), - %% Send early hints if any + %% Send early hints if any (103 responses) Req1 = send_early_hints(Req, EarlyHints), + %% Send response Req2 = cowboy_req:reply(StatusCode, CowboyHeaders, Body, Req1), {ok, Req2, State}. +%% @private +send_early_hints(Req, []) -> + Req; +send_early_hints(Req, [Hints | Rest]) -> + HintHeaders = convert_headers(Hints), + Req1 = cowboy_req:inform(103, HintHeaders, Req), + send_early_hints(Req1, Rest). + +parse_status_code(Status) when is_binary(Status) -> + case binary:split(Status, <<" ">>) of + [CodeBin | _] -> binary_to_integer(CodeBin); + _ -> 500 + end; +parse_status_code(Status) when is_list(Status) -> + parse_status_code(list_to_binary(Status)); +parse_status_code(Status) when is_integer(Status) -> + Status. + %%% ============================================================================ %%% 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, @@ -589,19 +384,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), @@ -619,7 +401,49 @@ build_request_info(Req) -> %%% ============================================================================ %% @private -%% Convert headers from various formats to cowboy map format +header_to_wsgi_key(Name) -> + case Name of + <<"content-type">> -> <<"CONTENT_TYPE">>; + <<"content-length">> -> <<"CONTENT_LENGTH">>; + _ -> + Upper = string:uppercase(Name), + Underscored = binary:replace(Upper, <<"-">>, <<"_">>, [global]), + <<"HTTP_", Underscored/binary>> + end. + +%% @private +format_protocol('HTTP/1.0') -> <<"HTTP/1.0">>; +format_protocol('HTTP/1.1') -> <<"HTTP/1.1">>; +format_protocol('HTTP/2') -> <<"HTTP/2">>. + +%% @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">> => #{} + }. + +%% @private convert_headers(Headers) -> lists:foldl(fun(Header, Acc) -> case Header of @@ -643,202 +467,14 @@ to_lower_binary(V) when is_atom(V) -> string:lowercase(atom_to_binary(V, utf8)); to_lower_binary(V) -> string:lowercase(to_binary(V)). %%% ============================================================================ -%%% Pooled Worker Dispatch Helpers -%%% ============================================================================ - -%% @private -%% Determine if body should be buffered (small) or streamed (large/unknown) -%% Buffer threshold is 16KB -should_buffer_body(undefined) -> - %% Unknown length - stream it - false; -should_buffer_body(ContentLengthBin) -> - try - ContentLength = binary_to_integer(ContentLengthBin), - ContentLength =< 16384 - catch - _:_ -> false - end. - -%% @private -%% Stream request body chunks to the worker channel -stream_body_to_channel(Channel, Req) -> - case cowboy_req:read_body(Req) of - {ok, Data, Req2} -> - py_channel:send(Channel, {body_chunk, Data}), - py_channel:send(Channel, body_done), - Req2; - {more, Data, Req2} -> - py_channel:send(Channel, {body_chunk, Data}), - stream_body_to_channel(Channel, Req2) - end. - -%% @private -%% Build request info map for WSGI pooled workers -build_pooled_request_info(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), - - %% Build HTTP_* headers (pre-converted for Python) - WsgiHeaders = maps:fold(fun(Name, Value, Acc) -> - HeaderKey = header_to_wsgi_key(Name), - Acc#{HeaderKey => Value} - end, #{}, Headers), - - %% Extract content-type and content-length - ContentType = maps:get(<<"content-type">>, Headers, undefined), - ContentLength = maps:get(<<"content-length">>, Headers, undefined), - - %% Get lifespan state - LifespanState = hornbeam_lifespan:get_state(), - - #{ - method => Method, - script_name => ScriptName, - path_info => PathInfo, - query_string => Qs, - wsgi_headers => WsgiHeaders, - content_type => ContentType, - content_length => ContentLength, - server => {Host, Port}, - client => {format_ip(ClientIp), ClientPort}, - scheme => Scheme, - protocol => format_protocol(Version), - lifespan_state => LifespanState - }. - -%% @private -%% Receive WSGI response from pooled worker -receive_wsgi_response(Req, ReqInfo, TimeoutMs, State) -> - receive - {headers, StatusCode, Headers} -> - %% Got headers - stream body directly to client - CowboyHeaders = convert_headers(Headers), - receive_wsgi_body(Req, StatusCode, CowboyHeaders, TimeoutMs, State); - {response, StatusCode, Headers, Body} -> - %% Buffered response (single message) - Response = #{ - <<"status">> => StatusCode, - <<"headers">> => Headers, - <<"body">> => Body - }, - Response1 = hornbeam_http_hooks:run_on_response(Response), - send_wsgi_response(Req, Response1, State); - {error, Reason} -> - handle_error(Req, Reason, ReqInfo, State) - after TimeoutMs -> - handle_error(Req, timeout, ReqInfo, State) - end. - -%% @private -%% Receive WSGI body chunks from pooled worker - streams directly to client -receive_wsgi_body(Req, StatusCode, CowboyHeaders, TimeoutMs, State) -> - %% Start streaming response immediately - Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req), - stream_wsgi_body(Req2, TimeoutMs, State). - -stream_wsgi_body(Req, TimeoutMs, State) -> - receive - {chunk, Chunk} -> - ok = cowboy_req:stream_body(Chunk, nofin, Req), - stream_wsgi_body(Req, TimeoutMs, State); - done -> - ok = cowboy_req:stream_body(<<>>, fin, Req), - {ok, Req, State}; - {error, _Reason} -> - %% Error mid-stream - close connection - ok = cowboy_req:stream_body(<<>>, fin, Req), - {ok, Req, State} - after TimeoutMs -> - %% Timeout - close stream - ok = cowboy_req:stream_body(<<>>, fin, Req), - {ok, Req, State} - end. - -%% @private -%% Receive ASGI response from pooled worker -receive_asgi_response(Req, ReqInfo, TimeoutMs, State) -> - receive - {response, StatusCode, Headers, Body} -> - %% Buffered response (single message) - Response = #{ - <<"status">> => StatusCode, - <<"headers">> => Headers, - <<"body">> => Body - }, - Response1 = hornbeam_http_hooks:run_on_response(Response), - send_asgi_response(Req, Response1, State); - {response, StatusCode, Headers, Body, EarlyHints} -> - %% Buffered response with early hints - Response = #{ - <<"status">> => StatusCode, - <<"headers">> => Headers, - <<"body">> => Body, - <<"early_hints">> => EarlyHints - }, - Response1 = hornbeam_http_hooks:run_on_response(Response), - send_asgi_response(Req, Response1, State); - {headers, StatusCode, Headers} -> - %% Streaming response - stream directly to client - CowboyHeaders = convert_headers(Headers), - receive_asgi_body(Req, StatusCode, CowboyHeaders, TimeoutMs, State); - {error, Reason} -> - handle_error(Req, Reason, ReqInfo, State) - after TimeoutMs -> - handle_error(Req, timeout, ReqInfo, State) - end. - -%% @private -%% Receive ASGI body chunks from pooled worker - streams directly to client -receive_asgi_body(Req, StatusCode, CowboyHeaders, TimeoutMs, State) -> - %% Start streaming response immediately - Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req), - stream_asgi_body(Req2, TimeoutMs, State). - -stream_asgi_body(Req, TimeoutMs, State) -> - receive - {chunk, Chunk} -> - ok = cowboy_req:stream_body(Chunk, nofin, Req), - stream_asgi_body(Req, TimeoutMs, State); - done -> - ok = cowboy_req:stream_body(<<>>, fin, Req), - {ok, Req, State}; - {error, _Reason} -> - %% Error mid-stream - close connection - ok = cowboy_req:stream_body(<<>>, fin, Req), - {ok, Req, State} - after TimeoutMs -> - %% Timeout - close stream - ok = cowboy_req:stream_body(<<>>, fin, Req), - {ok, Req, State} - end. - -%%% ============================================================================ -%%% WebSocket callbacks (delegate to hornbeam_websocket) +%%% Mount pythonpath setup %%% ============================================================================ -%% @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; @@ -850,6 +486,10 @@ setup_mount_pythonpath(Mount) -> end, Paths) end. +%%% ============================================================================ +%%% WebSocket callbacks (delegate to hornbeam_websocket) +%%% ============================================================================ + websocket_init(State) -> hornbeam_websocket:websocket_init(State). diff --git a/src/hornbeam_mounts.erl b/src/hornbeam_mounts.erl index 1577572..822b396 100644 --- a/src/hornbeam_mounts.erl +++ b/src/hornbeam_mounts.erl @@ -41,10 +41,7 @@ register/1, lookup/1, list/0, - clear/0, - set_channel/3, - clear_channels/1, - get_channel/2 + clear/0 ]). %% gen_server callbacks @@ -93,23 +90,6 @@ lookup(Path) -> {error, no_match} end. -%% @doc Set a channel for a mount worker. --spec set_channel(MountId :: binary(), WorkerIdx :: non_neg_integer(), Channel :: term()) -> ok. -set_channel(MountId, WorkerIdx, Channel) -> - ets:insert(?TABLE, {{MountId, WorkerIdx}, Channel}), - ok. - -%% @doc Clear all channels for a mount. --spec clear_channels(MountId :: binary()) -> ok. -clear_channels(MountId) -> - ets:match_delete(?TABLE, {{MountId, '_'}, '_'}), - ok. - -%% @doc Get channel for a mount by worker index (0-based). --spec get_channel(MountId :: binary(), WorkerIdx :: non_neg_integer()) -> term(). -get_channel(MountId, WorkerIdx) -> - ets:lookup_element(?TABLE, {MountId, WorkerIdx}, 2). - %% @doc List all registered mounts. -spec list() -> [mount()]. list() -> @@ -151,43 +131,9 @@ handle_call({register, Mounts}, _From, State) -> ), ets:insert(?TABLE, {sorted_mounts, SortedMounts}), - %% Start worker pools for mounts with pool_enabled = true - lists:foreach(fun(Mount) -> - case maps:get(pool_enabled, Mount, false) of - true -> - MountId = maps:get(mount_id, Mount), - case hornbeam_worker_pool:start_mount_pool(MountId, Mount) of - {ok, _Pid} -> - ok; - {error, {already_started, _}} -> - ok; - {error, Reason} -> - error_logger:error_msg("hornbeam_mounts: Failed to start pool for ~s: ~p~n", - [MountId, Reason]) - end; - false -> - ok - end - end, SortedMounts), - {reply, ok, State}; handle_call(clear, _From, State) -> - %% Stop all worker pools first - case ets:lookup(?TABLE, sorted_mounts) of - [{sorted_mounts, Mounts}] -> - lists:foreach(fun(Mount) -> - case maps:get(pool_enabled, Mount, false) of - true -> - MountId = maps:get(mount_id, Mount), - catch hornbeam_worker_pool:stop_mount_pool(MountId); - false -> - ok - end - end, Mounts); - [] -> - ok - end, ets:delete_all_objects(?TABLE), {reply, ok, State}; diff --git a/src/hornbeam_pool.erl b/src/hornbeam_pool.erl index 7023d26..9ce1c86 100644 --- a/src/hornbeam_pool.erl +++ b/src/hornbeam_pool.erl @@ -32,6 +32,7 @@ start_link/0, start_link/1, get_context/0, + get_context_rr/0, call/4, call/5, pool_size/0, @@ -84,6 +85,16 @@ get_context() -> Idx = (SchedId - 1) rem N, ets:lookup_element(?TABLE, {context, Idx}, 2). +%% @doc Get a context from the pool using round-robin selection. +%% +%% Uses an atomic counter for fair distribution across all contexts. +%% Useful when scheduler affinity isn't desired (e.g., long-running requests). +-spec get_context_rr() -> pid(). +get_context_rr() -> + N = ets:lookup_element(?TABLE, pool_size, 2), + Idx = ets:update_counter(?TABLE, rr_counter, {2, 1, N - 1, 0}), + ets:lookup_element(?TABLE, {context, Idx}, 2). + %% @doc Call a Python function using a pooled context. %% %% Automatically selects a context using scheduler affinity. @@ -131,7 +142,13 @@ init(Opts) -> %% Create ETS table for pool data _ = ets:new(?TABLE, [named_table, public, set, {read_concurrency, true}]), - PoolSize = maps:get(pool_size, Opts, ?DEFAULT_POOL_SIZE), + %% Pool size: Opts > app env > default (schedulers) + PoolSize = case maps:get(pool_size, Opts, undefined) of + undefined -> + application:get_env(hornbeam, pool_size, ?DEFAULT_POOL_SIZE); + Size -> + Size + end, %% Try to create contexts (may fail if Python not started) {Monitors, ActualSize} = try @@ -148,6 +165,9 @@ init(Opts) -> ets:insert(?TABLE, {{counter, Idx}, 0}) end, lists:seq(0, max(0, ActualSize - 1))), + %% Initialize round-robin counter + ets:insert(?TABLE, {rr_counter, 0}), + %% Store pool size ets:insert(?TABLE, {pool_size, ActualSize}), diff --git a/src/hornbeam_sup.erl b/src/hornbeam_sup.erl index c632780..8c8cac8 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_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, []}, @@ -124,14 +133,6 @@ init([]) -> shutdown => 5000, type => worker, modules => [hornbeam_presence] - }, - #{ - id => hornbeam_worker_pool, - start => {hornbeam_worker_pool, start_link, []}, - restart => permanent, - shutdown => infinity, - type => supervisor, - modules => [hornbeam_worker_pool] } ], diff --git a/src/hornbeam_worker_arbiter.erl b/src/hornbeam_worker_arbiter.erl deleted file mode 100644 index dc2ae36..0000000 --- a/src/hornbeam_worker_arbiter.erl +++ /dev/null @@ -1,307 +0,0 @@ -%% 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 Per-mount gen_server managing persistent Python workers. -%%% -%%% Each arbiter manages N workers for a single mount. Workers are persistent -%%% Python processes that receive requests via channels and loop continuously, -%%% reducing Python startup overhead. -%%% -%%% Features: -%%% - O(1) channel lookup via ETS -%%% - Heartbeat monitoring with automatic restart -%%% - Scheduler affinity for cache locality --module(hornbeam_worker_arbiter). - --behaviour(gen_server). - --export([ - start_link/2, - worker_status/1 -]). - --export([ - init/1, - handle_call/3, - handle_cast/2, - handle_info/2, - terminate/2, - code_change/3 -]). - --define(HEARTBEAT_INTERVAL, 5000). %% Check heartbeats every 5 seconds --define(HEARTBEAT_TIMEOUT, 15000). %% Restart worker if no heartbeat for 15 seconds - --record(worker_info, { - idx :: non_neg_integer(), - channel :: term(), - task_ref :: reference() | undefined, - last_heartbeat :: integer(), - status :: starting | running | stopping -}). - --record(state, { - mount_id :: binary(), - mount_config :: map(), - num_workers :: pos_integer(), - workers :: #{non_neg_integer() => #worker_info{}}, - heartbeat_timer :: reference() | undefined -}). - -%%% ============================================================================ -%%% API -%%% ============================================================================ - -%% @doc Start the arbiter for a mount. -%% -%% Config should contain: -%% - app_module: Python module name -%% - app_callable: Python callable name -%% - worker_class: wsgi | asgi -%% - workers: Number of workers (default: number of schedulers) --spec start_link(MountId :: binary(), Config :: map()) -> - {ok, pid()} | {error, term()}. -start_link(MountId, Config) -> - gen_server:start_link(?MODULE, {MountId, Config}, []). - - -%% @doc Get status of all workers managed by this arbiter. --spec worker_status(pid()) -> map(). -worker_status(Pid) -> - gen_server:call(Pid, get_status). - -%%% ============================================================================ -%%% gen_server callbacks -%%% ============================================================================ - -init({MountId, Config}) -> - process_flag(trap_exit, true), - - NumWorkers = maps:get(workers, Config, erlang:system_info(schedulers)), - - %% Start all workers - Workers = start_all_workers(MountId, Config, NumWorkers), - - %% Store channels in ETS for O(1) lookup - store_channels(MountId, Workers, NumWorkers), - - %% Start heartbeat timer - HeartbeatTimer = erlang:send_after(?HEARTBEAT_INTERVAL, self(), check_heartbeats), - - State = #state{ - mount_id = MountId, - mount_config = Config, - num_workers = NumWorkers, - workers = Workers, - heartbeat_timer = HeartbeatTimer - }, - - {ok, State}. - -handle_call(get_status, _From, #state{workers = Workers, mount_id = MountId} = State) -> - Now = erlang:system_time(millisecond), - WorkerList = maps:fold(fun(Idx, #worker_info{last_heartbeat = LastHB, status = Status}, Acc) -> - [#{ - idx => Idx, - status => Status, - last_heartbeat_ms_ago => Now - LastHB - } | Acc] - end, [], Workers), - Reply = #{ - mount_id => MountId, - num_workers => maps:size(Workers), - workers => WorkerList - }, - {reply, Reply, State}; - -handle_call(_Request, _From, State) -> - {reply, {error, unknown_request}, State}. - -handle_cast(_Request, State) -> - {noreply, State}. - -handle_info({heartbeat, WorkerId}, #state{workers = Workers} = State) -> - %% Extract worker index from WorkerId (format: "mount_id_idx") - Idx = parse_worker_idx(WorkerId), - case maps:get(Idx, Workers, undefined) of - undefined -> - {noreply, State}; - WorkerInfo -> - Now = erlang:system_time(millisecond), - UpdatedWorker = WorkerInfo#worker_info{ - last_heartbeat = Now, - status = running - }, - {noreply, State#state{workers = Workers#{Idx => UpdatedWorker}}} - end; - -handle_info(check_heartbeats, #state{workers = Workers, mount_id = MountId, - mount_config = Config, num_workers = NumWorkers} = State) -> - Now = erlang:system_time(millisecond), - - %% Check each worker for heartbeat timeout - {UpdatedWorkers, Changed} = maps:fold(fun(Idx, WorkerInfo, {Acc, Ch}) -> - #worker_info{last_heartbeat = LastHB, status = Status} = WorkerInfo, - case Status of - running when (Now - LastHB) > ?HEARTBEAT_TIMEOUT -> - %% Worker missed heartbeats - restart it - error_logger:warning_msg("hornbeam_worker_arbiter: Worker ~s_~p missed heartbeats, restarting~n", - [MountId, Idx]), - NewWorker = restart_worker(MountId, Config, Idx, WorkerInfo), - {Acc#{Idx => NewWorker}, true}; - _ -> - {Acc#{Idx => WorkerInfo}, Ch} - end - end, {#{}, false}, Workers), - - %% Update channels in ETS if any worker was restarted - case Changed of - true -> store_channels(MountId, UpdatedWorkers, NumWorkers); - false -> ok - end, - - %% Schedule next heartbeat check - NewTimer = erlang:send_after(?HEARTBEAT_INTERVAL, self(), check_heartbeats), - - {noreply, State#state{workers = UpdatedWorkers, heartbeat_timer = NewTimer}}; - -handle_info({'EXIT', _Pid, normal}, State) -> - %% Normal exit, likely during shutdown - {noreply, State}; - -handle_info({'EXIT', _Pid, Reason}, State) -> - %% Unexpected exit - will be handled by heartbeat timeout - error_logger:warning_msg("hornbeam_worker_arbiter: Worker exited with reason: ~p~n", [Reason]), - {noreply, State}; - -handle_info(_Info, State) -> - {noreply, State}. - -terminate(_Reason, #state{workers = Workers, mount_id = MountId}) -> - %% Send stop to all workers - maps:foreach(fun(_Idx, #worker_info{channel = Channel}) -> - catch py_channel:send(Channel, stop) - end, Workers), - - %% Give workers time to stop gracefully - timer:sleep(100), - - %% Close all channels - maps:foreach(fun(_Idx, #worker_info{channel = Channel}) -> - catch py_channel:close(Channel) - end, Workers), - - %% Clear channels from ETS - hornbeam_mounts:clear_channels(MountId), - - ok. - -code_change(_OldVsn, State, _Extra) -> - {ok, State}. - -%%% ============================================================================ -%%% Internal functions -%%% ============================================================================ - -start_all_workers(MountId, Config, NumWorkers) -> - lists:foldl(fun(Idx, Acc) -> - WorkerInfo = start_worker(MountId, Config, Idx), - Acc#{Idx => WorkerInfo} - end, #{}, lists:seq(0, NumWorkers - 1)). - -start_worker(MountId, Config, Idx) -> - %% Create channel for this worker - {ok, Channel} = py_channel:new(), - - %% Build worker ID - WorkerId = <>, - - %% Get app config - AppModule = maps:get(app_module, Config), - AppCallable = maps:get(app_callable, Config), - WorkerClass = maps:get(worker_class, Config, wsgi), - - %% Get arbiter PID for heartbeat messages - ArbiterPid = self(), - - %% Spawn Python worker task - TaskRef = spawn_python_worker(Channel, ArbiterPid, WorkerId, - AppModule, AppCallable, WorkerClass), - - Now = erlang:system_time(millisecond), - #worker_info{ - idx = Idx, - channel = Channel, - task_ref = TaskRef, - last_heartbeat = Now, - status = starting - }. - -restart_worker(MountId, Config, Idx, #worker_info{channel = OldChannel}) -> - %% Close old channel (will cause Python worker to exit) - catch py_channel:close(OldChannel), - - %% Start new worker - start_worker(MountId, Config, Idx). - -spawn_python_worker(Channel, ArbiterPid, WorkerId, AppModule, AppCallable, WorkerClass) -> - %% Get the channel reference for Python - ChannelRef = py_channel:get_ref(Channel), - - %% Determine which Python module/function to call - {Module, Function} = case WorkerClass of - wsgi -> {<<"hornbeam_wsgi_worker">>, <<"worker_loop">>}; - asgi -> {<<"hornbeam_asgi_worker">>, <<"worker_loop">>} - end, - - %% Create a reference for tracking - Ref = make_ref(), - - %% Schedule the Python task - %% The worker_loop function will run until it receives 'stop' - case py_event_loop:get_loop() of - {ok, LoopRef} -> - py_event_loop:run_async(LoopRef, #{ - ref => Ref, - caller => self(), - module => Module, - func => Function, - args => [ChannelRef, ArbiterPid, WorkerId, AppModule, AppCallable] - }), - Ref; - {error, _} -> - %% Fallback: use py:spawn if event loop not available - py:spawn(Module, Function, [ChannelRef, ArbiterPid, WorkerId, AppModule, AppCallable]), - Ref - end. - -parse_worker_idx(WorkerId) when is_binary(WorkerId) -> - %% Parse "mount_id_idx" to get idx - case binary:split(WorkerId, <<"_">>, [global]) of - Parts when length(Parts) >= 2 -> - IdxBin = lists:last(Parts), - try binary_to_integer(IdxBin) - catch _:_ -> 0 - end; - _ -> - 0 - end. - -%% @private -%% Store each channel in ETS with key {MountId, Idx}. -store_channels(MountId, Workers, NumWorkers) -> - lists:foreach(fun(Idx) -> - #worker_info{channel = Ch} = maps:get(Idx, Workers), - hornbeam_mounts:set_channel(MountId, Idx, Ch) - end, lists:seq(0, NumWorkers - 1)). diff --git a/src/hornbeam_worker_pool.erl b/src/hornbeam_worker_pool.erl deleted file mode 100644 index e2b426b..0000000 --- a/src/hornbeam_worker_pool.erl +++ /dev/null @@ -1,110 +0,0 @@ -%% 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 Top-level supervisor for persistent worker pools. -%%% -%%% This supervisor manages hornbeam_worker_arbiter processes, one per mount -%%% with pool_enabled = true. Each arbiter manages N persistent Python workers. -%%% -%%% Architecture: -%%% ``` -%%% hornbeam_worker_pool (supervisor, one_for_one) -%%% +-- hornbeam_worker_arbiter (mount: "abc123") -%%% | +-- manages N Python workers via channels -%%% +-- hornbeam_worker_arbiter (mount: "def456") -%%% +-- manages N Python workers via channels -%%% ''' --module(hornbeam_worker_pool). - --behaviour(supervisor). - --export([ - start_link/0, - start_mount_pool/2, - stop_mount_pool/1, - list_pools/0 -]). - --export([init/1]). - --define(SERVER, ?MODULE). - -%%% ============================================================================ -%%% API -%%% ============================================================================ - -%% @doc Start the worker pool supervisor. --spec start_link() -> {ok, pid()} | ignore | {error, term()}. -start_link() -> - supervisor:start_link({local, ?SERVER}, ?MODULE, []). - -%% @doc Start a worker pool for a mount. -%% -%% Config should contain: -%% - app_module: Python module name -%% - app_callable: Python callable name -%% - worker_class: wsgi | asgi -%% - workers: Number of workers (default: schedulers count) -%% - heartbeat_interval: Heartbeat check interval in ms (default: 5000) -%% - heartbeat_timeout: Max time without heartbeat in ms (default: 15000) --spec start_mount_pool(MountId :: binary(), Config :: map()) -> - {ok, pid()} | {error, term()}. -start_mount_pool(MountId, Config) -> - ChildSpec = #{ - id => {hornbeam_worker_arbiter, MountId}, - start => {hornbeam_worker_arbiter, start_link, [MountId, Config]}, - restart => permanent, - shutdown => 10000, - type => worker, - modules => [hornbeam_worker_arbiter] - }, - supervisor:start_child(?SERVER, ChildSpec). - -%% @doc Stop a worker pool for a mount. --spec stop_mount_pool(MountId :: binary()) -> ok | {error, term()}. -stop_mount_pool(MountId) -> - ChildId = {hornbeam_worker_arbiter, MountId}, - case supervisor:terminate_child(?SERVER, ChildId) of - ok -> - supervisor:delete_child(?SERVER, ChildId); - {error, not_found} -> - {error, not_found}; - Error -> - Error - end. - - -%% @doc List all active worker pools. --spec list_pools() -> [#{mount_id := binary(), pid := pid()}]. -list_pools() -> - Children = supervisor:which_children(?SERVER), - lists:filtermap(fun - ({{hornbeam_worker_arbiter, MountId}, Pid, worker, _}) when is_pid(Pid) -> - {true, #{mount_id => MountId, pid => Pid}}; - (_) -> - false - end, Children). - -%%% ============================================================================ -%%% supervisor callbacks -%%% ============================================================================ - -init([]) -> - SupFlags = #{ - strategy => one_for_one, - intensity => 10, - period => 60 - }, - %% Start with no children - mounts are added dynamically - {ok, {SupFlags, []}}. From aef08897af13ac5bda0c0ab75a814dd1d3795699 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sat, 14 Mar 2026 18:12:18 +0100 Subject: [PATCH 18/52] Add WSGI streaming request body support - Small bodies (< 64KB): buffered path (read fully before Python call) - Large bodies (>= 64KB): stream via py_channel in 64KB chunks - Add handle_wsgi_streaming entry point in Python worker - StreamingBodyReader reads body chunks from channel - Add hornbeam_context_pool:add_paths/1 to set pythonpath in all contexts - Fix Channel.receive() to use timeout_ms parameter - Add wsgi_body_chunk_size and wsgi_streaming_threshold config options --- config/sys.config | 5 +- priv/hornbeam_wsgi_worker.py | 98 ++++++++++++++++++++++++- src/hornbeam.erl | 6 +- src/hornbeam_context_pool.erl | 36 ++++++++- src/hornbeam_handler.erl | 133 ++++++++++++++++++++++++++++------ 5 files changed, 250 insertions(+), 28 deletions(-) diff --git a/config/sys.config b/config/sys.config index 633a589..01bfa52 100644 --- a/config/sys.config +++ b/config/sys.config @@ -7,7 +7,10 @@ {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}, diff --git a/priv/hornbeam_wsgi_worker.py b/priv/hornbeam_wsgi_worker.py index faa3810..9b9d91b 100644 --- a/priv/hornbeam_wsgi_worker.py +++ b/priv/hornbeam_wsgi_worker.py @@ -555,7 +555,7 @@ def read(self, size: int = -1) -> bytes: if size > 0 and len(self._buffer) >= size: break - msg = self.channel.receive(timeout=30000) + msg = self.channel.receive(timeout_ms=30000) if msg == b'body_done' or msg == 'body_done': self._eof = True @@ -621,6 +621,102 @@ def __next__(self): return line +# ============================================================================ +# Streaming request body entry point +# ============================================================================ + +def handle_wsgi_streaming(caller_pid, app_module: bytes, app_callable: bytes, + environ_map: dict, channel_ref, content_length: int): + """Handle a WSGI request with streaming body via channel. + + This entry point is used for large request bodies (> 64KB) that are + streamed via py_channel instead of buffered. + + Args: + caller_pid: Erlang PID to send response to + app_module: Python module containing WSGI app (bytes) + app_callable: Name of WSGI callable in module (bytes) + environ_map: Pre-built environ dict from Erlang + channel_ref: py_channel reference for receiving body chunks + content_length: Content-Length header value + + Returns: + 'done' on success, 'error' on failure + """ + if not HAS_ERLANG: + return b'error' + + from erlang import Channel + + # Convert bytes to strings + module_name = app_module.decode('utf-8') if isinstance(app_module, bytes) else app_module + callable_name = app_callable.decode('utf-8') if isinstance(app_callable, bytes) else app_callable + + try: + # Create channel wrapper and streaming body reader + channel = Channel(channel_ref) + wsgi_input = StreamingBodyReader(channel, content_length) + + # Build environ with streaming input + environ = _build_environ_streaming(environ_map, wsgi_input, content_length) + + # Load and call WSGI app + app = _load_app(module_name, callable_name) + response = _Response() + + result = app(environ, response.start_response) + + # Build state for response processing + state = { + 'caller': caller_pid, + 'response': response, + 'result': result, + 'result_iter': iter(result) if not isinstance(result, (bytes, bytearray)) else None, + 'body_parts': list(response._write_buffer), + 'total_size': sum(len(p) for p in response._write_buffer), + 'streaming': False, + 'phase': 'collect', + 'wsgi_input': wsgi_input, + 'channel': channel, + } + + return _wsgi_collect(state) + + except Exception as e: + try: + reply(caller_pid, (b'error', str(e).encode('utf-8'))) + except Exception: + pass + return b'error' + + +def _build_environ_streaming(environ_map: dict, wsgi_input, content_length: int) -> dict: + """Build WSGI environ with streaming body reader.""" + # Build environ from template + environ = _ENVIRON_TEMPLATE.copy() + + # Copy from environ_map, converting bytes to strings + for key, value in environ_map.items(): + str_key = key.decode('utf-8') if isinstance(key, bytes) else str(key) + if isinstance(value, bytes): + str_value = value.decode('utf-8', errors='replace') + elif value is None or (hasattr(value, '__class__') and value.__class__.__name__ == 'Atom'): + str_value = '' + else: + str_value = value + environ[str_key] = str_value + + # Set required WSGI keys with streaming input + environ['wsgi.input'] = wsgi_input + environ['wsgi.errors'] = _SHARED_ERRORS + + # Ensure CONTENT_LENGTH is set if provided + if content_length is not None: + environ['CONTENT_LENGTH'] = str(content_length) + + return environ + + # ============================================================================ # Legacy entry points (for backwards compatibility) # ============================================================================ diff --git a/src/hornbeam.erl b/src/hornbeam.erl index f13bd75..d3e3855 100644 --- a/src/hornbeam.erl +++ b/src/hornbeam.erl @@ -647,7 +647,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), diff --git a/src/hornbeam_context_pool.erl b/src/hornbeam_context_pool.erl index 115b0a8..dbfff39 100644 --- a/src/hornbeam_context_pool.erl +++ b/src/hornbeam_context_pool.erl @@ -32,7 +32,8 @@ get_context_ref/0, get_context_rr/0, pool_size/0, - stats/0 + stats/0, + add_paths/1 ]). %% gen_server callbacks @@ -98,6 +99,14 @@ pool_size() -> stats() -> gen_server:call(?MODULE, stats). +%% @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}). + %% ============================================================================ %% gen_server callbacks %% ============================================================================ @@ -126,6 +135,13 @@ handle_call(stats, _From, #state{pool_size = PoolSize} = State) -> }, {reply, Stats, State}; +handle_call({add_paths, Paths}, _From, #state{contexts = Contexts} = State) -> + %% Add paths to all contexts + maps:foreach(fun(_Id, Ref) -> + add_paths_to_context(Ref, Paths) + end, Contexts), + {reply, ok, State}; + handle_call(_Request, _From, State) -> {reply, {error, unknown_request}, State}. @@ -163,6 +179,24 @@ create_contexts(PoolSize) -> maps:put(Id, Ref, Acc) end, #{}, lists:seq(0, PoolSize - 1)). +%% @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). + create_context(Id, PrivDir) -> case py_nif:context_create(auto) of {ok, Ref, InterpId} -> diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl index 09e70a4..6fe29bc 100644 --- a/src/hornbeam_handler.erl +++ b/src/hornbeam_handler.erl @@ -90,35 +90,24 @@ handle_websocket_upgrade(Req, State) -> %%% WSGI Handler - uses context_call 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 + handle_wsgi(Req, State) -> ReqInfo = build_request_info(Req), ReqInfo1 = hornbeam_http_hooks:run_on_request(ReqInfo), try - AppModule = maps:get(app_module, State), - AppCallable = maps:get(app_callable, State), - TimeoutMs = maps:get(timeout, State, 30000), - - %% Build environ map - Environ = build_environ_map(Req, State), - - %% Read request body - {ok, Body, _Req2} = cowboy_req:read_body(Req), - - %% Get context ref from pool (zero-copy via persistent_term) - CtxRef = hornbeam_context_pool:get_context_ref(), - - %% Call Python via context - uses schedule_inline internally - case py_nif:context_call(CtxRef, - <<"hornbeam_wsgi_worker">>, <<"handle_wsgi">>, - [self(), AppModule, AppCallable, Environ, Body], #{}) of - {ok, <<"done">>} -> - %% Response sent via receive loop - receive_wsgi_response(Req, ReqInfo1, TimeoutMs, State); - {ok, <<"error">>} -> - handle_error(Req, wsgi_error, ReqInfo1, State); - {error, Reason} -> - handle_error(Req, Reason, ReqInfo1, State) + %% Check content-length to decide streaming vs buffered + ContentLength = get_content_length(Req), + case ContentLength of + CL when CL =:= undefined; CL < ?WSGI_STREAMING_THRESHOLD -> + %% Small body: use buffered path + handle_wsgi_buffered(Req, ReqInfo1, State); + _ -> + %% Large body: stream via channel + handle_wsgi_streaming(Req, ReqInfo1, ContentLength, State) end catch Class:Error:Stack -> @@ -127,6 +116,102 @@ handle_wsgi(Req, State) -> handle_error(Req, {Class, Error}, ReqInfo1, State) end. +%% @private +%% 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 +%% Handle small request bodies - read fully before calling Python +handle_wsgi_buffered(Req, ReqInfo, State) -> + AppModule = maps:get(app_module, State), + AppCallable = maps:get(app_callable, State), + TimeoutMs = maps:get(timeout, State, 30000), + + %% Build environ map + Environ = build_environ_map(Req, State), + + %% Read request body fully + {ok, Body, _Req2} = cowboy_req:read_body(Req), + + %% Get context ref from pool (zero-copy via persistent_term) + CtxRef = hornbeam_context_pool:get_context_ref(), + + %% Call Python via context - uses schedule_inline internally + case py_nif:context_call(CtxRef, + <<"hornbeam_wsgi_worker">>, <<"handle_wsgi">>, + [self(), AppModule, AppCallable, Environ, Body], #{}) of + {ok, <<"done">>} -> + %% Response sent via receive loop + receive_wsgi_response(Req, ReqInfo, TimeoutMs, State); + {ok, <<"error">>} -> + handle_error(Req, wsgi_error, ReqInfo, State); + {error, Reason} -> + handle_error(Req, Reason, ReqInfo, State) + end. + +%% @private +%% Handle large request bodies - stream via channel +handle_wsgi_streaming(Req, ReqInfo, ContentLength, State) -> + AppModule = maps:get(app_module, State), + AppCallable = maps:get(app_callable, State), + TimeoutMs = maps:get(timeout, State, 30000), + + %% Build environ map + Environ = build_environ_map(Req, State), + + %% Create channel for body streaming + {ok, BodyChannel} = py_channel:new(#{max_size => 1048576}), + + %% Spawn process to stream body chunks to channel + Self = self(), + spawn_link(fun() -> + try + stream_body_to_channel(Req, BodyChannel, ?WSGI_BODY_CHUNK_SIZE), + Self ! body_stream_done + catch + _:Reason -> + Self ! {body_stream_error, Reason} + end + end), + + %% Get context ref from pool + CtxRef = hornbeam_context_pool:get_context_ref(), + + %% Call Python with channel reference for streaming body + case py_nif:context_call(CtxRef, + <<"hornbeam_wsgi_worker">>, <<"handle_wsgi_streaming">>, + [self(), AppModule, AppCallable, Environ, BodyChannel, ContentLength], #{}) of + {ok, <<"done">>} -> + receive_wsgi_response(Req, ReqInfo, TimeoutMs, State); + {ok, <<"error">>} -> + py_channel:close(BodyChannel), + handle_error(Req, wsgi_error, ReqInfo, State); + {error, Reason} -> + py_channel:close(BodyChannel), + handle_error(Req, Reason, ReqInfo, State) + end. + +%% @private +%% Stream request body to channel in chunks +stream_body_to_channel(Req, Channel, ChunkSize) -> + case cowboy_req:read_body(Req, #{length => ChunkSize}) of + {ok, Chunk, _Req2} -> + %% Last chunk + py_channel:send(Channel, {body_chunk, Chunk}), + py_channel:send(Channel, body_done); + {more, Chunk, Req2} -> + %% More data available + py_channel:send(Channel, {body_chunk, Chunk}), + stream_body_to_channel(Req2, Channel, ChunkSize) + end. + %% @private %% Receive WSGI response from Python worker receive_wsgi_response(Req, ReqInfo, TimeoutMs, State) -> From 20b4ef11972fe290f8df5d9f3b9283a32c5e3f84 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sat, 14 Mar 2026 20:50:47 +0100 Subject: [PATCH 19/52] Simplify WSGI to unified channel-based architecture - Replace handle_wsgi_buffered/handle_wsgi_streaming with single handle_wsgi - Add ChannelBuffer (inherits io.BufferedIOBase) for wsgi.input - Body delivered via channel for all sizes (small: {body, Data}, large: chunks) - Add hop-by-hop header filtering for HTTP compliance - Remove BytesIO pool and StreamingBodyReader class --- priv/hornbeam_wsgi_worker.py | 777 +++++++++++++---------------------- src/hornbeam_handler.erl | 189 ++++----- 2 files changed, 389 insertions(+), 577 deletions(-) diff --git a/priv/hornbeam_wsgi_worker.py b/priv/hornbeam_wsgi_worker.py index 9b9d91b..0d4691b 100644 --- a/priv/hornbeam_wsgi_worker.py +++ b/priv/hornbeam_wsgi_worker.py @@ -12,37 +12,33 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""High-performance WSGI worker using schedule_inline. +"""Simplified WSGI worker using unified channel-based approach. -This module provides a WSGI worker that uses erlang.schedule_inline() -to release the dirty scheduler between processing steps, enabling -better concurrency. +This module provides a WSGI worker with: +- Single entry point for all requests +- Channel-based body delivery via ChannelBuffer (file-like object) +- Clear schedule_inline phases for yielding Architecture: -1. Erlang calls handle_wsgi() with request data -2. Python processes request, yielding via schedule_inline when needed -3. Response sent via erlang.reply() - streaming or buffered -4. schedule_inline continues processing without message passing overhead - -Key optimizations: -- No channel overhead for simple requests -- schedule_inline releases dirty scheduler (~3x faster than messaging) -- Streaming support for large bodies -- BytesIO pooling for request bodies +1. Erlang sends body via channel (single {body, Data} or streamed chunks) +2. Python phases using schedule_inline: + - Phase 1: handle_request - setup ChannelBuffer, call app, schedule iteration + - Phase 2: _iterate_response - send response chunks, yield every N chunks """ import io import threading -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Callable, Dict, List, Optional, Tuple try: import erlang - from erlang import reply + from erlang import reply, Channel HAS_ERLANG = True except ImportError: HAS_ERLANG = False erlang = None reply = None + Channel = None # ============================================================================ @@ -50,6 +46,7 @@ # ============================================================================ _WSGI_VERSION = (1, 0) +CHUNKS_PER_BATCH = 10 class _WSGIErrorsWrapper: @@ -103,36 +100,181 @@ def __next__(self): # ============================================================================ -# BytesIO pool +# ChannelBuffer - BufferedIOBase backed by channel # ============================================================================ -_BYTESIO_POOL: List[io.BytesIO] = [] -_BYTESIO_POOL_SIZE = 100 -_BYTESIO_POOL_LOCK = threading.Lock() +class ChannelBuffer(io.BufferedIOBase): + """Buffered IO object that reads body from Erlang channel. + Inherits from io.BufferedIOBase for proper file-like interface. + Supports both single-message bodies ({body, Data}) and + streaming bodies ({body_chunk, Chunk}... body_done). + """ + + def __init__(self, channel): + self._channel = channel + self._buffer = b'' + self._eof = False + self._closed = False + + def readable(self) -> bool: + return True + + def writable(self) -> bool: + return False + + def seekable(self) -> bool: + return False + + @property + def closed(self) -> bool: + return self._closed + + def read(self, size: int = -1) -> bytes: + """Read up to size bytes from the body.""" + if self._closed: + raise ValueError("I/O operation on closed file") + + if self._eof and not self._buffer: + return b'' + + # If we have enough in buffer, return it + if size > 0 and len(self._buffer) >= size: + result = self._buffer[:size] + self._buffer = self._buffer[size:] + return result + + # Need to read more from channel + self._fill_buffer(size) + + # Return requested amount + if size is None or size < 0: + result = self._buffer + self._buffer = b'' + else: + result = self._buffer[:size] + self._buffer = self._buffer[size:] + + return result + + def read1(self, size: int = -1) -> bytes: + """Read up to size bytes with at most one channel read.""" + if self._closed: + raise ValueError("I/O operation on closed file") + + if self._eof and not self._buffer: + return b'' + + # If buffer is empty, do one read from channel + if not self._buffer and not self._eof: + self._pull_one() + + # Return what we have (up to size) + if size is None or size < 0: + result = self._buffer + self._buffer = b'' + else: + result = self._buffer[:size] + self._buffer = self._buffer[size:] + + return result + + def readinto(self, b) -> int: + """Read bytes into a pre-allocated buffer.""" + data = self.read(len(b)) + n = len(data) + b[:n] = data + return n + + def readinto1(self, b) -> int: + """Read bytes into buffer with at most one channel read.""" + data = self.read1(len(b)) + n = len(data) + b[:n] = data + return n + + def _pull_one(self): + """Pull one message from channel.""" + if self._eof: + return + + try: + msg = self._channel.receive(timeout_ms=30000) + except Exception: + self._eof = True + return + + if msg == b'body_done' or msg == 'body_done': + self._eof = True + elif isinstance(msg, tuple) and len(msg) >= 2: + tag, data = msg[0], msg[1] + if tag in (b'body', 'body'): + # Complete body in one message + if isinstance(data, bytes): + self._buffer += data + elif isinstance(data, str): + self._buffer += data.encode('utf-8') + self._eof = True + elif tag in (b'body_chunk', 'body_chunk'): + if isinstance(data, bytes): + self._buffer += data + elif isinstance(data, str): + self._buffer += data.encode('utf-8') + + def _fill_buffer(self, target_size: int = -1): + """Fill buffer from channel until we have enough or EOF.""" + while not self._eof: + if target_size > 0 and len(self._buffer) >= target_size: + break + self._pull_one() + + def readline(self, size: int = -1) -> bytes: + """Read a line from the body.""" + if self._closed: + raise ValueError("I/O operation on closed file") + + result = b'' + while True: + if size > 0 and len(result) >= size: + break + chunk = self.read(1) + if not chunk: + break + result += chunk + if chunk == b'\n': + break + return result + + def readlines(self, hint: int = -1) -> List[bytes]: + """Read all lines from the body.""" + lines = [] + total = 0 + while True: + line = self.readline() + if not line: + break + lines.append(line) + total += len(line) + if hint > 0 and total >= hint: + break + return lines -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 __iter__(self): + return self + def __next__(self): + line = self.readline() + if not line: + raise StopIteration + return line -def _return_bytesio(bio: io.BytesIO) -> None: - """Return a BytesIO to the pool.""" - with _BYTESIO_POOL_LOCK: - if len(_BYTESIO_POOL) < _BYTESIO_POOL_SIZE: - bio.seek(0) - bio.truncate() - _BYTESIO_POOL.append(bio) + def close(self): + """Close the buffer.""" + if not self._closed: + self._eof = True + self._buffer = b'' + self._closed = True + super().close() # ============================================================================ @@ -171,22 +313,15 @@ def _load_app(module_name: str, callable_name: str) -> Callable: # Helpers # ============================================================================ -def _to_str(val) -> str: - """Convert bytes/None to string.""" - if val is None: - return '' - if val.__class__.__name__ == 'Atom' or val == b'undefined': - return '' +def _to_bytes(val) -> bytes: + """Convert value to bytes.""" if isinstance(val, bytes): - return val.decode('utf-8', errors='replace') - return str(val) if not isinstance(val, str) else val - - -def _is_none(val) -> bool: - """Check if value is None or Erlang's undefined atom.""" - return val is None or val == b'undefined' or ( - val.__class__.__name__ == 'Atom' and str(val) == 'undefined' - ) + 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: @@ -200,6 +335,23 @@ def _parse_status(status_str) -> int: return 500 +def _process_environ(environ_map: dict) -> dict: + """Convert all binary keys/values to strings.""" + environ = _ENVIRON_TEMPLATE.copy() + + for key, value in environ_map.items(): + str_key = key.decode('utf-8') if isinstance(key, bytes) else str(key) + if isinstance(value, bytes): + str_value = value.decode('utf-8', errors='replace') + elif value is None or (hasattr(value, '__class__') and value.__class__.__name__ == 'Atom'): + str_value = '' + else: + str_value = str(value) if not isinstance(value, str) else value + environ[str_key] = str_value + + return environ + + # ============================================================================ # Response class # ============================================================================ @@ -236,22 +388,18 @@ def _write(self, data): # ============================================================================ -# Main entry point: handle_wsgi with schedule_inline +# Phase 1: Entry point - setup and call app # ============================================================================ -def handle_wsgi(caller_pid, app_module: bytes, app_callable: bytes, - environ_map: dict, body: bytes): - """Handle a WSGI request using schedule_inline for yielding. - - This is the main entry point called from Erlang via context_call. - Uses schedule_inline to release the dirty scheduler between steps. +def handle_request(caller_pid, channel_ref, app_module: bytes, app_callable: bytes, environ_map: dict): + """Entry point - setup ChannelBuffer, call app, iterate response. Args: caller_pid: Erlang PID to send response to + channel_ref: Channel reference for receiving body app_module: Python module containing WSGI app (bytes) app_callable: Name of WSGI callable in module (bytes) environ_map: Pre-built environ dict from Erlang - body: Request body bytes Returns: 'done' on success, or schedule_inline marker for continuation @@ -259,189 +407,90 @@ def handle_wsgi(caller_pid, app_module: bytes, app_callable: bytes, if not HAS_ERLANG: return b'error' - # Convert bytes to strings - module_name = app_module.decode('utf-8') if isinstance(app_module, bytes) else app_module - callable_name = app_callable.decode('utf-8') if isinstance(app_callable, bytes) else app_callable - - # Build state for potential continuation - state = { - 'caller': caller_pid, - 'module': module_name, - 'callable': callable_name, - 'environ_map': environ_map, - 'body': body, - 'phase': 'init', - } - - return _wsgi_process(state) - - -def _wsgi_process(state: dict): - """Process WSGI request with schedule_inline continuation. - - This function handles the actual WSGI processing and can yield - via schedule_inline to release the dirty scheduler. - """ - phase = state.get('phase', 'init') - caller = state['caller'] - wsgi_input = None - try: - if phase == 'init': - # Build environ - environ = _build_environ(state['environ_map'], state['body']) - wsgi_input = environ.get('_hornbeam.wsgi_input') - state['wsgi_input'] = wsgi_input + # Wrap channel reference + channel = Channel(channel_ref) - # Load app - app = _load_app(state['module'], state['callable']) - response = _Response() + # Convert bytes to strings + module_name = app_module.decode('utf-8') if isinstance(app_module, bytes) else app_module + callable_name = app_callable.decode('utf-8') if isinstance(app_callable, bytes) else app_callable - # Call WSGI app - result = app(environ, response.start_response) + # Process environ (convert bytes to strings) + environ = _process_environ(environ_map) - # Store result iterator for streaming - state['response'] = response - state['result'] = result - state['result_iter'] = iter(result) if not isinstance(result, (bytes, bytearray)) else None - state['body_parts'] = list(response._write_buffer) # Start with write() buffer - state['total_size'] = sum(len(p) for p in state['body_parts']) - state['streaming'] = False - state['phase'] = 'collect' + # Create ChannelBuffer as wsgi.input + wsgi_input = ChannelBuffer(channel) + environ['wsgi.input'] = wsgi_input + environ['wsgi.errors'] = _SHARED_ERRORS - # Continue to collection phase - return _wsgi_collect(state) + # Load and call app + app = _load_app(module_name, callable_name) + response = _Response() + result = app(environ, response.start_response) - elif phase == 'collect': - return _wsgi_collect(state) + # 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, + } - elif phase == 'stream': - return _wsgi_stream(state) + # Call iterate_response directly (not via schedule_inline) + # schedule_inline is only used for continuation within iteration + return _iterate_response(state) except Exception as e: try: - reply(caller, (b'error', str(e).encode('utf-8'))) + reply(caller_pid, (b'error', str(e).encode('utf-8'))) except Exception: pass return b'error' - finally: - # Return BytesIO to pool if we're done - if phase == 'done' and 'wsgi_input' in state: - wsgi_input = state.get('wsgi_input') - if wsgi_input is not None: - _return_bytesio(wsgi_input) +# ============================================================================ +# Phase 2: Iterate response +# ============================================================================ -def _wsgi_collect(state: dict): - """Collect response body, switching to streaming if needed.""" - BUFFER_THRESHOLD = 65536 - CHUNK_COUNT_YIELD = 10 # Yield after this many chunks +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'] - response = state['response'] result = state['result'] - result_iter = state.get('result_iter') - body_parts = state['body_parts'] - total_size = state['total_size'] - streaming = state['streaming'] - chunks_processed = 0 + result_iter = state['result_iter'] + write_buffer = state['write_buffer'] + headers_sent = state['headers_sent'] try: - if isinstance(result, (bytes, bytearray)): - # Single bytes result - chunk = bytes(result) - if total_size + len(chunk) < BUFFER_THRESHOLD: - body_parts.append(chunk) - # Send buffered response - body = b''.join(body_parts) - reply(caller, (b'response', response.status_code, response.headers, body)) - state['phase'] = 'done' - return b'done' - else: - # Switch to streaming - reply(caller, (b'headers', response.status_code, response.headers)) - for part in body_parts: - reply(caller, (b'chunk', part)) - reply(caller, (b'chunk', chunk)) - reply(caller, b'done') - state['phase'] = 'done' - return b'done' - else: - # Iterable result - process chunks - while True: - try: - chunk = next(result_iter) - except StopIteration: - break - - if chunk: - if isinstance(chunk, str): - chunk = chunk.encode('utf-8') - elif isinstance(chunk, bytearray): - chunk = bytes(chunk) - - chunks_processed += 1 - - if not streaming and total_size + len(chunk) < BUFFER_THRESHOLD: - body_parts.append(chunk) - total_size += len(chunk) - else: - # Switch to streaming mode - if not streaming: - reply(caller, (b'headers', response.status_code, response.headers)) - for part in body_parts: - reply(caller, (b'chunk', part)) - body_parts.clear() - streaming = True - state['streaming'] = True - - reply(caller, (b'chunk', chunk)) - - # Yield periodically to let other work run - if chunks_processed >= CHUNK_COUNT_YIELD: - state['body_parts'] = body_parts - state['total_size'] = total_size - state['phase'] = 'collect' - # Use schedule_inline to release scheduler and continue - return erlang.schedule_inline( - 'hornbeam_wsgi_worker', '_wsgi_collect', - args=[state] - ) - - # Done iterating - if hasattr(result, 'close'): - result.close() - - if streaming: - reply(caller, b'done') - else: - body = b''.join(body_parts) - reply(caller, (b'response', response.status_code, response.headers, body)) - - state['phase'] = 'done' + # 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 + reply(caller, (b'response', state['status'], state['headers'], body)) + _cleanup_result(result) return b'done' - except Exception as e: - if hasattr(result, 'close'): - try: - result.close() - except Exception: - pass - reply(caller, (b'error', str(e).encode('utf-8'))) - state['phase'] = 'done' - return b'error' + # Send any buffered write() data first + if write_buffer and not headers_sent: + reply(caller, (b'start_response', state['status'], state['headers'])) + state['headers_sent'] = True + for part in write_buffer: + reply(caller, (b'chunk', _to_bytes(part))) + state['write_buffer'] = [] + # Process chunks from iterator + chunks_processed = 0 -def _wsgi_stream(state: dict): - """Stream remaining response body chunks.""" - # This is called when we've already sent headers and are streaming - caller = state['caller'] - result_iter = state.get('result_iter') - CHUNK_COUNT_YIELD = 10 - chunks_processed = 0 - - try: while True: try: chunk = next(result_iter) @@ -449,284 +498,46 @@ def _wsgi_stream(state: dict): break if chunk: - if isinstance(chunk, str): - chunk = chunk.encode('utf-8') - elif isinstance(chunk, bytearray): - chunk = bytes(chunk) + chunk = _to_bytes(chunk) + + # Send headers on first chunk + if not state['headers_sent']: + reply(caller, (b'start_response', state['status'], state['headers'])) + state['headers_sent'] = True reply(caller, (b'chunk', chunk)) chunks_processed += 1 - if chunks_processed >= CHUNK_COUNT_YIELD: - state['phase'] = 'stream' + # Yield after batch to release scheduler + if chunks_processed >= CHUNKS_PER_BATCH: return erlang.schedule_inline( - 'hornbeam_wsgi_worker', '_wsgi_stream', + 'hornbeam_wsgi_worker', '_iterate_response', args=[state] ) - # Done - result = state['result'] - if hasattr(result, 'close'): - result.close() + # Done iterating + if state['headers_sent']: + reply(caller, b'done') + else: + # Empty response (no chunks produced) + reply(caller, (b'response', state['status'], state['headers'], b'')) - reply(caller, b'done') - state['phase'] = 'done' + _cleanup_result(result) return b'done' except Exception as e: - result = state.get('result') - if result and hasattr(result, 'close'): - try: - result.close() - except Exception: - pass - reply(caller, (b'error', str(e).encode('utf-8'))) - state['phase'] = 'done' - return b'error' - - -def _build_environ(environ_map: dict, body: bytes) -> dict: - """Build WSGI environ from Erlang map and body.""" - # Handle body - if body is None or body == b'': - body_bytes = b'' - elif isinstance(body, bytes): - body_bytes = body - elif isinstance(body, str): - body_bytes = body.encode('utf-8') - else: - body_bytes = b'' - - wsgi_input = _get_bytesio(body_bytes) - - # Build environ from template - environ = _ENVIRON_TEMPLATE.copy() - - # Copy from environ_map, converting bytes to strings - for key, value in environ_map.items(): - str_key = key.decode('utf-8') if isinstance(key, bytes) else str(key) - if isinstance(value, bytes): - str_value = value.decode('utf-8', errors='replace') - elif value is None or (hasattr(value, '__class__') and value.__class__.__name__ == 'Atom'): - str_value = '' - else: - str_value = value - environ[str_key] = str_value - - # Set required WSGI keys - environ['wsgi.input'] = wsgi_input - environ['wsgi.errors'] = _SHARED_ERRORS - environ['_hornbeam.wsgi_input'] = wsgi_input - - return environ - - -# ============================================================================ -# Streaming request body support -# ============================================================================ - -class StreamingBodyReader: - """File-like object that reads body chunks from Erlang channel.""" - - def __init__(self, channel, content_length: Optional[int] = None): - self.channel = channel - self.content_length = content_length - self._buffer = b'' - self._eof = False - self._bytes_read = 0 - - def read(self, size: int = -1) -> bytes: - """Read up to size bytes from the body.""" - if self._eof: - return b'' - - # If we have enough in buffer, return it - if size > 0 and len(self._buffer) >= size: - result = self._buffer[:size] - self._buffer = self._buffer[size:] - self._bytes_read += len(result) - return result - - # Need to read more from channel - from erlang import Channel, ChannelClosed - + _cleanup_result(state.get('result')) try: - while not self._eof: - if size > 0 and len(self._buffer) >= size: - break - - msg = self.channel.receive(timeout_ms=30000) - - if msg == b'body_done' or msg == 'body_done': - self._eof = True - break - elif isinstance(msg, tuple) and len(msg) == 2: - tag, chunk = msg - if tag == b'body_chunk' or tag == 'body_chunk': - if isinstance(chunk, bytes): - self._buffer += chunk - elif isinstance(chunk, str): - self._buffer += chunk.encode('utf-8') - - except ChannelClosed: - self._eof = True - - # Return requested amount - if size < 0: - result = self._buffer - self._buffer = b'' - else: - result = self._buffer[:size] - self._buffer = self._buffer[size:] - - self._bytes_read += len(result) - return result - - def readline(self, size: int = -1) -> bytes: - """Read a line from the body.""" - # Simple implementation - read until newline - result = b'' - while True: - if size > 0 and len(result) >= size: - break - chunk = self.read(1) - if not chunk: - break - result += chunk - if chunk == b'\n': - break - return result - - def readlines(self, hint: int = -1) -> List[bytes]: - """Read all lines from the body.""" - lines = [] - total = 0 - while True: - line = self.readline() - if not line: - break - lines.append(line) - total += len(line) - if hint > 0 and total >= hint: - break - return lines - - def __iter__(self): - return self - - def __next__(self): - line = self.readline() - if not line: - raise StopIteration - return line - - -# ============================================================================ -# Streaming request body entry point -# ============================================================================ - -def handle_wsgi_streaming(caller_pid, app_module: bytes, app_callable: bytes, - environ_map: dict, channel_ref, content_length: int): - """Handle a WSGI request with streaming body via channel. - - This entry point is used for large request bodies (> 64KB) that are - streamed via py_channel instead of buffered. - - Args: - caller_pid: Erlang PID to send response to - app_module: Python module containing WSGI app (bytes) - app_callable: Name of WSGI callable in module (bytes) - environ_map: Pre-built environ dict from Erlang - channel_ref: py_channel reference for receiving body chunks - content_length: Content-Length header value - - Returns: - 'done' on success, 'error' on failure - """ - if not HAS_ERLANG: + reply(caller, (b'error', str(e).encode('utf-8'))) + except Exception: + pass return b'error' - from erlang import Channel - - # Convert bytes to strings - module_name = app_module.decode('utf-8') if isinstance(app_module, bytes) else app_module - callable_name = app_callable.decode('utf-8') if isinstance(app_callable, bytes) else app_callable - - try: - # Create channel wrapper and streaming body reader - channel = Channel(channel_ref) - wsgi_input = StreamingBodyReader(channel, content_length) - - # Build environ with streaming input - environ = _build_environ_streaming(environ_map, wsgi_input, content_length) - - # Load and call WSGI app - app = _load_app(module_name, callable_name) - response = _Response() - - result = app(environ, response.start_response) - - # Build state for response processing - state = { - 'caller': caller_pid, - 'response': response, - 'result': result, - 'result_iter': iter(result) if not isinstance(result, (bytes, bytearray)) else None, - 'body_parts': list(response._write_buffer), - 'total_size': sum(len(p) for p in response._write_buffer), - 'streaming': False, - 'phase': 'collect', - 'wsgi_input': wsgi_input, - 'channel': channel, - } - - return _wsgi_collect(state) - except Exception as e: +def _cleanup_result(result): + """Close result iterator if it has a close method.""" + if result and hasattr(result, 'close'): try: - reply(caller_pid, (b'error', str(e).encode('utf-8'))) + result.close() except Exception: pass - return b'error' - - -def _build_environ_streaming(environ_map: dict, wsgi_input, content_length: int) -> dict: - """Build WSGI environ with streaming body reader.""" - # Build environ from template - environ = _ENVIRON_TEMPLATE.copy() - - # Copy from environ_map, converting bytes to strings - for key, value in environ_map.items(): - str_key = key.decode('utf-8') if isinstance(key, bytes) else str(key) - if isinstance(value, bytes): - str_value = value.decode('utf-8', errors='replace') - elif value is None or (hasattr(value, '__class__') and value.__class__.__name__ == 'Atom'): - str_value = '' - else: - str_value = value - environ[str_key] = str_value - - # Set required WSGI keys with streaming input - environ['wsgi.input'] = wsgi_input - environ['wsgi.errors'] = _SHARED_ERRORS - - # Ensure CONTENT_LENGTH is set if provided - if content_length is not None: - environ['CONTENT_LENGTH'] = str(content_length) - - return environ - - -# ============================================================================ -# Legacy entry points (for backwards compatibility) -# ============================================================================ - -def handle_request_direct(args_tuple) -> None: - """Legacy entry point - redirects to handle_wsgi.""" - if not HAS_ERLANG: - return - - channel_ref, caller_pid, app_module, app_callable, environ, body = args_tuple - - # Call new implementation - handle_wsgi(caller_pid, app_module, app_callable, environ, body) diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl index 6fe29bc..e90fe68 100644 --- a/src/hornbeam_handler.erl +++ b/src/hornbeam_handler.erl @@ -87,27 +87,53 @@ handle_websocket_upgrade(Req, State) -> hornbeam_websocket:init(Req, State). %%% ============================================================================ -%%% WSGI Handler - uses context_call with schedule_inline +%%% 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) -> ReqInfo = build_request_info(Req), ReqInfo1 = hornbeam_http_hooks:run_on_request(ReqInfo), try - %% Check content-length to decide streaming vs buffered + AppModule = maps:get(app_module, State), + AppCallable = maps:get(app_callable, State), + TimeoutMs = maps:get(timeout, State, 30000), + + %% Build complete environ in Erlang + Environ = build_wsgi_environ(Req, State), + + %% Create channel for body + responses + {ok, Channel} = py_channel:new(#{max_size => 1048576}), + + %% Send body via channel (small or large) ContentLength = get_content_length(Req), - case ContentLength of - CL when CL =:= undefined; CL < ?WSGI_STREAMING_THRESHOLD -> - %% Small body: use buffered path - handle_wsgi_buffered(Req, ReqInfo1, State); - _ -> - %% Large body: stream via channel - handle_wsgi_streaming(Req, ReqInfo1, ContentLength, State) + send_body_to_channel(Req, Channel, ContentLength), + + %% Call Python + CtxRef = hornbeam_context_pool:get_context_ref(), + case py_nif:context_call(CtxRef, + <<"hornbeam_wsgi_worker">>, <<"handle_request">>, + [self(), Channel, AppModule, AppCallable, Environ], #{}) of + {ok, <<"done">>} -> + %% Enter receive loop + wsgi_receive_loop(Req, ReqInfo1, Channel, TimeoutMs, State); + {ok, <<"error">>} -> + py_channel:close(Channel), + handle_error(Req, wsgi_error, ReqInfo1, State); + {error, Reason} -> + py_channel:close(Channel), + handle_error(Req, Reason, ReqInfo1, State) end catch Class:Error:Stack -> @@ -128,75 +154,15 @@ get_content_length(Req) -> end. %% @private -%% Handle small request bodies - read fully before calling Python -handle_wsgi_buffered(Req, ReqInfo, State) -> - AppModule = maps:get(app_module, State), - AppCallable = maps:get(app_callable, State), - TimeoutMs = maps:get(timeout, State, 30000), - - %% Build environ map - Environ = build_environ_map(Req, State), - - %% Read request body fully +%% Send body to channel - unified for small and large bodies +send_body_to_channel(Req, Channel, ContentLength) when + ContentLength =:= undefined; ContentLength < ?WSGI_STREAMING_THRESHOLD -> + %% Small body: read all, send once {ok, Body, _Req2} = cowboy_req:read_body(Req), - - %% Get context ref from pool (zero-copy via persistent_term) - CtxRef = hornbeam_context_pool:get_context_ref(), - - %% Call Python via context - uses schedule_inline internally - case py_nif:context_call(CtxRef, - <<"hornbeam_wsgi_worker">>, <<"handle_wsgi">>, - [self(), AppModule, AppCallable, Environ, Body], #{}) of - {ok, <<"done">>} -> - %% Response sent via receive loop - receive_wsgi_response(Req, ReqInfo, TimeoutMs, State); - {ok, <<"error">>} -> - handle_error(Req, wsgi_error, ReqInfo, State); - {error, Reason} -> - handle_error(Req, Reason, ReqInfo, State) - end. - -%% @private -%% Handle large request bodies - stream via channel -handle_wsgi_streaming(Req, ReqInfo, ContentLength, State) -> - AppModule = maps:get(app_module, State), - AppCallable = maps:get(app_callable, State), - TimeoutMs = maps:get(timeout, State, 30000), - - %% Build environ map - Environ = build_environ_map(Req, State), - - %% Create channel for body streaming - {ok, BodyChannel} = py_channel:new(#{max_size => 1048576}), - - %% Spawn process to stream body chunks to channel - Self = self(), - spawn_link(fun() -> - try - stream_body_to_channel(Req, BodyChannel, ?WSGI_BODY_CHUNK_SIZE), - Self ! body_stream_done - catch - _:Reason -> - Self ! {body_stream_error, Reason} - end - end), - - %% Get context ref from pool - CtxRef = hornbeam_context_pool:get_context_ref(), - - %% Call Python with channel reference for streaming body - case py_nif:context_call(CtxRef, - <<"hornbeam_wsgi_worker">>, <<"handle_wsgi_streaming">>, - [self(), AppModule, AppCallable, Environ, BodyChannel, ContentLength], #{}) of - {ok, <<"done">>} -> - receive_wsgi_response(Req, ReqInfo, TimeoutMs, State); - {ok, <<"error">>} -> - py_channel:close(BodyChannel), - handle_error(Req, wsgi_error, ReqInfo, State); - {error, Reason} -> - py_channel:close(BodyChannel), - handle_error(Req, Reason, ReqInfo, State) - end. + py_channel:send(Channel, {body, Body}); +send_body_to_channel(Req, Channel, _ContentLength) -> + %% Large body: spawn process to stream chunks + spawn_link(fun() -> stream_body_to_channel(Req, Channel, ?WSGI_BODY_CHUNK_SIZE) end). %% @private %% Stream request body to channel in chunks @@ -213,53 +179,70 @@ stream_body_to_channel(Req, Channel, ChunkSize) -> end. %% @private -%% Receive WSGI response from Python worker -receive_wsgi_response(Req, ReqInfo, TimeoutMs, State) -> +%% Main receive loop for WSGI responses from Python +wsgi_receive_loop(Req, ReqInfo, Channel, TimeoutMs, State) -> receive - {<<"headers">>, StatusCode, Headers} -> - %% Streaming response - stream directly to client - CowboyHeaders = convert_headers(Headers), - receive_wsgi_body(Req, StatusCode, CowboyHeaders, TimeoutMs, State); + {<<"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, Channel, TimeoutMs, State); {<<"response">>, StatusCode, Headers, Body} -> - %% Buffered response (single message) + %% Complete response + SafeHeaders = filter_hop_by_hop(Headers), Response = #{ <<"status">> => StatusCode, - <<"headers">> => Headers, + <<"headers">> => SafeHeaders, <<"body">> => Body }, Response1 = hornbeam_http_hooks:run_on_response(Response), + py_channel:close(Channel), send_response(Req, Response1, State); {<<"error">>, Reason} -> + py_channel:close(Channel), handle_error(Req, Reason, ReqInfo, State) after TimeoutMs -> + py_channel:close(Channel), handle_error(Req, timeout, ReqInfo, State) end. %% @private -%% Receive body chunks - stream directly to client -receive_wsgi_body(Req, StatusCode, CowboyHeaders, TimeoutMs, State) -> - Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req), - stream_body(Req2, TimeoutMs, State). - -stream_body(Req, TimeoutMs, State) -> +%% Receive and stream response chunks to client +stream_response_loop(Req, Channel, TimeoutMs, State) -> receive {<<"chunk">>, Chunk} -> ok = cowboy_req:stream_body(Chunk, nofin, Req), - stream_body(Req, TimeoutMs, State); + stream_response_loop(Req, Channel, TimeoutMs, State); <<"done">> -> ok = cowboy_req:stream_body(<<>>, fin, Req), + py_channel:close(Channel), {ok, Req, State}; {<<"error">>, _Reason} -> ok = cowboy_req:stream_body(<<>>, fin, Req), + py_channel:close(Channel), {ok, Req, State} after TimeoutMs -> ok = cowboy_req:stream_body(<<>>, fin, Req), + py_channel:close(Channel), {ok, Req, State} end. +%% @private +%% 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). + %% @private %% Build environ map for WSGI -build_environ_map(Req, State) -> +build_wsgi_environ(Req, State) -> Method = cowboy_req:method(Req), Path = cowboy_req:path(Req), Qs = cowboy_req:qs(Req), @@ -373,7 +356,25 @@ receive_asgi_response(Req, ReqInfo, TimeoutMs, State) -> %% @private receive_asgi_body(Req, StatusCode, CowboyHeaders, TimeoutMs, State) -> Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req), - stream_body(Req2, TimeoutMs, State). + asgi_stream_body(Req2, TimeoutMs, State). + +%% @private +%% Stream ASGI response body chunks to client +asgi_stream_body(Req, TimeoutMs, State) -> + receive + {<<"chunk">>, Chunk} -> + ok = cowboy_req:stream_body(Chunk, nofin, Req), + asgi_stream_body(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 %% Build ASGI scope From 505854479fa6af7170ab4b3e44281d50a6339b4a Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sat, 14 Mar 2026 23:01:37 +0100 Subject: [PATCH 20/52] Replace py_channel with py_buffer for zero-copy WSGI body streaming - Use py_buffer API for request body (zero-copy shared memory) - Use erlang.send instead of erlang.reply for responses - Skip buffer creation for bodyless GET/HEAD/DELETE/OPTIONS - Preload WSGI app at startup in all contexts - Single message path for simple [body] list responses - Use worker mode instead of subinterpreter for contexts Benchmark: 56,720 req/sec (5.8x faster than Gunicorn) --- priv/hornbeam_wsgi_worker.py | 316 +++++++++------------------------- src/hornbeam.erl | 7 +- src/hornbeam_context_pool.erl | 30 +++- src/hornbeam_handler.erl | 79 +++++---- 4 files changed, 168 insertions(+), 264 deletions(-) diff --git a/priv/hornbeam_wsgi_worker.py b/priv/hornbeam_wsgi_worker.py index 0d4691b..cc1fd51 100644 --- a/priv/hornbeam_wsgi_worker.py +++ b/priv/hornbeam_wsgi_worker.py @@ -12,33 +12,29 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Simplified WSGI worker using unified channel-based approach. +"""WSGI worker using py_buffer for zero-copy request body streaming. This module provides a WSGI worker with: - Single entry point for all requests -- Channel-based body delivery via ChannelBuffer (file-like object) -- Clear schedule_inline phases for yielding +- py_buffer as wsgi.input (zero-copy shared memory) +- erlang.send for responses Architecture: -1. Erlang sends body via channel (single {body, Data} or streamed chunks) -2. Python phases using schedule_inline: - - Phase 1: handle_request - setup ChannelBuffer, call app, schedule iteration - - Phase 2: _iterate_response - send response chunks, yield every N chunks +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 import threading -from typing import Callable, Dict, List, Optional, Tuple +from typing import Callable, Dict, Tuple try: import erlang - from erlang import reply, Channel HAS_ERLANG = True except ImportError: HAS_ERLANG = False erlang = None - reply = None - Channel = None # ============================================================================ @@ -100,213 +96,44 @@ def __next__(self): # ============================================================================ -# ChannelBuffer - BufferedIOBase backed by channel +# App loading and preloading # ============================================================================ -class ChannelBuffer(io.BufferedIOBase): - """Buffered IO object that reads body from Erlang channel. +# Cached app reference - set by preload_app() for fast access +_preloaded_app: Callable = None +_preloaded_key: Tuple[str, str] = None - Inherits from io.BufferedIOBase for proper file-like interface. - Supports both single-message bodies ({body, Data}) and - streaming bodies ({body_chunk, Chunk}... body_done). - """ - - def __init__(self, channel): - self._channel = channel - self._buffer = b'' - self._eof = False - self._closed = False - - def readable(self) -> bool: - return True - - def writable(self) -> bool: - return False - - def seekable(self) -> bool: - return False - - @property - def closed(self) -> bool: - return self._closed - - def read(self, size: int = -1) -> bytes: - """Read up to size bytes from the body.""" - if self._closed: - raise ValueError("I/O operation on closed file") - - if self._eof and not self._buffer: - return b'' - - # If we have enough in buffer, return it - if size > 0 and len(self._buffer) >= size: - result = self._buffer[:size] - self._buffer = self._buffer[size:] - return result - - # Need to read more from channel - self._fill_buffer(size) - - # Return requested amount - if size is None or size < 0: - result = self._buffer - self._buffer = b'' - else: - result = self._buffer[:size] - self._buffer = self._buffer[size:] - - return result - - def read1(self, size: int = -1) -> bytes: - """Read up to size bytes with at most one channel read.""" - if self._closed: - raise ValueError("I/O operation on closed file") - - if self._eof and not self._buffer: - return b'' - - # If buffer is empty, do one read from channel - if not self._buffer and not self._eof: - self._pull_one() - - # Return what we have (up to size) - if size is None or size < 0: - result = self._buffer - self._buffer = b'' - else: - result = self._buffer[:size] - self._buffer = self._buffer[size:] - - return result - - def readinto(self, b) -> int: - """Read bytes into a pre-allocated buffer.""" - data = self.read(len(b)) - n = len(data) - b[:n] = data - return n - - def readinto1(self, b) -> int: - """Read bytes into buffer with at most one channel read.""" - data = self.read1(len(b)) - n = len(data) - b[:n] = data - return n - - def _pull_one(self): - """Pull one message from channel.""" - if self._eof: - return - - try: - msg = self._channel.receive(timeout_ms=30000) - except Exception: - self._eof = True - return - - if msg == b'body_done' or msg == 'body_done': - self._eof = True - elif isinstance(msg, tuple) and len(msg) >= 2: - tag, data = msg[0], msg[1] - if tag in (b'body', 'body'): - # Complete body in one message - if isinstance(data, bytes): - self._buffer += data - elif isinstance(data, str): - self._buffer += data.encode('utf-8') - self._eof = True - elif tag in (b'body_chunk', 'body_chunk'): - if isinstance(data, bytes): - self._buffer += data - elif isinstance(data, str): - self._buffer += data.encode('utf-8') - - def _fill_buffer(self, target_size: int = -1): - """Fill buffer from channel until we have enough or EOF.""" - while not self._eof: - if target_size > 0 and len(self._buffer) >= target_size: - break - self._pull_one() - - def readline(self, size: int = -1) -> bytes: - """Read a line from the body.""" - if self._closed: - raise ValueError("I/O operation on closed file") - - result = b'' - while True: - if size > 0 and len(result) >= size: - break - chunk = self.read(1) - if not chunk: - break - result += chunk - if chunk == b'\n': - break - return result - - def readlines(self, hint: int = -1) -> List[bytes]: - """Read all lines from the body.""" - lines = [] - total = 0 - while True: - line = self.readline() - if not line: - break - lines.append(line) - total += len(line) - if hint > 0 and total >= hint: - break - return lines - def __iter__(self): - return self - - def __next__(self): - line = self.readline() - if not line: - raise StopIteration - return line - - def close(self): - """Close the buffer.""" - if not self._closed: - self._eof = True - self._buffer = b'' - self._closed = True - super().close() - - -# ============================================================================ -# App loading -# ============================================================================ +def preload_app(app_module: bytes, app_callable: bytes) -> bytes: + """Preload WSGI application at startup for zero-overhead access. -_app_cache: Dict[Tuple[str, str], Callable] = {} -_app_cache_lock = threading.Lock() + Called from Erlang during context initialization. + """ + global _preloaded_app, _preloaded_key + module_name = app_module.decode('utf-8') if isinstance(app_module, bytes) else app_module + callable_name = app_callable.decode('utf-8') if isinstance(app_callable, bytes) else app_callable -def _load_app(module_name: str, callable_name: str) -> Callable: - """Load a WSGI application with thread-safe caching.""" - cache_key = (module_name, callable_name) + import importlib + module = importlib.import_module(module_name) + app = getattr(module, callable_name) - if cache_key in _app_cache: - return _app_cache[cache_key] + _preloaded_app = app + _preloaded_key = (module_name, callable_name) - with _app_cache_lock: - if cache_key in _app_cache: - return _app_cache[cache_key] + return b'ok' - import importlib - import sys - if module_name not in sys.modules: - module = importlib.import_module(module_name) - else: - module = sys.modules[module_name] +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 - app = getattr(module, callable_name) - _app_cache[cache_key] = app - return app + # Fallback: import on demand + import importlib + module = importlib.import_module(module_name) + return getattr(module, callable_name) # ============================================================================ @@ -388,15 +215,15 @@ def _write(self, data): # ============================================================================ -# Phase 1: Entry point - setup and call app +# Entry point - setup and call app # ============================================================================ -def handle_request(caller_pid, channel_ref, app_module: bytes, app_callable: bytes, environ_map: dict): - """Entry point - setup ChannelBuffer, call app, iterate response. +def handle_request(caller_pid, buffer, app_module: bytes, app_callable: bytes, environ_map: dict): + """Entry point - use py_buffer as wsgi.input, call app. Args: caller_pid: Erlang PID to send response to - channel_ref: Channel reference for receiving body + 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) environ_map: Pre-built environ dict from Erlang @@ -408,9 +235,6 @@ def handle_request(caller_pid, channel_ref, app_module: bytes, app_callable: byt return b'error' try: - # Wrap channel reference - channel = Channel(channel_ref) - # Convert bytes to strings module_name = app_module.decode('utf-8') if isinstance(app_module, bytes) else app_module callable_name = app_callable.decode('utf-8') if isinstance(app_callable, bytes) else app_callable @@ -418,13 +242,39 @@ def handle_request(caller_pid, channel_ref, app_module: bytes, app_callable: byt # Process environ (convert bytes to strings) environ = _process_environ(environ_map) - # Create ChannelBuffer as wsgi.input - wsgi_input = ChannelBuffer(channel) - environ['wsgi.input'] = wsgi_input + # 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 environ['wsgi.errors'] = _SHARED_ERRORS - # Load and call app - app = _load_app(module_name, callable_name) + # Call app in separate function (allows schedule_inline continuation) + 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 _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) @@ -439,20 +289,19 @@ def handle_request(caller_pid, channel_ref, app_module: bytes, app_callable: byt 'headers_sent': False, } - # Call iterate_response directly (not via schedule_inline) - # schedule_inline is only used for continuation within iteration + # Call iterate_response directly return _iterate_response(state) except Exception as e: try: - reply(caller_pid, (b'error', str(e).encode('utf-8'))) + erlang.send(caller_pid, (b'error', str(e).encode('utf-8'))) except Exception: pass return b'error' # ============================================================================ -# Phase 2: Iterate response +# Iterate response # ============================================================================ def _iterate_response(state: dict): @@ -476,16 +325,23 @@ def _iterate_response(state: dict): 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 - reply(caller, (b'response', state['status'], state['headers'], 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: - reply(caller, (b'start_response', state['status'], state['headers'])) + erlang.send(caller, (b'start_response', state['status'], state['headers'])) state['headers_sent'] = True for part in write_buffer: - reply(caller, (b'chunk', _to_bytes(part))) + erlang.send(caller, (b'chunk', _to_bytes(part))) state['write_buffer'] = [] # Process chunks from iterator @@ -502,10 +358,10 @@ def _iterate_response(state: dict): # Send headers on first chunk if not state['headers_sent']: - reply(caller, (b'start_response', state['status'], state['headers'])) + erlang.send(caller, (b'start_response', state['status'], state['headers'])) state['headers_sent'] = True - reply(caller, (b'chunk', chunk)) + erlang.send(caller, (b'chunk', chunk)) chunks_processed += 1 # Yield after batch to release scheduler @@ -517,10 +373,10 @@ def _iterate_response(state: dict): # Done iterating if state['headers_sent']: - reply(caller, b'done') + erlang.send(caller, b'done') else: # Empty response (no chunks produced) - reply(caller, (b'response', state['status'], state['headers'], b'')) + erlang.send(caller, (b'response', state['status'], state['headers'], b'')) _cleanup_result(result) return b'done' @@ -528,7 +384,7 @@ def _iterate_response(state: dict): except Exception as e: _cleanup_result(state.get('result')) try: - reply(caller, (b'error', str(e).encode('utf-8'))) + erlang.send(caller, (b'error', str(e).encode('utf-8'))) except Exception: pass return b'error' diff --git a/src/hornbeam.erl b/src/hornbeam.erl index d3e3855..d7fe6ac 100644 --- a/src/hornbeam.erl +++ b/src/hornbeam.erl @@ -180,8 +180,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 diff --git a/src/hornbeam_context_pool.erl b/src/hornbeam_context_pool.erl index dbfff39..111b6e2 100644 --- a/src/hornbeam_context_pool.erl +++ b/src/hornbeam_context_pool.erl @@ -33,7 +33,8 @@ get_context_rr/0, pool_size/0, stats/0, - add_paths/1 + add_paths/1, + preload_app/3 ]). %% gen_server callbacks @@ -107,6 +108,13 @@ stats() -> 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 %% ============================================================================ @@ -142,6 +150,24 @@ handle_call({add_paths, Paths}, _From, #state{contexts = Contexts} = State) -> end, Contexts), {reply, ok, State}; +handle_call({preload_app, WorkerClass, AppModule, AppCallable}, _From, + #state{contexts = Contexts} = 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, Contexts), + {reply, ok, State}; + handle_call(_Request, _From, State) -> {reply, {error, unknown_request}, State}. @@ -198,7 +224,7 @@ add_paths_to_context(Ref, Paths) -> end, Paths). create_context(Id, PrivDir) -> - case py_nif:context_create(auto) of + case py_nif:context_create(worker) of {ok, Ref, InterpId} -> %% Set up callback handler (for erlang.call from Python) py_nif:context_set_callback_handler(Ref, self()), diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl index e90fe68..5f2acc3 100644 --- a/src/hornbeam_handler.erl +++ b/src/hornbeam_handler.erl @@ -113,26 +113,30 @@ handle_wsgi(Req, State) -> %% Build complete environ in Erlang Environ = build_wsgi_environ(Req, State), - %% Create channel for body + responses - {ok, Channel} = py_channel:new(#{max_size => 1048576}), - - %% Send body via channel (small or large) + %% Create buffer for request body (skip for bodyless requests) ContentLength = get_content_length(Req), - send_body_to_channel(Req, Channel, ContentLength), + 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, %% Call Python CtxRef = hornbeam_context_pool:get_context_ref(), case py_nif:context_call(CtxRef, <<"hornbeam_wsgi_worker">>, <<"handle_request">>, - [self(), Channel, AppModule, AppCallable, Environ], #{}) of + [self(), Buffer, AppModule, AppCallable, Environ], #{}) of {ok, <<"done">>} -> %% Enter receive loop - wsgi_receive_loop(Req, ReqInfo1, Channel, TimeoutMs, State); + wsgi_receive_loop(Req, ReqInfo1, TimeoutMs, State); {ok, <<"error">>} -> - py_channel:close(Channel), handle_error(Req, wsgi_error, ReqInfo1, State); {error, Reason} -> - py_channel:close(Channel), handle_error(Req, Reason, ReqInfo1, State) end catch @@ -154,40 +158,59 @@ get_content_length(Req) -> end. %% @private -%% Send body to channel - unified for small and large bodies -send_body_to_channel(Req, Channel, ContentLength) when +%% 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 +%% 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, send once + %% Small body: read all, write once, close {ok, Body, _Req2} = cowboy_req:read_body(Req), - py_channel:send(Channel, {body, Body}); -send_body_to_channel(Req, Channel, _ContentLength) -> + 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_channel(Req, Channel, ?WSGI_BODY_CHUNK_SIZE) end). + spawn_link(fun() -> stream_body_to_buffer(Req, Buffer, ?WSGI_BODY_CHUNK_SIZE) end). %% @private -%% Stream request body to channel in chunks -stream_body_to_channel(Req, Channel, ChunkSize) -> +%% 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_channel:send(Channel, {body_chunk, Chunk}), - py_channel:send(Channel, body_done); + py_buffer:write(Buffer, Chunk), + py_buffer:close(Buffer); {more, Chunk, Req2} -> %% More data available - py_channel:send(Channel, {body_chunk, Chunk}), - stream_body_to_channel(Req2, Channel, ChunkSize) + py_buffer:write(Buffer, Chunk), + stream_body_to_buffer(Req2, Buffer, ChunkSize) end. %% @private %% Main receive loop for WSGI responses from Python -wsgi_receive_loop(Req, ReqInfo, Channel, TimeoutMs, State) -> +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, Channel, TimeoutMs, State); + stream_response_loop(Req2, TimeoutMs, State); {<<"response">>, StatusCode, Headers, Body} -> %% Complete response SafeHeaders = filter_hop_by_hop(Headers), @@ -197,34 +220,28 @@ wsgi_receive_loop(Req, ReqInfo, Channel, TimeoutMs, State) -> <<"body">> => Body }, Response1 = hornbeam_http_hooks:run_on_response(Response), - py_channel:close(Channel), send_response(Req, Response1, State); {<<"error">>, Reason} -> - py_channel:close(Channel), handle_error(Req, Reason, ReqInfo, State) after TimeoutMs -> - py_channel:close(Channel), handle_error(Req, timeout, ReqInfo, State) end. %% @private %% Receive and stream response chunks to client -stream_response_loop(Req, Channel, TimeoutMs, State) -> +stream_response_loop(Req, TimeoutMs, State) -> receive {<<"chunk">>, Chunk} -> ok = cowboy_req:stream_body(Chunk, nofin, Req), - stream_response_loop(Req, Channel, TimeoutMs, State); + stream_response_loop(Req, TimeoutMs, State); <<"done">> -> ok = cowboy_req:stream_body(<<>>, fin, Req), - py_channel:close(Channel), {ok, Req, State}; {<<"error">>, _Reason} -> ok = cowboy_req:stream_body(<<>>, fin, Req), - py_channel:close(Channel), {ok, Req, State} after TimeoutMs -> ok = cowboy_req:stream_body(<<>>, fin, Req), - py_channel:close(Channel), {ok, Req, State} end. From 888371cbe6a0d3cee7e6b1452edf8573dbe7959b Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sat, 14 Mar 2026 23:15:59 +0100 Subject: [PATCH 21/52] Rename workers to num_contexts and wire to context pool - Rename workers config option to num_contexts for clarity - Default num_contexts to erlang:system_info(schedulers) - Restart context pool when num_contexts changes - Fix _get_app safety check for multi-app scenarios - Remove unused Python runtime functions --- priv/hornbeam_wsgi_worker.py | 2 +- src/hornbeam.erl | 108 +++++++++++++---------------------- src/hornbeam_config.erl | 18 +++--- 3 files changed, 49 insertions(+), 79 deletions(-) diff --git a/priv/hornbeam_wsgi_worker.py b/priv/hornbeam_wsgi_worker.py index cc1fd51..f4060e9 100644 --- a/priv/hornbeam_wsgi_worker.py +++ b/priv/hornbeam_wsgi_worker.py @@ -249,7 +249,7 @@ def handle_request(caller_pid, buffer, app_module: bytes, app_callable: bytes, e environ['wsgi.input'] = buffer environ['wsgi.errors'] = _SHARED_ERRORS - # Call app in separate function (allows schedule_inline continuation) + # Call app directly (faster than schedule_inline for simple requests) return _call_app(caller_pid, module_name, callable_name, environ) except Exception as e: diff --git a/src/hornbeam.erl b/src/hornbeam.erl index d7fe6ac..2cd9735 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,7 +66,7 @@ -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, timeout => pos_integer(), @@ -133,14 +133,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 +167,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 @@ -260,13 +260,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(), @@ -365,7 +365,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), @@ -377,7 +377,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 }; @@ -514,14 +514,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, @@ -542,69 +542,39 @@ 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)), + ok = application:set_env(hornbeam, context_pool_size, NumContexts), + case current_context_count() of + {ok, NumContexts} -> ok; _ -> - restart_python_runtime() + restart_context_pool() 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 +restart_context_pool() -> + %% Restart context pool with new size + case supervisor:terminate_child(hornbeam_sup, hornbeam_context_pool) of ok -> - start_python_runtime(); - {error, {not_started, erlang_python}} -> - start_python_runtime(); - {error, Reason} -> - {error, {python_stop_failed, Reason}} - end. - -start_python_runtime() -> - case application:start(erlang_python) 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) -> 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 From 568353e3a6341db810b2e4ebf97d387cae4fdc70 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 17 Mar 2026 07:53:45 +0100 Subject: [PATCH 22/52] Use py_event_loop:spawn_task for ASGI async execution - Replace py_event_loop:create_task with spawn_task (fire-and-forget) - Use py_buffer for request body streaming (consistent with WSGI) - Handle async_result messages in receive loops - Add asgi_noop_app.py benchmark app - Simplify ASGI worker to use erlang.run() pattern ASGI performance: ~39k req/sec (WSGI: ~62.5k req/sec) --- benchmarks/asgi_noop_app.py | 17 +++ benchmarks/simple_asgi_app.py | 1 - priv/hornbeam_asgi_worker.py | 204 ++++++++++++++-------------------- src/hornbeam_handler.erl | 30 +++-- 4 files changed, 121 insertions(+), 131 deletions(-) create mode 100644 benchmarks/asgi_noop_app.py 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/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/priv/hornbeam_asgi_worker.py b/priv/hornbeam_asgi_worker.py index cd7e7a1..7a1ebd6 100644 --- a/priv/hornbeam_asgi_worker.py +++ b/priv/hornbeam_asgi_worker.py @@ -32,7 +32,6 @@ import asyncio import importlib import sys -import threading from typing import Any, Callable, Dict, List, Optional, Tuple try: @@ -44,32 +43,38 @@ # ============================================================================ -# App loading +# App loading and preloading # ============================================================================ -_app_cache: Dict[Tuple[str, str], Callable] = {} -_app_cache_lock = threading.Lock() +# Cached app reference - set by preload_app() for fast access +_preloaded_app: Callable = None +_preloaded_key: Tuple[str, str] = None -def _load_app(module_name: str, callable_name: str) -> Callable: - """Load an ASGI application with thread-safe caching.""" - cache_key = (module_name, callable_name) +def preload_app(app_module: bytes, app_callable: bytes) -> bytes: + """Preload ASGI application at startup for zero-overhead access.""" + global _preloaded_app, _preloaded_key - if cache_key in _app_cache: - return _app_cache[cache_key] + module_name = app_module.decode('utf-8') if isinstance(app_module, bytes) else app_module + callable_name = app_callable.decode('utf-8') if isinstance(app_callable, bytes) else app_callable - with _app_cache_lock: - if cache_key in _app_cache: - return _app_cache[cache_key] + module = importlib.import_module(module_name) + app = getattr(module, callable_name) - if module_name not in sys.modules: - module = importlib.import_module(module_name) - else: - module = sys.modules[module_name] + _preloaded_app = app + _preloaded_key = (module_name, callable_name) - app = getattr(module, callable_name) - _app_cache[cache_key] = app - return app + return b'ok' + + +def _get_app(module_name: str, callable_name: str) -> Callable: + """Get ASGI application - uses preloaded app if available.""" + if _preloaded_key == (module_name, callable_name): + return _preloaded_app + + # Fallback: import on demand + module = importlib.import_module(module_name) + return getattr(module, callable_name) # ============================================================================ @@ -106,10 +111,10 @@ def _to_str(val) -> str: # ============================================================================ async def handle_asgi(caller_pid, app_module: bytes, app_callable: bytes, - scope: dict, body: bytes): + scope: dict, buffer): """Handle an ASGI request asynchronously. - This is the main entry point called from Erlang via py_event_loop. + This is the main entry point called from Erlang via erlang.run(). Uses erlang.send() to stream response directly to caller. Args: @@ -117,10 +122,7 @@ async def handle_asgi(caller_pid, app_module: bytes, app_callable: bytes, app_module: Python module containing ASGI app (bytes) app_callable: Name of ASGI callable in module (bytes) scope: ASGI scope dict - body: Request body bytes - - Note: This function MUST be called via py_event_loop:create_task() - or erlang.run() for proper async execution. + buffer: py_buffer for request body, or 'empty' atom for bodyless requests """ if not HAS_ERLANG: return @@ -129,18 +131,18 @@ async def handle_asgi(caller_pid, app_module: bytes, app_callable: bytes, module_name = _to_str(app_module) callable_name = _to_str(app_callable) - # Ensure body is bytes - if isinstance(body, str): - body = body.encode('utf-8') - elif not isinstance(body, bytes): - body = b'' + # Determine buffer for receive (None for bodyless requests) + if buffer == b'empty' or (hasattr(buffer, '__class__') and buffer.__class__.__name__ == 'Atom'): + actual_buffer = None + else: + actual_buffer = buffer try: - # Load app - app = _load_app(module_name, callable_name) + # Get app (preloaded or import on demand) + app = _get_app(module_name, callable_name) # Create receive/send callables - receive = _ASGIReceive(body) + receive = _ASGIReceive(actual_buffer) send = _ASGISend(caller_pid) # Run ASGI app @@ -160,14 +162,14 @@ async def handle_asgi(caller_pid, app_module: bytes, app_callable: bytes, class _ASGIReceive: - """ASGI receive callable with streaming support.""" - __slots__ = ('body', 'body_sent', 'channel', 'disconnected') + """ASGI receive callable with async buffer support.""" + __slots__ = ('buffer', 'body_sent', 'disconnected', '_cached_body') - def __init__(self, body: bytes, channel=None): - self.body = body + def __init__(self, buffer): + self.buffer = buffer # py_buffer or None for empty self.body_sent = False - self.channel = channel # Optional channel for streaming body self.disconnected = False + self._cached_body = None async def __call__(self) -> dict: if self.disconnected: @@ -175,38 +177,44 @@ async def __call__(self) -> dict: if not self.body_sent: self.body_sent = True - - # If we have a channel, read body from it - if self.channel is not None: - body, more_body = await self._read_from_channel() - if not more_body: - self.body_sent = True - return {'type': 'http.request', 'body': body, 'more_body': more_body} - - # Use pre-loaded body - return {'type': 'http.request', 'body': self.body, 'more_body': False} + body = await self._read_body() + return {'type': 'http.request', 'body': body, 'more_body': False} return _DISCONNECT_MSG - async def _read_from_channel(self) -> Tuple[bytes, bool]: - """Read body chunk from channel (for streaming uploads).""" - from erlang import Channel, ChannelClosed - - try: - msg = self.channel.receive(timeout=30000) - - if msg == b'body_done' or msg == 'body_done': - return b'', False - - if isinstance(msg, tuple) and len(msg) == 2: - tag, chunk = msg - if tag == b'body_chunk' or tag == 'body_chunk': - return _to_bytes(chunk), True - - except ChannelClosed: - self.disconnected = True + async def _read_body(self) -> bytes: + """Read body from buffer using async non-blocking reads.""" + if self._cached_body is not None: + return self._cached_body + + if self.buffer is None: + self._cached_body = b'' + return b'' + + # Use non-blocking reads with asyncio yield + chunks = [] + while True: + # Check if data available + if hasattr(self.buffer, 'readable_amount'): + available = self.buffer.readable_amount() + if available > 0: + chunk = self.buffer.read_nonblock(available) + if chunk: + chunks.append(chunk) + elif hasattr(self.buffer, 'at_eof') and self.buffer.at_eof(): + break + else: + # Yield to event loop while waiting for data + await asyncio.sleep(0) + else: + # Fallback: blocking read (buffer already complete) + chunk = self.buffer.read() if hasattr(self.buffer, 'read') else b'' + if chunk: + chunks.append(chunk) + break - return b'', False + self._cached_body = b''.join(chunks) + return self._cached_body class _ASGISend: @@ -292,77 +300,31 @@ async def __call__(self, message: dict) -> None: self.finished = True -# ============================================================================ -# Streaming request body support -# ============================================================================ - -async def handle_asgi_streaming(caller_pid, app_module: bytes, app_callable: bytes, - scope: dict, channel_ref): - """Handle an ASGI request with streaming request body. - - This variant reads the request body from a channel, enabling - streaming uploads without buffering the entire body. - - Args: - caller_pid: Erlang PID to send response to - app_module: Python module containing ASGI app (bytes) - app_callable: Name of ASGI callable in module (bytes) - scope: ASGI scope dict - channel_ref: Channel reference for receiving body chunks - """ - if not HAS_ERLANG: - return - - from erlang import Channel - - module_name = _to_str(app_module) - callable_name = _to_str(app_callable) - - try: - app = _load_app(module_name, callable_name) - - channel = Channel(channel_ref) - receive = _ASGIReceive(b'', channel=channel) - send = _ASGISend(caller_pid, buffering=False) # Always stream response - - await app(scope, receive, send) - - if not send.finished: - if not send.headers_sent: - erlang.send(caller_pid, (b'headers', 500, [])) - erlang.send(caller_pid, b'done') - - except Exception as e: - try: - erlang.send(caller_pid, (b'error', str(e).encode('utf-8'))) - except Exception: - pass - - # ============================================================================ # Synchronous wrapper for context_call # ============================================================================ def handle_asgi_sync(caller_pid, app_module: bytes, app_callable: bytes, - scope: dict, body: bytes): + scope: dict, buffer): """Synchronous wrapper that runs handle_asgi with erlang.run(). This is used when calling from py_nif:context_call() which expects - a synchronous function. Uses erlang.run() to get proper event loop. + a synchronous function. Uses erlang.run() for proper Erlang event + loop integration. Args: caller_pid: Erlang PID to send response to app_module: Python module containing ASGI app (bytes) app_callable: Name of ASGI callable in module (bytes) scope: ASGI scope dict - body: Request body bytes + buffer: py_buffer for request body, or 'empty' atom for bodyless requests """ if not HAS_ERLANG: return b'error' try: # Use erlang.run() for proper Erlang event loop integration - erlang.run(handle_asgi(caller_pid, app_module, app_callable, scope, body)) + erlang.run(handle_asgi(caller_pid, app_module, app_callable, scope, buffer)) return b'done' except Exception as e: try: @@ -477,14 +439,10 @@ async def send(message: dict) -> None: pass -# ============================================================================ -# Legacy entry points (for backwards compatibility) -# ============================================================================ - def handle_request_direct(args_tuple) -> None: """Legacy entry point - uses sync wrapper.""" if not HAS_ERLANG: return - channel_ref, caller_pid, app_module, app_callable, scope, body = args_tuple - handle_asgi_sync(caller_pid, app_module, app_callable, scope, body) + caller_pid, app_module, app_callable, scope, buffer = args_tuple + handle_asgi_sync(caller_pid, app_module, app_callable, scope, buffer) diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl index 5f2acc3..75640e0 100644 --- a/src/hornbeam_handler.erl +++ b/src/hornbeam_handler.erl @@ -324,14 +324,23 @@ handle_asgi(Req, State) -> %% Build ASGI scope Scope = build_scope(Req, State), - %% Read request body - {ok, Body, _Req2} = cowboy_req:read_body(Req), + %% 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 -> + empty; + true -> + {ok, Buf} = create_body_buffer(ContentLength), + write_body_to_buffer(Req, Buf, ContentLength), + Buf + end, - %% Submit to event loop (non-blocking, async execution) - %% Python will send response via erlang.send() - _Ref = py_event_loop:create_task( + %% Spawn async task to run Python ASGI handler (fire-and-forget) + %% Python handler sends response via erlang.send() to this process + ok = py_event_loop:spawn_task( <<"hornbeam_asgi_worker">>, <<"handle_asgi">>, - [self(), AppModule, AppCallable, Scope, Body]), + [self(), AppModule, AppCallable, Scope, Buffer]), %% Receive response from async Python receive_asgi_response(Req, ReqInfo1, TimeoutMs, State) @@ -365,6 +374,10 @@ receive_asgi_response(Req, ReqInfo, TimeoutMs, State) -> Req2 = cowboy_req:inform(103, HintHeaders, Req), receive_asgi_response(Req2, ReqInfo, TimeoutMs, State); {<<"error">>, Reason} -> + handle_error(Req, Reason, ReqInfo, State); + {async_result, _Ref, {ok, _}} -> + receive_asgi_response(Req, ReqInfo, TimeoutMs, State); + {async_result, _Ref, {error, Reason}} -> handle_error(Req, Reason, ReqInfo, State) after TimeoutMs -> handle_error(Req, timeout, ReqInfo, State) @@ -387,7 +400,10 @@ asgi_stream_body(Req, TimeoutMs, State) -> {ok, Req, State}; {<<"error">>, _Reason} -> ok = cowboy_req:stream_body(<<>>, fin, Req), - {ok, Req, State} + {ok, Req, State}; + {async_result, _Ref, _Result} -> + %% Async task finished, continue streaming + asgi_stream_body(Req, TimeoutMs, State) after TimeoutMs -> ok = cowboy_req:stream_body(<<>>, fin, Req), {ok, Req, State} From 85b17f870556e30e4a28ed8cc4ef4f5743e8f128 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 17 Mar 2026 09:03:21 +0100 Subject: [PATCH 23/52] Optimize ASGI performance with scope template and cached send - Add pre-computed ASGI_SCOPE_TEMPLATE macro for static scope fields - Cache _erlang_send function reference to avoid attribute lookup per call - Store cached send in _ASGISend.__slots__ for instance-level access Performance improvement: - Before: ~39k req/sec - After: ~63k req/sec (+62%) - ASGI now matches WSGI performance --- priv/hornbeam_asgi_worker.py | 44 ++++++++++++++++++++---------------- src/hornbeam_handler.erl | 35 ++++++++++++++-------------- 2 files changed, 41 insertions(+), 38 deletions(-) diff --git a/priv/hornbeam_asgi_worker.py b/priv/hornbeam_asgi_worker.py index 7a1ebd6..fea702f 100644 --- a/priv/hornbeam_asgi_worker.py +++ b/priv/hornbeam_asgi_worker.py @@ -37,9 +37,12 @@ try: import erlang HAS_ERLANG = True + # Cache erlang.send for faster lookups (avoids attribute access per call) + _erlang_send = erlang.send except ImportError: HAS_ERLANG = False erlang = None + _erlang_send = None # ============================================================================ @@ -151,12 +154,12 @@ async def handle_asgi(caller_pid, app_module: bytes, app_callable: bytes, # Ensure completion is signaled if not send.finished: if not send.headers_sent: - erlang.send(caller_pid, (b'headers', 500, [])) - erlang.send(caller_pid, b'done') + _erlang_send(caller_pid, (b'headers', 500, [])) + _erlang_send(caller_pid, b'done') except Exception as e: try: - erlang.send(caller_pid, (b'error', str(e).encode('utf-8'))) + _erlang_send(caller_pid, (b'error', str(e).encode('utf-8'))) except Exception: pass @@ -220,13 +223,14 @@ async def _read_body(self) -> bytes: class _ASGISend: """ASGI send callable that streams to Erlang via erlang.send().""" __slots__ = ('caller_pid', 'status', 'headers', 'headers_sent', - 'body_parts', 'finished', 'buffering') + 'body_parts', 'finished', 'buffering', '_send') # Buffer small responses before sending BUFFER_THRESHOLD = 65536 def __init__(self, caller_pid, buffering: bool = True): self.caller_pid = caller_pid + self._send = _erlang_send # Cached function reference self.status = None self.headers = [] self.headers_sent = False @@ -243,7 +247,7 @@ async def __call__(self, message: dict) -> None: if not self.buffering: # Send headers immediately for streaming - erlang.send(self.caller_pid, (b'headers', self.status, self.headers)) + self._send(self.caller_pid, (b'headers', self.status, self.headers)) self.headers_sent = True elif msg_type == 'http.response.body': @@ -263,30 +267,30 @@ async def __call__(self, message: dict) -> None: if not more_body: # Done - send complete response body = b''.join(self.body_parts) - erlang.send(self.caller_pid, + self._send(self.caller_pid, (b'response', self.status or 500, self.headers, body)) self.finished = True elif total_size >= self.BUFFER_THRESHOLD: # Switch to streaming - erlang.send(self.caller_pid, (b'headers', self.status or 500, self.headers)) + self._send(self.caller_pid, (b'headers', self.status or 500, self.headers)) self.headers_sent = True for part in self.body_parts: - erlang.send(self.caller_pid, (b'chunk', part)) + self._send(self.caller_pid, (b'chunk', part)) self.body_parts.clear() self.buffering = False else: # Streaming mode if not self.headers_sent: - erlang.send(self.caller_pid, (b'headers', self.status or 500, self.headers)) + self._send(self.caller_pid, (b'headers', self.status or 500, self.headers)) self.headers_sent = True if body_part: - erlang.send(self.caller_pid, (b'chunk', body_part)) + self._send(self.caller_pid, (b'chunk', body_part)) if not more_body: - erlang.send(self.caller_pid, b'done') + self._send(self.caller_pid, b'done') self.finished = True elif msg_type == 'http.response.informational': @@ -294,7 +298,7 @@ async def __call__(self, message: dict) -> None: status = message.get('status', 100) headers = message.get('headers', []) if status == 103: - erlang.send(self.caller_pid, (b'early_hints', headers)) + self._send(self.caller_pid, (b'early_hints', headers)) elif msg_type == 'http.disconnect': self.finished = True @@ -328,7 +332,7 @@ def handle_asgi_sync(caller_pid, app_module: bytes, app_callable: bytes, return b'done' except Exception as e: try: - erlang.send(caller_pid, (b'error', str(e).encode('utf-8'))) + _erlang_send(caller_pid, (b'error', str(e).encode('utf-8'))) except Exception: pass return b'error' @@ -410,31 +414,31 @@ async def send(message: dict) -> None: connected = True subprotocol = message.get('subprotocol') headers = message.get('headers', []) - erlang.send(caller_pid, (b'accept', subprotocol, headers)) + _erlang_send(caller_pid, (b'accept', subprotocol, headers)) elif msg_type == 'websocket.send': if 'text' in message: - erlang.send(caller_pid, (b'text', message['text'])) + _erlang_send(caller_pid, (b'text', message['text'])) elif 'bytes' in message: - erlang.send(caller_pid, (b'bytes', message['bytes'])) + _erlang_send(caller_pid, (b'bytes', message['bytes'])) elif msg_type == 'websocket.close': code = message.get('code', 1000) reason = message.get('reason', '') - erlang.send(caller_pid, (b'close', code, reason)) + _erlang_send(caller_pid, (b'close', code, reason)) closed = True try: - app = _load_app(module_name, callable_name) + app = _get_app(module_name, callable_name) await app(scope, receive, send) # Ensure close is sent if not closed: - erlang.send(caller_pid, (b'close', 1000, b'')) + _erlang_send(caller_pid, (b'close', 1000, b'')) except Exception as e: try: - erlang.send(caller_pid, (b'error', str(e).encode('utf-8'))) + _erlang_send(caller_pid, (b'error', str(e).encode('utf-8'))) except Exception: pass diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl index 75640e0..f8978f2 100644 --- a/src/hornbeam_handler.erl +++ b/src/hornbeam_handler.erl @@ -94,6 +94,13 @@ handle_websocket_upgrade(Req, State) -> -define(WSGI_STREAMING_THRESHOLD, 65536). %% 64KB -define(WSGI_BODY_CHUNK_SIZE, 65536). %% 64KB chunks +%% Pre-computed ASGI scope template (static fields) +%% Avoids recreating these maps on every request +-define(ASGI_SCOPE_TEMPLATE, #{ + type => <<"http">>, + asgi => #{<<"version">> => <<"3.0">>, <<"spec_version">> => <<"2.4">>} +}). + %% Hop-by-hop headers that should not be forwarded -define(HOP_BY_HOP_HEADERS, [ <<"connection">>, <<"keep-alive">>, <<"proxy-authenticate">>, @@ -410,15 +417,9 @@ asgi_stream_body(Req, TimeoutMs, State) -> end. %% @private -%% Build ASGI scope +%% Build ASGI scope - uses pre-computed template for static fields build_scope(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), @@ -426,26 +427,24 @@ build_scope(Req, State) -> RootPath = maps:get(script_name, State, <<>>), ScopePath = maps:get(path_info, State, Path), + %% Build headers list - inline for performance HeaderList = maps:fold(fun(Name, Value, Acc) -> [[Name, Value] | Acc] - end, [], Headers), - - LifespanState = hornbeam_lifespan:get_state(), + end, [], cowboy_req:headers(Req)), - #{ - type => <<"http">>, - asgi => #{<<"version">> => <<"3.0">>, <<"spec_version">> => <<"2.4">>}, + %% Merge dynamic fields into pre-computed template + ?ASGI_SCOPE_TEMPLATE#{ http_version => format_http_version(Version), - method => Method, - scheme => Scheme, + method => cowboy_req:method(Req), + scheme => cowboy_req:scheme(Req), path => ScopePath, raw_path => ScopePath, - query_string => Qs, + query_string => cowboy_req:qs(Req), root_path => RootPath, headers => HeaderList, - server => {Host, Port}, + server => {cowboy_req:host(Req), cowboy_req:port(Req)}, client => {format_ip(ClientIp), ClientPort}, - state => LifespanState, + state => hornbeam_lifespan:get_state(), extensions => build_extensions(Version) }. From 1bbab18d4895b543c433145f77bd1f5bdbf6a62b Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 17 Mar 2026 09:09:00 +0100 Subject: [PATCH 24/52] Fix ASGI large response streaming Check body size threshold before checking more_body flag. This ensures large single-chunk responses are streamed instead of buffered, preventing memory issues with large responses. - Reorder threshold check to happen first - Stream if total_size >= BUFFER_THRESHOLD (64KB) - Add test app for large response validation --- benchmarks/asgi_large_response_app.py | 27 +++++++++++++++++++++++++++ priv/hornbeam_asgi_worker.py | 22 +++++++++++++--------- 2 files changed, 40 insertions(+), 9 deletions(-) create mode 100644 benchmarks/asgi_large_response_app.py 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/priv/hornbeam_asgi_worker.py b/priv/hornbeam_asgi_worker.py index fea702f..9a8dc2c 100644 --- a/priv/hornbeam_asgi_worker.py +++ b/priv/hornbeam_asgi_worker.py @@ -264,15 +264,8 @@ async def __call__(self, message: dict) -> None: total_size = sum(len(p) for p in self.body_parts) - if not more_body: - # Done - send complete response - body = b''.join(self.body_parts) - self._send(self.caller_pid, - (b'response', self.status or 500, self.headers, body)) - self.finished = True - - elif total_size >= self.BUFFER_THRESHOLD: - # Switch to streaming + if total_size >= self.BUFFER_THRESHOLD: + # Body too large - switch to streaming self._send(self.caller_pid, (b'headers', self.status or 500, self.headers)) self.headers_sent = True for part in self.body_parts: @@ -280,6 +273,17 @@ async def __call__(self, message: dict) -> None: self.body_parts.clear() self.buffering = False + if not more_body: + self._send(self.caller_pid, b'done') + self.finished = True + + elif not more_body: + # Small response - send complete in one message + body = b''.join(self.body_parts) + self._send(self.caller_pid, + (b'response', self.status or 500, self.headers, body)) + self.finished = True + else: # Streaming mode if not self.headers_sent: From 3b2c5b8175e419458ba33a3f28f442e380e45dc7 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 17 Mar 2026 09:34:33 +0100 Subject: [PATCH 25/52] Cache lifespan state in handler state at startup Fetch lifespan_state once when configuring cowboy routes instead of calling hornbeam_lifespan:get_state() on every request. - Add lifespan_state to HandlerState in start_listener - Add lifespan_state to multi-app HandlerState - Use cached state in build_scope instead of ETS lookup --- src/hornbeam.erl | 8 ++++++-- src/hornbeam_handler.erl | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/hornbeam.erl b/src/hornbeam.erl index 2cd9735..0e9f02a 100644 --- a/src/hornbeam.erl +++ b/src/hornbeam.erl @@ -473,8 +473,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) @@ -648,11 +650,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_handler.erl b/src/hornbeam_handler.erl index f8978f2..06042e8 100644 --- a/src/hornbeam_handler.erl +++ b/src/hornbeam_handler.erl @@ -432,6 +432,9 @@ build_scope(Req, State) -> [[Name, Value] | Acc] end, [], cowboy_req:headers(Req)), + %% Get lifespan state from handler state (cached at startup, no lookup per request) + LifespanState = maps:get(lifespan_state, State, #{}), + %% Merge dynamic fields into pre-computed template ?ASGI_SCOPE_TEMPLATE#{ http_version => format_http_version(Version), @@ -444,7 +447,7 @@ build_scope(Req, State) -> headers => HeaderList, server => {cowboy_req:host(Req), cowboy_req:port(Req)}, client => {format_ip(ClientIp), ClientPort}, - state => hornbeam_lifespan:get_state(), + state => LifespanState, extensions => build_extensions(Version) }. From 1c7e4795a4bddf23955b6e512c0f76a56b560d2a Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 17 Mar 2026 10:50:59 +0100 Subject: [PATCH 26/52] Optimize WSGI/ASGI performance with 5 targeted improvements - Fix quadratic buffering in ASGI send with O(1) size tracking - Use create_task instead of spawn_task to avoid process overhead - Move pythonpath setup to mount registration (not per-request) - Implement ASGI request body streaming with more_body support - Wire up WSGI tuple fast path for O(1) environ creation ASGI now at 86% of WSGI throughput (67.5K vs 78.3K req/s). --- priv/hornbeam_asgi_worker.py | 92 +++++++++++++++-------------- priv/hornbeam_wsgi_worker.py | 108 +++++++++++++++++++++++++++++++++++ src/hornbeam_handler.erl | 105 +++------------------------------- src/hornbeam_mounts.erl | 23 ++++++++ src/hornbeam_request.erl | 5 +- 5 files changed, 191 insertions(+), 142 deletions(-) diff --git a/priv/hornbeam_asgi_worker.py b/priv/hornbeam_asgi_worker.py index 9a8dc2c..4c2c350 100644 --- a/priv/hornbeam_asgi_worker.py +++ b/priv/hornbeam_asgi_worker.py @@ -165,65 +165,72 @@ async def handle_asgi(caller_pid, app_module: bytes, app_callable: bytes, class _ASGIReceive: - """ASGI receive callable with async buffer support.""" - __slots__ = ('buffer', 'body_sent', 'disconnected', '_cached_body') + """ASGI receive callable with streaming buffer support.""" + __slots__ = ('buffer', 'body_sent', 'disconnected', '_eof_reached') + + # Max chunk size for streaming + CHUNK_SIZE = 65536 def __init__(self, buffer): self.buffer = buffer # py_buffer or None for empty self.body_sent = False self.disconnected = False - self._cached_body = None + self._eof_reached = False async def __call__(self) -> dict: if self.disconnected: return _DISCONNECT_MSG - if not self.body_sent: - self.body_sent = True - body = await self._read_body() - return {'type': 'http.request', 'body': body, 'more_body': False} + if self.buffer is None: + # No body - return empty with more_body=False + if not self.body_sent: + self.body_sent = True + return {'type': 'http.request', 'body': b'', 'more_body': False} + return _DISCONNECT_MSG - return _DISCONNECT_MSG + # Stream chunks from buffer with more_body=True until EOF + chunk = await self._read_chunk() + if chunk is None: + return _DISCONNECT_MSG - async def _read_body(self) -> bytes: - """Read body from buffer using async non-blocking reads.""" - if self._cached_body is not None: - return self._cached_body + # Check if we've reached EOF + at_eof = self._eof_reached + return {'type': 'http.request', 'body': chunk, 'more_body': not at_eof} - if self.buffer is None: - self._cached_body = b'' - return b'' - - # Use non-blocking reads with asyncio yield - chunks = [] - while True: - # Check if data available - if hasattr(self.buffer, 'readable_amount'): + async def _read_chunk(self) -> bytes: + """Read next available chunk from buffer.""" + if self._eof_reached: + self.disconnected = True + return None + + if hasattr(self.buffer, 'readable_amount'): + # Streaming buffer - wait for data + while True: available = self.buffer.readable_amount() if available > 0: - chunk = self.buffer.read_nonblock(available) - if chunk: - chunks.append(chunk) - elif hasattr(self.buffer, 'at_eof') and self.buffer.at_eof(): - break - else: - # Yield to event loop while waiting for data - await asyncio.sleep(0) - else: - # Fallback: blocking read (buffer already complete) - chunk = self.buffer.read() if hasattr(self.buffer, 'read') else b'' - if chunk: - chunks.append(chunk) - break - - self._cached_body = b''.join(chunks) - return self._cached_body + chunk = self.buffer.read_nonblock(min(available, self.CHUNK_SIZE)) + # Check EOF after read + if hasattr(self.buffer, 'at_eof') and self.buffer.at_eof(): + self._eof_reached = True + return chunk if chunk else b'' + if hasattr(self.buffer, 'at_eof') and self.buffer.at_eof(): + self._eof_reached = True + return b'' + # Yield to event loop while waiting for data + await asyncio.sleep(0) + else: + # Fallback: blocking read (buffer already complete) + if not self.body_sent: + self.body_sent = True + self._eof_reached = True + return self.buffer.read() if hasattr(self.buffer, 'read') else b'' + return None class _ASGISend: """ASGI send callable that streams to Erlang via erlang.send().""" __slots__ = ('caller_pid', 'status', 'headers', 'headers_sent', - 'body_parts', 'finished', 'buffering', '_send') + 'body_parts', 'finished', 'buffering', '_send', '_total_size') # Buffer small responses before sending BUFFER_THRESHOLD = 65536 @@ -237,6 +244,7 @@ def __init__(self, caller_pid, buffering: bool = True): self.body_parts = [] self.finished = False self.buffering = buffering + self._total_size = 0 # Track buffer size in O(1) instead of O(n) async def __call__(self, message: dict) -> None: msg_type = message.get('type', '') @@ -261,16 +269,16 @@ async def __call__(self, message: dict) -> None: # Buffer body parts if body_part: self.body_parts.append(body_part) + self._total_size += len(body_part) # O(1) instead of O(n) - total_size = sum(len(p) for p in self.body_parts) - - if total_size >= self.BUFFER_THRESHOLD: + if self._total_size >= self.BUFFER_THRESHOLD: # Body too large - switch to streaming self._send(self.caller_pid, (b'headers', self.status or 500, self.headers)) self.headers_sent = True for part in self.body_parts: self._send(self.caller_pid, (b'chunk', part)) self.body_parts.clear() + self._total_size = 0 self.buffering = False if not more_body: diff --git a/priv/hornbeam_wsgi_worker.py b/priv/hornbeam_wsgi_worker.py index f4060e9..4d04269 100644 --- a/priv/hornbeam_wsgi_worker.py +++ b/priv/hornbeam_wsgi_worker.py @@ -397,3 +397,111 @@ def _cleanup_result(result): 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 + module_name = app_module.decode('utf-8') if isinstance(app_module, bytes) else app_module + callable_name = app_callable.decode('utf-8') if isinstance(app_callable, bytes) else 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). + + 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() + + # Convert bytes to strings for key values + def to_str(v): + if isinstance(v, bytes): + return v.decode('utf-8', errors='replace') + return str(v) if v is not None else '' + + # Update with request-specific values (no loops!) + environ['REQUEST_METHOD'] = to_str(method) + environ['SCRIPT_NAME'] = to_str(script_name) if script_name else '' + environ['PATH_INFO'] = to_str(path_info) + environ['QUERY_STRING'] = to_str(query_string) + environ['SERVER_NAME'] = to_str(server[0]) + environ['SERVER_PORT'] = str(server[1]) + environ['SERVER_PROTOCOL'] = to_str(protocol) + environ['wsgi.url_scheme'] = to_str(scheme) + environ['REMOTE_ADDR'] = to_str(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 in correct format from Erlang) + # This is O(1) dict.update() instead of O(n) iteration + if wsgi_headers: + for key, value in wsgi_headers.items(): + environ[to_str(key)] = to_str(value) + + # Add content-type/length if present + if content_type is not None: + environ['CONTENT_TYPE'] = to_str(content_type) + if content_length is not None: + environ['CONTENT_LENGTH'] = to_str(content_length) + + # Store lifespan state + if lifespan_state: + environ['_hornbeam.lifespan_state'] = lifespan_state + + return environ diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl index 06042e8..7fc0876 100644 --- a/src/hornbeam_handler.erl +++ b/src/hornbeam_handler.erl @@ -36,8 +36,7 @@ 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) %% Build new state from mount config NewState = State#{ app_module => maps:get(app_module, Mount), @@ -117,8 +116,8 @@ handle_wsgi(Req, State) -> AppCallable = maps:get(app_callable, State), TimeoutMs = maps:get(timeout, State, 30000), - %% Build complete environ in Erlang - Environ = build_wsgi_environ(Req, State), + %% 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), @@ -133,11 +132,11 @@ handle_wsgi(Req, State) -> Buf end, - %% Call Python + %% Call Python with tuple fast path CtxRef = hornbeam_context_pool:get_context_ref(), case py_nif:context_call(CtxRef, - <<"hornbeam_wsgi_worker">>, <<"handle_request">>, - [self(), Buffer, AppModule, AppCallable, Environ], #{}) of + <<"hornbeam_wsgi_worker">>, <<"handle_request_tuple">>, + [self(), Buffer, AppModule, AppCallable, ReqTuple], #{}) of {ok, <<"done">>} -> %% Enter receive loop wsgi_receive_loop(Req, ReqInfo1, TimeoutMs, State); @@ -264,57 +263,6 @@ filter_hop_by_hop(Headers) -> not lists:member(LowerName, ?HOP_BY_HOP_HEADERS) end, Headers). -%% @private -%% Build environ map for WSGI -build_wsgi_environ(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), - - %% 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.url_scheme">> => Scheme - }, - - %% 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} - end. - %%% ============================================================================ %%% ASGI Handler - uses py_event_loop for full async %%% ============================================================================ @@ -343,9 +291,10 @@ handle_asgi(Req, State) -> Buf end, - %% Spawn async task to run Python ASGI handler (fire-and-forget) + %% Create async task to run Python ASGI handler %% Python handler sends response via erlang.send() to this process - ok = py_event_loop:spawn_task( + %% Using create_task avoids a throwaway process per request (spawn_task overhead) + _TaskRef = py_event_loop:create_task( <<"hornbeam_asgi_worker">>, <<"handle_asgi">>, [self(), AppModule, AppCallable, Scope, Buffer]), @@ -521,22 +470,6 @@ build_request_info(Req) -> %%% Utilities %%% ============================================================================ -%% @private -header_to_wsgi_key(Name) -> - case Name of - <<"content-type">> -> <<"CONTENT_TYPE">>; - <<"content-length">> -> <<"CONTENT_LENGTH">>; - _ -> - Upper = string:uppercase(Name), - Underscored = binary:replace(Upper, <<"-">>, <<"_">>, [global]), - <<"HTTP_", Underscored/binary>> - end. - -%% @private -format_protocol('HTTP/1.0') -> <<"HTTP/1.0">>; -format_protocol('HTTP/1.1') -> <<"HTTP/1.1">>; -format_protocol('HTTP/2') -> <<"HTTP/2">>. - %% @private format_http_version('HTTP/1.0') -> <<"1.0">>; format_http_version('HTTP/1.1') -> <<"1.1">>; @@ -587,26 +520,6 @@ 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)). -%%% ============================================================================ -%%% Mount pythonpath setup -%%% ============================================================================ - -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:eval(<<"__import__('sys').path.insert(0, p) if p not in __import__('sys').path else None">>, - #{p => PathBin}) - end, Paths) - end. - %%% ============================================================================ %%% WebSocket callbacks (delegate to hornbeam_websocket) %%% ============================================================================ diff --git a/src/hornbeam_mounts.erl b/src/hornbeam_mounts.erl index 822b396..297729c 100644 --- a/src/hornbeam_mounts.erl +++ b/src/hornbeam_mounts.erl @@ -122,6 +122,11 @@ handle_call({register, Mounts}, _From, State) -> Mount#{mount_id => MountId} end, Mounts), + %% Setup pythonpath for each mount at registration time (not per-request) + lists:foreach(fun(Mount) -> + setup_mount_pythonpath(Mount) + end, MountsWithIds), + %% Sort mounts by prefix length descending (longest first) SortedMounts = lists:sort( fun(#{prefix := P1}, #{prefix := P2}) -> @@ -225,3 +230,21 @@ generate_mount_id() -> 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:eval(<<"__import__('sys').path.insert(0, p) if p not in __import__('sys').path else None">>, + #{p => PathBin}) + end, Paths) + end. diff --git a/src/hornbeam_request.erl b/src/hornbeam_request.erl index 15daf69..45d67a3 100644 --- a/src/hornbeam_request.erl +++ b/src/hornbeam_request.erl @@ -51,9 +51,6 @@ build_wsgi_tuple(Req, State) -> ScriptName = maps:get(script_name, State, <<>>), PathInfo = maps:get(path_info, State, Path), - %% Read body - {ok, Body, _Req2} = cowboy_req:read_body(Req), - %% Convert headers to WSGI format with Content-Type/Length extracted {WsgiHeaders, ContentType, ContentLength} = convert_headers_wsgi(Headers), @@ -68,7 +65,7 @@ build_wsgi_tuple(Req, State) -> WsgiHeaders, % HTTP_* headers (pre-converted map) ContentType, % CONTENT_TYPE (or undefined) ContentLength, % CONTENT_LENGTH (or undefined) - Body, % wsgi.input (raw bytes) + undefined, % Body placeholder (passed via buffer) {Host, Port}, % SERVER_NAME, SERVER_PORT {format_ip(ClientIp), ClientPort}, % REMOTE_ADDR, REMOTE_PORT Scheme, % wsgi.url_scheme From fc172ce41aa2ca2f415560aa2f8c412f68426173 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 17 Mar 2026 12:16:12 +0100 Subject: [PATCH 27/52] Fix ASGI lifespan state persistence across requests Use Python-side lifespan state dict from hornbeam_lifespan_runner instead of the Erlang-provided copy. This ensures state modifications made by request handlers persist across requests per ASGI spec. --- priv/hornbeam_asgi_worker.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/priv/hornbeam_asgi_worker.py b/priv/hornbeam_asgi_worker.py index 4c2c350..2b8b9d7 100644 --- a/priv/hornbeam_asgi_worker.py +++ b/priv/hornbeam_asgi_worker.py @@ -144,6 +144,14 @@ async def handle_asgi(caller_pid, app_module: bytes, app_callable: bytes, # Get app (preloaded or import on demand) app = _get_app(module_name, callable_name) + # Use Python-side lifespan state (mutable, shared across requests) + # This is per ASGI spec - state modifications should persist + try: + from hornbeam_lifespan_runner import get_state + scope['state'] = get_state() + except ImportError: + pass # Keep Erlang-provided state if lifespan runner not available + # Create receive/send callables receive = _ASGIReceive(actual_buffer) send = _ASGISend(caller_pid) From aefb601fd57b26b3922ebbb8a3f6d0c064c0daf4 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 17 Mar 2026 14:33:07 +0100 Subject: [PATCH 28/52] Add owngil context mode and benchmark support - Add context_mode option (worker | owngil) to hornbeam and context pool - owngil mode uses per-interpreter GIL for true parallelism (Python 3.12+) - Update benchmark to support WSGI owngil testing via PYTHON_CONFIG env var - Rebuild erlang_python when PYTHON_CONFIG is set for correct Python version --- benchmarks/bench_wsgi_vs_asgi.sh | 179 ++++++++++++++++++++++++++----- src/hornbeam.erl | 3 + src/hornbeam_context_pool.erl | 21 ++-- 3 files changed, 168 insertions(+), 35 deletions(-) diff --git a/benchmarks/bench_wsgi_vs_asgi.sh b/benchmarks/bench_wsgi_vs_asgi.sh index 98d45ac..15ea773 100755 --- a/benchmarks/bench_wsgi_vs_asgi.sh +++ b/benchmarks/bench_wsgi_vs_asgi.sh @@ -1,7 +1,12 @@ #!/bin/bash -# Benchmark comparison: WSGI vs ASGI +# Benchmark comparison: WSGI vs WSGI (owngil) vs ASGI # -# Compares performance between WSGI and ASGI worker classes +# 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 @@ -13,34 +18,63 @@ if ! command -v ab &> /dev/null; then 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_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 ASGI Benchmark" +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 @@ -57,6 +91,23 @@ erl -pa _build/default/lib/*/ebin \ " > /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 \ @@ -75,8 +126,12 @@ PID_ASGI=$! 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 $PORT_WSGI $PORT_ASGI; do +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" @@ -94,6 +149,7 @@ 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 @@ -112,6 +168,18 @@ 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) @@ -137,6 +205,18 @@ 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) @@ -162,6 +242,18 @@ 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) @@ -187,6 +279,18 @@ 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) @@ -202,28 +306,47 @@ echo "==============================================" echo " Summary" echo "==============================================" echo "" -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 [ "$RUN_OWNGIL" = true ]; then + printf "%-25s %12s %12s %12s\n" "Test" "WSGI" "WSGI(owngil)" "ASGI" + printf "%-25s %12s %12s %12s\n" "-------------------------" "------------" "------------" "------------" -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_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" "-------------------------" "---------------" "---------------" "----------" -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 + # 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" + 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 "" diff --git a/src/hornbeam.erl b/src/hornbeam.erl index 0e9f02a..be4f503 100644 --- a/src/hornbeam.erl +++ b/src/hornbeam.erl @@ -69,6 +69,7 @@ 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(), @@ -545,7 +546,9 @@ parse_app_spec(AppSpec) when is_binary(AppSpec) -> ensure_python_runtime(Config) -> 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; diff --git a/src/hornbeam_context_pool.erl b/src/hornbeam_context_pool.erl index 111b6e2..f42a92b 100644 --- a/src/hornbeam_context_pool.erl +++ b/src/hornbeam_context_pool.erl @@ -42,6 +42,7 @@ -record(state, { pool_size :: pos_integer(), + context_mode :: worker | owngil, contexts :: #{pos_integer() => reference()} }). @@ -60,6 +61,9 @@ start_link() -> %% %% Options: %% - pool_size: Number of contexts (default: number of schedulers) +%% - context_mode: worker | owngil (default: worker) +%% - worker: Standard sub-interpreter mode +%% - owngil: Per-interpreter GIL mode (Python 3.12+, true parallelism) -spec start_link(map()) -> {ok, pid()} | {error, term()}. start_link(Opts) -> gen_server:start_link({local, ?MODULE}, ?MODULE, Opts, []). @@ -124,21 +128,24 @@ init(Opts) -> PoolSize = maps:get(pool_size, Opts, application:get_env(hornbeam, context_pool_size, ?DEFAULT_POOL_SIZE)), + ContextMode = maps:get(context_mode, Opts, + application:get_env(hornbeam, context_mode, worker)), %% Create atomic counter for round-robin Counter = atomics:new(1, [{signed, false}]), persistent_term:put(hornbeam_context_counter, Counter), %% Create contexts and store in persistent_term - Contexts = create_contexts(PoolSize), + Contexts = create_contexts(PoolSize, ContextMode), persistent_term:put(hornbeam_context_pool_size, PoolSize), - {ok, #state{pool_size = PoolSize, contexts = Contexts}}. + {ok, #state{pool_size = PoolSize, context_mode = ContextMode, contexts = Contexts}}. -handle_call(stats, _From, #state{pool_size = PoolSize} = State) -> +handle_call(stats, _From, #state{pool_size = PoolSize, context_mode = ContextMode} = State) -> Stats = #{ pool_size => PoolSize, + context_mode => ContextMode, execution_mode => py_nif:execution_mode() }, {reply, Stats, State}; @@ -195,12 +202,12 @@ terminate(_Reason, #state{pool_size = PoolSize, contexts = Contexts}) -> %% Internal functions %% ============================================================================ -create_contexts(PoolSize) -> +create_contexts(PoolSize, ContextMode) -> PrivDir = code:priv_dir(hornbeam), PrivDirBin = list_to_binary(PrivDir), lists:foldl(fun(Id, Acc) -> - {Ref, InterpId} = create_context(Id, PrivDirBin), + {Ref, InterpId} = create_context(Id, PrivDirBin, ContextMode), persistent_term:put({hornbeam_context, Id}, {Ref, InterpId}), maps:put(Id, Ref, Acc) end, #{}, lists:seq(0, PoolSize - 1)). @@ -223,8 +230,8 @@ add_paths_to_context(Ref, Paths) -> end end, Paths). -create_context(Id, PrivDir) -> - case py_nif:context_create(worker) of +create_context(Id, PrivDir, ContextMode) -> + case py_nif:context_create(ContextMode) of {ok, Ref, InterpId} -> %% Set up callback handler (for erlang.call from Python) py_nif:context_set_callback_handler(Ref, self()), From 2628fac8edc81cdcdb3cbf04a5894d986edf2f00 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Wed, 18 Mar 2026 10:29:25 +0100 Subject: [PATCH 29/52] Fix Python ModuleNotFoundError for hornbeam_lifespan_runner Use hornbeam_context_pool instead of py:context() to ensure priv/ is in sys.path when calling Python lifespan functions. Also use py_nif:context_call with empty options map to avoid passing timeout as Python kwargs. --- src/hornbeam_lifespan.erl | 227 ++++++++++++++++++++++++++++++++++---- 1 file changed, 205 insertions(+), 22 deletions(-) diff --git a/src/hornbeam_lifespan.erl b/src/hornbeam_lifespan.erl index b735d2b..b6e4b42 100644 --- a/src/hornbeam_lifespan.erl +++ b/src/hornbeam_lifespan.erl @@ -53,10 +53,15 @@ start_link/1, startup/0, startup/1, + startup/2, shutdown/0, + shutdown/1, get_state/0, + get_state/1, + set_state/2, get_context/0, - is_running/0 + is_running/0, + is_running/1 ]). -export([ @@ -72,6 +77,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 +93,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 +123,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 +148,23 @@ 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 Check if lifespan is running (single-app mode). -spec is_running() -> boolean(). is_running() -> case ets:lookup(?CACHE_TABLE, started) of @@ -129,6 +172,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 +206,13 @@ init(Opts) -> {read_concurrency, true} ]), - %% Get a 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:contexts_started() of - true -> py:context(); - false -> 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 @@ -221,6 +274,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}; @@ -289,10 +417,10 @@ run_startup(AppModule, AppCallable, PyContext) -> Result = case PyContext of undefined -> py:call(hornbeam_lifespan_runner, startup, - [AppModule, AppCallable, TimeoutMs], #{timeout => TimeoutMs + 5000}); - Ctx -> - py:call(Ctx, hornbeam_lifespan_runner, startup, - [AppModule, AppCallable, TimeoutMs], #{timeout => TimeoutMs + 5000}) + [AppModule, AppCallable, TimeoutMs], #{}); + CtxRef -> + py_nif:context_call(CtxRef, <<"hornbeam_lifespan_runner">>, <<"startup">>, + [AppModule, AppCallable, TimeoutMs], #{}) end, case Result of {ok, Response} -> @@ -322,21 +450,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], #{timeout => TimeoutMs}); - Ctx -> - py:call(Ctx, hornbeam_lifespan_runner, shutdown, - [AppModule, AppCallable], #{timeout => TimeoutMs}) + [AppModule, AppCallable], #{}); + CtxRef -> + py_nif:context_call(CtxRef, <<"hornbeam_lifespan_runner">>, <<"shutdown">>, + [AppModule, AppCallable], #{}) end, case Result of {ok, Response} -> @@ -351,6 +473,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">> -> From 3dcac22154ecbcb969b8a2e77c20f0c1e99aaf1d Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 19 Mar 2026 07:31:25 +0100 Subject: [PATCH 30/52] Implement ASGI-compliant mutable lifespan state - Add _MutableStateProxy in hornbeam_asgi_worker.py that syncs scope['state'] mutations to Erlang ETS via erlang.send() - Add update_state/2 and update_state/3 to hornbeam_lifespan.erl - Add handle_info for {<<"update_state">>, Key, Value} messages - Read fresh lifespan state from ETS per request (not cached) - Update lifespan_test_app.py to prefer scope state over module state - Requires erlang-python with erlang.whereis() support --- priv/hornbeam_asgi_worker.py | 313 ++++++++++++++++------------ src/hornbeam_handler.erl | 198 +++++++++++++----- src/hornbeam_lifespan.erl | 31 +++ test/test_apps/lifespan_test_app.py | 31 ++- 4 files changed, 386 insertions(+), 187 deletions(-) diff --git a/priv/hornbeam_asgi_worker.py b/priv/hornbeam_asgi_worker.py index 2b8b9d7..77afa6a 100644 --- a/priv/hornbeam_asgi_worker.py +++ b/priv/hornbeam_asgi_worker.py @@ -36,6 +36,7 @@ try: import erlang + from erlang import ByteChannel, ByteChannelClosed HAS_ERLANG = True # Cache erlang.send for faster lookups (avoids attribute access per call) _erlang_send = erlang.send @@ -43,6 +44,62 @@ HAS_ERLANG = False erlang = None _erlang_send = None + ByteChannel = None + ByteChannelClosed = Exception + + +class _MutableStateProxy(dict): + """A dict subclass that syncs mutations back to Erlang ETS. + + When items are set, the change is sent to hornbeam_lifespan process + which persists it to ETS. Subsequent requests see the updated value. + + This implements ASGI-compliant mutable scope['state'] behavior. + """ + + __slots__ = ('_mount_id', '_lifespan_pid') + + def __init__(self, initial_state: dict, mount_id: Optional[str] = None): + super().__init__(initial_state) + self._mount_id = mount_id + # Lookup hornbeam_lifespan PID once at construction + self._lifespan_pid = None + if HAS_ERLANG: + try: + self._lifespan_pid = erlang.whereis("hornbeam_lifespan") + except Exception: + pass + + def __setitem__(self, key, value): + # Update local dict + super().__setitem__(key, value) + # Send update to Erlang (fire-and-forget) + if self._lifespan_pid is not None: + try: + if self._mount_id is not None: + # Multi-app mode: {update_state, MountId, Key, Value} + _erlang_send(self._lifespan_pid, + (b'update_state', self._mount_id, key, value)) + else: + # Single-app mode: {update_state, Key, Value} + _erlang_send(self._lifespan_pid, + (b'update_state', key, value)) + except Exception: + pass # Don't fail request if state sync fails + + def update(self, other=None, **kwargs): + """Override update to sync each key.""" + if other: + for k, v in (other.items() if hasattr(other, 'items') else other): + self[k] = v + for k, v in kwargs.items(): + self[k] = v + + def setdefault(self, key, default=None): + """Override setdefault to sync if key is added.""" + if key not in self: + self[key] = default + return self[key] # ============================================================================ @@ -54,12 +111,13 @@ _preloaded_key: Tuple[str, str] = None -def preload_app(app_module: bytes, app_callable: bytes) -> bytes: +def preload_app(app_module: str, app_callable: str) -> bytes: """Preload ASGI application at startup for zero-overhead access.""" global _preloaded_app, _preloaded_key - module_name = app_module.decode('utf-8') if isinstance(app_module, bytes) else app_module - callable_name = app_callable.decode('utf-8') if isinstance(app_callable, bytes) else app_callable + # erlang_python converts binaries to str automatically (UTF-8 decode in C) + module_name = app_module + callable_name = app_callable module = importlib.import_module(module_name) app = getattr(module, callable_name) @@ -93,13 +151,6 @@ def _to_bytes(val) -> bytes: return b'' -def _to_str(val) -> str: - """Convert bytes to string.""" - if isinstance(val, bytes): - return val.decode('utf-8', errors='replace') - if isinstance(val, str): - return val - return str(val) if val is not None else '' # ============================================================================ @@ -114,47 +165,48 @@ def _to_str(val) -> str: # ============================================================================ async def handle_asgi(caller_pid, app_module: bytes, app_callable: bytes, - scope: dict, buffer): + scope: dict, req_body_ch, resp_body_ch): """Handle an ASGI request asynchronously. - This is the main entry point called from Erlang via erlang.run(). - Uses erlang.send() to stream response directly to caller. + This is the main entry point called from Erlang via py_event_loop:create_task. + Uses byte channels for body data and erlang.send() for control messages. + + Transport design: + - Control plane (mailbox): headers, early_hints, error, response (small) + - Data plane (byte channels): request body (ReqBodyCh), response body (RespBodyCh) Args: - caller_pid: Erlang PID to send response to + caller_pid: Erlang PID to send control messages to app_module: Python module containing ASGI app (bytes) app_callable: Name of ASGI callable in module (bytes) scope: ASGI scope dict - buffer: py_buffer for request body, or 'empty' atom for bodyless requests + req_body_ch: ByteChannel reference for reading request body + resp_body_ch: ByteChannel reference for writing response body """ if not HAS_ERLANG: return - # Convert bytes to strings - module_name = _to_str(app_module) - callable_name = _to_str(app_callable) + # erlang_python converts binaries to str in C (UTF-8 decode) + module_name = app_module + callable_name = app_callable - # Determine buffer for receive (None for bodyless requests) - if buffer == b'empty' or (hasattr(buffer, '__class__') and buffer.__class__.__name__ == 'Atom'): - actual_buffer = None - else: - actual_buffer = buffer + # Wrap channel references + req_channel = ByteChannel(req_body_ch) + resp_channel = ByteChannel(resp_body_ch) try: # Get app (preloaded or import on demand) app = _get_app(module_name, callable_name) - # Use Python-side lifespan state (mutable, shared across requests) - # This is per ASGI spec - state modifications should persist - try: - from hornbeam_lifespan_runner import get_state - scope['state'] = get_state() - except ImportError: - pass # Keep Erlang-provided state if lifespan runner not available + # Wrap scope['state'] with mutable proxy that syncs to Erlang ETS + # This implements ASGI-compliant mutable state behavior + if 'state' in scope: + mount_id = scope.get('mount_id') + scope['state'] = _MutableStateProxy(scope['state'], mount_id) - # Create receive/send callables - receive = _ASGIReceive(actual_buffer) - send = _ASGISend(caller_pid) + # Create receive/send callables with byte channels + receive = _ASGIReceive(req_channel) + send = _ASGISend(caller_pid, resp_channel) # Run ASGI app await app(scope, receive, send) @@ -163,25 +215,35 @@ async def handle_asgi(caller_pid, app_module: bytes, app_callable: bytes, if not send.finished: if not send.headers_sent: _erlang_send(caller_pid, (b'headers', 500, [])) - _erlang_send(caller_pid, b'done') - + # close the channel to signal EOF + try: + resp_channel.close() + except ByteChannelClosed: + pass except Exception as e: try: + # close the channel, then send error + resp_channel.close() _erlang_send(caller_pid, (b'error', str(e).encode('utf-8'))) except Exception: pass class _ASGIReceive: - """ASGI receive callable with streaming buffer support.""" - __slots__ = ('buffer', 'body_sent', 'disconnected', '_eof_reached') + """ASGI receive callable using ByteChannel for request body. - # Max chunk size for streaming - CHUNK_SIZE = 65536 + Reads request body chunks from the byte channel provided by Erlang. + Channel close signals EOF (no more body data). + """ + __slots__ = ('channel', 'disconnected', '_eof_reached') - def __init__(self, buffer): - self.buffer = buffer # py_buffer or None for empty - self.body_sent = False + def __init__(self, channel): + """Initialize receive callable. + + Args: + channel: ByteChannel for reading request body from Erlang + """ + self.channel = channel self.disconnected = False self._eof_reached = False @@ -189,70 +251,64 @@ async def __call__(self) -> dict: if self.disconnected: return _DISCONNECT_MSG - if self.buffer is None: - # No body - return empty with more_body=False - if not self.body_sent: - self.body_sent = True - return {'type': 'http.request', 'body': b'', 'more_body': False} - return _DISCONNECT_MSG - - # Stream chunks from buffer with more_body=True until EOF - chunk = await self._read_chunk() - if chunk is None: - return _DISCONNECT_MSG - - # Check if we've reached EOF - at_eof = self._eof_reached - return {'type': 'http.request', 'body': chunk, 'more_body': not at_eof} - - async def _read_chunk(self) -> bytes: - """Read next available chunk from buffer.""" if self._eof_reached: + # Already reached EOF - return disconnect self.disconnected = True - return None + return _DISCONNECT_MSG - if hasattr(self.buffer, 'readable_amount'): - # Streaming buffer - wait for data - while True: - available = self.buffer.readable_amount() - if available > 0: - chunk = self.buffer.read_nonblock(min(available, self.CHUNK_SIZE)) - # Check EOF after read - if hasattr(self.buffer, 'at_eof') and self.buffer.at_eof(): - self._eof_reached = True - return chunk if chunk else b'' - if hasattr(self.buffer, 'at_eof') and self.buffer.at_eof(): - self._eof_reached = True - return b'' - # Yield to event loop while waiting for data - await asyncio.sleep(0) - else: - # Fallback: blocking read (buffer already complete) - if not self.body_sent: - self.body_sent = True - self._eof_reached = True - return self.buffer.read() if hasattr(self.buffer, 'read') else b'' + chunk = await self._read_chunk() + if not chunk: + # Channel closed = EOF + self._eof_reached = True + return {'type': 'http.request', 'body': b'', 'more_body': False} + # Got data - return with more_body=True (may have more) + return {'type': 'http.request', 'body': chunk, 'more_body': True} + + async def _read_chunk(self) -> bytes | None: + """Read next chunk asynchronously. + + Channel close signals EOF. + """ + try: + chunk = await self.channel.async_receive_bytes() + return chunk + except ByteChannelClosed: return None class _ASGISend: - """ASGI send callable that streams to Erlang via erlang.send().""" - __slots__ = ('caller_pid', 'status', 'headers', 'headers_sent', - 'body_parts', 'finished', 'buffering', '_send', '_total_size') + """ASGI send callable using ByteChannel for response body. - # Buffer small responses before sending + Transport design: + - Control plane (erlang.send): headers, early_hints, response (small) + - Data plane (ByteChannel): response body for streaming + + For small responses (< BUFFER_THRESHOLD), sends complete response via + erlang.send() for efficiency. For larger/streaming responses, sends + headers via erlang.send() and body via ByteChannel. + """ + __slots__ = ('caller_pid', 'resp_channel', 'status', 'headers', + 'headers_sent', 'body_parts', 'finished', '_send', '_total_size') + + # Buffer small responses before sending (optimization) BUFFER_THRESHOLD = 65536 - def __init__(self, caller_pid, buffering: bool = True): + def __init__(self, caller_pid, resp_channel): + """Initialize send callable. + + Args: + caller_pid: Erlang PID for control messages + resp_channel: ByteChannel for writing response body + """ self.caller_pid = caller_pid + self.resp_channel = resp_channel self._send = _erlang_send # Cached function reference self.status = None self.headers = [] self.headers_sent = False - self.body_parts = [] + self.body_parts = [] # Buffer for small responses self.finished = False - self.buffering = buffering - self._total_size = 0 # Track buffer size in O(1) instead of O(n) + self._total_size = 0 async def __call__(self, message: dict) -> None: msg_type = message.get('type', '') @@ -260,67 +316,57 @@ async def __call__(self, message: dict) -> None: if msg_type == 'http.response.start': self.status = message.get('status', 200) self.headers = message.get('headers', []) - - if not self.buffering: - # Send headers immediately for streaming - self._send(self.caller_pid, (b'headers', self.status, self.headers)) - self.headers_sent = True - + self._send(self.caller_pid, (b'headers', self.status or 400, self.headers)) + self.headers_sent = True elif msg_type == 'http.response.body': body_part = message.get('body', b'') if isinstance(body_part, str): body_part = body_part.encode('utf-8') - more_body = message.get('more_body', False) + # we don't need an intermediate buffer, just append to the channel queue + try: + self.resp_channel.send_bytes(body_part) + except ByteChannelClosed: + # erlang has closed the channel. + self.finished = True + return - if self.buffering and not self.headers_sent: - # Buffer body parts - if body_part: - self.body_parts.append(body_part) - self._total_size += len(body_part) # O(1) instead of O(n) + more_body = message.get('more_body', False) + if not self.headers_sent: if self._total_size >= self.BUFFER_THRESHOLD: - # Body too large - switch to streaming + # Body too large - switch to streaming via byte channel self._send(self.caller_pid, (b'headers', self.status or 500, self.headers)) self.headers_sent = True - for part in self.body_parts: - self._send(self.caller_pid, (b'chunk', part)) - self.body_parts.clear() self._total_size = 0 - self.buffering = False if not more_body: - self._send(self.caller_pid, b'done') + # channel close signal EOF + self.resp_channel.close() self.finished = True - elif not more_body: - # Small response - send complete in one message - body = b''.join(self.body_parts) + # TODO: check if this case can exists self._send(self.caller_pid, - (b'response', self.status or 500, self.headers, body)) + (b'response', self.status or 500, self.headers)) + self.resp_channel.close() self.finished = True else: - # Streaming mode - if not self.headers_sent: - self._send(self.caller_pid, (b'headers', self.status or 500, self.headers)) - self.headers_sent = True - - if body_part: - self._send(self.caller_pid, (b'chunk', body_part)) - if not more_body: - self._send(self.caller_pid, b'done') + # Send empty chunk to signal EOF (Erlang will close channel) + self.resp_channel.close() self.finished = True elif msg_type == 'http.response.informational': - # Early hints (103) + # Early hints (103) - control message via mailbox status = message.get('status', 100) headers = message.get('headers', []) if status == 103: self._send(self.caller_pid, (b'early_hints', headers)) elif msg_type == 'http.disconnect': + # We close the channel to signal EOF + self.resp_channel.close() self.finished = True @@ -329,7 +375,7 @@ async def __call__(self, message: dict) -> None: # ============================================================================ def handle_asgi_sync(caller_pid, app_module: bytes, app_callable: bytes, - scope: dict, buffer): + scope: dict, req_body_ch, resp_body_ch): """Synchronous wrapper that runs handle_asgi with erlang.run(). This is used when calling from py_nif:context_call() which expects @@ -337,18 +383,20 @@ def handle_asgi_sync(caller_pid, app_module: bytes, app_callable: bytes, loop integration. Args: - caller_pid: Erlang PID to send response to + caller_pid: Erlang PID to send control messages to app_module: Python module containing ASGI app (bytes) app_callable: Name of ASGI callable in module (bytes) scope: ASGI scope dict - buffer: py_buffer for request body, or 'empty' atom for bodyless requests + req_body_ch: ByteChannel reference for reading request body + resp_body_ch: ByteChannel reference for writing response body """ if not HAS_ERLANG: return b'error' try: # Use erlang.run() for proper Erlang event loop integration - erlang.run(handle_asgi(caller_pid, app_module, app_callable, scope, buffer)) + erlang.run(handle_asgi(caller_pid, app_module, app_callable, scope, + req_body_ch, resp_body_ch)) return b'done' except Exception as e: try: @@ -381,8 +429,9 @@ async def handle_websocket(caller_pid, app_module: bytes, app_callable: bytes, from erlang import Channel, ChannelClosed - module_name = _to_str(app_module) - callable_name = _to_str(app_callable) + # erlang_python converts binaries to str in C (UTF-8 decode) + module_name = app_module + callable_name = app_callable channel = Channel(channel_ref) connected = False @@ -405,7 +454,8 @@ async def receive() -> dict: return {'type': 'websocket.connect'} elif tag == b'text' or tag == 'text': - return {'type': 'websocket.receive', 'text': _to_str(msg[1])} + # Text from channel - already str from erlang_python + return {'type': 'websocket.receive', 'text': msg[1]} elif tag == b'bytes' or tag == 'bytes': return {'type': 'websocket.receive', 'bytes': _to_bytes(msg[1])} @@ -468,5 +518,6 @@ def handle_request_direct(args_tuple) -> None: if not HAS_ERLANG: return - caller_pid, app_module, app_callable, scope, buffer = args_tuple - handle_asgi_sync(caller_pid, app_module, app_callable, scope, buffer) + caller_pid, app_module, app_callable, scope, req_body_ch, resp_body_ch = args_tuple + handle_asgi_sync(caller_pid, app_module, app_callable, scope, + req_body_ch, resp_body_ch) diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl index 7fc0876..2dfa067 100644 --- a/src/hornbeam_handler.erl +++ b/src/hornbeam_handler.erl @@ -37,6 +37,8 @@ init(Req, #{multi_app := true} = State) -> case hornbeam_mounts:lookup(Path) of {ok, Mount, PathInfo} -> %% 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), @@ -44,7 +46,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); @@ -264,9 +269,11 @@ filter_hop_by_hop(Headers) -> end, Headers). %%% ============================================================================ -%%% ASGI Handler - uses py_event_loop for full async +%%% ASGI Handler - uses py_event_loop for full async with byte channels %%% ============================================================================ +-define(ASGI_BODY_CHUNK_SIZE, 65536). %% 64KB chunks for request body + handle_asgi(Req, State) -> ReqInfo = build_request_info(Req), ReqInfo1 = hornbeam_http_hooks:run_on_request(ReqInfo), @@ -279,27 +286,52 @@ handle_asgi(Req, State) -> %% Build ASGI scope Scope = build_scope(Req, State), - %% Create buffer for request body (skip for bodyless requests) + %% Create byte channels for request and response bodies + %% ReqBodyCh: Erlang writes request body, Python reads + %% RespBodyCh: Python writes response body, Erlang reads + {ok, ReqBodyCh} = py_byte_channel:new(), + {ok, RespBodyCh} = py_byte_channel:new(), + + %% Check if request has a body ContentLength = get_content_length(Req), Method = cowboy_req:method(Req), - Buffer = case has_request_body(Method, ContentLength) of - false -> - empty; + HasBody = has_request_body(Method, ContentLength), + + %% Spawn body pump process if there's a body + %% This reads from Cowboy and writes to ReqBodyCh + BodyPumpPid = case HasBody of true -> - {ok, Buf} = create_body_buffer(ContentLength), - write_body_to_buffer(Req, Buf, ContentLength), - Buf + Ref = make_ref(), + HandlerPid = self(), + Pid = spawn(fun() -> + HandlerPid ! {pump_started, Ref}, + pump_request_body(Req, ReqBodyCh, ?ASGI_BODY_CHUNK_SIZE) + end), + %% Wait for pump to start before continuing + ok = wait_pump(Ref), + Pid; + false -> + %% No body - close channel to signal EOF + py_byte_channel:close(ReqBodyCh), + undefined end, %% Create async task to run Python ASGI handler - %% Python handler sends response via erlang.send() to this process - %% Using create_task avoids a throwaway process per request (spawn_task overhead) + %% Python handler sends control messages via erlang.send() + %% and body data via byte channel _TaskRef = py_event_loop:create_task( <<"hornbeam_asgi_worker">>, <<"handle_asgi">>, - [self(), AppModule, AppCallable, Scope, Buffer]), + [self(), AppModule, AppCallable, Scope, ReqBodyCh, RespBodyCh]), %% Receive response from async Python - receive_asgi_response(Req, ReqInfo1, TimeoutMs, State) + %% Pass ReqBodyCh so it can be closed when response starts + Result = receive_asgi_response(Req, ReqInfo1, ReqBodyCh, RespBodyCh, TimeoutMs, State), + + % ensure to kill body pump + % at this point, channels must have been closed, otherwise gc will do its job. + _ = maybe_kill_body_pump(BodyPumpPid), + + Result catch Class:Reason:Stack -> error_logger:error_msg("ASGI handler error: ~p:~p~n~p~n", @@ -307,62 +339,120 @@ handle_asgi(Req, State) -> handle_error(Req, {Class, Reason}, ReqInfo1, State) end. +wait_pump(Ref) -> + receive + {pump_started, Ref} -> ok + after 5000 -> + timeout + end. + + +maybe_kill_body_pump(undefined) -> true; +maybe_kill_body_pump(BodyPumpPid) when is_pid(BodyPumpPid) -> + exit(BodyPumpPid, kill). + + +%% @private +%% Pump request body from Cowboy to byte channel +%% Closes channel to signal EOF +pump_request_body(Req, ReqBodyCh, ChunkSize) -> + error_logger:info_msg("PUMP: starting~n"), + try + case cowboy_req:read_body(Req, #{length => ChunkSize}) of + {ok, Chunk, _Req2} -> + write_to_channel_with_backpressure(ReqBodyCh, Chunk), + py_byte_channel:close(ReqBodyCh); + {more, Chunk, Req2} -> + %% More data available - write and continue + write_to_channel_with_backpressure(ReqBodyCh, Chunk), + pump_request_body(Req2, ReqBodyCh, ChunkSize); + _Else -> + error_logger:error_msg("unexpected read body ~p", [_Else]), + py_byte_channel:close(ReqBodyCh) + end + catch + Class:Reason:Stack -> + py_byte_channel:close(ReqBodyCh) + end. + +%% @private +%% Write to channel with backpressure handling +write_to_channel_with_backpressure(Channel, Data) -> + case py_byte_channel:send(Channel, Data) of + ok -> ok; + busy -> + %% Channel full - wait a bit and retry + timer:sleep(1), + write_to_channel_with_backpressure(Channel, Data); + {error, closed} -> + %% Channel closed - stop writing + ok + end. + %% @private %% Receive ASGI response from Python worker -receive_asgi_response(Req, ReqInfo, TimeoutMs, State) -> +%% Control messages come via mailbox, response body via RespBodyCh +%% Closes ReqBodyCh when response starts (Python is done reading request) +receive_asgi_response(Req, ReqInfo, ReqBodyCh, RespBodyCh, TimeoutMs, State) -> receive - {<<"response">>, StatusCode, Headers, Body} -> - %% Buffered response (single message) - Response = #{ - <<"status">> => StatusCode, - <<"headers">> => Headers, - <<"body">> => Body - }, - Response1 = hornbeam_http_hooks:run_on_response(Response), - send_response(Req, Response1, State); {<<"headers">>, StatusCode, Headers} -> - %% Streaming response + error_logger:info_msg("Got response status ~p headers ~p~n", [StatusCode, Headers]), CowboyHeaders = convert_headers(Headers), - receive_asgi_body(Req, StatusCode, CowboyHeaders, TimeoutMs, State); + Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req), + drain_response_channel(Req2, RespBodyCh, TimeoutMs, State); {<<"early_hints">>, Headers} -> %% Early hints (103) HintHeaders = convert_headers(Headers), Req2 = cowboy_req:inform(103, HintHeaders, Req), - receive_asgi_response(Req2, ReqInfo, TimeoutMs, State); + receive_asgi_response(Req2, ReqInfo, ReqBodyCh, RespBodyCh, TimeoutMs, State); {<<"error">>, Reason} -> handle_error(Req, Reason, ReqInfo, State); {async_result, _Ref, {ok, _}} -> - receive_asgi_response(Req, ReqInfo, TimeoutMs, State); + %% Async task completion - continue receiving + receive_asgi_response(Req, ReqInfo, ReqBodyCh, RespBodyCh, TimeoutMs, State); {async_result, _Ref, {error, Reason}} -> + error_logger:info_msg("async result error ~p, ", [Reason]), + %% ensure to close channels there + ok = maybe_close_channel(ReqBodyCh), + ok = maybe_close_channel(RespBodyCh), handle_error(Req, Reason, ReqInfo, State) after TimeoutMs -> handle_error(Req, timeout, ReqInfo, State) end. %% @private -receive_asgi_body(Req, StatusCode, CowboyHeaders, TimeoutMs, State) -> - Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req), - asgi_stream_body(Req2, TimeoutMs, State). +%% Maybe close a 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 -%% Stream ASGI response body chunks to client -asgi_stream_body(Req, TimeoutMs, State) -> - receive - {<<"chunk">>, Chunk} -> +%% Drain response body from byte channel and stream to client +%% Empty chunk signals EOF, then Erlang closes the channel +drain_response_channel(Req, RespBodyCh, TimeoutMs, State) -> + case py_byte_channel:recv(RespBodyCh, TimeoutMs) of + {ok, Chunk} -> + %% Got data - send as non-final chunk ok = cowboy_req:stream_body(Chunk, nofin, Req), - asgi_stream_body(Req, TimeoutMs, State); - <<"done">> -> + drain_response_channel(Req, RespBodyCh, TimeoutMs, State); + {error, closed} -> + %% Channel closed = EOF ok = cowboy_req:stream_body(<<>>, fin, Req), {ok, Req, State}; - {<<"error">>, _Reason} -> + {error, timeout} -> + %% Timeout - close the stream + py_byte_channel:close(RespBodyCh), ok = cowboy_req:stream_body(<<>>, fin, Req), - {ok, Req, State}; - {async_result, _Ref, _Result} -> - %% Async task finished, continue streaming - asgi_stream_body(Req, TimeoutMs, State) - after TimeoutMs -> - ok = cowboy_req:stream_body(<<>>, fin, Req), - {ok, Req, State} + {ok, Req, State} end. %% @private @@ -381,11 +471,17 @@ build_scope(Req, State) -> [[Name, Value] | Acc] end, [], cowboy_req:headers(Req)), - %% Get lifespan state from handler state (cached at startup, no lookup per request) - LifespanState = maps:get(lifespan_state, State, #{}), + %% Get mount_id for per-mount state isolation (multi-app mode) + MountId = maps:get(mount_id, State, undefined), + + %% Get fresh lifespan state from ETS on each request (supports mutable state) + LifespanState = case MountId of + undefined -> hornbeam_lifespan:get_state(); + _ -> hornbeam_lifespan:get_state(MountId) + end, %% Merge dynamic fields into pre-computed template - ?ASGI_SCOPE_TEMPLATE#{ + BaseScope = ?ASGI_SCOPE_TEMPLATE#{ http_version => format_http_version(Version), method => cowboy_req:method(Req), scheme => cowboy_req:scheme(Req), @@ -398,7 +494,13 @@ build_scope(Req, State) -> client => {format_ip(ClientIp), ClientPort}, state => LifespanState, 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. %%% ============================================================================ %%% Response sending diff --git a/src/hornbeam_lifespan.erl b/src/hornbeam_lifespan.erl index b6e4b42..d904d27 100644 --- a/src/hornbeam_lifespan.erl +++ b/src/hornbeam_lifespan.erl @@ -59,6 +59,8 @@ get_state/0, get_state/1, set_state/2, + update_state/2, + update_state/3, get_context/0, is_running/0, is_running/1 @@ -164,6 +166,23 @@ 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() -> @@ -380,6 +399,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}. 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: From 0b533bf0e11473e87e56dcfdd2a0f50073971518 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 19 Mar 2026 16:04:38 +0100 Subject: [PATCH 31/52] Simplify ASGI response handling, fix default status code - Remove unused buffering logic in _ASGISend - Stream all responses directly through ByteChannel - Fix default status code from 400 to 200 on http.response.start --- priv/hornbeam_asgi_worker.py | 36 ++++++++---------------------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/priv/hornbeam_asgi_worker.py b/priv/hornbeam_asgi_worker.py index 77afa6a..4f09624 100644 --- a/priv/hornbeam_asgi_worker.py +++ b/priv/hornbeam_asgi_worker.py @@ -316,46 +316,26 @@ async def __call__(self, message: dict) -> None: if msg_type == 'http.response.start': self.status = message.get('status', 200) self.headers = message.get('headers', []) - self._send(self.caller_pid, (b'headers', self.status or 400, self.headers)) + self._send(self.caller_pid, (b'headers', self.status or 200, self.headers)) self.headers_sent = True + elif msg_type == 'http.response.body': body_part = message.get('body', b'') if isinstance(body_part, str): body_part = body_part.encode('utf-8') - # we don't need an intermediate buffer, just append to the channel queue + more_body = message.get('more_body', False) + + # Send body to channel try: self.resp_channel.send_bytes(body_part) except ByteChannelClosed: - # erlang has closed the channel. self.finished = True return - more_body = message.get('more_body', False) - - if not self.headers_sent: - if self._total_size >= self.BUFFER_THRESHOLD: - # Body too large - switch to streaming via byte channel - self._send(self.caller_pid, (b'headers', self.status or 500, self.headers)) - self.headers_sent = True - self._total_size = 0 - - if not more_body: - # channel close signal EOF - self.resp_channel.close() - self.finished = True - elif not more_body: - # TODO: check if this case can exists - self._send(self.caller_pid, - (b'response', self.status or 500, self.headers)) - self.resp_channel.close() - self.finished = True - - else: - if not more_body: - # Send empty chunk to signal EOF (Erlang will close channel) - self.resp_channel.close() - self.finished = True + if not more_body: + self.resp_channel.close() + self.finished = True elif msg_type == 'http.response.informational': # Early hints (103) - control message via mailbox From 93d945726ff6c08c10b8e7f4557816aa698ef871 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 19 Mar 2026 16:14:00 +0100 Subject: [PATCH 32/52] Clean up ASGI handler, remove debug logging - Remove unused fast path response handler - Remove debug logging statements - Add hop-by-hop header filtering to streaming path - Close request channel when response starts --- src/hornbeam_handler.erl | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl index 2dfa067..8287d25 100644 --- a/src/hornbeam_handler.erl +++ b/src/hornbeam_handler.erl @@ -293,12 +293,11 @@ handle_asgi(Req, State) -> {ok, RespBodyCh} = py_byte_channel:new(), %% Check if request has a body - ContentLength = get_content_length(Req), Method = cowboy_req:method(Req), + ContentLength = get_content_length(Req), HasBody = has_request_body(Method, ContentLength), %% Spawn body pump process if there's a body - %% This reads from Cowboy and writes to ReqBodyCh BodyPumpPid = case HasBody of true -> Ref = make_ref(), @@ -307,7 +306,6 @@ handle_asgi(Req, State) -> HandlerPid ! {pump_started, Ref}, pump_request_body(Req, ReqBodyCh, ?ASGI_BODY_CHUNK_SIZE) end), - %% Wait for pump to start before continuing ok = wait_pump(Ref), Pid; false -> @@ -356,7 +354,6 @@ maybe_kill_body_pump(BodyPumpPid) when is_pid(BodyPumpPid) -> %% Pump request body from Cowboy to byte channel %% Closes channel to signal EOF pump_request_body(Req, ReqBodyCh, ChunkSize) -> - error_logger:info_msg("PUMP: starting~n"), try case cowboy_req:read_body(Req, #{length => ChunkSize}) of {ok, Chunk, _Req2} -> @@ -367,11 +364,10 @@ pump_request_body(Req, ReqBodyCh, ChunkSize) -> write_to_channel_with_backpressure(ReqBodyCh, Chunk), pump_request_body(Req2, ReqBodyCh, ChunkSize); _Else -> - error_logger:error_msg("unexpected read body ~p", [_Else]), - py_byte_channel:close(ReqBodyCh) + py_byte_channel:close(ReqBodyCh) end catch - Class:Reason:Stack -> + _:_ -> py_byte_channel:close(ReqBodyCh) end. @@ -396,8 +392,10 @@ write_to_channel_with_backpressure(Channel, Data) -> receive_asgi_response(Req, ReqInfo, ReqBodyCh, RespBodyCh, TimeoutMs, State) -> receive {<<"headers">>, StatusCode, Headers} -> - error_logger:info_msg("Got response status ~p headers ~p~n", [StatusCode, Headers]), - CowboyHeaders = convert_headers(Headers), + %% Streaming path: headers first, then drain channel + ok = maybe_close_channel(ReqBodyCh), + SafeHeaders = filter_hop_by_hop(Headers), + CowboyHeaders = convert_headers(SafeHeaders), Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req), drain_response_channel(Req2, RespBodyCh, TimeoutMs, State); {<<"early_hints">>, Headers} -> @@ -411,7 +409,6 @@ receive_asgi_response(Req, ReqInfo, ReqBodyCh, RespBodyCh, TimeoutMs, State) -> %% Async task completion - continue receiving receive_asgi_response(Req, ReqInfo, ReqBodyCh, RespBodyCh, TimeoutMs, State); {async_result, _Ref, {error, Reason}} -> - error_logger:info_msg("async result error ~p, ", [Reason]), %% ensure to close channels there ok = maybe_close_channel(ReqBodyCh), ok = maybe_close_channel(RespBodyCh), From 2e83042c63799996c6b970e36adb92f7c7c09a50 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 19 Mar 2026 17:28:10 +0100 Subject: [PATCH 33/52] Add ASGI protocol compliance validation - Raise RuntimeError if http.response.start sent twice - Raise RuntimeError if http.response.body sent before start - Raise RuntimeError if send called after response completed - Raise OSError on client disconnect per ASGI spec 2.4 --- priv/hornbeam_asgi_worker.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/priv/hornbeam_asgi_worker.py b/priv/hornbeam_asgi_worker.py index 4f09624..0544274 100644 --- a/priv/hornbeam_asgi_worker.py +++ b/priv/hornbeam_asgi_worker.py @@ -311,15 +311,23 @@ def __init__(self, caller_pid, resp_channel): self._total_size = 0 async def __call__(self, message: dict) -> None: + if self.finished: + raise RuntimeError("Response already completed") + msg_type = message.get('type', '') if msg_type == 'http.response.start': + if self.headers_sent: + raise RuntimeError("http.response.start already sent") self.status = message.get('status', 200) self.headers = message.get('headers', []) self._send(self.caller_pid, (b'headers', self.status or 200, self.headers)) self.headers_sent = True elif msg_type == 'http.response.body': + if not self.headers_sent: + raise RuntimeError("http.response.start must be sent before http.response.body") + body_part = message.get('body', b'') if isinstance(body_part, str): body_part = body_part.encode('utf-8') @@ -331,7 +339,7 @@ async def __call__(self, message: dict) -> None: self.resp_channel.send_bytes(body_part) except ByteChannelClosed: self.finished = True - return + raise OSError("Client disconnected") if not more_body: self.resp_channel.close() From ab828be91941fa290cb5a54fcc817f5e81b82fcc Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 19 Mar 2026 18:29:34 +0100 Subject: [PATCH 34/52] Use py_event_loop_pool for ASGI task distribution Switch from py_event_loop to py_event_loop_pool for better load distribution across multiple event loops. Process affinity ensures ordered execution for requests from the same handler. Benchmark shows improved scaling at higher concurrency: - 200 connections: 25.4k req/s - 400 connections: 27.5k req/s --- src/hornbeam_handler.erl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl index 8287d25..b875160 100644 --- a/src/hornbeam_handler.erl +++ b/src/hornbeam_handler.erl @@ -315,14 +315,14 @@ handle_asgi(Req, State) -> end, %% Create async task to run Python ASGI handler + %% Uses event loop pool with process affinity for better distribution %% Python handler sends control messages via erlang.send() %% and body data via byte channel - _TaskRef = py_event_loop:create_task( + _TaskRef = py_event_loop_pool:create_task( <<"hornbeam_asgi_worker">>, <<"handle_asgi">>, [self(), AppModule, AppCallable, Scope, ReqBodyCh, RespBodyCh]), %% Receive response from async Python - %% Pass ReqBodyCh so it can be closed when response starts Result = receive_asgi_response(Req, ReqInfo1, ReqBodyCh, RespBodyCh, TimeoutMs, State), % ensure to kill body pump From e982002c873b874203683d0c2fc46abee8536a54 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 19 Mar 2026 19:58:04 +0100 Subject: [PATCH 35/52] Improve WSGI/ASGI protocol handling and multi-app lifespan WSGI worker: - Remove unnecessary decode() calls (erlang_python handles in C) - Add documentation for binary-to-string conversion Lifespan runner: - Add per-mount lifespan support for multi-app mode - Each mount gets isolated state dict - Add startup_mount/shutdown_mount functions hornbeam.erl: - Pass mount_id to lifespan startup for state isolation - Build mount-specific options for lifespan protocol --- priv/hornbeam_lifespan_runner.py | 231 ++++++++++++++++++++++++++++++- priv/hornbeam_wsgi_worker.py | 59 ++++---- src/hornbeam.erl | 10 +- 3 files changed, 262 insertions(+), 38 deletions(-) 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_worker.py b/priv/hornbeam_wsgi_worker.py index 4d04269..b334284 100644 --- a/priv/hornbeam_wsgi_worker.py +++ b/priv/hornbeam_wsgi_worker.py @@ -111,15 +111,13 @@ def preload_app(app_module: bytes, app_callable: bytes) -> bytes: """ global _preloaded_app, _preloaded_key - module_name = app_module.decode('utf-8') if isinstance(app_module, bytes) else app_module - callable_name = app_callable.decode('utf-8') if isinstance(app_callable, bytes) else app_callable - + # erlang_python converts binaries to str in C import importlib - module = importlib.import_module(module_name) - app = getattr(module, callable_name) + module = importlib.import_module(app_module) + app = getattr(module, app_callable) _preloaded_app = app - _preloaded_key = (module_name, callable_name) + _preloaded_key = (app_module, app_callable) return b'ok' @@ -236,8 +234,9 @@ def handle_request(caller_pid, buffer, app_module: bytes, app_callable: bytes, e try: # Convert bytes to strings - module_name = app_module.decode('utf-8') if isinstance(app_module, bytes) else app_module - callable_name = app_callable.decode('utf-8') if isinstance(app_callable, bytes) else app_callable + # erlang_python converts binaries to str in C + module_name = app_module + callable_name = app_callable # Process environ (convert bytes to strings) environ = _process_environ(environ_map) @@ -427,8 +426,9 @@ def handle_request_tuple(caller_pid, buffer, app_module: bytes, app_callable: by try: # Convert bytes to strings - module_name = app_module.decode('utf-8') if isinstance(app_module, bytes) else app_module - callable_name = app_callable.decode('utf-8') if isinstance(app_callable, bytes) else app_callable + # 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) @@ -450,6 +450,9 @@ def _create_environ_from_tuple(req_tuple, buffer): 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 @@ -464,22 +467,17 @@ def _create_environ_from_tuple(req_tuple, buffer): # Start with template copy (O(1) - shallow copy of small dict) environ = _ENVIRON_TEMPLATE.copy() - # Convert bytes to strings for key values - def to_str(v): - if isinstance(v, bytes): - return v.decode('utf-8', errors='replace') - return str(v) if v is not None else '' - - # Update with request-specific values (no loops!) - environ['REQUEST_METHOD'] = to_str(method) - environ['SCRIPT_NAME'] = to_str(script_name) if script_name else '' - environ['PATH_INFO'] = to_str(path_info) - environ['QUERY_STRING'] = to_str(query_string) - environ['SERVER_NAME'] = to_str(server[0]) + # 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'] = to_str(protocol) - environ['wsgi.url_scheme'] = to_str(scheme) - environ['REMOTE_ADDR'] = to_str(client[0]) + 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 @@ -488,17 +486,16 @@ def to_str(v): else: environ['wsgi.input'] = buffer - # Add pre-converted HTTP_* headers (already in correct format from Erlang) - # This is O(1) dict.update() instead of O(n) iteration + # Add pre-converted HTTP_* headers (already str from erlang_python) + # Direct dict.update() - O(n) but no per-item function calls if wsgi_headers: - for key, value in wsgi_headers.items(): - environ[to_str(key)] = to_str(value) + environ.update(wsgi_headers) # Add content-type/length if present if content_type is not None: - environ['CONTENT_TYPE'] = to_str(content_type) + environ['CONTENT_TYPE'] = content_type if content_length is not None: - environ['CONTENT_LENGTH'] = to_str(content_length) + environ['CONTENT_LENGTH'] = content_length # Store lifespan state if lifespan_state: diff --git a/src/hornbeam.erl b/src/hornbeam.erl index be4f503..3611b98 100644 --- a/src/hornbeam.erl +++ b/src/hornbeam.erl @@ -454,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. From 95882297d24f06613ba0cb96a1b5c621a7bcfce2 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 19 Mar 2026 23:32:19 +0100 Subject: [PATCH 36/52] Optimize ASGI response streaming and reduce hot path overhead - Add chunk coalescing in drain_response_channel (4KB threshold, 1ms timeout) to batch small chunks and reduce per-request syscall overhead - Unify scope builders: use hornbeam_request:build_asgi_scope everywhere, remove duplicate build_scope from handler - Optimize hooks: store individual hooks in persistent_term with direct keys for zero-overhead check when no hooks configured --- src/hornbeam_handler.erl | 137 +++++++++++++----------------------- src/hornbeam_http_hooks.erl | 32 ++++++--- src/hornbeam_request.erl | 37 ++++++---- 3 files changed, 91 insertions(+), 115 deletions(-) diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl index b875160..85791db 100644 --- a/src/hornbeam_handler.erl +++ b/src/hornbeam_handler.erl @@ -98,13 +98,6 @@ handle_websocket_upgrade(Req, State) -> -define(WSGI_STREAMING_THRESHOLD, 65536). %% 64KB -define(WSGI_BODY_CHUNK_SIZE, 65536). %% 64KB chunks -%% Pre-computed ASGI scope template (static fields) -%% Avoids recreating these maps on every request --define(ASGI_SCOPE_TEMPLATE, #{ - type => <<"http">>, - asgi => #{<<"version">> => <<"3.0">>, <<"spec_version">> => <<"2.4">>} -}). - %% Hop-by-hop headers that should not be forwarded -define(HOP_BY_HOP_HEADERS, [ <<"connection">>, <<"keep-alive">>, <<"proxy-authenticate">>, @@ -274,6 +267,10 @@ filter_hop_by_hop(Headers) -> -define(ASGI_BODY_CHUNK_SIZE, 65536). %% 64KB chunks for request body +%% Chunk coalescing for response body streaming +-define(CHUNK_COALESCE_SIZE, 4096). %% 4KB threshold before flushing +-define(CHUNK_COALESCE_TIMEOUT, 1). %% 1ms max wait for more chunks + handle_asgi(Req, State) -> ReqInfo = build_request_info(Req), ReqInfo1 = hornbeam_http_hooks:run_on_request(ReqInfo), @@ -283,8 +280,8 @@ handle_asgi(Req, State) -> AppCallable = maps:get(app_callable, State), TimeoutMs = maps:get(timeout, State, 30000), - %% Build ASGI scope - Scope = build_scope(Req, State), + %% Build ASGI scope (unified builder in hornbeam_request) + Scope = hornbeam_request:build_asgi_scope(Req, State), %% Create byte channels for request and response bodies %% ReqBodyCh: Erlang writes request body, Python reads @@ -433,72 +430,59 @@ maybe_close_channel(Channel) -> end. %% @private -%% Drain response body from byte channel and stream to client -%% Empty chunk signals EOF, then Erlang closes the channel +%% Drain response body from byte channel and stream to client. +%% Coalesces small chunks to reduce syscall overhead. drain_response_channel(Req, RespBodyCh, TimeoutMs, State) -> + drain_response_channel(Req, RespBodyCh, TimeoutMs, State, []). + +%% @private +%% Drain with buffer accumulation - coalesce small chunks +drain_response_channel(Req, RespBodyCh, TimeoutMs, State, Buffer) -> + case py_byte_channel:recv(RespBodyCh, ?CHUNK_COALESCE_TIMEOUT) of + {ok, Chunk} -> + NewBuffer = [Chunk | Buffer], + BufferSize = iolist_size(NewBuffer), + if + BufferSize >= ?CHUNK_COALESCE_SIZE -> + %% Flush buffer - threshold reached + ok = cowboy_req:stream_body(iolist_to_binary(lists:reverse(NewBuffer)), nofin, Req), + drain_response_channel(Req, RespBodyCh, TimeoutMs, State, []); + true -> + %% Keep buffering + drain_response_channel(Req, RespBodyCh, TimeoutMs, State, NewBuffer) + end; + {error, timeout} when Buffer =/= [] -> + %% Timeout with buffered data - flush and continue + ok = cowboy_req:stream_body(iolist_to_binary(lists:reverse(Buffer)), nofin, Req), + drain_response_channel(Req, RespBodyCh, TimeoutMs, State, []); + {error, timeout} -> + %% Timeout with no buffer - wait longer + drain_response_channel_wait(Req, RespBodyCh, TimeoutMs, State); + {error, closed} -> + %% EOF - flush remaining buffer + case Buffer of + [] -> ok; + _ -> ok = cowboy_req:stream_body(iolist_to_binary(lists:reverse(Buffer)), nofin, Req) + end, + ok = cowboy_req:stream_body(<<>>, fin, Req), + {ok, Req, State} + end. + +%% @private +%% Wait for data with full timeout when buffer is empty +drain_response_channel_wait(Req, RespBodyCh, TimeoutMs, State) -> case py_byte_channel:recv(RespBodyCh, TimeoutMs) of {ok, Chunk} -> - %% Got data - send as non-final chunk - ok = cowboy_req:stream_body(Chunk, nofin, Req), - drain_response_channel(Req, RespBodyCh, TimeoutMs, State); + drain_response_channel(Req, RespBodyCh, TimeoutMs, State, [Chunk]); {error, closed} -> - %% Channel closed = EOF ok = cowboy_req:stream_body(<<>>, fin, Req), {ok, Req, State}; {error, timeout} -> - %% Timeout - close the stream py_byte_channel:close(RespBodyCh), ok = cowboy_req:stream_body(<<>>, fin, Req), {ok, Req, State} end. -%% @private -%% Build ASGI scope - uses pre-computed template for static fields -build_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), - - %% Build headers list - inline for performance - 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), - - %% Get fresh lifespan state from ETS on each request (supports mutable state) - LifespanState = case MountId of - undefined -> hornbeam_lifespan:get_state(); - _ -> hornbeam_lifespan:get_state(MountId) - end, - - %% Merge dynamic fields into pre-computed template - BaseScope = ?ASGI_SCOPE_TEMPLATE#{ - 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}, - state => LifespanState, - 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. - %%% ============================================================================ %%% Response sending %%% ============================================================================ @@ -569,33 +553,6 @@ build_request_info(Req) -> %%% Utilities %%% ============================================================================ -%% @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">> => #{} - }. - %% @private convert_headers(Headers) -> lists:foldl(fun(Header, Acc) -> 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_request.erl b/src/hornbeam_request.erl index 45d67a3..e54e7aa 100644 --- a/src/hornbeam_request.erl +++ b/src/hornbeam_request.erl @@ -77,15 +77,10 @@ build_wsgi_tuple(Req, State) -> %% %% 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) -> - 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), @@ -96,26 +91,40 @@ build_asgi_scope(Req, State) -> %% Convert headers to ASGI format [[name, value], ...] HeaderList = maps:fold(fun(Name, Value, Acc) -> [[Name, Value] | Acc] - end, [], Headers), + end, [], cowboy_req:headers(Req)), - LifespanState = hornbeam_lifespan:get_state(), + %% Get mount_id for per-mount state isolation (multi-app mode) + MountId = maps:get(mount_id, State, undefined), - #{ + %% Get fresh lifespan state from ETS on each request (supports mutable state) + LifespanState = case MountId of + undefined -> hornbeam_lifespan:get_state(); + _ -> hornbeam_lifespan:get_state(MountId) + end, + + %% Build scope map with all fields + BaseScope = #{ type => <<"http">>, asgi => #{<<"version">> => <<"3.0">>, <<"spec_version">> => <<"2.4">>}, http_version => format_http_version(Version), - method => Method, - scheme => Scheme, + method => cowboy_req:method(Req), + scheme => cowboy_req:scheme(Req), path => ScopePath, raw_path => ScopePath, - query_string => Qs, + query_string => cowboy_req:qs(Req), root_path => RootPath, headers => HeaderList, - server => {Host, Port}, + server => {cowboy_req:host(Req), cowboy_req:port(Req)}, client => {format_ip(ClientIp), ClientPort}, state => LifespanState, 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. %% "accept-encoding" -> <<"HTTP_ACCEPT_ENCODING">> From d9ce7d52b890ea1a054b79f461ae83c7f5904d4d Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Fri, 20 Mar 2026 00:13:09 +0100 Subject: [PATCH 37/52] Add threshold-based ASGI request body handling Skip channel/pump for small bodies (<64KB): pass directly to Python. Large bodies still use channel streaming. Reduces process spawns and memory pressure for typical requests. --- priv/hornbeam_asgi_worker.py | 128 ++++++++++++++++++++++------------- src/hornbeam_handler.erl | 53 +++++++++------ 2 files changed, 116 insertions(+), 65 deletions(-) diff --git a/priv/hornbeam_asgi_worker.py b/priv/hornbeam_asgi_worker.py index 0544274..16a65fc 100644 --- a/priv/hornbeam_asgi_worker.py +++ b/priv/hornbeam_asgi_worker.py @@ -165,7 +165,7 @@ def _to_bytes(val) -> bytes: # ============================================================================ async def handle_asgi(caller_pid, app_module: bytes, app_callable: bytes, - scope: dict, req_body_ch, resp_body_ch): + scope: dict, body_ref, resp_body_ch): """Handle an ASGI request asynchronously. This is the main entry point called from Erlang via py_event_loop:create_task. @@ -173,14 +173,18 @@ async def handle_asgi(caller_pid, app_module: bytes, app_callable: bytes, Transport design: - Control plane (mailbox): headers, early_hints, error, response (small) - - Data plane (byte channels): request body (ReqBodyCh), response body (RespBodyCh) + - Data plane (byte channels): response body (RespBodyCh) + - Request body: passed as body_ref (see below) Args: caller_pid: Erlang PID to send control messages to app_module: Python module containing ASGI app (bytes) app_callable: Name of ASGI callable in module (bytes) scope: ASGI scope dict - req_body_ch: ByteChannel reference for reading request body + body_ref: One of: + - 'empty' or b'empty': no body + - ('body', binary) or (b'body', binary): small body passed directly + - ('channel', ref) or (b'channel', ref): large body via ByteChannel resp_body_ch: ByteChannel reference for writing response body """ if not HAS_ERLANG: @@ -190,8 +194,7 @@ async def handle_asgi(caller_pid, app_module: bytes, app_callable: bytes, module_name = app_module callable_name = app_callable - # Wrap channel references - req_channel = ByteChannel(req_body_ch) + # Wrap response channel reference resp_channel = ByteChannel(resp_body_ch) try: @@ -204,8 +207,9 @@ async def handle_asgi(caller_pid, app_module: bytes, app_callable: bytes, mount_id = scope.get('mount_id') scope['state'] = _MutableStateProxy(scope['state'], mount_id) - # Create receive/send callables with byte channels - receive = _ASGIReceive(req_channel) + # Create receive/send callables + # _ASGIReceive handles the three body modes based on body_ref type + receive = _ASGIReceive(body_ref) send = _ASGISend(caller_pid, resp_channel) # Run ASGI app @@ -230,22 +234,56 @@ async def handle_asgi(caller_pid, app_module: bytes, app_callable: bytes, class _ASGIReceive: - """ASGI receive callable using ByteChannel for request body. + """ASGI receive callable with three body modes. - Reads request body chunks from the byte channel provided by Erlang. - Channel close signals EOF (no more body data). + Handles request body based on how Erlang passed it: + - 'empty': No body (GET, HEAD, etc.) + - ('body', binary): Small body passed directly (< 64KB) + - ('channel', ref): Large body streamed via ByteChannel + + The small body optimization eliminates channel/pump overhead for ~95% of requests. """ - __slots__ = ('channel', 'disconnected', '_eof_reached') + __slots__ = ('_mode', '_body', '_channel', 'disconnected', '_eof_reached') - def __init__(self, channel): + def __init__(self, body_ref): """Initialize receive callable. Args: - channel: ByteChannel for reading request body from Erlang + body_ref: One of: + - 'empty' or b'empty': no body + - ('body', binary) or (b'body', binary): small body + - ('channel', ref) or (b'channel', ref): large body via channel """ - self.channel = channel self.disconnected = False self._eof_reached = False + self._body = None + self._channel = None + + # Detect body mode from body_ref type + # Note: erlang_python converts atoms to strings and binaries to str + if body_ref == 'empty' or body_ref == b'empty': + # No body + self._mode = 'empty' + self._eof_reached = True + elif isinstance(body_ref, tuple) and len(body_ref) >= 2: + tag = body_ref[0] + if tag == 'body' or tag == b'body': + # Small body passed directly + # erlang_python may convert binary to str, so use _to_bytes + self._mode = 'body' + self._body = _to_bytes(body_ref[1]) + elif tag == 'channel' or tag == b'channel': + # Large body via ByteChannel + self._mode = 'channel' + self._channel = ByteChannel(body_ref[1]) + else: + # Unknown tuple - treat as channel ref for backward compat + self._mode = 'channel' + self._channel = ByteChannel(body_ref) + else: + # Raw channel ref (backward compatibility) + self._mode = 'channel' + self._channel = ByteChannel(body_ref) async def __call__(self) -> dict: if self.disconnected: @@ -256,42 +294,43 @@ async def __call__(self) -> dict: self.disconnected = True return _DISCONNECT_MSG - chunk = await self._read_chunk() - if not chunk: - # Channel closed = EOF + if self._mode == 'empty': + # No body - return empty request, mark EOF self._eof_reached = True return {'type': 'http.request', 'body': b'', 'more_body': False} - # Got data - return with more_body=True (may have more) - return {'type': 'http.request', 'body': chunk, 'more_body': True} - - async def _read_chunk(self) -> bytes | None: - """Read next chunk asynchronously. - Channel close signals EOF. - """ + if self._mode == 'body': + # Small body fast path - return entire body at once + self._eof_reached = True + return {'type': 'http.request', 'body': self._body, 'more_body': False} + + # Channel mode - stream chunks from ByteChannel + return await self._read_channel() + + async def _read_channel(self) -> dict: + """Read chunk from channel (large body streaming).""" try: - chunk = await self.channel.async_receive_bytes() - return chunk + chunk = await self._channel.async_receive_bytes() + if not chunk: + self._eof_reached = True + return {'type': 'http.request', 'body': b'', 'more_body': False} + # Got data - return with more_body=True (may have more) + return {'type': 'http.request', 'body': chunk, 'more_body': True} except ByteChannelClosed: - return None + # Channel closed = EOF + self._eof_reached = True + return {'type': 'http.request', 'body': b'', 'more_body': False} class _ASGISend: """ASGI send callable using ByteChannel for response body. Transport design: - - Control plane (erlang.send): headers, early_hints, response (small) - - Data plane (ByteChannel): response body for streaming - - For small responses (< BUFFER_THRESHOLD), sends complete response via - erlang.send() for efficiency. For larger/streaming responses, sends - headers via erlang.send() and body via ByteChannel. + - Control plane (erlang.send): headers, early_hints + - Data plane (ByteChannel): response body streaming """ __slots__ = ('caller_pid', 'resp_channel', 'status', 'headers', - 'headers_sent', 'body_parts', 'finished', '_send', '_total_size') - - # Buffer small responses before sending (optimization) - BUFFER_THRESHOLD = 65536 + 'headers_sent', 'finished', '_send') def __init__(self, caller_pid, resp_channel): """Initialize send callable. @@ -306,9 +345,7 @@ def __init__(self, caller_pid, resp_channel): self.status = None self.headers = [] self.headers_sent = False - self.body_parts = [] # Buffer for small responses self.finished = False - self._total_size = 0 async def __call__(self, message: dict) -> None: if self.finished: @@ -334,7 +371,7 @@ async def __call__(self, message: dict) -> None: more_body = message.get('more_body', False) - # Send body to channel + # Send body directly to channel try: self.resp_channel.send_bytes(body_part) except ByteChannelClosed: @@ -353,7 +390,6 @@ async def __call__(self, message: dict) -> None: self._send(self.caller_pid, (b'early_hints', headers)) elif msg_type == 'http.disconnect': - # We close the channel to signal EOF self.resp_channel.close() self.finished = True @@ -363,7 +399,7 @@ async def __call__(self, message: dict) -> None: # ============================================================================ def handle_asgi_sync(caller_pid, app_module: bytes, app_callable: bytes, - scope: dict, req_body_ch, resp_body_ch): + scope: dict, body_ref, resp_body_ch): """Synchronous wrapper that runs handle_asgi with erlang.run(). This is used when calling from py_nif:context_call() which expects @@ -375,7 +411,7 @@ def handle_asgi_sync(caller_pid, app_module: bytes, app_callable: bytes, app_module: Python module containing ASGI app (bytes) app_callable: Name of ASGI callable in module (bytes) scope: ASGI scope dict - req_body_ch: ByteChannel reference for reading request body + body_ref: Body reference (see handle_asgi for variants) resp_body_ch: ByteChannel reference for writing response body """ if not HAS_ERLANG: @@ -384,7 +420,7 @@ def handle_asgi_sync(caller_pid, app_module: bytes, app_callable: bytes, try: # Use erlang.run() for proper Erlang event loop integration erlang.run(handle_asgi(caller_pid, app_module, app_callable, scope, - req_body_ch, resp_body_ch)) + body_ref, resp_body_ch)) return b'done' except Exception as e: try: @@ -506,6 +542,6 @@ def handle_request_direct(args_tuple) -> None: if not HAS_ERLANG: return - caller_pid, app_module, app_callable, scope, req_body_ch, resp_body_ch = args_tuple + caller_pid, app_module, app_callable, scope, body_ref, resp_body_ch = args_tuple handle_asgi_sync(caller_pid, app_module, app_callable, scope, - req_body_ch, resp_body_ch) + body_ref, resp_body_ch) diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl index 85791db..990a9d5 100644 --- a/src/hornbeam_handler.erl +++ b/src/hornbeam_handler.erl @@ -266,6 +266,7 @@ filter_hop_by_hop(Headers) -> %%% ============================================================================ -define(ASGI_BODY_CHUNK_SIZE, 65536). %% 64KB chunks for request body +-define(ASGI_BODY_BUFFER_THRESHOLD, 65536). %% 64KB - bodies smaller than this are passed directly %% Chunk coalescing for response body streaming -define(CHUNK_COALESCE_SIZE, 4096). %% 4KB threshold before flushing @@ -283,10 +284,8 @@ handle_asgi(Req, State) -> %% Build ASGI scope (unified builder in hornbeam_request) Scope = hornbeam_request:build_asgi_scope(Req, State), - %% Create byte channels for request and response bodies - %% ReqBodyCh: Erlang writes request body, Python reads + %% Create response body channel %% RespBodyCh: Python writes response body, Erlang reads - {ok, ReqBodyCh} = py_byte_channel:new(), {ok, RespBodyCh} = py_byte_channel:new(), %% Check if request has a body @@ -294,9 +293,23 @@ handle_asgi(Req, State) -> ContentLength = get_content_length(Req), HasBody = has_request_body(Method, ContentLength), - %% Spawn body pump process if there's a body - BodyPumpPid = case HasBody of + %% Determine body handling strategy based on size: + %% - No body: pass 'empty' atom + %% - Small body (<64KB with known length): read sync, pass {body, {bytes, Binary}} + %% - Large body or unknown length: use channel with pump process + %% Note: {bytes, Binary} ensures Python receives bytes, not str (see erlang_python) + {BodyRef, BodyPumpPid} = case HasBody of + false -> + %% No body - pass empty marker + {empty, undefined}; + true when is_integer(ContentLength), ContentLength < ?ASGI_BODY_BUFFER_THRESHOLD -> + %% Small body - read synchronously, pass binary directly + %% Wrap in {bytes, _} to ensure Python receives bytes, not str + {ok, Body, _Req2} = cowboy_req:read_body(Req), + {{body, {bytes, Body}}, undefined}; true -> + %% Large body or unknown size - use channel with pump + {ok, ReqBodyCh} = py_byte_channel:new(), Ref = make_ref(), HandlerPid = self(), Pid = spawn(fun() -> @@ -304,23 +317,19 @@ handle_asgi(Req, State) -> pump_request_body(Req, ReqBodyCh, ?ASGI_BODY_CHUNK_SIZE) end), ok = wait_pump(Ref), - Pid; - false -> - %% No body - close channel to signal EOF - py_byte_channel:close(ReqBodyCh), - undefined + {{channel, ReqBodyCh}, Pid} end, %% Create async task to run Python ASGI handler %% Uses event loop pool with process affinity for better distribution %% Python handler sends control messages via erlang.send() - %% and body data via byte channel + %% and body data via byte channel (for large bodies) _TaskRef = py_event_loop_pool:create_task( <<"hornbeam_asgi_worker">>, <<"handle_asgi">>, - [self(), AppModule, AppCallable, Scope, ReqBodyCh, RespBodyCh]), + [self(), AppModule, AppCallable, Scope, BodyRef, RespBodyCh]), %% Receive response from async Python - Result = receive_asgi_response(Req, ReqInfo1, ReqBodyCh, RespBodyCh, TimeoutMs, State), + Result = receive_asgi_response(Req, ReqInfo1, BodyRef, RespBodyCh, TimeoutMs, State), % ensure to kill body pump % at this point, channels must have been closed, otherwise gc will do its job. @@ -385,12 +394,12 @@ write_to_channel_with_backpressure(Channel, Data) -> %% @private %% Receive ASGI response from Python worker %% Control messages come via mailbox, response body via RespBodyCh -%% Closes ReqBodyCh when response starts (Python is done reading request) -receive_asgi_response(Req, ReqInfo, ReqBodyCh, RespBodyCh, TimeoutMs, State) -> +%% BodyRef is one of: empty, {body, Binary}, {channel, ReqBodyCh} +receive_asgi_response(Req, ReqInfo, BodyRef, RespBodyCh, TimeoutMs, State) -> receive {<<"headers">>, StatusCode, Headers} -> %% Streaming path: headers first, then drain channel - ok = maybe_close_channel(ReqBodyCh), + ok = maybe_close_body_channel(BodyRef), SafeHeaders = filter_hop_by_hop(Headers), CowboyHeaders = convert_headers(SafeHeaders), Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req), @@ -399,21 +408,27 @@ receive_asgi_response(Req, ReqInfo, ReqBodyCh, RespBodyCh, TimeoutMs, State) -> %% Early hints (103) HintHeaders = convert_headers(Headers), Req2 = cowboy_req:inform(103, HintHeaders, Req), - receive_asgi_response(Req2, ReqInfo, ReqBodyCh, RespBodyCh, TimeoutMs, State); + receive_asgi_response(Req2, ReqInfo, BodyRef, RespBodyCh, TimeoutMs, State); {<<"error">>, Reason} -> handle_error(Req, Reason, ReqInfo, State); {async_result, _Ref, {ok, _}} -> %% Async task completion - continue receiving - receive_asgi_response(Req, ReqInfo, ReqBodyCh, RespBodyCh, TimeoutMs, State); + receive_asgi_response(Req, ReqInfo, BodyRef, RespBodyCh, TimeoutMs, State); {async_result, _Ref, {error, Reason}} -> %% ensure to close channels there - ok = maybe_close_channel(ReqBodyCh), + ok = maybe_close_body_channel(BodyRef), ok = maybe_close_channel(RespBodyCh), handle_error(Req, Reason, ReqInfo, State) after TimeoutMs -> handle_error(Req, timeout, ReqInfo, State) end. +%% @private +%% Close body channel if BodyRef contains one +maybe_close_body_channel(empty) -> ok; +maybe_close_body_channel({body, _}) -> ok; +maybe_close_body_channel({channel, Ch}) -> maybe_close_channel(Ch). + %% @private %% Maybe close a channel maybe_close_channel(Channel) -> From 5090b111b6c3f8914e02fcddcc6a2ecc286511aa Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Fri, 20 Mar 2026 00:41:12 +0100 Subject: [PATCH 38/52] [experimental] Add protocol-style ASGI loop handler Prototype using Cowboy's async body reading with push/pull pattern: - cowboy_req:cast for async body chunks - ASGIProtocol class mirroring asyncio.Protocol interface - Buffer + asyncio.Event for ASGI receive() Use worker_class => asgi_loop to test. Not yet optimized for production. --- priv/hornbeam_asgi_loop.py | 338 ++++++++++++++++++++++++++++++++++ src/hornbeam.erl | 3 + src/hornbeam_asgi_loop.erl | 303 ++++++++++++++++++++++++++++++ src/hornbeam_context_pool.erl | 5 +- src/hornbeam_handler.erl | 20 ++ 5 files changed, 667 insertions(+), 2 deletions(-) create mode 100644 priv/hornbeam_asgi_loop.py create mode 100644 src/hornbeam_asgi_loop.erl diff --git a/priv/hornbeam_asgi_loop.py b/priv/hornbeam_asgi_loop.py new file mode 100644 index 0000000..215402b --- /dev/null +++ b/priv/hornbeam_asgi_loop.py @@ -0,0 +1,338 @@ +# 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 asyncio.Protocol-style push/pull pattern. + +This module implements ASGI request handling using a Protocol pattern: + +- ASGIProtocol handles the request lifecycle +- Data flows via callbacks (data_received, eof_received) +- Buffer + Event pattern for ASGI receive() +- Clean separation of concerns + +The protocol mirrors asyncio.Protocol's interface but adapts it for +Erlang channel communication. +""" + +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 + + +class ASGIProtocol: + """Protocol-style ASGI handler. + + Mirrors asyncio.Protocol interface but adapted for Erlang channels. + Handles the full request/response lifecycle. + """ + + def __init__(self, caller_pid, app: Callable, scope: dict, + req_channel: ByteChannel, resp_channel: ByteChannel): + self._caller_pid = caller_pid + self._app = app + self._scope = scope + self._req_channel = req_channel + self._resp_channel = resp_channel + self._send_fn = _erlang_send + + # Body state (like asyncio.Protocol) + self._buffer = bytearray() + self._body_event = asyncio.Event() + self._eof_received = False + self._disconnected = False + + # Response state + self._response_started = False + self._response_finished = False + + # Tasks + self._reader_task: Optional[asyncio.Task] = None + self._app_task: Optional[asyncio.Task] = None + + # ========================================================================= + # Protocol callbacks (asyncio.Protocol style) + # ========================================================================= + + def connection_made(self): + """Called when channel is ready.""" + # Start the channel reader + self._reader_task = asyncio.create_task(self._read_channel()) + + def data_received(self, data: bytes): + """Called when body data is received.""" + self._buffer.extend(data) + self._body_event.set() + + def eof_received(self): + """Called when body is complete (channel closed).""" + self._eof_received = True + self._body_event.set() + + def connection_lost(self, exc: Optional[Exception]): + """Called when connection is lost.""" + self._disconnected = True + self._eof_received = True + self._body_event.set() + if self._app_task and not self._app_task.done(): + self._app_task.cancel() + + # ========================================================================= + # Channel reader (bridges channel to Protocol callbacks) + # ========================================================================= + + async def _read_channel(self): + """Read from channel and dispatch to Protocol callbacks.""" + try: + while True: + try: + chunk = await self._req_channel.async_receive_bytes() + if chunk: + self.data_received(chunk) + except ByteChannelClosed: + self.eof_received() + break + except asyncio.CancelledError: + pass + except Exception as exc: + self.connection_lost(exc) + + # ========================================================================= + # ASGI interface + # ========================================================================= + + async def receive(self) -> dict: + """ASGI receive callable.""" + if self._disconnected: + return {'type': 'http.disconnect'} + + # Return buffered data if available + if self._buffer: + body = bytes(self._buffer) + self._buffer.clear() + return { + 'type': 'http.request', + 'body': body, + 'more_body': not self._eof_received, + } + + # EOF with no data + if self._eof_received: + return { + 'type': 'http.request', + 'body': b'', + 'more_body': False, + } + + # Wait for data + await self._body_event.wait() + self._body_event.clear() + + if self._disconnected: + return {'type': 'http.disconnect'} + + # Return buffered data + body = bytes(self._buffer) + self._buffer.clear() + + return { + 'type': 'http.request', + 'body': body, + 'more_body': not self._eof_received, + } + + async def send(self, message: dict) -> None: + """ASGI send callable.""" + 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") + + status = message.get('status', 200) + headers = message.get('headers', []) + self._send_fn(self._caller_pid, (b'headers', status, 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) + + try: + if body: + self._resp_channel.send_bytes(body) + except ByteChannelClosed: + self._response_finished = True + raise OSError("Client disconnected") + + if not more_body: + self._resp_channel.close() + 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._resp_channel.close() + self._response_finished = True + + # ========================================================================= + # Lifecycle + # ========================================================================= + + async def run(self): + """Run the ASGI application.""" + self.connection_made() + + try: + await self._app(self._scope, self.receive, self.send) + + # Ensure response is completed + if not self._response_finished: + if not self._response_started: + self._send_fn(self._caller_pid, (b'headers', 500, [])) + try: + self._resp_channel.close() + except ByteChannelClosed: + pass + + except Exception as e: + try: + self._resp_channel.close() + self._send_fn(self._caller_pid, (b'error', str(e).encode('utf-8'))) + except Exception: + pass + + finally: + # Cleanup + if self._reader_task and not self._reader_task.done(): + self._reader_task.cancel() + try: + await self._reader_task + except asyncio.CancelledError: + pass + + +# ============================================================================= +# Entry point +# ============================================================================= + +async def handle_asgi_loop(caller_pid, app_module: str, app_callable: str, + scope: dict, req_body_ch, resp_body_ch): + """Handle ASGI request using Protocol pattern. + + Entry point called from hornbeam_asgi_loop.erl. + """ + if not HAS_ERLANG: + return + + # Wrap channels + req_channel = ByteChannel(req_body_ch) + resp_channel = ByteChannel(resp_body_ch) + + # Get app + app = _get_app(app_module, app_callable) + + # Wrap scope['state'] if present + if 'state' in scope: + mount_id = scope.get('mount_id') + scope['state'] = _MutableStateProxy(scope['state'], mount_id) + + # Create and run protocol + protocol = ASGIProtocol(caller_pid, app, scope, req_channel, resp_channel) + await protocol.run() + + +# ============================================================================= +# Helpers +# ============================================================================= + +_preloaded_app: Callable = None +_preloaded_key: tuple = None + + +def preload_app(app_module: str, app_callable: str) -> bytes: + """Preload ASGI application at startup.""" + global _preloaded_app, _preloaded_key + + 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 ASGI application.""" + global _preloaded_app, _preloaded_key + + if _preloaded_key == (module_name, callable_name): + return _preloaded_app + + import importlib + module = importlib.import_module(module_name) + return getattr(module, callable_name) + + +class _MutableStateProxy(dict): + """Dict that syncs mutations to Erlang ETS.""" + + __slots__ = ('_mount_id', '_lifespan_pid') + + def __init__(self, initial_state: dict, mount_id=None): + super().__init__(initial_state) + self._mount_id = mount_id + self._lifespan_pid = None + if HAS_ERLANG: + try: + self._lifespan_pid = erlang.whereis("hornbeam_lifespan") + except Exception: + pass + + def __setitem__(self, key, value): + super().__setitem__(key, value) + if self._lifespan_pid is not None: + try: + if self._mount_id is not None: + _erlang_send(self._lifespan_pid, + (b'update_state', self._mount_id, key, value)) + else: + _erlang_send(self._lifespan_pid, (b'update_state', key, value)) + except Exception: + pass diff --git a/src/hornbeam.erl b/src/hornbeam.erl index 3611b98..0d7e023 100644 --- a/src/hornbeam.erl +++ b/src/hornbeam.erl @@ -641,6 +641,9 @@ maybe_run_lifespan_startup(asgi, Config) -> off -> ok; _ -> hornbeam_lifespan:startup(#{lifespan => LifespanMode}) end; +maybe_run_lifespan_startup(asgi_loop, Config) -> + %% asgi_loop uses same lifespan handling as asgi + maybe_run_lifespan_startup(asgi, Config); maybe_run_lifespan_startup(wsgi, _Config) -> %% WSGI doesn't support lifespan ok. diff --git a/src/hornbeam_asgi_loop.erl b/src/hornbeam_asgi_loop.erl new file mode 100644 index 0000000..20ee54c --- /dev/null +++ b/src/hornbeam_asgi_loop.erl @@ -0,0 +1,303 @@ +%% 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 ASGI handler using Cowboy loop handler with push/pull body streaming. +%%% +%%% This module implements ASGI request handling using Cowboy's async body +%%% reading via loop handlers. Instead of spawning a pump process, it uses +%%% cowboy_req:cast to receive body chunks as messages, which are then +%%% pushed to Python via channel. +%%% +%%% Architecture: +%%% - Cowboy sends {request_body, Ref, nofin|fin, Data} messages +%%% - Loop handler info/3 pushes chunks to Python channel +%%% - Python buffers chunks and signals via asyncio.Event +%%% - ASGI receive() pulls from buffer +%%% +%%% Benefits over pump process approach: +%%% - No extra process spawn per request +%%% - Event-driven data flow +%%% - Natural backpressure via channel +%%% - Better integration with Cowboy's internals +%%% +%%% @end +-module(hornbeam_asgi_loop). + +-behaviour(cowboy_loop). + +-export([init/2, info/3, terminate/3]). + +%% Internal state +-record(state, { + req_info, + app_module, + app_callable, + timeout_ms, + scope, + req_body_ch, + resp_body_ch, + body_ref, %% Reference for async body reading + has_body, + headers_sent = false, + handler_state %% Original handler state from init +}). + +-define(CHUNK_COALESCE_SIZE, 4096). +-define(CHUNK_COALESCE_TIMEOUT, 1). + +%%% ============================================================================ +%%% 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), + + %% Build ASGI scope + Scope = hornbeam_request:build_asgi_scope(Req, HandlerState), + + %% Create channels + {ok, ReqBodyCh} = py_byte_channel:new(), + {ok, RespBodyCh} = py_byte_channel:new(), + + %% Check if request has a body + Method = cowboy_req:method(Req), + ContentLength = get_content_length(Req), + HasBody = has_request_body(Method, ContentLength), + + %% Start async body reading if there's a body + BodyRef = case HasBody of + true -> + Ref = make_ref(), + %% Start async body reading - Cowboy will send us messages + cowboy_req:cast({read_body, self(), Ref, auto, infinity}, Req), + Ref; + false -> + %% No body - close channel immediately + py_byte_channel:close(ReqBodyCh), + undefined + end, + + %% Create Python task + _TaskRef = py_event_loop_pool:create_task( + <<"hornbeam_asgi_loop">>, <<"handle_asgi_loop">>, + [self(), AppModule, AppCallable, Scope, ReqBodyCh, RespBodyCh]), + + State = #state{ + req_info = ReqInfo1, + app_module = AppModule, + app_callable = AppCallable, + timeout_ms = TimeoutMs, + scope = Scope, + req_body_ch = ReqBodyCh, + resp_body_ch = RespBodyCh, + body_ref = BodyRef, + has_body = HasBody, + handler_state = HandlerState + }, + + %% Return cowboy_loop to enable loop handler + {cowboy_loop, Req, State, TimeoutMs}. + +%% Handle async body chunks from Cowboy +info({request_body, Ref, nofin, Data}, Req, #state{body_ref = Ref, req_body_ch = Ch} = State) -> + %% More body data coming - push to channel + ok = push_to_channel(Ch, Data), + %% Request more data + cowboy_req:cast({read_body, self(), Ref, auto, infinity}, Req), + {ok, Req, State}; + +info({request_body, Ref, fin, _BodyLen, Data}, Req, #state{body_ref = Ref, req_body_ch = Ch} = State) -> + %% Final chunk - push and close channel + case Data of + <<>> -> ok; + _ -> ok = push_to_channel(Ch, Data) + end, + py_byte_channel:close(Ch), + {ok, Req, State#state{body_ref = undefined}}; + +%% Handle response headers from Python +info({<<"headers">>, StatusCode, Headers}, Req, State) -> + SafeHeaders = filter_hop_by_hop(Headers), + CowboyHeaders = convert_headers(SafeHeaders), + Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req), + %% Start draining response channel + self() ! drain_response, + {ok, Req2, State#state{headers_sent = true}}; + +%% 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}; + +%% Drain response body channel +info(drain_response, Req, #state{resp_body_ch = RespBodyCh, handler_state = HandlerState} = State) -> + case drain_response_chunk(RespBodyCh) of + {ok, Data} -> + ok = cowboy_req:stream_body(Data, nofin, Req), + self() ! drain_response, + {ok, Req, State}; + {error, closed} -> + ok = cowboy_req:stream_body(<<>>, fin, Req), + {stop, Req, HandlerState}; + {error, timeout} -> + %% No data yet, schedule retry + erlang:send_after(?CHUNK_COALESCE_TIMEOUT, self(), drain_response), + {ok, Req, State} + end; + +%% Handle error from Python +info({<<"error">>, Reason}, Req, #state{req_info = ReqInfo, handler_state = HandlerState} = State) -> + maybe_close_channel(State#state.req_body_ch), + maybe_close_channel(State#state.resp_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) -> + %% Task completed, continue draining response + {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), + maybe_close_channel(State#state.resp_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), + maybe_close_channel(State#state.resp_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. + +drain_response_chunk(RespBodyCh) -> + py_byte_channel:recv(RespBodyCh, ?CHUNK_COALESCE_TIMEOUT). + +maybe_close_channel(undefined) -> ok; +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 +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 +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_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 +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 +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_context_pool.erl b/src/hornbeam_context_pool.erl index f42a92b..69c4c0e 100644 --- a/src/hornbeam_context_pool.erl +++ b/src/hornbeam_context_pool.erl @@ -115,7 +115,7 @@ add_paths(Paths) when is_list(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. +-spec preload_app(wsgi | asgi | asgi_loop, binary(), binary()) -> ok. preload_app(WorkerClass, AppModule, AppCallable) -> gen_server:call(?MODULE, {preload_app, WorkerClass, AppModule, AppCallable}). @@ -162,7 +162,8 @@ handle_call({preload_app, WorkerClass, AppModule, AppCallable}, _From, %% Preload app in all contexts WorkerModule = case WorkerClass of wsgi -> <<"hornbeam_wsgi_worker">>; - asgi -> <<"hornbeam_asgi_worker">> + asgi -> <<"hornbeam_asgi_worker">>; + asgi_loop -> <<"hornbeam_asgi_loop">> end, maps:foreach(fun(_Id, Ref) -> case py_nif:context_call(Ref, WorkerModule, <<"preload_app">>, diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl index 990a9d5..dc08f32 100644 --- a/src/hornbeam_handler.erl +++ b/src/hornbeam_handler.erl @@ -26,8 +26,11 @@ -module(hornbeam_handler). -behaviour(cowboy_websocket). +-behaviour(cowboy_loop). -export([init/2]). +%% Loop handler callback (for asgi_loop mode) +-export([info/3]). %% WebSocket callbacks (delegate to hornbeam_websocket) -export([websocket_init/1, websocket_handle/2, websocket_info/2, terminate/3]). @@ -75,6 +78,14 @@ handle_request(asgi, Req, State) -> handle_websocket_upgrade(Req, State); false -> handle_asgi(Req, State) + end; +handle_request(asgi_loop, Req, State) -> + %% ASGI with loop handler (experimental push/pull pattern) + case is_websocket_upgrade(Req) of + true -> + handle_websocket_upgrade(Req, State); + false -> + hornbeam_asgi_loop:init(Req, State) end. %% @private @@ -591,6 +602,15 @@ 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)). +%%% ============================================================================ +%%% Loop handler callback (for asgi_loop mode) +%%% ============================================================================ + +%% @private +%% Delegate to hornbeam_asgi_loop for loop handler messages +info(Msg, Req, State) -> + hornbeam_asgi_loop:info(Msg, Req, State). + %%% ============================================================================ %%% WebSocket callbacks (delegate to hornbeam_websocket) %%% ============================================================================ From 05d563871410257a6c4af209821fba1316785204 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Fri, 20 Mar 2026 09:29:34 +0100 Subject: [PATCH 39/52] Simplify ASGI loop handler, remove response channel - Remove response channel, use erlang.send() directly for body - Remove drain loop with timer polling - Remove Python buffer/event/reader task pattern - Read from request channel directly in receive() Result: -130 lines, +21% GET, +10% POST throughput --- priv/hornbeam_asgi_loop.py | 182 ++++++++----------------------------- src/hornbeam_asgi_loop.erl | 64 +++++-------- 2 files changed, 58 insertions(+), 188 deletions(-) diff --git a/priv/hornbeam_asgi_loop.py b/priv/hornbeam_asgi_loop.py index 215402b..086cd40 100644 --- a/priv/hornbeam_asgi_loop.py +++ b/priv/hornbeam_asgi_loop.py @@ -12,20 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""ASGI handler using asyncio.Protocol-style push/pull pattern. +"""ASGI handler using Cowboy loop handler with direct message passing. -This module implements ASGI request handling using a Protocol pattern: - -- ASGIProtocol handles the request lifecycle -- Data flows via callbacks (data_received, eof_received) -- Buffer + Event pattern for ASGI receive() -- Clean separation of concerns - -The protocol mirrors asyncio.Protocol's interface but adapts it for -Erlang channel communication. +Simple design: +- Request body: read from Erlang channel in receive() +- Response: send via erlang.send() directly to Cowboy handler +- No buffering, no extra tasks """ -import asyncio from typing import Callable, Optional try: @@ -42,128 +36,49 @@ class ASGIProtocol: - """Protocol-style ASGI handler. + """ASGI handler using Erlang channels for request body. - Mirrors asyncio.Protocol interface but adapted for Erlang channels. - Handles the full request/response lifecycle. + Simple design: read from channel directly in receive(), no buffering. """ def __init__(self, caller_pid, app: Callable, scope: dict, - req_channel: ByteChannel, resp_channel: ByteChannel): + req_channel: Optional[ByteChannel]): self._caller_pid = caller_pid self._app = app self._scope = scope - self._req_channel = req_channel - self._resp_channel = resp_channel + self._req_channel = req_channel # None for no-body requests self._send_fn = _erlang_send - # Body state (like asyncio.Protocol) - self._buffer = bytearray() - self._body_event = asyncio.Event() - self._eof_received = False - self._disconnected = False - # Response state self._response_started = False self._response_finished = False - # Tasks - self._reader_task: Optional[asyncio.Task] = None - self._app_task: Optional[asyncio.Task] = None - - # ========================================================================= - # Protocol callbacks (asyncio.Protocol style) - # ========================================================================= - - def connection_made(self): - """Called when channel is ready.""" - # Start the channel reader - self._reader_task = asyncio.create_task(self._read_channel()) - - def data_received(self, data: bytes): - """Called when body data is received.""" - self._buffer.extend(data) - self._body_event.set() - - def eof_received(self): - """Called when body is complete (channel closed).""" - self._eof_received = True - self._body_event.set() - - def connection_lost(self, exc: Optional[Exception]): - """Called when connection is lost.""" - self._disconnected = True - self._eof_received = True - self._body_event.set() - if self._app_task and not self._app_task.done(): - self._app_task.cancel() - - # ========================================================================= - # Channel reader (bridges channel to Protocol callbacks) - # ========================================================================= - - async def _read_channel(self): - """Read from channel and dispatch to Protocol callbacks.""" - try: - while True: - try: - chunk = await self._req_channel.async_receive_bytes() - if chunk: - self.data_received(chunk) - except ByteChannelClosed: - self.eof_received() - break - except asyncio.CancelledError: - pass - except Exception as exc: - self.connection_lost(exc) - # ========================================================================= # ASGI interface # ========================================================================= async def receive(self) -> dict: - """ASGI receive callable.""" - if self._disconnected: - return {'type': 'http.disconnect'} - - # Return buffered data if available - if self._buffer: - body = bytes(self._buffer) - self._buffer.clear() - return { - 'type': 'http.request', - 'body': body, - 'more_body': not self._eof_received, - } + """ASGI receive callable - reads directly from channel.""" + # No body case + if self._req_channel is None: + return {'type': 'http.request', 'body': b'', 'more_body': False} - # EOF with no data - if self._eof_received: + # Read from channel + try: + chunk = await self._req_channel.async_receive_bytes() return { 'type': 'http.request', - 'body': b'', - 'more_body': False, + 'body': chunk if chunk else b'', + 'more_body': True, } - - # Wait for data - await self._body_event.wait() - self._body_event.clear() - - if self._disconnected: - return {'type': 'http.disconnect'} - - # Return buffered data - body = bytes(self._buffer) - self._buffer.clear() - - return { - 'type': 'http.request', - 'body': body, - 'more_body': not self._eof_received, - } + except ByteChannelClosed: + return {'type': 'http.request', 'body': b'', 'more_body': False} async def send(self, message: dict) -> None: - """ASGI send callable.""" + """ASGI send callable. + + Uses erlang.send() directly for all response data - no channel overhead. + """ if self._response_finished: raise RuntimeError("Response already completed") @@ -188,15 +103,10 @@ async def send(self, message: dict) -> None: more_body = message.get('more_body', False) - try: - if body: - self._resp_channel.send_bytes(body) - except ByteChannelClosed: - self._response_finished = True - raise OSError("Client disconnected") + # Send body directly via erlang.send - no channel overhead + self._send_fn(self._caller_pid, (b'body', body, more_body)) if not more_body: - self._resp_channel.close() self._response_finished = True elif msg_type == 'http.response.informational': @@ -206,17 +116,10 @@ async def send(self, message: dict) -> None: self._send_fn(self._caller_pid, (b'early_hints', headers)) elif msg_type == 'http.disconnect': - self._resp_channel.close() self._response_finished = True - # ========================================================================= - # Lifecycle - # ========================================================================= - async def run(self): """Run the ASGI application.""" - self.connection_made() - try: await self._app(self._scope, self.receive, self.send) @@ -224,26 +127,10 @@ async def run(self): if not self._response_finished: if not self._response_started: self._send_fn(self._caller_pid, (b'headers', 500, [])) - try: - self._resp_channel.close() - except ByteChannelClosed: - pass + self._send_fn(self._caller_pid, (b'body', b'', False)) except Exception as e: - try: - self._resp_channel.close() - self._send_fn(self._caller_pid, (b'error', str(e).encode('utf-8'))) - except Exception: - pass - - finally: - # Cleanup - if self._reader_task and not self._reader_task.done(): - self._reader_task.cancel() - try: - await self._reader_task - except asyncio.CancelledError: - pass + self._send_fn(self._caller_pid, (b'error', str(e).encode('utf-8'))) # ============================================================================= @@ -251,17 +138,20 @@ async def run(self): # ============================================================================= async def handle_asgi_loop(caller_pid, app_module: str, app_callable: str, - scope: dict, req_body_ch, resp_body_ch): + scope: dict, req_body_ch): """Handle ASGI request using Protocol pattern. Entry point called from hornbeam_asgi_loop.erl. + Response is sent directly via erlang.send() - no channel needed. """ if not HAS_ERLANG: return - # Wrap channels - req_channel = ByteChannel(req_body_ch) - resp_channel = ByteChannel(resp_body_ch) + # Wrap request channel (may be 'empty' atom for no-body requests) + if req_body_ch == 'empty' or req_body_ch == b'empty': + req_channel = None + else: + req_channel = ByteChannel(req_body_ch) # Get app app = _get_app(app_module, app_callable) @@ -272,7 +162,7 @@ async def handle_asgi_loop(caller_pid, app_module: str, app_callable: str, scope['state'] = _MutableStateProxy(scope['state'], mount_id) # Create and run protocol - protocol = ASGIProtocol(caller_pid, app, scope, req_channel, resp_channel) + protocol = ASGIProtocol(caller_pid, app, scope, req_channel) await protocol.run() diff --git a/src/hornbeam_asgi_loop.erl b/src/hornbeam_asgi_loop.erl index 20ee54c..6a51cf2 100644 --- a/src/hornbeam_asgi_loop.erl +++ b/src/hornbeam_asgi_loop.erl @@ -46,16 +46,12 @@ timeout_ms, scope, req_body_ch, - resp_body_ch, body_ref, %% Reference for async body reading has_body, headers_sent = false, handler_state %% Original handler state from init }). --define(CHUNK_COALESCE_SIZE, 4096). --define(CHUNK_COALESCE_TIMEOUT, 1). - %%% ============================================================================ %%% Cowboy Loop Handler Callbacks %%% ============================================================================ @@ -71,32 +67,28 @@ init(Req, HandlerState) -> %% Build ASGI scope Scope = hornbeam_request:build_asgi_scope(Req, HandlerState), - %% Create channels - {ok, ReqBodyCh} = py_byte_channel:new(), - {ok, RespBodyCh} = py_byte_channel:new(), - %% Check if request has a body Method = cowboy_req:method(Req), ContentLength = get_content_length(Req), HasBody = has_request_body(Method, ContentLength), - %% Start async body reading if there's a body - BodyRef = case HasBody of + %% Create request body channel only if body exists (skip for GET/no-body) + {ReqBodyCh, BodyRef} = case HasBody of true -> + {ok, Ch} = py_byte_channel:new(), Ref = make_ref(), %% Start async body reading - Cowboy will send us messages cowboy_req:cast({read_body, self(), Ref, auto, infinity}, Req), - Ref; + {Ch, Ref}; false -> - %% No body - close channel immediately - py_byte_channel:close(ReqBodyCh), - undefined + %% No body - pass empty marker, skip channel + {empty, undefined} end, - %% Create Python task + %% Create Python task (response sent via erlang.send, no channel needed) _TaskRef = py_event_loop_pool:create_task( <<"hornbeam_asgi_loop">>, <<"handle_asgi_loop">>, - [self(), AppModule, AppCallable, Scope, ReqBodyCh, RespBodyCh]), + [self(), AppModule, AppCallable, Scope, ReqBodyCh]), State = #state{ req_info = ReqInfo1, @@ -105,7 +97,6 @@ init(Req, HandlerState) -> timeout_ms = TimeoutMs, scope = Scope, req_body_ch = ReqBodyCh, - resp_body_ch = RespBodyCh, body_ref = BodyRef, has_body = HasBody, handler_state = HandlerState @@ -136,36 +127,30 @@ info({<<"headers">>, StatusCode, Headers}, Req, State) -> SafeHeaders = filter_hop_by_hop(Headers), CowboyHeaders = convert_headers(SafeHeaders), Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req), - %% Start draining response channel - self() ! drain_response, {ok, Req2, State#state{headers_sent = true}}; +%% Handle response body from Python (sent directly via erlang.send) +info({<<"body">>, Body, MoreBody}, Req, #state{handler_state = HandlerState} = State) -> + BodyBin = to_binary(Body), + case MoreBody of + true -> + ok = cowboy_req:stream_body(BodyBin, nofin, Req), + {ok, Req, State}; + false -> + ok = cowboy_req:stream_body(BodyBin, fin, Req), + maybe_close_channel(State#state.req_body_ch), + {stop, Req, HandlerState} + end; + %% 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}; -%% Drain response body channel -info(drain_response, Req, #state{resp_body_ch = RespBodyCh, handler_state = HandlerState} = State) -> - case drain_response_chunk(RespBodyCh) of - {ok, Data} -> - ok = cowboy_req:stream_body(Data, nofin, Req), - self() ! drain_response, - {ok, Req, State}; - {error, closed} -> - ok = cowboy_req:stream_body(<<>>, fin, Req), - {stop, Req, HandlerState}; - {error, timeout} -> - %% No data yet, schedule retry - erlang:send_after(?CHUNK_COALESCE_TIMEOUT, self(), drain_response), - {ok, Req, State} - end; - %% Handle error from Python info({<<"error">>, Reason}, Req, #state{req_info = ReqInfo, handler_state = HandlerState} = State) -> maybe_close_channel(State#state.req_body_ch), - maybe_close_channel(State#state.resp_body_ch), {StatusCode, Body} = hornbeam_http_hooks:run_on_error(Reason, ReqInfo), Req2 = cowboy_req:reply(StatusCode, #{<<"content-type">> => <<"text/plain">>}, @@ -174,12 +159,10 @@ info({<<"error">>, Reason}, Req, #state{req_info = ReqInfo, handler_state = Hand %% Handle async task completion info({async_result, _Ref, {ok, _}}, Req, State) -> - %% Task completed, continue draining response {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), - maybe_close_channel(State#state.resp_body_ch), {StatusCode, Body} = hornbeam_http_hooks:run_on_error(Reason, ReqInfo), Req2 = cowboy_req:reply(StatusCode, #{<<"content-type">> => <<"text/plain">>}, @@ -189,7 +172,6 @@ info({async_result, _Ref, {error, Reason}}, Req, #state{req_info = ReqInfo, hand %% Handle timeout info(timeout, Req, #state{req_info = ReqInfo, handler_state = HandlerState} = State) -> maybe_close_channel(State#state.req_body_ch), - maybe_close_channel(State#state.resp_body_ch), {StatusCode, Body} = hornbeam_http_hooks:run_on_error(timeout, ReqInfo), Req2 = cowboy_req:reply(StatusCode, #{<<"content-type">> => <<"text/plain">>}, @@ -218,10 +200,8 @@ push_to_channel(Channel, Data) -> ok end. -drain_response_chunk(RespBodyCh) -> - py_byte_channel:recv(RespBodyCh, ?CHUNK_COALESCE_TIMEOUT). - maybe_close_channel(undefined) -> ok; +maybe_close_channel(empty) -> ok; maybe_close_channel(Channel) -> try case py_byte_channel:info(Channel) of From 76c17a830019cfad50ba9e8401623e0fc55f0d24 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Fri, 20 Mar 2026 13:42:20 +0100 Subject: [PATCH 40/52] Unify ASGI handler, remove old code - Rename hornbeam_asgi_loop to hornbeam_asgi - Remove old ASGI handler code from hornbeam_handler.erl - Use direct reply for responses with Content-Length - Use chunked encoding for responses without Content-Length - Fix empty body handling: check Transfer-Encoding too - Simplify Python worker: no buffering, read channel directly Removes ~900 lines, all 38 ASGI tests pass. --- priv/hornbeam_asgi_loop.py | 228 ------------- priv/hornbeam_asgi_worker.py | 587 ++++++++-------------------------- src/hornbeam.erl | 3 - src/hornbeam_asgi.erl | 385 +++++++++++++++------- src/hornbeam_asgi_loop.erl | 283 ---------------- src/hornbeam_context_pool.erl | 5 +- src/hornbeam_handler.erl | 255 +-------------- 7 files changed, 409 insertions(+), 1337 deletions(-) delete mode 100644 priv/hornbeam_asgi_loop.py delete mode 100644 src/hornbeam_asgi_loop.erl diff --git a/priv/hornbeam_asgi_loop.py b/priv/hornbeam_asgi_loop.py deleted file mode 100644 index 086cd40..0000000 --- a/priv/hornbeam_asgi_loop.py +++ /dev/null @@ -1,228 +0,0 @@ -# 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. - -Simple design: -- Request body: read from Erlang channel in receive() -- Response: send via erlang.send() directly to Cowboy handler -- No buffering, no extra tasks -""" - -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 - - -class ASGIProtocol: - """ASGI handler using Erlang channels for request body. - - Simple design: read from channel directly in receive(), no buffering. - """ - - def __init__(self, caller_pid, app: Callable, scope: dict, - req_channel: Optional[ByteChannel]): - self._caller_pid = caller_pid - self._app = app - self._scope = scope - self._req_channel = req_channel # None for no-body requests - self._send_fn = _erlang_send - - # Response state - self._response_started = False - self._response_finished = False - - # ========================================================================= - # ASGI interface - # ========================================================================= - - async def receive(self) -> dict: - """ASGI receive callable - reads directly from channel.""" - # No body case - if self._req_channel is None: - return {'type': 'http.request', 'body': b'', 'more_body': False} - - # Read from channel - try: - chunk = await self._req_channel.async_receive_bytes() - return { - 'type': 'http.request', - 'body': chunk if chunk else b'', - 'more_body': True, - } - except ByteChannelClosed: - return {'type': 'http.request', 'body': b'', 'more_body': False} - - async def send(self, message: dict) -> None: - """ASGI send callable. - - Uses erlang.send() directly for all response data - no channel overhead. - """ - 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") - - status = message.get('status', 200) - headers = message.get('headers', []) - self._send_fn(self._caller_pid, (b'headers', status, 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) - - # Send body directly via erlang.send - no channel overhead - self._send_fn(self._caller_pid, (b'body', body, more_body)) - - if not more_body: - 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: - self._send_fn(self._caller_pid, (b'headers', 500, [])) - self._send_fn(self._caller_pid, (b'body', b'', False)) - - except Exception as e: - self._send_fn(self._caller_pid, (b'error', str(e).encode('utf-8'))) - - -# ============================================================================= -# Entry point -# ============================================================================= - -async def handle_asgi_loop(caller_pid, app_module: str, app_callable: str, - scope: dict, req_body_ch): - """Handle ASGI request using Protocol pattern. - - Entry point called from hornbeam_asgi_loop.erl. - Response is sent directly via erlang.send() - no channel needed. - """ - if not HAS_ERLANG: - return - - # Wrap request channel (may be 'empty' atom for no-body requests) - if req_body_ch == 'empty' or req_body_ch == b'empty': - req_channel = None - else: - req_channel = ByteChannel(req_body_ch) - - # Get app - app = _get_app(app_module, app_callable) - - # Wrap scope['state'] if present - if 'state' in scope: - mount_id = scope.get('mount_id') - scope['state'] = _MutableStateProxy(scope['state'], mount_id) - - # Create and run protocol - protocol = ASGIProtocol(caller_pid, app, scope, req_channel) - await protocol.run() - - -# ============================================================================= -# Helpers -# ============================================================================= - -_preloaded_app: Callable = None -_preloaded_key: tuple = None - - -def preload_app(app_module: str, app_callable: str) -> bytes: - """Preload ASGI application at startup.""" - global _preloaded_app, _preloaded_key - - 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 ASGI application.""" - global _preloaded_app, _preloaded_key - - if _preloaded_key == (module_name, callable_name): - return _preloaded_app - - import importlib - module = importlib.import_module(module_name) - return getattr(module, callable_name) - - -class _MutableStateProxy(dict): - """Dict that syncs mutations to Erlang ETS.""" - - __slots__ = ('_mount_id', '_lifespan_pid') - - def __init__(self, initial_state: dict, mount_id=None): - super().__init__(initial_state) - self._mount_id = mount_id - self._lifespan_pid = None - if HAS_ERLANG: - try: - self._lifespan_pid = erlang.whereis("hornbeam_lifespan") - except Exception: - pass - - def __setitem__(self, key, value): - super().__setitem__(key, value) - if self._lifespan_pid is not None: - try: - if self._mount_id is not None: - _erlang_send(self._lifespan_pid, - (b'update_state', self._mount_id, key, value)) - else: - _erlang_send(self._lifespan_pid, (b'update_state', key, value)) - except Exception: - pass diff --git a/priv/hornbeam_asgi_worker.py b/priv/hornbeam_asgi_worker.py index 16a65fc..11efdfd 100644 --- a/priv/hornbeam_asgi_worker.py +++ b/priv/hornbeam_asgi_worker.py @@ -12,33 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""High-performance ASGI worker using py_event_loop. +"""ASGI handler using Cowboy loop handler with direct message passing. -This module provides an ASGI worker that uses the Erlang event loop -for true async execution. It supports: - -- Full async execution via py_event_loop -- Streaming responses via erlang.send() -- Sub-millisecond latency using Erlang timers -- No message passing overhead for continuations - -Architecture: -1. Erlang submits task to py_event_loop:create_task() -2. Python runs async app with Erlang-backed event loop -3. Response streamed via erlang.send() as chunks arrive -4. Event-driven - no polling, no busy waiting +Simple design: +- Request body: read from Erlang channel in receive() +- Response: send via erlang.send() directly to Cowboy handler +- No buffering, no extra tasks """ -import asyncio -import importlib -import sys -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Callable, Optional try: import erlang from erlang import ByteChannel, ByteChannelClosed HAS_ERLANG = True - # Cache erlang.send for faster lookups (avoids attribute access per call) _erlang_send = erlang.send except ImportError: HAS_ERLANG = False @@ -48,500 +35,194 @@ ByteChannelClosed = Exception -class _MutableStateProxy(dict): - """A dict subclass that syncs mutations back to Erlang ETS. - - When items are set, the change is sent to hornbeam_lifespan process - which persists it to ETS. Subsequent requests see the updated value. - - This implements ASGI-compliant mutable scope['state'] behavior. - """ - - __slots__ = ('_mount_id', '_lifespan_pid') - - def __init__(self, initial_state: dict, mount_id: Optional[str] = None): - super().__init__(initial_state) - self._mount_id = mount_id - # Lookup hornbeam_lifespan PID once at construction - self._lifespan_pid = None - if HAS_ERLANG: - try: - self._lifespan_pid = erlang.whereis("hornbeam_lifespan") - except Exception: - pass - - def __setitem__(self, key, value): - # Update local dict - super().__setitem__(key, value) - # Send update to Erlang (fire-and-forget) - if self._lifespan_pid is not None: - try: - if self._mount_id is not None: - # Multi-app mode: {update_state, MountId, Key, Value} - _erlang_send(self._lifespan_pid, - (b'update_state', self._mount_id, key, value)) - else: - # Single-app mode: {update_state, Key, Value} - _erlang_send(self._lifespan_pid, - (b'update_state', key, value)) - except Exception: - pass # Don't fail request if state sync fails - - def update(self, other=None, **kwargs): - """Override update to sync each key.""" - if other: - for k, v in (other.items() if hasattr(other, 'items') else other): - self[k] = v - for k, v in kwargs.items(): - self[k] = v - - def setdefault(self, key, default=None): - """Override setdefault to sync if key is added.""" - if key not in self: - self[key] = default - return self[key] - - -# ============================================================================ -# 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: str, app_callable: str) -> bytes: - """Preload ASGI application at startup for zero-overhead access.""" - global _preloaded_app, _preloaded_key - - # erlang_python converts binaries to str automatically (UTF-8 decode in C) - module_name = app_module - callable_name = app_callable - - module = importlib.import_module(module_name) - app = getattr(module, callable_name) - - _preloaded_app = app - _preloaded_key = (module_name, callable_name) - - return b'ok' - - -def _get_app(module_name: str, callable_name: str) -> Callable: - """Get ASGI application - uses preloaded app if available.""" - if _preloaded_key == (module_name, callable_name): - return _preloaded_app - - # Fallback: import on demand - module = importlib.import_module(module_name) - return getattr(module, callable_name) - - -# ============================================================================ -# Helpers -# ============================================================================ - -def _to_bytes(val) -> bytes: - """Convert string to bytes.""" - if isinstance(val, bytes): - return val - if isinstance(val, str): - return val.encode('utf-8') - return b'' - - - - -# ============================================================================ -# Pre-allocated messages -# ============================================================================ - -_DISCONNECT_MSG = {'type': 'http.disconnect'} - - -# ============================================================================ -# Main entry point: handle_asgi (async) -# ============================================================================ - -async def handle_asgi(caller_pid, app_module: bytes, app_callable: bytes, - scope: dict, body_ref, resp_body_ch): - """Handle an ASGI request asynchronously. - - This is the main entry point called from Erlang via py_event_loop:create_task. - Uses byte channels for body data and erlang.send() for control messages. - - Transport design: - - Control plane (mailbox): headers, early_hints, error, response (small) - - Data plane (byte channels): response body (RespBodyCh) - - Request body: passed as body_ref (see below) - - Args: - caller_pid: Erlang PID to send control messages to - app_module: Python module containing ASGI app (bytes) - app_callable: Name of ASGI callable in module (bytes) - scope: ASGI scope dict - body_ref: One of: - - 'empty' or b'empty': no body - - ('body', binary) or (b'body', binary): small body passed directly - - ('channel', ref) or (b'channel', ref): large body via ByteChannel - resp_body_ch: ByteChannel reference for writing response body - """ - if not HAS_ERLANG: - return - - # erlang_python converts binaries to str in C (UTF-8 decode) - module_name = app_module - callable_name = app_callable - - # Wrap response channel reference - resp_channel = ByteChannel(resp_body_ch) - - try: - # Get app (preloaded or import on demand) - app = _get_app(module_name, callable_name) - - # Wrap scope['state'] with mutable proxy that syncs to Erlang ETS - # This implements ASGI-compliant mutable state behavior - if 'state' in scope: - mount_id = scope.get('mount_id') - scope['state'] = _MutableStateProxy(scope['state'], mount_id) - - # Create receive/send callables - # _ASGIReceive handles the three body modes based on body_ref type - receive = _ASGIReceive(body_ref) - send = _ASGISend(caller_pid, resp_channel) - - # Run ASGI app - await app(scope, receive, send) - - # Ensure completion is signaled - if not send.finished: - if not send.headers_sent: - _erlang_send(caller_pid, (b'headers', 500, [])) - # close the channel to signal EOF - try: - resp_channel.close() - except ByteChannelClosed: - pass - except Exception as e: - try: - # close the channel, then send error - resp_channel.close() - _erlang_send(caller_pid, (b'error', str(e).encode('utf-8'))) - except Exception: - pass - - -class _ASGIReceive: - """ASGI receive callable with three body modes. - - Handles request body based on how Erlang passed it: - - 'empty': No body (GET, HEAD, etc.) - - ('body', binary): Small body passed directly (< 64KB) - - ('channel', ref): Large body streamed via ByteChannel +class ASGIProtocol: + """ASGI handler using Erlang channels for request body. - The small body optimization eliminates channel/pump overhead for ~95% of requests. + Simple design: read from channel directly in receive(), no buffering. """ - __slots__ = ('_mode', '_body', '_channel', 'disconnected', '_eof_reached') - def __init__(self, body_ref): - """Initialize receive callable. - - Args: - body_ref: One of: - - 'empty' or b'empty': no body - - ('body', binary) or (b'body', binary): small body - - ('channel', ref) or (b'channel', ref): large body via channel - """ - self.disconnected = False - self._eof_reached = False - self._body = None - self._channel = None - - # Detect body mode from body_ref type - # Note: erlang_python converts atoms to strings and binaries to str - if body_ref == 'empty' or body_ref == b'empty': - # No body - self._mode = 'empty' - self._eof_reached = True - elif isinstance(body_ref, tuple) and len(body_ref) >= 2: - tag = body_ref[0] - if tag == 'body' or tag == b'body': - # Small body passed directly - # erlang_python may convert binary to str, so use _to_bytes - self._mode = 'body' - self._body = _to_bytes(body_ref[1]) - elif tag == 'channel' or tag == b'channel': - # Large body via ByteChannel - self._mode = 'channel' - self._channel = ByteChannel(body_ref[1]) - else: - # Unknown tuple - treat as channel ref for backward compat - self._mode = 'channel' - self._channel = ByteChannel(body_ref) - else: - # Raw channel ref (backward compatibility) - self._mode = 'channel' - self._channel = ByteChannel(body_ref) - - async def __call__(self) -> dict: - if self.disconnected: - return _DISCONNECT_MSG - - if self._eof_reached: - # Already reached EOF - return disconnect - self.disconnected = True - return _DISCONNECT_MSG - - if self._mode == 'empty': - # No body - return empty request, mark EOF - self._eof_reached = True + def __init__(self, caller_pid, app: Callable, scope: dict, + req_channel: Optional[ByteChannel]): + self._caller_pid = caller_pid + self._app = app + self._scope = scope + self._req_channel = req_channel # None for no-body requests + self._send_fn = _erlang_send + + # Response state + self._response_started = False + self._response_finished = False + + # ========================================================================= + # ASGI interface + # ========================================================================= + + async def receive(self) -> dict: + """ASGI receive callable - reads directly from channel.""" + # No body case + if self._req_channel is None: return {'type': 'http.request', 'body': b'', 'more_body': False} - if self._mode == 'body': - # Small body fast path - return entire body at once - self._eof_reached = True - return {'type': 'http.request', 'body': self._body, 'more_body': False} - - # Channel mode - stream chunks from ByteChannel - return await self._read_channel() - - async def _read_channel(self) -> dict: - """Read chunk from channel (large body streaming).""" + # Read from channel try: - chunk = await self._channel.async_receive_bytes() - if not chunk: - self._eof_reached = True - return {'type': 'http.request', 'body': b'', 'more_body': False} - # Got data - return with more_body=True (may have more) - return {'type': 'http.request', 'body': chunk, 'more_body': True} + chunk = await self._req_channel.async_receive_bytes() + return { + 'type': 'http.request', + 'body': chunk if chunk else b'', + 'more_body': True, + } except ByteChannelClosed: - # Channel closed = EOF - self._eof_reached = True return {'type': 'http.request', 'body': b'', 'more_body': False} + async def send(self, message: dict) -> None: + """ASGI send callable. -class _ASGISend: - """ASGI send callable using ByteChannel for response body. - - Transport design: - - Control plane (erlang.send): headers, early_hints - - Data plane (ByteChannel): response body streaming - """ - __slots__ = ('caller_pid', 'resp_channel', 'status', 'headers', - 'headers_sent', 'finished', '_send') - - def __init__(self, caller_pid, resp_channel): - """Initialize send callable. - - Args: - caller_pid: Erlang PID for control messages - resp_channel: ByteChannel for writing response body + Uses erlang.send() directly for all response data - no channel overhead. """ - self.caller_pid = caller_pid - self.resp_channel = resp_channel - self._send = _erlang_send # Cached function reference - self.status = None - self.headers = [] - self.headers_sent = False - self.finished = False - - async def __call__(self, message: dict) -> None: - if self.finished: + if self._response_finished: raise RuntimeError("Response already completed") msg_type = message.get('type', '') if msg_type == 'http.response.start': - if self.headers_sent: + if self._response_started: raise RuntimeError("http.response.start already sent") - self.status = message.get('status', 200) - self.headers = message.get('headers', []) - self._send(self.caller_pid, (b'headers', self.status or 200, self.headers)) - self.headers_sent = True + + status = message.get('status', 200) + headers = message.get('headers', []) + self._send_fn(self._caller_pid, (b'headers', status, headers)) + self._response_started = True elif msg_type == 'http.response.body': - if not self.headers_sent: - raise RuntimeError("http.response.start must be sent before http.response.body") + if not self._response_started: + raise RuntimeError("http.response.start must be sent first") - body_part = message.get('body', b'') - if isinstance(body_part, str): - body_part = body_part.encode('utf-8') + body = message.get('body', b'') + if isinstance(body, str): + body = body.encode('utf-8') more_body = message.get('more_body', False) - # Send body directly to channel - try: - self.resp_channel.send_bytes(body_part) - except ByteChannelClosed: - self.finished = True - raise OSError("Client disconnected") + # Send body directly via erlang.send - no channel overhead + self._send_fn(self._caller_pid, (b'body', body, more_body)) if not more_body: - self.resp_channel.close() - self.finished = True + self._response_finished = True elif msg_type == 'http.response.informational': - # Early hints (103) - control message via mailbox status = message.get('status', 100) headers = message.get('headers', []) if status == 103: - self._send(self.caller_pid, (b'early_hints', headers)) + self._send_fn(self._caller_pid, (b'early_hints', headers)) elif msg_type == 'http.disconnect': - self.resp_channel.close() - self.finished = True - - -# ============================================================================ -# Synchronous wrapper for context_call -# ============================================================================ - -def handle_asgi_sync(caller_pid, app_module: bytes, app_callable: bytes, - scope: dict, body_ref, resp_body_ch): - """Synchronous wrapper that runs handle_asgi with erlang.run(). - - This is used when calling from py_nif:context_call() which expects - a synchronous function. Uses erlang.run() for proper Erlang event - loop integration. + self._response_finished = True - Args: - caller_pid: Erlang PID to send control messages to - app_module: Python module containing ASGI app (bytes) - app_callable: Name of ASGI callable in module (bytes) - scope: ASGI scope dict - body_ref: Body reference (see handle_asgi for variants) - resp_body_ch: ByteChannel reference for writing response body - """ - if not HAS_ERLANG: - return b'error' - - try: - # Use erlang.run() for proper Erlang event loop integration - erlang.run(handle_asgi(caller_pid, app_module, app_callable, scope, - body_ref, resp_body_ch)) - return b'done' - except Exception as e: + async def run(self): + """Run the ASGI application.""" try: - _erlang_send(caller_pid, (b'error', str(e).encode('utf-8'))) - except Exception: - pass - return b'error' + await self._app(self._scope, self.receive, self.send) + + # Ensure response is completed + if not self._response_finished: + if not self._response_started: + self._send_fn(self._caller_pid, (b'headers', 500, [])) + self._send_fn(self._caller_pid, (b'body', b'', False)) + except Exception as e: + self._send_fn(self._caller_pid, (b'error', str(e).encode('utf-8'))) -# ============================================================================ -# WebSocket support -# ============================================================================ -async def handle_websocket(caller_pid, app_module: bytes, app_callable: bytes, - scope: dict, channel_ref): - """Handle a WebSocket connection. +# ============================================================================= +# Entry point +# ============================================================================= - WebSocket messages are received from and sent to the channel. - The app can send/receive messages asynchronously. +async def handle_asgi(caller_pid, app_module: str, app_callable: str, + scope: dict, req_body_ch): + """Handle ASGI request. - Args: - caller_pid: Erlang PID for control messages - app_module: Python module containing ASGI app (bytes) - app_callable: Name of ASGI callable in module (bytes) - scope: ASGI scope dict with type='websocket' - channel_ref: Channel reference for WebSocket messages + Entry point called from hornbeam_asgi_loop.erl. + Response is sent directly via erlang.send(). """ if not HAS_ERLANG: return - from erlang import Channel, ChannelClosed + # Wrap request channel (may be 'empty' atom for no-body requests) + if req_body_ch == 'empty' or req_body_ch == b'empty': + req_channel = None + else: + req_channel = ByteChannel(req_body_ch) - # erlang_python converts binaries to str in C (UTF-8 decode) - module_name = app_module - callable_name = app_callable + # Get app + app = _get_app(app_module, app_callable) - channel = Channel(channel_ref) - connected = False - closed = False + # Wrap scope['state'] if present + if 'state' in scope: + mount_id = scope.get('mount_id') + scope['state'] = _MutableStateProxy(scope['state'], mount_id) - async def receive() -> dict: - nonlocal connected, closed + # Create and run protocol + protocol = ASGIProtocol(caller_pid, app, scope, req_channel) + await protocol.run() - if closed: - return {'type': 'websocket.disconnect', 'code': 1000} - - try: - msg = channel.receive(timeout=60000) - if isinstance(msg, tuple): - tag = msg[0] - - if tag == b'connect' or tag == 'connect': - connected = True - return {'type': 'websocket.connect'} - - elif tag == b'text' or tag == 'text': - # Text from channel - already str from erlang_python - return {'type': 'websocket.receive', 'text': msg[1]} - - elif tag == b'bytes' or tag == 'bytes': - return {'type': 'websocket.receive', 'bytes': _to_bytes(msg[1])} +# ============================================================================= +# Helpers +# ============================================================================= - elif tag == b'disconnect' or tag == 'disconnect': - code = msg[1] if len(msg) > 1 else 1000 - closed = True - return {'type': 'websocket.disconnect', 'code': code} +_preloaded_app: Callable = None +_preloaded_key: tuple = None - elif msg == b'connect' or msg == 'connect': - connected = True - return {'type': 'websocket.connect'} - except ChannelClosed: - closed = True - return {'type': 'websocket.disconnect', 'code': 1006} +def preload_app(app_module: str, app_callable: str) -> bytes: + """Preload ASGI application at startup.""" + global _preloaded_app, _preloaded_key - return {'type': 'websocket.disconnect', 'code': 1000} + import importlib + module = importlib.import_module(app_module) + app = getattr(module, app_callable) - async def send(message: dict) -> None: - nonlocal connected, closed + _preloaded_app = app + _preloaded_key = (app_module, app_callable) - msg_type = message.get('type', '') + return b'ok' - if msg_type == 'websocket.accept': - connected = True - subprotocol = message.get('subprotocol') - headers = message.get('headers', []) - _erlang_send(caller_pid, (b'accept', subprotocol, headers)) - elif msg_type == 'websocket.send': - if 'text' in message: - _erlang_send(caller_pid, (b'text', message['text'])) - elif 'bytes' in message: - _erlang_send(caller_pid, (b'bytes', message['bytes'])) +def _get_app(module_name: str, callable_name: str) -> Callable: + """Get ASGI application.""" + global _preloaded_app, _preloaded_key - elif msg_type == 'websocket.close': - code = message.get('code', 1000) - reason = message.get('reason', '') - _erlang_send(caller_pid, (b'close', code, reason)) - closed = True + if _preloaded_key == (module_name, callable_name): + return _preloaded_app - try: - app = _get_app(module_name, callable_name) - await app(scope, receive, send) + import importlib + module = importlib.import_module(module_name) + return getattr(module, callable_name) - # Ensure close is sent - if not closed: - _erlang_send(caller_pid, (b'close', 1000, b'')) - except Exception as e: - try: - _erlang_send(caller_pid, (b'error', str(e).encode('utf-8'))) - except Exception: - pass +class _MutableStateProxy(dict): + """Dict that syncs mutations to Erlang ETS.""" + __slots__ = ('_mount_id', '_lifespan_pid') -def handle_request_direct(args_tuple) -> None: - """Legacy entry point - uses sync wrapper.""" - if not HAS_ERLANG: - return + def __init__(self, initial_state: dict, mount_id=None): + super().__init__(initial_state) + self._mount_id = mount_id + self._lifespan_pid = None + if HAS_ERLANG: + try: + self._lifespan_pid = erlang.whereis("hornbeam_lifespan") + except Exception: + pass - caller_pid, app_module, app_callable, scope, body_ref, resp_body_ch = args_tuple - handle_asgi_sync(caller_pid, app_module, app_callable, scope, - body_ref, resp_body_ch) + def __setitem__(self, key, value): + super().__setitem__(key, value) + if self._lifespan_pid is not None: + try: + if self._mount_id is not None: + _erlang_send(self._lifespan_pid, + (b'update_state', self._mount_id, key, value)) + else: + _erlang_send(self._lifespan_pid, (b'update_state', key, value)) + except Exception: + pass diff --git a/src/hornbeam.erl b/src/hornbeam.erl index 0d7e023..3611b98 100644 --- a/src/hornbeam.erl +++ b/src/hornbeam.erl @@ -641,9 +641,6 @@ maybe_run_lifespan_startup(asgi, Config) -> off -> ok; _ -> hornbeam_lifespan:startup(#{lifespan => LifespanMode}) end; -maybe_run_lifespan_startup(asgi_loop, Config) -> - %% asgi_loop uses same lifespan handling as asgi - maybe_run_lifespan_startup(asgi, Config); maybe_run_lifespan_startup(wsgi, _Config) -> %% WSGI doesn't support lifespan ok. diff --git a/src/hornbeam_asgi.erl b/src/hornbeam_asgi.erl index 003c853..b49c016 100644 --- a/src/hornbeam_asgi.erl +++ b/src/hornbeam_asgi.erl @@ -12,142 +12,293 @@ %% See the License for the specific language governing permissions and %% limitations under the License. -%%% @doc ASGI scope builder and protocol handling. +%%% @doc ASGI handler using Cowboy loop handler. %%% -%%% 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 using Cowboy's async body +%%% reading via loop handlers. Request body is streamed via channels, +%%% response is sent directly via erlang.send(). %%% -%%% == 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, #{}). +%% 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, + headers_sent = false, + buffered_headers, %% {StatusCode, Headers, HasContentLength} or undefined + handler_state %% Original handler state from init +}). -%% @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 +%%% ============================================================================ +%%% 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), + + %% 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) + {ReqBodyCh, BodyRef} = case HasBody of + true -> + {ok, Ch} = py_byte_channel:new(), + Ref = make_ref(), + %% Start async body reading - Cowboy will send us messages + cowboy_req:cast({read_body, self(), Ref, auto, infinity}, Req), + {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) + %% Create Python task (response sent via erlang.send, no channel needed) + _TaskRef = py_event_loop_pool:create_task( + <<"hornbeam_asgi_worker">>, <<"handle_asgi">>, + [self(), AppModule, AppCallable, Scope, ReqBodyCh]), + + 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, + handler_state = HandlerState + }, + + %% Return cowboy_loop to enable loop handler + {cowboy_loop, Req, State, TimeoutMs}. + +%% Handle async body chunks from Cowboy +info({request_body, Ref, nofin, Data}, Req, #state{body_ref = Ref, req_body_ch = Ch} = State) -> + %% More body data coming - push to channel + ok = push_to_channel(Ch, Data), + %% Request more data + cowboy_req:cast({read_body, self(), Ref, auto, infinity}, Req), + {ok, Req, State}; + +info({request_body, Ref, fin, _BodyLen, Data}, Req, #state{body_ref = Ref, req_body_ch = Ch} = State) -> + %% Final chunk - push and close channel + case Data of + <<>> -> ok; + _ -> ok = push_to_channel(Ch, Data) + end, + py_byte_channel:close(Ch), + {ok, Req, State#state{body_ref = undefined}}; + +%% Handle response headers from Python +%% Buffer headers until we get body - then decide reply vs stream based on content-length +info({<<"headers">>, StatusCode, Headers}, Req, State) -> + SafeHeaders = filter_hop_by_hop(Headers), + CowboyHeaders = convert_headers(SafeHeaders), + HasContentLength = maps:is_key(<<"content-length">>, CowboyHeaders), + {ok, Req, State#state{ + headers_sent = false, + buffered_headers = {StatusCode, CowboyHeaders, HasContentLength} + }}; + +%% Handle response body from Python +info({<<"body">>, Body, MoreBody}, Req, #state{handler_state = HandlerState, + buffered_headers = BufferedHeaders} = State) -> + BodyBin = to_binary(Body), + case {BufferedHeaders, MoreBody} of + %% First body chunk with buffered headers + {{StatusCode, CowboyHeaders, true}, false} -> + %% Has Content-Length and no more body - use reply (not streaming) + Req2 = cowboy_req:reply(StatusCode, CowboyHeaders, BodyBin, Req), + maybe_close_channel(State#state.req_body_ch), + {stop, Req2, HandlerState}; + {{StatusCode, CowboyHeaders, _HasCL}, _} -> + %% Streaming response - start with stream_reply + Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req), + case MoreBody of + true -> + case BodyBin of + <<>> -> ok; + _ -> ok = cowboy_req:stream_body(BodyBin, nofin, Req2) + end, + {ok, Req2, State#state{headers_sent = true, buffered_headers = undefined}}; + false -> + ok = cowboy_req:stream_body(BodyBin, fin, Req2), + maybe_close_channel(State#state.req_body_ch), + {stop, Req2, HandlerState} + end; + %% Subsequent body chunks (headers already sent) + {undefined, true} -> + ok = cowboy_req:stream_body(BodyBin, nofin, Req), + {ok, Req, State}; + {undefined, false} -> + ok = cowboy_req:stream_body(BodyBin, fin, Req), + maybe_close_channel(State#state.req_body_ch), + {stop, Req, HandlerState} + end; + +%% 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(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 -%% 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)). +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 -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). +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 +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_asgi_loop.erl b/src/hornbeam_asgi_loop.erl deleted file mode 100644 index 6a51cf2..0000000 --- a/src/hornbeam_asgi_loop.erl +++ /dev/null @@ -1,283 +0,0 @@ -%% 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 ASGI handler using Cowboy loop handler with push/pull body streaming. -%%% -%%% This module implements ASGI request handling using Cowboy's async body -%%% reading via loop handlers. Instead of spawning a pump process, it uses -%%% cowboy_req:cast to receive body chunks as messages, which are then -%%% pushed to Python via channel. -%%% -%%% Architecture: -%%% - Cowboy sends {request_body, Ref, nofin|fin, Data} messages -%%% - Loop handler info/3 pushes chunks to Python channel -%%% - Python buffers chunks and signals via asyncio.Event -%%% - ASGI receive() pulls from buffer -%%% -%%% Benefits over pump process approach: -%%% - No extra process spawn per request -%%% - Event-driven data flow -%%% - Natural backpressure via channel -%%% - Better integration with Cowboy's internals -%%% -%%% @end --module(hornbeam_asgi_loop). - --behaviour(cowboy_loop). - --export([init/2, info/3, terminate/3]). - -%% 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, - headers_sent = false, - 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), - - %% Build ASGI scope - Scope = hornbeam_request:build_asgi_scope(Req, HandlerState), - - %% Check if request has a body - Method = cowboy_req:method(Req), - ContentLength = get_content_length(Req), - HasBody = has_request_body(Method, ContentLength), - - %% Create request body channel only if body exists (skip for GET/no-body) - {ReqBodyCh, BodyRef} = case HasBody of - true -> - {ok, Ch} = py_byte_channel:new(), - Ref = make_ref(), - %% Start async body reading - Cowboy will send us messages - cowboy_req:cast({read_body, self(), Ref, auto, infinity}, Req), - {Ch, Ref}; - false -> - %% No body - pass empty marker, skip channel - {empty, undefined} - end, - - %% Create Python task (response sent via erlang.send, no channel needed) - _TaskRef = py_event_loop_pool:create_task( - <<"hornbeam_asgi_loop">>, <<"handle_asgi_loop">>, - [self(), AppModule, AppCallable, Scope, ReqBodyCh]), - - 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, - handler_state = HandlerState - }, - - %% Return cowboy_loop to enable loop handler - {cowboy_loop, Req, State, TimeoutMs}. - -%% Handle async body chunks from Cowboy -info({request_body, Ref, nofin, Data}, Req, #state{body_ref = Ref, req_body_ch = Ch} = State) -> - %% More body data coming - push to channel - ok = push_to_channel(Ch, Data), - %% Request more data - cowboy_req:cast({read_body, self(), Ref, auto, infinity}, Req), - {ok, Req, State}; - -info({request_body, Ref, fin, _BodyLen, Data}, Req, #state{body_ref = Ref, req_body_ch = Ch} = State) -> - %% Final chunk - push and close channel - case Data of - <<>> -> ok; - _ -> ok = push_to_channel(Ch, Data) - end, - py_byte_channel:close(Ch), - {ok, Req, State#state{body_ref = undefined}}; - -%% Handle response headers from Python -info({<<"headers">>, StatusCode, Headers}, Req, State) -> - SafeHeaders = filter_hop_by_hop(Headers), - CowboyHeaders = convert_headers(SafeHeaders), - Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req), - {ok, Req2, State#state{headers_sent = true}}; - -%% Handle response body from Python (sent directly via erlang.send) -info({<<"body">>, Body, MoreBody}, Req, #state{handler_state = HandlerState} = State) -> - BodyBin = to_binary(Body), - case MoreBody of - true -> - ok = cowboy_req:stream_body(BodyBin, nofin, Req), - {ok, Req, State}; - false -> - ok = cowboy_req:stream_body(BodyBin, fin, Req), - maybe_close_channel(State#state.req_body_ch), - {stop, Req, HandlerState} - end; - -%% 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. - -maybe_close_channel(undefined) -> ok; -maybe_close_channel(empty) -> ok; -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 -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 -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_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 -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 -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_context_pool.erl b/src/hornbeam_context_pool.erl index 69c4c0e..f42a92b 100644 --- a/src/hornbeam_context_pool.erl +++ b/src/hornbeam_context_pool.erl @@ -115,7 +115,7 @@ add_paths(Paths) when is_list(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 | asgi_loop, binary(), binary()) -> ok. +-spec preload_app(wsgi | asgi, binary(), binary()) -> ok. preload_app(WorkerClass, AppModule, AppCallable) -> gen_server:call(?MODULE, {preload_app, WorkerClass, AppModule, AppCallable}). @@ -162,8 +162,7 @@ handle_call({preload_app, WorkerClass, AppModule, AppCallable}, _From, %% Preload app in all contexts WorkerModule = case WorkerClass of wsgi -> <<"hornbeam_wsgi_worker">>; - asgi -> <<"hornbeam_asgi_worker">>; - asgi_loop -> <<"hornbeam_asgi_loop">> + asgi -> <<"hornbeam_asgi_worker">> end, maps:foreach(fun(_Id, Ref) -> case py_nif:context_call(Ref, WorkerModule, <<"preload_app">>, diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl index dc08f32..481a732 100644 --- a/src/hornbeam_handler.erl +++ b/src/hornbeam_handler.erl @@ -29,7 +29,7 @@ -behaviour(cowboy_loop). -export([init/2]). -%% Loop handler callback (for asgi_loop mode) +%% 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]). @@ -77,15 +77,7 @@ handle_request(asgi, Req, State) -> true -> handle_websocket_upgrade(Req, State); false -> - handle_asgi(Req, State) - end; -handle_request(asgi_loop, Req, State) -> - %% ASGI with loop handler (experimental push/pull pattern) - case is_websocket_upgrade(Req) of - true -> - handle_websocket_upgrade(Req, State); - false -> - hornbeam_asgi_loop:init(Req, State) + hornbeam_asgi:init(Req, State) end. %% @private @@ -272,243 +264,6 @@ filter_hop_by_hop(Headers) -> not lists:member(LowerName, ?HOP_BY_HOP_HEADERS) end, Headers). -%%% ============================================================================ -%%% ASGI Handler - uses py_event_loop for full async with byte channels -%%% ============================================================================ - --define(ASGI_BODY_CHUNK_SIZE, 65536). %% 64KB chunks for request body --define(ASGI_BODY_BUFFER_THRESHOLD, 65536). %% 64KB - bodies smaller than this are passed directly - -%% Chunk coalescing for response body streaming --define(CHUNK_COALESCE_SIZE, 4096). %% 4KB threshold before flushing --define(CHUNK_COALESCE_TIMEOUT, 1). %% 1ms max wait for more chunks - -handle_asgi(Req, State) -> - ReqInfo = build_request_info(Req), - ReqInfo1 = hornbeam_http_hooks:run_on_request(ReqInfo), - - try - AppModule = maps:get(app_module, State), - AppCallable = maps:get(app_callable, State), - TimeoutMs = maps:get(timeout, State, 30000), - - %% Build ASGI scope (unified builder in hornbeam_request) - Scope = hornbeam_request:build_asgi_scope(Req, State), - - %% Create response body channel - %% RespBodyCh: Python writes response body, Erlang reads - {ok, RespBodyCh} = py_byte_channel:new(), - - %% Check if request has a body - Method = cowboy_req:method(Req), - ContentLength = get_content_length(Req), - HasBody = has_request_body(Method, ContentLength), - - %% Determine body handling strategy based on size: - %% - No body: pass 'empty' atom - %% - Small body (<64KB with known length): read sync, pass {body, {bytes, Binary}} - %% - Large body or unknown length: use channel with pump process - %% Note: {bytes, Binary} ensures Python receives bytes, not str (see erlang_python) - {BodyRef, BodyPumpPid} = case HasBody of - false -> - %% No body - pass empty marker - {empty, undefined}; - true when is_integer(ContentLength), ContentLength < ?ASGI_BODY_BUFFER_THRESHOLD -> - %% Small body - read synchronously, pass binary directly - %% Wrap in {bytes, _} to ensure Python receives bytes, not str - {ok, Body, _Req2} = cowboy_req:read_body(Req), - {{body, {bytes, Body}}, undefined}; - true -> - %% Large body or unknown size - use channel with pump - {ok, ReqBodyCh} = py_byte_channel:new(), - Ref = make_ref(), - HandlerPid = self(), - Pid = spawn(fun() -> - HandlerPid ! {pump_started, Ref}, - pump_request_body(Req, ReqBodyCh, ?ASGI_BODY_CHUNK_SIZE) - end), - ok = wait_pump(Ref), - {{channel, ReqBodyCh}, Pid} - end, - - %% Create async task to run Python ASGI handler - %% Uses event loop pool with process affinity for better distribution - %% Python handler sends control messages via erlang.send() - %% and body data via byte channel (for large bodies) - _TaskRef = py_event_loop_pool:create_task( - <<"hornbeam_asgi_worker">>, <<"handle_asgi">>, - [self(), AppModule, AppCallable, Scope, BodyRef, RespBodyCh]), - - %% Receive response from async Python - Result = receive_asgi_response(Req, ReqInfo1, BodyRef, RespBodyCh, TimeoutMs, State), - - % ensure to kill body pump - % at this point, channels must have been closed, otherwise gc will do its job. - _ = maybe_kill_body_pump(BodyPumpPid), - - Result - 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. - -wait_pump(Ref) -> - receive - {pump_started, Ref} -> ok - after 5000 -> - timeout - end. - - -maybe_kill_body_pump(undefined) -> true; -maybe_kill_body_pump(BodyPumpPid) when is_pid(BodyPumpPid) -> - exit(BodyPumpPid, kill). - - -%% @private -%% Pump request body from Cowboy to byte channel -%% Closes channel to signal EOF -pump_request_body(Req, ReqBodyCh, ChunkSize) -> - try - case cowboy_req:read_body(Req, #{length => ChunkSize}) of - {ok, Chunk, _Req2} -> - write_to_channel_with_backpressure(ReqBodyCh, Chunk), - py_byte_channel:close(ReqBodyCh); - {more, Chunk, Req2} -> - %% More data available - write and continue - write_to_channel_with_backpressure(ReqBodyCh, Chunk), - pump_request_body(Req2, ReqBodyCh, ChunkSize); - _Else -> - py_byte_channel:close(ReqBodyCh) - end - catch - _:_ -> - py_byte_channel:close(ReqBodyCh) - end. - -%% @private -%% Write to channel with backpressure handling -write_to_channel_with_backpressure(Channel, Data) -> - case py_byte_channel:send(Channel, Data) of - ok -> ok; - busy -> - %% Channel full - wait a bit and retry - timer:sleep(1), - write_to_channel_with_backpressure(Channel, Data); - {error, closed} -> - %% Channel closed - stop writing - ok - end. - -%% @private -%% Receive ASGI response from Python worker -%% Control messages come via mailbox, response body via RespBodyCh -%% BodyRef is one of: empty, {body, Binary}, {channel, ReqBodyCh} -receive_asgi_response(Req, ReqInfo, BodyRef, RespBodyCh, TimeoutMs, State) -> - receive - {<<"headers">>, StatusCode, Headers} -> - %% Streaming path: headers first, then drain channel - ok = maybe_close_body_channel(BodyRef), - SafeHeaders = filter_hop_by_hop(Headers), - CowboyHeaders = convert_headers(SafeHeaders), - Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req), - drain_response_channel(Req2, RespBodyCh, TimeoutMs, State); - {<<"early_hints">>, Headers} -> - %% Early hints (103) - HintHeaders = convert_headers(Headers), - Req2 = cowboy_req:inform(103, HintHeaders, Req), - receive_asgi_response(Req2, ReqInfo, BodyRef, RespBodyCh, TimeoutMs, State); - {<<"error">>, Reason} -> - handle_error(Req, Reason, ReqInfo, State); - {async_result, _Ref, {ok, _}} -> - %% Async task completion - continue receiving - receive_asgi_response(Req, ReqInfo, BodyRef, RespBodyCh, TimeoutMs, State); - {async_result, _Ref, {error, Reason}} -> - %% ensure to close channels there - ok = maybe_close_body_channel(BodyRef), - ok = maybe_close_channel(RespBodyCh), - handle_error(Req, Reason, ReqInfo, State) - after TimeoutMs -> - handle_error(Req, timeout, ReqInfo, State) - end. - -%% @private -%% Close body channel if BodyRef contains one -maybe_close_body_channel(empty) -> ok; -maybe_close_body_channel({body, _}) -> ok; -maybe_close_body_channel({channel, Ch}) -> maybe_close_channel(Ch). - -%% @private -%% Maybe close a 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 -%% Drain response body from byte channel and stream to client. -%% Coalesces small chunks to reduce syscall overhead. -drain_response_channel(Req, RespBodyCh, TimeoutMs, State) -> - drain_response_channel(Req, RespBodyCh, TimeoutMs, State, []). - -%% @private -%% Drain with buffer accumulation - coalesce small chunks -drain_response_channel(Req, RespBodyCh, TimeoutMs, State, Buffer) -> - case py_byte_channel:recv(RespBodyCh, ?CHUNK_COALESCE_TIMEOUT) of - {ok, Chunk} -> - NewBuffer = [Chunk | Buffer], - BufferSize = iolist_size(NewBuffer), - if - BufferSize >= ?CHUNK_COALESCE_SIZE -> - %% Flush buffer - threshold reached - ok = cowboy_req:stream_body(iolist_to_binary(lists:reverse(NewBuffer)), nofin, Req), - drain_response_channel(Req, RespBodyCh, TimeoutMs, State, []); - true -> - %% Keep buffering - drain_response_channel(Req, RespBodyCh, TimeoutMs, State, NewBuffer) - end; - {error, timeout} when Buffer =/= [] -> - %% Timeout with buffered data - flush and continue - ok = cowboy_req:stream_body(iolist_to_binary(lists:reverse(Buffer)), nofin, Req), - drain_response_channel(Req, RespBodyCh, TimeoutMs, State, []); - {error, timeout} -> - %% Timeout with no buffer - wait longer - drain_response_channel_wait(Req, RespBodyCh, TimeoutMs, State); - {error, closed} -> - %% EOF - flush remaining buffer - case Buffer of - [] -> ok; - _ -> ok = cowboy_req:stream_body(iolist_to_binary(lists:reverse(Buffer)), nofin, Req) - end, - ok = cowboy_req:stream_body(<<>>, fin, Req), - {ok, Req, State} - end. - -%% @private -%% Wait for data with full timeout when buffer is empty -drain_response_channel_wait(Req, RespBodyCh, TimeoutMs, State) -> - case py_byte_channel:recv(RespBodyCh, TimeoutMs) of - {ok, Chunk} -> - drain_response_channel(Req, RespBodyCh, TimeoutMs, State, [Chunk]); - {error, closed} -> - ok = cowboy_req:stream_body(<<>>, fin, Req), - {ok, Req, State}; - {error, timeout} -> - py_byte_channel:close(RespBodyCh), - ok = cowboy_req:stream_body(<<>>, fin, Req), - {ok, Req, State} - end. - %%% ============================================================================ %%% Response sending %%% ============================================================================ @@ -603,13 +358,13 @@ to_lower_binary(V) when is_atom(V) -> string:lowercase(atom_to_binary(V, utf8)); to_lower_binary(V) -> string:lowercase(to_binary(V)). %%% ============================================================================ -%%% Loop handler callback (for asgi_loop mode) +%%% Loop handler callback (for ASGI) %%% ============================================================================ %% @private -%% Delegate to hornbeam_asgi_loop for loop handler messages +%% Delegate to hornbeam_asgi for ASGI loop handler messages info(Msg, Req, State) -> - hornbeam_asgi_loop:info(Msg, Req, State). + hornbeam_asgi:info(Msg, Req, State). %%% ============================================================================ %%% WebSocket callbacks (delegate to hornbeam_websocket) From 91a8977392c1e3966fd8a39b301788b47f00e24b Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Mon, 23 Mar 2026 23:10:44 +0100 Subject: [PATCH 41/52] Simplify ASGI message protocol and cache cowboy pid/streamid - Cache pid/streamid in state for direct send (avoid map lookups) - Use direct Pid ! message instead of cowboy_req:cast for body reading - Replace 5+ message types with simplified protocol: - start_response: headers + first chunk - chunk: subsequent body chunks - fin: end of response - Remove headers_sent/buffered_headers state fields --- priv/hornbeam_asgi_worker.py | 227 +++++++++++++++++++++++++++-------- src/hornbeam_asgi.erl | 130 ++++++++++---------- 2 files changed, 246 insertions(+), 111 deletions(-) diff --git a/priv/hornbeam_asgi_worker.py b/priv/hornbeam_asgi_worker.py index 11efdfd..4f9dbcc 100644 --- a/priv/hornbeam_asgi_worker.py +++ b/priv/hornbeam_asgi_worker.py @@ -14,12 +14,13 @@ """ASGI handler using Cowboy loop handler with direct message passing. -Simple design: -- Request body: read from Erlang channel in receive() -- Response: send via erlang.send() directly to Cowboy handler -- No buffering, no extra tasks +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: @@ -34,50 +35,157 @@ 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 handler using Erlang channels for request body. + """ASGI protocol handler with response batching (gunicorn-inspired). - Simple design: read from channel directly in receive(), no buffering. + 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 """ - def __init__(self, caller_pid, app: Callable, scope: dict, - req_channel: Optional[ByteChannel]): + __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._req_channel = req_channel # None for no-body requests + self._body_receiver = body_receiver self._send_fn = _erlang_send - # Response state + # Response state (buffer headers for batching) + self._status = None + self._headers = None self._response_started = False self._response_finished = False - # ========================================================================= - # ASGI interface - # ========================================================================= - async def receive(self) -> dict: - """ASGI receive callable - reads directly from channel.""" - # No body case - if self._req_channel is None: - return {'type': 'http.request', 'body': b'', 'more_body': False} - - # Read from channel - try: - chunk = await self._req_channel.async_receive_bytes() - return { - 'type': 'http.request', - 'body': chunk if chunk else b'', - 'more_body': True, - } - except ByteChannelClosed: - return {'type': 'http.request', 'body': b'', 'more_body': False} + """ASGI receive - delegates to BodyReceiver.""" + return await self._body_receiver.receive() async def send(self, message: dict) -> None: - """ASGI send callable. + """ASGI send callable with simplified protocol. - Uses erlang.send() directly for all response data - no channel overhead. + 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") @@ -88,9 +196,9 @@ async def send(self, message: dict) -> None: if self._response_started: raise RuntimeError("http.response.start already sent") - status = message.get('status', 200) - headers = message.get('headers', []) - self._send_fn(self._caller_pid, (b'headers', status, headers)) + # 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': @@ -103,10 +211,19 @@ async def send(self, message: dict) -> None: more_body = message.get('more_body', False) - # Send body directly via erlang.send - no channel overhead - self._send_fn(self._caller_pid, (b'body', body, more_body)) + 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': @@ -126,9 +243,23 @@ async def run(self): # Ensure response is completed if not self._response_finished: if not self._response_started: - self._send_fn(self._caller_pid, (b'headers', 500, [])) - self._send_fn(self._caller_pid, (b'body', b'', False)) + # 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'))) @@ -138,21 +269,20 @@ async def run(self): # ============================================================================= async def handle_asgi(caller_pid, app_module: str, app_callable: str, - scope: dict, req_body_ch): + scope: dict, req_body_ref): """Handle ASGI request. - Entry point called from hornbeam_asgi_loop.erl. + 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 - # Wrap request channel (may be 'empty' atom for no-body requests) - if req_body_ch == 'empty' or req_body_ch == b'empty': - req_channel = None - else: - req_channel = ByteChannel(req_body_ch) - # Get app app = _get_app(app_module, app_callable) @@ -161,8 +291,11 @@ async def handle_asgi(caller_pid, app_module: str, app_callable: str, mount_id = scope.get('mount_id') scope['state'] = _MutableStateProxy(scope['state'], 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, req_channel) + protocol = ASGIProtocol(caller_pid, app, scope, body_receiver) await protocol.run() diff --git a/src/hornbeam_asgi.erl b/src/hornbeam_asgi.erl index b49c016..0e819e7 100644 --- a/src/hornbeam_asgi.erl +++ b/src/hornbeam_asgi.erl @@ -12,11 +12,13 @@ %% See the License for the specific language governing permissions and %% limitations under the License. -%%% @doc ASGI handler using Cowboy loop handler. +%%% @doc ASGI handler with fast synchronous path. %%% -%%% This module implements ASGI request handling using Cowboy's async body -%%% reading via loop handlers. Request body is streamed via channels, -%%% response is sent directly via erlang.send(). +%%% 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 %%% %%% @end -module(hornbeam_asgi). @@ -25,6 +27,10 @@ -export([init/2, info/3, terminate/3]). +%% Threshold for fast synchronous path (64KB) +%% Requests with bodies smaller than this use the fast sync path +-define(ASGI_BODY_BUFFER_THRESHOLD, 65536). + %% Internal state -record(state, { req_info, @@ -35,8 +41,9 @@ req_body_ch, body_ref, %% Reference for async body reading has_body, - headers_sent = false, - buffered_headers, %% {StatusCode, Headers, HasContentLength} or undefined + %% Cached for direct send (avoid map lookups per body chunk) + cowboy_pid, + cowboy_streamid, handler_state %% Original handler state from init }). @@ -52,6 +59,10 @@ init(Req, 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), @@ -63,13 +74,19 @@ init(Req, HandlerState) -> HasBody = has_request_body(Method, ContentLength, TransferEncoding), %% Create request body channel only if body exists (skip for GET/no-body) - {ReqBodyCh, BodyRef} = case HasBody of + %% 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(), - %% Start async body reading - Cowboy will send us messages - cowboy_req:cast({read_body, self(), Ref, auto, infinity}, Req), - {Ch, 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} @@ -78,7 +95,13 @@ init(Req, HandlerState) -> %% Create Python task (response sent via erlang.send, no channel needed) _TaskRef = py_event_loop_pool:create_task( <<"hornbeam_asgi_worker">>, <<"handle_asgi">>, - [self(), AppModule, AppCallable, Scope, ReqBodyCh]), + [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, @@ -89,22 +112,26 @@ init(Req, HandlerState) -> 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 -info({request_body, Ref, nofin, Data}, Req, #state{body_ref = Ref, req_body_ch = Ch} = State) -> - %% More body data coming - push to channel +%% 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), - %% Request more data - cowboy_req:cast({read_body, self(), Ref, auto, infinity}, Req), + %% Direct send instead of cowboy_req:cast + Pid ! {{Pid, StreamID}, {read_body, self(), Ref, auto, infinity}}, {ok, Req, State}; -info({request_body, Ref, fin, _BodyLen, Data}, Req, #state{body_ref = Ref, req_body_ch = Ch} = State) -> - %% Final chunk - push and close channel +%% 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) @@ -112,52 +139,26 @@ info({request_body, Ref, fin, _BodyLen, Data}, Req, #state{body_ref = Ref, req_b py_byte_channel:close(Ch), {ok, Req, State#state{body_ref = undefined}}; -%% Handle response headers from Python -%% Buffer headers until we get body - then decide reply vs stream based on content-length -info({<<"headers">>, StatusCode, Headers}, Req, State) -> - SafeHeaders = filter_hop_by_hop(Headers), - CowboyHeaders = convert_headers(SafeHeaders), - HasContentLength = maps:is_key(<<"content-length">>, CowboyHeaders), - {ok, Req, State#state{ - headers_sent = false, - buffered_headers = {StatusCode, CowboyHeaders, HasContentLength} - }}; - -%% Handle response body from Python -info({<<"body">>, Body, MoreBody}, Req, #state{handler_state = HandlerState, - buffered_headers = BufferedHeaders} = State) -> - BodyBin = to_binary(Body), - case {BufferedHeaders, MoreBody} of - %% First body chunk with buffered headers - {{StatusCode, CowboyHeaders, true}, false} -> - %% Has Content-Length and no more body - use reply (not streaming) - Req2 = cowboy_req:reply(StatusCode, CowboyHeaders, BodyBin, Req), - maybe_close_channel(State#state.req_body_ch), - {stop, Req2, HandlerState}; - {{StatusCode, CowboyHeaders, _HasCL}, _} -> - %% Streaming response - start with stream_reply - Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req), - case MoreBody of - true -> - case BodyBin of - <<>> -> ok; - _ -> ok = cowboy_req:stream_body(BodyBin, nofin, Req2) - end, - {ok, Req2, State#state{headers_sent = true, buffered_headers = undefined}}; - false -> - ok = cowboy_req:stream_body(BodyBin, fin, Req2), - maybe_close_channel(State#state.req_body_ch), - {stop, Req2, HandlerState} - end; - %% Subsequent body chunks (headers already sent) - {undefined, true} -> - ok = cowboy_req:stream_body(BodyBin, nofin, Req), - {ok, Req, State}; - {undefined, false} -> - ok = cowboy_req:stream_body(BodyBin, fin, Req), - maybe_close_channel(State#state.req_body_ch), - {stop, Req, HandlerState} - end; +%% 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) -> @@ -219,6 +220,7 @@ push_to_channel(Channel, Data) -> 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 From ce1cd51a3478c83a231bd5e1379181443b3255b0 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Mon, 23 Mar 2026 23:15:36 +0100 Subject: [PATCH 42/52] Use sys.modules instead of importlib for app lookup Module is already imported by Erlang via ensure_all_imported, so use sys.modules lookup with caching instead of importlib. --- priv/hornbeam_asgi_worker.py | 45 ++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/priv/hornbeam_asgi_worker.py b/priv/hornbeam_asgi_worker.py index 4f9dbcc..fc42e44 100644 --- a/priv/hornbeam_asgi_worker.py +++ b/priv/hornbeam_asgi_worker.py @@ -303,34 +303,39 @@ async def handle_asgi(caller_pid, app_module: str, app_callable: str, # Helpers # ============================================================================= -_preloaded_app: Callable = None -_preloaded_key: tuple = None +import sys +# Cache apps by (module, callable) key - modules already imported by Erlang +_app_cache: dict = {} -def preload_app(app_module: str, app_callable: str) -> bytes: - """Preload ASGI application at startup.""" - global _preloaded_app, _preloaded_key - - import importlib - module = importlib.import_module(app_module) - app = getattr(module, app_callable) - _preloaded_app = app - _preloaded_key = (app_module, app_callable) +def _get_app(module_name: str, callable_name: str) -> Callable: + """Get ASGI application from cache or sys.modules. - return b'ok' + 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) -def _get_app(module_name: str, callable_name: str) -> Callable: - """Get ASGI application.""" - global _preloaded_app, _preloaded_key + app = getattr(module, callable_name) + _app_cache[key] = app + return app - if _preloaded_key == (module_name, callable_name): - return _preloaded_app - import importlib - module = importlib.import_module(module_name) - return getattr(module, callable_name) +def preload_app(app_module: str, app_callable: str) -> bytes: + """Preload ASGI application at startup.""" + _get_app(app_module, app_callable) + return b'ok' class _MutableStateProxy(dict): From b7434c4fe4d51b2fa2064ad02105228416ff7d70 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Mon, 23 Mar 2026 23:49:05 +0100 Subject: [PATCH 43/52] Use main event loop directly in ASGI handler Replace py_event_loop_pool with py_event_loop:get_loop() to remove pool routing overhead. --- src/hornbeam_asgi.erl | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/hornbeam_asgi.erl b/src/hornbeam_asgi.erl index 0e819e7..e555881 100644 --- a/src/hornbeam_asgi.erl +++ b/src/hornbeam_asgi.erl @@ -92,10 +92,12 @@ init(Req, HandlerState) -> {empty, undefined} end, - %% Create Python task (response sent via erlang.send, no channel needed) - _TaskRef = py_event_loop_pool:create_task( + %% Submit task directly to main event loop + {ok, LoopRef} = py_event_loop:get_loop(), + TaskRef = make_ref(), + ok = py_nif:submit_task(LoopRef, self(), TaskRef, <<"hornbeam_asgi_worker">>, <<"handle_asgi">>, - [self(), AppModule, AppCallable, Scope, ReqBodyRef]), + [self(), AppModule, AppCallable, Scope, ReqBodyRef], #{}), %% Extract channel ref for state (if using channel mode) ReqBodyCh = case ReqBodyRef of From 8c73619e0f7041a99749c74e0951c976df6d3bf0 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 24 Mar 2026 01:20:49 +0100 Subject: [PATCH 44/52] Use lazy state proxy with callbacks for ASGI state access - Register lifespan_state_get/set callbacks at startup in hornbeam_lifespan - Replace _MutableStateProxy with _LazyStateProxy in Python - Remove state from ASGI scope building, Python fetches lazily Benefits: - No erlang.whereis() on every request - State only fetched when actually accessed - Direct ETS access via callbacks, no message passing --- priv/hornbeam_asgi_worker.py | 102 +++++++++++++++++++++++++++++------ src/hornbeam_lifespan.erl | 40 ++++++++++++++ src/hornbeam_request.erl | 9 +--- 3 files changed, 128 insertions(+), 23 deletions(-) diff --git a/priv/hornbeam_asgi_worker.py b/priv/hornbeam_asgi_worker.py index fc42e44..2a4f211 100644 --- a/priv/hornbeam_asgi_worker.py +++ b/priv/hornbeam_asgi_worker.py @@ -283,13 +283,12 @@ async def handle_asgi(caller_pid, app_module: str, app_callable: str, if not HAS_ERLANG: return - # Get app + # Get app (cached) app = _get_app(app_module, app_callable) - # Wrap scope['state'] if present - if 'state' in scope: - mount_id = scope.get('mount_id') - scope['state'] = _MutableStateProxy(scope['state'], mount_id) + # Use lazy state proxy (no whereis, lazy ETS access via callback) + mount_id = scope.get('mount_id') + scope['state'] = _LazyStateProxy(mount_id) # Create body receiver (handles body mode detection) body_receiver = BodyReceiver(req_body_ref) @@ -338,29 +337,100 @@ def preload_app(app_module: str, app_callable: str) -> bytes: return b'ok' -class _MutableStateProxy(dict): - """Dict that syncs mutations to Erlang ETS.""" +class _LazyStateProxy(dict): + """Dict that lazily fetches from ETS and syncs mutations via callback. - __slots__ = ('_mount_id', '_lifespan_pid') + 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, initial_state: dict, mount_id=None): - super().__init__(initial_state) + def __init__(self, mount_id=None): + super().__init__() self._mount_id = mount_id - self._lifespan_pid = None + 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: - self._lifespan_pid = erlang.whereis("hornbeam_lifespan") + 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) - if self._lifespan_pid is not None: + # Sync to ETS via callback (no whereis needed) + if HAS_ERLANG: try: if self._mount_id is not None: - _erlang_send(self._lifespan_pid, - (b'update_state', self._mount_id, key, value)) + erlang.call('lifespan_state_set', self._mount_id, key, value) else: - _erlang_send(self._lifespan_pid, (b'update_state', key, value)) + 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/src/hornbeam_lifespan.erl b/src/hornbeam_lifespan.erl index d904d27..6366220 100644 --- a/src/hornbeam_lifespan.erl +++ b/src/hornbeam_lifespan.erl @@ -241,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 = #{}, @@ -573,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_request.erl b/src/hornbeam_request.erl index e54e7aa..8a91f4b 100644 --- a/src/hornbeam_request.erl +++ b/src/hornbeam_request.erl @@ -96,13 +96,9 @@ build_asgi_scope(Req, State) -> %% Get mount_id for per-mount state isolation (multi-app mode) MountId = maps:get(mount_id, State, undefined), - %% Get fresh lifespan state from ETS on each request (supports mutable state) - LifespanState = case MountId of - undefined -> hornbeam_lifespan:get_state(); - _ -> hornbeam_lifespan:get_state(MountId) - end, - %% 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">>}, @@ -116,7 +112,6 @@ build_asgi_scope(Req, State) -> headers => HeaderList, server => {cowboy_req:host(Req), cowboy_req:port(Req)}, client => {format_ip(ClientIp), ClientPort}, - state => LifespanState, extensions => build_extensions(Version) }, From 2357e995034c2e99e337ecec789d05c214c4813d Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 24 Mar 2026 01:27:26 +0100 Subject: [PATCH 45/52] Use event loop pool for ASGI task distribution --- src/hornbeam_asgi.erl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hornbeam_asgi.erl b/src/hornbeam_asgi.erl index e555881..6ba63a0 100644 --- a/src/hornbeam_asgi.erl +++ b/src/hornbeam_asgi.erl @@ -92,8 +92,8 @@ init(Req, HandlerState) -> {empty, undefined} end, - %% Submit task directly to main event loop - {ok, LoopRef} = py_event_loop:get_loop(), + %% 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">>, From f0e244a686ec0cc4963e88e56a8b5bac03f1c07a Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 24 Mar 2026 02:20:19 +0100 Subject: [PATCH 46/52] Use py_context_router for context management and cache state proxies - hornbeam_context_pool now uses py_context_router for context lifecycle - Cache NIF refs in persistent_term for O(1) access without message passing - Cache state proxies per mount_id to avoid allocation per request - Use py_import:add_path and py_import:ensure_imported for mount setup --- priv/hornbeam_asgi_worker.py | 18 +++++- src/hornbeam_context_pool.erl | 117 +++++++++++++++++----------------- src/hornbeam_mounts.erl | 15 +++-- 3 files changed, 87 insertions(+), 63 deletions(-) diff --git a/priv/hornbeam_asgi_worker.py b/priv/hornbeam_asgi_worker.py index 2a4f211..b02904a 100644 --- a/priv/hornbeam_asgi_worker.py +++ b/priv/hornbeam_asgi_worker.py @@ -286,9 +286,9 @@ async def handle_asgi(caller_pid, app_module: str, app_callable: str, # Get app (cached) app = _get_app(app_module, app_callable) - # Use lazy state proxy (no whereis, lazy ETS access via callback) + # Use cached state proxy (shared per mount_id) mount_id = scope.get('mount_id') - scope['state'] = _LazyStateProxy(mount_id) + scope['state'] = _get_state_proxy(mount_id) # Create body receiver (handles body mode detection) body_receiver = BodyReceiver(req_body_ref) @@ -307,6 +307,9 @@ async def handle_asgi(caller_pid, app_module: str, app_callable: str, # 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. @@ -337,6 +340,17 @@ def preload_app(app_module: str, app_callable: str) -> bytes: 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. diff --git a/src/hornbeam_context_pool.erl b/src/hornbeam_context_pool.erl index f42a92b..ce1fdca 100644 --- a/src/hornbeam_context_pool.erl +++ b/src/hornbeam_context_pool.erl @@ -12,10 +12,10 @@ %% See the License for the specific language governing permissions and %% limitations under the License. -%%% @doc Context pool using persistent_term for zero-copy access. +%%% @doc Context pool using py_context_router with cached NIF refs. %%% -%%% Stores Python context references in persistent_term for O(1) lookup -%%% with no message passing or copying overhead. +%%% Uses py_context_router for context lifecycle management and caches +%%% NIF references in persistent_term for O(1) lookup without message passing. %%% %%% Each context is a sub-interpreter (Python 3.12+) or worker thread. %%% With free-threading Python (3.13+), contexts execute truly in parallel. @@ -43,7 +43,8 @@ -record(state, { pool_size :: pos_integer(), context_mode :: worker | owngil, - contexts :: #{pos_integer() => reference()} + contexts :: [pid()], %% Context pids from py_context_router + nif_refs :: #{pos_integer() => reference()} %% Cached NIF refs }). -define(DEFAULT_POOL_SIZE, erlang:system_info(schedulers)). @@ -135,12 +136,24 @@ init(Opts) -> Counter = atomics:new(1, [{signed, false}]), persistent_term:put(hornbeam_context_counter, Counter), - %% Create contexts and store in persistent_term - Contexts = create_contexts(PoolSize, ContextMode), + %% Start py_context_router if not already started + case py_context_router:is_started() of + true -> ok; + false -> + {ok, _} = py_context_router:start(#{contexts => PoolSize, mode => ContextMode}) + end, + + %% Get contexts from py_context_router and cache NIF refs + Contexts = py_context_router:contexts(), + NifRefs = cache_nif_refs(Contexts), + + %% Setup hornbeam-specific modules in each context + setup_contexts(NifRefs), persistent_term:put(hornbeam_context_pool_size, PoolSize), - {ok, #state{pool_size = PoolSize, context_mode = ContextMode, contexts = Contexts}}. + {ok, #state{pool_size = PoolSize, context_mode = ContextMode, + contexts = Contexts, nif_refs = NifRefs}}. handle_call(stats, _From, #state{pool_size = PoolSize, context_mode = ContextMode} = State) -> Stats = #{ @@ -150,15 +163,15 @@ handle_call(stats, _From, #state{pool_size = PoolSize, context_mode = ContextMod }, {reply, Stats, State}; -handle_call({add_paths, Paths}, _From, #state{contexts = Contexts} = State) -> +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, Contexts), + end, NifRefs), {reply, ok, State}; handle_call({preload_app, WorkerClass, AppModule, AppCallable}, _From, - #state{contexts = Contexts} = State) -> + #state{nif_refs = NifRefs} = State) -> %% Preload app in all contexts WorkerModule = case WorkerClass of wsgi -> <<"hornbeam_wsgi_worker">>; @@ -172,7 +185,7 @@ handle_call({preload_app, WorkerClass, AppModule, AppCallable}, _From, error_logger:warning_msg( "hornbeam: failed to preload app in context: ~p~n", [Err]) end - end, Contexts), + end, NifRefs), {reply, ok, State}; handle_call(_Request, _From, State) -> @@ -184,13 +197,8 @@ handle_cast(_Request, State) -> handle_info(_Info, State) -> {noreply, State}. -terminate(_Reason, #state{pool_size = PoolSize, contexts = Contexts}) -> - %% Destroy contexts - maps:foreach(fun(_Id, Ref) -> - catch py_nif:context_destroy(Ref) - end, Contexts), - - %% Clean up persistent_term entries +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)), @@ -202,15 +210,42 @@ terminate(_Reason, #state{pool_size = PoolSize, contexts = Contexts}) -> %% Internal functions %% ============================================================================ -create_contexts(PoolSize, ContextMode) -> +%% @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), - - lists:foldl(fun(Id, Acc) -> - {Ref, InterpId} = create_context(Id, PrivDirBin, ContextMode), - persistent_term:put({hornbeam_context, Id}, {Ref, InterpId}), - maps:put(Id, Ref, Acc) - end, #{}, lists:seq(0, PoolSize - 1)). + 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 @@ -229,35 +264,3 @@ add_paths_to_context(Ref, Paths) -> error_logger:warning_msg("Failed to add path ~s to context: ~p~n", [AbsPath, Err]) end end, Paths). - -create_context(Id, PrivDir, ContextMode) -> - case py_nif:context_create(ContextMode) of - {ok, Ref, InterpId} -> - %% Set up callback handler (for erlang.call from Python) - py_nif:context_set_callback_handler(Ref, self()), - - %% Extend erlang module first (must happen before importing workers) - %% This makes erlang.send, erlang.call, erlang.schedule_inline available - py_context:extend_erlang_module_in_context(Ref), - - %% Add priv dir to sys.path and preload worker modules - SetupCode = <<" -import sys -priv_dir = '", PrivDir/binary, "' -if priv_dir not in sys.path: - sys.path.insert(0, priv_dir) -import hornbeam_wsgi_worker -import hornbeam_asgi_worker -">>, - 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, - - {Ref, InterpId}; - {error, Reason} -> - error({context_create_failed, Id, Reason}) - end. diff --git a/src/hornbeam_mounts.erl b/src/hornbeam_mounts.erl index 297729c..dcbf102 100644 --- a/src/hornbeam_mounts.erl +++ b/src/hornbeam_mounts.erl @@ -122,9 +122,10 @@ handle_call({register, Mounts}, _From, State) -> Mount#{mount_id => MountId} end, Mounts), - %% Setup pythonpath for each mount at registration time (not per-request) + %% Setup pythonpath and preload apps at registration time (not per-request) lists:foreach(fun(Mount) -> - setup_mount_pythonpath(Mount) + setup_mount_pythonpath(Mount), + setup_mount_imports(Mount) end, MountsWithIds), %% Sort mounts by prefix length descending (longest first) @@ -244,7 +245,13 @@ setup_mount_pythonpath(Mount) -> 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}) + 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). From d3589c64976cf25cc5ddf28d914d90c0a011c118 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 24 Mar 2026 02:28:43 +0100 Subject: [PATCH 47/52] Reuse default py_context_router pool and remove per-mount workers - hornbeam_context_pool now caches NIF refs from default pool - Wait for py_context_router to be ready before caching - Remove workers and pool_enabled from mount type (use shared pool) - Simplify mount type to just routing config --- src/hornbeam_context_pool.erl | 74 ++++++++++++++++------------------- src/hornbeam_mounts.erl | 9 ++--- 2 files changed, 37 insertions(+), 46 deletions(-) diff --git a/src/hornbeam_context_pool.erl b/src/hornbeam_context_pool.erl index ce1fdca..a4f49f2 100644 --- a/src/hornbeam_context_pool.erl +++ b/src/hornbeam_context_pool.erl @@ -12,13 +12,11 @@ %% See the License for the specific language governing permissions and %% limitations under the License. -%%% @doc Context pool using py_context_router with cached NIF refs. +%%% @doc Cached NIF refs for the default py_context_router pool. %%% -%%% Uses py_context_router for context lifecycle management and caches -%%% NIF references in persistent_term for O(1) lookup without message passing. -%%% -%%% Each context is a sub-interpreter (Python 3.12+) or worker thread. -%%% With free-threading Python (3.13+), contexts execute truly in parallel. +%%% 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). @@ -42,32 +40,22 @@ -record(state, { pool_size :: pos_integer(), - context_mode :: worker | owngil, - contexts :: [pid()], %% Context pids from py_context_router nif_refs :: #{pos_integer() => reference()} %% Cached NIF refs }). --define(DEFAULT_POOL_SIZE, erlang:system_info(schedulers)). - %% ============================================================================ %% API %% ============================================================================ -%% @doc Start the context pool with default size (one per scheduler). +%% @doc Start the context pool cache. -spec start_link() -> {ok, pid()} | {error, term()}. start_link() -> start_link(#{}). -%% @doc Start the context pool with options. -%% -%% Options: -%% - pool_size: Number of contexts (default: number of schedulers) -%% - context_mode: worker | owngil (default: worker) -%% - worker: Standard sub-interpreter mode -%% - owngil: Per-interpreter GIL mode (Python 3.12+, true parallelism) +%% @doc Start the context pool cache. -spec start_link(map()) -> {ok, pid()} | {error, term()}. -start_link(Opts) -> - gen_server:start_link({local, ?MODULE}, ?MODULE, Opts, []). +start_link(_Opts) -> + gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). %% @doc Get a context using scheduler affinity. %% @@ -124,41 +112,30 @@ preload_app(WorkerClass, AppModule, AppCallable) -> %% gen_server callbacks %% ============================================================================ -init(Opts) -> +init([]) -> process_flag(trap_exit, true), - PoolSize = maps:get(pool_size, Opts, - application:get_env(hornbeam, context_pool_size, ?DEFAULT_POOL_SIZE)), - ContextMode = maps:get(context_mode, Opts, - application:get_env(hornbeam, context_mode, worker)), - - %% Create atomic counter for round-robin - Counter = atomics:new(1, [{signed, false}]), - persistent_term:put(hornbeam_context_counter, Counter), - - %% Start py_context_router if not already started - case py_context_router:is_started() of - true -> ok; - false -> - {ok, _} = py_context_router:start(#{contexts => PoolSize, mode => ContextMode}) - end, + %% Wait for py_context_router to be ready + wait_for_context_router(), - %% Get contexts from py_context_router and cache NIF refs + %% 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, context_mode = ContextMode, - contexts = Contexts, nif_refs = NifRefs}}. + {ok, #state{pool_size = PoolSize, nif_refs = NifRefs}}. -handle_call(stats, _From, #state{pool_size = PoolSize, context_mode = ContextMode} = State) -> +handle_call(stats, _From, #state{pool_size = PoolSize} = State) -> Stats = #{ pool_size => PoolSize, - context_mode => ContextMode, execution_mode => py_nif:execution_mode() }, {reply, Stats, State}; @@ -264,3 +241,18 @@ add_paths_to_context(Ref, Paths) -> 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_mounts.erl b/src/hornbeam_mounts.erl index dcbf102..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,10 +55,9 @@ app_module := binary(), app_callable := binary(), worker_class := wsgi | asgi, - workers := pos_integer(), timeout := pos_integer(), - mount_id => binary(), %% 6-char random ID for pool routing - pool_enabled => boolean() %% Enable persistent worker pool + mount_id => binary(), %% 6-char random ID for routing + pythonpath => [binary()] %% Additional Python paths for this mount }. -export_type([mount/0]). From d2d5471824cc195ad11dc2c98b892af77e111ec0 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 24 Mar 2026 02:33:22 +0100 Subject: [PATCH 48/52] Update docs for shared context pool and ASGI performance optimizations - Add [Unreleased] changelog section with performance metrics - Remove per-mount workers option from docs (now uses shared pool) - Add notes explaining shared py_context_router pool architecture --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ docs/guides/multi-app.md | 23 +++++++++-------------- docs/reference/configuration.md | 7 +++---- 3 files changed, 38 insertions(+), 18 deletions(-) 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/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}} ], From 35b8ef0c8cbe811c426a9db7492987af31f0b648 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 24 Mar 2026 02:53:24 +0100 Subject: [PATCH 49/52] Remove dead code from performance optimizations - Delete hornbeam_pool.erl (replaced by hornbeam_context_pool) - Remove unused get_context_rr/0 and stats/0 from hornbeam_context_pool - Remove dead handle_request and _process_environ from WSGI worker - Remove unused streaming code from ASGI runner - Fix comment in hornbeam_sup.erl --- priv/hornbeam_asgi_runner.py | 217 ------------------------------- priv/hornbeam_wsgi_worker.py | 67 +--------- src/hornbeam_context_pool.erl | 24 ---- src/hornbeam_pool.erl | 238 ---------------------------------- src/hornbeam_sup.erl | 2 +- 5 files changed, 2 insertions(+), 546 deletions(-) delete mode 100644 src/hornbeam_pool.erl diff --git a/priv/hornbeam_asgi_runner.py b/priv/hornbeam_asgi_runner.py index 2909632..3b795ca 100644 --- a/priv/hornbeam_asgi_runner.py +++ b/priv/hornbeam_asgi_runner.py @@ -594,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_wsgi_worker.py b/priv/hornbeam_wsgi_worker.py index b334284..92496c1 100644 --- a/priv/hornbeam_wsgi_worker.py +++ b/priv/hornbeam_wsgi_worker.py @@ -26,8 +26,7 @@ """ import io -import threading -from typing import Callable, Dict, Tuple +from typing import Callable, Tuple try: import erlang @@ -160,23 +159,6 @@ def _parse_status(status_str) -> int: return 500 -def _process_environ(environ_map: dict) -> dict: - """Convert all binary keys/values to strings.""" - environ = _ENVIRON_TEMPLATE.copy() - - for key, value in environ_map.items(): - str_key = key.decode('utf-8') if isinstance(key, bytes) else str(key) - if isinstance(value, bytes): - str_value = value.decode('utf-8', errors='replace') - elif value is None or (hasattr(value, '__class__') and value.__class__.__name__ == 'Atom'): - str_value = '' - else: - str_value = str(value) if not isinstance(value, str) else value - environ[str_key] = str_value - - return environ - - # ============================================================================ # Response class # ============================================================================ @@ -212,53 +194,6 @@ def _write(self, data): self._write_buffer.append(data) -# ============================================================================ -# Entry point - setup and call app -# ============================================================================ - -def handle_request(caller_pid, buffer, app_module: bytes, app_callable: bytes, environ_map: dict): - """Entry point - use py_buffer as wsgi.input, call app. - - 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) - environ_map: Pre-built environ dict from Erlang - - 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 - - # Process environ (convert bytes to strings) - environ = _process_environ(environ_map) - - # 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 - environ['wsgi.errors'] = _SHARED_ERRORS - - # Call app directly (faster than schedule_inline for simple requests) - 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 _call_app(caller_pid, module_name: str, callable_name: str, environ: dict): """Call WSGI app and iterate response. diff --git a/src/hornbeam_context_pool.erl b/src/hornbeam_context_pool.erl index a4f49f2..1e39e10 100644 --- a/src/hornbeam_context_pool.erl +++ b/src/hornbeam_context_pool.erl @@ -28,9 +28,7 @@ start_link/1, get_context/0, get_context_ref/0, - get_context_rr/0, pool_size/0, - stats/0, add_paths/1, preload_app/3 ]). @@ -73,26 +71,11 @@ get_context_ref() -> {Ref, _InterpId} = get_context(), Ref. -%% @doc Get a context using round-robin selection. -%% -%% Uses an atomic counter for fair distribution across all contexts. --spec get_context_rr() -> {reference(), non_neg_integer()}. -get_context_rr() -> - N = persistent_term:get(hornbeam_context_pool_size), - Counter = atomics:add_get(persistent_term:get(hornbeam_context_counter), 1, 1), - Id = (Counter - 1) rem N, - persistent_term:get({hornbeam_context, Id}). - %% @doc Get the pool size. -spec pool_size() -> pos_integer(). pool_size() -> persistent_term:get(hornbeam_context_pool_size). -%% @doc Get pool statistics. --spec stats() -> map(). -stats() -> - gen_server:call(?MODULE, stats). - %% @doc Add paths to sys.path in all contexts. %% %% Call this after starting the context pool to add user-specified paths @@ -133,13 +116,6 @@ init([]) -> {ok, #state{pool_size = PoolSize, nif_refs = NifRefs}}. -handle_call(stats, _From, #state{pool_size = PoolSize} = State) -> - Stats = #{ - pool_size => PoolSize, - execution_mode => py_nif:execution_mode() - }, - {reply, Stats, State}; - handle_call({add_paths, Paths}, _From, #state{nif_refs = NifRefs} = State) -> %% Add paths to all contexts maps:foreach(fun(_Id, Ref) -> diff --git a/src/hornbeam_pool.erl b/src/hornbeam_pool.erl deleted file mode 100644 index 9ce1c86..0000000 --- a/src/hornbeam_pool.erl +++ /dev/null @@ -1,238 +0,0 @@ -%% 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 Static Python context pool using ETS for O(1) lookup. -%%% -%%% This module provides scheduler-affinity based context assignment for -%%% optimal cache locality and reduced contention. Each scheduler gets -%%% a pre-assigned context via phash2(scheduler_id). -%%% -%%% Key features: -%%% - O(1) context lookup via ETS lookup_element -%%% - Scheduler affinity for cache locality -%%% - No gen_server overhead for hot path -%%% - Atomic call counters via ETS update_counter -%%% - Automatic restart on context crash --module(hornbeam_pool). - --behaviour(gen_server). - --export([ - start_link/0, - start_link/1, - get_context/0, - get_context_rr/0, - call/4, - call/5, - pool_size/0, - stats/0 -]). - --export([ - init/1, - handle_call/3, - handle_cast/2, - handle_info/2, - terminate/2, - code_change/3 -]). - --define(SERVER, ?MODULE). --define(TABLE, hornbeam_pool). --define(DEFAULT_POOL_SIZE, erlang:system_info(schedulers)). - --record(state, { - pool_size :: non_neg_integer(), - monitors :: #{pid() => {pos_integer(), reference()}} % pid -> {idx, mref} -}). - -%%% ============================================================================ -%%% API -%%% ============================================================================ - -%% @doc Start the context pool with default size (one per scheduler). --spec start_link() -> {ok, pid()} | {error, term()}. -start_link() -> - start_link(#{}). - -%% @doc Start the context pool with options. -%% -%% Options: -%% - pool_size: Number of contexts (default: number of schedulers) --spec start_link(map()) -> {ok, pid()} | {error, term()}. -start_link(Opts) -> - gen_server:start_link({local, ?SERVER}, ?MODULE, Opts, []). - -%% @doc Get a context from the pool using scheduler affinity. -%% -%% Returns the context assigned to the current scheduler for optimal -%% cache locality and reduced contention. O(1) lookup via ETS. --spec get_context() -> pid(). -get_context() -> - SchedId = erlang:system_info(scheduler_id), - N = ets:lookup_element(?TABLE, pool_size, 2), - Idx = (SchedId - 1) rem N, - ets:lookup_element(?TABLE, {context, Idx}, 2). - -%% @doc Get a context from the pool using round-robin selection. -%% -%% Uses an atomic counter for fair distribution across all contexts. -%% Useful when scheduler affinity isn't desired (e.g., long-running requests). --spec get_context_rr() -> pid(). -get_context_rr() -> - N = ets:lookup_element(?TABLE, pool_size, 2), - Idx = ets:update_counter(?TABLE, rr_counter, {2, 1, N - 1, 0}), - ets:lookup_element(?TABLE, {context, Idx}, 2). - -%% @doc Call a Python function using a pooled context. -%% -%% Automatically selects a context using scheduler affinity. -%% Falls back to py:call if pool is not enabled. --spec call(atom() | binary(), atom() | binary(), list(), map()) -> - {ok, term()} | {error, term()}. -call(Module, Func, Args, Kwargs) -> - call(Module, Func, Args, Kwargs, 30000). - -%% @doc Call a Python function with timeout. --spec call(atom() | binary(), atom() | binary(), list(), map(), timeout()) -> - {ok, term()} | {error, term()}. -call(Module, Func, Args, Kwargs, Timeout) -> - SchedId = erlang:system_info(scheduler_id), - N = ets:lookup_element(?TABLE, pool_size, 2), - Idx = (SchedId - 1) rem N, - Ctx = ets:lookup_element(?TABLE, {context, Idx}, 2), - ets:update_counter(?TABLE, {counter, Idx}, 1), - py_context:call(Ctx, Module, Func, Args, Kwargs, Timeout). - -%% @doc Get the pool size. --spec pool_size() -> pos_integer(). -pool_size() -> - ets:lookup_element(?TABLE, pool_size, 2). - -%% @doc Get pool statistics. --spec stats() -> map(). -stats() -> - PoolSize = ets:lookup_element(?TABLE, pool_size, 2), - CallCounts = [ets:lookup_element(?TABLE, {counter, Idx}, 2) - || Idx <- lists:seq(0, PoolSize - 1)], - #{ - pool_size => PoolSize, - call_counts => CallCounts, - total_calls => lists:sum(CallCounts) - }. - -%%% ============================================================================ -%%% gen_server callbacks -%%% ============================================================================ - -init(Opts) -> - process_flag(trap_exit, true), - - %% Create ETS table for pool data - _ = ets:new(?TABLE, [named_table, public, set, {read_concurrency, true}]), - - %% Pool size: Opts > app env > default (schedulers) - PoolSize = case maps:get(pool_size, Opts, undefined) of - undefined -> - application:get_env(hornbeam, pool_size, ?DEFAULT_POOL_SIZE); - Size -> - Size - end, - - %% Try to create contexts (may fail if Python not started) - {Monitors, ActualSize} = try - create_contexts(PoolSize) - catch - _:_ -> - %% Python not ready - start with empty pool - error_logger:info_msg("hornbeam_pool: Python not ready, starting without contexts~n"), - {#{}, 0} - end, - - %% Initialize counters to 0 - lists:foreach(fun(Idx) -> - ets:insert(?TABLE, {{counter, Idx}, 0}) - end, lists:seq(0, max(0, ActualSize - 1))), - - %% Initialize round-robin counter - ets:insert(?TABLE, {rr_counter, 0}), - - %% Store pool size - ets:insert(?TABLE, {pool_size, ActualSize}), - - {ok, #state{ - pool_size = ActualSize, - monitors = Monitors - }}. - -handle_call(get_pool_size, _From, #state{pool_size = PoolSize} = State) -> - {reply, PoolSize, State}; - -handle_call(_Request, _From, State) -> - {reply, {error, unknown_request}, State}. - -handle_cast(_Request, State) -> - {noreply, State}. - -handle_info({'DOWN', MRef, process, Pid, Reason}, #state{monitors = Monitors} = State) -> - %% A context died - find and restart it - case maps:get(Pid, Monitors, undefined) of - {Idx, MRef} -> - error_logger:warning_msg("hornbeam_pool: Context ~p died: ~p, restarting~n", - [Idx, Reason]), - {NewCtx, NewMRef} = start_context(Idx), - ets:insert(?TABLE, {{context, Idx}, NewCtx}), - NewMonitors = maps:remove(Pid, Monitors), - NewMonitors2 = maps:put(NewCtx, {Idx, NewMRef}, NewMonitors), - {noreply, State#state{monitors = NewMonitors2}}; - undefined -> - {noreply, State} - end; - -handle_info(_Info, State) -> - {noreply, State}. - -terminate(_Reason, #state{pool_size = PoolSize}) -> - %% Stop all contexts - lists:foreach(fun(Idx) -> - case ets:lookup(?TABLE, {context, Idx}) of - [] -> ok; - [{_, Ctx}] -> - catch py_context:stop(Ctx) - end - end, lists:seq(0, PoolSize - 1)), - %% Delete ETS table - catch ets:delete(?TABLE), - ok. - -code_change(_OldVsn, State, _Extra) -> - {ok, State}. - -%%% ============================================================================ -%%% Internal functions -%%% ============================================================================ - -create_contexts(PoolSize) -> - Monitors = lists:foldl(fun(Idx, Acc) -> - {Ctx, MRef} = start_context(Idx), - ets:insert(?TABLE, {{context, Idx}, Ctx}), - maps:put(Ctx, {Idx, MRef}, Acc) - end, #{}, lists:seq(0, PoolSize - 1)), - {Monitors, PoolSize}. - -start_context(Id) -> - %% Use 'auto' mode - detects subinterp on Python 3.12+, worker otherwise - {ok, Ctx} = py_context:start_link(Id, auto), - MRef = erlang:monitor(process, Ctx), - {Ctx, MRef}. diff --git a/src/hornbeam_sup.erl b/src/hornbeam_sup.erl index 8c8cac8..a6267a5 100644 --- a/src/hornbeam_sup.erl +++ b/src/hornbeam_sup.erl @@ -22,7 +22,7 @@ %%% - hornbeam_callbacks: Erlang callback registry %%% - hornbeam_pubsub: Pub/sub messaging %%% - hornbeam_lifespan: ASGI lifespan management -%%% - hornbeam_pool: Python context pool +%%% - 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) From ed63e6b3bcfe8dc7c928bc73019f9947452fc7ba Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 24 Mar 2026 02:58:43 +0100 Subject: [PATCH 50/52] Bump erlang_python to 2.2.0 --- rebar.config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rebar.config b/rebar.config index f840efa..c7fa496 100644 --- a/rebar.config +++ b/rebar.config @@ -20,7 +20,7 @@ {deps, [ {cowboy, "2.12.0"}, - {erlang_python, {git, "https://github.com/benoitc/erlang-python.git", {branch, "main"}}} + {erlang_python, "2.2.0"} ]}. {shell, [ From 59854072bd45ed8aeee4adf6b2dd58ff04c47900 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 24 Mar 2026 03:02:45 +0100 Subject: [PATCH 51/52] Fix edoc comments with unescaped angle brackets --- src/hornbeam_request.erl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hornbeam_request.erl b/src/hornbeam_request.erl index 8a91f4b..1a7c4c7 100644 --- a/src/hornbeam_request.erl +++ b/src/hornbeam_request.erl @@ -122,7 +122,7 @@ build_asgi_scope(Req, State) -> end. %% @doc Convert header name to WSGI HTTP_* format. -%% "accept-encoding" -> <<"HTTP_ACCEPT_ENCODING">> +%% Example: "accept-encoding" becomes "HTTP_ACCEPT_ENCODING" -spec to_wsgi_header_key(binary()) -> binary(). to_wsgi_header_key(Name) -> Upper = to_upper_underscore(Name), @@ -168,7 +168,7 @@ convert_headers_wsgi(Headers) -> %% @private %% Convert lowercase header to uppercase with underscores. -%% "accept-encoding" -> <<"ACCEPT_ENCODING">> +%% Example: "accept-encoding" becomes "ACCEPT_ENCODING" to_upper_underscore(Bin) -> << <<(upper_char(C))>> || <> <= Bin >>. From 1eda9f3ee7827604364b99dff3b3db841389b53b Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 24 Mar 2026 03:03:42 +0100 Subject: [PATCH 52/52] Add OTP 28 and Python 3.14 to CI matrix --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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