Skip to content

Review fixes: LiteRT-LM server crashes and model defaults, app secret, backups, downloads - #8

Merged
ciroprogamer merged 80 commits into
mainfrom
feat/relaxed-ptolemy-3rpd21
Oct 26, 2026
Merged

ciroprogamer merged 80 commits into
mainfrom
feat/relaxed-ptolemy-3rpd21

Conversation

@ciroprogamer

@ciroprogamer ciroprogamer commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

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.

# Commit What it fixes
1 fix(litert-lm-server): stream use-after-free; crash on non-UTF-8 errors Heap corruption: the stream queue's condition variable could be signalled after the queue was freed (ASan: ~500 one-token streams under CPU contention). Server kill: a non-UTF-8 error message made SendError throw in a catch handler, calling std::terminate.
2 fix(litert-lm-server): reject out-of-range .litertlm header offsets A file with the right magic but a bogus header offset killed the server; it now gets a 500 "invalid .litertlm header", the loaded model kept.
3 fix(android): keep models, caches, logs and the daemon out of backups With a model downloaded in the app, the models and extracted daemon put the backup over the 25 MB cloud quota, so Android skipped it all, chats and settings included.
4 fix(android): per-launch app secret so other apps can't use the daemon Every app on a phone shares 127.0.0.1, so any installed app could use the daemon's whole API through loopback trust.
5 fix(android): count downloads as busy; re-post a swiped notification Stalled downloads: with the screen off they got no wake lock, and with notifications off leaving the app could stop the service mid-download. Lost Quit control: on Android 14+ swiping the notification removed the only Quit control.
6 fix(android): stage the daemon's real version Every Android install reported version 0.1.1, so the boot-time update check saw an "update" at every start.
7 fix(play-listing): parse listing-copy.md with CRLF line endings The Play listing step failed on a Git-for-Windows (CRLF) checkout.
8 fix(android): send the app secret only to a daemon that proves itself The app sent its secret to whatever held 127.0.0.1:7000, on a cold start not yet the daemon, so another app could take the port, collect the secret and let go. The holder must now prove itself (GET /api/v1/app-proof) first.
9 fix(android): load localhost:7000 links from 127.0.0.1 with the cookie A http://localhost:7000 link 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.
10 fix(android): app proof and secret each go on a new socket, never pooled Commit 8's proof could be bypassed on every cold start: Android's HttpURLConnection pools keep-alive sockets, so an app holding the port at launch could let the daemon have it, keep the socket the app had pooled, relay each nonce and get the secret on the next request, no race needed. The proof and every request now use Connection: close, each on a new, never-pooled socket.
11 fix(android): Play FGS declaration covers downloads and swipe re-post PLAY-FGS-DECLARATION.md left out the in-app model downloads the service now keeps going, their "Downloading models" text and wake lock, and the re-post of a swiped notification on Android 14+. Text only.
12 fix(android): correct stale EngineService comments EngineService's comments said the wake lock and the notifications-off stop were about generation only, and called Quit the only way to free a loaded model. Comments only.
13 fix(android): release check no longer ends on a 401 from /api/v1/engines BUILDING-ENGINES.md's release self-check ended with curl /api/v1/engines over an adb forward, now a 401 (adb counts as another app). It now checks /healthz and the daemon log's "registered bundled engine" lines and says how to query the API with a key. Docs only.
14 fix(litert-lm-server): bad tool call use-after-free; 16 KiB header cap Use-after-free (older): a malformed <tool_call> (Qwen-family bundles, requests with tools) 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".
15 fix(android): document that backups carry plaintext third-party tokens The backup rules keep config.json on 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.
16 fix(android): app proof and secret never go through a system proxy Both DaemonAuth connections 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's setHttpProxy one. 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 use Proxy.NO_PROXY.
17 fix(android): another app's proof answer can't stall or crash the app Stall: with no overall deadline (timeouts are per connect and per read), an app that accepted the proof connection at launch could trickle a byte every 1.5 s and hold the splash, or the service's poll, indefinitely. Crash: the body went whole into org.json, which recurses, and only Exception was caught, so a multi-MB body or a few hundred KB of [ crashed the app. The secret was never at risk this way.
18 fix(android): say the engine log line comes only on a clean first launch BUILDING-ENGINES.md read as if every boot logs "registered bundled engine"; the daemon logs it only on first registering an engine, and daemon.log is truncated at each Node start, so on a phone that already had the app the grep finds nothing. It now says so; the release check (which uninstalls first) was right. Docs only.
19 fix(litert-lm-server): error replies no longer race the decode thread After an error chunk (a malformed tool call) the server cancelled the generation and copied the runtime's benchmark info while the decode thread, only asked to stop, could still write it unlocked: a data race that could crash the engine, for usage then thrown away.
20 fix(litert-lm-server): stalled stream reader no longer hangs the engine Chat and /v1/models replies were written with a blocking send() 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/models request hung while /health said ok, the realistic trigger being another app on the engine port (see Known).
21 fix(litert-lm-server): context-window cut-off reports length, not stop A reply cut off by a full KV cache ended "stop", so clients (the daemon's Messages-API gateway included) took it for complete; "length" came only on a "RESOURCE_EXHAUSTED: Max number of tokens reached" error, which LiteRT-LM 0.18.0's C API never sends. "stop" now becomes "length" when prompt plus completion tokens reach the configured max_num_tokens.
22 fix(litert-lm-server): disconnect cancel is retried until the turn ends A disconnected client's cancel was sent once, and LiteRT-LM 0.18.0 can lose one that lands between prefill and decode; the turn then ran to its end, holding the engine lock while every other request waited.
23 fix(android): a notification dropped before a grant is posted again After a grant: on Android 13+ a post made without the notification permission is dropped for good and nothing re-posted it, so a grant in Settings with the app running, a model load and Back left the service in the foreground, the model loaded, with no notification or Quit. Channel off: the same after turning the Engine channel off once the app was left. The app now re-posts it, and stops once idle where Quit can't be reached (unloading the model until 55, stopping until 60).
24 fix(android): mailto, xmpp and irc links no longer replace the UI Only http(s) links went to the system; other schemes but turbollm-app went to the WebView, which cannot load mailto:, xmpp: or irc: and put an ERR_UNKNOWN_URL_SCHEME page in place of the UI. Replies link bare e-mail addresses as mailto:: a tap composed nothing, and Back reloaded the UI, dropping a typed message. mailto:, xmpp:, irc:, ircs: and tel: now open the matching app; other non-web schemes (intent:, market:, file:, content:, deep links) do nothing.
25 fix(android): WebView remote debugging only in debuggable builds Release and Play builds had WebView debugging on, so a computer with authorized adb could attach DevTools and run script in the http://127.0.0.1:7000 origin with the app's cookie: the whole keyless API, POST /api/v1/keys included, 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.
26 fix(android): links to other apps carry CATEGORY_BROWSABLE Web links and 24's contact links went to other apps as a bare ACTION_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.
27 fix(android): https links to the daemon's port load over http in app A link to https://127.0.0.1:7000/… or https://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.
28 docs: privacy notes list the WebView data and prefs that are backed up privacy-notes.md named only the chat history and config.json as backed up. Auto Backup also takes app_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.
29 fix(litert-lm-server): streamed parallel tool calls get their own index Streamed parallel tool calls (a Qwen-family model sends each in its own chunk) were numbered per chunk, so all went out as index 0 with one id and OpenAI clients and the daemon's gateways merged them; through /v1/messages that was one tool_use block with the non-JSON input {"city":"c0"}{"city":"c1"}. Non-streaming replies were right.
30 fix(litert-lm-server): web pages can no longer drive the engine port The engine port checked neither Host nor Origin and parsed any body as JSON, so in a browser that lets pages reach loopback (Firefox for Android does) any site could POST to it as a form or text/plain request (no preflight): one naming any file on the phone released the user's model to load that file, and a DNS-rebinding page could also send JSON and read the replies. Both now get 403.
31 fix(litert-lm-server): deeply nested JSON no longer kills the engine Copying a parsed value and dump() 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 /v1 client in an arguments string. Such a body now gets 400.
32 fix(litert-lm-server): running out of threads no longer kills the engine Each connection gets its own thread, held until a request arrives or 60 s pass. Out of threads, starting one threw, nothing caught it and std::terminate aborted the server, unloading the model; another app only had to open enough idle connections to the engine port.
33 fix(litert-lm-server): no "usage": null on chunks before the usage one The daemon's gateway turns include_usage on 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.
34 fix(android): keep the app's external files folder out of backups Auto Backup also takes Android/data/com.turbollm/files/, the external files folder, and the rules excluded only paths under files/. 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 and config.json included; a device transfer copied the models too.
35 fix(litert-lm-server): parsing many objects in a body is linear again Commit 31's json::parse callback 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 /v1 chat client could send one, as could any app on the phone, a few in parallel taking the cores inference uses.
36 fix(android): FGS declaration says why downloads keep specialUse PLAY-FGS-DECLARATION.md says the service also keeps imports and downloads going, but answered "Why does no other foreground service type fit?" with "It is not data sync", though Android lists both under dataSync; the manifest's special-use subtype, reviewed with it, named only the model and generations. The page and the subtype now cover them; the service type is unchanged.
37 docs: a restore at install also leaves out the engine log line The release check uninstalls, installs and greps daemon.log for "registered bundled engine", but Android restores an Auto Backup at every install, adb install included, made by an APK signed with the same key, and the restored config.json already lists the engines, so the grep finds nothing. BUILDING-ENGINES.md now says so and how to avoid it.
38 docs: name the staged daemon/package.json and its version field BUILDING-ENGINES.md's table of load-bearing settings named dist/package.json = {"type":"module"}; stage-daemon-assets.mjs writes daemon/package.json, next to dist/, 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.
39 docs: privacy notes list the app's real permissions and engine source privacy-notes.md, the input for the Data safety form and the privacy policy, said the app asks for no notification or storage permission, runs no foreground service and downloads its engine when first needed; the manifest declares the specialUse service, POST_NOTIFICATIONS, WAKE_LOCK and (to Android 10) the storage permissions, and the engines ship in the APK. The notes and two MainActivity KDocs now match. Docs and comments only.
40 docs: privacy notes say when Auto Backup runs while the service is up Commits 3 and 34 keep a backup under the quota but do not make one run: the scheduled full backup passes over an app with a foreground service and retries 1 to 3 h later, and EngineService runs from app open until Quit (since 60 also until a close with nothing running). The notes now say when one runs and why android:backupInForeground is not set.
41 docs: privacy notes say where model folders can be outside the app The notes said the daemon's state, models included, stays in app-private storage, but a model folder the user adds can be in shared storage or in the external files folder, readable over USB and, depending on the Android version, by other apps. The notes and the manifest's storage-permission comment now say so. Docs and a comment only.
42 fix(build): shell scripts check out with LF on Windows too With no .gitattributes, Git for Windows checked out CRLF, and the Vulkan engine build, run in WSL from that checkout, stopped at the first \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. *.sh and gradlew are now eol=lf; BUILDING-ENGINES.md gives the one-time step for an existing checkout, and RELEASING.md points to it.
43 docs: Play listing says Android's backup can copy chats off the phone listing-copy.md said chats and models stay in private storage, "with nothing leaving the device", but Auto Backup and device transfer copy the chats and config.json on purpose, and this PR makes that backup fit the quota for people with models. The listing now says so.
44 docs: Play listing and privacy notes name the app's network requests The listing said the app's only network requests are user-triggered, among them fetching the engine when first needed. The engines ship in the APK, and the daemon fetched registry.npmjs.org 5 s after every launch (TurboLLM#287 now skips that in the app). Images in a reply or model card load from their host as soon as they show, with no approval, so a reply can pass chat text to that host in an image URL. The listing and the notes now say so.
45 fix(android): the app proof caps the answer's headers, not its body only Commit 17 capped the proof answer's body at 4 KiB, but HttpURLConnection reads the status line and each header line whole, unbounded, so an app holding the port at launch could send header lines without end: on the JVM with OkHttp 2.7.5 they filled a 96 MiB heap in under 2 s, inside the 3 s deadline; on Android an OutOfMemoryError on the main thread ends the app. The proof is now a hand-written GET on a plain socket (still Proxy.NO_PROXY, never pooled) reading at most 4 KiB in all.
46 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 did stream_options.include_usage: null. Both are now false when absent or null; any other non-boolean gets a 400.
47 fix(android): a quick reopen after Back no longer unloads the model After Back and a quick reopen the old activity's close could reach EngineService after the new one's start, and the service took the open app for closed: with notifications off it unloaded the model and stopped (no longer foreground, so Android could freeze or kill the open app's process); with the notification up, turning the Engine channel off later did the same.
48 fix(android): drop the unused battery-optimization exemption permission The manifest declared REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, which nothing uses and Play allows only when Doze breaks an app's core function, so it only risked a flag in review. It is removed.
49 docs: FGS video script loads a model the default engine runs The video script Play reviews with the declaration began by loading a .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.
50 fix(android): the import sweep leaves a daemon download's .part alone A picked model was copied to <name>.part, and the sweep at app start and before each import deleted every top-level *.part in files/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.
51 docs: privacy notes name the Kotlin code that talks to the daemon The notes cited MainActivity.kt:69 for the daemon's --no-telemetry args, about 50 lines off, and said DaemonClient.kt hits http://127.0.0.1:7000; it calls 127.0.0.1:6996, and only the never-shown commonMain App() screen constructs it. They now cite the right code.
52 docs: FGS video script selects LiteRT-LM by the name Engines shows Commit 49's step 1 said the Engines selector lists LiteRT-LM as litert-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.
53 fix(android): the download notification shows its progress and speed While a model downloaded, the notification read only "Downloading models" (file, progress and speed were only in the app), and a poll that could not read the list showed "Running in the background" for 3 s. It now has the file's name as its title ("Downloading models" for several), " of · ", "N waiting" and a progress bar, updated silently every 3 s; a failed poll keeps the last progress for up to three polls.
54 fix(litert-lm-server): liblitert-lm.so loads without libandroid.so On the phone it was reported on, no .litertlm ever 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.
55 fix(android): leaving the app no longer unloads the model With notifications off, Back or a Recents swipe unloaded the model once nothing ran (older behaviour 23 and 47 kept): coming back meant a reload, and a load in progress was cancelled. The service now unloads nothing: it stays while any model is loaded or loading (GET /api/v1/status, poolModels included) 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.
56 fix(android): Quit writes the chat database to disk before the app exits Quit killed the process at once while the daemon held chats in memory (sql.js, written every 10 s before TurboLLM#287), so a reply finished 2 s before came back empty and a chat just started was gone. Quit now has the daemon write the database first, then exits as before.
57 fix(android): the WebView is destroyed when its screen goes away Nothing called WebView.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. onRelease now stops loading and destroys it.
58 fix(android): a crashed WebView renderer no longer closes the app With no onRenderProcessGone, 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.
59 fix(android): the notification says what is running The notification read "Generating" for any work (a model load, an auto-tune, a skill being written), "Running in the background" while a reply waited for a tool approval, and flashed "Generating" at the end of every download or import. It now names the work ("Loading a model", "Auto-tuning", "Writing a skill", "Generating"), reads "Waiting for your approval" (no wake lock) when only that remains, and posts once, only when what it shows changed.
60 fix(android): closing the app with nothing running quits it fully A close (Back, a Recents swipe) never ended the process: with the notification up the service stayed until Quit with nothing running, or with notifications off stopped by itself once work ended, leaving the process to Android.
61 fix(android): the notification says Idle when nothing runs With nothing in progress it read "Running in the background", as if work went on. It now reads "Idle", or "Idle · ".
62 fix(android): an engine dies with the app process, by the kernel An engine outlived every Quit, crash or kill of the app until system_server's cgroup kill. The APK now ships libtether_exec.so, which arms PR_SET_PDEATHSIG(SIGKILL) and execs the engine; TurboLLM#287's daemon starts engines and probes through it.
63 fix(litert-lm-server): the server ends when the app does, on its own A daemon without TurboLLM#287's tether left the LiteRT-LM server running past the app. main() now arms PR_SET_PDEATHSIG(SIGKILL) first and exits 1 if its parent has gone.
64 fix(android): the WebView lays the UI out at its real height Workspace → "Load a model" was an empty strip, every vh 0px: with no layout params the WebView was WRAP_CONTENT, whose page Chromium lays out at height 0. The factory now sets MATCH_PARENT.
65 fix(android): an APK never carries a web UI older than its checkout Staging copied the built web UI (not in git) as found, so after a pull an APK could pair the new daemon with an older UI. It now rebuilds it when web/ changed.
66 fix(litert-lm-server): chats with LFM2 and gemma-3-270m no longer fail LFM2 and gemma-3-270m-it: the server turned constrained decoding on for every request, as litert-lm serve does, 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.
67 fix(litert-lm-server): LFM2 answers each chat from a fresh state An LFM2 model answered each request from the state the one before left (LiteRT-LM 0.18.0 starts a conversation on an engine's buffers as left, and LFM2's short-convolution cache is read as is: LiteRT-LM #3165), so after a pirate chat "Answer in one word." got a sentence.
68 fix(litert-lm-server): models that take images or audio get encoders No vision or audio backend was set up, so every image sent to a vision model (LFM2.5-VL-450M) got a 500 "Vision executor should not be null", as did one sent to a text-only model.
69 fix(litert-lm-server): sampling starts from the model's, with a seed Greedy: a request setting any sampler field got fixed values for the rest (top_k 1, top_p 0.95, temperature 1), not the model's, so a temperature alone, as TurboLLM's thread settings send it, decoded greedily, and Qwen3-0.6B lost its top_k 20. Seed 0: without a seed every reply used 0, so Regenerate repeated a reply word for word.
70 fix(litert-lm-server): top_k 0 is no limit; bad sampler values are 400s Top K 0, which TurboLLM's dialogs offer as disabled, and vLLM clients' -1 were 500s ("k must be positive"), as were a top_p outside 0 to 1 and a negative temperature; a top_k or seed with a fraction was cut silently.
71 fix(litert-lm-server): thinking follows what the request asks Only the top-level reasoning_effort was read, so chat_template_kwargs (whose enable_thinking the daemon sends) and thinking_budget_tokens were 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.
72 fix(litert-lm-server): a request's stop strings end the reply stop was read nowhere, so a thread's stop strings (the daemon sends them as stop) and API clients' had no effect.
73 fix(litert-lm-server): repetition penalties reach the model presence_penalty, frequency_penalty, repetition_penalty and repeat_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.
74 fix(litert-lm-server): tool_choice "none" withholds the tools tool_choice was 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.
75 fix(litert-lm-server): a context larger than the model takes is said LiteRT-LM caps a context at the model's limit with only a log warning, but the server judged "length" against the size asked: on LFM2.5-230M with 8192 asked a reply cut at 4096 tokens ended "stop", with none asked "length" never came, and a prompt over the real window was a 500.
76 fix(litert-lm-server): with no backend set, the model's own is used With no backend set the server always picked CPU, so a GPU-only bundle (gemma-4-E2B-it-gpu) failed ("Main backend constraint mismatch"), which matters once TurboLLM#287's Backend Auto sends none.
77 fix(litert-lm-server): a developer message is a system message OpenAI's developer role 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).
78 fix(litert-lm-server): max_tokens is read, and a cap below 1 is a 400 max_tokens, which most API clients still send, was not read, and a max_completion_tokens of 0 or below generated one token, finish "length".
79 fix(litert-lm-server): n, logprobs and top_logprobs are refused A request for several choices or log-probabilities, which LiteRT-LM's conversation API can't give, got a 200 without them (one choice for "n": 2).
80 fix(litert-lm-server): a reply the server cannot continue is a 400 A conversation ending with a partial assistant reply (a client's prefill), which LiteRT-LM's C API can't continue, was sent as a finished turn, so the model began anew and the client showed "The capital of France is The capital of France is Paris."

Behaviour changes

  • A link to localhost on a port other than the daemon's opens in the system browser, not the app, and one to another 127.0.0.1 port no longer opens at all: cookies are scoped by host, not port, so anything on another local port could otherwise get the app secret.
  • Play Console: paste PLAY-FGS-DECLARATION.md again and record its video with a GGUF model: 36 changes the answer to "Why does no other foreground service type fit?" (and the manifest's special-use subtype), 49 and 52 the video script's step 1, 23, 53, 55 and 59 the "What does the user see" answer (the Engine channel off after leaving and the notifications-off lifetime, both replaced by 60, the notification lines, who unloads the model, the wake lock), and 60 and 61 the text and step 6. 43 and 44 change the listing that publishReleaseListing uploads (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:

  • The UI and every native call wait for the daemon's proof; as each native call makes two new loopback connections (the proof and the request), the service's 3 s poll opens four (six while nothing runs: since 61 it also reads /api/v1/status).
  • If a LAN toggle finds port 7000 taken while the daemon rebinds, the daemon exits, and the app with it, rather than let the WebView's cookie go to whoever holds the port.

From commit 14:

  • After a malformed tool call the stream closes right after response.error and [DONE], a non-streaming client gets a 500 with the runtime's message, and the next request is served.
  • A parse error quoting "CANCELLED" or "Max number of tokens reached" is reported as an error, not a normal or length stop.
  • A header offset over 16 KiB no longer gets the runtime's "Failed to read header data".

From commits 16, 17 and 23 to 25:

  • No native call (the splash's proof, the service's polls and requests, model folders, rescans) uses a system proxy: behind a remote manual proxy with an empty bypass list the splash never ended (traced, not run) and now reaches the daemon.
  • A proof gives up after about 3 s even while bytes keep arriving, and fails unless the proof is in the answer's first 4 KiB (status line and headers included since 45); our daemon answers in milliseconds. One idle thread serves this deadline for the process's life.
  • 23's re-posts are silent. After Back or a Recents swipe with the notification up, turning the Engine channel off counted as leaving with notifications off (23, 55) until 60 made it change nothing.
  • A contact link (24) does nothing if no matching app is installed; in a sub-frame, a navigation to any non-web scheme is dropped, contact links included.
  • Release builds no longer open the WebView DevTools socket (chrome://inspect does not list them); a QA pass over CDP needs a debug build, and assembleDebug -PturbollmApplicationIdSuffix=.test installs one beside the Play app.

From commits 20 to 22:

  • Once a client's socket buffers are full, a write that gets nothing out for 30 s fails: a streaming client that stops reading is cut off within one to two minutes, as on a disconnect, and its generation cancelled. This holds for every response; a client that keeps reading is unaffected.
  • 21's "length" holds streaming or not; usage and timings are unchanged.

From commits 26 and 34:

  • A link handed to another app opens only in an activity that accepts links from web pages: a handler not declaring CATEGORY_BROWSABLE no longer gets it, and with no other handler the tap does nothing; https links an app has claimed still open that app.
  • Nothing in 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 under files/; 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:

  • Streamed tool calls get the same ids the non-streaming reply gives (29).
  • The engine port answers 403 to a request whose Host is neither localhost nor an IP address (a LAN client of a --host 0.0.0.0 server must use its IP), and to a POST from a web page (Origin set) that is not application/json, unless --cors-origin allows its origin. For a browser client of a daemon without TurboLLM#287, see below.
  • A chat request nested deeper than 128 levels gets 400 (31); tool-call arguments given as a string nested that deeply become {}, in a request and in a tool call the model returns as a string. Parsing takes as long as with the plain json::parse before, also with many objects in one array or object (35).
  • A connection no thread can be started for is closed without a reply, and the server keeps running.
  • With include_usage, the chunks before the usage one have no usage key, which OpenAI's SDKs read as null.

From commits 45 and 50:

  • A proof answer is read until the socket closes or 4 KiB have come, whatever its Content-Length says, so a complete 200 whose sender keeps the socket open fails at the 2 s read timeout, as does a status line with no reason phrase (HTTP/1.1 200). Nothing changes with our daemon, which closes the socket (45).
  • A .part left 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:

  • Leaving the app neither unloads a model (an embedding model in its own pool slot included) nor cancels a load; the idle timer is Settings → Idle timeout (60 minutes by default, 0 turns it off). With notifications or the Engine channel off the service then stopped once nothing ran, until 60 (55).
  • Quit and the service's own stop (since 60, a close with nothing running) wait up to 2 s (tens of ms normally) for the chat database write; with TurboLLM#287 Quit's write also keeps the text of a reply still streaming (55, 56).
  • The download notification reads, e.g., "1.23 GB of 3.14 GB · 12.3 MB/s" ("2 downloads · …" for several); its bar is indeterminate while a size is unknown (53). The wake lock is also held for a model load, an auto-tune or a skill (59).
  • A renderer death (Android 8+) reloads the UI, at once if the app is on screen, otherwise when it is next on screen; a kill for memory never counts toward the crash screen (58).
  • The APK carries liblrtandr.so (5 KB per ABI); the engine process no longer loads libandroid.so or anything only it needs (45 system libraries mapped instead of 230 on an Android 15 image). Staging also builds the shim, links a probe and removes only stale shims (liblr*) from its output; a LiteRT-LM bump updates the two patched hashes, which the build prints (54).

From commits 60 to 63:

  • A close (Back, or a Recents swipe, which no longer sends a second close beside onTaskRemoved) with nothing running quits about 3 s later, notifications on or off, once the chat database is written; reopening in the 3 s keeps the app. A model loaded or loading, work (a running routine; a scheduled one fires nothing until the app is opened), an approval wait, a running or queued download, an import, LAN access, remote access or hosted Turbo Link peers count as running, as does a daemon that fails to answer; such a close keeps the service, also after the idle timer unloads the model, which it still does (60).
  • A close before the daemon proves itself waits for it (33.5 s at most; see Known). Quit ends the app 3 s after the tap; the app back on screen in them keeps it (60). When hot, the Idle line reads "Idle. The phone is hot, so generation will be slower" (no name); a poll that can't read /api/v1/activity keeps the last work or approval line for up to three polls (about 9 s), where it read idle at once (61).
  • The APK carries 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:

  • Viewport heights are real from the first paint: the picker shows its search box and models, or "No models found.". HEIGHT_FIX_JS is a fallback that logs "100vh is 0px in this WebView" when it acts. On screens that scroll the whole page, html and body are the document's height (64).
  • An Android build rebuilds the web UI (40-55 s) once after this update, after a hand-built web UI and when web/ changed since staging last built it; otherwise checking costs a hash, under 50 ms. If it must build, a stale or missing web/node_modules stops it with npm --prefix "<turbollm>/web" ci. build-inputs.json is in the assets and at /build-inputs.json; pr-alpha.yml leaves the web build to staging (65).

From commits 66 to 80:

  • Constrained decoding is on only for a request with tools; a model refusing it (LFM2's, gemma-3-270m-it's) gets such a request without it, its calls unconstrained, found out and logged once per load; models that worked before reply the same (66).
  • An LFM2 model (by type, or a recurrent state in its header) gets a new engine for each conversation after the first, the daemon's title, memory and compaction requests included: 0.13 to 0.17 s for LFM2.5-230M on a 4-core x86-64 CPU with the compiled-model cache (0.9 s without), 1.4 to 1.5 s for LFM2.5-VL-450M; other models keep their engine (67).
  • With no backend set a model runs on the one its file prefers (CPU without a constraint, GPU for a GPU-only bundle or an Artisan decoder), falling back, logged, to the next it lists (76). A model taking images or audio holds its encoder from load (about 0.2 GB more for LFM2.5-VL-450M on CPU), on the config's vision_backend or audio_backend, now read, else its file's first (68).
  • Requests are sampled from the model's own parameters with a new random seed (a request's seed is reproducible), so Qwen3-0.6B no longer repeats a reply; a model without its own stays greedy, but a temperature or top_p sent alone samples with no top-k limit (69), as does top_k 0 or below (70).
  • Now read: after the top-level reasoning_effort, chat_template_kwargs' enable_thinking and reasoning_effort, then thinking_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) and repeat_penalty (the last 64 tokens unless repeat_last_n says otherwise: 73); tool_choice "none", leaving the tools out (74); max_tokens (78); the developer role, as system (77).
  • New headers and fields: each chat reply's X-Context-Window (with X-Context-Window-Requested when 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: unverified marks a thinking request to a file that doesn't say whether it thinks (LFM2.5-230M: 71), X-Unapplied-Parameters: min_p a min_p above 0 (73).
  • 400s, each naming the field or reason: a response_format the 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); a stop that isn't a string or array of strings (72); a repetition penalty below 1, a penalty that isn't a number, a repeat_last_n below -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, the response.error event: 75); a cap below 1 (78); n other than 1, logprobs or top_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-proof route.

  • Merge TurboLLM#287 first. The app sends its secret only once the daemon proves it holds the port; a daemon without that route (TurboLLM main today) leaves the app on its splash screen.
  • The new daemon works with today's app and with Android main's, though with notifications off main'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.
  • 49 and 52 fit the video script to TurboLLM#287's engine selection (49) and Engines screen (52), and 44's listing and notes hold only with its "fix(update): the Android app does not check npm for an app update".
  • TurboLLM#287 also sends the daemon's chat pass-through to the engine as application/json, without the browser's Origin; otherwise 30 refuses a browser client of the daemon's /v1/chat/completions posting its JSON as text/plain while a LiteRT-LM model is loaded.
  • 55, 56 and 59 read TurboLLM#287's POST /api/v1/daemon/flush, poolModels on GET /api/v1/status and /api/v1/activity's waiting list and load, tune and skill kinds; 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.
  • 60 reads TurboLLM#287's 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).
  • TurboLLM#287's 464-478 send a LiteRT-LM model reasoning_effort for each thinking choice (and chat_template_kwargs and thinking_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 as repeat_penalty (73's last 64 tokens) and, under Backend Auto and an unset context, no backend or max_num_tokens: 66-80 fill the rest from the model. Its 476 refuses a forced tool_choice on serve in 74's words, and 477 names min_p on /v1/messages, which 73's header never reaches.
  • Do not approve a play-alpha run of this PR until TurboLLM#287 is on main: pr-alpha.yml bundles TurboLLM main. Reject any run queued earlier, then push a new commit: a re-run reuses its versionCode.
  • Local builds need -Pturbollm.dir pointing at a checkout containing TurboLLM#287, with npm ci run in it and its web/ (65). 64 needs no TurboLLM change: the picker's class is TurboLLM#270's, on main.
  • Never add a fallback that sends the secret without a proof (for an older daemon), which would undo 8, nor drop DaemonAuth's Connection: close headers (10) or its Proxy.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

  • 1 to 65, per commit: each message records the cases it ran, before and after. In short:
    • Native server (1, 2, 14, 19 to 22, 29 to 33, 35, 46, 54): the real liblitert-lm.so 0.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]), with include_usage the 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.
    • App code on the JVM (8, 10, 16, 17, 23 to 27, 45, 47, 50, 53, 55 to 63): the real 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.
    • Docs, backups and listings (6, 7, 18, 28, 34, 36 to 44, 49, 51, 52): each against the code, AOSP sources (34: frameworks/base's backup rule parser on a fake install) and Android and Play pages, the listing and manifests parsed (6: the full staging script not run); 42 on fresh core.autocrlf=true checkouts.
    • 64, 65: new scripts/webview-height-fix.test.mjs (Playwright's Chromium) and scripts/stage-daemon-assets.test.mjs, the real web UI in Chromium and staging on TurboLLM b0e8455.
    • Kotlin and lint: :composeApp:compileDebugKotlinAndroid on its own where each message says (-Pturbollm.dir at a TurboLLM#287 checkout); lintDebug fails 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.
  • Daemon bundle: TurboLLM's Android bundle (companion branch, Node 18.20.4, app secret): the proof matches byte for byte; native calls, the WebView cookie, LAN on and off, the secret gate, the polls and the host-only settings gate behave as TurboLLM#287 describes.
  • At 65: Gradle compileDebugKotlinAndroid mergeDebugJniLibFolders at 65 (mergeDebugAssets too before its last staging change): BUILD SUCCESSFUL (at 63 its stageTetherExec built both ABIs with NDK 27, libtether_exec.so in 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:compileDebugKotlinAndroid at 59 (a full recompile, Android SDK 36, -Pturbollm.dir at a TurboLLM#287 checkout before 432): BUILD SUCCESSFUL, with only the existing expect/actual warnings, on git archive of 59 (byte-identical to the branch). TurboLLM#287's Android bundle at 463 builds, and all 16 chunks parse on Node 18.20.4.
  • 66 to 80: each commit's main.cc and 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's main.cc as its message says. At 80, node --test --test-concurrency=1 scripts/*.test.mjs engines/litert-lm-server/*.test.mjs with 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:mergeDebugJniLibFolders gave 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_choice none, 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).
  • Still needs a device (45, 47, 50 and 53 to 80 never ran on one). Please check:
    • the app starts (the proof handshake), the UI loads and chats work; importing a model and the storage rescan work;
    • a download keeps going with the screen off, and a swiped notification comes back;
    • a http://localhost:7000 link opens in the app; LAN access on and off;
    • native calls (import, storage rescan, Eject, the busy poll) keep working with a new connection each, also with a Wi-Fi HTTP proxy or a VPN app's proxy set (empty bypass list), and the app still starts then;
    • a mailto: link in a chat reply opens the mail app and the UI stays; an intent: link does nothing;
    • deny the notification prompt, allow notifications in Settings while the app runs, come back: the notification shows, and after a model load and Back Quit is still in the shade; turning the Engine channel off after Back keeps the model loaded (55);
    • a release build is not in chrome://inspect, a debug build is; a web link opens in the browser (or an app that claimed it), an https://127.0.0.1:7000 link in the app;
    • with a model folder under Android/data/com.turbollm/files/, a cloud backup after Quit goes ahead and leaves it out;
    • with the app's own models folder as the primary download folder, a non-Hugging Face Import from URL survives Back and a reopen and finishes;
    • a download's shade: its name, sizes, speed and a bar moving silently every 3 s; two at once summed, with "N waiting"; with no length, an indeterminate bar (53);
    • a .litertlm loads and chats on CPU on the phone where it failed, and whether the GPU backend initialises (54);
    • notifications off: after Back and a quick reopen, or a reopen minutes later, the model is still loaded and answers at once; with Idle timeout at 1 minute the model is unloaded in about two minutes, EngineService staying in adb shell dumpsys activity services com.turbollm until a later close (47, 55, 60);
    • Quit within a second of a short reply's end, then reopen: the reply is there (56);
    • Back with the notification on and a model loaded: chrome://inspect (debug build) lists no com.turbollm page and the renderer leaves adb shell ps -A | grep sandboxed; the UI loads on reopen (57);
    • emulator or rooted phone, model loaded, download running, Home, kill the renderer: the app, the download and the model stay and the UI comes back; three kill -SEGVs within a minute leave "The page keeps crashing." and a working Reload (58);
    • "Loading a model" and "Auto-tuning" in the shade; a tool set to Ask, a call to it, Home: "Waiting for your approval", and no TurboLLM:generation wake lock in adb shell dumpsys power; no "Generating" as a download ends (59);
    • nothing loaded, a swipe: 3 s later adb shell pidof com.turbollm prints 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): pidof prints nothing once the daemon is up, at least 3 s after the swipe; files/daemon.log has tether: libtether_exec.so self-test passed; with a model loaded, adb shell ps -A shows the engine with the app as its parent and no libtether_exec.so, and no engine is left after a force-stop, Quit or a kill -9 of the app (these pass on the cgroup kill too; only the tether: line tests the helper) (60-63);
    • adb logcat -s TurboLLM has 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).
    • an LFM2 .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).
  • As usual, nothing is uploaded until you approve the Play-alpha job.

Known, not fixed here

  • Backups carry plaintext tokens (needs a decision): cloud backup and device transfer include files/.turbollm/config.json, with every third-party token entered (15), and app_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. Excluding config.json would lose every setting on restore, and app_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.
  • Images in replies and model cards load from the host they name with no approval (44 discloses it); the behaviour is TurboLLM#287's "Markdown images in chat", an owner decision.
  • Privacy notes, item 2: it does not yet list the Hugging Face requests from the Library's model details (Discover's repo dialog), Auto-tune's model-card fetches or Settings' Hugging Face token check (all user-started).
  • Other malformed .litertlm headers: 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.
  • WebView downloads: blob: downloads (chat export and similar) are still unsupported: the WebView has no DownloadListener; a product decision.
  • Engines have no auth: llama-server and litert-lm-server on 127.0.0.1:8081+ take no key, so another app on the phone can run inference on either (the app secret covers only the daemon). Since 30 litert-lm-server refuses cross-site and DNS-rebinding requests; llama-server does not, so in a browser that lets pages reach loopback (Firefox for Android does) a web page can still use it. A fix (a per-launch engine key or Unix sockets in the app's private storage) needs C++ changes in litert-lm-server, a native rebuild for both ABIs and device testing. A client that never reads is cut off (20) and running out of threads no longer kills the server (32); one that keeps reading very slowly can still hold the engine for long.
  • Refused requests are read whole first (litert-lm-server, older): the server reads every body whole, up to 256 MiB, before routing, so a request 30 refuses is read in full before its 403; a web page can still make it buffer that much per request.
  • Other fields of the wrong JSON type (litert-lm-server, older): a message's role: null, a tool's "type": null or a tool call's "id": null still gets a 500 instead of a 400; 46 fixes only stream and include_usage.
  • App proof window: the proof and the request still go out on two connections (never pooled since 10, never proxied since 16). What remains is the LAN-toggle rebind: an app that binds port 7000 and lets go within the few ms while the daemon rebinds, just as a native request is made, could still get the secret. One socket for both would close that too; since 45 the proof is hand-written on its own socket, but 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 with POST /api/v1/keys. An authenticated API port change leaves the WebView on 7000, whose cookie then goes to whatever binds 7000 next.
  • LiteRT-LM on a phone (54): not yet run; GPU from the engine process is unverified: a /data executable's linker namespace searches only /system and /system_ext, so a vendor libOpenCL.so may 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).
  • The shim aborts on a call (54): none of its 8 functions returns; if LiteRT-LM ever calls one, the engine logs "liblitert-lm.so called ()…" and aborts, unloading the model. The build sees what the library imports, not whether the server's paths call it; on a bump, check the shim list it prints.
  • Notifications off (60): with no Quit, a close with something running keeps the process, nothing in the shade, also after the idle unload (none with Idle timeout 0), until a later close with nothing running or Android 13+'s active-apps list.
  • A close during start-up (60): one whose daemon has not proved itself within about 30 s (a slow first launch, a daemon dead at boot, the port taken) counts as something running.
  • A kill from outside the app (55, 56): Android reclaiming memory, a force-stop or a vendor task killer still loses what the daemon had not written: with TurboLLM#287 about the last second, and a streaming reply's text since its last 10 s save.
  • Download notification (53): a size or speed at an exact decimal tie may differ in its last digit from the web panel's; with an import and a download running, the unchanged "Importing models" notification is reposted silently every 3 s.
  • Renderer deaths (58): the UI reloads from the start, not on the screen it showed; Android 7 (API 24, 25) has no onRenderProcessGone: nothing changes there.
  • Engine channel off and on again after leaving the app: the cancelled notification is not posted again until the next state change or the app comes back on screen (since 60 at any interval).
  • DevTools on release-type builds: 25 adds no opt-in property for WebView debugging there; nothing in the repo needs one.
  • 60-65 on a device: unverified: the boot window, Back during the splash on Android 7-11, the shade at Quit, the 3 s localStorage hold, the helper under SELinux and on arm64, 16 KB-page devices, the /system/bin/sh watchers (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.
  • 66-80 on a device: unverified: the cost of LFM2's per-conversation reload on the GPU (67 gives the CPU's); an encoder's memory and load time on the GPU (LFM2.5-VL, Gemma 3n and 4) and whether a GPU-only encoder loads; that the GPU sampler takes the vocabulary's size as top_k (no limit) and a top_k such as 100 (one it can't take fails with the runtime's error, a 500 or stream error, not a 400); whether each conversation's sampler takes effect on a GPU engine that already served one (0.18.0's executor code suggests the first stays, yet on one CPU engine each conversation's did; LFM2 gets a new engine anyway); a GPU-only bundle (gemma-4-E2B-it-gpu) with no backend set loading on the GPU, with no sampler unless a sampling field is sent, and falling back only to a backend its file lists; the window reported on a GPU with no size asked (at most 4096) and for a dynamic-context file (stand-in tests only).
  • LiteRT-LM 0.18.0 limits (66-80): LFM2's short-convolution state carries across conversations on one engine (#3165) and the C API can't reset it (hence 67's reload); continuing a conversation isn't token-equivalent to rendering it fresh, so none is reused; nothing in a file tells a model that never thinks (LFM2.5-230M) from one that always does (LFM2.5-1.2B-Thinking), which a budget of 0 can't stop (hence 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.
  • Desktop and Termux run Google's Python litert-lm serve, which has none of 66-80's fixes (TurboLLM#287 lists its limits).
  • HEIGHT_FIX_JS's limits (64), only where pages still lay out at height 0: vh in 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.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
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.
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.
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.
@ciroprogamer ciroprogamer changed the title Review fixes: LiteRT-LM server crashes, per-launch app secret, backups, downloads Review fixes: LiteRT-LM server crashes and model defaults, app secret, backups, downloads Oct 26, 2026
@ciroprogamer
ciroprogamer force-pushed the feat/relaxed-ptolemy-3rpd21 branch from aafba21 to 424a925 Compare October 26, 2026 00:08

@mohitsoni48 mohitsoni48 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ,gpu silently gets the CPU engine.
  • main.cc ~2596: the getppid()==1 check refuses to start a server legitimately run as PID 1 (e.g. a container entrypoint). Not an issue on Android.
  • The FileInfo cache 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 as float, 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:88 reads the proof until EOF, ParseChunk uses unbounded json::parse, the WebSocket path bypasses the port block, and the poll makes four loopback connections.

Generated by Claude Code

@ciroprogamer
ciroprogamer merged commit 16b3763 into main Oct 26, 2026
1 check was waiting
ciroprogamer added a commit that referenced this pull request Oct 26, 2026
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.
ciroprogamer added a commit that referenced this pull request Oct 26, 2026
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.
ciroprogamer added a commit that referenced this pull request Oct 26, 2026
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.
ciroprogamer added a commit that referenced this pull request Oct 26, 2026
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.
ciroprogamer added a commit that referenced this pull request Oct 26, 2026
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.
ciroprogamer added a commit that referenced this pull request Oct 26, 2026
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.
ciroprogamer added a commit that referenced this pull request Oct 26, 2026
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.
ciroprogamer added a commit that referenced this pull request Oct 26, 2026
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.
ciroprogamer added a commit that referenced this pull request Oct 26, 2026
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.
ciroprogamer added a commit that referenced this pull request Oct 26, 2026
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.
ciroprogamer added a commit that referenced this pull request Oct 26, 2026
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.
ciroprogamer added a commit that referenced this pull request Oct 26, 2026
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.
ciroprogamer added a commit that referenced this pull request Oct 26, 2026
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.
ciroprogamer added a commit that referenced this pull request Oct 26, 2026
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.
ciroprogamer added a commit that referenced this pull request Oct 26, 2026
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.
mohitsoni48 added a commit that referenced this pull request Oct 31, 2026
Follow-ups to #8: backend fallback, container start, model file cache, float range, chunk depth, poll

This branch is waiting to be deployed

1 waiting deployment
play-alpha — 424a9254 Waiting Oct 26, 2026 by ciroprogamer via publish-alpha #31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants