Skip to content

[Bug] Race Condition (TOCTOU) in /tts voice file check-load/create causes intermittent 500 and corrupted audio #301

Description

@Galaxync

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:

  1. 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.
  2. 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).
  3. 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:

  1. Start ChatTTS-UI with the default waitress server (multi-threaded).

  2. Pick a voice name that does not yet exist, e.g. voice=9999.pt.

  3. 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
  4. Both threads evaluate os.path.exists(seed_path)False before either writes the file.

  5. Thread A enters the generate branch and begins utils.save_speaker(...) (which calls torch.save).

  6. Thread B (or a third request arriving milliseconds later) sees the partially-written file via os.path.existsTrue and calls torch.load(seed_path, ...) on a half-written pickle.

  7. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions