Skip to content

Commit 58795c4

Browse files
committed
chore(release): v0.1.1 — sidecar lifecycle, memory-cap auto-sync, RAM indicator
Three user-facing fixes landing as v0.1.1, all in the Tauri control plane (no server / inference changes). * fix(lumen-app): kill sidecar lumen-server on app exit - Tauri's RunEvent::ExitRequested fires on the event-loop thread and Tauri 2 may std::process::exit() before any tokio runtime drop, so `kill_on_drop(true)` on the spawned `tokio::process::Child` never actually delivers the kill. The orphaned lumen-server kept the port and the model's wired RAM resident across app launches. - New synchronous `ServerSupervisor::shutdown_blocking()` grabs the PID via try_lock and sends SIGTERM directly through `nix::kill`, polls every 100 ms for up to 3 s, then SIGKILL fallback. No tokio runtime dependency — works after the runtime has been torn down. - main.rs switches from `.run(ctx)` to `.build(ctx).run(closure)` so we can hook ExitRequested and call shutdown_blocking() before the process exits. * fix(lumen-app): reclaim port at server start when prior sidecar leaked - Defense-in-depth for the case where shutdown_blocking() never ran (hard force-quit via Activity Monitor, panic mid-cleanup, etc.). On `start()` we run `lsof -nP -iTCP:PORT -sTCP:LISTEN -t` and, for each listening PID, verify via `ps -o comm=` that argv0 contains "lumen-server" before sending SIGTERM/SIGKILL. Unrelated services on the same port are left alone — the spawn will fail loudly instead. * fix(lumen-app): auto-sync Metal memory caps to active model + ctx - The METAL MEMORY card displayed two different sets of numbers: a "tuned for <model> + ctx N (W/C/M GB)" hint computed from the active model + context size, and the live input values which held whatever `reset_memory_caps` had stored (system-default 70/2/85% of RAM). On a 24 GB Mac with an 11 GB model + ctx 8192, hint said 10.742 / 2.000 / 13.742 but inputs showed 16 / 2 / 20. - New `syncTunedMemoryCaps()` runs on (a) `setActive` (model change), (b) `saveContext` (ctx change), (c) `onMount` (heal stale caps from pre-feature sessions). `wired_limit_gb` cleared to null so the backend emits byte-exact LUMEN_WIRED_LIMIT_BYTES from the safetensors size — no GB-rounding that could truncate a 14.45 GB model to a 14 GB ceiling. No-op when already in sync or no active model is set. * feat(lumen-app): live system memory indicator in topbar - New `sysinfo::current_memory_usage()` parses `vm_stat`: used = (wired + active + compressor-occupied) × page_size — same formula Activity Monitor uses for "Memory Used". No new crate dep. - `get_memory_usage` Tauri command polled every 2 s alongside the existing metrics poll. - Topbar actions row renders a compact `X.X/Y GB` chip with an inline mini bar; >=80% turns warn-colored, >=92% turns hot. Helps the operator catch wired-limit/RAM-pressure issues before the OS starts paging or the GPU starts evicting weights. Version bumped in three places per docs/release.md: - crates/lumen-app/Cargo.toml 0.1.0 -> 0.1.1 - crates/lumen-app/tauri.conf.json 0.1.0 -> 0.1.1 - crates/lumen-app/frontend/package.json 0.1.0 -> 0.1.1
1 parent 6391b6b commit 58795c4

10 files changed

Lines changed: 302 additions & 7 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/lumen-app/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "lumen-app"
3-
version = "0.1.0"
3+
version = "0.1.1"
44
edition.workspace = true
55
rust-version.workspace = true
66
license.workspace = true

