Skip to content
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
0c739d9
server: SSE replay buffer, survives client disconnect
ServeurpersoCom May 16, 2026
adff9a5
server: create stream session only after post_tasks succeeds
ServeurpersoCom May 17, 2026
026b654
server, ui: drop X-Stream-Resume, X-Conversation-Id alone enables the…
ServeurpersoCom May 17, 2026
2b1b3b7
server: drop magic 17, derive the X-Conversation-Id header length fro…
ServeurpersoCom May 17, 2026
00dd081
refactor: address review feedback from ngxson
ServeurpersoCom May 18, 2026
ee592f2
server-context: cleaning
ServeurpersoCom May 18, 2026
c5db9f6
server-stream: fix use-after-free on rd
ServeurpersoCom May 18, 2026
5e4ea04
ui: fix cross-conversation contamination
ServeurpersoCom May 18, 2026
14daa7f
server-http: keep request alive in detached SSE drain
ServeurpersoCom May 18, 2026
4e7c3a2
ui: address review feedback from coder543
ServeurpersoCom May 18, 2026
13eaa77
format
ServeurpersoCom May 18, 2026
d8e3300
ui: remove unused selectedModelName
ServeurpersoCom May 18, 2026
316c1d3
server-stream: poll session->is_cancelled() in stream_aware_should_stop
ServeurpersoCom May 18, 2026
4482543
server-stream, ui: replace GET /v1/streams with POST /v1/streams/lookup
ServeurpersoCom May 19, 2026
fd5c81a
ui: read conv ids from IndexedDB in syncRemoteRunningStreams
ServeurpersoCom May 19, 2026
54be28f
server-models: deduplicate stream lookup timeouts behind one constant
ServeurpersoCom May 19, 2026
a61cf96
ui: extract visibility kick grace into a stream constant, bump to 100…
ServeurpersoCom May 19, 2026
81c394f
make it safer & more simple
ngxson May 19, 2026
cc90c08
server-stream: survive client disconnect via stream_pipe::finish_prod…
ServeurpersoCom May 20, 2026
1b4d9a8
server-stream: drain disconnected streams on a manager owned thread
ServeurpersoCom May 20, 2026
4a5cbac
ui: add missing JSDoc
ServeurpersoCom May 20, 2026
cffee54
server-stream: drain on the http worker, drop the manager thread
ServeurpersoCom May 20, 2026
231e3a0
server-stream: split stream_pipe into producer and consumer classes
ServeurpersoCom May 20, 2026
4524ea7
server-stream: rename producer methods to unix pipe semantics
ServeurpersoCom May 20, 2026
52884f1
server, ui: route resumable streams via a conv map, persist resume id…
ServeurpersoCom May 21, 2026
eeb62dd
ui: resolve continue target by id to stop cross-conversation flash on…
ServeurpersoCom May 22, 2026
c572968
ui: skip stream resume when the abort is intentional
ServeurpersoCom May 22, 2026
374ddbf
server: move the conv id to model map into a self contained tracker
ServeurpersoCom May 23, 2026
8667f14
ui: replace stream magic values with enums and shared constants
ServeurpersoCom May 23, 2026
d5c85aa
ui: fold the stream resume and discovery helpers into ChatService
ServeurpersoCom May 23, 2026
7962c30
docs: document the SSE replay buffer in server README-dev
ServeurpersoCom May 26, 2026
99f8ae0
ui: align attachServerStream call with onCompletionId param in handle…
ServeurpersoCom Jun 2, 2026
2f4be74
server-http: rename del_ to del to match get and post
ServeurpersoCom Jun 8, 2026
5a3a981
ui: address review feedback from allozaur
ServeurpersoCom Jun 18, 2026
9ddbaf8
ui: drop duplicate SSE constants, keep sse.ts canonical
ServeurpersoCom Jun 25, 2026
6eaafab
ui: use svelte:document for the visibilitychange listener
ServeurpersoCom Jun 25, 2026
ac02404
server: trim redundant stream drain comments
ServeurpersoCom Jun 25, 2026
8b591a5
server: balance and clean up stream comments
ServeurpersoCom Jun 25, 2026
d346f8c
ui: balance and clean up stream comments
ServeurpersoCom Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions tools/server/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ add_library(${TARGET} STATIC
server-common.h
server-context.cpp
server-context.h
server-stream.cpp
server-stream.h
server-tools.cpp
server-tools.h
server-schema.cpp
Expand Down
54 changes: 54 additions & 0 deletions tools/server/README-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ The core architecture consists of the following components:
- `server_tokens`: Unified representation of token sequences (supports both text and multimodal tokens); used by `server_task` and `server_slot`.
- `server_prompt_checkpoint`: For recurrent (e.g., RWKV) and SWA models, stores snapshots of KV cache state. Enables reuse when subsequent requests share the same prompt prefix, saving redundant computation.
- `server_models`: Standalone component for managing multiple backend instances (used in router mode). It is completely independent of `server_context`.
- `stream_session_manager`: Process wide owner of resumable SSE stream sessions (`g_stream_sessions`), keyed by conversation id. Backs the replay buffer that lets a client reattach to a generation after an HTTP disconnect. See the "Resumable streaming" section below.

```mermaid
graph TD
Expand Down Expand Up @@ -117,6 +118,58 @@ Here is an example trace of an API request for text completion:
- As the response is stateless, `server_res_generator` calls `response->update()` to update the response with the current state.
- `server_res_generator` then calls `response->to_json()` and passes the response to the HTTP layer.

### Resumable streaming (SSE replay buffer)

By default a streaming generation is bound to its HTTP socket: when the socket drops (refresh, tab close, mobile background, transient network) the generation aborts and the live stream is lost. This feature keeps the generation running server side and lets a client reattach.

It is opt in via the `X-Conversation-Id` header on `POST /v1/chat/completions`. Without the header the OAI strict path is unchanged. The conversation id is the only identity end to end (server map key, client localStorage key, route path), with an optional `::model` suffix for direct routing in router mode.

The feature lives entirely in `server-stream.{h,cpp}` and rests on three types:

- `stream_session`: a bounded ring buffer (4 MiB cap, oldest bytes drop first) plus a condvar. `append` pushes raw SSE bytes, `read_from` drains from any offset and blocks for live bytes or finalize, `finalize` wakes readers, `cancel` stops the producer. One conv maps to at most one live session.
- `stream_session_manager` (`g_stream_sessions`): owns all sessions keyed by conv id, enforces the one conv one session invariant via `create_or_replace`, and runs a GC thread that drops completed sessions past their TTL.
- `stream_pipe_producer` / `stream_pipe_consumer`: the write and read ends. The producer owns the session lifetime and finalizes it on destruction; the consumer is read only and never finalizes, so a reader detaching cannot kill a running generation.

Producer side: `server_res_generator` attaches a producer pipe when the header is present. The HTTP content provider mirrors every chunk into the ring before writing it to the socket. While a pipe is attached, `stream_aware_should_stop` ignores peer disconnect, so a dropped socket does not stop generation: only an explicit `DELETE` does. When the peer leaves early, `on_complete` calls `close()`, which drains the rest of the generation into the ring on the http worker.

Lifetime safety: the producer pipe holds a shared `alive` flag also captured by the session cancel hook. `~server_res_generator` calls `cleanup()` to clear that hook while the reader is still alive, so a `cancel` arriving during teardown can never call `stop()` on a freed response. This ordering is the most fragile part of the feature: finalizing or destroying the producer before `cleanup()` runs reintroduces a use after free.

Consumer side: `GET /v1/stream/<conv_id>?from=N` opens a `text/event-stream` that replays buffered bytes from offset `N` and blocks for live bytes, so the browser reattaches like a fresh EventSource. An offset below the dropped prefix returns 400.

Routes:

- `GET /v1/stream/:conv_id?from=N`: replay or live reattach.
- `POST /v1/streams/lookup` with `{"conversation_ids": [...]}`: returns session status only for ids the caller already owns. There is no listing route, so live sessions cannot be enumerated (an earlier `GET /v1/streams` was removed for exactly this reason).
- `DELETE /v1/stream/:conv_id`: explicit Stop, idempotent (`evict_and_cancel`).

Router mode binds the same paths to proxy handlers. A `conv_id -> child` map (`conv_models`), populated when a POST is routed, resolves the owning child in one lookup with no polling. The lookup groups ids per child; GET and DELETE proxy straight to the owner. This loopback REST hop is expected to move to a websocket IPC later, swapping only the transport.

Lifecycle: `g_stream_sessions.start_gc()` runs in main after common init, `stop_gc()` runs first in `clean_up()` and finalizes every live session so no reader hangs. Reader blocking and the post drop drain both run on httplib worker threads, which block on a condvar rather than spin.

| Constant | Value | Role |
| --- | --- | --- |
| `STREAM_SESSION_TTL_SECONDS` | 300 | retention of a completed session before GC |
| `STREAM_SESSION_MAX_BYTES` | 4 MiB | ring cap per session |
| `STREAM_SESSION_GC_INTERVAL_SECONDS` | 60 | GC tick |
| `STREAM_READ_WAKE_INTERVAL_MS` | 200 | read_from wake to recheck should_stop |
| `STREAM_LOOKUP_TIMEOUT_MS` | 250 | router to child loopback budget |

```mermaid
graph TD
Client -- "POST + X-Conversation-Id" --> RG[server_res_generator]
RG -- attach --> Prod[stream_pipe_producer]
Prod -- "write, drain on peer drop" --> Sess
subgraph g_stream_sessions
Sess[stream_session: ring buffer, 4 MiB]
GC[GC thread] -- drop after TTL --> Sess
end
Sess -- read_from offset --> Cons[stream_pipe_consumer]
Cons -- "GET /v1/stream/:id?from=N" --> Client
DEL[DELETE /v1/stream/:id] -- evict_and_cancel --> Sess
```

The diagram shows the buffer touch points. The live wire (chunks streamed to the original client during a normal generation) is the producer's default output, described under "Producer side" above.

### Testing

`llama-server` includes an automated test suite based on `pytest`.
Expand Down Expand Up @@ -223,6 +276,7 @@ The flow for downloading a new model:
- Speculative decoding: https://github.com/ggml-org/llama.cpp/pull/17808 and rework in https://github.com/ggml-org/llama.cpp/pull/17808
- INI presets: https://github.com/ggml-org/llama.cpp/pull/17859 (+ refactoring: https://github.com/ggml-org/llama.cpp/pull/18169)
- Sleeping mode: https://github.com/ggml-org/llama.cpp/pull/18228
- Resumable streaming (SSE replay buffer): https://github.com/ggml-org/llama.cpp/pull/23226



Expand Down
24 changes: 20 additions & 4 deletions tools/server/server-context.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include "server-task.h"
#include "server-queue.h"
#include "server-schema.h"
#include "server-stream.h"

#include "build-info.h"
#include "common.h"
Expand Down Expand Up @@ -4022,6 +4023,15 @@ struct server_res_generator : server_http_res {
queue_tasks.wait_until_no_sleep();
}
}
~server_res_generator() override {
// cleanup() must run while rd is still alive (rd is destroyed after this body returns)
if (spipe) {
spipe->cleanup();
}
}
void stop() override {
rd.stop();
}
void ok(const json & response_data) {
status = 200;
data = safe_json_to_str(response_data);
Expand Down Expand Up @@ -4210,8 +4220,10 @@ std::unique_ptr<server_res_generator> server_routes::handle_completions_impl(
}
};

auto effective_should_stop = stream_aware_should_stop(res_this, req.should_stop);

try {
if (req.should_stop()) {
if (effective_should_stop()) {
SRV_DBG("%s", "stopping streaming due to should_stop condition\n");
return false; // should_stop condition met
}
Expand Down Expand Up @@ -4245,8 +4257,8 @@ std::unique_ptr<server_res_generator> server_routes::handle_completions_impl(
// receive subsequent results
bool timeout = false;
int64_t start_time = ggml_time_ms();
auto result = rd.next([&timeout, &req, &start_time, &params]() {
if (req.should_stop()) {
auto result = rd.next([&timeout, &start_time, &params, &effective_should_stop]() {
if (effective_should_stop()) {
return true; // should_stop condition met
} else if (params.sse_ping_interval > 0 && ggml_time_ms() - start_time > (int64_t)params.sse_ping_interval * 1000) {
timeout = true;
Expand All @@ -4264,7 +4276,7 @@ std::unique_ptr<server_res_generator> server_routes::handle_completions_impl(

if (result == nullptr) {
SRV_DBG("%s", "stopping streaming due to should_stop condition\n");
GGML_ASSERT(req.should_stop());
GGML_ASSERT(effective_should_stop());
return false; // should_stop condition met
}

Expand Down Expand Up @@ -4302,6 +4314,10 @@ std::unique_ptr<server_res_generator> server_routes::handle_completions_impl(
};
}

// attach a producer pipe to the response when X-Conversation-Id is present.
// the pipe mirrors SSE chunks into the ring buffer and wires up the cancel hook.
stream_session_attach_pipe(*res, req.headers);

return res;
}

Expand Down
59 changes: 53 additions & 6 deletions tools/server/server-http.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "common.h"
#include "server-http.h"
#include "server-stream.h"
#include "server-common.h"
#include "ui.h"

Expand Down Expand Up @@ -456,13 +457,40 @@ static void set_headers(httplib::Response & res, const std::map<std::string, std
}
}

// percent-decode a path component (%XX). path params arrive raw from httplib, unlike query
// params, so a conv id like "conv::model" sent as "conv%3A%3Amodel" must be decoded here to
// match the value the client put in the X-Conversation-Id header
static std::string decode_path_component(const std::string & in) {
std::string out;
out.reserve(in.size());
for (size_t i = 0; i < in.size(); i++) {
if (in[i] == '%' && i + 2 < in.size()) {
auto hex = [](char c) -> int {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
};
int hi = hex(in[i + 1]);
int lo = hex(in[i + 2]);
if (hi >= 0 && lo >= 0) {
out.push_back(char((hi << 4) | lo));
i += 2;
continue;
}
}
out.push_back(in[i]);
}
return out;
}

static std::map<std::string, std::string> get_params(const httplib::Request & req) {
std::map<std::string, std::string> params;
for (const auto & [key, value] : req.params) {
params[key] = value;
}
for (const auto & [key, value] : req.path_params) {
params[key] = value;
params[key] = decode_path_component(value);
}
return params;
}
Expand Down Expand Up @@ -497,26 +525,45 @@ static void process_handler_response(server_http_req_ptr && request, server_http
set_headers(res, response->headers);
const std::string content_type = response->content_type;
// convert to shared_ptr as both chunked_content_provider() and on_complete() need to use it
std::shared_ptr q_ptr = std::move(request);
std::shared_ptr r_ptr = std::move(response);
const auto chunked_content_provider = [response = r_ptr](size_t, const httplib::DataSink & sink) -> bool {
std::shared_ptr<server_http_req> q_ptr = std::move(request);
std::shared_ptr<server_http_res> r_ptr = std::move(response);

const auto chunked_content_provider = [response = r_ptr](size_t, httplib::DataSink & sink) -> bool {
std::string chunk;
const bool has_next = response->next(chunk);
if (!chunk.empty()) {
// mirror into the ring buffer first, the session must reflect every SSE chunk
// whether or not the wire write below succeeds
if (response->spipe) {
response->spipe->write(chunk.data(), chunk.size());
}
if (!sink.write(chunk.data(), chunk.size())) {
// peer is gone, stop the wire path here. when a pipe is attached on_complete
// drains the rest of the generation into the ring buffer
return false;
Comment thread
ServeurpersoCom marked this conversation as resolved.
}
SRV_DBG("http: streamed chunk: %s\n", chunk.c_str());
}
if (!has_next) {
// producer reached its natural end on the wire, a later close() skips the drain
if (response->spipe) {
response->spipe->done();
}
sink.done();
SRV_DBG("%s", "http: stream ended\n");
}
return has_next;
};
const auto on_complete = [request = q_ptr, response = r_ptr](bool) mutable {
response.reset(); // trigger the destruction of the response object
request.reset(); // trigger the destruction of the request object
// the peer may have dropped before the producer finished. when a pipe is attached, drain
// the rest of the generation into the ring buffer here, on this http worker. httplib
// runs a large dynamic pool and the worker blocks in next() on a condvar rather than
// burning cpu, so holding it until the generation ends is fine. see stream_pipe_producer::close
Comment thread
allozaur marked this conversation as resolved.
Outdated
if (response->spipe) {
response->spipe->close();
}
response.reset(); // spipe destructor finalizes the session if attached
request.reset();
};
res.set_chunked_content_provider(content_type, chunked_content_provider, on_complete);
} else {
Expand Down
13 changes: 12 additions & 1 deletion tools/server/server-http.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
#include <atomic>
#include <functional>
#include <map>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#include <cstdint>
#include <unordered_map>

struct common_params;
struct stream_pipe_producer; // defined in server-stream.h

// generator-like API for HTTP response generation
// this object response with one of the 2 modes:
Expand All @@ -23,12 +25,21 @@ struct server_http_res {
std::string data;
std::map<std::string, std::string> headers;

// TODO: move this to a virtual function once we have proper polymorphism support
// if set, the stream survives a client disconnect: when the peer leaves before the producer is
// done, on_complete drains the rest of the generation into the ring buffer on the http worker.
// the producer pipe destructor finalizes the session so no explicit on_stream_end is needed.
// shared_ptr used (not unique_ptr) so the forward-declared type is safe to delete here.
std::shared_ptr<stream_pipe_producer> spipe;

std::function<bool(std::string &)> next = nullptr;
bool is_stream() const {
return next != nullptr;
}

// called when the session is cancelled (e.g. DELETE /v1/stream/<conv_id>).
// server_res_generator overrides this to stop its reader; the default is a no-op.
virtual void stop() {}

virtual ~server_http_res() = default;
};

Expand Down
Loading
Loading