This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 7683971ca912 chore: camel-jbang - the Ollama tab no longer flickers 
with a model on MLX
7683971ca912 is described below

commit 7683971ca91273823cb427238e17ded6828b0381
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 15 08:25:04 2026 +0200

    chore: camel-jbang - the Ollama tab no longer flickers with a model on MLX
    
    Ollama runs a model with safetensors weights on MLX in its own runner
    (`ollama runner`), which answers /slots with 404. The Ollama tab took any
    failed slot read as the runner having exited and dropped it; the process
    scan found it again a second later, so the header, the throughput
    sparkline and the host panel switched between the live and the
    per-request view every couple of seconds.
    
    A runner that answers /slots with 404 or 501 is now kept, without live
    state, and not asked again; a runner that does not answer is dropped only
    once its process is gone. The sparkline follows the slot state instead of
    the runner, the context panel says the runner has no live state, and
    tui_get_ollama reports it as runner.liveState.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../ROOT/pages/camel-jbang-tui-local-models.adoc   |   4 +-
 .../dsl/jbang/core/commands/tui/OllamaMonitor.java | 134 +++++++++++++++------
 .../dsl/jbang/core/commands/tui/OllamaTab.java     |  25 +++-
 .../src/main/resources/tui/help/ollama.md          |  28 +++--
 .../jbang/core/commands/tui/OllamaMonitorTest.java | 104 ++++++++++++++++
 .../core/commands/tui/OllamaTabRenderTest.java     |  21 ++++
 .../jbang/core/commands/tui/SourceSizeTest.java    |   2 +-
 7 files changed, 262 insertions(+), 56 deletions(-)

diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-local-models.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-local-models.adoc
index 7bb1780e23e4..c2e13667d71c 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-local-models.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-local-models.adoc
@@ -201,7 +201,9 @@ not the phase split.
 The live figures, the context panel and the host panel need the model runner 
on the same machine:
 Ollama starts a `llama-server` process per loaded model and the tab reads its 
slot state a few times a
 second. Against a remote or containerised Ollama the tab keeps the model, 
per-request and session data
-and says which panels are unavailable.
+and says which panels are unavailable. A model with safetensors weights that 
Ollama runs on MLX (Apple
+silicon) is served by Ollama's own runner instead, which has no slot state: 
the throughput and context
+panels then show the figures of the last request.
 
 Press *r* to reset the request log and the session totals, *F5* to refresh 
immediately. The same data
 is available to AI agents through the `tui_get_ollama` MCP tool. For what the 
figures mean for the AI
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java
index 31a5015ae815..d27d5dab5daa 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java
@@ -450,11 +450,15 @@ final class OllamaMonitor {
         }
     }
 
