Coverage for integrations / social / livekit_supervisor.py: 0.0%

309 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2LiveKit supervisor — self-hosted SFU lifecycle, baked into HARTOS. 

3 

4PURPOSE 

5------- 

6HARTOS is mostly P2P (PeerLink + WebRTC mesh signaling). LiveKit is the 

7FALLBACK SFU that kicks in when: 

8 - Call has > 4 participants (mesh inefficient) 

9 - One participant is an AgentVoiceBridge (needs a stable rendezvous URL) 

10 

11ARCHITECTURE INTENT (per user clarification 2026-05-07) 

12------------------------------------------------------- 

13* **regional** — multi-tenant SFU host. Runs `livekit-server` locally, 

14 signing JWTs with locally-generated dev keys. This module spawns the 

15 binary as a managed subprocess. 

16* **flat** (single device) — same as regional but typically only one 

17 tenant. Supervisor still runs so >4-participant calls work. 

18* **central** — sync / federation / backup-restore only. Does NOT run 

19 an SFU. This module is a no-op when LIVEKIT_DISABLE=1 or 

20 HEVOLVE_DEPLOY_MODE=central. 

21* **embedded** — bundled mobile node. No SFU; same as central. 

22 

23ZERO-CONFIG GOAL 

24---------------- 

25`pip install -e .` plus first start of HARTOS should produce a working 

26SFU on regional/flat with no manual setup. This module: 

27 1. Generates AES-256-grade API key + secret on first start, persists 

28 them in ~/.hevolve/livekit/dev_keys.json (mode 0600). 

29 2. Lazy-downloads the official `livekit-server` Go binary from the 

30 LiveKit GitHub release that matches the pinned version, verifies 

31 the SHA-256 against an embedded checksum, and installs to 

32 ~/.hevolve/livekit/livekit-server (or .exe on Windows). 

33 3. Generates a config file (~/.hevolve/livekit/livekit.yaml) wiring 

34 the dev keys, port (default 7880), TURN/TCP fallback, and Redis 

35 when HEVOLVE_REDIS_URL is set (multi-node regional clusters). 

36 4. Spawns the binary as a daemon-thread-managed subprocess; restarts 

37 on crash with exponential backoff capped at 60s. 

38 5. Exposes runtime status via .info() so /health endpoints can report. 

39 

40The token issuer (livekit_service.py) reads the same dev keys file, so 

41both sides share a single source of truth — no copy-paste configuration. 

42 

43This module is INTENTIONALLY decoupled from the binary fetch URL: the 

44default GitHub release URL can be overridden via LIVEKIT_BINARY_URL for 

45air-gapped installs, ISO builds (which pre-stage the binary), or 

46proxied environments. 

