[Bug] Race Condition (TOCTOU) in /tts voice file check-load/create causes intermittent 500 and corrupted audio
Race Condition (TOCTOU) in /tts voice file check-load/create
Describe the bug
The /tts endpoint (app.py, function tts()) implements a classic Time-of-Check-Time-of-Use (TOCTOU) race condition when resolving a voice preset file. The app is served by waitress, a multi-threaded WSGI server, so concurrent requests are handled in separate threads with no synchronization around the voice file.
The flow for a voice that does not yet exist on disk:
- Check phase — the code tests
os.path.exists(seed_path)for.csv/.ptvariants (lines 180, 183, 188). All three branches miss, sorand_spkstaysNone. - Create phase — at line 192,
if rand_spk is None:, a new speaker embedding is generated (torch.randn(768) * std + mean, line 198) and persisted viautils.save_speaker(voice, rand_spk)(line 200). - Use phase — for the
.ptbranch specifically (line 183-185), a subsequent request doestorch.load(seed_path, ...).
There is no lock, no atomic write (torch.save / utils.save_speaker serializes incrementally and is not atomic), and no file-locking around the check/create/load sequence. Two concurrent requests targeting the same not-yet-created voice can both pass the exists() checks, both enter the create branch, and interleave writes with reads.
To Reproduce
Steps to reproduce the behavior:
Start ChatTTS-UI with the default waitress server (multi-threaded).
Pick a voice name that does not yet exist, e.g.
voice=9999.pt.Send two (or more) simultaneous requests for that same voice:
curl -G "http://127.0.0.1:9966/tts" \ --data-urlencode "voice=9999.pt" \ --data-urlencode "text=hello" & curl -G "http://127.0.0.1:9966/tts" \ --data-urlencode "voice=9999.pt" \ --data-urlencode "text=world" & waitBoth threads evaluate
os.path.exists(seed_path)→Falsebefore either writes the file.Thread A enters the generate branch and begins
utils.save_speaker(...)(which callstorch.save).Thread B (or a third request arriving milliseconds later) sees the partially-written file via
os.path.exists→Trueand callstorch.load(seed_path, ...)on a half-written pickle.Observe: intermittent HTTP 500 (deserialization crash on truncated pickle) or silently corrupted speaker embeddings producing garbage audio. Repeating makes the failure reliably reproducible.
Expected behavior
Each voice preset must be reliably loaded or atomically created exactly once. Concurrent requests for the same not-yet-existing voice should never read a half-written file or create it twice. The check-create-load sequence should be serialized per voice (e.g. via a per-path lock) or written atomically (write to a temp file + os.replace).
Configuration
- Python version: 3.10+
- ChatTTS-UI version:
mainbranch (latest commit at time of report) - WSGI server: waitress (default, multi-threaded)
- Device: any (CPU or CUDA)
Proof of Concept
# voice 9999.pt does not exist beforehand; fire 5 concurrent requests:
for i in 1 2 3 4 5; do
curl -s -G "http://127.0.0.1:9966/tts" \
--data-urlencode "voice=9999.pt" \
--data-urlencode "text=test number $i" \
-o /dev/null -w "%{http_code}\n" &
done
wait
# Expect: a mix of 500 (torch.load on partial file) and 200 (corrupted embedding).Additional context
Suggested fix — wrap the voice resolution in a per-path lock, and/or make the write atomic:
import threading, os
_voice_locks = {}
_voice_locks_guard = threading.Lock()
def _get_voice_lock(path):
with _voice_locks_guard:
if path not in _voice_locks:
_voice_locks[path] = threading.Lock()
return _voice_locks[path]
# in tts():
with _get_voice_lock(seed_path):
# existing check / create / load logic
...
# for the save: write atomically
tmp = seed_path + ".tmp"
utils.save_speaker(voice + ".tmp", rand_spk)
os.replace(tmp, seed_path)Affected location: app.py:tts() — voice loading/saving block, lines ~176-200 (check at 180/183/188, create at 192-200, load at 185).
This issue was found by automated security audit (race-condition review of the /tts endpoint) and verified against the current main branch source.
Source: jianchang512/ChatTTS-ui