# Rust startup and Logout lifecycle measurements — 2026-10-12

## Scope and source

This supplies the missing Rust observations for the shared startup and Logout
review in `docs/WIP_AGENT_CLAIMS.md`. The two proposals became rulings in
`58e9463c3` while the probe was being qualified. This record describes measured
behavior; it does not close the startup ruling or change an engine contract.

Final qualification source: `14efbbf9c01ded53b2fd8b799c1df878599f31e1`.
The private standalone Cargo project is
`internal/engine/rust/tools/session-lifecycle-probe`. Its README gives the run
and cold-check commands. No production source, public test or matrix score
changes in this slice. All **368 Rust, 632 shared and five private probe files**
are captured as path/SHA-256 pairs. Publication retains the exact Rust/probe
inputs and all but one shared cold-check file: the inventory anchor repair
described below is verified separately after the compiled measurements.

The engine remains the same through the initial and final probes. Initial
source is `0c79f3edb2393dff0d9587578d52e7b33c2cbbf6`; the intermediate source is
`a5886d51f0866454a054fab01f8a71f61abcc0bb`. The retained bundle contains those
original probe versions as well as the final publication.

## Startup observations

The probe holds an actual listening TCP socket, not a preselected port number.
With `MaxSessions=1` and `4`, `SessionSettings::acceptor()` returns `Err` with
`bind port <port> errno=98` synchronously (tens of microseconds on this run).
No acceptor object, status handle or bound-port accessor is returned. Rust
combines construction and binding in this factory: it does not expose Java's
separate `createSession`/`start` calls here. No Listening state can be published
by an acceptor that was not constructed. This measurement does not invent a
Faulted object or an error event that Rust's failed constructor cannot expose.

The file-store path is under a regular file in an owned fixture directory.
For each of **async-file, QuickFIX C++ and QuickFIX/J**:

- `SessionSettings::initiator()` returns `Err` during construction, before
  `Initiator::run()` can be called. The async-file error includes the derived
  path and `File exists (os error 17)`; both QuickFIX layouts report
  `Not a directory (os error 20)`. This is the outstanding timing difference
  against the new ruling: configured store opening must move to startup.
- `SessionSettings::acceptor()` succeeds and reports Listening. The store is
  per connection and has not been opened yet. On each of two real connections,
  `poll()` returns `Ok`, `refused_setup` increases, `last_refusal()` exposes the
  actual store error, the session list stays empty and the peer reads EOF
  without a FIX frame. The listener remains Listening and the second
  connection is independently refused. This measures the permitted
  per-connection failure boundary, not a failed listening socket.

A refused dial with the default no-retry policy returns `-2` from `run()` and
publishes Created, Connecting, Faulted. `session.error` remains zero for this
pre-attachment refusal. Dial failure is separately recorded from store opening.

## Driver Logout requests over TCP

`LogoutHandle::logout()` returns unit, like the C++ driver's void request.
The raw peer builds its own frames and never runs a second `Session` that
could answer a Logout automatically.

| Request point | Wire and owner behavior |
|---|---|
| Before the first run | No dial or frame; the status stays Created at the call, and the next run returns 0 in Stopped. A subsequent run dials and sends Logon. |
| Logon sent, unanswered | No Logout; immediate EOF, Disconnected then Stopped, `run()` returns 0. |
| Logged on | Exactly one Logout, LogoutSent, then Disconnected and Stopped after the peer's answer; `run()` returns 0. |
| Again while LogoutSent | Exactly one Logout overall. A silent peer receives the full configured one-second wait before EOF; asking again does not end the wait. |
| After the exchange and run ended | No dial or frame; the status stays Stopped at the call, and the next run returns 0. A subsequent run dials with the continued sequence. |

The observed silent-peer close times are retained in the JSON Lines reports
(about one second); they are coarse lifecycle observations, not benchmarks.
The pre-Logon close records zero whole milliseconds on this run. The request
made while nothing runs is consumed by one run, not held permanently. That
persistence choice is expressly outside the new ruling. These driver rows
match the C++ shape adopted by the ruling; this slice changes no return type.

