Skip to content

fix(tts): never leak temp files and run gTTS off the event loop - #200

Merged
Rushaway merged 3 commits into
masterfrom
fix/183-tts-tempfile-leaks
Oct 14, 2026
Merged

Rushaway merged 3 commits into
masterfrom
fix/183-tts-tempfile-leaks

Conversation

@Rushaway

Copy link
Copy Markdown
Member

Summary

Fixes the temp-file and file-descriptor leaks in the TTS commands (!say, !tsay, !dec) and moves gTTS synthesis off the event loop. Scope: the Say, TranslateSay and DECTalk classes in Commands.py, plus a few module-level helpers placed between TranslateSay and DECTalk.

One ownership scheme for TTS temp files

Why: Say.Say and TranslateSay.Say created a NamedTemporaryFile(delete=False) and then called gTTS.write_to_fp(). Any gTTS or HTTP error (rate limit, network down) left the handle open and the file on disk. DECTalk.Say leaked the file when the say binary was missing or communicate() failed.

Fix:

  • _write_gtts_file writes inside with temp_file: and removes the file if anything raises.
  • _play_tts_file hands the file to the clip's Stop callback only after Play() succeeded. If AudioClip() returns None, Play() returns False, or either of them raises, the file is removed.
  • The Stop callback captures only the path string, so no file object or other large object outlives the playback.
  • Temp files use the torchlight-tts- prefix (.mp3 for gTTS, .wav for DECtalk), so leftovers are easy to spot.
  • TranslateSay.Say now calls super().Say(...) after translating instead of keeping a copy of the synthesis code.

gTTS no longer blocks the event loop

Why: write_to_fp() is a synchronous HTTP call. While it ran, the whole loop was frozen: every playing stream stopped feeding the voice server, and RCON and SourceMod messages queued up.

Fix: gTTS(...) and write_to_fp now run in asyncio.to_thread. The worker thread cannot be interrupted, so if the task is cancelled while it runs, a done-callback removes the file once the thread finishes.

DECTalk hardening

Why: the say exit code was ignored, so a failed synthesis played an empty file. The process was not killed on error or cancellation. _func also set the chat cooldown twice.

Fix:

  • A non-zero exit code is treated as a failure.
  • If communicate() raises or is cancelled, the process is killed and reaped.
  • The temp file is removed on every failure path.
  • The duplicate SetPlayerCooldown is gone.

Failures are logged and reported to the player

Why: Say() was started with a bare asyncio.ensure_future, so its exceptions were never logged.

Fix:

  • Say._func (also used by TranslateSay) and DECTalk._func now start the coroutine with Utils.FireAndForget(coro, self.logger).
  • Synthesis failures are logged and the player gets a private message. gTTS shows TTS failed: <error>, the same text !tsay already used. DECtalk shows a plain TTS failed., because its errors can contain server paths.

Everything else the player sees is unchanged.

Test plan

  • ruff check ., ruff format --check ., mypy: no new findings. The only remaining ones are the 3 known baseline errors fixed by CI: Python Linting is red on master since the Python 3.12 bump (ruff UP041, mypy arg-type) #178's PR.
  • Local script with stubbed gTTS, AudioManager and subprocess. It counts torchlight-tts-* files in the temp dir before and after each case:
    • write_to_fp raises: no file left, player gets TTS failed: ..., returns 1
    • Slow fake gTTS (0.5 s): a concurrent 20 ms ticker kept ticking (16 ticks), so the loop is not blocked
    • AudioClip() returns None, Play() returns False, AudioClip() raises: no file left
    • Successful play: the file exists until the Stop callback runs, then it is removed. The callback closure holds only a str.
    • Task cancelled during gTTS synthesis: the file is removed once the worker finishes
    • TranslateSay: gTTS error, Play() fails, and success with Stop
    • DECTalk with the binary missing: no file left, player gets TTS failed.
    • DECTalk with a non-zero exit code: no file left, nothing played
    • DECTalk with a hung process that gets cancelled: the process is killed and waited, and no file is left
    • DECTalk success: plays with -af volume=10dB, and the file is removed on Stop
    • DECTalk._func: goes through FireAndForget (an unexpected exception is logged) and sets the cooldown once
  • Needs a live server: !say, !tsay, !dec play normally, and nothing stays in /tmp after playback ends or is stopped

No VERSION bump (batch policy, see #177).

Closes #183
Part of #177

🤖 Generated with Claude Code

Say, TranslateSay and DECTalk now share one temp-file ownership scheme:
the file (prefixed torchlight-tts-) is closed and removed on every
failure path, and is only handed to the clip's Stop callback once
Play() succeeded. That callback captures just the path string.

gTTS synthesis runs in asyncio.to_thread so its HTTP round-trip no
longer freezes the loop (stream stutter, queued RCON/SourceMod
messages). If the task is cancelled while the worker runs, the file is
removed when the worker finishes.

DECTalk checks the say exit code, kills and reaps the process when
communicate() fails or is cancelled, and no longer sets the chat
cooldown twice.

The Say coroutines are started with Utils.FireAndForget so unexpected
errors are logged, and the player is told privately when TTS fails.

Closes #183

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Rushaway
Rushaway merged commit 7bc2faf into master Oct 14, 2026
2 checks passed
@Rushaway
Rushaway deleted the fix/183-tts-tempfile-leaks branch October 14, 2026 11:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Say/TranslateSay/DECTalk: temp files leak on failure paths and gTTS blocks the event loop

1 participant