Chapter Orientation
The SSH series concludes — the previous six chapters' parts are finally assembled here. ssh_client composes core/stream-session/dynamic-channels over a caller-provided xnetstream: it creates no hidden Engine, blocks no Worker, pre-allocates no channels or message buffers, and introduces no second state machine (four no's — SSH's edition of "no second implementation"). Connection lifecycle: after ClientInit, the event table and data pointer go straight to xrtNetStreamConnect (or, on an already-built stream, ClientAttach) — once TCP opens, ReadyTimeout (default 30 seconds) uniformly bounds version exchange → KEX → host trust → authentication; the Ready event is the sole publication point of SSH-usable state. Three interaction points: host trust (rejected by default — after DEFER, the HostKey event hands over the decision), authentication (the Authenticate event when a provider returns NEED_MORE), unknown protocol extensions (the Packet callback retains full handling capability). The Dial convenience layer (DNS/Happy-Eyeballs/timeout/cancellation) is subject to independent trimming — proxies and custom transports go through Attach, unconstrained.
129.1引入
The hard part of a "final assembly layer" is closing responsibilities: all connection state (version/KEX/authentication/channels/forwarding) has its owner among the six chapters' parts; the client strings them into one event-driven whole — but each layer's behavioral boundary is unchanged (the core is still that core, the window still that window). The closing glue is the event table (the client's events configuration struct): Ready/Error/Close/HostKey/Authenticate/Data/Drain/Global/Packet — the application's entire interaction surface; isomorphic to Chapter 104's HTTP server event chain.
Backpressure's two-layer chaining deserves a first look: when the TCP queue returns AGAIN, the complete packet of the encryption remains solely held by transport while new SSH input is paused; the LowWater/Drain callbacks retry the internal packet first, and only after the internal transaction has committed and the TCP queue has truly drained is the client Drain published — new messages the application submits inside Drain never overtake the previously retained packet (an ordering guarantee — the correctness root of forwarding tunnels). OOM is equally precise: a failed internal DATA reservation holds the connection; after memory is freed, PacketRetry — "OOM never commits half a packet".
129.2概念
Connection and the timeout budget
The three interaction points
- Host trust: rejected by default — after the core's HostKey returns DEFER, the application gets the decision moment via the
HostKeyevent, answering withxrtSshClientHostKeyAccept/Reject(Chapters 122/123's two-step trust in its client-side interactive form — the event is not a synchronous judgment inside a callback; you may query the store, ask the user, verify out-of-band, and decide asynchronously). - Authentication: a provider returning NEED_MORE triggers the
Authenticateevent — once credentials are ready (asked the user / fetched from the agent),xrtSshClientContinueproceeds (Chapters 124's methods' asynchronous mouth). - The Packet escape hatch: the
Packetcallback runs before the underlying read transaction commits — full handling capability for unknown protocol extensions and custom channel types (Chapter 126's forwarding parse hangs exactly here) — the client never swallows unknown messages.
The data and control-message paths
xrtSshClientSend is the payload fast path: it adds only SSH packet encoding + one ownership transfer into the TCP queue (zero transit copies). xrtSshClientBuild provides grow-from-zero, reusable, hard-capped contiguous scratch for mutable control messages (the building buffer for custom messages). Standard DATA/stderr first enter channel I/O staging; after committing, the Data event fires (the application zero-copy-inspects or reads explicitly). Global replies: GlobalReplies/ReplyReserve's dynamic bounded FIFO paired with the Global event (token correlation — seen in Chapter 126).
Channels and the lifecycle finish
xrtSshClientChannelOpen is the base entrance for custom channel types: the caller writes the type-specific fields; the client manages dynamic numbering/windows/reply correlation/commit/rollback — the classic session and direct-tcpip helpers are both built on it (no second set of channel state maintained). ChannelFlush builds fragments from the channel's send queue, bounded jointly by "remote window + max-packet + TCP backpressure" — the confluence of three budget layers. The finish: OwnsChannel validates ownership (preventing handing another connection's pointer to the send queue); Drain/Abort follow Chapter 110's same semantics.
129.3示例
First complete program: the no-hidden-runtime statement
The program below is from examples/client — the minimal client's resource shape:
#include <stdio.h> #include <xssh.h> /* 展示客户端由外部 Engine 和 Stream 驱动,不创建隐藏运行时。 */ int main(void) { xsshclientconfig Config; xsshclient Client; if ( !xrtSshClientConfigInit(&Config) || !xrtSshClientInit(&Client, &Config, NULL, NULL) ) { return 1; } printf("state=%d channels=%zu\n", (int)xrtSshClientState(&Client), Config.Channels.MaxChannels); return xrtSshClientClear(&Client) ? 0 : 1; }
What just happened. (1) xrtSshClientConfigInit + xrtSshClientInit(&Client, &Config, NULL, NULL) — a client on the stack; the xrtSshClientState initial state and Channels.MaxChannels (default 1024) printed. (2) The header comment is the design manifesto: "driven by an external Engine and Stream; creates no hidden runtime" — Chapter 121's transport core "no buffers", Chapter 122's KEX "no socket", here "no Engine" — all seven layers zero-hidden; the assembly's transparency is the accumulation of the parts' transparency. (3) ClientClear finishes — an unconnected client's cleanup is zero-burden. The real assembly (the event table into NetStreamConnect) is in the client_session/client_core samples.
Second complete program: the Dial convenience-layer configuration
The second program is from examples/client_dial — the pre-connection budgets:
#include <stdio.h> #include <xssh.h> /* 展示 SSH Dial 与 TCP Dial 共享同一套可裁剪连接策略。 */ int main(void) { xnetdialconfig DialConfig; xsshclientconfig ClientConfig; xsshclient Client; xrtNetDialConfigInit(&DialConfig); DialConfig.Timeout = 10000; DialConfig.FallbackDelay = 250; if ( !xrtSshClientConfigInit(&ClientConfig) || !xrtSshClientInit(&Client, &ClientConfig, NULL, NULL) ) { return 1; } printf("dial-timeout=%llu fallback=%llu state=%d\n", (unsigned long long)DialConfig.Timeout, (unsigned long long)DialConfig.FallbackDelay, (int)xrtSshClientState(&Client)); return xrtSshClientClear(&Client) ? 0 : 1; }
What just happened. (1) xrtNetDialConfigInit's Timeout=10s/FallbackDelay=250ms — the TCP-side budget (DNS/connecting/Happy-Eyeballs fallback), independent at two levels from ReadyTimeout (the SSH side): TCP 10 seconds + handshake-and-authentication 30 seconds; the total budget is clear and tunable (Chapter 99's two deadline kinds, SSH edition). (2) The Dial completion callback means only the TCP outcome — SSH usability is still published only by Ready (callback semantics not swapped). (3) Proxies/custom transports are not constrained by Dial — the ClientAttach path stays open (the composition mouth of Chapter 70's proxy tunnel + SSH). Companions: client_core (core + password-authentication assembly), client_session (the session stream), client_future (the Future form), client_auth_ed25519 (public-key authentication), client_pty (terminal negotiation).
129.4契约
- Four no's: no hidden Engine / no blocking Workers / no pre-allocated channels or message buffers / no second state machine.
- Connection assembly: the event table goes to NetStreamConnect; an already-built stream does
ClientAttachon its owning Worker; scratch binds the Worker buffer pool after TCP opens. - Two-level timeout: ReadyTimeout (default 30s, zero disables) governs version→KEX→trust→authentication; Dial's Timeout governs DNS/connecting — independent.
- Ready is unique: SSH-usable state is published only by the Ready event; the Dial completion callback ≠ SSH usable.
- Host rejected by default: DEFER → HostKey event → Accept/Reject decided asynchronously.
- Authentication's asynchronous mouth: NEED_MORE → Authenticate event → Continue.
- The Packet escape hatch: runs before the read transaction commits; full capability for unknown extensions and custom channels; the client never swallows unknown messages.
- Send paths: Send = zero-transit fast path; Build = reusable scratch; three budget layers confluence in ChannelFlush.
- Drain ordering: internal packets retried first; published only after the transaction commits and the queue drains — new messages never overtake.
- OOM precise: never commits half a packet; reservation failure holds; after freeing, PacketRetry.
- Global replies: a dynamic bounded FIFO + the token-correlated Global event.
Common Mistakes
Symptom: opening a channel right in the Dial callback — an error or crash; SSH is still handshaking.
Cause: the Dial callback only means TCP is up; version/KEX/trust/authentication come after — Ready is the usable signal.
on_dial_done(...) { xrtSshClientChannelOpen(Client, "session", ...); /* mid-handshake: wrong */ }
on_dial_done(...) { /* TCP only - wait for Ready */ } on_ready(...) { /* only here open channels / send forwarding requests */ xrtSshClientChannelOpen(Client, "session", ...); }
Symptom: the connection sticks and finally ReadyTimeout — nobody Accepted/Rejected the event.
Cause: the HostKey event is a decision point that must be answered (the default rejection protects against "no trust policy configured"; it does not mean "the event may be ignored"). After querying the store / asking the user, be sure to answer.
on_host_key(...) { audit_log(Key); /* logged but never answered: the connection hangs to timeout */ }
on_host_key(...) { audit_log(Key); if ( known_hosts_check(Key) == TRUST ) { xrtSshClientHostKeyAccept(Client); } else { xrtSshClientHostKeyReject(Client); } }
Symptom: at ten-thousand-concurrency targets, the 1024-channel cap silently intercepts — half the connections cannot open new channels; or the reverse, on embedded targets the untuned budgets squeeze memory.
Cause: defaults like Channels.MaxChannels are general-purpose values (Chapter 125's set contract: "tune per workload, never rely on unbounded growth") — deployment parameters are not the library's private business.
xrtSshClientConfigInit(&Config); /* all defaults into production */
xrtSshClientConfigInit(&Config); Config.Channels.MaxChannels = my_model.channels; Config.Channels.ReceiveWindow = my_model.window; Config.GlobalReplyLimit = my_model.global_tokens; /* declared explicitly per the workload model - the budget is a deployment contract */
Programming Exercises
Against a real SSH server (local sshd or a container), print the event sequence for a whole connection: Dial→(TCP)→version→KEX→HostKey→authentication→Ready→channel→Close. Acceptance criteria: the sequence matches this chapter's flow diagram; the two-level timeouts each trigger independently (short-value experiments).
Inside the HostKey event, start an asynchronous verification (simulating an out-of-band check with 200 ms delay) then Accept — verifying the event model's asynchronous decision capability. Contrast with the synchronous immediate-reject path. Acceptance criteria: both paths' Ready/Close timing correct; the connection stays alive during the decision (no timeout).
The assembly exercise: Dial + password authentication + HostKey (known_hosts integration) + local forwarding (the -L shape) + 3 concurrent channels + graceful Drain — a usable mini ssh -L. Acceptance criteria: HTTP requests round-trip through the tunnel; three channels concurrent without interference; Ctrl-C triggers Drain and the whole chain exits cleanly (budget and reference reconciliation).
Summary
| Topic | Quick reference |
|---|---|
| Four no's | no hidden Engine / no blocking Workers / zero pre-allocation / no second state machine |
| Assembly | the event table to NetStreamConnect, or Attach on the owning Worker |
| Two-level timeout | ReadyTimeout (whole SSH journey, default 30s) independent of Dial Timeout |
| Ready is unique | SSH usable published only by Ready — Dial completion ≠ ready |
| Host trust | rejected by default; DEFER → HostKey event → Accept/Reject always answered |
| Authentication async | NEED_MORE → Authenticate → Continue |
| Packet escape hatch | runs before commit; unknown messages never swallowed — the custom-channel capability |
| Sending | Send zero-transit / Build scratch / Flush conflues three budgets |
| Drain ordering | internal packets first; published only when drained — new messages never overtake |
| Global replies | bounded FIFO + token for the Global event |
| Assembly map | 117 transport → 118 KEX → 119 trust → 120 authentication → 121 channels → 122 forwarding → 123 client |
📖 API references in this chapter:xssh-ssh_client (31 entries)xssh-ssh_client_core (9 entries)xssh-ssh_session_stream (14 entries)
Click for full signatures, parameter constraints, return values and error notes