Repository navigation
Review fixes: LiteRT-LM server crashes and model defaults, app secret, backups, downloads - #8
Conversation
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
Two crashes in the native OpenAI-compatible server: - StreamCallback released the queue mutex before notify_one(). Once the lock was dropped, the request thread could take the final chunk, finish the response and free the queue, so the runtime thread then signalled a freed condition variable and corrupted the heap. An ASan build caught it with no added delay: about 500 one-token streaming requests under CPU contention ended in a heap-use-after-free in pthread_cond_signal. The notify now happens while the lock is still held. - SendError serialised the error message with nlohmann's strict UTF-8 mode, and it runs inside the request's catch handlers. When the runtime's error text was not valid UTF-8 (it echoes a bad model file's raw magic bytes back), dump() threw out of the handler and std::terminate killed the whole server. The streaming "response.error" event used the same strict dump; there the throw was caught, but the client got neither the error event nor [DONE]. Both dumps now use error_handler_t::replace, so bad bytes become U+FFFD. Verified against the real liblitert-lm.so 0.18.0: release and ASan builds, the original ASan repro (now 0 reports), a 500-request parallel stress run, the malformed-model and invalid-UTF-8 error repros, and normal streaming / non-streaming requests.
The runtime allocates from the uint64 header-end offset at byte 24 of a .litertlm without checking it. A file with the right magic but a bogus offset made liblitert-lm throw std::bad_alloc across the C API into std::terminate (or segfault, for an offset of exactly 32), killing the whole server. Any request naming such a file was enough. GetOrCreateEngine now reads the first 32 bytes (the same ones the daemon's models/litertlm.ts reads) and answers 500 with the usual "Failed to load engine" error, ending in "invalid .litertlm header", unless 32 < header_end <= file size. Files that can't be read, are shorter than 32 bytes or lack the magic are still left to the runtime, which reports them more precisely. The check runs before the loaded engine is released, so a rejected file leaves the current model loaded. Verified against the real liblitert-lm.so 0.18.0 in release and ASan builds: files with an out-of-range offset now get the 500 and the server stays up, other bad files still get the runtime's own error, and normal streaming and non-streaming requests still work. Other malformed files (an in-range offset over junk FlatBuffer bytes, or a file cut off before its first section) still crash inside the upstream runtime 0.18.0; those need an upstream fix.
Cloud backup has a 25 MB quota, and Android skips an app's whole backup when it goes over. The rules only excluded files/models/ (the import folder), while the daemon's default download folder files/.turbollm/models/ and the ~14.5 MB extracted daemon in files/daemon/ were included. So for anyone who had downloaded a model in the app, chats and settings were not backed up at all. backup_rules.xml and data_extraction_rules.xml now also exclude .turbollm/models/, .turbollm/cache/ (LiteRT-LM's compiled-model cache), .turbollm/logs/ (engine logs) and daemon/ (re-extracted from the APK), for both cloud backup and device transfer. Chats and config are still backed up. In the device-transfer block, the comment on the model folders now gives the real reason (multi-GB files that can be fetched again make a transfer much slower) rather than the cloud quota, which does not apply there.
The daemon trusts any request from 127.0.0.1, but on Android every installed app shares that loopback address. Any app with the INTERNET permission could read chats and tokens, change settings and use routes that launch processes as TurboLLM. The app now generates a 32-byte random secret once per process (SecureRandom, hex) and passes it to the daemon as TURBOLLM_APP_SECRET through the existing env map before Node starts. The WebView gets it as an HttpOnly, SameSite=Strict "turbollm_app" cookie, set each time the WebView is created and before the UI is loaded. Every native call to the daemon (the startup probe in DaemonClient.isDaemonReachable, EngineService's activity check and model unload, model import and storage rescan) sends it as the X-TurboLLM-App header. The daemon side, which then trusts only loopback callers that present the secret, is a separate pull request in the TurboLLM repo; without it the cookie and header are simply ignored. Cookies are scoped by host, not port, so anything on the page pointing at another 127.0.0.1 port (an image in a model card or a reply, a link) would hand the secret to whichever app listens there. The WebView now blocks that: - shouldInterceptRequest answers 403 to any request whose URL is 127.0.0.1 on a port other than the daemon's. - shouldOverrideUrlLoading cancels main-frame navigations to such a port, including a redirect hop of a tapped link, before the request goes out. - Image and media requests that redirect there are covered by a CSP header the daemon sends in this mode (also in the TurboLLM pull request). Behaviour change: a link to localhost on any port other than the daemon's now opens in the system browser instead of inside the app, and a link to another 127.0.0.1 port no longer opens at all. Not compiled here (no Android SDK in the review environment): needs an assembleDebug and a quick on-device check.
- In-app model downloads were not counted as busy work, so they got no wake lock and stalled with the screen off. With notifications off, leaving the app could also stop the service mid-download, after which Android could freeze or kill the process. EngineService now also asks GET /api/v1/downloads (sending the app secret, like its other calls) and treats a queued or downloading entry as busy, both in the poll and in the checks before it unloads the model and stops; a paused or failed download does not count. The notification says "Downloading models" meanwhile. A failed query counts as idle, like the existing activity check. - On Android 14+ users can swipe away a foreground-service notification, which also removed the only Quit control while the service kept the model in memory. The notification now has a delete intent that re-posts it while the service is running (and stops the service if it is not). Not compiled here (no Android SDK in the review environment).
stage-daemon-assets.mjs wrote daemon/package.json as {"type":"module"}
only. The daemon reads its version from that file and fell back to
0.1.1, so every Android install reported 0.1.1 in the UI, telemetry
and the Turbo Link hello, and the boot-time update check saw an
"update" on every start. The staged package.json now also carries the
version from turbollm/package.json; staging fails loudly if it is
missing.
The version-writing step was run against a scratch layout, where the
daemon's own lookup then reports the real version. The full staging
script was not run, because it rewrites the APK assets.
stage-play-listing.mjs only matched LF line endings, so on a default Git-for-Windows checkout (CRLF; the repo has no .gitattributes) every field parsed as null and the Play listing step stopped with a "could not parse" error. Line endings are now normalised to LF right after the file is read. Checked with a CRLF copy of the real listing-copy.md: the title and both descriptions parse, with no stray \r in them.
bd75aef to
8c69810
Compare
The app sent its per-launch secret (the X-TurboLLM-App header from native code, the turbollm_app cookie from the WebView) to whatever listened on 127.0.0.1:7000. On a cold start that is not yet the daemon: MainActivity starts EngineService, whose busy polls carry the header, before Node, and the startup probe sent it as well. Another app on the phone could listen on 7000 first, collect the secret, and let go of the port inside the daemon's ~10 s bind retry. The daemon then bound normally, and the stolen secret opened the whole API to that app for as long as the app process lived. A LAN toggle opens the same gap for a moment, while the daemon rebinds its port. Native code now reaches the daemon only through the new DaemonAuth.kt. Before each request, daemonProven() sends a fresh 32-byte SecureRandom nonce to GET /api/v1/app-proof and checks the answer against the HMAC-SHA256, under the secret, of "turbollm-app-proof\n127.0.0.1:7000\n<nonce>", in constant time (MessageDigest.isEqual) and without following redirects. Only the daemon this process started can answer, and the answer is no use as the secret. The daemon signs the address and port it was asked on, so an app holding 7000 cannot relay the nonce to it through another socket. open(path) returns a connection carrying the header only after a proof, and otherwise throws an IOException, which every caller already treats as an unreachable daemon. The proof is per request, not kept until a request fails: an app that took the port between two requests would answer the second one, not fail it. - EngineService (engine/stop, activity, downloads), ModelImport (modeldirs) and StorageAccess (models/rescan) call DaemonAuth.open instead of setting the header themselves. - DaemonWebViewScreen waits for daemonProven(), on Dispatchers.IO, instead of the status probe that carried the secret. The WebView, and with it the cookie set in its factory and the first page load, is created only after that. - DaemonClient.isDaemonReachable (commonMain), whose only caller was that probe, is removed, so no caller is left that sends the secret before a proof. - The comments in MainActivity and at the cookie say so. The daemon side, the proof route and an exit when a LAN toggle finds its port taken (the WebView cannot ask for a proof before each request), is in mohitsoni48/TurboLLM#287. Behaviour change: the app now needs a daemon with /api/v1/app-proof, so merge mohitsoni48/TurboLLM#287 first. Against an older daemon (TurboLLM main today) the app stays on its splash screen with no message, and every native call fails. The PR alpha workflow bundles TurboLLM main, so an alpha built before that merge does the same. This corrects 8568672, which said an older daemon simply ignores the cookie and header. Each native call now makes two loopback connections. Not fixed here: the proof and the request use two connections, a few-ms window that matters only inside a rebind gap. Moving the port through the API (an authenticated caller only) leaves the WebView, and its cookie, on 7000, where another app could listen next. There is no Kotlin test harness in this repo. compileDebugKotlinAndroid passes, and DaemonAuth.kt was compiled and run on the JVM: against an impostor listening on the port it sends nothing (the old code sent the secret); against the daemon's real HTTP app with the same secret it is proven and its request passes the auth gate; against one with another secret it sends nothing. So the app and the daemon agree on the HMAC message byte for byte. Still needs a device check: app start, and a LAN toggle on and off.
A link to http://localhost:7000/... (in a reply or a docs snippet) points at the app's own daemon under another name, so the WebView kept it in the app instead of opening the browser. But the app-secret cookie is set for the host 127.0.0.1 only, so that page loaded without it and showed the API-key prompt. shouldOverrideUrlLoading now catches a main-frame http navigation to localhost on the daemon's port and loads the same URL with its authority rewritten to 127.0.0.1:7000 (path, query and fragment kept). The page stays in the app and gets the cookie. Opening the link in the browser instead would not help: the browser has no cookie either and shows the same prompt. localhost on any other port still opens in the browser; https links and sub-frame navigations are handled as before. Behaviour change: a link to http://localhost:7000/... now opens inside the app as http://127.0.0.1:7000/.... There is no Android test harness in this repo: checked by the compile (compileDebugKotlinAndroid) and by reading. Needs a device check.
3d8e680 to
609020b
Compare
60b3cc3 to
8748b66
Compare
The app-proof handshake added earlier in this PR could be bypassed on every cold start. Android's HttpURLConnection keeps keep-alive sockets in one pool for the whole process and reuses them, even after disconnect(), and a pooled socket stays connected to whoever accepted it. An app listening on 127.0.0.1:7000 at launch (the daemon retries a busy port for about 10 s) could let the daemon have the port but keep the socket EngineService's first poll had left in the pool. The next proof then went out on that socket: the app passed the nonce on to the real daemon, the answer checked out, and the X-TurboLLM-App request that followed reused the same socket and handed it the secret. No race was needed; any app holding the port at launch could do it. DaemonAuth now sets "Connection: close" on the proof connection and on the connection open() returns. A request that says Connection: close is never put back in the pool: its socket is closed after that one exchange. So no socket to the port is ever pooled, and every proof and every request opens a new connection to whoever listens at that moment. The proof's header is what closes the hole; the one on open() makes sure no socket to an earlier listener is left for a later proof to pick up. The comments say so: the class KDoc explains the pool and what is guaranteed now, and that native code must not open any other connection to the daemon's port; open()'s KDoc says callers must not override its Connection header; daemonProven()'s KDoc no longer claims more than it checks (a proof vouches for the daemon holding the port then, not for the socket). No caller changes, and none sets a Connection header. Behaviour change: each proof and each daemon request now uses its own loopback TCP connection instead of a reused one: four new connections per 3-second EngineService poll. This corrects the "Not fixed here" note of ced6bac, which called the remaining problem a few-ms window inside a rebind gap. What is left now is only that gap: during a LAN-toggle rebind, an app that binds 7000 and lets go within those few ms, just as a request is made, could still get the secret (an app still on the port makes the daemon exit). Sending proof and request on one socket would close it too, but needs a hand-written HTTP client in place of HttpURLConnection; not done. There is no Kotlin test harness in this repo. Checked on the JVM with the real DaemonAuth.kt, compiled with kotlinc against OkHttp 2.7.5 (the code behind Android's HttpURLConnection) set up as Android does, with one shared pool, and the daemon's real routes with the same secret binding the port after a squatter lets go. Two squatter strategies (answer the first proof with junk on a keep-alive 200; hold it and answer with the daemon's real proof): before, the squatter received the secret on 3 of 3 busy polls in both; after, 0 times, and the polls get 200 from the real daemon. With only the open() change it still leaks. compileDebugKotlinAndroid passes. Not yet run on a device.
PLAY-FGS-DECLARATION.md, the text for the Play Console's foreground-service declaration, had not caught up with this PR's EngineService changes. In-app model downloads now keep the service and its partial wake lock, the notification says "Downloading models", and on Android 14+ a swiped-away notification is posted again. The page promises Play a description of what the service does, and it described less than that. - "Why foreground": names an in-app model download next to a model import as work the service keeps from being frozen halfway. - "What the user sees": the notification is re-posted at once when swiped away on Android 14 and later, so Quit stays reachable; the "Downloading models" text, shown while a download is running or queued; with notifications off, leaving the app stops the service only once nothing is generating, importing or downloading, and a paused or failed download does not keep it running; the wake lock is held during downloads too. Text only, no code change. For the owner: the declaration now tells Play that the specialUse service also carries downloads, while the answer on why no other type fits still rules out data sync; a Play reviewer may ask about that part.
Several comments in EngineService.kt still described the service from before model imports and this PR's downloads counted as busy work, and one overstated what Quit is for. Comments only, no code change: - Class KDoc: said the wake lock is held only while something is generating, with "busy" coming from /api/v1/activity. The poll counts a reply being generated, a model download (/api/v1/downloads) or a model import (ModelImport) as busy; it now says so. - stopWhenIdle and appClosed(): said the service stops once nothing is generating. The stop waits until nothing is generating, importing or downloading. stopWhenIdle now points at appClosed(), where it is set, instead of onStartCommand. - quit(): called Quit the only way to free a loaded model's memory short of a force-stop. With notifications off appClosed() unloads it, and the UI's Eject does too; it now says Quit is how the user frees it without going back into the app. - The wake-lock renewal comment says "work that is still going", not "a generation". The wake lock's tag "TurboLLM:generation" is left as is: it is runtime data shown in battery stats, not a comment. compileDebugKotlinAndroid passes.
The self-sufficiency check in BUILDING-ENGINES.md, which the doc says to run before every release, ended with an adb forward and `curl /api/v1/engines`, and the run passed when both bundled engines were listed. The per-launch app secret added earlier in this PR broke it. The app starts Node with TURBOLLM_APP_SECRET, and with the matching daemon a request arriving through the adb forward, which carries neither the app's cookie nor its X-TurboLLM-App header, counts as another app on the phone and gets 401 "A valid API key is required for non-local access". adb cannot create a key either: POST /api/v1/keys is refused the same way. A maintainer following the checklist got what looked like a failed boot or missing engines on a build that works. The check now: - curls /healthz over the forward, which needs no key and returns the daemon's status and version; - greps files/daemon.log (read with run-as, which the debug APK the check installs allows) for the "registered bundled engine" line the daemon logs per engine. A new paragraph says why /api/v1/engines answers 401 over the forward (adb, curl and Termux count as another app and need an API key like any LAN client) and how to query the API anyway: create a key in the app under Customize > Connect > API keys and send it as X-TurboLLM-Auth. The "last run" note now says that run used the old engines check, from before the app secret. Docs only. The new commands were checked against the daemon and app sources (the open /healthz route, the log line, Node's stdout going to files/daemon.log), not run on a device.
Two fixes in the native server's main.cc.
A malformed tool call freed the stream queue while the runtime was
still writing to it (older bug, not from this PR). When a request
carries `tools` (an agent client using the daemon's OpenAI or
Anthropic gateway, or a direct call to the engine port; the app's own
chat sends none) and the model writes a <tool_call> block that is not
valid JSON (Qwen-family bundles, whose tool calls are unconstrained),
LiteRT-LM 0.18.0 reports "INVALID_ARGUMENT: Failed to parse tool
calls" as soon as the closing tag streams in, marks it final, and
keeps decoding; at least one more callback always follows. The drain
loop took that error as the end of the generation and
HandleChatCompletions returned without cancelling. The queue was
declared after the conversation, so it was destroyed first; deleting
the conversation then waited (up to 10 minutes, holding the engine
lock) while the runtime kept calling StreamCallback with the freed
queue. On a glibc build the runtime thread blocked for good on the
freed mutex: the streaming client got response.error and [DONE] but
its connection never closed, a non-streaming client got no reply at
all, and every later chat request hung while /health still said ok.
On Android, locking a destroyed mutex aborts the engine (or the heap
is corrupted instead).
- The StreamQueue is now declared before the conversation, so it is
freed after it. Deleting the conversation waits for its generation
to end, so every StreamCallback has returned before the queue goes,
whichever chunk was marked final. The StreamGuard comment now says
a chunk marked final is not always the last callback.
- Every error chunk now cancels the generation first
(litert_lm_conversation_cancel_process, once, through the existing
`cancelled` flag; a no-op once the generation has ended), so
deleting the conversation does not wait for the rest of the turn
while the request holds the engine.
- The error text is now matched from its start; it is
absl::Status::ToString(), code first. A turn ends quietly only on
"CANCELLED:", and with finish_reason "length" only on
"RESOURCE_EXHAUSTED: Max number of tokens reached". The old search
over the whole text (also an older bug) misread parse errors, which
quote the model's output: a malformed call such as
{"status":"CANCELLED"} passed for a cancelled turn (finish "stop"
or a 200, no error, the turn left running), and one quoting the
max-tokens text for a length stop. No error the 0.18.0 runtime
really sends changes class: a real cancel reads "CANCELLED: Task
cancelled", and its max-tokens error starts with
"RESOURCE_EXHAUSTED:".
The .litertlm header-offset check added earlier in this PR accepted
any offset up to the file size. LiteRT-LM 0.18.0 reads the header from
the file's first 16 KiB only (kLitertLmHeaderMaxSize), yet first
allocates and zeroes header_end - 32 bytes. So an offset between
16 KiB and the file size could never load, but cost an allocation up
to the whole file before "Failed to read header data": for a crafted
or corrupt multi-GB file on a phone, the bad_alloc-to-terminate crash
(or a low-memory kill) the check was meant to stop. A 2 GiB file under
a 1.5 GiB memory limit aborted the server. GetOrCreateEngine now also
rejects header_end > min(file size, 16 KiB), with the same "invalid
.litertlm header" 500 and before the loaded engine is released;
header_end <= 32 stays rejected. No loadable file is refused: an
offset of exactly 16 KiB still loads, and the runtime fails every
larger one itself. The comment no longer says the runtime allocates
"unchecked" (it does reject an offset below 32; the allocation is what
it leaves unbounded). <algorithm> is now included for std::min.
Behaviour change: any error chunk from the runtime now stops the
generation. After a malformed tool call the streaming connection
closes right after response.error and [DONE], a non-streaming client
gets the 500 with the runtime's message, and the next request is
served. A parse error that quotes "CANCELLED" or "Max number of tokens
reached" is now reported as an error, not as a normal or length stop.
A file with a header offset over 16 KiB now gets "invalid .litertlm
header" instead of the runtime's "Failed to read header data".
No native test harness exists in the repo. Checked with the real
liblitert-lm.so 0.18.0 in release and ASan builds:
- A sparse 600 MiB file with header_end at its size raised the
server's peak RSS from ~150 MB to ~700 MB before the fix and leaves
it flat now; the test model (offset 552) still serves streaming and
non-streaming requests; the earlier bad-header cases are unchanged.
- A shim loaded in front of the real library replays the runtime's
sequence over a real generation (a text chunk, the parse error
marked final, the remaining real chunks, optionally a second error
at the end). Before: ASan reports a heap-use-after-free on the freed
queue when the decode thread calls back after the error, in both
stream modes, and the release build hangs as above. After: 0 ASan
reports, the stream closes in ~0.4 s, non-streaming gets the 500,
follow-up requests get 200.
- With the shim's error text quoting "CANCELLED" or the max-tokens
text, the client used to get "stop" or "length" while the turn ran
on (~2.7 s, the next request waiting); it now gets the error and the
runtime's next chunk is "CANCELLED: Task cancelled". The real cancel
text and a real client disconnect still end quietly.
- Concurrent stress mixing injected errors, plain requests and early
disconnects, and the earlier normal, stress, error-path and
use-after-free runs: server up, 0 ASan reports. NDK 27 builds for
arm64-v8a and x86_64 have 0 warnings.
Not run with a real model writing a malformed tool call, nor on a
device; the bionic outcome is reasoned from its sources. The size
still comes from a separate stat() rather than the opened stream, and
an in-range offset over junk FlatBuffer bytes still crashes inside the
runtime, as before.
Older bug, not from this PR. Both backup rule files keep everything under files/.turbollm except models, caches and logs, on purpose (chats and settings are meant to survive a restore). That includes the daemon's config.json, which holds every third-party credential the user entered in plaintext: the Hugging Face and GitHub tokens, the Cloudflare tunnel token, the ngrok authtoken, the Tavily and Kagi keys, MCP server keys and env, and the raw Turbo Link peer tokens that grant access to the user's other TurboLLM machines. All of them go to the user's Google cloud backup and to any device-transfer target; only the API keys the daemon issues are stored hashed. This PR's earlier backup-quota fix makes that cloud upload actually happen for anyone with a downloaded model, whose whole backup used to be skipped. Meanwhile play-store-assets/privacy-notes.md still claimed no backup rules file existed. - privacy-notes.md: the stale open item is rewritten to the real state. The rules exist and keep models, caches, logs and the extracted daemon out; chats and config.json, tokens included, are backed up (end-to-end encrypted with the lock-screen credential on Android 9+ when the phone has one) and transferred. It lists the tokens and keeps the choice open for the team before shipping: disclose it, or move the credentials to a file the rules exclude. - data_extraction_rules.xml: the header comment says config.json and its tokens are kept on purpose for now and points at the notes. backup_rules.xml says the same in brief and points at data_extraction_rules.xml. Comments and docs only; what is backed up does not change. Both XML files parse and compileDebugKotlinAndroid passes. The exposure itself is not fixed; it needs an owner decision. Excluding config.json would drop every setting on restore (engines, model folders and presets, MCP servers, agents, issued API keys, Turbo Link pairings, remote-access setup), not just the tokens. Keeping the credentials in a separate, excluded file needs a daemon change and a config migration, and users would re-enter tokens and re-pair links after a restore.
6743926 to
1b5147e
Compare
With no backend in the model reference or the config, the server always
picked CPU, whatever the model prefers. `litert-lm serve` follows the
model (model.model_default_backend: an Artisan text decoder means gpu,
else the decoder section's first backend_constraint, else cpu), and
the C API lists the same order (litert_lm_loaded_file_modality
_supported_backends: a GPU-only bundle lists gpu alone, one with no
constraint [cpu, gpu]). So a GPU-only bundle (litert-community's
gemma-4-E2B-it-gpu.litertlm has an Artisan decoder constrained to
gpu_artisan) sent without a backend was loaded on CPU and failed
("Main backend constraint mismatch", engine_settings.cc). TurboLLM
writes a backend for every model today, so this matters once its
Backend "Auto" leaves the choice to the model (a TurboLLM change made
alongside), and for any client that sends no backend.
Older bug: origin/main has it (`... : "cpu"` for a request without a
backend).
- engines/litert-lm-server/src/main.cc: with no backend in the model
reference or the config, the engine is created on the first backend
the model's header lists for its text, as the C API lists them (the
decoder section's backend_constraint, cpu then gpu when it has none,
npu after them for a file with an NPU auxiliary model), except that an
Artisan text decoder puts gpu first, as `litert-lm serve` picks it;
read once per version of the file with the rest of ModelFileInfo, CPU
when the file cannot be read; and the server logs a backend other than
CPU ("Using <model>'s own backend: gpu"), as `litert-lm serve` does.
When that backend, if it is not CPU, does not start, the engine is
created on the next backend the model lists, and only on one it lists
(a GPU-only bundle is never tried on CPU, which cannot run it, and
whose reason would hide the GPU's), and the server logs why
("<model>'s own backend, gpu, did not start (<the runtime's reason>);
falling back to cpu"); when none starts, the 500 gives each one's
reason ("on gpu: ...; on cpu: ..."); the engine's key keeps the
model's choice, so later requests use that engine without trying the
GPU again until another engine is loaded; a model that gets a new
engine for each conversation (LFM2) gets it on the backend its last
one ran on. A backend the request or the config sets is used as set
and fails as before. The sampler's top_k limit, and the context window
when no size is asked (no more than 4096 on GPU), follow the backend
the engine runs on.
- BUILDING-ENGINES.md: says so under "What the server is".
Behaviour change: a request without a backend loads a model on the
backend its file prefers: CPU for files without a backend constraint
(LFM2.5-230M, gemma-3-270m-it, Qwen3-0.6B: unchanged), GPU for a
GPU-only bundle or an Artisan decoder, falling back to the next backend
the model lists, CPU for most, when that fails to start.
Tests: in engines/litert-lm-server/model-settings.test.mjs, against the
stand-in library, which now logs the backend of each engine it creates
and cannot create one on gpu for a model named gpu-broken, or on any
backend for one named no-engine, on stand-in files whose decoder
sections have backend constraints: "with no backend set, the model's own
is used, and one that does not start falls back to the next it lists,
logged" (cpu first, gpu first, gpu alone, npu first, an Artisan decoder,
an unreadable header, a GPU that does not start for a model that lists
CPU and for one that does not, no backend that starts, the context
window with no size asked for an 8192-token model that falls back to CPU
(8192) and for one that runs on GPU (4096), and an LFM2 header whose
later conversations go straight to CPU). With main.cc as it was it fails
(cpu for a model that prefers gpu); it fails too with the fallback to
CPU whatever the model lists (a GPU-only bundle tried on CPU), and with
the window worked out for the backend the model lists first instead of
the one that started (4096 for the model that fell back to CPU). No
existing test changed.
Not run on a device: a GPU-only bundle loading on a phone's GPU with no
backend set, and the fallback after a GPU that fails to start there.
OpenAI's `developer` role, its newer name for `system`, reached the
model's template as a role it does not know, so an API client's system
instructions were not followed. On LFM2.5-230M, [developer "Answer in
one word.", user "What is the capital of France?"] got "The capital of
France is Paris." (27 prompt tokens: the word was rendered as text)
where [system ...] gets "Paris" (26); gemma-3-270m-it and Qwen3-0.6B
also answered differently. Nothing in the daemon maps developer to
system either, and the Python `litert-lm serve` passes it through as
this server did.
Older bug: origin/main has it (TranslateMessage passes the role as is).
- engines/litert-lm-server/src/main.cc: TranslateMessage reads the
developer role as system, for the preface and for the message sent,
and the server says so once ("the developer role is read as system,
its older name").
Behaviour change: developer messages are followed as system messages.
Tests: in engines/litert-lm-server/model-settings.test.mjs, against the
stand-in library, which now logs a conversation's preface messages: "a
developer message is a system message"; and, with
LITERT_LM_TEST_LIBRARY and LITERT_LM_TEST_MODELS set, "a real model
follows a developer message as it follows a system one: <model>"
(greedy: the same reply and prompt token count). With main.cc as it
was, both fail: role 'developer', and on LFM2.5-230M_int8 'The capital
of France is Paris.' (27 tokens) for 'Paris' (26), on gemma-3-270m-it
and Qwen3-0.6B other replies too. No existing test changed.
max_completion_tokens 0 or below was passed to the runtime, which then
generated one token, and the server called that "length": on
LFM2.5-230M, {"max_completion_tokens": 0} and -5 both answered "The",
1 completion token, finish_reason "length". OpenAI refuses values below
1. And max_tokens, OpenAI's older name for the same cap, which most
API clients still send, was not read at all: {"max_tokens": 5} got the
whole sentence. (The daemon renames max_tokens for a
LiteRT-LM model; direct clients of this server do not.)
Older bug: origin/main has it (max_completion_tokens only
type-checked; max_tokens never read).
- engines/litert-lm-server/src/main.cc: the reply's cap is
max_completion_tokens, or max_tokens when that is absent or null.
Either must be an integer of at least 1, else a 400 that names the
field and the value ("max_completion_tokens must be an integer of at
least 1, got 0").
Behaviour change: max_tokens caps the reply; a cap below 1 (or not an
integer) is a 400 instead of a one-token reply.
Tests: in engines/litert-lm-server/model-settings.test.mjs, against the
stand-in library: "max_completion_tokens or max_tokens caps the reply,
and below 1 is a 400"; and, with LITERT_LM_TEST_LIBRARY and
LITERT_LM_TEST_MODELS set, "a real model's reply is capped by
max_tokens, and max_completion_tokens 0 is a 400: <model>". With
main.cc as it was, both fail: no cap for max_tokens 5, and on
LFM2.5-230M_int8 a 90-token reply for it. No existing test changed.
A request for several choices or for token log-probabilities got a
200 without them, and nothing said so: on LFM2.5-230M {"n": 2} returned
one choice, and {"logprobs": true, "top_logprobs": 3} returned none.
LiteRT-LM's conversation API, the one this server uses, gives one reply
per message and no log-probabilities (its candidates and token scores
exist only on the lower-level session API), so the server cannot honour
either.
Older bug: origin/main has it (neither field is read).
- engines/litert-lm-server/src/main.cc: n other than 1, logprobs other
than false and top_logprobs other than 0 are a 400 that names the
field and why: "Unsupported parameter: n 2: this server gives one
choice per request", "Unsupported parameter: logprobs true:
LiteRT-LM gives no token log-probabilities". Absent, null, n 1,
logprobs false and top_logprobs 0 are fine, as before.
Behaviour change: such a request gets a 400 instead of a reply that
lacks what it asked for.
Tests: in engines/litert-lm-server/model-settings.test.mjs, against the
stand-in library: "n above 1, logprobs and top_logprobs, which
LiteRT-LM cannot give, are 400s that name the field". With main.cc as
it was it fails (200s). No existing test changed.
A conversation that ends with the assistant's own words, a client prefilling the start of the reply (the daemon's gateway passes those through: openai-guidance.ts and agent-guidance.ts know clients do it), asks for that reply to be continued. LiteRT-LM 0.18.0 can do that only from C++ (OptionalArgs::has_pending_message, runtime/conversation/conversation.h); its C API has no such option, so the server sent the partial reply as a finished turn and the model began a new one, which the client then appended to its prefill: on LFM2.5-230M, [user "What is the capital of France?", assistant "The capital of France is"] got "The capital of France is Paris." (26 prompt tokens against 16 for the question alone: the partial reply was closed as a turn), so the client showed "The capital of France is The capital of France is Paris.". TurboLLM's own chat never ends a request with an assistant message (its retries and title requests end with a user turn). Older bug: origin/main has it (the last message is always sent as a new turn). - engines/litert-lm-server/src/main.cc: a last message from the assistant without tool calls is a 400 "Invalid messages: the last message is the assistant's, and this server cannot continue a reply (LiteRT-LM's C API has no way to); end with a user or tool message". A last assistant message whose tool_calls is null or empty, as clients that write out every optional field send a plain reply, counts as one without tool calls. An assistant turn of tool calls, and assistant turns earlier in the history, are as before. - BUILDING-ENGINES.md: says so under "What the server is". Behaviour change: a request that ends with a partial assistant reply gets a 400 instead of a new reply that duplicates the prefill. Tests: in engines/litert-lm-server/model-settings.test.mjs, against the stand-in library: "a conversation that ends with the assistant's words is a 400 that says the reply cannot be continued" (streamed or not, string or parts, tool_calls absent, null or empty; tool calls and history are unaffected). With main.cc as it was it fails (200s), and it fails too when only a message without a tool_calls key counts (200s for null and empty). No existing test changed.
aafba21 to
424a925
Compare
mohitsoni48
left a comment
There was a problem hiding this comment.
Approving. Sorry for the delay this time. I have moved and settled 1700km from my home town so everything is a mess now. slowly catching up the pace again
Reviewed at head 424a925, including the 28 commits added since e6099a0 (server fixes for assistant-last/n/logprobs/max_tokens/developer role/default backend, sampler and tool_choice work, and the Android service/WebView changes). I found no blocking bug. Backend fallback is a finite list with no engine leak, shared state is under g_engine_mu, the new header parsing is bounds-checked, and the app-secret scheme is intact. This was a static review; I did not run the tests.
Condition: TurboLLM#287 must be merged first. Without /api/v1/app-proof the app stays on the splash screen, so please don't approve the waiting play-alpha run until then.
Non-blocking nits:
main.cc~838/872: after a header-chosen GPU backend falls back to CPU, the cached key still says "gpu", so a later request that explicitly sets,gpusilently gets the CPU engine.main.cc~2596: thegetppid()==1check refuses to start a server legitimately run as PID 1 (e.g. a container entrypoint). Not an issue on Android.- The
FileInfocache is keyed on size and mtime in seconds, and every request stats and reads the header even when the engine is loaded. ParsePenalties/sampler values are checked as doubles but passed asfloat, so a huge finite value becomes inf.- The assistant-last 400 means a client sending a prefill now gets an error instead of a doubled reply. That is intended, but worth confirming nothing in the daemon relies on it.
- Carried over from earlier:
DaemonAuth.kt:88reads the proof until EOF,ParseChunkuses unboundedjson::parse, the WebSocket path bypasses the port block, and the poll makes four loopback connections.
Generated by Claude Code
A model whose own backend is gpu, sent without a backend, gets an engine on cpu when the GPU does not start. That engine was kept under the model's choice, "gpu", so a request that then set gpu itself got it: on the stand-in library's gpu-broken.litertlm (header gpu,cpu; no GPU engine starts), "<path>" loaded on gpu, fell back to cpu, and "<path>,gpu" was answered 200 by that cpu engine, nothing created and nothing said. For an LFM2 header, which gets a new engine for each conversation, "<path>,gpu" silently created a new engine on cpu. A backend the user sets must get that backend or an error. Introduced by fix(litert-lm-server): with no backend set, the model's own is used (93b3f70, in #8); main before #8 had no fallback. - engines/litert-lm-server/src/main.cc: an engine's key holds the backend it runs on, and the engine keeps the model's own backend it was made for when no backend was set. A request that sets a backend matches only an engine on that backend, so gpu after a fallback creates an engine on gpu, failing with the GPU's error as a set backend always has, and cpu takes the engine that runs on cpu. A request that sets none still takes the engine made for the same choice of the model's, on whichever backend it fell back to, without trying the GPU again until another engine is loaded; a model that gets a new engine for each conversation gets it on that backend, as before. An engine on a backend a request set, or one made for another choice of the model's (its file replaced), does not serve a request without a backend, which gets the model's own, as before. - BUILDING-ENGINES.md: says a set backend is honoured also then. Behaviour change: after the model's own backend fell back to CPU, a request that sets gpu gets an engine on gpu or its 500, instead of the CPU engine. Tests: in engines/litert-lm-server/model-settings.test.mjs, against the stand-in library: "a backend the request sets after the model's own fell back gets an engine on it or its error, never the other" (gpu set after the fallback, for gpu-broken and for an LFM2 header; cpu set after it; the model's own and a set backend on gpu-first; a file replaced by one whose own backend is gpu). With main.cc as it was it fails (200 and no engine for gpu set after the fallback; a scratch run showed the LFM2 header creating a cpu engine for it). It fails too with only the key changed (requests without a backend then try the GPU again), and with the reuse keyed by whether the model chose (the replaced file's gpu is not used). No existing test changed.
The server refuses to start when its parent is PID 1, which is how it tells that the process that started it died before its PR_SET_PDEATHSIG was armed. But PID 1 is also a container's entrypoint (tini under `docker run --init`, or a shell), which starts the server as its child on purpose, and such a server was refused: under `unshare --pid --fork --mount-proc`, both `sh -c 'litertlm_server serve ... & wait'` and `tini -- litertlm_server serve ...` printed "the process that started turbollm-litertlm-server has already exited; not starting" and exited 1. (A server that is itself PID 1 has parent 0 and was never refused.) This binary ships only in the APK, so this hits host builds run in a container, not phones. Introduced by fix(litert-lm-server): the server ends when the app does, on its own (db0375c, in #8). - engines/litert-lm-server/src/main.cc: a parent of PID 1 means the starter is gone only when PID 1 is the machine's own init, that of the root PID namespace (/proc/self/ns/pid, inode 4026531836, the kernel's PROC_PID_INIT_INO; as before when /proc cannot be read). In a container's namespace the kernel keeps nothing that tells an orphan its PID 1 adopted from that PID 1's child, so both start there. On Android nothing changes: only the app starts this binary, so a parent of PID 1 is always the app gone, also where Android runs in a container of its own (ChromeOS's ARC++). The engine tether, which knows the daemon's pid, and the daemon's sh lifeline are unchanged. - BUILDING-ENGINES.md: says so. Behaviour change: off Android, a server whose parent is PID 1 of a container's PID namespace starts, including one orphaned there before its prctl. Tests: in engines/litert-lm-server/parent-death.test.mjs, where unshare can make a PID namespace (as root, or with -r): "a server whose parent is PID 1 of its own PID namespace, as a container's entrypoint is, starts" (under sh and, where installed, tini; it also ends with that PID 1). With main.cc as it was it fails ("not starting"). "a server whose parent exited before it started does not start", the orphan adopted by this machine's init, passes before and after.
What the server reads from a model's header (which inputs it takes, on which backends, its sampler, context and thinking) was kept for the file's size and modification time in whole seconds. A file rewritten in place within the same second at the same size kept the old file's answers: a stand-in rewritten.litertlm with a text decoder and an embedder, rewritten with a vision encoder instead (same inode, same size, mtime moved from .25 to .75 of the same second), still got "Invalid messages: rewritten.litertlm takes no image input" for an image. And every request opened the file and read its first 32 bytes, even with nothing changed: strace counted 4 opens of the model for 4 requests. Introduced in #8: the size-and-second cache by fix(litert-lm-server): models that take images or audio get encoders (243572a), the read on every request by fix(litert-lm-server): reject out-of-range .litertlm header offsets (c3f51e5). - engines/litert-lm-server/src/main.cc: FileInfo stats the file first and keeps what it read for the file's device, inode, size and modification time to the nanosecond; a request whose file is unchanged costs that stat alone, and one whose file changed reads the header again (the stat comes before the read, so a change in between is read next time). A header end the runtime cannot load is still a 500 for every request, now from that one read, and still leaves the loaded engine in place. With --verbose the server logs each read ("Reading the header of <path>"). Behaviour change: a model file changed in any way, also within the same second at the same size, is read again; an unchanged one is not. Tests: in engines/litert-lm-server/model-settings.test.mjs, against the stand-in library: "a model file is read once while it is unchanged, and again once it changes, also in the same second at the same size" (an image refused, then taken after the rewrite; one read for four requests, two after the rewrite; a header end past 16 KiB refused twice from one read, no engine created after). With main.cc as it was it fails; with only the log line added to it, 4 reads for 4 requests, and the strace run above shows the image still refused after the rewrite. No existing test changed.
LiteRT-LM takes the sampler's temperature and the repetition penalties as floats. The server checked them as doubles (a number, finite, at least 0 for temperature and 1 for repetition_penalty) and converted them, so a value beyond a float's range passed and reached the runtime as an infinity, with a 200: on the stand-in library, temperature 1e39 reached the sampler as inf, and repetition_penalty 1e39, repeat_penalty 1e39, presence_penalty -1e39 and frequency_penalty 1e39 reached the penalty config as infinities. Older bug for temperature: main before #8 has it (temperature passed as a float, checked only to be a number). The penalties came with fix(litert-lm-server): repetition penalties reach the model (b9be217, in #8). - engines/litert-lm-server/src/main.cc: temperature, top_p, top_k, seed and each penalty that does not convert to a finite float is a 400 that names the field, as the other range errors are: "Invalid sampler parameters: ValueError: temperature must be within a float's range (-3.4028235e+38 to 3.4028235e+38), got 1e+39". The test is the conversion itself, so a value a hair past FLT_MAX that still rounds to it (3.4028235e38) is taken, and one that rounds to infinity (3.4028236e38) is not. top_p, top_k and seed were already bounded by their own checks; min_p is never passed to the runtime. - BUILDING-ENGINES.md: says so. Behaviour change: a temperature or penalty beyond a float's range is a 400 instead of an infinity passed to the runtime. Tests: in engines/litert-lm-server/model-settings.test.mjs, against the stand-in library: "a sampler or penalty value a float cannot hold is a 400 that names it, and one it can is passed as set" (1e39 and -1e39 for each field, streamed or not, and 3.4028236e38; 3e38, -3e38 and 3.4028235e38 reach the sampler and the penalties as set). With main.cc as it was it fails (200 for temperature 1e39). No existing test changed.
Each chunk the runtime streams is JSON that carries the tool calls it parsed from the model's output, whose arguments nest as deep as the model wrote brackets. The server parsed it with json::parse, which keeps its own stack, but then copied the tool calls and later wrote them out, which recurse once per level on the connection thread's stack. On the stand-in library a tool call 200,000 levels deep killed the server with SIGSEGV (the request got a reset connection, and the loaded model went with the process); 10,000 levels still passed on this machine's 8 MB thread stacks, Android's are about 1 MB. A model can be steered into writing that by its prompt. Older bug: main before #8 has it (ParseChunk's json::parse). - engines/litert-lm-server/src/main.cc: ParseChunk parses with ParseBounded, the parser a request's body already goes through, so a chunk nested deeper than 128 levels stops there. The request then fails as on the runtime's own error: the generation is cancelled, and the client gets a 500, or in a stream the response.error event and [DONE], "RuntimeError: the model's output, as the runtime parsed it, is nested deeper than 128 levels". A chunk that is not JSON is still plain text, as before. - engines/litert-lm-server/testdata/fake_litert_lm.cc: the stand-in answers a message "nested:<n>" with a tool call whose arguments nest n arrays. Behaviour change: model output nested deeper than 128 levels fails its request instead of crashing the server (or, below the stack's limit, being passed on). Tests: in engines/litert-lm-server/model-settings.test.mjs, against the stand-in library: "model output nested deeper than the server reads fails its request, streamed or not, and the server stays up" (100 levels still a tool call; 200,000 a 500 and a stream's error event; a chat answered after). With main.cc as it was it fails (fetch failed: the server died with SIGSEGV). No existing test changed.
What the server reads from a model's header (which inputs it takes, on which backends, its sampler, context and thinking) was kept for the file's size and modification time in whole seconds. A file rewritten in place within the same second at the same size kept the old file's answers: a stand-in rewritten.litertlm with a text decoder and an embedder, rewritten with a vision encoder instead (same inode, same size, mtime moved from .25 to .75 of the same second), still got "Invalid messages: rewritten.litertlm takes no image input" for an image. And every request opened the file and read its first 32 bytes, even with nothing changed: strace counted 4 opens of the model for 4 requests. Introduced in #8: the size-and-second cache by fix(litert-lm-server): models that take images or audio get encoders (243572a), the read on every request by fix(litert-lm-server): reject out-of-range .litertlm header offsets (c3f51e5). - engines/litert-lm-server/src/main.cc: FileInfo stats the file first and keeps what it read for the file's device, inode, size, and modification and change times to the nanosecond; a request whose file is unchanged costs that stat alone, and one whose file changed reads the header again (the stat comes before the read, so a change in between is read next time). The change time moves with every write and cannot be set back, so a rewrite that puts the old modification time back (cp -p, touch -r, tar) is read again too. A header end the runtime cannot load is still a 500 for every request, now from that one read, and still leaves the loaded engine in place. With --verbose the server logs each read ("Reading the header of <path>"). Behaviour change: a model file changed in any way, also within the same second at the same size or with its modification time put back, is read again; an unchanged one is not. Tests: in engines/litert-lm-server/model-settings.test.mjs, against the stand-in library: "a model file is read once while it is unchanged, and again once it changes, in the same second at the same size or with its time put back" (an image refused, then taken after the rewrite, then refused after a rewrite that restores the modification time to the nanosecond; one read for four requests, two after the rewrite, three after the restore; a header end past 16 KiB refused twice from one read, no engine created after). With main.cc as it was it fails; with only the log line added to it, 4 reads for 4 requests, and the strace run above shows the image still refused after the rewrite. Without the change time compared, the image is taken after the restore. No existing test changed.
LiteRT-LM takes the sampler's temperature and the repetition penalties as floats. The server checked them as doubles (a number, finite, at least 0 for temperature and 1 for repetition_penalty) and converted them, so a value beyond a float's range passed and reached the runtime as an infinity, with a 200: on the stand-in library, temperature 1e39 reached the sampler as inf, and repetition_penalty 1e39, repeat_penalty 1e39, presence_penalty -1e39 and frequency_penalty 1e39 reached the penalty config as infinities. Older bug for temperature: main before #8 has it (temperature passed as a float, checked only to be a number). The penalties came with fix(litert-lm-server): repetition penalties reach the model (b9be217, in #8). - engines/litert-lm-server/src/main.cc: temperature, top_p, top_k, seed and each penalty that does not convert to a finite float is a 400 that names the field, as the other range errors are: "Invalid sampler parameters: ValueError: temperature must be within a float's range (-3.4028235e+38 to 3.4028235e+38), got 1e+39". The test is the conversion itself, so a value a hair past FLT_MAX that still rounds to it (3.4028235e38) is taken, and one that rounds to infinity (3.4028236e38) is not. top_p, top_k and seed were already bounded by their own checks; min_p is never passed to the runtime. - BUILDING-ENGINES.md: says so. Behaviour change: a temperature or penalty beyond a float's range is a 400 instead of an infinity passed to the runtime. Tests: in engines/litert-lm-server/model-settings.test.mjs, against the stand-in library: "a sampler or penalty value a float cannot hold is a 400 that names it, and one it can is passed as set" (1e39 for each field and -1e39 for the two that may be negative, streamed or not, and 3.4028236e38; 3e38, -3e38 and 3.4028235e38 reach the sampler and the penalties as set). With main.cc as it was it fails (200 for temperature 1e39). No existing test changed.
Each chunk the runtime streams is JSON that carries the tool calls it parsed from the model's output. The server parsed it with json::parse, which keeps its own stack, but then copied the tool calls and later wrote them out, which recurse once per level on the connection thread's stack. A stand-in library's chunk holding a tool call 200,000 levels deep killed the server with SIGSEGV (the request got a reset connection, and the loaded model went with the process); 10,000 levels still passed on this machine's 8 MB thread stacks, Android's are about 1 MB. LiteRT-LM 0.18.0 itself stops a JSON tool call at 128 levels (serde_json, rust/json_parser.rs:55) and parses Python and FC syntax recursively on its own thread (parser_common.cc ConvertJsonValue, the copy in data_utils.cc, the dump in c/conversation.cc) before the server sees it, so the crash was shown only with the stand-in's chunk: what this guards is the server's side. Older bug: main before #8 has it (ParseChunk's json::parse). - engines/litert-lm-server/src/main.cc: ParseChunk parses with ParseBounded, the parser a request's body already goes through, now given a depth: the chunk's envelope (the chunk, its tool_calls, the call, its function) is 4 levels, so it takes kMaxJsonDepth + 4 and a call's arguments may nest as deep as a request's, 128 levels. A JSON chunk nested deeper fails its request as on the runtime's own error: the generation is cancelled, and the client gets a 500, or in a stream the response.error event and [DONE], "RuntimeError: the model's output, as the runtime parsed it, is nested deeper than 132 levels (128 within a tool call's arguments)". A chunk that is not JSON is still plain text, as before, however many brackets it opens (json::accept tells, building nothing). - engines/litert-lm-server/testdata/fake_litert_lm.cc: the stand-in answers a message "nested:<n>" with a tool call whose arguments nest n arrays, and "nested-text:<n>" with a chunk of n brackets and text. Behaviour change: a runtime chunk nested deeper than 132 levels fails its request instead of crashing the server (or, below the stack's limit, being passed on). Tool calls the runtime takes are passed on as before: with LiteRT-LM 0.18.0, Qwen3-0.6B and a regex response_format forcing arguments 125 and 126 levels deep, 200s before and after (127 and more the runtime refuses itself). Tests: in engines/litert-lm-server/model-settings.test.mjs, against the stand-in library: "model output nested deeper than the server reads fails its request, streamed or not, and the server stays up" (100, 125 and 127 arrays still a tool call; 128 and 200,000 a 500 and a stream's error event; a non-JSON chunk of 200 brackets still text; a chat answered after). With main.cc as it was it fails (fetch failed: the server died with SIGSEGV); it fails too with the bound counting the envelope (125 a 500), and without the accept() check (the non-JSON chunk a 500). No existing test changed.
The engine service polls the daemon every 3 s: GET /api/v1/downloads and GET /api/v1/activity, and GET /api/v1/status while nothing runs, each request on a connection of its own after a proof of the daemon on another (DaemonAuth): 4 loopback connections a tick, 6 while idle, for as long as the app runs. The proof has to stay per request, so only fewer requests save connections, and the activity route carried nothing of the other two. TurboLLM's fix(api): one activity request carries what the Android poll reads adds them to it: `downloads`, the running and queued rows of GET /api/v1/downloads, and `status`, the fields of GET /api/v1/status the app reads, in the same names. Introduced in #8: main before it polled the activity alone, on a pooled connection with no proof; the downloads by fix(android): the download notification shows its progress and speed, the status by fix(android): the notification says what is running, and the proof per request by fix(android): app proof and secret each go on a new socket, never pooled. - composeApp/src/androidMain/kotlin/com/turbollm/app/EngineService.kt: each poll reads the activity first, and takes the downloads and the status from that reply when it carries them as those routes answer (ActivityReply), asking a route only for what the reply lacks: one request, two connections a tick, with a daemon that has them; with an older one, the same requests as before. A close's check (anythingRuns) reads them from its one activity reply the same way. The notification, the wake lock and what a close decides are as before. - composeApp/src/androidMain/kotlin/com/turbollm/app/DownloadNotice.kt: the running and queued rows are read from a reply's JSON object (liveDownloads), so the activity reply's list goes through the same filter as GET /api/v1/downloads's. - PLAY-FGS-DECLARATION.md, play-store-assets/privacy-notes.md: the `serving` field, and the requests, can come from the activity reply. Behaviour change: with a daemon that has TurboLLM's fix(api): one activity request carries what the Android poll reads, the poll makes one request a tick instead of two or three. Tests: there is no Kotlin test harness in this repo, so none is added. Checked on the JVM with the real EngineService.kt and DownloadNotice.kt against the scripted daemon of fix(android): leaving the app no longer unloads the model, as later commits extended it, its activity answer built as that TurboLLM commit builds it when asked (its downloads and status from the scripted routes, each left out when its route answers no JSON). With it, every poll is one request, two connections: idle with a model loaded or with none, a reply, an approval wait, a download running, queued or paused; before the change, two requests (four connections) and three while idle. The status unreadable, only it is asked of its route (four connections), both unreadable, both (six); an activity reply whose fields are not those routes' answers asks the routes. The shade and the wake lock read the same in each case. All 150 scenarios of the harness, notifications off and on (300 runs), give the same verdicts and shade with an older daemon as before; with the new one, too, but for three whose events were timed to land inside a close's three-request second reading: with one request a reading, the close is decided 4.5 s sooner, before they come, and timed inside its one-request second reading they keep the app in the foreground as before (a reopen, a swiped card then a reopen, a load starting). compileDebugKotlinAndroid and mergeDebugJniLibFolders pass. Not run on a device.
A model whose own backend is gpu, sent without a backend, gets an engine on cpu when the GPU does not start. That engine was kept under the model's choice, "gpu", so a request that then set gpu itself got it: on the stand-in library's gpu-broken.litertlm (header gpu,cpu; no GPU engine starts), "<path>" loaded on gpu, fell back to cpu, and "<path>,gpu" was answered 200 by that cpu engine, nothing created and nothing said. For an LFM2 header, which gets a new engine for each conversation, "<path>,gpu" silently created a new engine on cpu. A backend the user sets must get that backend or an error. Introduced by fix(litert-lm-server): with no backend set, the model's own is used (93b3f70, in #8); main before #8 had no fallback. - engines/litert-lm-server/src/main.cc: an engine's key holds the backend it runs on, and the engine keeps the model's own backend it was made for when no backend was set. A request that sets a backend matches only an engine on that backend, so gpu after a fallback creates an engine on gpu, failing with the GPU's error as a set backend always has, and cpu takes the engine that runs on cpu. A request that sets none still takes the engine made for the same choice of the model's, on whichever backend it fell back to, without trying the GPU again until another engine is loaded; a model that gets a new engine for each conversation gets it on that backend, as before. An engine on a backend a request set, or one made for another choice of the model's (its file replaced), does not serve a request without a backend, which gets the model's own, as before. - BUILDING-ENGINES.md: says a set backend is honoured also then. Behaviour change: after the model's own backend fell back to CPU, a request that sets gpu gets an engine on gpu or its 500, instead of the CPU engine. Tests: in engines/litert-lm-server/model-settings.test.mjs, against the stand-in library: "a backend the request sets after the model's own fell back gets an engine on it or its error, never the other" (gpu set after the fallback, for gpu-broken and for an LFM2 header; cpu set after it; the model's own and a set backend on gpu-first; a file replaced by one whose own backend is gpu). With main.cc as it was it fails (200 and no engine for gpu set after the fallback; a scratch run showed the LFM2 header creating a cpu engine for it). It fails too with only the key changed (requests without a backend then try the GPU again), and with the reuse keyed by whether the model chose (the replaced file's gpu is not used). No existing test changed.
The server refuses to start when its parent is PID 1, which is how it tells that the process that started it died before its PR_SET_PDEATHSIG was armed. But PID 1 is also a container's entrypoint (tini under `docker run --init`, or a shell), which starts the server as its child on purpose, and such a server was refused: under `unshare --pid --fork --mount-proc`, both `sh -c 'litertlm_server serve ... & wait'` and `tini -- litertlm_server serve ...` printed "the process that started turbollm-litertlm-server has already exited; not starting" and exited 1. (A server that is itself PID 1 has parent 0 and was never refused.) This binary ships only in the APK, so this hits host builds run in a container, not phones. Introduced by fix(litert-lm-server): the server ends when the app does, on its own (db0375c, in #8). - engines/litert-lm-server/src/main.cc: a parent of PID 1 means the starter is gone only when PID 1 is the machine's own init, that of the root PID namespace (/proc/self/ns/pid, inode 4026531836, the kernel's PROC_PID_INIT_INO; as before when /proc cannot be read). In a container's namespace the kernel keeps nothing that tells an orphan its PID 1 adopted from that PID 1's child, so both start there. On Android nothing changes: only the app starts this binary, so a parent of PID 1 is always the app gone, also where Android runs in a container of its own (ChromeOS's ARC++). The engine tether, which knows the daemon's pid, and the daemon's sh lifeline are unchanged. - BUILDING-ENGINES.md: says so. Behaviour change: off Android, a server whose parent is PID 1 of a container's PID namespace starts, including one orphaned there before its prctl. Tests: in engines/litert-lm-server/parent-death.test.mjs, where unshare can make a PID namespace (as root, or with -r): "a server whose parent is PID 1 of its own PID namespace, as a container's entrypoint is, starts" (under sh and, where installed, tini; it also ends with that PID 1). With main.cc as it was it fails ("not starting"). "a server whose parent exited before it started does not start", the orphan adopted by this machine's init, passes before and after.
What the server reads from a model's header (which inputs it takes, on which backends, its sampler, context and thinking) was kept for the file's size and modification time in whole seconds. A file rewritten in place within the same second at the same size kept the old file's answers: a stand-in rewritten.litertlm with a text decoder and an embedder, rewritten with a vision encoder instead (same inode, same size, mtime moved from .25 to .75 of the same second), still got "Invalid messages: rewritten.litertlm takes no image input" for an image. And every request opened the file and read its first 32 bytes, even with nothing changed: strace counted 4 opens of the model for 4 requests. Introduced in #8: the size-and-second cache by fix(litert-lm-server): models that take images or audio get encoders (243572a), the read on every request by fix(litert-lm-server): reject out-of-range .litertlm header offsets (c3f51e5). - engines/litert-lm-server/src/main.cc: FileInfo stats the file first and keeps what it read for the file's device, inode, size, and modification and change times to the nanosecond; a request whose file is unchanged costs that stat alone, and one whose file changed reads the header again (the stat comes before the read, so a change in between is read next time). The change time moves with every write and cannot be set back, so a rewrite that puts the old modification time back (cp -p, touch -r, tar) is read again too. A header end the runtime cannot load is still a 500 for every request, now from that one read, and still leaves the loaded engine in place. With --verbose the server logs each read ("Reading the header of <path>"). Behaviour change: a model file changed in any way, also within the same second at the same size or with its modification time put back, is read again; an unchanged one is not. Tests: in engines/litert-lm-server/model-settings.test.mjs, against the stand-in library: "a model file is read once while it is unchanged, and again once it changes, in the same second at the same size or with its time put back" (an image refused, then taken after the rewrite, then refused after a rewrite that restores the modification time to the nanosecond; one read for four requests, two after the rewrite, three after the restore; a header end past 16 KiB refused twice from one read, no engine created after). With main.cc as it was it fails; with only the log line added to it, 4 reads for 4 requests, and the strace run above shows the image still refused after the rewrite. Without the change time compared, the image is taken after the restore. No existing test changed.
LiteRT-LM takes the sampler's temperature and the repetition penalties as floats. The server checked them as doubles (a number, finite, at least 0 for temperature and 1 for repetition_penalty) and converted them, so a value beyond a float's range passed and reached the runtime as an infinity, with a 200: on the stand-in library, temperature 1e39 reached the sampler as inf, and repetition_penalty 1e39, repeat_penalty 1e39, presence_penalty -1e39 and frequency_penalty 1e39 reached the penalty config as infinities. Older bug for temperature: main before #8 has it (temperature passed as a float, checked only to be a number). The penalties came with fix(litert-lm-server): repetition penalties reach the model (b9be217, in #8). - engines/litert-lm-server/src/main.cc: temperature, top_p, top_k, seed and each penalty that does not convert to a finite float is a 400 that names the field, as the other range errors are: "Invalid sampler parameters: ValueError: temperature must be within a float's range (-3.4028235e+38 to 3.4028235e+38), got 1e+39". The test is the conversion itself, so a value a hair past FLT_MAX that still rounds to it (3.4028235e38) is taken, and one that rounds to infinity (3.4028236e38) is not. top_p, top_k and seed were already bounded by their own checks; min_p is never passed to the runtime. - BUILDING-ENGINES.md: says so. Behaviour change: a temperature or penalty beyond a float's range is a 400 instead of an infinity passed to the runtime. Tests: in engines/litert-lm-server/model-settings.test.mjs, against the stand-in library: "a sampler or penalty value a float cannot hold is a 400 that names it, and one it can is passed as set" (1e39 for each field and -1e39 for the two that may be negative, streamed or not, and 3.4028236e38; 3e38, -3e38 and 3.4028235e38 reach the sampler and the penalties as set). With main.cc as it was it fails (200 for temperature 1e39). No existing test changed.
Each chunk the runtime streams is JSON that carries the tool calls it parsed from the model's output. The server parsed it with json::parse, which keeps its own stack, but then copied the tool calls and later wrote them out, which recurse once per level on the connection thread's stack. A stand-in library's chunk holding a tool call 200,000 levels deep killed the server with SIGSEGV (the request got a reset connection, and the loaded model went with the process); 10,000 levels still passed on this machine's 8 MB thread stacks, Android's are about 1 MB. LiteRT-LM 0.18.0 itself stops a JSON tool call at 128 levels (serde_json, rust/json_parser.rs:55) and parses Python and FC syntax recursively on its own thread (parser_common.cc ConvertJsonValue, the copy in data_utils.cc, the dump in c/conversation.cc) before the server sees it, so the crash was shown only with the stand-in's chunk: what this guards is the server's side. Older bug: main before #8 has it (ParseChunk's json::parse). - engines/litert-lm-server/src/main.cc: ParseChunk parses with ParseBounded, the parser a request's body already goes through, now given a depth: the chunk's envelope (the chunk, its tool_calls, the call, its function) is 4 levels, so it takes kMaxJsonDepth + 4 and a call's arguments may nest as deep as a request's, 128 levels. A JSON chunk nested deeper fails its request as on the runtime's own error: the generation is cancelled, and the client gets a 500, or in a stream the response.error event and [DONE], "RuntimeError: the model's output, as the runtime parsed it, is nested deeper than 132 levels (128 within a tool call's arguments)". A chunk that is not JSON is still plain text, as before, however many brackets it opens (json::accept tells, building nothing). - engines/litert-lm-server/testdata/fake_litert_lm.cc: the stand-in answers a message "nested:<n>" with a tool call whose arguments nest n arrays, and "nested-text:<n>" with a chunk of n brackets and text. Behaviour change: a runtime chunk nested deeper than 132 levels fails its request instead of crashing the server (or, below the stack's limit, being passed on). Tool calls the runtime takes are passed on as before: with LiteRT-LM 0.18.0, Qwen3-0.6B and a regex response_format forcing arguments 125 and 126 levels deep, 200s before and after (127 and more the runtime refuses itself). Tests: in engines/litert-lm-server/model-settings.test.mjs, against the stand-in library: "model output nested deeper than the server reads fails its request, streamed or not, and the server stays up" (100, 125 and 127 arrays still a tool call; 128 and 200,000 a 500 and a stream's error event; a non-JSON chunk of 200 brackets still text; a chat answered after). With main.cc as it was it fails (fetch failed: the server died with SIGSEGV); it fails too with the bound counting the envelope (125 a 500), and without the accept() check (the non-JSON chunk a 500). No existing test changed.
The engine service polls the daemon every 3 s: GET /api/v1/downloads and GET /api/v1/activity, and GET /api/v1/status while nothing runs, each request on a connection of its own after a proof of the daemon on another (DaemonAuth): 4 loopback connections a tick, 6 while idle, for as long as the app runs. The proof has to stay per request, so only fewer requests save connections, and the activity route carried nothing of the other two. TurboLLM's fix(api): one activity request carries what the Android poll reads adds them to it: `downloads`, the running and queued rows of GET /api/v1/downloads, and `status`, the fields of GET /api/v1/status the app reads, in the same names. Introduced in #8: main before it polled the activity alone, on a pooled connection with no proof; the downloads by fix(android): the download notification shows its progress and speed, the status by fix(android): the notification says what is running, and the proof per request by fix(android): app proof and secret each go on a new socket, never pooled. - composeApp/src/androidMain/kotlin/com/turbollm/app/EngineService.kt: each poll reads the activity first, and takes the downloads and the status from that reply when it carries them as those routes answer (ActivityReply), asking a route only for what the reply lacks: one request, two connections a tick, with a daemon that has them; with an older one, the same requests as before. A close's check (anythingRuns) reads them from its one activity reply the same way. The notification, the wake lock and what a close decides are as before. - composeApp/src/androidMain/kotlin/com/turbollm/app/DownloadNotice.kt: the running and queued rows are read from a reply's JSON object (liveDownloads), so the activity reply's list goes through the same filter as GET /api/v1/downloads's. - PLAY-FGS-DECLARATION.md, play-store-assets/privacy-notes.md: the `serving` field, and the requests, can come from the activity reply. Behaviour change: with a daemon that has TurboLLM's fix(api): one activity request carries what the Android poll reads, the poll makes one request a tick instead of two or three. Tests: there is no Kotlin test harness in this repo, so none is added. Checked on the JVM with the real EngineService.kt and DownloadNotice.kt against the scripted daemon of fix(android): leaving the app no longer unloads the model, as later commits extended it, its activity answer built as that TurboLLM commit builds it when asked (its downloads and status from the scripted routes, each left out when its route answers no JSON). With it, every poll is one request, two connections: idle with a model loaded or with none, a reply, an approval wait, a download running, queued or paused; before the change, two requests (four connections) and three while idle. The status unreadable, only it is asked of its route (four connections), both unreadable, both (six); an activity reply whose fields are not those routes' answers asks the routes. The shade and the wake lock read the same in each case. All 150 scenarios of the harness, notifications off and on (300 runs), give the same verdicts and shade with an older daemon as before; with the new one, too, but for three whose events were timed to land inside a close's three-request second reading: with one request a reading, the close is decided 4.5 s sooner, before they come, and timed inside its one-request second reading they keep the app in the foreground as before (a reopen, a swiped card then a reopen, a load starting). compileDebugKotlinAndroid and mergeDebugJniLibFolders pass. Not run on a device.
Follow-ups to #8: backend fallback, container start, model file cache, float range, chunk depth, poll
Summary
Fixes to the Android app and the native LiteRT-LM server, in 80 focused commits, with no new features and no refactors without a bug behind them. Commits 16, 17, 25 and 45 close gaps around this PR's app secret (45 one 17 left); 18 and 28 correct the docs of 13 and 15, 37 those of 13 and 18, and 38 the build table for 6; 33 completes a TurboLLM#287 fix, 49 updates the FGS video script for another and 52 for a third; 35 closes a gap in 31, 36 one in 11 and 53 one in 5 (the download notification); and 19 to 24, 26, 27, 29 to 32, 34, 39 to 44, 46 to 48, 50, 51 and 54 to 80 fix older bugs (59 also removes a "Generating" flash from 5, and 60 and 61 apply the owner's close rule to 23's and 55's stop and 59's notification; in 64 the WebView laid the page out at height 0, leaving the model picker an empty strip, in 65 an APK could carry an older web UI than its checkout, and 75 also closes 21's gap for a bundle whose own context is smaller than asked). 66 to 80 make the native LiteRT-LM server follow each model (its data processor, sampler parameters, modalities, backend, context and thinking) and honour what a request asks or refuse it with a 400 naming the field (min_p, which LiteRT-LM lacks, is answered and named in
X-Unapplied-Parameters). Each commit message explains the problem, the fix and how it was verified.fix(litert-lm-server): stream use-after-free; crash on non-UTF-8 errorsSendErrorthrow in a catch handler, callingstd::terminate.fix(litert-lm-server): reject out-of-range .litertlm header offsetsfix(android): keep models, caches, logs and the daemon out of backupsfix(android): per-launch app secret so other apps can't use the daemonfix(android): count downloads as busy; re-post a swiped notificationfix(android): stage the daemon's real versionfix(play-listing): parse listing-copy.md with CRLF line endingsfix(android): send the app secret only to a daemon that proves itselfGET /api/v1/app-proof) first.fix(android): load localhost:7000 links from 127.0.0.1 with the cookiehttp://localhost:7000link stayed in the app without the cookie (scoped to 127.0.0.1), so the page asked for an API key; it now loads from 127.0.0.1.fix(android): app proof and secret each go on a new socket, never pooledConnection: close, each on a new, never-pooled socket.fix(android): Play FGS declaration covers downloads and swipe re-postfix(android): correct stale EngineService commentsfix(android): release check no longer ends on a 401 from /api/v1/enginescurl /api/v1/enginesover an adb forward, now a 401 (adb counts as another app). It now checks/healthzand the daemon log's "registered bundled engine" lines and says how to query the API with a key. Docs only.fix(litert-lm-server): bad tool call use-after-free; 16 KiB header cap<tool_call>(Qwen-family bundles, requests withtools) made the runtime report a parse error and decode on while the server freed the stream queue: every later chat request hung on glibc; on Android, per bionic's sources (not seen on a device), the engine aborts. Header cap: an offset between 16 KiB and the file size could never load yet made the runtime allocate up to the whole file; it now gets "invalid .litertlm header".fix(android): document that backups carry plaintext third-party tokensconfig.jsonon purpose, with the Hugging Face, GitHub, Cloudflare, ngrok, Tavily/Kagi, MCP and Turbo Link peer tokens in plaintext, while privacy-notes.md said no backup rules existed. The notes and rule files now say what is backed up and leave the choice to the team (see Known). Comments and docs only.fix(android): app proof and secret never go through a system proxyDaemonAuthconnections followed the system proxy, and Android exempts 127.0.0.1 only with a non-empty bypass list; a Wi-Fi proxy's starts empty, as does a VPN app'ssetHttpProxyone. A proxy served by an app on the phone (an accepted VPN app, or a Wi-Fi or device-owner proxy pointed at a local proxy app) could relay the nonce to the daemon, which proves itself, and read the secret on the next request. Both now useProxy.NO_PROXY.fix(android): another app's proof answer can't stall or crash the appExceptionwas caught, so a multi-MB body or a few hundred KB of[crashed the app. The secret was never at risk this way.fix(android): say the engine log line comes only on a clean first launchfix(litert-lm-server): error replies no longer race the decode threadfix(litert-lm-server): stalled stream reader no longer hangs the engine/v1/modelsreplies were written with a blockingsend()under the engine lock, with only a receive timeout, so a client that asked for a stream and never read blocked the engine while connected: every later chat and/v1/modelsrequest hung while/healthsaid ok, the realistic trigger being another app on the engine port (see Known).fix(litert-lm-server): context-window cut-off reports length, not stopmax_num_tokens.fix(litert-lm-server): disconnect cancel is retried until the turn endsfix(android): a notification dropped before a grant is posted againfix(android): mailto, xmpp and irc links no longer replace the UIturbollm-appwent to the WebView, which cannot loadmailto:,xmpp:orirc:and put an ERR_UNKNOWN_URL_SCHEME page in place of the UI. Replies link bare e-mail addresses asmailto:: a tap composed nothing, and Back reloaded the UI, dropping a typed message.mailto:,xmpp:,irc:,ircs:andtel:now open the matching app; other non-web schemes (intent:,market:,file:,content:, deep links) do nothing.fix(android): WebView remote debugging only in debuggable buildshttp://127.0.0.1:7000origin with the app's cookie: the whole keyless API,POST /api/v1/keysincluded, with nothing on screen, though BUILDING-ENGINES.md counts adb as another app needing a key. Other apps never got in. Debugging is now on only in debuggable builds.fix(android): links to other apps carry CATEGORY_BROWSABLEACTION_VIEW, so one tap on a link in a reply or model card could start an activity its app opens only to other apps' code, not to web links.fix(android): https links to the daemon's port load over http in apphttps://127.0.0.1:7000/…orhttps://localhost:7000/…was loaded as is on a plain-http port, so the TLS handshake failed and an error page replaced the UI; Back, the only way out, reloaded it.docs: privacy notes list the WebView data and prefs that are backed upconfig.jsonas backed up. Auto Backup also takesapp_webview/(the WebView's cookies and the web UI's localStorage, where an API key pasted into the key prompt sits in plaintext),shared_prefs/and the external files folder, which a Data safety form filled in from the notes would miss.fix(litert-lm-server): streamed parallel tool calls get their own index/v1/messagesthat was onetool_useblock with the non-JSON input{"city":"c0"}{"city":"c1"}. Non-streaming replies were right.fix(litert-lm-server): web pages can no longer drive the engine portfix(litert-lm-server): deeply nested JSON no longer kills the enginedump()recurse once per JSON level on the connection thread's stack (~1 MB on Android), so thousands of nested brackets in a message (20 KB was enough) or in a tool call's arguments string killed the server with SIGSEGV, unloading the model. Any engine-port client could send one, as could a daemon/v1client in an arguments string. Such a body now gets 400.fix(litert-lm-server): running out of threads no longer kills the enginestd::terminateaborted the server, unloading the model; another app only had to open enough idle connections to the engine port.fix(litert-lm-server): no "usage": null on chunks before the usage oneinclude_usageon for its stats and, since TurboLLM#287, takes the usage chunk out for a client that never asked; every LiteRT-LM chunk before it still carried"usage": null, so that client's stream still differed from what it asked for.fix(android): keep the app's external files folder out of backupsAndroid/data/com.turbollm/files/, the external files folder, and the rules excluded only paths underfiles/. A model folder there (added with no prompt, or set as the download folder) put its multi-GB models over the 25 MB cloud quota, so Android skipped the whole cloud backup, chats andconfig.jsonincluded; a device transfer copied the models too.fix(litert-lm-server): parsing many objects in a body is linear againjson::parsecallback made nlohmann rescan the enclosing array or object at each closing object, O(N²) for N objects in one: an extra field of 50,000 empty objects (575 KB) in a chat request kept a core busy 16 s, on the connection thread before the engine lock and the disconnect watch, so a client hanging up stopped nothing. The daemon passes unknown fields on, so any/v1chat client could send one, as could any app on the phone, a few in parallel taking the cores inference uses.fix(android): FGS declaration says why downloads keep specialUsedocs: a restore at install also leaves out the engine log lineadb installincluded, made by an APK signed with the same key, and the restoredconfig.jsonalready lists the engines, so the grep finds nothing. BUILDING-ENGINES.md now says so and how to avoid it.docs: name the staged daemon/package.json and its version fielddist/package.json={"type":"module"}; stage-daemon-assets.mjs writesdaemon/package.json, next todist/, with the version from commit 6, which the daemon reads (0.1.1 if missing). The row and the table's intro now say so.docs: privacy notes list the app's real permissions and engine sourcedocs: privacy notes say when Auto Backup runs while the service is upandroid:backupInForegroundis not set.docs: privacy notes say where model folders can be outside the appfix(build): shell scripts check out with LF on Windows too\r: build-static-engine.sh failed with "set: pipefail: invalid option name", the other two scripts did not parse, and gradlew failed the same way from WSL, Linux or macOS.*.shandgradleware noweol=lf; BUILDING-ENGINES.md gives the one-time step for an existing checkout, and RELEASING.md points to it.docs: Play listing says Android's backup can copy chats off the phoneconfig.jsonon purpose, and this PR makes that backup fit the quota for people with models. The listing now says so.docs: Play listing and privacy notes name the app's network requestsfix(android): the app proof caps the answer's headers, not its body onlyProxy.NO_PROXY, never pooled) reading at most 4 KiB in all.fix(litert-lm-server): "stream": null is false, not a 500"stream": null, which clients that write every optional field send and OpenAI reads as false, got a 500 type_error from every LiteRT-LM model, also through the daemon's gateway, as didstream_options.include_usage: null. Both are now false when absent or null; any other non-boolean gets a 400.fix(android): a quick reopen after Back no longer unloads the modelfix(android): drop the unused battery-optimization exemption permissiondocs: FGS video script loads a model the default engine runs.litertlm. With TurboLLM#287 a model loads only on the selected engine, on a fresh install the bundled llama.cpp, so the Models screen hides the.litertlm, a load of it fails, and step 2 could not be recorded. Step 1 now loads a small GGUF (Qwen2.5-0.5B-Instruct, Q4_K_M) and says to select LiteRT-LM in Engines first to record with a.litertlm.fix(android): the import sweep leaves a daemon download's .part alone<name>.part, and the sweep at app start and before each import deleted every top-level*.partinfiles/models, also a daemon model folder: as the primary download folder it gets a non-Hugging Face Import from URL's<name>.part, which a reopen after Back or a picker import then deleted, failing a running download at its rename (ENOENT) and restarting a paused one from byte 0. The import now writes<name>.import-part, the only suffix the sweep deletes.docs: privacy notes name the Kotlin code that talks to the daemonMainActivity.kt:69for the daemon's--no-telemetryargs, about 50 lines off, and saidDaemonClient.kthitshttp://127.0.0.1:7000; it calls 127.0.0.1:6996, and only the never-shown commonMainApp()screen constructs it. They now cite the right code.docs: FGS video script selects LiteRT-LM by the name Engines showslitert-lm-android. With TurboLLM#287's "fix(web): the Android app's LiteRT-LM card can select it, and says so" it reads "LiteRT-LM", and the card under "Built into the app" has a Use button. Step 1 now says to tap Use there or pick "LiteRT-LM" in the selector.fix(android): the download notification shows its progress and speedfix(litert-lm-server): liblitert-lm.so loads without libandroid.so.litertlmever loaded: the engine exited with "dlopen failed: library "libstagefright.so" not found". Google's liblitert-lm.so needs libandroid.so, only for 8 APK-asset functions, and a process the daemon executes must resolve its whole framework tree itself. Staging now points that DT_NEEDED entry at liblrtandr.so, a shim that aborts if called (see Known), the patched file pinned per ABI.fix(android): leaving the app no longer unloads the modelGET /api/v1/status,poolModelsincluded) or work runs, then has the daemon write its chat database (POST /api/v1/daemon/flush, at most 2 s) and stops (until 60). Only the user or the daemon's idle timer unloads a model.fix(android): Quit writes the chat database to disk before the app exitsfix(android): the WebView is destroyed when its screen goes awayWebView.destroy(): after Back the page and its renderer kept their memory beside the loaded model, with the page's requests to the daemon open, until garbage collection, and a reopen made a second WebView.onReleasenow stops loading and destroys it.fix(android): a crashed WebView renderer no longer closes the apponRenderProcessGone, a renderer that crashed or was killed for memory took the app's process with it: the daemon, the loaded model, a reply in progress and any download. The app now lives on and builds a fresh WebView with the same cookie setup; after a third crash within a minute it shows "The page keeps crashing." and Reload instead of looping.fix(android): the notification says what is runningfix(android): closing the app with nothing running quits it fullyfix(android): the notification says Idle when nothing runsfix(android): an engine dies with the app process, by the kernellibtether_exec.so, which arms PR_SET_PDEATHSIG(SIGKILL) and execs the engine; TurboLLM#287's daemon starts engines and probes through it.fix(litert-lm-server): the server ends when the app does, on its ownfix(android): the WebView lays the UI out at its real heightfix(android): an APK never carries a web UI older than its checkoutweb/changed.fix(litert-lm-server): chats with LFM2 and gemma-3-270m no longer faillitert-lm servedoes, and their data processors refuse it, so on the owner's phone LFM2.5-230M's first request, the daemon's warm-up, got a 500 "Constrained decoding is not supported for Lfm2DataProcessor" and the model never finished loading.response_format: one the model can't follow, or whose regex or schema llguidance rejects, got a 500, not a 400.fix(litert-lm-server): LFM2 answers each chat from a fresh statefix(litert-lm-server): models that take images or audio get encodersfix(litert-lm-server): sampling starts from the model's, with a seedfix(litert-lm-server): top_k 0 is no limit; bad sampler values are 400sfix(litert-lm-server): thinking follows what the request asksreasoning_effortwas read, sochat_template_kwargs(whoseenable_thinkingthe daemon sends) andthinking_budget_tokenswere dropped: Qwen3-0.6B thought for 190 tokens with thinking off or a budget of 0 or 16, and a request to think sent to a model that can't was dropped without a word.fix(litert-lm-server): a request's stop strings end the replystopwas read nowhere, so a thread's stop strings (the daemon sends them asstop) and API clients' had no effect.fix(litert-lm-server): repetition penalties reach the modelpresence_penalty,frequency_penalty,repetition_penaltyandrepeat_penalty(the daemon's name for a thread's or saved Repeat penalty) were all dropped, as was min_p, silently, so no penalty changed a reply.fix(litert-lm-server): tool_choice "none" withholds the toolstool_choicewas ignored:"none", which the daemon sends after a repeated tool call or an empty answer, still offered the tools, and LFM2.5-230M and Qwen3-0.6B called them;"required"and a named function went unenforced, unsaid.fix(litert-lm-server): a context larger than the model takes is saidfix(litert-lm-server): with no backend set, the model's own is usedfix(litert-lm-server): a developer message is a system messagedeveloperrole reached the template as a role it doesn't know, so an API client's system instructions weren't followed (LFM2.5-230M answered "Answer in one word." with a sentence).fix(litert-lm-server): max_tokens is read, and a cap below 1 is a 400max_tokens, which most API clients still send, was not read, and amax_completion_tokensof 0 or below generated one token, finish "length".fix(litert-lm-server): n, logprobs and top_logprobs are refused"n": 2).fix(litert-lm-server): a reply the server cannot continue is a 400Behaviour changes
localhoston a port other than the daemon's opens in the system browser, not the app, and one to another127.0.0.1port no longer opens at all: cookies are scoped by host, not port, so anything on another local port could otherwise get the app secret.publishReleaseListinguploads (the Play-alpha job does not), and 39 to 41, 44, 48, 51, 55, 56, 59 and 60 the privacy notes the Data safety form and privacy policy are filled in from.From commits 8 to 10:
/api/v1/status).From commit 14:
response.errorand[DONE], a non-streaming client gets a 500 with the runtime's message, and the next request is served.From commits 16, 17 and 23 to 25:
chrome://inspectdoes not list them); a QA pass over CDP needs a debug build, andassembleDebug -PturbollmApplicationIdSuffix=.testinstalls one beside the Play app.From commits 20 to 22:
From commits 26 and 34:
CATEGORY_BROWSABLEno longer gets it, and with no other handler the tap does nothing; https links an app has claimed still open that app.Android/data/com.turbollm/files/is now backed up or carried by a device transfer. Models there are copied or downloaded again after a restore, like those underfiles/; with such a folder, a cloud backup that runs now fits the quota and keeps chats and settings.From commits 29 to 33 and 35:
--host 0.0.0.0server must use its IP), and to a POST from a web page (Origin set) that is notapplication/json, unless--cors-originallows its origin. For a browser client of a daemon without TurboLLM#287, see below.{}, in a request and in a tool call the model returns as a string. Parsing takes as long as with the plainjson::parsebefore, also with many objects in one array or object (35).include_usage, the chunks before the usage one have nousagekey, which OpenAI's SDKs read as null.From commits 45 and 50:
HTTP/1.1 200). Nothing changes with our daemon, which closes the socket (45)..partleft by an import cut off before this update stays (50): at most one file, and no clean-up is added for it.From commits 53 to 59:
liblr*) from its output; a LiteRT-LM bump updates the two patched hashes, which the build prints (54).From commits 60 to 63:
/api/v1/activitykeeps the last work or approval line for up to three polls (about 9 s), where it read idle at once (61).libtether_exec.so(5.7 KB arm64, 5.5 KB x86_64) in every build; assemble*/bundle* fail if it is missing for a shipped ABI, and the build compiles one more file per ABI with the NDK. Engines and probes under it end with the daemon's thread (62). Started by hand, the LiteRT-LM server ends with the shell that started it (63).From commits 64 and 65:
web/changed since staging last built it; otherwise checking costs a hash, under 50 ms. If it must build, a stale or missingweb/node_modulesstops it withnpm --prefix "<turbollm>/web" ci.build-inputs.jsonis in the assets and at/build-inputs.json; pr-alpha.yml leaves the web build to staging (65).From commits 66 to 80:
vision_backendoraudio_backend, now read, else its file's first (68).reasoning_effort,chat_template_kwargs'enable_thinkingandreasoning_effort, thenthinking_budget_tokens(0 off, n a budget), then the config, the kwargs reaching the template (71);stop, cutting the reply before the first, streamed or not, and stopping the generation (72);presence_penalty,frequency_penalty,repetition_penalty(the whole reply) andrepeat_penalty(the last 64 tokens unlessrepeat_last_nsays otherwise: 73);tool_choice"none", leaving the tools out (74);max_tokens(78); thedeveloperrole, assystem(77).X-Context-Window(withX-Context-Window-Requestedwhen the model takes less than asked) and/v1/models'max_model_len(max_model_len_requested) give the window LiteRT-LM gives the engine, which "length" is judged against (75);X-Thinking: unverifiedmarks a thinking request to a file that doesn't say whether it thinks (LFM2.5-230M: 71),X-Unapplied-Parameters: min_pa min_p above 0 (73).response_formatthe model can't follow or llguidance rejects (66); an image or audio part for a model without that input, before it loads (68); a top_p outside 0 to 1, a negative temperature, a fractional or out-of-range top_k or seed (70); thinking on for a model that doesn't think, before it loads (71); astopthat isn't a string or array of strings (72); a repetition penalty below 1, a penalty that isn't a number, arepeat_last_nbelow -1, a min_p that isn't a number (73);tool_choice"required"or a named function (74); a prompt over the window (in a stream, theresponse.errorevent: 75); a cap below 1 (78);nother than 1,logprobsortop_logprobs(79); a last assistant message without tool calls (80).How this relates to the TurboLLM PR
The daemon side of the app secret is in the companion PR mohitsoni48/TurboLLM#287, with the CSP covering image and media redirects and the new
GET /api/v1/app-proofroute.maintoday) leaves the app on its splash screen.main's, though with notifications offmain's app now unloads the model on leaving while a reply waits for a tool approval (the wait no longer counts as busy), which denies the call.application/json, without the browser's Origin; otherwise 30 refuses a browser client of the daemon's/v1/chat/completionsposting its JSON as text/plain while a LiteRT-LM model is loaded.POST /api/v1/daemon/flush,poolModelsonGET /api/v1/statusand/api/v1/activity'swaitinglist andload,tuneandskillkinds; against an older daemon the flush's 404 is skipped, only a Jev or Laya model alone in a pool slot keeps the service, an approval wait reads "Generating", and a model load, an auto-tune or a skill shows as idle, with no wake lock. The idle timer, after 55 the only automatic unload, counts from the model's last use only with TurboLLM#287's "fix(engines): a chat or Code turn restarts the idle clock". 57 and 58 rely on TurboLLM#287 running replies in the daemon whatever the page does.serving(432): with an older daemon a close with only LAN access, remote access or hosted peers on quits. 62 relies on its tether (433, 434): an older daemon ignores the helper; engines then end at the cgroup kill, LiteRT-LM's server at its own PDEATHSIG (63).reasoning_effortfor each thinking choice (andchat_template_kwargsandthinking_budget_tokens, which 71 reads), the sampling the user set (saved, thread or request; once any is set, the bundle's own for the rest), the Repeat penalty asrepeat_penalty(73's last 64 tokens) and, under Backend Auto and an unset context, no backend ormax_num_tokens: 66-80 fill the rest from the model. Its 476 refuses a forcedtool_choiceonservein 74's words, and 477 names min_p on/v1/messages, which 73's header never reaches.play-alpharun of this PR until TurboLLM#287 is onmain:pr-alpha.ymlbundles TurboLLMmain. Reject any run queued earlier, then push a new commit: a re-run reuses its versionCode.-Pturbollm.dirpointing at a checkout containing TurboLLM#287, withnpm cirun in it and itsweb/(65). 64 needs no TurboLLM change: the picker's class is TurboLLM#270's, onmain.DaemonAuth'sConnection: closeheaders (10) or itsProxy.NO_PROXY(16), nor put the proof back on HttpURLConnection (45). A friendlier "daemon too old" message on a 404 would be a separate follow-up.Testing
liblitert-lm.so0.18.0 with its test model, release and ASan builds (0 reports), scripted stubs or a shim replaying the runtime (14: not with a real model writing a malformed tool call); host g++ (29 to 33:-O3 -Wall -Wextra) and NDK 27 for both ABIs (29 to 33: aarch64-fsyntax-only, 0 diagnostics), 0 warnings. On the real library 29 to 33's replies match before and after (finish reason, usage,[DONE]), withinclude_usagethe nulls gone, a cross-site text/plain POST 200 → 403 and a 200-level body 500 → 400; 35 passes all 22 scripted stub checks; 54 also ran in the x86_64 Android 15 emulator image's userland under chroot.DaemonAuth.kt,EngineService.kt,ModelImport, DownloadNotice.kt and RendererCrashes.kt (kotlinc 2.2.20) over OkHttp 2.7.5 and the JDK's HttpURLConnection, with stubs, the daemon's real HTTP app (8, 45) and a scripted daemon (60 to 63: 145 close and notification scenarios, notifications off and on, and 11 more boot-wait cases); TurboLLM#287's Android bundle on Node 18.20.4 for 55 and 56's kill runs and 60 to 63's tether (mksh, the host-built helper: 20 runs, nothing left). 57 and 58 also by reading against the Compose UI 1.9 contracts.core.autocrlf=truecheckouts.scripts/webview-height-fix.test.mjs(Playwright's Chromium) andscripts/stage-daemon-assets.test.mjs, the real web UI in Chromium and staging on TurboLLM b0e8455.:composeApp:compileDebugKotlinAndroidon its own where each message says (-Pturbollm.dirat a TurboLLM#287 checkout);lintDebugfails at the base too (AndroidX lint jars clash with AGP's Kotlin Analysis API; see build.gradle.kts). The SDK's standalone lint: 0 errors and the same warnings before and after 1 to 9, not re-run for 10 to 33 or 35 to 80; 34's FullBackupContent check is in its message.compileDebugKotlinAndroid mergeDebugJniLibFoldersat 65 (mergeDebugAssetstoo before its last staging change): BUILD SUCCESSFUL (at 63 itsstageTetherExecbuilt both ABIs with NDK 27,libtether_exec.soin the merged native libs);node --test scripts/*.test.mjs engines/litert-lm-server/*.test.mjs: 44 tests, 43 pass, 1 skipped (stage-tether-exec's NDK case: no NDK on PATH; Gradle's run builds it), at 64 alone 32, 31, 1.:composeApp:compileDebugKotlinAndroidat 59 (a full recompile, Android SDK 36,-Pturbollm.dirat a TurboLLM#287 checkout before 432): BUILD SUCCESSFUL, with only the existing expect/actual warnings, ongit archiveof 59 (byte-identical to the branch). TurboLLM#287's Android bundle at 463 builds, and all 16 chunks parse on Node 18.20.4.main.ccand stand-in library compile with-Wall -Wextra, 0 warnings, and its stand-in suites pass (10 pass and 1 skipped at 66, up to 32 and 14 at 80), its new tests failing with the parent'smain.ccas its message says. At 80,node --test --test-concurrency=1 scripts/*.test.mjs engines/litert-lm-server/*.test.mjswith the real-model variables (the manylinux 0.18.0 library; LFM2.5-230M, gemma-3-270m-it, Qwen3-0.6B and LFM2.5-VL-450M; the NDK): 112 tests, 112 pass; the NDK 27 builds of both ABIs: 0 warnings. On a build whose server library is byte for byte 80's, Gradle:composeApp:compileDebugKotlinAndroid :composeApp:mergeDebugJniLibFoldersgave BUILD SUCCESSFUL, and in the x86_64 Android 15 userland under chroot (the NDK build, CPU) 75 of 75 end-to-end checks passed on those four models: plain, streamed, tools,tool_choicenone,response_format, thinking on and off, stop, the three penalties, top_k 0, a temperature alone, min_p, the prefill 400, a second chat equal to a fresh engine's, no backend set, the context headers, the file read's memory, an image to LFM2.5-VL ("Red") and to gemma-3-270m-it (a 400).http://localhost:7000link opens in the app; LAN access on and off;mailto:link in a chat reply opens the mail app and the UI stays; anintent:link does nothing;chrome://inspect, a debug build is; a web link opens in the browser (or an app that claimed it), anhttps://127.0.0.1:7000link in the app;Android/data/com.turbollm/files/, a cloud backup after Quit goes ahead and leaves it out;.litertlmloads and chats on CPU on the phone where it failed, and whether the GPU backend initialises (54);adb shell dumpsys activity services com.turbollmuntil a later close (47, 55, 60);chrome://inspect(debug build) lists no com.turbollm page and the renderer leavesadb shell ps -A | grep sandboxed; the UI loads on reopen (57);kill -SEGVs within a minute leave "The page keeps crashing." and a working Reload (58);adb shell dumpsys power; no "Generating" as a download ends (59);adb shell pidof com.turbollmprints nothing, no engine left; a swipe mid-download: the notification stays, reads "Idle" after the download ends, and Quit ends it; Quit from the lock-screen shade, unlocked within 3 s: the app and its notification stay, and a second Quit ends it about 3 s later; a swipe during the splash (on a first launch once the animated bolt shows: the daemon is unpacked):pidofprints nothing once the daemon is up, at least 3 s after the swipe; files/daemon.log hastether: libtether_exec.so self-test passed; with a model loaded,adb shell ps -Ashows the engine with the app as its parent and no libtether_exec.so, and no engine is left after a force-stop, Quit or akill -9of the app (these pass on the cgroup kill too; only thetether:line tests the helper) (60-63);adb logcat -s TurboLLMhas no "100vh is 0px" warning (a page loaded before the WebView had a height warns in its console); Load a model shows the search box and models, keyboard up and down; MobileNav pinned on short pages; onboarding, sheets, dialogs, Discover, a WebView rebuilt after a renderer crash and the first paint look right (64); on Windows the next Gradle build logs "web UI up to date (built from these N files under web/)", N in the hundreds, or "building the web UI: …" (65)..litertlm(LFM2.5-230M int8) loads, chats and answers a request with tools, an array-of-objects tool too (never "Failed to create conversation"; the engine log says "constrained decoding is not available for ..." once per load); the GPU checks are in Known (66-80).Known, not fixed here
files/.turbollm/config.json, with every third-party token entered (15), andapp_webview/, with an API key pasted into the web UI's key prompt, both in plaintext; daemon-issued API keys are stored hashed. Android 9+ encrypts the cloud backup end to end with the lock-screen credential, if set. Excludingconfig.jsonwould lose every setting on restore, andapp_webview/the web UI's settings; moving the credentials to a separate excluded file needs a daemon change and a config migration, and users would re-enter tokens and re-pair links after a restore. 15 and 28 document it in privacy-notes.md for the team to decide before shipping..litertlmheaders: an in-range offset over junk FlatBuffer bytes, or a file cut off before its first section, still crashes inside the upstream runtime 0.18.0, which needs an upstream fix.blob:downloads (chat export and similar) are still unsupported: the WebView has noDownloadListener; a product decision.role: null, a tool's"type": nullor a tool call's"id": nullstill gets a 500 instead of a 400; 46 fixes onlystreamandinclude_usage.open()'s request still uses HttpURLConnection. A leaked secret also outlives the process: the app is the host, so its holder can create a permanent API key withPOST /api/v1/keys. An authenticated API port change leaves the WebView on 7000, whose cookie then goes to whatever binds 7000 next./dataexecutable's linker namespace searches only/systemand/system_ext, so a vendorlibOpenCL.somay not be found; a GPU the user chose then fails with the runtime's error and the app suggests the CPU Backend (under Auto the server falls back to the next backend the file lists: 76). Termux runs the Python CLI with the wheel's own file and may hit the same failure on such a phone (not checked).onRenderProcessGone: nothing changes there./system/bin/shwatchers (MCP, tunnels) against Android 12+'s phantom-process limit (one more process per engine without the helper: 62), system_server's cgroup kill of a grandchild and 65 on Windows; 64's fix rests on Chromium's sources, 65's OneDrive handling on libuv's and Node's sources and a Linux simulation; pr-alpha.yml's change awaits its next run.X-Thinking: unverified); the C API can't force a tool call or continue a partial reply, and there is no min-p, no n above 1 and no log-probabilities (hence the 400s and the header); the CPU sampler implements TOP_P only (a model's TOP_K or GREEDY maps onto it); benchmark mode stays on (its GPU cost unmeasured) and speculative decoding off unless the config turns it on (a drafter needs device measurement); gemma-3-270m-it's template can't render tool turns. The server reads a file's header as 0.18.0's model_info.cc does and works out the window as its engine settings do: a LiteRT-LM upgrade must check both still match.litert-lm serve, which has none of 66-80's fixes (TurboLLM#287 lists its limits).md:…variants or non-height classes, a container-query unit beside vh, a class whose only rule is cross-origin, its !important over responsive overrides, height or orientation media queries. The current UI uses none.