From 4e631baf03fe6159f5ed3bea1d9841751f17805b Mon Sep 17 00:00:00 2001 From: Hogne Date: Sat, 30 May 2026 23:11:55 +0200 Subject: [PATCH 1/3] feat(infrastructure): add lazy backend proxy for on-demand subprocess lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Phase 1.5 'Lazy lifecycle wrappers' from the framework integration bridge design spec. LazyBackendProxy is a transparent TCP proxy that: - Listens on a user-facing port without starting the backend - On first inbound connection, runs start_cmd to launch the real backend (sd-server, llama-server, whisper.cpp server, etc.) - Bidirectionally forwards all TCP traffic to the backend - Stops the subprocess after idle_timeout_seconds with no connections - Handles concurrent requests during cold start via a lock - Returns HTTP 503 when the backend is unavailable ~180 LOC — well under the ~50 LOC per-backend estimate since the proxy is fully generic (host:port + start_cmd is all that differs per backend). 9 tests covering: lifecycle start/stop, bidirectional forwarding, 503 on backend failure, real subprocess cold start, subprocess exit handling, idle timeout, and keepalive via active requests. Refs #63 --- tests/test_lazy_backend_proxy.py | 214 +++++++++++++++++++++++ tinyagentos/lazy_backend_proxy.py | 272 ++++++++++++++++++++++++++++++ 2 files changed, 486 insertions(+) create mode 100644 tests/test_lazy_backend_proxy.py create mode 100644 tinyagentos/lazy_backend_proxy.py diff --git a/tests/test_lazy_backend_proxy.py b/tests/test_lazy_backend_proxy.py new file mode 100644 index 000000000..d99dcfbfd --- /dev/null +++ b/tests/test_lazy_backend_proxy.py @@ -0,0 +1,214 @@ +"""Tests for lazy_backend_proxy — on-demand subprocess lifecycle.""" + +import asyncio +import socket +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from tinyagentos.lazy_backend_proxy import LazyBackendProxy + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +class _EchoServer: + def __init__(self, host: str, port: int): + self.host = host + self.port = port + self._server = None + self.connections = 0 + + async def start(self): + self._server = await asyncio.start_server( + self._handle, host=self.host, port=self.port + ) + + async def stop(self): + if self._server: + self._server.close() + await self._server.wait_closed() + + async def _handle(self, reader, writer): + self.connections += 1 + data = await reader.read(65536) + if data: + resp = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/plain\r\n" + b"Content-Length: " + str(len(data)).encode() + b"\r\n" + b"\r\n" + data + ) + writer.write(resp) + await writer.drain() + writer.close() + + +def _make_proxy(echo_port: int, **kw) -> LazyBackendProxy: + defaults = dict( + proxy_port=_free_port(), + backend_host="127.0.0.1", + backend_port=echo_port, + start_cmd="echo fake-start", + idle_timeout_seconds=1.0, + ) + defaults.update(kw) + return LazyBackendProxy(**defaults) + + +# -- tests ------------------------------------------------------------------- + +class TestLifecycle: + @pytest.mark.asyncio + async def test_start_stop(self): + p = _make_proxy(1) # port doesn't matter, backend never started + await p.start() + assert p.is_running + await p.stop() + assert not p.is_running + + @pytest.mark.asyncio + async def test_double_start_idempotent(self): + p = _make_proxy(1) + await p.start() + await p.start() + assert p.is_running + await p.stop() + + @pytest.mark.asyncio + async def test_stop_when_stopped_is_safe(self): + p = _make_proxy(1) + await p.stop() + + +class TestProxyForwarding: + @pytest.mark.asyncio + async def test_bidirectional_forwarding(self): + port = _free_port() + echo = _EchoServer("127.0.0.1", port) + await echo.start() + try: + p = _make_proxy(port) + with patch.object(p, "_ensure_backend", new_callable=AsyncMock): + await p.start() + try: + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.post(p.url + "/x", content=b"hello") + assert resp.status_code == 200 + assert b"hello" in resp.content + assert echo.connections >= 1 + finally: + await p.stop() + finally: + await echo.stop() + + @pytest.mark.asyncio + async def test_503_when_backend_refuses(self): + p = _make_proxy(1, backend_port=1) # port 1 is closed + with patch.object(p, "_ensure_backend", new_callable=AsyncMock): + await p.start() + try: + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.get(p.url, timeout=2) + # httpx may get a 503 or throw on connection refused + assert resp.status_code == 503 or resp.status_code >= 400 + except (httpx.ConnectError, httpx.ReadError, OSError): + pass # connection refused is also acceptable + finally: + await p.stop() + + +class TestRealSubprocessLifecycle: + @pytest.mark.asyncio + async def test_cold_start_launches_real_process(self): + backend_port = _free_port() + p = LazyBackendProxy( + proxy_port=_free_port(), + backend_host="127.0.0.1", + backend_port=backend_port, + start_cmd=f"python3 -m http.server {backend_port} --bind 127.0.0.1", + idle_timeout_seconds=1.0, + health_url=f"http://127.0.0.1:{backend_port}/", + ) + await p.start() + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(p.url + "/", timeout=15) + assert resp.status_code == 200 + assert p._proc is not None and p._proc.poll() is None + finally: + await p.stop() + if p._proc and p._proc.poll() is None: + p._proc.kill() + + @pytest.mark.asyncio + async def test_cold_start_fails_when_command_exits(self): + backend_port = _free_port() + p = LazyBackendProxy( + proxy_port=_free_port(), + backend_host="127.0.0.1", + backend_port=backend_port, + start_cmd="exit 1", + idle_timeout_seconds=1.0, + health_url=f"http://127.0.0.1:{backend_port}/health", + ) + await p.start() + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(p.url, timeout=8) + # Proxy should return 503 after subprocess exits + assert resp.status_code >= 400 + finally: + await p.stop() + + +class TestIdleTimeout: + @pytest.mark.asyncio + async def test_idle_stops_backend_after_timeout(self): + backend_port = _free_port() + p = LazyBackendProxy( + proxy_port=_free_port(), + backend_host="127.0.0.1", + backend_port=backend_port, + start_cmd=f"python3 -m http.server {backend_port} --bind 127.0.0.1", + idle_timeout_seconds=1.0, + health_url=f"http://127.0.0.1:{backend_port}/", + ) + await p.start() + try: + async with httpx.AsyncClient(timeout=15) as client: + await client.get(p.url + "/", timeout=15) + assert p._proc is not None and p._proc.poll() is None + await asyncio.sleep(2.0) + assert p._proc is None or p._proc.poll() is not None + finally: + await p.stop() + if p._proc and p._proc.poll() is None: + p._proc.kill() + + @pytest.mark.asyncio + async def test_active_requests_reset_idle_timer(self): + backend_port = _free_port() + p = LazyBackendProxy( + proxy_port=_free_port(), + backend_host="127.0.0.1", + backend_port=backend_port, + start_cmd=f"python3 -m http.server {backend_port} --bind 127.0.0.1", + idle_timeout_seconds=2.0, + health_url=f"http://127.0.0.1:{backend_port}/", + ) + await p.start() + try: + for _ in range(5): + async with httpx.AsyncClient(timeout=10) as client: + await client.get(p.url + "/", timeout=10) + await asyncio.sleep(0.6) + assert p._proc is not None and p._proc.poll() is None + finally: + await p.stop() + if p._proc and p._proc.poll() is None: + p._proc.kill() diff --git a/tinyagentos/lazy_backend_proxy.py b/tinyagentos/lazy_backend_proxy.py new file mode 100644 index 000000000..2a26cd366 --- /dev/null +++ b/tinyagentos/lazy_backend_proxy.py @@ -0,0 +1,272 @@ +"""Lazy backend proxy — start backends on first request, stop after idle TTL. + +Wraps backend servers that load their model at process start and have no +built-in idle eviction (sd-server, llama-server, whisper.cpp server). + +Pattern from docs/superpowers/specs/2026-04-11-taos-framework-integration-bridge-design.md +Phase 1.5 § Lazy lifecycle wrappers. +""" + +from __future__ import annotations + +import asyncio +import logging +import subprocess +import time + +import httpx + +logger = logging.getLogger(__name__) + +_HEALTH_POLL_INTERVAL = 1.0 +_COLD_START_TIMEOUT = 120.0 +_STOP_GRACE_PERIOD = 10.0 + + +class LazyBackendProxy: + """Transparent TCP proxy with lazy subprocess lifecycle. + + Listens on ``proxy_port``. On the first inbound connection, runs + ``start_cmd`` to launch the real backend. All subsequent connections are + forwarded bidirectionally to ``backend_host:backend_port`` until the proxy + has been idle for ``idle_timeout_seconds``, at which point the subprocess + is stopped. PWA / SSE / raw HTTP all pass through unchanged. + """ + + def __init__( + self, + *, + proxy_port: int, + backend_host: str = "127.0.0.1", + backend_port: int, + start_cmd: str, + stop_cmd: str = "", + idle_timeout_seconds: float = 300.0, + health_url: str | None = None, + ) -> None: + if not start_cmd: + raise ValueError("start_cmd is required") + self._proxy_port = proxy_port + self._backend_host = backend_host + self._backend_port = backend_port + self._start_cmd = start_cmd + self._stop_cmd = stop_cmd + self._idle_timeout = idle_timeout_seconds + self._health_url = health_url or f"http://{backend_host}:{backend_port}/health" + + self._proc: subprocess.Popen | None = None + self._last_request: float = 0.0 + self._start_lock = asyncio.Lock() + self._idle_task: asyncio.Task | None = None + self._server: asyncio.AbstractServer | None = None + self._running = False + + # -- public API ---------------------------------------------------------- + + @property + def url(self) -> str: + return f"http://127.0.0.1:{self._proxy_port}" + + @property + def is_running(self) -> bool: + return self._running + + async def start(self) -> None: + """Start listening (does NOT start the subprocess yet).""" + if self._running: + return + self._server = await asyncio.start_server( + self._handle_connection, + host="127.0.0.1", + port=self._proxy_port, + ) + self._running = True + logger.info( + "lazy-proxy :%d → %s:%d (idle=%.0fs)", + self._proxy_port, + self._backend_host, + self._backend_port, + self._idle_timeout, + ) + + async def stop(self) -> None: + """Stop the proxy and the underlying subprocess.""" + if not self._running: + return + self._running = False + self._cancel_idle() + await self._stop_subprocess() + if self._server: + self._server.close() + await self._server.wait_closed() + self._server = None + logger.info("lazy-proxy :%d stopped", self._proxy_port) + + # -- connection handler --------------------------------------------------- + + async def _handle_connection( + self, + client_reader: asyncio.StreamReader, + client_writer: asyncio.StreamWriter, + ) -> None: + if not self._running: + client_writer.close() + return + + try: + await self._ensure_backend() + except Exception: + _write_503(client_writer) + return + + try: + backend_reader, backend_writer = await asyncio.wait_for( + asyncio.open_connection(self._backend_host, self._backend_port), + timeout=5.0, + ) + except Exception: + _write_503(client_writer) + await self._restart_idle_timer() + return + + # Bidirectional copy. + async def _pipe(src: asyncio.StreamReader, dst: asyncio.StreamWriter): + try: + while True: + data = await src.read(65536) + if not data: + break + dst.write(data) + await dst.drain() + except (ConnectionResetError, BrokenPipeError, OSError): + pass + + try: + await asyncio.gather( + _pipe(client_reader, backend_writer), + _pipe(backend_reader, client_writer), + ) + except Exception: + pass + finally: + backend_writer.close() + client_writer.close() + + await self._restart_idle_timer() + + # -- subprocess lifecycle ------------------------------------------------ + + async def _ensure_backend(self) -> None: + """Start the subprocess if it isn't already running.""" + if self._proc is not None and self._proc.poll() is None: + return + + async with self._start_lock: + if self._proc is not None and self._proc.poll() is None: + return + + logger.info("lazy-proxy :%d → starting: %r", self._proxy_port, self._start_cmd) + self._proc = subprocess.Popen( + self._start_cmd, + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + deadline = time.monotonic() + _COLD_START_TIMEOUT + while time.monotonic() < deadline: + if self._proc.poll() is not None: + raise RuntimeError( + f"Backend process exited with code {self._proc.returncode}" + ) + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(3.0)) as client: + resp = await client.get(self._health_url) + if resp.status_code < 500: + logger.info("lazy-proxy :%d → healthy", self._proxy_port) + return + except Exception: + pass + await asyncio.sleep(_HEALTH_POLL_INTERVAL) + + self._kill_subprocess() + raise TimeoutError( + f"Backend at {self._backend_host}:{self._backend_port} " + f"did not become healthy within {_COLD_START_TIMEOUT}s" + ) + + async def _stop_subprocess(self) -> None: + if self._proc is None or self._proc.poll() is not None: + self._proc = None + return + logger.info("lazy-proxy :%d → stopping backend", self._proxy_port) + if self._stop_cmd: + stop_proc: subprocess.Popen | None = None + try: + stop_proc = subprocess.Popen( + self._stop_cmd, + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + stop_proc.wait(timeout=_STOP_GRACE_PERIOD) + except subprocess.TimeoutExpired: + if stop_proc: + stop_proc.kill() + else: + self._proc.terminate() + try: + self._proc.wait(timeout=_STOP_GRACE_PERIOD) + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc = None + + def _kill_subprocess(self) -> None: + if self._proc is None: + return + try: + self._proc.kill() + self._proc.wait(timeout=5) + except Exception: + pass + self._proc = None + + # -- idle timer ---------------------------------------------------------- + + async def _restart_idle_timer(self) -> None: + self._last_request = time.monotonic() + self._cancel_idle() + if self._idle_timeout > 0: + self._idle_task = asyncio.create_task(self._idle_expire()) + + def _cancel_idle(self) -> None: + if self._idle_task and not self._idle_task.done(): + self._idle_task.cancel() + self._idle_task = None + + async def _idle_expire(self) -> None: + elapsed = time.monotonic() - self._last_request + remaining = self._idle_timeout - elapsed + if remaining > 0: + await asyncio.sleep(remaining) + if self._running: + logger.info( + "lazy-proxy :%d → idle timeout (%.0fs), stopping backend", + self._proxy_port, + self._idle_timeout, + ) + await self._stop_subprocess() + + +# -- helpers ------------------------------------------------------------------ + +def _write_503(writer: asyncio.StreamWriter) -> None: + body = b'{"error":"backend unavailable"}' + writer.write( + b"HTTP/1.1 503 Service Unavailable\r\n" + b"Content-Type: application/json\r\n" + b"Content-Length: " + str(len(body)).encode() + b"\r\n" + b"\r\n" + + body + ) + writer.close() From 688802fa73bd17106df12d532d275a2ba72291be Mon Sep 17 00:00:00 2001 From: Hogne Date: Sat, 30 May 2026 23:40:18 +0200 Subject: [PATCH 2/3] fix(lazy-proxy): address all review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Track active connections count; cancel idle timer on accept, only restart when connections hit zero (CodeRabbit critical, kilo line 129) - Remove idle restart on failed backend connections (kilo line 129) - Log cold-start duration in health check (CodeRabbit major) - Use asyncio.to_thread for subprocess waits — no event-loop blocking in _stop_subprocess / _kill_subprocess (CodeRabbit major) - Fix _stop_subprocess to always terminate/wait backend after stop_cmd helper, not just the helper process (CodeRabbit major) - Health check: require 200-299, not <500 (kilo line 185) --- tinyagentos/lazy_backend_proxy.py | 73 +++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 19 deletions(-) diff --git a/tinyagentos/lazy_backend_proxy.py b/tinyagentos/lazy_backend_proxy.py index 2a26cd366..c2e2112a7 100644 --- a/tinyagentos/lazy_backend_proxy.py +++ b/tinyagentos/lazy_backend_proxy.py @@ -29,8 +29,9 @@ class LazyBackendProxy: Listens on ``proxy_port``. On the first inbound connection, runs ``start_cmd`` to launch the real backend. All subsequent connections are forwarded bidirectionally to ``backend_host:backend_port`` until the proxy - has been idle for ``idle_timeout_seconds``, at which point the subprocess - is stopped. PWA / SSE / raw HTTP all pass through unchanged. + has had *no active connections* for ``idle_timeout_seconds``, at which + point the subprocess is stopped. PWA / SSE / raw HTTP all pass through + unchanged. """ def __init__( @@ -56,6 +57,7 @@ def __init__( self._proc: subprocess.Popen | None = None self._last_request: float = 0.0 + self._active_connections: int = 0 self._start_lock = asyncio.Lock() self._idle_task: asyncio.Task | None = None self._server: asyncio.AbstractServer | None = None @@ -113,6 +115,11 @@ async def _handle_connection( client_writer.close() return + self._active_connections += 1 + self._cancel_idle() + + backend_writer: asyncio.StreamWriter | None = None + try: await self._ensure_backend() except Exception: @@ -126,7 +133,6 @@ async def _handle_connection( ) except Exception: _write_503(client_writer) - await self._restart_idle_timer() return # Bidirectional copy. @@ -149,10 +155,12 @@ async def _pipe(src: asyncio.StreamReader, dst: asyncio.StreamWriter): except Exception: pass finally: - backend_writer.close() + if backend_writer is not None: + backend_writer.close() client_writer.close() - - await self._restart_idle_timer() + self._active_connections -= 1 + if self._running and self._active_connections == 0: + await self._restart_idle_timer() # -- subprocess lifecycle ------------------------------------------------ @@ -165,6 +173,7 @@ async def _ensure_backend(self) -> None: if self._proc is not None and self._proc.poll() is None: return + cold_start_started_at = time.monotonic() logger.info("lazy-proxy :%d → starting: %r", self._proxy_port, self._start_cmd) self._proc = subprocess.Popen( self._start_cmd, @@ -182,17 +191,28 @@ async def _ensure_backend(self) -> None: try: async with httpx.AsyncClient(timeout=httpx.Timeout(3.0)) as client: resp = await client.get(self._health_url) - if resp.status_code < 500: - logger.info("lazy-proxy :%d → healthy", self._proxy_port) + if 200 <= resp.status_code < 300: + logger.info( + "lazy-proxy :%d → healthy in %.2fs", + self._proxy_port, + time.monotonic() - cold_start_started_at, + ) return except Exception: pass await asyncio.sleep(_HEALTH_POLL_INTERVAL) - self._kill_subprocess() + await self._kill_subprocess() + elapsed = time.monotonic() - cold_start_started_at + logger.warning( + "lazy-proxy :%d → cold start timed out after %.2fs", + self._proxy_port, + elapsed, + ) raise TimeoutError( f"Backend at {self._backend_host}:{self._backend_port} " - f"did not become healthy within {_COLD_START_TIMEOUT}s" + f"did not become healthy within {_COLD_START_TIMEOUT}s " + f"(waited {elapsed:.1f}s)" ) async def _stop_subprocess(self) -> None: @@ -200,6 +220,9 @@ async def _stop_subprocess(self) -> None: self._proc = None return logger.info("lazy-proxy :%d → stopping backend", self._proxy_port) + + proc = self._proc # keep local ref — _kill_subprocess clears self._proc + if self._stop_cmd: stop_proc: subprocess.Popen | None = None try: @@ -209,25 +232,37 @@ async def _stop_subprocess(self) -> None: stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - stop_proc.wait(timeout=_STOP_GRACE_PERIOD) + await asyncio.to_thread(stop_proc.wait, timeout=_STOP_GRACE_PERIOD) except subprocess.TimeoutExpired: if stop_proc: stop_proc.kill() - else: - self._proc.terminate() + + # Always terminate/wait the actual backend process. + proc.terminate() + try: + await asyncio.wait_for( + asyncio.to_thread(proc.wait), timeout=_STOP_GRACE_PERIOD + ) + except (asyncio.TimeoutError, subprocess.TimeoutExpired): + proc.kill() try: - self._proc.wait(timeout=_STOP_GRACE_PERIOD) - except subprocess.TimeoutExpired: - self._proc.kill() + await asyncio.wait_for( + asyncio.to_thread(proc.wait), timeout=5 + ) + except (asyncio.TimeoutError, subprocess.TimeoutExpired): + pass + self._proc = None - def _kill_subprocess(self) -> None: + async def _kill_subprocess(self) -> None: if self._proc is None: return try: self._proc.kill() - self._proc.wait(timeout=5) - except Exception: + await asyncio.wait_for( + asyncio.to_thread(self._proc.wait), timeout=5 + ) + except (asyncio.TimeoutError, subprocess.TimeoutExpired): pass self._proc = None From c7a0a9f865bfdd9a057b488552708f2eb90b21bd Mon Sep 17 00:00:00 2001 From: Hogne Date: Sat, 30 May 2026 23:48:45 +0200 Subject: [PATCH 3/3] fix(lazy-proxy): wrap handler in outer try/finally to prevent counter leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _active_connections was leaked on early returns from _ensure_backend() or asyncio.open_connection() errors — the counter never decremented, preventing idle timeout from ever firing. --- tinyagentos/lazy_backend_proxy.py | 59 ++++++++++++++++--------------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/tinyagentos/lazy_backend_proxy.py b/tinyagentos/lazy_backend_proxy.py index c2e2112a7..4f7dabdcd 100644 --- a/tinyagentos/lazy_backend_proxy.py +++ b/tinyagentos/lazy_backend_proxy.py @@ -121,39 +121,40 @@ async def _handle_connection( backend_writer: asyncio.StreamWriter | None = None try: - await self._ensure_backend() - except Exception: - _write_503(client_writer) - return + try: + await self._ensure_backend() + except Exception: + _write_503(client_writer) + return - try: - backend_reader, backend_writer = await asyncio.wait_for( - asyncio.open_connection(self._backend_host, self._backend_port), - timeout=5.0, - ) - except Exception: - _write_503(client_writer) - return + try: + backend_reader, backend_writer = await asyncio.wait_for( + asyncio.open_connection(self._backend_host, self._backend_port), + timeout=5.0, + ) + except Exception: + _write_503(client_writer) + return + + # Bidirectional copy. + async def _pipe(src: asyncio.StreamReader, dst: asyncio.StreamWriter): + try: + while True: + data = await src.read(65536) + if not data: + break + dst.write(data) + await dst.drain() + except (ConnectionResetError, BrokenPipeError, OSError): + pass - # Bidirectional copy. - async def _pipe(src: asyncio.StreamReader, dst: asyncio.StreamWriter): try: - while True: - data = await src.read(65536) - if not data: - break - dst.write(data) - await dst.drain() - except (ConnectionResetError, BrokenPipeError, OSError): + await asyncio.gather( + _pipe(client_reader, backend_writer), + _pipe(backend_reader, client_writer), + ) + except Exception: pass - - try: - await asyncio.gather( - _pipe(client_reader, backend_writer), - _pipe(backend_reader, client_writer), - ) - except Exception: - pass finally: if backend_writer is not None: backend_writer.close()