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 / .pt variants (lines 180, 183, 188). All three branches miss, so rand_spk stays None.
- 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 via utils.save_speaker(voice, rand_spk) (line 200).
- Use phase — for the
.pt branch specifically (line 183-185), a subsequent request does torch.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" &
wait
-
Both threads evaluate os.path.exists(seed_path) → False before either writes the file.
-
Thread A enters the generate branch and begins utils.save_speaker(...) (which calls torch.save).
-
Thread B (or a third request arriving milliseconds later) sees the partially-written file via os.path.exists → True and calls torch.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:
main branch (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.
Race Condition (TOCTOU) in
/ttsvoice file check-load/createDescribe the bug
The
/ttsendpoint (app.py, functiontts()) 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:
os.path.exists(seed_path)for.csv/.ptvariants (lines 180, 183, 188). All three branches miss, sorand_spkstaysNone.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)..ptbranch specifically (line 183-185), a subsequent request doestorch.load(seed_path, ...).There is no lock, no atomic write (
torch.save/utils.save_speakerserializes 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 theexists()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:
Both 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
mainbranch (latest commit at time of report)Proof of Concept
Additional context
Suggested fix — wrap the voice resolution in a per-path lock, and/or make the write atomic:
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
/ttsendpoint) and verified against the currentmainbranch source.