47""" 

48 

49from __future__ import annotations 

50 

51import hashlib 

52import json 

53import logging 

54import os 

55import platform 

56import secrets 

57import shutil 

58import socket 

59import stat 

60import subprocess 

61import threading 

62import time 

63import urllib.request 

64from pathlib import Path 

65from typing import Any, Dict, Optional 

66 

67logger = logging.getLogger('hevolve_social') 

68 

69 

70# ── Pinned binary version ────────────────────────────────────────────── 

71# Bump together: VERSION + SHA-256 table. When upgrading: 

72# curl -sL https://github.com/livekit/livekit/releases/download/v$VERSION/checksums.txt 

73# and copy the hashes for the platforms we ship. 

74LIVEKIT_VERSION = '1.7.2' 

75 

76# SHA-256 of the official release artifacts from 

77# https://github.com/livekit/livekit/releases/download/v1.7.2/checksums.txt 

78# These are pinned for supply-chain integrity — a download whose hash 

79# doesn't match is rejected by ensure_binary(). 

80# 

81# Note: LiveKit does NOT ship darwin (macOS) builds in this release 

82# series. Operators on Apple Silicon must either install via Homebrew 

83# (`brew install livekit-server`) and let the supervisor's PATH lookup 

84# find it, or set LIVEKIT_BINARY_PATH explicitly. 

85_LIVEKIT_SHA256 = { 

86 'linux-amd64': '7669b1a112449e71ff80cb82460dae7e526e92b3d81e15c70f66a030fac62f4a', 

87 'linux-arm64': '482ced7026cbf4c661ab262d04e2d1ba4a723a478bd87028cd27a8a4bcf38035', 

88 'linux-armv7': '68a48cf10b2641aaca449ec61018922a2e3294b2682ce0eb9d40ad7fb5e14c2e', 

89 'windows-amd64': '9589bd307b4a908beaf65c6887f675090a8299f47979447e49a3b2a78d07a1d8', 

90 'windows-arm64': '746adc54325d82e080c32501e17f66cd1830e937bc496026eb155c06cc6fd257', 

91 'windows-armv7': '855007017fd5c2043ada6d43d21eb74e1cad8d496a74476be8af9e33bce296bc', 

92} 

93 

94 

95# ── Filesystem layout ───────────────────────────────────────────────── 

96def _hevolve_home() -> Path: 

97 """`~/.hevolve` — shared HARTOS data dir. Override via HEVOLVE_HOME.""" 

98 base = os.environ.get('HEVOLVE_HOME') 

99 if base: 

100 return Path(base).expanduser() 

101 return Path.home() / '.hevolve' 

102 

103 

104def _livekit_home() -> Path: 

105 return _hevolve_home() / 'livekit' 

106 

107 

108DEV_KEYS_FILE = 'dev_keys.json' # {api_key, api_secret, generated_at} 

109CONFIG_FILE = 'livekit.yaml' 

110BINARY_NAME = 'livekit-server.exe' if os.name == 'nt' else 'livekit-server' 

111 

112 

113# ── Deploy-mode detection ───────────────────────────────────────────── 

114def _deploy_mode() -> str: 

115 """Return one of `flat | regional | central | embedded`. 

116 

117 Resolution order: 

118 1. `HEVOLVE_DEPLOY_MODE` env var. 

119 2. `LIVEKIT_DISABLE=1` → forces 'central' (skip everything). 

120 3. Default 'flat' (laptop dev / single-device install). 

121 """ 

122 if os.environ.get('LIVEKIT_DISABLE') == '1': 

123 return 'central' 

124 return os.environ.get('HEVOLVE_DEPLOY_MODE', 'flat').lower().strip() 

125 

126 

127def supervisor_should_run() -> bool: 

128 """True iff this deploy mode hosts the SFU itself. 

129 

130 Central + embedded skip everything (sync/federation/backup-only). 

131 Regional + flat run the supervised binary. 

132 

133 Override: set `LIVEKIT_AUTOSTART=0` to force-disable, or 

134 `LIVEKIT_AUTOSTART=1` to force-enable regardless of deploy mode. 

135 """ 

136 forced = os.environ.get('LIVEKIT_AUTOSTART') 

137 if forced == '0': 

138 return False 

139 if forced == '1': 

140 return True 

141 return _deploy_mode() in ('flat', 'regional') 

142 

143 

144# ── Dev-key bootstrap (shared with livekit_service token issuer) ────── 

145def ensure_dev_keys() -> Dict[str, str]: 

146 """Return {api_key, api_secret} — generates + persists on first call. 

147 

148 Idempotent: same keys returned across restarts. Safe to call from 

149 multiple processes (atomic write via temp + rename). Permissions: 

150 0700 dir, 0600 file (POSIX). On Windows we rely on user-profile 

151 ACLs (CreateDirectoryW restricts to the current user by default). 

152 

153 Env override: if LIVEKIT_API_KEY + LIVEKIT_API_SECRET are both set, 

154 those win — we don't write the dev_keys.json. This is the path 

155 operators use when deploying with managed LiveKit Cloud or a 

156 central-issued key/secret pair. 

