fix(frank-tts): send the registration body through a file, not an argument
The deploy of the newly built engine image came back `0 voice(s) warm` with
`/app/warm-voices.sh: line 152: /usr/bin/curl: Argument list too long` above
every line, and the unit was `active` with a green `/health` beside it. The hook
built `{"name":…,"wav_b64":…}` in a shell variable and passed it as ONE curl
argument; the kernel caps a single argument at MAX_ARG_STRLEN = 131,072 bytes,
and the shipped reference clips base64 to about 1.27 MB, so curl was never
executed and the hook reported the absence as "HTTP no response".
The body now goes to a mktemp file and curl reads `@file`; the audio enters
through a redirection instead of a command substitution, so the script never
holds a 1.3 MB string at all. Every pack in the fleet is ~1 MB, i.e. ~13x the
cliff, so this was always going to happen — it took the first real packs to show
it because no fixture was ever that big.
The regression test therefore ships a 300 KB pack against the seam's REAL stub
listener with the REAL curl: it fails with the same kernel error the host did
(`Argument list too long`, then the 400-for-every-name summary), and passes once
the body is a file. Proved red before the fix, green after.
This commit is contained in:
@@ -16,17 +16,17 @@
|
|||||||
# voice that did not warm becomes a deploy result instead of only a journal
|
# voice that did not warm becomes a deploy result instead of only a journal
|
||||||
# line (the unit's `-` prefix deliberately cannot fail the unit).
|
# line (the unit's `-` prefix deliberately cannot fail the unit).
|
||||||
#
|
#
|
||||||
# WHY IT EXISTS AT ALL (the cold-voice law). The FIRST speech request for a
|
# WHY IT EXISTS AT ALL (the registry law). The voice registry is MEMORY ONLY: a
|
||||||
# registered voice decodes the whole reference clip to snapshot its codec state:
|
# restart forgets every voice, and re-registering a voice does not warm it — only
|
||||||
# 6.2-8.0 s, first audio byte at 5,530 ms. Every later request for that voice
|
# synthesizing does. A boot that registers nothing therefore leaves frank-tts
|
||||||
# restores the snapshot in 0.5 ms. Two facts make that a boot problem rather than
|
# answering 400 for every name, so this script registers every pack it finds. The
|
||||||
# a curiosity: the voice registry is MEMORY ONLY (a restart forgets every voice),
|
# per-voice cold penalty is real but small, and smaller than the trial reported:
|
||||||
# and re-registering a voice does not warm it — only synthesizing does. So a boot
|
# measured on the deployed build 2026-10-18 against an empty registry, the FIRST
|
||||||
# that registers the voices and stops leaves the first caller of the day paying
|
# synthesis of a freshly registered voice cost 1,883 ms against 1,226-1,283 ms for
|
||||||
# 5.5 s of silence. This script speaks one short sentence per voice instead, then
|
# the requests after it — about 0.6 s, not the trial's 6.2-8.0 s. Each voice gets
|
||||||
# speaks a second one so the journal carries the warm number too: the line
|
# two identical requests so the journal carries both numbers, `voice <name>
|
||||||
# `voice <name> cold=…ms warm=…ms` is the repo's standing proof that the second
|
# cold=…ms warm=…ms`; cold ≈ warm is the expected shape, and a large gap is what
|
||||||
# request of a voice answers well under a second.
|
# would mean the snapshot path is broken.
|
||||||
#
|
#
|
||||||
# THE VOICES. /voices — the host directory /home/dank/voices mounted read-only by
|
# THE VOICES. /voices — the host directory /home/dank/voices mounted read-only by
|
||||||
# the unit, the same packs frank-tts mounts for its own name list. A pack is a
|
# the unit, the same packs frank-tts mounts for its own name list. A pack is a
|
||||||
@@ -47,7 +47,9 @@
|
|||||||
#
|
#
|
||||||
# NOTHING HERE IS A SECRET, and nothing is logged that could be: the audio bytes
|
# NOTHING HERE IS A SECRET, and nothing is logged that could be: the audio bytes
|
||||||
# are discarded, the synthesized text comes from the env file, and the reference
|
# are discarded, the synthesized text comes from the env file, and the reference
|
||||||
# clip is base64'd straight into the request body without ever being echoed.
|
# clip is base64'd into a request body that is written to a temporary file and
|
||||||
|
# handed to curl as `@file` — never echoed, and never placed on a command line,
|
||||||
|
# because of the size law explained at the mktemp below.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# The engine's own address, from inside its namespace. Loopback is valid HERE:
|
# The engine's own address, from inside its namespace. Loopback is valid HERE:
|
||||||
@@ -103,6 +105,18 @@ fi
|
|||||||
warm_json="${warm_text//\\/\\\\}"
|
warm_json="${warm_text//\\/\\\\}"
|
||||||
warm_json="${warm_json//\"/\\\"}"
|
warm_json="${warm_json//\"/\\\"}"
|
||||||
|
|
||||||
|
# The registration body is a FILE, never an argument. The kernel caps a single
|
||||||
|
# command-line string at 32 pages — MAX_ARG_STRLEN = 131,072 bytes — and a shipped
|
||||||
|
# reference clip base64's to about 1.27 MB, so passing the body as one argument
|
||||||
|
# fails with E2BIG before curl is even executed. The first real boot of the unit
|
||||||
|
# (2026-10-18, the three shipped packs) proved it end to end: `/usr/bin/curl:
|
||||||
|
# Argument list too long` for every voice, reported as the misleading
|
||||||
|
# "registration failed (HTTP no response)". `mktemp` lands in the container's
|
||||||
|
# /tmp as the unprivileged `tts` user; one file for the whole pass, overwritten
|
||||||
|
# per voice, removed on any exit — it is container scratch, not host state.
|
||||||
|
body_file="$(mktemp -t frank-tts-warm.XXXXXX)"
|
||||||
|
trap 'rm -f "$body_file"' EXIT
|
||||||
|
|
||||||
fail=0
|
fail=0
|
||||||
log() { printf 'frank-tts-engine-warm: %s\n' "$*"; }
|
log() { printf 'frank-tts-engine-warm: %s\n' "$*"; }
|
||||||
|
|
||||||
@@ -146,9 +160,16 @@ for dir in "$VOICES_DIR"/*/; do
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
body="$(printf '{"name":"%s","wav_b64":"%s"}' "$name" "$(base64 -w0 "${dir}reference.wav")")"
|
# The audio enters through a redirection rather than a command substitution:
|
||||||
|
# the file is several hundred KB, and both a shell variable and an argument of
|
||||||
|
# that size are what this script used to die on.
|
||||||
|
{
|
||||||
|
printf '{"name":"%s","wav_b64":"' "$name"
|
||||||
|
base64 -w0 "${dir}reference.wav"
|
||||||
|
printf '"}'
|
||||||
|
} > "$body_file"
|
||||||
if ! code="$(curl -sS --max-time "$REQUEST_TIMEOUT" -o /dev/null -w '%{http_code}' \
|
if ! code="$(curl -sS --max-time "$REQUEST_TIMEOUT" -o /dev/null -w '%{http_code}' \
|
||||||
-H 'Content-Type: application/json' --data-binary "$body" \
|
-H 'Content-Type: application/json' --data-binary "@${body_file}" \
|
||||||
"${ENGINE_URL}/v1/audio/voices")" || [[ "$code" != "200" ]]; then
|
"${ENGINE_URL}/v1/audio/voices")" || [[ "$code" != "200" ]]; then
|
||||||
log "voice '${name}': registration failed (HTTP ${code:-no response}) — the shim will answer 400 for this name"
|
log "voice '${name}': registration failed (HTTP ${code:-no response}) — the shim will answer 400 for this name"
|
||||||
fail=1
|
fail=1
|
||||||
|
|||||||
@@ -394,6 +394,37 @@ assert {s["response_format"] for s in speech} == {"wav"}, speech
|
|||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "a reference clip too big for one argument still registers" {
|
||||||
|
# The kernel caps a SINGLE command-line string at 32 pages = 131,072 bytes
|
||||||
|
# (MAX_ARG_STRLEN), and a shipped reference clip base64's to about 1.27 MB —
|
||||||
|
# so a warmer that hands the registration body to curl as an argument dies
|
||||||
|
# before curl runs. That is exactly what the first real boot of 2026-10-18
|
||||||
|
# hit with the real packs: `/usr/bin/curl: Argument list too long`, then the
|
||||||
|
# misleading "registration failed (HTTP no response)" for all three voices.
|
||||||
|
# The stub engine is a real listener and curl is the real binary, so this
|
||||||
|
# test fails for the same kernel reason the host did.
|
||||||
|
start_engine_stub
|
||||||
|
local big="$TEST_TMP/bigvoices/bigvoice"
|
||||||
|
mkdir -p "$big"
|
||||||
|
head -c 300000 /dev/zero | tr '\0' 'A' > "$big/reference.wav"
|
||||||
|
|
||||||
|
run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$TEST_TMP/bigvoices" \
|
||||||
|
bash "$WARM"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||||
|
[[ "$output" == *"voice bigvoice cold="*"ms warm="*"ms"* ]] || { echo "$output"; false; }
|
||||||
|
|
||||||
|
# And the audio that arrived is the audio that was on disk, whole.
|
||||||
|
python3 - "$TEST_TMP/journal.jsonl" "$big/reference.wav" <<'PY'
|
||||||
|
import hashlib, json, sys
|
||||||
|
entries = [json.loads(l) for l in open(sys.argv[1])]
|
||||||
|
regs = [e for e in entries if e["path"] == "/v1/audio/voices"]
|
||||||
|
assert len(regs) == 1, entries
|
||||||
|
assert regs[0]["name"] == "bigvoice", regs[0]
|
||||||
|
want = hashlib.sha256(open(sys.argv[2], "rb").read()).hexdigest()
|
||||||
|
assert regs[0]["wav_sha256"] == want, (len(regs[0]["wav_sha256"]), want)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
@test "the warm sentence survives both readers of the env file intact" {
|
@test "the warm sentence survives both readers of the env file intact" {
|
||||||
# scripts/frank-tts-engine.env is read two ways and they disagree about
|
# scripts/frank-tts-engine.env is read two ways and they disagree about
|
||||||
# quotes. bash (`set -a; source`, what the seams and a human do) strips the
|
# quotes. bash (`set -a; source`, what the seams and a human do) strips the
|
||||||
|
|||||||
Reference in New Issue
Block a user