Files
alktty/docs/architecture/decisions/004-exit-code-on-control-chunk.md
T
glm-5.3-flash e9e01c04e1 docs: remove em dashes from tty-local spec and ADRs 004/006/007
- Rewrite dash-joined clauses with colons, semicolons, commas, and
  parentheses, varied per instance
- No decision, status, ADR reference, heading structure, or code-fence
  content changed
Verification: grep -c '—' returns 0 for all four files; structure,
links, tables, and fences unchanged
2026-11-01 13:03:57 +00:00

11 KiB

ADR-004: Exit Code on a Control Chunk (the Last Chunk Before Stream Close)

Status

Accepted (ported from alknet ADR-055 2026-08-17; cross-references renumbered to alktty's ADR range: ADR-052→001, ADR-053→002, ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008. The alknet ADR referenced by alknet number (052, 053) is not ported into alktty's ADR range as a separate ADR; it is ADR-001 and ADR-002 here. The alknet originals at /workspace/@alkdev/alknet/docs/architecture/decisions/ remain authoritative for any ADR not yet ported.)

Context

The alknet-docker POC validated exit-code propagation for the JSON carriage path: exec with an exit code rides on a final call.responded frame { "exitCode": N } before call.completed. That works because the JSON carriage path is the call protocol: call.responded and call.completed exist and carry the result.

The raw-carriage path (ADR-001) has no call.responded and no call.completed; after the negotiation frame, the stream is raw chunks. The exit code must ride on the chunk format itself. Two options:

  • (a) Control chunk: {"type":"exit","code":N} as the last control chunk (STREAM_CTRL_OUT, stream_type 4) before stream close. Clean, explicit, carries the code as structured data on the channel that already exists for control metadata.
  • (b) Final data chunk with exit code: a special stdout chunk with an exit-code payload. Overloads the data channel for metadata: a client parsing stdout chunks would have to special-case "this stdout chunk is actually an exit code," conflating data and control.