157 """ 

158 env_key = os.environ.get('LIVEKIT_API_KEY') 

159 env_secret = os.environ.get('LIVEKIT_API_SECRET') 

160 if env_key and env_secret: 

161 return {'api_key': env_key, 'api_secret': env_secret} 

162 

163 home = _livekit_home() 

164 home.mkdir(parents=True, exist_ok=True) 

165 try: 

166 os.chmod(home, 0o700) 

167 except OSError: 

168 pass # Windows / restricted FS — best effort. 

169 

170 keys_path = home / DEV_KEYS_FILE 

171 if keys_path.exists(): 

172 try: 

173 with keys_path.open('r', encoding='utf-8') as fp: 

174 data = json.load(fp) 

175 if data.get('api_key') and data.get('api_secret'): 

176 return data 

177 except (OSError, json.JSONDecodeError) as e: 

178 logger.warning( 

179 "livekit_supervisor: could not read %s (%s); regenerating", 

180 keys_path, e) 

181 

182 # First boot — generate. LiveKit accepts an arbitrary string for 

183 # api_key (commonly prefixed `API`); we use a 16-byte random hex for 

184 # easy diffing in logs. Secret is 32 bytes (256-bit HMAC key). 

185 new_keys = { 

186 'api_key': 'API' + secrets.token_hex(8), 

187 'api_secret': secrets.token_urlsafe(32), 

188 'generated_at': int(time.time()), 

189 'generated_by': 'livekit_supervisor.ensure_dev_keys', 

190 } 

191 tmp_path = keys_path.with_suffix('.tmp') 

192 with tmp_path.open('w', encoding='utf-8') as fp: 

193 json.dump(new_keys, fp, indent=2) 

194 try: 

195 os.chmod(tmp_path, 0o600) 

196 except OSError: 

197 pass 

198 os.replace(tmp_path, keys_path) 

199 logger.info( 

200 "livekit_supervisor: generated dev keys (api_key=%s) at %s", 

201 new_keys['api_key'], keys_path) 

202 return new_keys 

203 

204 

205def get_livekit_url() -> str: 

206 """Resolve the URL clients should connect to. 

207 

208 Order: 

209 1. `LIVEKIT_URL` env var (managed cloud or operator override). 

210 2. `ws://localhost:<LIVEKIT_PORT>` (default 7880) for self-hosted. 

211 """ 

212 url = os.environ.get('LIVEKIT_URL') 

213 if url: 

214 return url 

215 port = os.environ.get('LIVEKIT_PORT', '7880') 

216 return f'ws://localhost:{port}' 

217 

218 

219# ── Binary download + verify ────────────────────────────────────────── 

220def _platform_tag() -> str: 

221 sys_name = platform.system().lower() # 'linux'/'darwin'/'windows' 

222 machine = platform.machine().lower() 

223 if machine in ('x86_64', 'amd64'): 

224 arch = 'amd64' 

225 elif machine in ('aarch64', 'arm64'): 

226 arch = 'arm64' 

227 else: 

228 arch = machine 

229 if sys_name == 'darwin': 

230 return f'darwin-{arch}' 

231 if sys_name == 'windows': 

232 return f'windows-{arch}' 

233 return f'linux-{arch}' 

234 

235 

236def _binary_url() -> str: 

237 """Resolve the binary archive URL. `LIVEKIT_BINARY_URL` lets ISO / 

238 air-gapped builds point at a local mirror or cached file://. 

239 

240 LiveKit's release filenames use underscore between os and arch 

241 (`livekit_1.7.2_linux_amd64.tar.gz`), but our internal platform 

242 tag uses hyphen (`linux-amd64`) since it doubles as a dict key in 

243 _LIVEKIT_SHA256. The translation happens here. 

244 """ 

245 override = os.environ.get('LIVEKIT_BINARY_URL') 

246 if override: 

247 return override 

248 tag = _platform_tag() # e.g. 'linux-amd64' 

249 url_tag = tag.replace('-', '_') # e.g. 'linux_amd64' 

250 ext = 'zip' if tag.startswith('windows-') else 'tar.gz' 

251 return ( 

252 f'https://github.com/livekit/livekit/releases/download/' 

253 f'v{LIVEKIT_VERSION}/livekit_{LIVEKIT_VERSION}_{url_tag}.{ext}' 

254 ) 

255 

256 

257def _verify_sha256(path: Path, tag: str) -> None: 

258 expected = _LIVEKIT_SHA256.get(tag, '') 

259 h = hashlib.sha256() 

260 with path.open('rb') as fp: 

261 for chunk in iter(lambda: fp.read(1 << 16), b''): 

262 h.update(chunk) 

263 actual = h.hexdigest() 

264 if not expected: 

265 logger.info( 

266 "livekit_supervisor: download checksum (%s) = %s — pinning " 

267 "deferred; set _LIVEKIT_SHA256[%r] in livekit_supervisor.py " 

268 "to lock this version", tag, actual, tag) 

269 return 

270 if expected.lower() != actual.lower(): 

271 raise RuntimeError( 

272 f'LiveKit binary checksum mismatch for {tag}: ' 

273 f'expected {expected}, got {actual}') 

274 

275 

276def _find_prestaged_binary() -> Optional[Path]: 

277 """Look for an already-installed livekit-server in standard 

