mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
Every summary that took more than ten seconds to start writing ended with "The connection to plMail was lost … the model host is not involved", ten seconds in, while the server went on to finish and store it: reload and it was there. The connection was fine. The first heartbeat frame renders the elapsed time, and it called this.constructor.#clock(). Stimulus registers a subclass of every controller, so this.constructor is that subclass, and a private static is not reachable through it: TypeError "Receiver must be class default". The fetch's catch took every error for a network error and said so. Broken since the heartbeat arrived on 2026-09-03; the "connection dropped 40 s into a full run" measured that day was very likely this too. - #clock is an instance method. - readFrames() no longer lets a throwing frame handler end the read. The error goes to reportError(), and so to the admin browser-errors list with its file and line, and the frame is skipped. Both readers put a failed read down to the network, which is what hid this one. - The reading line said "Nothing appears until it has finished", which reads as "until the summary is finished". It meant the reading: the words stream as they are written, and the line now says so. EN, DE, en_PI. The new browser test serves the server's frames without a model: a heartbeat before the first word must neither end the run nor throw. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
100 lines
4.3 KiB
JavaScript
100 lines
4.3 KiB
JavaScript
/**
|
|
* Reading a stream of NDJSON frames from a fetch() body.
|
|
*
|
|
* One JSON object per line, which is the least framing that survives a chunk
|
|
* boundary landing mid-object. A chunk off the wire is NOT a frame: it can
|
|
* carry half an object, or three of them, and the tail has to be held back
|
|
* until its newline arrives. The server does exactly this to Ollama's own
|
|
* stream one hop upstream — the failure mode for splitting on chunk boundaries
|
|
* instead is a parser that works perfectly against localhost and silently drops
|
|
* words over a real network.
|
|
*
|
|
* ITS OWN MODULE BECAUSE THE SECOND READER ARRIVED
|
|
* ────────────────────────────────────────────────
|
|
* compose/ai_assist_controller grew this first and mail/thread_summary_controller
|
|
* needs it byte for byte: two features, two panes, one wire format, and no part
|
|
* of the buffering above is specific to either. This is the moment csrf.js
|
|
* describes — "all nine were still byte-identical, which is the moment to
|
|
* collapse them rather than the moment after."
|
|
*
|
|
* WHAT IS DELIBERATELY NOT HERE
|
|
* ─────────────────────────────
|
|
* The state machine. What a `state`, `token`, `done` or `error` frame MEANS is
|
|
* entirely different in a composer and in a reading pane — one inserts text at
|
|
* a caret and offers accept/discard, the other renders a card and stores
|
|
* nothing — so the two controllers keep their own #apply(). What they share is
|
|
* the part that is genuinely one thing: getting whole JSON objects out of a
|
|
* byte stream.
|
|
*/
|
|
|
|
/**
|
|
* Feed whole frames to a callback until the body ends.
|
|
*
|
|
* Returns nothing. Whether the stream ENDING is a success or a dropped
|
|
* connection is the caller's to decide, because only the caller knows whether a
|
|
* terminal frame arrived — see both controllers, which check their own abort
|
|
* controller afterwards.
|
|
*
|
|
* @param {ReadableStream<Uint8Array>} body `response.body`
|
|
* @param {(frame: object) => void} onFrame called once per parsed line; a
|
|
* throw is reported and costs that frame, not the stream
|
|
* @param {() => boolean} isCurrent asked after every read; false
|
|
* abandons the stream. An aborted fetch settles asynchronously, so
|
|
* without this the PREVIOUS run's frames keep arriving into a surface
|
|
* the new run has already claimed.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
export async function readFrames(body, onFrame, isCurrent) {
|
|
const reader = body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
|
|
for (;;) {
|
|
const { value, done } = await reader.read();
|
|
|
|
if (false === isCurrent()) return;
|
|
|
|
if (true === done) break;
|
|
|
|
// `stream: true` so a multi-byte character split across two chunks is
|
|
// held rather than decoded into a replacement character. Every umlaut
|
|
// in a German draft sits on that boundary eventually, and so does every
|
|
// one in a German summary.
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
let cut;
|
|
|
|
while (-1 !== (cut = buffer.indexOf("\n"))) {
|
|
const line = buffer.slice(0, cut).trim();
|
|
buffer = buffer.slice(cut + 1);
|
|
|
|
if ("" === line) continue;
|
|
|
|
let frame;
|
|
|
|
try {
|
|
frame = JSON.parse(line);
|
|
} catch {
|
|
// A truncated final line from a connection that died mid-frame.
|
|
// Skipped rather than fatal: the frames before it are still the
|
|
// text somebody is watching arrive.
|
|
continue;
|
|
}
|
|
|
|
// A handler that throws is the page's fault, not the stream's, and
|
|
// must not end the read: both callers put a failed read down to
|
|
// the host or the network, and the summary card says "the
|
|
// connection was lost" in as many words. That is how its own
|
|
// TypeError on the first heartbeat passed for a network fault for
|
|
// three weeks. Reported where plMail's own browser errors go
|
|
// (client_errors.js), and the frame is skipped like the truncated
|
|
// line above.
|
|
try {
|
|
onFrame(frame);
|
|
} catch (error) {
|
|
reportError(error);
|
|
}
|
|
}
|
|
}
|
|
}
|