-    /** Immutable view for rendering and for the MCP tool. */
+    /**
+     * Immutable view for rendering and for the MCP tool. {@code 
runnerWithoutSlots} is set when the runner has no slot
+     * state to read (Ollama's MLX runner), so the live figures stay empty and 
the requests carry the numbers.
+     */
     record Snapshot(ServerInfo server, List<LoadedModel> models, List<String> 
installed, SlotState slot,
-            RunnerInfo runner, HostStats host, List<RequestEntry> requests, 
double liveDecodeRate,
-            double livePrefillRate, long[] decodeHistory, SessionTotals 
totals, String lastError, Instant lastPoll,
-            String probedUrl, int panelWindow, int panelBudget, ActiveQuestion 
activeQuestion) {
+            RunnerInfo runner, boolean runnerWithoutSlots, HostStats host, 
List<RequestEntry> requests,
+            double liveDecodeRate, double livePrefillRate, long[] 
decodeHistory, SessionTotals totals,
+            String lastError, Instant lastPoll, String probedUrl, int 
panelWindow, int panelBudget,
+            ActiveQuestion activeQuestion) {
 
         boolean connected() {
             return server != null;
@@ -483,6 +487,7 @@ final class OllamaMonitor {
     private final Set<String> shapeAttempted = new HashSet<>();
     private SlotState slot;
     private RunnerInfo runner;
+    private boolean runnerWithoutSlots;
     private long serverPid;
     private HostStats host;
     private final Deque<RequestEntry> requests = new ArrayDeque<>();
@@ -558,6 +563,7 @@ final class OllamaMonitor {
                 server = null;
                 lastVersion = 0;
                 runner = null;
+                runnerWithoutSlots = false;
                 slot = null;
             }
         }
@@ -778,12 +784,25 @@ final class OllamaMonitor {
     void updateRunner(RunnerInfo info) {
         synchronized (lock) {
             runner = info;
+            runnerWithoutSlots = false;
             if (info == null) {
                 slot = null;
             }
         }
     }
 
+    /**
+     * The runner answered {@code /slots} with 404 or 501: it has no slot 
state (Ollama runs MLX models in its own
+     * runner, not in llama-server). It stays the known runner, for the header 
and the host panel, until the process
+     * scan no longer finds it, and is not asked again.
+     */
+    void runnerHasNoSlots() {
+        synchronized (lock) {
+            runnerWithoutSlots = true;
+            slot = null;
+        }
+    }
+
     /** Applies a runner slot sample; the decode and prefill rate windows 
advance from its counters. */
     void updateSlot(SlotState state) {
         updateSlot(state, MonotonicClock.millis());
@@ -823,7 +842,7 @@ final class OllamaMonitor {
         synchronized (lock) {
             long now = MonotonicClock.millis();
             return new Snapshot(
-                    server, models, installed, slot, runner, host, 
List.copyOf(requests),
+                    server, models, installed, slot, runner, 
runnerWithoutSlots, host, List.copyOf(requests),
                     decodeWindow.ratePerSecond(now), 
prefillWindow.ratePerSecond(now),
                     decodeHistory.clone(), totals, lastError, lastPoll, 
baseUrl, panelWindow, panelBudget,
                     activeQuestion);
@@ -942,31 +961,48 @@ final class OllamaMonitor {
                 current = found;
             }
         }
-        if (current != null) {
-            // llama-server logs every request at the verbosity Ollama starts 
it with, so the slot is read only as
-            // often as the live view needs: fast while it generates, slowly 
while idle
-            boolean busy;
-            synchronized (lock) {
-                busy = slot != null && slot.processing();
-            }
-            long slotInterval = busy ? SLOT_BUSY_INTERVAL_MS : 
SLOT_IDLE_INTERVAL_MS;
-            if (now - lastSlotPoll >= slotInterval) {
-                lastSlotPoll = now;
-                JsonArray slots = getJsonArray("http://127.0.0.1:"; + 
current.port() + "/slots");
-                if (slots != null) {
-                    updateSlot(OllamaParsers.parseSlots(slots, Instant.now()));
-                } else {
-                    updateRunner(null);
-                    current = null;
-                }
-            }
-        }
+        current = pollSlots(now, current);
         if (now - lastHost >= HOST_INTERVAL_MS) {
             lastHost = now;
             updateHost(probeHost(now, current));
         }
     }
 
+    /**
+     * Reads the runner's slot state when it is due and returns the runner 
still known. llama-server logs every request
+     * at the verbosity Ollama starts it with, so the slot is read only as 
often as the live view needs: fast while it
+     * generates, slowly while idle, and never again from a runner without 
slots. A runner that does not answer is
+     * dropped only once its process is gone: dropping it on any failed read 
made the process scan find it again a
+     * second later, and the panels flicker between the live and the 
per-request view.
+     */
+    RunnerInfo pollSlots(long now, RunnerInfo current) {
+        if (current == null) {
+            return null;
+        }
+        boolean busy;
+        boolean noSlots;
+        synchronized (lock) {
+            busy = slot != null && slot.processing();
+            noSlots = runnerWithoutSlots;
+        }
+        long slotInterval = busy ? SLOT_BUSY_INTERVAL_MS : 
SLOT_IDLE_INTERVAL_MS;
+        if (noSlots || now - lastSlotPoll < slotInterval) {
+            return current;
+        }
+        lastSlotPoll = now;
+        HttpResponse<String> response = send("http://127.0.0.1:"; + 
current.port() + "/slots", null);
+        if (parseJson(response) instanceof JsonArray slots) {
+            updateSlot(OllamaParsers.parseSlots(slots, Instant.now()));
+        } else if (response != null && (response.statusCode() == 404 || 
response.statusCode() == 501)) {
+            // 404 from Ollama's MLX runner, 501 from a llama-server started 
without the endpoint
+            runnerHasNoSlots();
+        } else if 
(!ProcessHandle.of(current.pid()).map(ProcessHandle::isAlive).orElse(false)) {
+            updateRunner(null);
+            return null;
+        }
+        return current;
+    }
+
     private ModelShape shapeFor(String base, String name) {
         synchronized (lock) {
             ModelShape cached = shapes.get(name);
@@ -1136,17 +1172,17 @@ final class OllamaMonitor {
         return parsed instanceof JsonObject jo ? jo : null;
     }
 
-    private JsonArray getJsonArray(String url) {
-        Object parsed = getJson(url, null);
-        return parsed instanceof JsonArray ja ? ja : null;
-    }
-
     private JsonObject postJsonObject(String url, String body) {
         Object parsed = getJson(url, body);
         return parsed instanceof JsonObject jo ? jo : null;
     }
 
     private Object getJson(String url, String postBody) {
+        return parseJson(send(url, postBody));
+    }
+
+    /** The response to a GET, or to a POST of the JSON body; null when 
nothing answered in time. */
+    private HttpResponse<String> send(String url, String postBody) {
         try {
             HttpRequest.Builder b = 
HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofMillis(2500));
             if (postBody != null) {
@@ -1154,10 +1190,22 @@ final class OllamaMonitor {
             } else {
                 b.GET();
             }
-            HttpResponse<String> response = http.send(b.build(), 
HttpResponse.BodyHandlers.ofString());
-            if (response.statusCode() / 100 != 2 || response.body() == null || 
response.body().isBlank()) {
-                return null;
-            }
+            return http.send(b.build(), HttpResponse.BodyHandlers.ofString());
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            return null;
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /** The JSON body of a successful response, null for anything else. */
+    private static Object parseJson(HttpResponse<String> response) {
+        if (response == null || response.statusCode() / 100 != 2 || 
response.body() == null
+                || response.body().isBlank()) {
+            return null;
+        }
+        try {
             return Jsoner.deserialize(response.body());
         } catch (Exception e) {
             return null;
@@ -1262,13 +1310,7 @@ final class OllamaMonitor {
         root.put("live", live);
 
         if (s.runner() != null) {
-            JsonObject runner = new JsonObject();
-            runner.put("pid", s.runner().pid());
-            runner.put("port", s.runner().port());
-            runner.put("executable", s.runner().executable());
-            runner.put("contextSize", s.runner().contextSize());
-            runner.put("parallel", s.runner().parallel());
-            root.put("runner", runner);
+            root.put("runner", runnerJson(s));
         }
         if (s.host() != null) {
             JsonObject hostJson = new JsonObject();
@@ -1441,6 +1483,18 @@ final class OllamaMonitor {
         return j;
     }
 
+    private static JsonObject runnerJson(Snapshot s) {
+        JsonObject j = new JsonObject();
+        j.put("pid", s.runner().pid());
+        j.put("port", s.runner().port());
+        j.put("executable", s.runner().executable());
+        j.put("contextSize", s.runner().contextSize());
+        j.put("parallel", s.runner().parallel());
+        // false for a runner without slot state (Ollama's MLX runner): "live" 
then stays empty
+        j.put("liveState", !s.runnerWithoutSlots());
+        return j;
+    }
+
     private static double round1(double v) {
         return Math.round(v * 10.0) / 10.0;
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTab.java
index 2cdd8149a0df..6d5f6ce2d6d5 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTab.java
@@ -362,18 +362,20 @@ class OllamaTab extends AbstractTab {
         Block block = 
Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title(" 
Throughput ").build();
         
frame.renderWidget(Paragraph.builder().text(Text.from(lines)).block(block).build(),
 area);
 
-        // sparkline on the remaining rows: live decode history with the 
runner, otherwise one bar per request
+        // sparkline on the remaining rows: live decode history from the 
runner's slot state, otherwise (a remote
+        // Ollama, or a runner without slots such as Ollama's MLX runner) one 
bar per request
         Rect inner = block.inner(area);
         int sparkY = inner.y() + lines.size();
         if (sparkY < inner.y() + inner.height() && inner.width() > 2) {
-            long[] data = s.runner() != null ? s.decodeHistory() : 
requestHistory(s.requests(), inner.width() - 2);
+            boolean liveHistory = s.slot() != null;
+            long[] data = liveHistory ? s.decodeHistory() : 
requestHistory(s.requests(), inner.width() - 2);
             String spark = sparkline(data, inner.width() - 2);
             if (!spark.isBlank()) {
                 frame.buffer().setString(inner.x() + 1, sparkY, spark, 
Theme.success());
             }
             int labelY = sparkY + 1;
             if (labelY < inner.y() + inner.height()) {
-                String label = s.runner() != null
+                String label = liveHistory
                         ? "decode tok/s, last " + 
(OllamaMonitor.HISTORY_POINTS / 2) + "s"
                         : "decode tok/s per request";
                 frame.buffer().setString(inner.x() + 1, labelY, label, 
Theme.muted().dim());
@@ -443,9 +445,7 @@ class OllamaTab extends AbstractTab {
             } else {
                 lines.add(Line.from(Span.styled(" no context data yet", 
Theme.muted())));
             }
-            lines.add(Line.from(Span.styled(s.local()
-                    ? " live state appears once the runner is found"
-                    : " live state needs the runner on this machine", 
Theme.muted().dim())));
+            lines.add(Line.from(Span.styled(liveStateNote(s), 
Theme.muted().dim())));
             Line trend = contextTrendLine(s, block.inner(area).width());
             if (trend != null) {
                 lines.add(trend);
@@ -456,6 +456,19 @@ class OllamaTab extends AbstractTab {
                 area);
     }
 
+    /** Why the context panel shows the last request instead of the runner's 
live state. */
+    private static String liveStateNote(Snapshot s) {
+        if (!s.local()) {
+            return " live state needs the runner on this machine";
+        }
+        if (s.runner() == null) {
+            return " live state appears once the runner is found";
+        }
+        return s.runnerWithoutSlots()
+                ? " the " + s.runner().executable() + " runner has no live 
state"
+                : " live state appears once the runner answers";
+    }
+
     /**
      * How full the context was on each of the last AI panel turns, oldest 
first, scaled to the whole window so the bars
      * are comparable across turns; a drop between bars is a compaction. Null 
when no turn has a known window.
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/ollama.md 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/ollama.md
index 20ae86aba47a..9a50745e5647 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/ollama.md
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/ollama.md
@@ -73,7 +73,8 @@ loads on the first request.
 - **session** — average decode rate, total input and output tokens and
   request count since the TUI started or since the last reset.
 - The sparkline is the live decode rate over the last 60 seconds when
-  the runner is on this machine, otherwise one bar per request.
+  the runner on this machine reports its live state, otherwise one bar
+  per request.
 
 ## Context
 
@@ -100,8 +101,9 @@ loads on the first request.
   it), else the window (yellow from 50%, red from 80%): that is when
   `/compact` in the AI panel, or a smaller toolset, pays off.
 
-Without the local runner the panel falls back to the last request's
-tokens against the model's context length.
+Without the runner's live state (a remote Ollama, or a model on MLX)
+the panel falls back to the last request's tokens against the model's
+context length.
 
 ## Host
 
@@ -167,14 +169,24 @@ live figures, the context panel's live state and the host 
panel need
 the runner on this machine; against a remote host or a container the
 tab says so and keeps the rest.
 
+## Models on MLX
+
+On Apple silicon Ollama runs a model with safetensors weights on MLX,
+in its own runner (`ollama runner`) rather than llama-server. That
+runner has no slot state to read: decode and prefill are the figures
+of the last request, the sparkline has one bar per request, and the
+context panel says the runner has no live state. The host panel and
+the request log work as for any other model.
+
 ## How often the tab reads
 
 While the tab is showing, the runner's slot state is read twice a
-second during generation and every two seconds when idle, the Ollama
-API once a second and the host probes once a second. With the tab
-closed only a version probe every ten seconds keeps the More menu
-entry current. Each read shows up in Ollama's own log at the verbosity
-it starts the runner with.
+second during generation and every two seconds when idle (a runner
+without slot state is asked once), the Ollama API once a second and
+the host probes once a second. With the tab closed only a version
+probe every ten seconds keeps the More menu entry current. Each read
+shows up in Ollama's own log at the verbosity it starts the runner
+with.
 
 ## Keyboard Shortcuts
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java
index 0591699e51fc..d336ffa6be97 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java
@@ -16,22 +16,33 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.tui;
 
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
 import java.time.Instant;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
 
+import com.sun.net.httpserver.HttpHandler;
+import com.sun.net.httpserver.HttpServer;
 import org.apache.camel.component.ai.observability.GenAiAttributes;
 import org.apache.camel.dsl.jbang.core.commands.LlmClient;
 import org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.RequestEntry;
 import 
org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.RequestSource;
+import org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.RunnerInfo;
 import org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.ServerInfo;
 import org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.SlotState;
 import org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.Snapshot;
 import org.apache.camel.util.json.JsonArray;
 import org.apache.camel.util.json.JsonObject;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -40,6 +51,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class OllamaMonitorTest {
 
+    /** What llama-server's {@code /slots} answers while it generates. */
+    private static final String SLOTS = """
+            
[{"id":0,"n_ctx":262144,"is_processing":true,"n_prompt_tokens":25,"n_prompt_tokens_processed":25,
+            
"n_prompt_tokens_cache":9,"next_token":[{"has_next_token":true,"n_decoded":6}]}]
+            """;
+
     /** The usage Ollama returned for a real warm request: 16 prompt tokens in 
188 ms, 6 tokens in 81 ms. */
     private static final LlmClient.TokenUsage WARM = new 
LlmClient.TokenUsage(16, 6, 22, 0, 188, 81, 12, 300);
     /** And a cold one: 11.1 s spent loading the model first. */
@@ -477,6 +494,93 @@ class OllamaMonitorTest {
         assertEquals(0, ((JsonArray) json.get("requests")).size());
     }
 
+    @ParameterizedTest
+    @ValueSource(ints = { 404, 501 })
+    void aRunnerWithoutSlotsStaysKnownAndIsNotAskedAgain(int status) throws 
IOException {
+        // Ollama's MLX runner answers /slots with 404, a llama-server started 
without the endpoint with 501
+        AtomicInteger reads = new AtomicInteger();
+        HttpServer endpoint = runnerEndpoint(exchange -> {
+            reads.incrementAndGet();
+            exchange.sendResponseHeaders(status, -1);
+            exchange.close();
+        });
+        try {
+            OllamaMonitor monitor = new OllamaMonitor();
+            RunnerInfo runner = new RunnerInfo(
+                    ProcessHandle.current().pid(), 
endpoint.getAddress().getPort(), 0, 0, "qwen3.6:35b-mlx", "ollama");
+            monitor.updateRunner(runner);
+
+            // the runner stays, on this poll and the later ones, so the tab 
does not flip between its views
+            assertThat(monitor.pollSlots(10_000, runner)).isEqualTo(runner);
+            assertThat(monitor.pollSlots(20_000, runner)).isEqualTo(runner);
+            assertThat(monitor.pollSlots(30_000, runner)).isEqualTo(runner);
+            assertThat(reads).hasValue(1);
+            Snapshot s = monitor.snapshot();
+            assertThat(s.runner()).isEqualTo(runner);
+            assertThat(s.runnerWithoutSlots()).isTrue();
+            assertThat(s.slot()).isNull();
+            assertThat(((JsonObject) 
monitor.toJson(1).get("runner")).get("liveState")).isEqualTo(false);
+
+            // the runner of the next model is asked again
+            monitor.updateRunner(new RunnerInfo(runner.pid(), runner.port(), 
0, 0, "qwen3.8:27b-mlx", "ollama"));
+            assertThat(monitor.snapshot().runnerWithoutSlots()).isFalse();
+        } finally {
+            endpoint.stop(0);
+        }
+    }
+
+    @Test
+    void aRunnerThatStopsAnsweringIsDroppedOnlyOnceItsProcessIsGone() throws 
Exception {
+        HttpServer endpoint = runnerEndpoint(exchange -> {
+            byte[] body = SLOTS.getBytes(StandardCharsets.UTF_8);
+            exchange.getResponseHeaders().add("Content-Type", 
"application/json");
+            exchange.sendResponseHeaders(200, body.length);
+            exchange.getResponseBody().write(body);
+            exchange.close();
+        });
+        int port = endpoint.getAddress().getPort();
+        OllamaMonitor monitor = new OllamaMonitor();
+        RunnerInfo alive = new RunnerInfo(ProcessHandle.current().pid(), port, 
0, 0, "/blob", "llama-server");
+        monitor.updateRunner(alive);
+        try {
+            assertThat(monitor.pollSlots(10_000, alive)).isEqualTo(alive);
+            assertThat(monitor.snapshot().slot()).isNotNull();
+            assertThat(((JsonObject) 
monitor.toJson(1).get("runner")).get("liveState")).isEqualTo(true);
+        } finally {
+            endpoint.stop(0);
+        }
+
+        // no answer while the process lives (a slow reply during a long 
prefill): the runner and its slot stay
+        assertThat(monitor.pollSlots(20_000, alive)).isEqualTo(alive);
+        assertThat(monitor.snapshot().runner()).isEqualTo(alive);
+        assertThat(monitor.snapshot().slot()).isNotNull();
+
+        // no answer and the process is gone (the model was unloaded): the 
runner goes
+        RunnerInfo exited = new RunnerInfo(exitedPid(), port, 0, 0, "/blob", 
"llama-server");
+        monitor.updateRunner(exited);
+        assertThat(monitor.pollSlots(30_000, exited)).isNull();
+        assertThat(monitor.snapshot().runner()).isNull();
+        assertThat(monitor.snapshot().slot()).isNull();
+    }
+
+    /** A runner's HTTP endpoint on a free port of this machine, answering 
{@code /slots} with the handler. */
+    private static HttpServer runnerEndpoint(HttpHandler slots) throws 
IOException {
+        HttpServer server = HttpServer.create(new 
InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext("/slots", slots);
+        server.start();
+        return server;
+    }
+
+    /** The pid of a process that has exited. */
+    private static long exitedPid() throws Exception {
+        Process process = new 
ProcessBuilder(ProcessHandle.current().info().command().orElseThrow(), 
"-version")
+                .redirectErrorStream(true)
+                .redirectOutput(ProcessBuilder.Redirect.DISCARD)
+                .start();
+        assertThat(process.waitFor(30, TimeUnit.SECONDS)).isTrue();
+        return process.pid();
+    }
+
     private static SlotState slot(boolean processing, long decoded) {
         return new SlotState(processing, 25, 25, 9, decoded, 262144, 
"draft-mtp", 1, Instant.now());
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTabRenderTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTabRenderTest.java
index 3a4dd722fb76..fe465fa141f8 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTabRenderTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTabRenderTest.java
@@ -42,6 +42,7 @@ import org.apache.camel.util.json.JsonObject;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -136,6 +137,26 @@ class OllamaTabRenderTest {
         assertTrue(rendered.contains("working"), rendered);
     }
 
+    @Test
+    void aRunnerWithoutSlotsShowsTheRequestsInsteadOfLiveState() {
+        // a model on MLX: Ollama serves it from its own runner, which has no 
slot state
+        localServerWithModel();
+        monitor.updateRunner(new RunnerInfo(36380, 52162, 0, 0, 
"qwen3.6:35b-mlx", "ollama"));
+        monitor.runnerHasNoSlots();
+        monitor.recordRequest("qwen3.6:35b-mlx", new LlmClient.TokenUsage(
+                5_900, 445, 6_345, 4_300, 1_900, 7_100, 10,
+                9_000), 0, "stop", 1, "why is the rate about 10 msg/m");
+
+        String rendered = TuiTestHelper.renderToString(new OllamaTab(ctx, 
monitor), 200, 40);
+        assertThat(rendered)
+                .contains("runner ollama :52162")
+                .contains("last request")
+                .contains("decode tok/s per request")
+                .doesNotContain("last 60s")
+                .contains("the ollama runner has no live state")
+                .doesNotContain("once the runner is found");
+    }
+
     @Test
     void remoteServerHidesHostPanelAndExplainsMissingLiveState() {
         monitor.updateServer(new ServerInfo("http://gpu-box.lan:11434";, 
"0.33.3", false));
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java
index be069c04c37a..b4dcf6c0f05b 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java
@@ -71,7 +71,7 @@ class SourceSizeTest {
             LogTab.render 173
             MemoryLeakTab.java 2033
             NetworkTab.renderContent 162
-            OllamaMonitor.toJson 206
+            OllamaMonitor.toJson 200
             OverviewTab.buildOverviewTable 185
             OverviewTab.renderThroughputChart 158
             RoutesTab.renderProcessors 179

Reply via email to