main: deployment source of truth + homelab sidecar scripts - CI (image build + npm publish) now triggers on main instead of release. - homelab/: version-controlled copies of the NAS-side lazy proxies (whisper-lazy, runpod-lazy) and the runpod-pod CLI.

54b88d3c278f20af11bb25f34e328844ed810328

permissionBRICK <40219477+permissionBRICK@users.noreply.github.com>

5 files changed, +580 -2Showing whitespace changes
.gitlab-ci.yml+2 -2
@@ -5,7 +5,7 @@ stages:
55build-image:
66 stage: build
77 only:
88 - releasemain
99 image: docker:latest
1010 services:
1111 - name: docker:dind
@@ -33,7 +33,7 @@ build-image:
3333publish-npm:
3434 stage: publish
3535 only:
3636 - releasemain
3737 image: node:24
3838 variables:
3939 npm_config_cache: .npm
homelab/README.md+15 -0
@@ -0,0 +1,15 @@
1+# Homelab sidecar services
2+
3+Version-controlled copies of the NAS-side scripts that support this
4+SillyTavern deployment. The deployed copies live on the unraid NAS; when
5+changing one, update both (scp to the NAS path + commit here).
6+
7+| File | Deployed at | Purpose |
8+|---|---|---|
9+| `whisper-lazy-proxy.py` | `/mnt/user/appdata/whisper-lazy/proxy.py` (container `whisper-lazy`, :10300) | Wyoming lazy proxy: answers HA availability checks from cache, starts `faster-whisper` on demand, stops it after 15 min idle (frees GPU VRAM). |
10+| `runpod-lazy-proxy.py` | `/mnt/user/appdata/runpod-lazy/proxy.py` (container `runpod-lazy`, :8189) | ComfyUI-compatible lazy proxy for a volumeless RunPod GPU pod: wake-on-generate with model-group detection, `/lazy/status|warmup|shutdown|ping` control API for the SillyTavern warmup UI, NAS-side 15-min idle terminate. |
11+| `runpod-pod.sh` | `/boot/config/runpod-pod.sh` | CLI for the runpod-lazy control API (`status|warmup|shutdown|ping|watch`). |
12+
13+Related: the RunPod worker image (ComfyUI + custom nodes + boot-time model
14+downloader) is built from github.com/permissionBRICK/comfyui-runpod-worker
15+(private repo, public GHCR package; mirrored on this GitGudLab).
homelab/runpod-lazy-proxy.py+325 -0
@@ -0,0 +1,325 @@
1+#!/usr/bin/env python3
2+"""ComfyUI lazy proxy for a volumeless RunPod on-demand pod.
3+
4+SillyTavern's fallback chain points at this proxy as a normal ComfyUI server;
5+the pod downloads its models at boot (boot-models.py in the image), so no
6+network volume or region lock is needed.
7+
8+Control API (CORS-enabled, used by the ST warmup UI and the CLI script):
9+ GET /lazy/status -> {"state": "red"|"orange"|"green", ...}
10+ POST /lazy/warmup -> start pod with MODELS=all (idempotent)
11+ POST /lazy/shutdown -> terminate pod now
12+ POST /lazy/ping -> keepalive (extends idle timer; never starts a pod)
13+
14+Wake semantics: a ComfyUI /prompt POST arriving with no pod starts one that
15+downloads only the model group the workflow references (flux/qwen/all).
16+The idle timer lives HERE (NAS side): IDLE_SECONDS after the last activity
17+signal (comfy traffic or keepalive ping) the pod is terminated - regardless
18+of whether any frontend still exists.
19+
20+stdlib only; runs on python:3.12-alpine.
21+"""
22+import json
23+import os
24+import threading
25+import time
26+import urllib.error
27+import urllib.request
28+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
29+
30+RUNPOD_KEY = os.environ['RUNPOD_KEY']
31+LISTEN_PORT = int(os.environ.get('LISTEN_PORT', '8189'))
32+IDLE_SECONDS = int(os.environ.get('IDLE_SECONDS', '900'))
33+DATACENTERS = [d for d in os.environ.get('DATACENTERS', '').split(',') if d] # empty = any
34+CLOUD_TYPE = os.environ.get('CLOUD_TYPE', 'SECURE')
35+IMAGE = os.environ.get('IMAGE', 'ghcr.io/permissionbrick/comfyui-runpod-worker:latest')
36+GPU_TYPES = os.environ.get('GPU_TYPES', 'NVIDIA RTX 6000 Ada Generation,NVIDIA L40S,NVIDIA L40,NVIDIA RTX A6000,NVIDIA A40').split(',')
37+POD_NAME = os.environ.get('POD_NAME', 'comfyui-lazy')
38+START_TIMEOUT = int(os.environ.get('START_TIMEOUT', '1500'))
39+HF_TOKEN = os.environ.get('HF_TOKEN', '')
40+COMFY_ARGS = os.environ.get('COMFY_ARGS', '--listen 0.0.0.0 --port 8188 --use-pytorch-cross-attention')
41+CACHE_FILE = os.environ.get('CACHE_FILE', '/app/system_stats.cache')
42+
43+# Model-group detection: filename fragment (lowercased) -> group
44+GROUP_HINTS = {'flux-2-klein': 'flux', 'flux2-klein': 'flux', 'flux2-vae': 'flux', 'qwen-rapid': 'qwen'}
45+
46+state = {
47+ 'pod_id': None, # current pod id (may still be booting)
48+ 'models': None, # MODELS value the pod was created with
49+ 'phase': 'red', # red | orange | green
50+ 'gpu': None,
51+ 'since': time.time(),
52+ 'last': time.monotonic(),
53+ 'lock': threading.Lock(),
54+}
55+
56+
57+def log(*args):
58+ print(time.strftime('%H:%M:%S'), *args, flush=True)
59+
60+
61+def api(method, path, body=None):
62+ req = urllib.request.Request(
63+ f'https://rest.runpod.io/v1{path}',
64+ json.dumps(body).encode() if body is not None else None,
65+ {'Authorization': f'Bearer {RUNPOD_KEY}', 'Content-Type': 'application/json'},
66+ method=method)
67+ with urllib.request.urlopen(req, timeout=60) as resp:
68+ data = resp.read()
69+ return json.loads(data) if data else {}
70+
71+
72+def find_pod():
73+ try:
74+ for pod in api('GET', '/pods'):
75+ if pod.get('name') == POD_NAME and pod.get('desiredStatus') == 'RUNNING':
76+ return pod['id'], (pod.get('env') or {}).get('MODELS'), pod.get('machine', {}).get('gpuTypeId')
77+ except Exception as err:
78+ log('find_pod failed:', err)
79+ return None, None, None
80+
81+
82+def create_pod(models):
83+ body = {
84+ 'name': POD_NAME,
85+ 'imageName': IMAGE,
86+ 'gpuTypeIds': GPU_TYPES,
87+ 'gpuCount': 1,
88+ 'cloudType': CLOUD_TYPE,
89+ 'containerDiskInGb': 80,
90+ 'ports': ['8188/http'],
91+ 'env': {'MODELS': models, 'HF_TOKEN': HF_TOKEN},
92+ 'dockerStartCmd': ['bash', '-c', f'python3 /boot-models.py && cd /comfyui && exec python main.py {COMFY_ARGS}'],
93+ }
94+ if DATACENTERS:
95+ body['dataCenterIds'] = DATACENTERS
96+ pod = api('POST', '/pods', body)
97+ log(f"pod created: {pod['id']} MODELS={models} {pod.get('machine', {}).get('gpuTypeId')} ${pod.get('costPerHr')}/hr")
98+ return pod['id'], pod.get('machine', {}).get('gpuTypeId')
99+
100+
101+def pod_url(pod_id):
102+ return f'https://{pod_id}-8188.proxy.runpod.net'
103+
104+
105+def upstream_ready(pod_id):
106+ try:
107+ with urllib.request.urlopen(pod_url(pod_id) + '/system_stats', timeout=5) as resp:
108+ if resp.status == 200:
109+ data = resp.read()
110+ with open(CACHE_FILE, 'wb') as f:
111+ f.write(data)
112+ return True
113+ except Exception:
114+ pass
115+ return False
116+
117+
118+def groups_cover(have, need):
119+ if not have:
120+ return False
121+ have_set = set(have.split(','))
122+ need_set = set(need.split(','))
123+ return 'all' in have_set or need_set <= have_set
124+
125+
126+def ensure_pod(models='all', wait=True):
127+ """Ensures a pod covering the requested model groups; optionally waits for readiness."""
128+ with state['lock']:
129+ pod_id, have, gpu = (state['pod_id'], state['models'], state['gpu'])
130+ if not pod_id:
131+ pod_id, have, gpu = find_pod()
132+ if pod_id and not groups_cover(have, models):
133+ log(f'pod has MODELS={have}, need {models} - recreating with all')
134+ try:
135+ api('DELETE', f'/pods/{pod_id}')
136+ except Exception as err:
137+ log('delete failed:', err)
138+ pod_id, have, gpu = None, None, None
139+ models = 'all'
140+ if not pod_id:
141+ state['phase'] = 'orange'
142+ state['since'] = time.time()
143+ last_err = None
144+ for attempt in range(3):
145+ try:
146+ pod_id, gpu = create_pod(models)
147+ break
148+ except urllib.error.HTTPError as err:
149+ last_err = err.read().decode()[:200]
150+ log(f'create_pod attempt {attempt + 1} failed:', last_err)
151+ time.sleep(5)
152+ if not pod_id:
153+ state['phase'] = 'red'
154+ raise RuntimeError(f'could not create pod: {last_err}')
155+ have = models
156+ state['pod_id'], state['models'], state['gpu'] = pod_id, have, gpu
157+ state['last'] = time.monotonic()
158+
159+ if state['phase'] != 'green':
160+ state['phase'] = 'orange'
161+ if not wait:
162+ return pod_id
163+ deadline = time.monotonic() + START_TIMEOUT
164+ while time.monotonic() < deadline:
165+ if upstream_ready(pod_id):
166+ if state['phase'] != 'green':
167+ state['phase'] = 'green'
168+ state['since'] = time.time()
169+ log('pod ready:', pod_id)
170+ state['last'] = time.monotonic()
171+ return pod_id
172+ if state['pod_id'] != pod_id:
173+ raise RuntimeError('pod replaced while waiting')
174+ time.sleep(5)
175+ raise RuntimeError('pod not ready within START_TIMEOUT')
176+
177+
178+def detect_groups(workflow_bytes):
179+ try:
180+ text = workflow_bytes.decode('utf-8', 'replace').lower()
181+ except Exception:
182+ return 'all'
183+ found = {group for hint, group in GROUP_HINTS.items() if hint in text}
184+ if len(found) == 1:
185+ return found.pop()
186+ return 'all'
187+
188+
189+def idle_reaper():
190+ while True:
191+ time.sleep(30)
192+ idle = time.monotonic() - state['last']
193+ if idle > IDLE_SECONDS:
194+ pod_id = state['pod_id'] or find_pod()[0]
195+ if pod_id:
196+ log(f'idle {int(idle)}s - terminating pod')
197+ try:
198+ api('DELETE', f'/pods/{pod_id}')
199+ except Exception as err:
200+ log('terminate failed:', err)
201+ if pod_id or state['phase'] != 'red':
202+ state.update({'pod_id': None, 'models': None, 'gpu': None, 'phase': 'red', 'since': time.time()})
203+
204+
205+def status_body():
206+ # Refresh green-ness cheaply on demand
207+ pod_id = state['pod_id']
208+ if pod_id and state['phase'] != 'green' and upstream_ready(pod_id):
209+ state['phase'] = 'green'
210+ state['since'] = time.time()
211+ if not pod_id:
212+ found, have, gpu = find_pod()
213+ if found:
214+ state.update({'pod_id': found, 'models': have, 'gpu': gpu})
215+ state['phase'] = 'green' if upstream_ready(found) else 'orange'
216+ else:
217+ state['phase'] = 'red'
218+ return json.dumps({
219+ 'state': state['phase'],
220+ 'pod_id': state['pod_id'],
221+ 'models': state['models'],
222+ 'gpu': state['gpu'],
223+ 'since': state['since'],
224+ 'url': pod_url(state['pod_id']) if state['pod_id'] else None,
225+ 'idle_seconds_left': max(0, IDLE_SECONDS - int(time.monotonic() - state['last'])) if state['pod_id'] else 0,
226+ }).encode()
227+
228+
229+class Proxy(BaseHTTPRequestHandler):
230+ protocol_version = 'HTTP/1.1'
231+
232+ def log_message(self, fmt, *args):
233+ pass
234+
235+ def _reply(self, code, data, ctype='application/json'):
236+ self.send_response(code)
237+ self.send_header('Content-Type', ctype or 'application/json')
238+ self.send_header('Content-Length', str(len(data)))
239+ self.send_header('Access-Control-Allow-Origin', '*')
240+ self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
241+ self.send_header('Access-Control-Allow-Headers', 'Content-Type')
242+ self.end_headers()
243+ self.wfile.write(data)
244+
245+ def do_OPTIONS(self):
246+ self._reply(204, b'')
247+
248+ def _handle(self):
249+ length = int(self.headers.get('Content-Length') or 0)
250+ body = self.rfile.read(length) if length else None
251+ path = self.path.split('?')[0]
252+
253+ # ---- control API ----
254+ if path == '/lazy/status':
255+ return self._reply(200, status_body())
256+ if path == '/lazy/ping':
257+ if state['pod_id']:
258+ state['last'] = time.monotonic()
259+ return self._reply(200, b'{"ok": true}')
260+ if path == '/lazy/warmup':
261+ threading.Thread(target=self._safe_ensure, args=('all',), daemon=True).start()
262+ state['phase'] = state['phase'] if state['phase'] == 'green' else 'orange'
263+ return self._reply(200, status_body())
264+ if path == '/lazy/shutdown':
265+ pod_id = state['pod_id'] or find_pod()[0]
266+ if pod_id:
267+ try:
268+ api('DELETE', f'/pods/{pod_id}')
269+ except Exception as err:
270+ return self._reply(500, json.dumps({'error': str(err)}).encode())
271+ state.update({'pod_id': None, 'models': None, 'gpu': None, 'phase': 'red', 'since': time.time()})
272+ return self._reply(200, status_body())
273+
274+ # ---- comfy proxying ----
275+ state['last'] = time.monotonic()
276+ pod_id = state['pod_id']
277+ ready = pod_id and upstream_ready(pod_id)
278+ if not ready and self.command == 'GET' and path == '/system_stats':
279+ # Reachability probe while cold: answer from cache; do NOT wake.
280+ try:
281+ with open(CACHE_FILE, 'rb') as f:
282+ return self._reply(200, f.read())
283+ except FileNotFoundError:
284+ return self._reply(200, json.dumps({'system': {'comfyui_version': 'cold'}, 'devices': []}).encode())
285+ if not ready:
286+ models = detect_groups(body) if (self.command == 'POST' and path == '/prompt' and body) else 'all'
287+ try:
288+ pod_id = ensure_pod(models)
289+ except Exception as err:
290+ log('ensure_pod failed:', err)
291+ return self._reply(502, json.dumps({'error': str(err)}).encode())
292+ try:
293+ req = urllib.request.Request(pod_url(pod_id) + self.path, body, method=self.command)
294+ for header in ('Content-Type', 'Accept'):
295+ if self.headers.get(header):
296+ req.add_header(header, self.headers[header])
297+ with urllib.request.urlopen(req, timeout=300) as resp:
298+ self._reply(resp.status, resp.read(), resp.headers.get('Content-Type'))
299+ except urllib.error.HTTPError as err:
300+ self._reply(err.code, err.read())
301+ except Exception as err:
302+ log('upstream error:', err)
303+ self._reply(502, json.dumps({'error': str(err)}).encode())
304+ finally:
305+ state['last'] = time.monotonic()
306+
307+ def _safe_ensure(self, models):
308+ try:
309+ ensure_pod(models)
310+ except Exception as err:
311+ log('warmup failed:', err)
312+
313+ do_GET = _handle
314+ do_POST = _handle
315+
316+
317+def main():
318+ threading.Thread(target=idle_reaper, daemon=True).start()
319+ server = ThreadingHTTPServer(('0.0.0.0', LISTEN_PORT), Proxy)
320+ log(f'runpod-lazy v2 on :{LISTEN_PORT} (pod "{POD_NAME}", DCs {DATACENTERS or "any"}, idle {IDLE_SECONDS}s)')
321+ server.serve_forever()
322+
323+
324+if __name__ == '__main__':
325+ main()
homelab/runpod-pod.sh+15 -0
@@ -0,0 +1,15 @@
1+#!/bin/sh
2+# CLI for the runpod-lazy proxy: status | warmup | shutdown | ping
3+# Usable by anyone (or any AI) without SillyTavern. Proxy owns the pod
4+# lifecycle; this only talks to its control API.
5+CMD="${1:-status}"
6+URL="${RUNPOD_LAZY_URL:-http://127.0.0.1:8189}"
7+case "$CMD" in
8+ status) curl -s "$URL/lazy/status" ;;
9+ warmup) curl -s -X POST "$URL/lazy/warmup" ;;
10+ shutdown) curl -s -X POST "$URL/lazy/shutdown" ;;
11+ ping) curl -s -X POST "$URL/lazy/ping" ;;
12+ watch) while true; do printf '%s ' "$(date +%H:%M:%S)"; curl -s "$URL/lazy/status"; echo; sleep 10; done ;;
13+ *) echo "usage: runpod-pod.sh [status|warmup|shutdown|ping|watch]"; exit 1 ;;
14+esac
15+echo
homelab/whisper-lazy-proxy.py+223 -0
@@ -0,0 +1,223 @@
1+#!/usr/bin/env python3
2+"""Wyoming lazy proxy for faster-whisper.
3+
4+Home Assistant talks to this proxy instead of faster-whisper directly.
5+- `describe` events are answered from a cached `info` response, so HA's
6+ availability checks never touch the backend.
7+- The first *real* event of a connection (e.g. `transcribe`) starts the
8+ faster-whisper container via the docker socket, replays the event, and
9+ relays bytes in both directions from then on.
10+- After IDLE_SECONDS without an active session the container is stopped
11+ again, freeing its VRAM.
12+
13+stdlib only; runs on any python:3.12 image.
14+"""
15+import asyncio
16+import json
17+import os
18+import socket
19+import time
20+
21+BACKEND_HOST = os.environ.get('BACKEND_HOST', '10.0.0.4')
22+BACKEND_PORT = int(os.environ.get('BACKEND_PORT', '10302'))
23+PROXY_PORT = int(os.environ.get('PROXY_PORT', '10300'))
24+IDLE_SECONDS = int(os.environ.get('IDLE_SECONDS', '900'))
25+CONTAINER = os.environ.get('CONTAINER', 'faster-whisper')
26+DOCKER_SOCK = os.environ.get('DOCKER_SOCK', '/var/run/docker.sock')
27+CACHE_FILE = os.environ.get('CACHE_FILE', '/app/info.cache')
28+START_TIMEOUT = int(os.environ.get('START_TIMEOUT', '180'))
29+
30+state = {'active': 0, 'last': time.monotonic(), 'lock': None}
31+
32+
33+def log(*args):
34+ print(time.strftime('%H:%M:%S'), *args, flush=True)
35+
36+
37+def docker_api(method, path):
38+ """Minimal docker engine API call over the unix socket. Returns (status, body)."""
39+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
40+ sock.settimeout(60)
41+ sock.connect(DOCKER_SOCK)
42+ sock.sendall(f'{method} {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n'.encode())
43+ buf = b''
44+ while True:
45+ chunk = sock.recv(65536)
46+ if not chunk:
47+ break
48+ buf += chunk
49+ sock.close()
50+ head, _, body = buf.partition(b'\r\n\r\n')
51+ return int(head.split(b' ', 2)[1]), body
52+
53+
54+def backend_running():
55+ try:
56+ status, body = docker_api('GET', f'/containers/{CONTAINER}/json')
57+ if status != 200:
58+ return False
59+ text = body.decode('utf-8', 'replace')
60+ text = text[text.find('{'):text.rfind('}') + 1]
61+ return json.loads(text).get('State', {}).get('Running', False)
62+ except Exception as err:
63+ log('backend_running check failed:', err)
64+ return False
65+
66+
67+def start_container():
68+ status, _ = docker_api('POST', f'/containers/{CONTAINER}/start')
69+ return status in (204, 304)
70+
71+
72+def stop_container():
73+ status, _ = docker_api('POST', f'/containers/{CONTAINER}/stop?t=30')
74+ return status in (204, 304)
75+
76+
77+async def read_event(reader):
78+ """Reads one wyoming event; returns {'raw': bytes, 'type': str} or None on EOF."""
79+ line = await reader.readline()
80+ if not line:
81+ return None
82+ header = json.loads(line)
83+ extra = b''
84+ for key in ('data_length', 'payload_length'):
85+ length = int(header.get(key) or 0)
86+ if length:
87+ extra += await reader.readexactly(length)
88+ return {'raw': line + extra, 'type': header.get('type')}
89+
90+
91+async def ensure_backend():
92+ """Starts the backend container if needed and returns an open (reader, writer).
93+
94+ A bare TCP connect is not proof of readiness: docker-proxy accepts on the
95+ published port as soon as the container starts, long before the Wyoming
96+ server listens. So readiness = a real describe -> info handshake.
97+ """
98+ async with state['lock']:
99+ deadline = time.monotonic() + START_TIMEOUT
100+ if not await asyncio.to_thread(backend_running):
101+ log('starting backend container')
102+ await asyncio.to_thread(start_container)
103+ while time.monotonic() < deadline:
104+ try:
105+ reader, writer = await asyncio.open_connection(BACKEND_HOST, BACKEND_PORT)
106+ writer.write(json.dumps({'type': 'describe', 'data': {}}).encode() + b'\n')
107+ await writer.drain()
108+ event = await asyncio.wait_for(read_event(reader), 5)
109+ if event and event['type'] == 'info':
110+ # Connection is clean again after consuming the info event.
111+ return reader, writer
112+ writer.close()
113+ except (OSError, asyncio.TimeoutError, asyncio.IncompleteReadError, json.JSONDecodeError):
114+ pass
115+ await asyncio.sleep(1)
116+ raise RuntimeError('backend did not come up within START_TIMEOUT')
117+
118+
119+async def refresh_cache():
120+ try:
121+ reader, writer = await asyncio.open_connection(BACKEND_HOST, BACKEND_PORT)
122+ writer.write(json.dumps({'type': 'describe', 'data': {}}).encode() + b'\n')
123+ await writer.drain()
124+ event = await asyncio.wait_for(read_event(reader), 20)
125+ writer.close()
126+ if event and event['type'] == 'info':
127+ with open(CACHE_FILE, 'wb') as f:
128+ f.write(event['raw'])
129+ log('info cache refreshed:', len(event['raw']), 'bytes')
130+ except Exception as err:
131+ log('cache refresh failed:', err)
132+
133+
134+async def pipe(reader, writer):
135+ try:
136+ while True:
137+ chunk = await reader.read(65536)
138+ if not chunk:
139+ break
140+ writer.write(chunk)
141+ await writer.drain()
142+ except (ConnectionResetError, BrokenPipeError):
143+ pass
144+ finally:
145+ try:
146+ writer.close()
147+ except Exception:
148+ pass
149+
150+
151+async def handle_client(client_reader, client_writer):
152+ peer = client_writer.get_extra_info('peername')
153+ backend = None
154+ try:
155+ while backend is None:
156+ event = await read_event(client_reader)
157+ if event is None:
158+ return
159+ if event['type'] == 'ping':
160+ client_writer.write(json.dumps({'type': 'pong', 'data': {}}).encode() + b'\n')
161+ await client_writer.drain()
162+ continue
163+ if event['type'] == 'describe' and os.path.exists(CACHE_FILE):
164+ with open(CACHE_FILE, 'rb') as f:
165+ client_writer.write(f.read())
166+ await client_writer.drain()
167+ continue
168+ # Real work (or describe without a cache yet): wake the backend.
169+ state['active'] += 1
170+ state['last'] = time.monotonic()
171+ try:
172+ backend = await ensure_backend()
173+ except Exception as err:
174+ state['active'] -= 1
175+ state['last'] = time.monotonic()
176+ log('backend wake failed:', err)
177+ return
178+ log(f'session start from {peer} (trigger: {event["type"]})')
179+ backend[1].write(event['raw'])
180+ await backend[1].drain()
181+ asyncio.ensure_future(refresh_cache())
182+ await asyncio.gather(pipe(client_reader, backend[1]), pipe(backend[0], client_writer))
183+ except (ConnectionResetError, BrokenPipeError, asyncio.IncompleteReadError, json.JSONDecodeError) as err:
184+ log('client error:', err)
185+ finally:
186+ if backend is not None:
187+ state['active'] -= 1
188+ state['last'] = time.monotonic()
189+ log('session end, active:', state['active'])
190+ try:
191+ client_writer.close()
192+ except Exception:
193+ pass
194+
195+
196+async def idle_reaper():
197+ while True:
198+ await asyncio.sleep(30)
199+ idle = time.monotonic() - state['last']
200+ if state['active'] == 0 and idle > IDLE_SECONDS and await asyncio.to_thread(backend_running):
201+ log(f'idle for {int(idle)}s - stopping backend container')
202+ await asyncio.to_thread(stop_container)
203+
204+
205+async def main():
206+ state['lock'] = asyncio.Lock()
207+ if not os.path.exists(CACHE_FILE):
208+ log('no info cache - bootstrapping backend once to capture it')
209+ try:
210+ reader, writer = await ensure_backend()
211+ writer.close()
212+ await refresh_cache()
213+ except Exception as err:
214+ log('bootstrap failed (will retry on first request):', err)
215+ server = await asyncio.start_server(handle_client, '0.0.0.0', PROXY_PORT)
216+ log(f'wyoming lazy proxy listening on :{PROXY_PORT} -> {BACKEND_HOST}:{BACKEND_PORT} '
217+ f'(container {CONTAINER}, idle {IDLE_SECONDS}s)')
218+ async with server:
219+ await asyncio.gather(server.serve_forever(), idle_reaper())
220+
221+
222+if __name__ == '__main__':
223+ asyncio.run(main())