DOCS

Engine exits and stream recovery

Why desktop-adapter engine exits and dropped engine frames are recorded, and what each event means.

About 9 min read · 5 sections

On this page

For SSH/Unix connection drops, also inspect unix_session_end in the listener's telemetry. It identifies the local close path separately from an engine exit; an abnormal socket close does not prove that the shared executor died. See SSH connection diagnosis for causes and upgrade ownership.

The adapter launched the engine, so a recoverable stream problem must never kill it. Three stream faults are recorded and skipped instead of fatal, native turn tracking cannot grow into a self-inflicted stop, and every engine process exit is recorded once with its cause.

Stream faults that keep the engine running

Frame recovery is provided by AppServerConnection (the spawned native engine and each spawned gateway generation). Handler recovery also applies to SocketAppServerConnection, which attaches to shared engines. The adapter's client-facing input stream keeps its 16 MiB hard limit.

Event Fields Meaning
engine_frame_dropped engine, sizeMiB One JSON line exceeded the 64 MiB engine frame limit. Its bytes were discarded up to the next newline and reading continued. sizeMiB is the frame's size band: "64" (up to 128 MiB), "128" (up to 256 MiB), "256" (up to 512 MiB), or "more". When the dropped line was the answer to a request the adapter had sent, that request fails at once instead of waiting for its 120-second timeout.
engine_frame_invalid engine One line was not valid JSON. It was skipped; the engine's replies for it (if any) time out like any lost response.
engine_message_failed engine, method? The handler for one engine message threw. The message was dropped, the exception was reported to the scrubbed error reporter as engine_message, and reading continued. This handler runs for notifications and for the engine's own server requests (an approval, for example); a server request that could not be passed on is answered with a fixed -32603 failure so the engine stops waiting for it. method appears only when it is a plain slash-separated name of at most 64 characters.

For a shared socket, engine_message_failed carries transport: "socket" instead of engine, because the transport does not identify the engine's kind. A callback failure preserves pending RPC requests, subsequent messages, and other clients. If sending the fixed error itself fails, normal transport failure handling still closes that client connection. Invalid JSON, oversized frames, binary frames, and queue floods retain their existing connection limits.

Stdin error (EPIPE) and real stream errors stop the engine only when the process is still running: the adapter waits 500 ms for the engine to finish exiting, and an exit inside that window stays recorded as engine-initiated and its remaining output is allowed to drain, because the engine was already on its way out. A missing executable is stopped at once.

The separate client-facing stdio output treats EPIPE and ERR_STREAM_DESTROYED as a disconnected client, not an engine pipe failure. Pending backpressured emits settle on disconnect or close, and later emits are no-ops. Guard state belongs to the stdout stream object for that object's lifetime: a late completion from an old stream remains handled, while a replaced stream starts with independent state. Unrelated errors reject pending emits so their callers can report them; an unrelated late error with no waiting caller remains fatal. This does not change engine stdin handling or provider network retry policy.

Server callback bookkeeping

Answered server callbacks do not count toward the 256 unanswered-request limit. Ordinary native callbacks, including attestations, do not necessarily produce a serverRequest/resolved notification. Counting their completed replies as pending could eventually block later tool and approval requests.

Each engine connection keeps bounded recent fingerprints and exact completed-ID membership. Numeric IDs are compressed into adjacent ranges of actually seen IDs; an unseen lower ID is not treated as completed just because a higher one arrived first. A completed ID whose fingerprint aged out is refused rather than exposed as a new tool call. Completed-history capacity also fails closed. The bounds are 256 unanswered requests, 256 recent fingerprints per connection, 4096 disjoint completed numeric-ID ranges, and 1024 completed string IDs. IDs from different engine connections never share a history. Delivery order is not assumed to match numeric allocation order.

Threaded callbacks on shared sockets, including dynamic tool calls, retain protected uncertainty records when their replies might have been lost. Ordinary attestation traffic cannot evict those records. A matching warm replay keeps its original desktop request ID; the adapter never automatically approves a request or replays a tool result. Genuine resolution or terminal-turn cleanup releases protected records. A full protected set refuses a new callback before exposing it to the client instead of discarding an older uncertain request. That protected set has a separate 256-entry limit across pending and answered-but-unconfirmed threaded callbacks. The limits remain deliberate fail-closed safeguards, not a promise to accept unbounded unresolved work.

Native turn tracking