The local-PTY POC validated option (a) end-to-end: the {"type":"exit","code":N} chunk fires after the child is reaped (the waiter thread's oneshot::Receiver<i32> resolves) and is the last control chunk before the stream closes. The POC's session.rs pump_exit task awaits pty.exit_code, serializes the result as ControlMessage::Exit { code }, enqueues it as a control chunk, and the drainer writes it to the client before the writer closes.

Why this is a one-way door

Clients will depend on the "exit chunk is last" invariant: after the exit control chunk, no more data chunks follow, and the stream closes. This is the deterministic completion notification the docker POC identified as the stopgap coordination property: a coordinator spawns a process, streams its output, and gets a reliable "it exited with code N" signal without polling or plugin state. Changing the ordering after clients exist would break every consumer that reads stdout until the exit chunk and then stops.

Decision

1. The exit code rides on a control chunk, not a data chunk

The exit code is control metadata (the process's termination status), not data (process output). It rides on the control channel (STREAM_CTRL_OUT, stream_type 4, ADR-001) as:

{"type":"exit","code":0}

The code is an i32 in this crate's exit-status convention: ExitStatus::code() on normal exit; on Unix, the negative signal number on signal-terminated exit (e.g., -9 for SIGKILL), which is the same convention as Python's subprocess.Popen.returncode. It is not ExitStatus::code()'s behavior, which returns None for signal termination. Both local backends encode it: the pipe path via ExitStatusExt::signal() (src/local/pipe.rs::exit_code_from), the PTY path via a direct libc::waitpid reap decoded to WEXITSTATUS / -WTERMSIG (src/local/pty.rs::wait_exit_code); portable-pty 0.9.0's ExitStatus would flatten a signal death to code = 1 with the signal only as a strsignal name string, so the waiter reaps by pid and skips the conversion (review #004). The chunk is the last control chunk before stream close.

2. The "exit chunk is last" invariant

After the {"type":"exit","code":N} control chunk:

  • The server sends no more data chunks (stdout/stderr) and no more control chunks.
  • The server closes the write half of the bidi stream.

A client reads stdout/stderr/control chunks until it sees the exit chunk, records the exit code, and treats subsequent stream close as the session end. The exit chunk is the deterministic completion signal.

3. The adapter owns the exit-chunk ordering, not the backend

Per ADR-002 assumption 3, the backend resolves exit_code (a BoxFuture<'static, Result<i32, TtyError>>); the adapter awaits it, sends the exit control chunk, and closes the stream. The backend does not write to the wire: it produces handles; the adapter pumps. This keeps the wire-format logic (including the "exit is last" invariant) in one place (the adapter's session driver) and the backend focused on its allocation target.

The adapter's session driver (see tty-adapter.md) runs three concurrent pumps:

  1. stdout → client: backend stdout → stdout chunks (and stderr chunks if TtyHandle.stderr is Some).
  2. client → backend: stdin chunks → backend stdin; control chunks → TtyControl::resize/signal/eof.
  3. exit → exit chunk: await TtyHandle.exit_code; on resolve, enqueue {"type":"exit","code":N} as a control chunk; after the drainer writes it, close the write half.

The exit-chunk task coordinates with the stdout pump: the stdout pump completes (backend stdout EOF) before or concurrently with the exit resolve, and the exit chunk is enqueued only after the exit resolves. The drainer writes chunks in arrival order; the exit chunk is last because the exit is the last thing to resolve (the child must exit before its stdout drains, but the exit chunk is sent only after exit_code resolves, which is after Child::wait() returns, i.e., after the child is reaped).

4. Error exit codes

A backend TtyError during allocate() (the PTY couldn't be allocated, the docker exec failed to start, the SSH channel request was rejected) is handled before the raw-carriage phase begins; the adapter sends a JSON error response to the negotiation frame and closes the stream without entering raw mode. See tty-adapter.md §"Negotiation errors".

A TtyError from the exit_code future (the child couldn't be reaped, or the backend's wait path failed) is serialized as an exit code of -1 (ControlMessage::Exit { code: -1 }) and the stream closes. The client treats -1 as "the backend reported an exit error, not a real exit code." This is a best-effort signal; a backend that cannot determine the exit code still sends the exit chunk so the client gets the completion notification.

Consequences

Positive:

  • The exit code is structured data on the control channel, not a hacky overload of the data channel. Clients parse it as a ControlMessage::Exit, not as a special-cased stdout chunk.
  • The "exit chunk is last" invariant gives coordinators deterministic completion notification: the same stopgap property the docker POC validated for logs subscriptions. No polling, no plugin state; the process exiting is the signal.
  • The adapter owns the ordering, so the invariant is enforced in one place; backends don't have to know the wire format's completion semantics.
  • The error-exit -1 fallback keeps the completion notification reliable even when the backend can't determine the real code, so the client still knows the session ended.

Negative:

  • The "exit chunk is last" invariant is a one-way door: clients depend on it. Reversing it (allowing data chunks after the exit chunk, or moving the exit code to a data chunk) would break every consumer. This is the intended commitment: the invariant is the value.
  • A client that doesn't read until the exit chunk (e.g., a runner that cancels mid-stream by closing the write half) won't see the exit code. That's correct: a cancelled stream doesn't have a deterministic exit; the client that cancels already knows it cancelled. The exit chunk is for the client that reads to completion.
  • The -1 sentinel collides with a real SIGHUP termination on the Unix pipe path: signal-terminated children are encoded -N, so SIGHUP (signal 1) encodes as exactly -1, the same value the adapter sends for a backend wait failure. A client can't distinguish a real SIGHUP termination from a backend wait failure from the code alone. The collision is narrow in practice (the usual way a pipe-mode child receives SIGHUP is a client-sent Signal { name: "HUP" } control chunk, which the client knows it sent), but it is real for any other SIGHUP source. Note std::process::ExitStatus::code() cannot itself cause the collision (it returns None for signal termination), so this conflation exists only through this crate's -N encoding. If distinguishing becomes load-bearing, a future control message type ({"type":"exit_error","message":"..."}) can carry the distinction additively (the type-tagged enum is the extension seam per ADR-001).

Door type

One-way. The "exit chunk is last" invariant is what clients depend on for deterministic completion. Changing it after clients exist breaks every consumer. The {"type":"exit","code":N} shape is also one-way (clients parse it as a ControlMessage::Exit), though the type-tagged enum (ADR-001) makes adding new control message types additive.

Assumptions

  1. The child exits before its stdout fully drains, and the exit chunk is sent after exit_code resolves. On Unix, Child::wait() blocks until the child is reaped, which happens after the child exits and its stdout pipe/PTY buffer drains. The POC validated this ordering: the reader thread sees EOF (buffer drained), the waiter thread reaps (exit code available), and the exit chunk is enqueued after the exit resolves. There is no race where stdout chunks arrive after the exit chunk.

  2. exit_code resolving implies the stdout pump is done or will be soon. The adapter's session driver waits for both the stdout pump to complete (backend stdout EOF) and the exit to resolve before sending the exit chunk and closing. If a backend's stdout outlives the exit resolve (a hypothetical backend where the process exits but a buffer flush is still in flight), the adapter waits for the stdout pump before the exit chunk. The TtyHandle.stderr (if Some) is pumped concurrently with stdout and also drains before the exit chunk.

References

  • ADR-001: the wire format (control channel, STREAM_CTRL_OUT stream_type 4) this ADR's exit chunk rides on
  • ADR-002: the TtyHandle.exit_code field (the Future the adapter awaits) this ADR's ordering consumes
  • ADR-005: the cancel path that bypasses this ADR's happy-path ordering (no exit chunk is sent on cancel, since the stream is gone)
  • ADR-008: the exit-chunk-is-last invariant generalizes to channels mode (the exit chunk is the last STREAM_CTRL_OUT chunk before the channel closes)
  • Spec: tty-adapter.md (the session driver that enforces the ordering)
  • Port origin: alknet ADR-055 at /workspace/@alkdev/alknet/docs/architecture/decisions/055-exit-code-on-control-chunk.md