278 locations. Used by Docker (Dockerfile installs to /usr/local/bin) 

279 and ISO builds (apt/manual install) so the supervisor doesn't 

280 re-download a binary already present. 

281 

282 Order: explicit `LIVEKIT_BINARY_PATH` env > `~/.hevolve/livekit/` 

283 > shutil.which() (PATH lookup). 

284 """ 

285 explicit = os.environ.get('LIVEKIT_BINARY_PATH') 

286 if explicit: 

287 p = Path(explicit).expanduser() 

288 if p.exists(): 

289 return p 

290 

291 home_path = _livekit_home() / BINARY_NAME 

292 if home_path.exists(): 

293 return home_path 

294 

295 on_path = shutil.which('livekit-server') 

296 if on_path: 

297 return Path(on_path) 

298 return None 

299 

300 

301def ensure_binary() -> Optional[Path]: 

302 """Download + extract the livekit-server binary if missing. 

303 

304 Returns the absolute path to the binary, or None if the download 

305 couldn't be completed (logged; supervisor will degrade to 

306 p2p_mesh-only mode). 

307 

308 Safe to call repeatedly: existence check short-circuits. Will 

309 prefer a pre-staged binary (Dockerfile / ISO / apt install) over 

310 a fresh download. 

311 """ 

312 pre = _find_prestaged_binary() 

313 if pre: 

314 return pre 

315 

316 home = _livekit_home() 

317 binary_path = home / BINARY_NAME 

318 if binary_path.exists(): 

319 return binary_path 

320 

321 home.mkdir(parents=True, exist_ok=True) 

322 tag = _platform_tag() 

323 url = _binary_url() 

324 

325 logger.info( 

326 "livekit_supervisor: fetching livekit-server v%s for %s from %s", 

327 LIVEKIT_VERSION, tag, url) 

328 

329 archive_ext = 'zip' if url.endswith('.zip') else 'tar.gz' 

330 archive_path = home / f'livekit-server-{LIVEKIT_VERSION}.{archive_ext}' 

331 

332 try: 

333 # Use urllib only — no extra deps. Set a UA so GitHub's CDN 

334 # doesn't 403 on default Python signature. 

335 req = urllib.request.Request( 

336 url, headers={'User-Agent': 'HARTOS-livekit-supervisor/1.0'}) 

337 with urllib.request.urlopen(req, timeout=120) as resp: 

338 with archive_path.open('wb') as fp: 

339 shutil.copyfileobj(resp, fp) 

340 except Exception as e: # network / 404 / permissions 

341 logger.warning( 

342 "livekit_supervisor: download failed (%s); SFU will not " 

343 "start. Calls fall back to P2P mesh. To install manually: " 

344 "download %s and extract to %s", 

345 e, url, home) 

346 return None 

347 

348 _verify_sha256(archive_path, tag) 

349 

350 # Extract binary out of the archive. LiveKit ships either a .tar.gz 

351 # (Linux/macOS) or .zip (Windows) containing a single 

352 # `livekit-server` (or `.exe`) at the root. 

353 try: 

354 if archive_ext == 'zip': 

355 import zipfile 

356 with zipfile.ZipFile(archive_path) as z: 

357 for name in z.namelist(): 

358 base = os.path.basename(name) 

359 if base in ('livekit-server', 'livekit-server.exe'): 

360 with z.open(name) as src, binary_path.open('wb') as dst: 

361 shutil.copyfileobj(src, dst) 

362 break 

363 else: 

364 import tarfile 

365 with tarfile.open(archive_path, 'r:gz') as t: 

366 for member in t.getmembers(): 

367 base = os.path.basename(member.name) 

368 if base == 'livekit-server': 

369 f = t.extractfile(member) 

370 if f is None: 

371 continue 

372 with binary_path.open('wb') as dst: 

373 shutil.copyfileobj(f, dst) 

374 break 

375 finally: 

376 try: 

377 archive_path.unlink() 

378 except OSError: 

379 pass 

380 

381 if not binary_path.exists(): 

382 logger.warning( 

383 "livekit_supervisor: archive extraction did not produce a " 

384 "livekit-server binary at %s; SFU disabled", binary_path) 

385 return None 

386 

387 # 0755 so it's runnable by the current user. 

388 try: 

389 st = binary_path.stat() 

390 os.chmod(binary_path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP) 

391 except OSError: 

392 pass 

393 logger.info( 

394 "livekit_supervisor: livekit-server v%s installed at %s", 

395 LIVEKIT_VERSION, binary_path) 

396 return binary_path 

397 

398 

399# ── Config generation ───────────────────────────────────────────────── 

400def _bind_addresses_for_mode() -> list: 

401 """Resolve which interface(s) the SFU should listen on. 

402 

403 Default policy is **silent install** — flat mode binds loopback only 

404 so first start never triggers a Windows / macOS firewall prompt 

405 (binding 0.0.0.0 is what triggers the OS dialog; loopback never 

406 does). Regional mode binds all interfaces because LAN peers must 

407 reach the SFU; that single first-start prompt is acceptable for 

408 the deploy-mode that's explicitly intended to host other users. 

409 

410 Override priority (highest first): 

411 1. `LIVEKIT_BIND_HOST` env — single literal address. 

412 "127.0.0.1" → loopback (silent) 

413 "0.0.0.0" → all interfaces (firewall-prompts) 

414 "192.168.1.50" → specific NIC 

415 2. Mode-aware default: 

416 flat / embedded → loopback only (no prompt) 

417 regional → all interfaces (one-time prompt) 

418 """ 

419 override = os.environ.get('LIVEKIT_BIND_HOST', '').strip() 

420 if override: 

421 # 0.0.0.0 → empty string = LiveKit "all interfaces" sentinel 

422 return [''] if override == '0.0.0.0' else [override] 

423 mode = _deploy_mode() 

424 if mode == 'regional': 

425 return [''] # all interfaces; LAN peers reach us 

426 # flat / embedded / unknown → loopback (silent first-start) 

427 return ['127.0.0.1'] 

428 

429 

430def _use_external_ip_for_mode() -> bool: 

431 """Loopback-bound SFU has no external IP to advertise. ICE 

432 candidates from a loopback bind would only confuse remote clients. 

433 Disable external-IP auto-detection on flat mode; enable on regional. 

434 """ 

435 bind = _bind_addresses_for_mode() 

436 # If we're listening on any non-loopback interface, advertise it. 

437 return not (len(bind) == 1 and bind[0] in ('127.0.0.1', '::1')) 

438 

439 

440def _generate_config(keys: Dict[str, str]) -> Path: 

441 """Emit `livekit.yaml` with the dev keys + standard ports. 

442 

443 Uses the official LiveKit config schema. Operators can override 

444 fields by editing the file or setting env vars HARTOS reads on 

445 next start. 