## Low-level sender boundary

`Session::send_logout()` is a synchronous protocol-frame sender returning an
integer transport status, distinct from the driver's application request.
The same probe measures it separately:

- Before start on closed Io: `-2`, no frame, Disconnected.
- With Logon unanswered: `0`, one Logout, LogoutSent, transport still open.
- Logged on: `0`, one Logout, LogoutSent.
- Asked again: `0`, a second Logout with the next sequence, still LogoutSent.
- After the exchange sets Disconnected but before caller teardown: `0`,
  another Logout on the still-open transport, status returns to LogoutSent.

This last row does **not** represent the initiator driver's already-closed
connection. A direct session caller still owns its transport teardown. Every
recorded direct frame is independently checked, including the third Logout.
The raw sender's behavior is retained explicitly; it is not evidence that
every public sender satisfies the application's state-gated Logout contract.
That API boundary needs to be considered when closing Rust's shared ruling.

## Verification and corrections

Scan runs the two editions serially on CPUs 10–19 with guarded `-j8`. The
guard requires at least 64 GiB available RAM on the 125 GiB physical host,
limits observed foreign heavy-process RSS and yields to a compiler overlapping
the reserved CPUs. No heavy BEELINK2 or HP job was run.

- Both editions complete **22 observations**, with zero resource skips and
  empty probe stderr. These are measurement rows, not full-suite test totals.
- Independent Python checking verifies complete case identities, source pin,
  every recorded FIX BodyLength and CheckSum, and matching edition observations
  after removing measured times, SendingTime/checksum and per-run port/path
  names. It refuses three negative controls: a missing row, wrong pin and
  altered wire with an unchanged checksum.
- Strict `cargo clippy --all-targets -- -D warnings` passes for the private
  probe in both editions. Rustfmt, API inventory and generated coverage checks
  pass, as do all 12 existing API-inventory tests. Engine-wide Clippy/full
  conformance stages were not rerun for this
  private measurement-only change.
- Builds/probes use Cargo/Rust 1.92.0; Clippy uses the retained 1.93.1 toolchain.
  Separate default/open target directories stay under the session build root.

The first cold inventory check fails on `pre_send_suppression_hook:dotnet:missing`.
Upstream `ad8dcd7f2` had replaced `SuppressBeforeSend` with `ApplyBeforeSendGate`
while retaining suppression and adding teardown guards. The inventory still
looked for the removed name. Its evidence now requires the current call with
the `out bool suppressed` result and the actual `BeforeSendAction.Suppress`
branch. The generator, its existing tests and the committed coverage check pass;
no capability score is weakened or flipped. This is the sole shared-file delta
after the probe's recorded pin, not a Rust build input or runtime change.

The initial probe passed both editions but omitted its raw peer when observing
the direct post-exchange send. It therefore recorded `0` and an open transport
without collecting the resulting frame. Those incomplete reports are retained
under `initial-incomplete-peer-observation`; they are not final wire evidence.
The corrected intermediate probe records the actual third Logout. Final probes
also collect direct bytes for a bounded 20 ms without assuming every open
transport gets a frame, and can observe a successful constructor followed by
store failure at `run()` after future startup alignment. Bounded loopback
silence is not a promise about asynchronous transports.

One scratch rotation also moved the build guard into the initial-evidence
directory. The next qualification attempt stopped before compilation with a
missing-script error. `setup-missing-guard.log` retains it; restoring the owned
guard allowed complete final qualification. An initial branch-backup push was
rejected as non-fast-forward because the session branch retained older rebased
history; only the owned branch was refreshed with a lease after fetching.

Evidence is retained with checked SHA-256 sums and a recoverable source bundle
at `~/archive/libhft/rust-startup-logout-measurements-20261012/` on BEELINK2,
Scan and HP. Production endpoint and venue/FIXP certification partials remain
open. The newly ruled startup timing and the Logout API boundary are additional
runtime follow-ups; a static supported matrix does not close them.
