mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-31 10:28:43 -04:00
The model's `task:` option is copied into the request shape by all nine
handlers, which is correct, but resolve_route then replaced the RPC's candidate
list with the pin WHOLESALE and never asked whether the pin was something that
RPC routes to. One pin therefore bled across all nine surfaces, and because the
family still supported the pinned task the result was a wrong 200 rather than an
error. Reproduced live: nemotron with task:asr made Vad return 200 with zero
segments after a full ASR decode, so 14 seconds of speech was reported as
silence, and Diarize did the same; silero_vad with task:vad made
AudioTranscription return 200 with empty text and four segments whose spans were
VAD segments, which combined with response_format in {text,srt,vtt,lrc} building
the body solely from Segments[].Text yields a well formed SRT of four timed
EMPTY cues. It also contradicted the documented contract, that a family which
cannot serve a request is refused rather than rerouted.
A pin is now checked against the RPC's admissible task set before it is adopted,
and the refusal names both the pin and the RPC. The set is derived from
task_candidates with every shape flag set rather than restated, so a task added
to an RPC's candidates cannot become inadmissible by omission. Every legitimate
pin survives, and the test asserts all fifteen of them alongside the eight
crossings that must not.
The live watchdog was defeated by empty frames. idle.touch() ran on ANY message,
before the has_audio and pcm.empty() filters, so a peer writing unset-oneof or
zero-length frames faster than the window held the lane indefinitely while
feeding the decoder nothing. There is one lane per model and one model per
process, so that is a single client denying the whole backend, which is what the
watchdog exists to prevent, and the thrown text already said "no audio frame
arrived". The touch moved below the filters, which are now a named predicate so
the distinction is testable rather than a call order nobody can see.
Three comments corrected against measurement rather than reasoning:
- CMakeLists claimed zero google::protobuf:: definitions remain in the
executable. nm -C --defined-only reports 2515, and that is expected: they are
generated code, sentencepiece::ModelProto's own _InternalParse among them. The
claim that holds, and the one the ABI fix is actually about, is that no
vendored protobuf RUNTIME is linked and ParseContext::ParseMessage is
UNDEFINED in the executable, resolving to libprotobuf.so.
- refuse_cloning_without_a_clip's "cannot misfire" paragraph had its reasoning
backwards. Routing picks VoiceCloning as the FALLBACK when there is no clip,
which is the case being caught; chatterbox, which ships in the gallery,
advertises clon and no tts at all, so every voice-less request lands there.
- audio_units read "2.1 min at 96 kHz" for index 11289602, which is 1.96 min.
2.1 min is 96 kHz's OWN first failure at 12288002. Both were remeasured and
the note is now a per-rate table.
Assisted-by: Claude:claude-opus-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
100 lines
4.5 KiB
C++
100 lines
4.5 KiB
C++
#pragma once
|
|
|
|
// A one-shot idle timer for a bidirectional stream. Standard library only, so
|
|
// it is tested without an audio.cpp checkout or a gRPC server.
|
|
//
|
|
// WHY IT EXISTS. AudioTranscriptionLive holds the model's inference lane for the
|
|
// whole stream, because the streaming session is stateful and a concurrent run
|
|
// would interleave two callers' audio. Every other RPC in this backend holds the
|
|
// lane across COMPUTE, or across a write to a slow reader, and both of those
|
|
// terminate on their own. A live stream instead blocks in a client-driven read,
|
|
// and a peer that goes silent WITHOUT closing the stream never terminates
|
|
// anything: the lane stays taken and every other RPC against that model queues
|
|
// behind a client that has stopped speaking. A websocket death does cancel the
|
|
// RPC and free it, but "the peer's TCP connection eventually dies" is not a
|
|
// bound anyone can state, so this supplies one.
|
|
//
|
|
// HOW IT ENDS THE STREAM, and the part that is not obvious: gRPC's synchronous
|
|
// ServerReaderWriter::Read has no timeout and cannot be given one. The only way
|
|
// to unblock it from another thread is ServerContext::TryCancel, which is what
|
|
// the callback is for. That means the client sees CANCELLED rather than whatever
|
|
// status the handler goes on to return: the returned status is for the server's
|
|
// own record. Releasing the lane is the point.
|
|
//
|
|
// ONE SHOT on purpose. Once the callback has run the stream is being torn down,
|
|
// so there is nothing left to watch, and a repeating timer would call TryCancel
|
|
// on a context the handler may already have returned from.
|
|
|
|
#include <chrono>
|
|
#include <condition_variable>
|
|
#include <functional>
|
|
#include <mutex>
|
|
#include <thread>
|
|
|
|
namespace audiocpp_backend {
|
|
|
|
class IdleWatchdog {
|
|
public:
|
|
// A window that is not positive DISABLES the watchdog entirely: no thread is
|
|
// started and fired() never becomes true. That is the operator's escape
|
|
// hatch for a client that legitimately holds a stream open through long
|
|
// pauses, and it is why the option carrying it documents 0 as "no limit"
|
|
// rather than as "expire immediately".
|
|
//
|
|
// `on_idle` runs on the watchdog's own thread with no lock held. It must be
|
|
// safe to call while the watched thread is blocked in a read, which is the
|
|
// only reason this class exists; ServerContext::TryCancel is documented as
|
|
// exactly that.
|
|
IdleWatchdog(std::chrono::milliseconds window, std::function<void()> on_idle);
|
|
|
|
// Joins the thread, so the callback can safely capture anything that
|
|
// outlives this object's scope and nothing else has to be reasoned about.
|
|
~IdleWatchdog();
|
|
|
|
IdleWatchdog(const IdleWatchdog &) = delete;
|
|
IdleWatchdog &operator=(const IdleWatchdog &) = delete;
|
|
|
|
// Restarts the window. Call it whenever the peer proves it is still there.
|
|
void touch();
|
|
|
|
// Stops watching and joins. Idempotent, and REQUIRED before any long
|
|
// non-read work the window must not cover: the caller's own decode is not
|
|
// the peer going quiet, and cancelling in the middle of it would throw away
|
|
// a transcript the client is waiting for.
|
|
void disarm();
|
|
|
|
// True once the window elapsed and the callback ran. Stays true after
|
|
// disarm, so the caller can tell "the peer closed" from "we cancelled it".
|
|
bool fired() const;
|
|
|
|
private:
|
|
void run();
|
|
|
|
const std::chrono::milliseconds window_;
|
|
std::function<void()> on_idle_;
|
|
|
|
mutable std::mutex mu_;
|
|
std::condition_variable cv_;
|
|
std::chrono::steady_clock::time_point last_;
|
|
bool stop_ = false;
|
|
bool fired_ = false;
|
|
std::thread thread_;
|
|
};
|
|
|
|
// Whether one message read off a live stream is a frame the decoder can
|
|
// actually consume, which is the ONLY thing that counts as the peer proving it
|
|
// is still there.
|
|
//
|
|
// Split out of the read loop so the distinction is testable, and because
|
|
// getting it wrong is silent. The loop used to touch the watchdog on ANY
|
|
// message, before it filtered on has_audio and on an empty pcm field, so a peer
|
|
// writing unset-oneof or zero-length frames faster than the window held the
|
|
// lane forever: no audio was ever fed, no work was ever done, and the timer
|
|
// that exists to break exactly that grip was reset by the frames doing it.
|
|
// There is one lane per model and one model per process, so that is a single
|
|
// client denying the whole backend. The thrown message already said "no audio
|
|
// frame arrived"; this is the code agreeing with it.
|
|
bool live_frame_carries_audio(bool has_audio, bool pcm_empty);
|
|
|
|
} // namespace audiocpp_backend
|