|
82 | 82 | PIPER_VOICE = os.getenv("PIPER_VOICE", "en_US-lessac-medium") |
83 | 83 | GRADER_WORKER_PATH = Path(__file__).resolve().parent / "grader_worker.py" |
84 | 84 | GRADE_SUBPROCESS_TIMEOUT_SECS = float(os.getenv("GRADE_SUBPROCESS_TIMEOUT_SECS", "60")) |
| 85 | +# Generous relative to the frontend's own 30s connect timeout, so a slow but |
| 86 | +# genuine client is never cut off — this only catches offers that go nowhere. |
| 87 | +SESSION_HANDSHAKE_TIMEOUT_SECS = float(os.getenv("SESSION_HANDSHAKE_TIMEOUT_SECS", "45")) |
| 88 | +# Each session loads Whisper/Piper and drives Ollama, so concurrency is bounded. |
| 89 | +MAX_ACTIVE_BOTS = int(os.getenv("MAX_ACTIVE_BOTS", "2")) |
85 | 90 |
|
86 | 91 | COACH_BEHAVIOR = ( |
87 | 92 | "Conduct a live voice interview. Ask probing follow-ups, push for trade-offs, " |
@@ -845,9 +850,11 @@ async def on_client_connected(transport: SmallWebRTCTransport, client: Any) -> N |
845 | 850 | # PipelineWorker enables RTVI by default and add_event_handler appends, |
846 | 851 | # so this runs alongside pipecat's own set_bot_ready() handler. |
847 | 852 | greeted = False |
| 853 | + client_ready = asyncio.Event() |
848 | 854 |
|
849 | 855 | @worker.rtvi.event_handler("on_client_ready") |
850 | 856 | async def on_client_ready(rtvi: Any) -> None: |
| 857 | + client_ready.set() |
851 | 858 | # A client that re-sends ready (reconnect) must not replay the |
852 | 859 | # welcome over an interview already in progress. |
853 | 860 | nonlocal greeted |
@@ -893,8 +900,34 @@ async def on_client_disconnected(transport: SmallWebRTCTransport, client: Any) - |
893 | 900 | # signal handling; the worker is still torn down from |
894 | 901 | # on_client_disconnected above. |
895 | 902 | runner = WorkerRunner(handle_sigint=False, handle_sigterm=False) |
896 | | - await runner.add_workers(worker) |
897 | | - await runner.run() |
| 903 | + |
| 904 | + async def _handshake_watchdog(current: ActiveSession) -> None: |
| 905 | + """Tear the session down if the client never finishes connecting. |
| 906 | +
|
| 907 | + /api/offer accepts an SDP and starts the pipeline immediately, so a |
| 908 | + caller that never completes the WebRTC/RTVI handshake would leave |
| 909 | + Whisper, Piper and Ollama loaded until the transport happened to |
| 910 | + notice or the process exited. |
| 911 | + """ |
| 912 | + try: |
| 913 | + await asyncio.wait_for( |
| 914 | + client_ready.wait(), timeout=SESSION_HANDSHAKE_TIMEOUT_SECS |
| 915 | + ) |
| 916 | + except asyncio.TimeoutError: |
| 917 | + logger.warning( |
| 918 | + f"No client handshake within {SESSION_HANDSHAKE_TIMEOUT_SECS:.0f}s " |
| 919 | + f"(track={track_id}); ending session." |
| 920 | + ) |
| 921 | + await current.shutdown() |
| 922 | + |
| 923 | + watchdog = asyncio.create_task(_handshake_watchdog(session)) |
| 924 | + try: |
| 925 | + await runner.add_workers(worker) |
| 926 | + await runner.run() |
| 927 | + finally: |
| 928 | + watchdog.cancel() |
| 929 | + with suppress(asyncio.CancelledError): |
| 930 | + await watchdog |
898 | 931 | finally: |
899 | 932 | if session is not None: |
900 | 933 | active_sessions.discard(session) |
@@ -1094,6 +1127,16 @@ async def offer(request: Request) -> dict[str, Any]: |
1094 | 1127 | status_code=503, |
1095 | 1128 | detail=f"Grader worker missing at {GRADER_WORKER_PATH.name}.", |
1096 | 1129 | ) |
| 1130 | + # Refuse before spawning: every session loads Whisper/Piper and competes for |
| 1131 | + # the same local Ollama, so unbounded offers would degrade the live ones. |
| 1132 | + if active_bots >= MAX_ACTIVE_BOTS: |
| 1133 | + raise HTTPException( |
| 1134 | + status_code=503, |
| 1135 | + detail=( |
| 1136 | + f"At capacity: {active_bots} interview(s) already running " |
| 1137 | + f"(MAX_ACTIVE_BOTS={MAX_ACTIVE_BOTS}). Try again shortly." |
| 1138 | + ), |
| 1139 | + ) |
1097 | 1140 |
|
1098 | 1141 | body = await _json_object_body(request) |
1099 | 1142 |
|
|
0 commit comments