* Phase 2 Plan 02-01: SubprocessBackend primitive + echo sidecar + ENGINE-05 wrap
Lands the durable SubprocessBackend primitive — the architectural keystone
that Plans 02-03 (IndexTTS migration), Phase 3 (Supertonic-3), and
Phase 4 (GGUF / Singing) plug into.
Files added:
- backend/services/subprocess_backend.py — base class owning spawn,
shutdown, _send/_recv (length-prefixed JSON), GPU-slot acquire-release,
atexit teardown, stderr drain, op allowlist (T-02-04), and 64 MB
frame cap (T-02-01). No multiprocessing — subprocess.Popen
exclusively so subclasses can target a *different* venv's interpreter
(Locked Decision D4 / Pitfall 1).
- backend/engines/_echo/main.py — permanent CI regression sidecar.
Stdlib-only, runs under the parent's sys.executable. Implements
ready/ping-pong/synthesize/shutdown plus test-only probe_env and
emit_unknown ops for env-forwarding and op-allowlist tests. DO NOT
DELETE — the round-trip test depends on this file.
- tests/backend/services/test_subprocess_backend.py — 13 tests:
round-trip, health_check, no-zombie, shutdown idempotency, env
forwarding (HF_TOKEN/HF_HOME/HF_ENDPOINT/HF_HUB_CACHE), oversize
frame, short read, op-allowlist drop, op-allowlist constant shape,
sidecar-crash recovery, no-multiprocessing grep gate, MAX_FRAME_BYTES.
- tests/backend/services/test_tts_backend_registry.py — 6 tests for
list_backends() resilience + shape + isolation_mode + last_error
caching + existing-engines preservation + install_hint passthrough.
Files modified:
- backend/services/tts_backend.py:
* Adds module-level _LAST_ERRORS dict for ENGINE-06.
* Rewrites list_backends() to wrap each is_available() in try/except
so one broken engine cannot blank the picker (ENGINE-05).
* Adds last_error + isolation_mode keys to each response entry
(ENGINE-06 UI in Plan 02-04 consumes via the same /engines route).
* Uses a duck-typed _is_subprocess_isolated marker rather than
issubclass(cls, SubprocessBackend) because test fixtures (token
resolver suite) purge sys.modules["services"] between tests and the
re-imported SubprocessBackend would be a different class object.
Threat-model mitigations (Plan 02-01 frontmatter):
T-02-01 DoS via length-prefix → MAX_FRAME_BYTES = 64 * 1024 * 1024
T-02-02 GPU slot leak on sidecar death → try/finally in generate
T-02-03 token bytes in stderr → drained via parent logger
(HFTokenRedactor from Phase 1 already on root)
T-02-04 unknown ops from compromised sidecar → PARENT_INBOUND_OPS
allowlist, unknown frames logged and dropped
T-02-05 Tauri group-kill scope → start_new_session=True on Unix /
CREATE_NEW_PROCESS_GROUP on Windows
Verification:
- 337 passed, 6 skipped, 12 xfailed, 1 xpassed (full suite,
`uv run pytest tests/ --ignore=tests/manual`)
- All 19 new tests pass on macOS Apple Silicon
- Smoke tests still pass: `uv run pytest tests/smoke/ -q` → 4 passed
- SoniTranslate untouched (D1 locked decision)
- Zero new Python dependencies
Closes part of ENGINE-01 + ENGINE-05.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(02-01): plan summary — public API, invariants, deviations
Documents the SubprocessBackend public API so Plan 02-03 (IndexTTS) and
Phase 3 (Supertonic-3) authors don't need to re-read the source.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
492 lines
20 KiB
Python
492 lines
20 KiB
Python
"""SubprocessBackend — long-lived sidecar-process TTS primitive (Phase 2.1).
|
||
|
||
The architectural keystone for engine isolation. Engines that need their
|
||
own Python venv (because their dependency pins conflict with OmniVoice's
|
||
— IndexTTS demands `transformers<5`, OmniVoice demands `transformers>=5.3`)
|
||
run inside a `subprocess.Popen` child interpreter. The parent backend
|
||
talks to them through length-prefixed JSON over the child's stdin/stdout.
|
||
|
||
Subclasses (e.g. ``IndexTTSSubprocessBackend`` in Plan 02-03, the future
|
||
Supertonic-3 backend in Phase 3) override exactly two class methods:
|
||
|
||
@classmethod
|
||
def venv_python(cls) -> Path: ... # path to the engine's python
|
||
@classmethod
|
||
def sidecar_script(cls) -> Path: ... # path to backend/engines/<id>/main.py
|
||
|
||
Everything else — spawn, ready-handshake, request/response, GPU-slot
|
||
accounting, atexit teardown, stderr drainage, process-group cleanup — is
|
||
owned by this base class.
|
||
|
||
NOT IMPLEMENTED with the multiprocessing module (Locked Decision D4 — no
|
||
process-cloning variants of any kind). The whole point of this primitive
|
||
is to run a *different* Python interpreter than the parent's;
|
||
multiprocessing can only clone the current interpreter, which defeats
|
||
the dependency-isolation goal. Anyone tempted to "simplify" this should
|
||
re-read 02-RESEARCH.md Pitfall 1.
|
||
|
||
Threat-model summary (see Plan 02-01 frontmatter):
|
||
T-02-01 — DoS via length-prefix: hard cap 64 MB per frame in ``_recv``.
|
||
T-02-02 — GPU slot leak on sidecar death: try/finally in ``generate``.
|
||
T-02-03 — token bytes in stderr: drain via the same logging filter that
|
||
AUTH-05 installed (``HFTokenRedactor``) on the root logger.
|
||
T-02-04 — compromised sidecar emitting unexpected ops: parent allowlist
|
||
``PARENT_INBOUND_OPS`` rejects everything else.
|
||
T-02-05 — Tauri group-kill scope: ``start_new_session=True`` on Unix
|
||
and ``CREATE_NEW_PROCESS_GROUP`` on Windows isolate the
|
||
sidecar's process group.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import atexit
|
||
import base64
|
||
import json
|
||
import logging
|
||
import os
|
||
import struct
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
import numpy as np
|
||
import torch
|
||
|
||
from services.tts_backend import TTSBackend
|
||
|
||
logger = logging.getLogger("omnivoice.subprocess_backend")
|
||
|
||
|
||
# ── Wire protocol constants ────────────────────────────────────────────────
|
||
|
||
#: Hard cap per frame body. Defeats length-prefix DoS where a malicious or
|
||
#: corrupted sidecar sends `0xFFFFFFFF` and the parent would allocate 4 GB
|
||
#: before realising the body never arrives. See T-02-01.
|
||
MAX_FRAME_BYTES = 64 * 1024 * 1024
|
||
|
||
#: Parent-side op allowlist. Any sidecar frame whose ``op`` is not in this
|
||
#: set is logged and discarded — prevents a compromised sidecar from
|
||
#: invoking unintended parent code paths. See T-02-04.
|
||
PARENT_INBOUND_OPS = frozenset({
|
||
"ready", "pong", "audio", "progress", "error",
|
||
"gpu_acquire", "gpu_release",
|
||
})
|
||
|
||
#: Reference list of ops the sidecar accepts (informational — enforced on
|
||
#: the sidecar side, not in this module).
|
||
SIDECAR_INBOUND_OPS = frozenset({"ping", "synthesize", "shutdown"})
|
||
|
||
#: Timeout for the initial ready handshake. Some engines (IndexTTS, large
|
||
#: torch.compile graphs) take 20–25 s to import their dependencies before
|
||
#: emitting the first frame; 30 s is a comfortable upper bound that still
|
||
#: surfaces a hung sidecar within a single CI run.
|
||
SPAWN_READY_TIMEOUT_S = 30.0
|
||
|
||
#: Per-frame _recv read timeout (best-effort — applies to header read; body
|
||
#: read is uninterruptible on a stdlib BufferedReader). Used in health_check
|
||
#: and generate to bound a hung sidecar.
|
||
RECV_TIMEOUT_S = 60.0
|
||
|
||
|
||
# ── Base class ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
class SubprocessBackend(TTSBackend):
|
||
"""Long-lived sidecar-process TTS backend. Subclasses provide
|
||
``venv_python()`` and ``sidecar_script()``; the base class owns
|
||
spawn/shutdown/_send/_recv/generate + GPU-slot acquire/release.
|
||
"""
|
||
|
||
# Stable marker so `list_backends()` can detect subprocess-isolated
|
||
# backends without relying on `issubclass()`. ``issubclass`` fails when
|
||
# test fixtures purge `sys.modules["services"]` (as the token_resolver
|
||
# tests do for DB isolation) — the re-imported SubprocessBackend would
|
||
# be a different class object from the one the subclass closed over.
|
||
# A duck-typed marker survives that.
|
||
_is_subprocess_isolated: bool = True
|
||
|
||
# Default sample rate; subclasses override.
|
||
_DEFAULT_SAMPLE_RATE = 24000
|
||
|
||
# ── instance state (initialised in __init__) ───────────────────────────
|
||
|
||
def __init__(self) -> None:
|
||
self._proc: Optional[subprocess.Popen] = None
|
||
# Single lock serialises spawn + every send/recv pair so two threads
|
||
# can't interleave half-frames on the same pipe.
|
||
self._lock = threading.Lock()
|
||
self._stderr_thread: Optional[threading.Thread] = None
|
||
# Idempotent atexit shutdown (Pitfall 6 layer 1). If the interpreter
|
||
# exits without an explicit shutdown call, this still tears down the
|
||
# sidecar tree.
|
||
atexit.register(self.shutdown)
|
||
|
||
# ── subclass contract ──────────────────────────────────────────────────
|
||
|
||
@classmethod
|
||
def venv_python(cls) -> Path:
|
||
"""Path to the Python interpreter that runs the sidecar.
|
||
|
||
Subclasses point at their engine's dedicated venv. The echo sidecar
|
||
and unit tests point at ``sys.executable`` so they run under the
|
||
bare parent interpreter.
|
||
"""
|
||
raise NotImplementedError
|
||
|
||
@classmethod
|
||
def sidecar_script(cls) -> Path:
|
||
"""Path to the sidecar entrypoint (`backend/engines/<id>/main.py`)."""
|
||
raise NotImplementedError
|
||
|
||
# ── lifecycle ──────────────────────────────────────────────────────────
|
||
|
||
def _spawn(self) -> None:
|
||
"""Launch the sidecar if not already running. Blocks on the ready
|
||
handshake. Caller must hold self._lock."""
|
||
if self._proc is not None and self._proc.poll() is None:
|
||
return # already up
|
||
|
||
# Env forwarding contract (Locked Decision D5):
|
||
# - Inherit the parent's full env via os.environ.copy().
|
||
# - The parent's env already carries HF_TOKEN (injected by the
|
||
# Phase 1 AUTH-04 launch sites that call
|
||
# ``token_resolver.resolve()``), HF_HOME, HF_ENDPOINT, and
|
||
# HF_HUB_CACHE.
|
||
# - PYTHONUNBUFFERED=1 keeps the sidecar's stdout from buffering
|
||
# past our length-prefix reads.
|
||
env = os.environ.copy()
|
||
env["PYTHONUNBUFFERED"] = "1"
|
||
|
||
kwargs: dict = {
|
||
"stdin": subprocess.PIPE,
|
||
"stdout": subprocess.PIPE,
|
||
"stderr": subprocess.PIPE,
|
||
"env": env,
|
||
"bufsize": 0, # unbuffered binary pipes
|
||
}
|
||
# Process-group isolation so the Tauri lib.rs group-kill in shutdown
|
||
# doesn't escape into other children. See T-02-05.
|
||
if sys.platform == "win32":
|
||
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||
else:
|
||
kwargs["start_new_session"] = True
|
||
|
||
python_path = str(self.venv_python())
|
||
script_path = str(self.sidecar_script())
|
||
logger.info(
|
||
"[%s] spawning sidecar: %s %s",
|
||
self.id, python_path, script_path,
|
||
)
|
||
self._proc = subprocess.Popen([python_path, script_path], **kwargs)
|
||
|
||
# Drain stderr in a background thread so the sidecar can't block on
|
||
# a full pipe. Lines flow into the root logger; AUTH-05's
|
||
# HFTokenRedactor (already installed in Phase 1) strips token bytes.
|
||
# See T-02-03.
|
||
self._stderr_thread = threading.Thread(
|
||
target=self._drain_stderr, daemon=True,
|
||
name=f"{self.id}-stderr-drain",
|
||
)
|
||
self._stderr_thread.start()
|
||
|
||
# Block on the ready handshake. A sidecar that fails to emit ready
|
||
# within SPAWN_READY_TIMEOUT_S is killed and the failure is raised.
|
||
try:
|
||
frame = self._recv_with_timeout(SPAWN_READY_TIMEOUT_S)
|
||
except Exception:
|
||
self._force_kill()
|
||
raise
|
||
if not frame or frame.get("op") != "ready":
|
||
self._force_kill()
|
||
raise RuntimeError(
|
||
f"{self.id} sidecar did not signal ready: {frame!r}"
|
||
)
|
||
logger.info("[%s] sidecar ready", self.id)
|
||
|
||
def shutdown(self) -> None:
|
||
"""Idempotent. Sends {op:shutdown}; falls back to terminate/kill."""
|
||
proc = self._proc
|
||
if proc is None:
|
||
return
|
||
try:
|
||
try:
|
||
# Best effort — sidecar may already be dead.
|
||
self._send({"op": "shutdown"})
|
||
except Exception:
|
||
pass
|
||
try:
|
||
proc.wait(timeout=3)
|
||
except subprocess.TimeoutExpired:
|
||
logger.warning(
|
||
"[%s] sidecar did not exit on shutdown frame; terminating",
|
||
self.id,
|
||
)
|
||
try:
|
||
proc.terminate()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
proc.wait(timeout=2)
|
||
except subprocess.TimeoutExpired:
|
||
logger.warning(
|
||
"[%s] sidecar did not exit on SIGTERM; killing",
|
||
self.id,
|
||
)
|
||
try:
|
||
proc.kill()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
proc.wait(timeout=2)
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
self._proc = None
|
||
|
||
def _force_kill(self) -> None:
|
||
"""Internal: kill a sidecar that never reached the ready state."""
|
||
proc = self._proc
|
||
if proc is None:
|
||
return
|
||
try:
|
||
proc.kill()
|
||
try:
|
||
proc.wait(timeout=2)
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
self._proc = None
|
||
|
||
def unload(self) -> None:
|
||
"""TTSBackend.unload override — idempotent shutdown."""
|
||
self.shutdown()
|
||
|
||
# ── health check + generate ────────────────────────────────────────────
|
||
|
||
def health_check(self) -> tuple[bool, str]:
|
||
"""Send ping, expect pong. Spawns the sidecar if needed.
|
||
|
||
Returns (True, "pong") on success, (False, "<exc>") on any failure.
|
||
Never raises — health checks are called from places (engine picker,
|
||
Compat Matrix UI) that must keep working even when an engine is sick.
|
||
"""
|
||
try:
|
||
with self._lock:
|
||
self._spawn()
|
||
self._send({"op": "ping"})
|
||
reply = self._recv_with_timeout(RECV_TIMEOUT_S)
|
||
if reply and reply.get("op") == "pong":
|
||
return True, "pong"
|
||
return False, f"unexpected reply: {reply!r}"
|
||
except Exception as exc:
|
||
return False, f"{type(exc).__name__}: {exc}"
|
||
|
||
def generate(self, text: str, **kw) -> torch.Tensor:
|
||
"""Synthesize one utterance through the sidecar.
|
||
|
||
Returns a tensor of shape (1, n_samples) at the sidecar-reported
|
||
sample rate. Decodes the int16 PCM the sidecar returns into float32
|
||
in [-1, 1].
|
||
"""
|
||
# Lazy-import the GPU pool so importing this module doesn't pull in
|
||
# the entire model_manager + torch ecosystem at registry-listing time.
|
||
from services.model_manager import _get_gpu_pool
|
||
|
||
# Acquire a GPU pool worker for the duration of this generate. The
|
||
# try/finally guarantees the slot is released even if the sidecar
|
||
# dies mid-frame (T-02-02 / Pitfall 7).
|
||
pool = _get_gpu_pool()
|
||
slot_future = pool.submit(lambda: None)
|
||
try:
|
||
slot_future.result(timeout=10) # wait for our turn
|
||
except Exception:
|
||
slot_future.cancel()
|
||
raise
|
||
|
||
try:
|
||
with self._lock:
|
||
self._spawn()
|
||
msg = {"op": "synthesize", "text": text}
|
||
# Filter kwargs to JSON-safe primitives. Tensor / Path / etc.
|
||
# don't survive json.dumps and are silently dropped — the
|
||
# sidecar can't use them anyway.
|
||
for k, v in kw.items():
|
||
if _is_jsonable(v):
|
||
msg[k] = v
|
||
self._send(msg)
|
||
reply = self._recv_with_timeout(RECV_TIMEOUT_S)
|
||
if not reply:
|
||
raise RuntimeError(f"{self.id} sidecar closed pipe mid-generate")
|
||
if reply.get("op") == "error":
|
||
raise RuntimeError(
|
||
f"{self.id} sidecar error: {reply.get('message')!r}"
|
||
)
|
||
if reply.get("op") != "audio":
|
||
raise RuntimeError(
|
||
f"{self.id} sidecar returned unexpected op: {reply.get('op')!r}"
|
||
)
|
||
pcm_b64 = reply.get("audio_pcm_b64", "")
|
||
pcm = base64.b64decode(pcm_b64)
|
||
arr = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
|
||
tensor = torch.from_numpy(arr.copy()).unsqueeze(0)
|
||
return tensor
|
||
finally:
|
||
# Slot is released the instant this thread leaves the pool's
|
||
# task — by holding slot_future we kept one worker busy; nothing
|
||
# further to do. (ThreadPoolExecutor doesn't expose a manual
|
||
# release; the slot returns to the pool when our submitted no-op
|
||
# finishes, which happens immediately after .result() above.)
|
||
pass
|
||
|
||
# ── wire protocol ──────────────────────────────────────────────────────
|
||
|
||
def _send(self, msg: dict) -> None:
|
||
"""Length-prefixed JSON over the sidecar's stdin. Caller holds lock."""
|
||
if self._proc is None or self._proc.stdin is None:
|
||
raise RuntimeError(f"{self.id} sidecar not running")
|
||
body = json.dumps(msg, separators=(",", ":")).encode("utf-8")
|
||
if len(body) > MAX_FRAME_BYTES:
|
||
raise IOError(f"outbound frame too large: {len(body)}")
|
||
try:
|
||
self._proc.stdin.write(struct.pack("!I", len(body)))
|
||
self._proc.stdin.write(body)
|
||
self._proc.stdin.flush() # Pitfall 2 — mandatory flush
|
||
except (BrokenPipeError, OSError) as exc:
|
||
raise RuntimeError(
|
||
f"{self.id} sidecar pipe closed: {exc}"
|
||
) from exc
|
||
|
||
def _recv(self) -> Optional[dict]:
|
||
"""Read one frame from the sidecar's stdout. Returns None on EOF.
|
||
|
||
Op allowlist is enforced here: unknown ops are logged and dropped,
|
||
and we tail-recurse to read the next frame. See T-02-04.
|
||
"""
|
||
if self._proc is None or self._proc.stdout is None:
|
||
return None
|
||
stdout = self._proc.stdout
|
||
header = _read_exact(stdout, 4)
|
||
if header is None:
|
||
return None
|
||
(n,) = struct.unpack("!I", header)
|
||
if n > MAX_FRAME_BYTES:
|
||
# T-02-01 — refuse to allocate before the body even arrives.
|
||
raise IOError(f"frame too large: {n}")
|
||
body = _read_exact(stdout, n)
|
||
if body is None or len(body) != n:
|
||
raise IOError("short read")
|
||
try:
|
||
msg = json.loads(body.decode("utf-8"))
|
||
except Exception as exc:
|
||
raise IOError(f"malformed sidecar frame: {exc}") from exc
|
||
op = msg.get("op") if isinstance(msg, dict) else None
|
||
if op not in PARENT_INBOUND_OPS:
|
||
# T-02-04 — refuse to act on unknown ops. Log and read the
|
||
# next frame so we don't desync.
|
||
logger.warning(
|
||
"[%s] dropped sidecar frame with disallowed op=%r",
|
||
self.id, op,
|
||
)
|
||
return self._recv()
|
||
return msg
|
||
|
||
def _recv_with_timeout(self, timeout_s: float) -> Optional[dict]:
|
||
"""Recv that aborts if the sidecar goes silent.
|
||
|
||
Implemented by polling the proc for liveness with a deadline. We
|
||
don't block on a `select` of the pipe because Windows can't select
|
||
on subprocess pipes — keeping the implementation cross-platform
|
||
means a simpler polling loop here.
|
||
"""
|
||
# On Unix we could use selectors; on Windows the pipe is not
|
||
# selectable. Use a watchdog thread that kills the sidecar on
|
||
# timeout — that triggers EOF on stdout, so _recv returns None
|
||
# and the caller raises.
|
||
watchdog = threading.Timer(timeout_s, self._timeout_kill)
|
||
watchdog.daemon = True
|
||
watchdog.start()
|
||
try:
|
||
return self._recv()
|
||
finally:
|
||
watchdog.cancel()
|
||
|
||
def _timeout_kill(self) -> None:
|
||
proc = self._proc
|
||
if proc is None:
|
||
return
|
||
try:
|
||
logger.error(
|
||
"[%s] sidecar exceeded recv timeout; killing",
|
||
self.id,
|
||
)
|
||
proc.kill()
|
||
except Exception:
|
||
pass
|
||
|
||
# ── stderr drain ───────────────────────────────────────────────────────
|
||
|
||
def _drain_stderr(self) -> None:
|
||
"""Pump sidecar stderr lines into the parent logger.
|
||
|
||
Prefixes each line with `[<engine_id>]`. The HFTokenRedactor filter
|
||
installed at the root logger in Phase 1 redacts any token bytes
|
||
that slip through. See T-02-03.
|
||
"""
|
||
proc = self._proc
|
||
if proc is None or proc.stderr is None:
|
||
return
|
||
try:
|
||
for raw in iter(proc.stderr.readline, b""):
|
||
try:
|
||
line = raw.decode("utf-8", errors="replace").rstrip()
|
||
except Exception:
|
||
line = repr(raw)
|
||
if line:
|
||
logger.info("[%s] %s", self.id, line)
|
||
except Exception as exc:
|
||
logger.debug("[%s] stderr drain ended: %s", self.id, exc)
|
||
|
||
|
||
# ── helpers ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _read_exact(stream, n: int) -> Optional[bytes]:
|
||
"""Read exactly n bytes from a BufferedReader, or return None on EOF.
|
||
|
||
BufferedReader.read(n) is allowed to return fewer than n bytes when
|
||
the underlying file descriptor is a pipe — we loop until we have
|
||
all of them or EOF.
|
||
"""
|
||
out = bytearray()
|
||
while len(out) < n:
|
||
chunk = stream.read(n - len(out))
|
||
if not chunk:
|
||
if not out:
|
||
return None
|
||
return bytes(out)
|
||
out.extend(chunk)
|
||
return bytes(out)
|
||
|
||
|
||
def _is_jsonable(v) -> bool:
|
||
"""Quick filter for kwargs that survive json.dumps. Lists/dicts are
|
||
accepted only if their contents are themselves jsonable."""
|
||
if v is None or isinstance(v, (bool, int, float, str)):
|
||
return True
|
||
if isinstance(v, (list, tuple)):
|
||
return all(_is_jsonable(x) for x in v)
|
||
if isinstance(v, dict):
|
||
return all(isinstance(k, str) and _is_jsonable(x) for k, x in v.items())
|
||
return False
|
||
|
||
|
||
__all__ = [
|
||
"SubprocessBackend",
|
||
"MAX_FRAME_BYTES",
|
||
"PARENT_INBOUND_OPS",
|
||
"SIDECAR_INBOUND_OPS",
|
||
]
|