statusline: sqlite3 calls SIGABRT on Android/PRoot (ANDROID_TZDATA_ROOT unset) — Vectors/HNSW silently pinned at 0
- Dominant language
- TypeScript
- Stars
- 72.7k
- Forks
- 8.6k
- Avg merge
- 2d 23h
- Merged PRs (30d)
- 83
Description
## Summary
On Android (Termux + PRoot Linux), every `sqlite3` call made by `.claude/helpers/statusline.cjs` dies with `SIGABRT`. The failure is completely silent to the user: `safeExec()` swallows it, so the statusline shows **Vectors 0 / HNSW off forever**, while the Android crash buffer fills with two aborted `sqlite3` processes on every statusline refresh.
The `sqlite3` binary and the database are both fine. The cause is a missing environment variable that PRoot does not export.
## Environment
- ruflo `3.30.2`
- node `v24.18.0`
- Android 16, arm64, rooted, Termux + `proot-distro` Ubuntu
- `/system/bin/sqlite3` → SQLite `3.44.3`
## Root cause
Android's bionic libc **hard-aborts** any platform binary that touches tzdata/ICU when `ANDROID_TZDATA_ROOT` is unset. SQLite touches it while preparing a statement. PRoot does not export the Android runtime roots, so:
```console
$ sqlite3 --version
3.44.3 2024-03-24 ... # fine
$ sqlite3 db.sqlite ".tables"
# fine (dot-command, no SQL prepared)
$ sqlite3 :memory: "SELECT 1;"
Aborted # exit 134 — no file involved at all
```
That last line is the minimal reproducer: no database, no filesystem, no WAL. The real error appears **only in logcat**, never on stderr:
```
E sqlite3 : ANDROID_TZDATA_ROOT environment variable not set
F libc : Fatal signal 6 (SIGABRT), code -1 (SI_QUEUE) in tid N (sqlite3)
```
Setting the roots fixes it outright:
```console
$ ANDROID_TZDATA_ROOT=/apex/com.android.tzdata ANDROID_I18N_ROOT=/apex/com.android.i18n \
sqlite3 :memory: "SELECT 1;"
1
```
`ANDROID_TZDATA_ROOT` + `ANDROID_I18N_ROOT` are the minimum; `ANDROID_ROOT` + `ANDROID_DATA` keep other platform binaries happy.
## Why it is invisible
`safeExec()` returns `''` on any throw, so an abort is indistinguishable from a legitimately empty result:
```js
const hn = safeExec("sqlite3 'file:" + memDb + "?mode=ro' " + hnswSql, 1500);
if (hn) result.hasHnsw = (parseInt(hn, 10) || 0) > 0; // stays false forever
```
The `hasHnsw` flag is the clearest tell — it is pinned `false` even when `vector_indexes` has rows. `vectorCount` is worse, because a real `0` and a crashed query render identically.
Verified against the same DB with Python (`sqlite3` module reads it without complaint):
| | statusline (before) | ground truth |
|---|---|---|
| `SELECT COUNT(*) FROM vector_indexes` | *aborted* → `hasHnsw: false` | `2` → should be `true` |
| `SELECT COUNT(*) FROM memory_entries WHERE embedding IS NOT NULL` | *aborted* → `0` | `0` |
## Secondary impact
Each abort also fails to spawn Android's crash_dump helper (`tombstoned: unexpected dump type: kDebuggerdAnyIntercept`), so the crash buffer accumulates untombstoned `SIGABRT` pairs every statusline refresh (~2 per 10–30s). On my device this actively masked an unrelated real app crash I was trying to diagnose.
## Proposed fix
Set the env at the `execSync` layer, gated on Android so it is inert elsewhere. Module scope, just after the requires:
```js
// Android/Termux-PRoot: Android platform binaries (/system/bin/sqlite3 and any
// other bionic binary that touches tzdata/ICU) abort() with SIGABRT the moment
// they run SQL if these roots are unset -- and PRoot does not export them.
// Gated on the apex dir existing, so this is inert off-Android. Existing values
// always win, so a correct ambient env is never overridden.
const EXEC_ENV = (() => {
const e = Object.assign({}, process.env);
try {
if (fs.existsSync('/apex/com.android.tzdata')) {
e.ANDROID_ROOT = e.ANDROID_ROOT || '/system';
e.ANDROID_DATA = e.ANDROID_DATA || '/data';
e.ANDROID_TZDATA_ROOT = e.ANDROID_TZDATA_ROOT || '/apex/com.android.tzdata';
e.ANDROID_I18N_ROOT = e.ANDROID_I18N_ROOT || '/apex/com.android.i18n';
}
} catch { /* not Android, or /apex unreadable -- leave env untouched */ }
return e;
})();
```
and in `safeExec()`:
```js
return execSync(cmd, {
encoding: 'utf-8',
timeout: timeoutMs || 2000,
stdio: ['pipe', 'pipe', 'pipe'],
env: EXEC_ENV, // <-- added
windowsHide: true,
}).trim();
```
Fixing it in `safeExec` rather than on the two `sqlite3` command strings covers any other platform binary the statusline may shell out to later.
Applied locally and verified in a shell with the vars deliberately unset: `hasHnsw` flipped `false` → `true`, and the crash buffer stayed at **0** aborts across a full statusline run.
## Optional hardening (separate concern)
`safeExec`'s bare `catch { return ''; }` turning a `SIGABRT` into an empty string is what made this a silent multi-month zero rather than a visible error. Distinguishing "command failed" from "command returned nothing" — even just a debug log behind an env flag — would have surfaced this immediately.
Contributor guide
Research direction
Start in .claude/helpers/statusline.cjs, especially safeExec() and the two sqlite3 calls that query vector_indexes and memory_entries. Reproduce the Android/PRoot failure with the minimal sqlite3 query or run the statusline with the Android variables unset, then verify that HNSW and vector counts are read correctly and no SIGABRTs occur during a full statusline run.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, javascript, node.js, sqlite
- Domain
- cli, mobile-dev, tooling
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100