446 """ 

447 home = _livekit_home() 

448 home.mkdir(parents=True, exist_ok=True) 

449 cfg_path = home / CONFIG_FILE 

450 

451 port = int(os.environ.get('LIVEKIT_PORT', '7880')) 

452 rtc_tcp_port = int(os.environ.get('LIVEKIT_RTC_TCP_PORT', '7881')) 

453 rtc_udp_min = int(os.environ.get('LIVEKIT_RTC_UDP_MIN', '50000')) 

454 rtc_udp_max = int(os.environ.get('LIVEKIT_RTC_UDP_MAX', '60000')) 

455 redis_url = os.environ.get('HEVOLVE_REDIS_URL') or os.environ.get( 

456 'LIVEKIT_REDIS_URL') 

457 

458 bind_addresses = _bind_addresses_for_mode() 

459 use_external_ip = _use_external_ip_for_mode() 

460 

461 yaml_lines = [ 

462 '# Auto-generated by HARTOS livekit_supervisor. Edit if you', 

463 '# need to override; HARTOS will not overwrite an existing', 

464 '# config — delete the file to regenerate from current env.', 

465 f'port: {port}', 

466 'bind_addresses:', 

467 ] 

468 # YAML list — quote each value so empty-string ("all interfaces") 

469 # round-trips correctly to LiveKit's parser. 

470 for addr in bind_addresses: 

471 yaml_lines.append(f" - '{addr}'") 

472 yaml_lines += [ 

473 'rtc:', 

474 f' tcp_port: {rtc_tcp_port}', 

475 f' port_range_start: {rtc_udp_min}', 

476 f' port_range_end: {rtc_udp_max}', 

477 f' use_external_ip: {str(use_external_ip).lower()}', 

478 'keys:', 

479 f' {keys["api_key"]}: {keys["api_secret"]}', 

480 'logging:', 

481 ' level: info', 

482 ' json: false', 

483 ] 

484 if redis_url: 

485 yaml_lines += [ 

486 'redis:', 

487 f' address: {redis_url}', 

488 ] 

489 

490 if not cfg_path.exists(): 

491 with cfg_path.open('w', encoding='utf-8') as fp: 

492 fp.write('\n'.join(yaml_lines) + '\n') 

493 try: 

494 os.chmod(cfg_path, 0o600) 

495 except OSError: 

496 pass 

497 logger.info( 

498 "livekit_supervisor: wrote default config at %s", cfg_path) 

499 else: 

500 logger.info( 

501 "livekit_supervisor: keeping existing config at %s " 

502 "(delete to regenerate)", cfg_path) 

503 return cfg_path 

504 

505 

506def _port_in_use(port: int) -> bool: 

507 try: 

508 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 

509 s.settimeout(0.5) 

510 return s.connect_ex(('127.0.0.1', port)) == 0 

511 except OSError: 

512 return False 

513 

514 

515# ── Supervisor lifecycle ────────────────────────────────────────────── 

516class _Supervisor: 

517 """Single instance per HARTOS process. Started by start_supervisor() 

518 and lives for the lifetime of the process. Daemon thread → exits 

519 cleanly when the parent dies. 

520 """ 

521 

522 def __init__(self) -> None: 

523 self.proc: Optional[subprocess.Popen] = None 

524 self.binary: Optional[Path] = None 

525 self.config: Optional[Path] = None 

526 self.thread: Optional[threading.Thread] = None 

527 self.stop_event = threading.Event() 

528 self.last_error: Optional[str] = None 

529 self.last_started: Optional[float] = None 

530 self.restart_count = 0 

531 self.url: str = get_livekit_url() 

532 self.lock = threading.Lock() 

533 

534 def start(self) -> Dict[str, Any]: 

535 """Provision keys + binary + config; spawn the supervisor thread. 

536 

537 Returns an info dict with status the caller can log or surface 

538 on a health endpoint. 

539 """ 

540 if self.thread is not None and self.thread.is_alive(): 

541 return self.info() 

542 

543 keys = ensure_dev_keys() 

544 self.binary = ensure_binary() 

545 self.config = _generate_config(keys) 

546 

547 if self.binary is None: 

548 self.last_error = ( 

549 'binary unavailable — calls fall back to P2P mesh only') 

550 logger.warning( 

551 "livekit_supervisor: %s", self.last_error) 

552 return self.info() 

553 

554 port = int(os.environ.get('LIVEKIT_PORT', '7880')) 

555 if _port_in_use(port): 

556 self.last_error = ( 

557 f'port {port} already in use; assuming an operator-' 

558 f'managed livekit-server is running and skipping spawn') 

559 logger.info("livekit_supervisor: %s", self.last_error) 

560 return self.info() 

561 

562 self.thread = threading.Thread( 

563 target=self._run, daemon=True, name='livekit-supervisor') 

564 self.thread.start() 

565 return self.info() 

566 

567 def stop(self) -> None: 

568 self.stop_event.set() 

569 with self.lock: 

570 if self.proc and self.proc.poll() is None: 

571 try: 

572 self.proc.terminate() 

573 try: 

574 self.proc.wait(timeout=5) 

575 except subprocess.TimeoutExpired: 

576 self.proc.kill() 

577 except OSError: 

578 pass 

579 

580 def _run(self) -> None: 

581 backoff = 1.0 

582 while not self.stop_event.is_set(): 

583 try: 

584 cmd = [str(self.binary), '--config', str(self.config)] 

585 logger.info("livekit_supervisor: spawning %s", ' '.join(cmd)) 

586 # Hide the cmd console window on Windows — the supervisor 

587 # already streams stdout to logger.info above. Routes 

588 # through core.subprocess_safe.hidden_popen_kwargs so 

589 # every site uses the same canonical helper instead of 

590 # drifting inline `os.name == 'nt'` checks. 

591 from core.subprocess_safe import hidden_popen_kwargs 

592 with self.lock: 

593 self.proc = subprocess.Popen( 

594 cmd, 

595 stdout=subprocess.PIPE, 

596 stderr=subprocess.STDOUT, 

597 cwd=str(_livekit_home()), 

598 **hidden_popen_kwargs(), 

599 ) 

600 self.last_started = time.time() 

601 # Stream output to logger so operators see what's happening 

602 # without needing to tail the binary's own log file. 

603 if self.proc.stdout is not None: 

604 for raw in self.proc.stdout: 

605 if self.stop_event.is_set(): 

606 break 

607 line = raw.decode('utf-8', errors='replace').rstrip() 

608 if line: 

609 logger.info('livekit-server: %s', line) 

610 rc = self.proc.wait() 

611 if self.stop_event.is_set(): 

612 return 

613 self.last_error = f'livekit-server exited rc={rc}' 

614 logger.warning("livekit_supervisor: %s", self.last_error) 

615 except Exception as e: 

616 self.last_error = f'spawn failed: {e}' 

617 logger.error("livekit_supervisor: %s", self.last_error, 

618 exc_info=True) 

619 

620 # Exponential backoff capped at 60s. 

621 self.restart_count += 1 

622 wait = min(backoff, 60.0) 

623 backoff = min(backoff * 2.0, 60.0) 

624 if self.stop_event.wait(wait): 

625 return 

626 

627 def info(self) -> Dict[str, Any]: 

628 running = ( 

629 self.proc is not None 

630 and self.proc.poll() is None 

631 and self.thread is not None 

632 and self.thread.is_alive() 

633 ) 

634 return { 

635 'mode': _deploy_mode(), 

636 'should_run': supervisor_should_run(), 

637 'binary_path': str(self.binary) if self.binary else None, 

638 'config_path': str(self.config) if self.config else None, 

639 'url': self.url, 

640 'running': running, 

641 'restart_count': self.restart_count, 

642 'last_started': self.last_started, 

643 'last_error': self.last_error, 

644 } 

645 

646 

647_INSTANCE: Optional[_Supervisor] = None 

648_INSTANCE_LOCK = threading.Lock() 

649 

650 

651def start_supervisor() -> Dict[str, Any]: 

652 """Idempotent entrypoint — call once during HARTOS bootstrap. 

653 

654 No-op when the deploy mode shouldn't host an SFU (central / 

655 embedded), preserving the architectural intent that central 

656 instances do sync/federation/backup-restore only. 

657 """ 

658 global _INSTANCE 

659 if not supervisor_should_run(): 

660 return { 

661 'mode': _deploy_mode(), 

662 'should_run': False, 

663 'reason': 

664 'deploy mode does not host an SFU; calls use P2P mesh ' 

665 'and any operator-managed external LiveKit URL', 

666 } 

667 

668 with _INSTANCE_LOCK: 

669 if _INSTANCE is None: 

670 _INSTANCE = _Supervisor() 

671 return _INSTANCE.start() 

672 

673 

674def stop_supervisor() -> None: 

675 """Process-shutdown hook. Safe to call when supervisor never ran.""" 

676 global _INSTANCE 

677 with _INSTANCE_LOCK: 

678 if _INSTANCE is not None: 

679 _INSTANCE.stop() 

680 _INSTANCE = None 

681 

682 

683def supervisor_info() -> Dict[str, Any]: 

684 """Read-only state — useful for /health, debug pages, tests.""" 

685 with _INSTANCE_LOCK: 

686 if _INSTANCE is None: 

687 return { 

688 'mode': _deploy_mode(), 

689 'should_run': supervisor_should_run(), 

690 'running': False, 

691 'reason': 'not yet started', 

692 } 

693 return _INSTANCE.info() 

694 

695 

696__all__ = [ 

697 'LIVEKIT_VERSION', 

698 'ensure_dev_keys', 

699 'get_livekit_url', 

700 'ensure_binary', 

701 'supervisor_should_run', 

702 'start_supervisor', 

703 'stop_supervisor', 

704 'supervisor_info', 

705]