fix(waves): close the TTS socket on every path out of a stream - #112
CaptainAni187 wants to merge 1 commit into
Conversation
synthesize and synthesize_streaming closed the socket only after the consume loop ran to completion, so an error raised past the close and a consumer that stopped reading early never reached it. start_streaming_session never closed at all and the class exposed no way to do it by hand. A connect that timed out left its half-open socket and run_forever thread behind too. Close in a finally on all three generators, add a public close(), and close the half-open socket before raising the connect timeout.
|
crim doesn't review pull requests automatically here. Comment |
|
crim review |
|
crim is reviewing this pull request. Findings will be posted shortly. |
Good to mergeA PR about not leaking sockets that also doesn't leak untested code paths — the fake WebSocketApp even drives its own callbacks. Ship it before the connection times out. |
There was a problem hiding this comment.
LGTM
Assessment
A focused, correct fix that guarantees the TTS WebSocket is closed on every exit path, backed by thorough tests.
What this PR does
Adds a public, idempotent close() method and wraps each streaming generator's consume loop in try/finally: self.close(), so the socket is released when the stream completes, raises, or is abandoned early. _connect() now routes cleanup through close(), and a failed connect closes the half-open socket before raising. New lifecycle tests exercise all paths with a fake WebSocketApp.
Findings
No issues found. The close() swap-and-none pattern is safe to call repeatedly, the finally blocks cover completion/error/early-exit, and the documented manual-session contract for start_streaming_session is consistent with its implementation.
synthesizeandsynthesize_streamingcallws.close()on the line after the consume loop, so the socket is closed only when that loop runs to completion. Two ordinary paths skip it:An error mid-stream raises out of the generator and the close never runs. A caller who stops reading early, which is just
breakin aforloop over the chunks, never runs it either, because abandoning a generator throwsGeneratorExitat theyield. Both leak the socket and therun_foreverthread behind it, and in a long-lived process those accumulate.start_streaming_sessionhas nows.close()on any path, and the class exposes no public way to reach the socket, so a caller of that method has no supported way to release it at all. That method is the one meant for manualsend_text_chunk/flush_bufferdriving, which is exactly the case where the session outlives a single call.A connect that times out also leaves its half-open
WebSocketAppand thread behind before raising.The change
All three generators close in a
finally, which covers normal completion, the raise, and the abandoned-generator case in one place.close()becomes public and idempotent, so the manual-session caller has something to call, and_connectuses it both to replace an existing socket and to clean up after a timeout.close()clearsself.wsbefore closing so a second call is a no-op rather than a double close on a socket that may already be gone.Tests
tests/custom/test_waves_stream_tts_lifecycle.py, six cases, no network:WebSocketAppis replaced with a fake that records sends and closes and drives the callbacks itself.Five of the six fail on
main, one for each leak described above. The sixth is the normal-completion path, which was never broken; it is there so thefinallycannot regress it into a double close, and it asserts exactly one close.The tests assert the socket was closed rather than that
self.wsisNone, so they pin the behaviour and not this particular implementation of it.src/smallestai/waves/stream_tts.pyis.fernignored, so a regen keeps this.One thing left alone
_on_closeputs the sentinel on the queue when the socket closes withoutis_complete, so a clean server-side close part way through a stream reads to the consumer as a normal end rather than a truncation. That is a behaviour change rather than a leak, so I left it out of this one. Happy to open it separately if you want it.