src/native-turns.ts tracks which native threads have a turn in progress. Entries are released by turn/completed, by the engine's own thread/closed notification, and by a thread/status/changed whose new tagged status.type is "idle" or "notLoaded". systemError is deliberately not a release: the SDK reports it while the turn is still running, so releasing there would drop a live turn on the floor. A status in no such shape changes nothing. Every release also bumps the notification revision, so a thread/read or thread/resume that was already in flight cannot re-add a thread the engine has closed. The 256-thread cap now evicts the oldest entry instead of throwing, which used to kill a healthy engine after leaked sub-agent threads filled the table:

Event Fields Meaning
native_turns_evicted count Entries the tracker dropped to stay under the cap. Emitted at most once per minute; count is how many were evicted since that report. Evictions counted inside that minute are reported when the minute closes, or when the engine exits, whichever comes first.

Engine exits

Event Fields Meaning
engine_exit engine, code?, signal?, uptimeMs, activeTurns, initiatedBy, reason One owned engine process ended, exactly once per process. engine is "native" or "gateway". activeTurns counts the native threads or the gateway generation's loaded turns; for a native engine it is counted after that engine's last frames have drained, so a completion the engine wrote just before exiting is not mistaken for a live turn.

The event carries no stderr text, paths, thread ids or arguments — only the fields above. Only a process the adapter spawned can say why it stopped: the engines reached over a shared socket (shared native executor or shared gateway executor) have no process the adapter watches, so they emit no engine_exit at all and the reason they are given is ignored. Their loss is reported through the socket paths documented in docs/diagnostics.md.

When an exit is unexpected (initiatedBy: "engine"), correlate uptimeMs with the app's adapter restarts: the app restarts the adapter within about half a second, which starts a new native process and a fresh engine_exit series. adapter_session_end records whether the native engine was already closed when the session ended.

The shared host's control connection

A desktop adapter that shares native chats keeps one extra connection to the executor host (host.sock) next to the engine socket. That connection is what the host keys this adapter's native ownership and generation leases to, and the host drops them when it closes it, so the session cannot go on even though the host process and its engine are still running. The adapter records execution_host_control_lost (only sharedGateway; no paths or chat ids) and ends the session the way a native engine exit does, so the app reconnects within about half a second and the new session discovers the host and acquires a fresh connection. Turns already running in the host-owned engine are not reported failed by this, for the same reason a lost engine socket does not prove SDK death; they are failed only when the host confirms that exact launch died. A connection the adapter closed itself, because it was stopping, records nothing.

Surviving a host restart (1.6.2)

When the control connection closes because the host restarted, and no native turn is running, the session continues instead of ending. ChatGPT 26.1029 shows "hit a snag" whenever its app-server session ends, so this matters more than the half-second reconnect.

host restarts ──► control + engine sockets close
                    │
     native turn running? ── yes ──► session ends as above (execution_host_control_lost)
                    │ no
                    ▼
     rediscover host ─► native/acquire ─► new engine socket
     ─► replay the app's initialize ─► thread/resume {excludeTurns} per loaded native chat
                    │
         ok ──► same session continues (execution_host_reconnected)
         90 s without success ──► session ends (execution_host_reconnect_failed)

The engine socket the adapter hands out (ReconnectingNativeConnection) stays one object throughout. Requests and sends that arrive during the repair wait for it, and its exited and drained settle only when the connection really ends, so nothing in the adapter or the app sees an exit. An engine socket that closes while the control connection stays open (the engine alone stopped) waits 1 s for the control connection. If it stays open, the loss takes the existing exit and death path unchanged. A deliberate stop abandons a repair at once.

Only native chats this session started, resumed or forked and still has loaded are resumed; throwaway chats (composer predictions) are not. Callback bookkeeping from the old engine is dropped, which is safe because no turn was running. Gateway chats on the restarted host do not end the session either: their engine sockets close too, and each re-attaches on its next request (see the re-attach rules in docs/app-server.md). The reconnect telemetry carries only counts and durations.

That exit path waits twice on the desktop app, and a lost host connection bounds both waits to 250 ms. It first lets the frames the engine already sent reach the app; if the app has stopped reading, a forwarded notification parks there and the session never ends. Then, when the host confirms that this exact launch died, the exit reports each live turn with an error and a failed turn/completed — either frame can park the same way, so each is raced against the bound, and once the bound has fired the loop stops rather than blocking on the next turn's pair. Every frame is still handed to the writer before it is awaited, so a bound that had already fired cannot swallow the report of a confirmed death: the notice goes out whenever the app reads again, and the reconnecting app finds those chats through their saved history either way.

The bound is a promise of its own rather than the one a deliberate stop resolves, because that one also ends the native/death check: cutting the confirmation short would lose a genuine death on an exit that has nothing to do with this connection. It arms whether or not this adapter had to close the engine socket itself, so it also bounds an exit whose socket died first.