crates/lumen-app/frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "lumen-app-frontend",
33
"private": true,
4-
"version": "0.1.0",
4+
"version": "0.1.1",
55
"type": "module",
66
"scripts": {
77
"dev": "vite --port 5173 --strictPort",

crates/lumen-app/frontend/src/App.svelte

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
type Catalog,
1616
type RecommendedModel,
1717
type SystemInfo,
18+
type MemoryUsage,
1819
type CorsMode,
1920
} from "./lib/api";
2021
import EnvOverrides from "./lib/EnvOverrides.svelte";
@@ -55,6 +56,7 @@
5556
let typedEnvKeys = $state<Set<string>>(new Set());
5657
let catalog = $state<Catalog>({ families: [], recommended: [], embeddings: [] });
5758
let systemInfo = $state<SystemInfo | null>(null);
59+
let memoryUsage = $state<MemoryUsage | null>(null);
5860
let selectedRecommend = $state<string>("");
5961
6062
let visibleModels = $derived(models.filter((m) => m.supported));
@@ -169,6 +171,12 @@
169171
console.error("doctor run failed:", e);
170172
}
171173
174+
// Heal any stale memory caps left over from a previous session that
175+
// pre-dates the auto-tune feature (e.g. system-default 16/2/20 saved
176+
// before an active model existed). No-op if caps already match the
177+
// tuned recommendation or no model is active.
178+
await syncTunedMemoryCaps();
179+
172180
unlistenLog = await onLog((l) => {
173181
logs = [...logs.slice(-499), l];
174182
});
@@ -196,10 +204,19 @@
196204
}
197205
});
198206
207+
try {
208+
memoryUsage = await api.getMemoryUsage();
209+
} catch (e) {
210+
console.error("get_memory_usage failed:", e);
211+
}
212+
199213
pollHandle = setInterval(async () => {
200214
if (status.state === "running") {
201215
metrics = await api.serverMetrics();
202216
}
217+
try {
218+
memoryUsage = await api.getMemoryUsage();
219+
} catch {}
203220
}, 2000);
204221
});
205222
@@ -227,6 +244,32 @@
227244
async function setActive(id: string) {
228245
if (!config) return;
229246
config = await api.setActiveModel(id);
247+
// Without this, switching models leaves the previous model's caps in
248+
// place (e.g. 16 GB wired saved for a 16 GB model is wildly oversized
249+
// for an 11 GB one). Re-tune to track the new model + current ctx.
250+
await syncTunedMemoryCaps();
251+
}
252+
253+
/// Sync the persisted Metal memory caps to the tuned recommendation
254+
/// derived from the active model + current context. No-op when no active
255+
/// model is set (system defaults stay in place) or values already match.
256+
/// `wired_limit_gb = null` so the backend emits byte-exact
257+
/// `LUMEN_WIRED_LIMIT_BYTES` from `active_model.size_bytes` instead of a
258+
/// GB-rounded ceiling that could truncate a 14.45 GB model to 14 GB.
259+
async function syncTunedMemoryCaps() {
260+
if (!config || !activeModel) return;
261+
if (recommendedMemoryGb == null || recommendedCacheGb == null) return;
262+
const needsUpdate =
263+
config.server.wired_limit_gb !== null ||
264+
config.server.cache_limit_gb !== recommendedCacheGb ||
265+
config.server.memory_limit_gb !== recommendedMemoryGb ||
266+
config.server.disable_wired_limit;
267+
if (!needsUpdate) return;
268+
config.server.wired_limit_gb = null;
269+
config.server.cache_limit_gb = recommendedCacheGb;
270+
config.server.memory_limit_gb = recommendedMemoryGb;
271+
config.server.disable_wired_limit = false;
272+
config = await api.updateServerConfig(config.server);
230273
}
231274
232275
async function saveServer() {
@@ -244,6 +287,9 @@
244287
async function saveContext() {
245288
if (!config) return;
246289
config = await api.updateContextConfig(config.context);
290+
// ctx affects KV-cache headroom in the tuned memory recommendation
291+
// (~1 GB per 8K tokens). Re-sync so saved caps follow.
292+
await syncTunedMemoryCaps();
247293
}
248294
249295
async function resetMemoryCaps() {
@@ -307,6 +353,22 @@
307353
</div>
308354
<div class="actions">
309355
{#if statusMessage}<span class="dim">{statusMessage}</span>{/if}
356+
{#if memoryUsage}
357+
{@const usedGb = memoryUsage.used_bytes / 1024 ** 3}
358+
{@const totalGb = memoryUsage.total_bytes / 1024 ** 3}
359+
{@const pct = (usedGb / totalGb) * 100}
360+
<span
361+
class="mem-indicator mono"
362+
class:warn={pct >= 80 && pct < 92}
363+
class:hot={pct >= 92}
364+
title="System memory: {usedGb.toFixed(1)} / {totalGb.toFixed(0)} GB ({pct.toFixed(0)}%) — wired + active + compressor"
365+
>
366+
<span class="mem-bar">
367+
<span class="mem-bar-fill" style="width: {Math.min(100, pct).toFixed(1)}%"></span>
368+
</span>
369+
{usedGb.toFixed(1)}/{totalGb.toFixed(0)} GB
370+
</span>
371+
{/if}
310372
<button
311373
class="health"
312374
class:healthy={doctorReport?.overall === "healthy"}
@@ -1054,6 +1116,46 @@
10541116
margin-left: 2px;
10551117
}
10561118
1119+
.mem-indicator {
1120+
display: inline-flex;
1121+
align-items: center;
1122+
gap: 6px;
1123+
font-size: 11px;
1124+
color: var(--text-dim);
1125+
padding: 2px 8px;
1126+
border: 1px solid var(--border);
1127+
border-radius: 6px;
1128+
background: var(--panel);
1129+
}
1130+
.mem-indicator.warn {
1131+
color: var(--warn);
1132+
border-color: var(--warn);
1133+
}
1134+
.mem-indicator.hot {
1135+
color: var(--err);
1136+
border-color: var(--err);
1137+
}
1138+
.mem-bar {
1139+
display: inline-block;
1140+
width: 48px;
1141+
height: 6px;
1142+
background: var(--border);
1143+
border-radius: 3px;
1144+
overflow: hidden;
1145+
}
1146+
.mem-bar-fill {
1147+
display: block;
1148+
height: 100%;
1149+
background: var(--text-dim);
1150+
transition: width 0.4s ease-out;
1151+
}
1152+
.mem-indicator.warn .mem-bar-fill {
1153+
background: var(--warn);
1154+
}
1155+
.mem-indicator.hot .mem-bar-fill {
1156+
background: var(--err);
1157+
}
1158+
10571159
.grid {
10581160
display: grid;
10591161
grid-template-columns: repeat(3, minmax(0, 1fr));

crates/lumen-app/frontend/src/lib/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ export interface SystemInfo {
119119
recommended: MemoryDefaults;
120120
}
121121

122+
export interface MemoryUsage {
123+
used_bytes: number;
124+
total_bytes: number;
125+
}
126+
122127
export type LifecycleState = "stopped" | "starting" | "running" | "stopping" | "crashed";
123128

124129
export interface ServerStatus {
@@ -218,6 +223,7 @@ export const api = {
218223
openConfigDir: () => invoke<string>("open_config_dir"),
219224

220225
getSystemInfo: () => invoke<SystemInfo>("get_system_info"),
226+
getMemoryUsage: () => invoke<MemoryUsage | null>("get_memory_usage"),
221227
resetMemoryCaps: () => invoke<PersistentConfig>("reset_memory_caps"),
222228

223229
doctorRun: () => invoke<DoctorReport>("doctor_run"),

crates/lumen-app/src/commands.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use crate::doctor::{self, DoctorReport};
1111
use crate::models::{self, DownloadProgress, ModelEntry};
1212
use crate::server::{self, ServerStatus, TYPED_ENV_KEYS};
1313
use crate::state::AppState;
14-
use crate::sysinfo::{self, SystemInfo};
14+
use crate::sysinfo::{self, MemoryUsage, SystemInfo};
1515

1616
/// Wrap anyhow::Error → String so Tauri can serialize it for the frontend.
1717
type CmdResult<T> = Result<T, String>;
@@ -104,6 +104,14 @@ pub async fn get_system_info() -> CmdResult<SystemInfo> {
104104
Ok(sysinfo::probe())
105105
}
106106

107+
/// Live system memory snapshot for the topbar monitor. Returns `None` only
108+
/// if the `vm_stat` probe fails — the UI hides the indicator in that case
109+
/// rather than rendering nonsense.
110+
#[tauri::command]
111+
pub async fn get_memory_usage() -> CmdResult<Option<MemoryUsage>> {
112+
Ok(sysinfo::current_memory_usage())
113+
}
114+
107115
/// Reset the Metal memory caps in `ServerConfig` to RAM-aware defaults
108116
/// (70/20/85% of total installed RAM). Useful after the user manually
109117
/// fiddles with the caps and wants to restart from a known-good point.

crates/lumen-app/src/main.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,24 @@ fn main() {
4949
commands::server_metrics,
5050
commands::open_config_dir,
5151
commands::get_system_info,
52+
commands::get_memory_usage,
5253
commands::reset_memory_caps,
5354
commands::doctor_run,
5455
commands::doctor_fix,
5556
updater::check_for_updates,
5657
updater::install_update,
5758
updater::current_version,
5859
])
59-
.run(tauri::generate_context!())
60-
.expect("error while running Lumen");
60+
.build(tauri::generate_context!())
61+
.expect("error while building Lumen")
62+
.run(|app_handle, event| {
63+
// Tauri may std::process::exit() after this returns, which skips
64+
// tokio runtime drop and therefore skips Child::kill_on_drop on
65+
// the sidecar. Send SIGTERM → SIGKILL synchronously here so the
66+
// server's port + RAM are reclaimed before the app process dies.
67+
if let tauri::RunEvent::ExitRequested { .. } = event {
68+
let state: tauri::State<AppState> = app_handle.state();
69+
state.supervisor.shutdown_blocking();
70+
}
71+
});
6172
}

crates/lumen-app/src/server.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,13 @@ impl ServerSupervisor {
280280
let bin = resolve_binary(cfg.server_binary_path.as_deref())
281281
.context("resolve lumen-server binary path")?;
282282

283+
// If a previous app exit (or force-quit / crash) left an orphaned
284+
// lumen-server holding the configured port, reclaim it before
285+
// spawning the new one — otherwise bind() fails with EADDRINUSE.
286+
// Only kills processes whose argv0 name contains "lumen-server" to
287+
// avoid wiping unrelated services on the same port.
288+
reclaim_port_if_lumen_server(&cfg.server.host, cfg.server.port);
289+
283290
let mut cmd = Command::new(&bin);
284291
cmd.stdout(Stdio::piped())
285292
.stderr(Stdio::piped())
@@ -384,6 +391,41 @@ impl ServerSupervisor {
384391
Ok(self.status().await)
385392
}
386393

394+
/// Synchronous best-effort kill for app-exit cleanup. Tauri's
395+
/// `RunEvent::ExitRequested` fires on the event-loop thread and may
396+
/// `std::process::exit()` before any tokio runtime drop, so we can't
397+
/// rely on `kill_on_drop` or the async `stop()` path — both need a live
398+
/// runtime. Instead grab the PID via `try_lock` and send signals
399+
/// directly via `nix::kill`. SIGTERM → 3 s grace → SIGKILL fallback.
400+
/// Returns silently if no server is running or the lock is contended
401+
/// (treat both as "nothing to clean up").
402+
pub fn shutdown_blocking(&self) {
403+
use nix::sys::signal::{Signal, kill};
404+
use nix::unistd::Pid;
405+
406+
let pid = match self.inner.try_lock() {
407+
Ok(g) => g.pid,
408+
Err(_) => return,
409+
};
410+
let Some(pid) = pid else { return };
411+
let pid_t = Pid::from_raw(pid as i32);
412+
413+
if kill(pid_t, Signal::SIGTERM).is_err() {
414+
return;
415+
}
416+
417+
// Poll for exit every 100 ms up to 3 s. `kill(pid, 0)` returns Err
418+
// (ESRCH) once the process is reaped — that's our exit signal.
419+
for _ in 0..30 {
420+
std::thread::sleep(Duration::from_millis(100));
421+
if kill(pid_t, None).is_err() {
422+
return;
423+
}
424+
}
425+
426+
let _ = kill(pid_t, Signal::SIGKILL);
427+
}
428+
387429
pub async fn stop(&self, app: AppHandle) -> Result<ServerStatus> {
388430
let pid = {
389431
let mut g = self.inner.lock().await;
@@ -685,6 +727,77 @@ pub fn resolve_binary_public(explicit: Option<&Path>) -> Result<PathBuf> {
685727
resolve_binary(explicit)
686728
}
687729

730+
/// If `host:port` is occupied by a process whose argv0 name contains
731+
/// `lumen-server`, send it SIGTERM (then SIGKILL after a short grace) so
732+
/// `start()` can bind cleanly. No-op when:
733+
/// - nothing is listening on the port
734+
/// - the listener is some other process (we leave it alone; the spawn will
735+
/// fail loudly and surface as a port-collision error to the user)
736+
///
737+
/// macOS-only — uses `lsof` (always present on macOS) + `ps`. On other
738+
/// platforms this would need a different probe; Lumen ships Apple Silicon
739+
/// only so the cross-platform fork can wait.
740+
fn reclaim_port_if_lumen_server(host: &str, port: u16) {
741+
use std::process::Command as StdCommand;
742+
743+
let lsof_target = if host == "0.0.0.0" || host.is_empty() {
744+
format!("-iTCP:{}", port)
745+
} else {
746+
format!("-iTCP@{}:{}", host, port)
747+
};
748+
let out = match StdCommand::new("lsof")
749+
.args(["-nP", &lsof_target, "-sTCP:LISTEN", "-t"])
750+
.output()
751+
{
752+
Ok(o) if o.status.success() => o,
753+
_ => return,
754+
};
755+
let stdout = String::from_utf8_lossy(&out.stdout);
756+
let pids: Vec<i32> = stdout
757+
.lines()
758+
.filter_map(|l| l.trim().parse::<i32>().ok())
759+
.collect();
760+
if pids.is_empty() {
761+
return;
762+
}
763+
764+
use nix::sys::signal::{Signal, kill};
765+
use nix::unistd::Pid;
766+
767+
for pid in pids {
768+
// Confirm it's actually a lumen-server before killing.
769+
let ps_out = StdCommand::new("ps")
770+
.args(["-p", &pid.to_string(), "-o", "comm="])
771+
.output();
772+
let is_lumen = match ps_out {
773+
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
774+
.trim()
775+
.rsplit('/')
776+
.next()
777+
.map(|name| name.contains("lumen-server"))
778+
.unwrap_or(false),
779+
_ => false,
780+
};
781+
if !is_lumen {
782+
continue;
783+
}
784+
785+
let pid_t = Pid::from_raw(pid);
786+
let _ = kill(pid_t, Signal::SIGTERM);
787+
for _ in 0..20 {
788+
std::thread::sleep(Duration::from_millis(100));
789+
if kill(pid_t, None).is_err() {
790+
break;
791+
}
792+
}
793+
if kill(pid_t, None).is_ok() {
794+
let _ = kill(pid_t, Signal::SIGKILL);
795+
// brief settle so the kernel reclaims the socket before bind()
796+
std::thread::sleep(Duration::from_millis(200));
797+
}
798+
}
799+
}
800+
688801
/// Resolution order:
689802
/// 1. Explicit `cfg.server_binary_path` if set
690803
/// 2. Sibling binary in the .app bundle (Resources/lumen-server)

0 commit comments

Comments
 (0)