Files
Dario LipicarandClaude Opus 5 80d028ab15 feat(timeout): per-call timeout entry points on the generated client (#32)
* feat(timeout): surface the per-call timeout the lp_* ABI already carried

lp_invoke/lp_invoke_async have taken timeout_ms: c_int since they existed,
and logos_protocol.cpp maps it (`timeout_ms > 0 ? Timeout(timeout_ms)
: Timeout()`). Rust passed a literal 0 at all five call sites and had no
mention of a timeout anywhere else, so every Rust module was pinned to the
20s default with no way to ask for anything else.

Exposed as a timeout-scoped VIEW rather than a parameter: Rust has no
default arguments, so an extra parameter would break every generated call
site, and a parallel `_with_timeout` entry point per method would double
the generated surface (and need a second answer for the async twin).
`with_timeout(Duration)` returns a new client over the SAME lp_client with
only the timeout different — one emission covering sync and async and every
method, with `self` left on the default so a scoped bound cannot leak.

Duration -> c_int converts in one place and REFUSES rather than clamps at
both ends: sub-millisecond would round to 0, which the ABI reads as "use
the default" (a 500us bound silently becoming 20s), and past c_int::MAX ms
truncation wraps negative — the default again. Both raise the new
LogosError::InvalidTimeout where the bad value was supplied.

Rust's error handling is deliberately untouched: it already returns Result
on both surfaces, which is the model the parallel C++ change is moving
toward.

Proved with a clock, not a getter: the provider fixture gained sleep(ms),
and tests/ipc-test.sh measures a 6s call under an 800ms timeout returning
at ~800ms on both surfaces, the same call with no timeout still completing
at ~6s, and a 25s call on the default path failing at ~20s. Restoring the
hardcoded 0 makes the check fail with "a 6s call COMPLETED under an 800ms
timeout".

The check script is extracted so flake.nix and tests/flake.nix (the CI job)
run the same assertions; they had drifted, and CI was not running the
binary-event assertions at all.

* fix(doctests): the SDK the modules link is the SDK the generator came from

The generated typed client calls into the SDK — `with_timeout` returns a
PluginProxy carrying a private field, and all five lp_invoke call sites live in
the crate — so generated code cannot be self-contained. Generator and runtime
crate have to move together.

The specs pinned them separately: the builder's SDK came from `{release}` (the
commit under test) while the crate's Cargo.toml carried a frozen `rev`. So this
PR's generator emitted `PluginProxy::with_timeout` against a runtime that
predated it — E0599, 5 failed of 44, on both platforms. The same pin had gone
stale once before on `logos_rust_sdk::args::Ty::…` (E0433), and bumping it just
resets the clock.

Both fixtures now take the SDK as `path = "../logos-rust-sdk-src"` — the form
real modules use — with the directory staged from
`logos-module-builder#rust-sdk-src` before `cargo generate-lockfile`, which is
what the frozen rev existed to work around. The lockfile-ordering constraint is
real; this satisfies it instead of sidestepping it. concurrent-dispatch stages
`{release}`, the commit under test, so there is one decision instead of two that
can drift.

cross-language-composition's two pins get the same treatment (no `{release}`
there — it stages module-builder's own pin, the published surface it
demonstrates). That is all four `rev` pins in this repo.

Also bumps the crate 0.2.0 -> 0.3.0: a 0.3 generator against a 0.2 runtime is
E0599, and `LogosError` gained `InvalidTimeout` without `#[non_exhaustive]`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(tests): the fixture lockfiles say 0.2.0 for a crate that is now 0.3.0

tests/{provider,caller}/rust-lib/Cargo.lock record logos-rust-sdk as a path dep
at 0.2.0. This is stale metadata, not breakage: buildRustPackage does not pass
--locked, so cargo re-resolves a path dep whose version moved and prints

    Updating logos-rust-sdk v0.2.0 (…/logos-rust-sdk-src) -> v0.3.0

(verified in a sandbox build log). Left alone the locks still work — they would
just be rewritten by the next local `cargo build`, dirtying the tree. Bump them
so they say what the crate is.

Worth recording, because it bounds what the staged SDK source does: it seeds the
lockfile's crates.io dependency GRAPH, it does not decide which SDK source gets
compiled. The builder's own logos-rust-sdk input decides that, and it wins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(doctests): regenerate cross-language-composition outputs

The committed walkthrough output still showed `rev = "8b89e56…"`. Re-ran the
spec (21 passed, 0 failed of 21) and re-rendered the .md, so the checked-in
sources match what the spec now writes.

Also teaches run.sh's clean step about leftovers its --also list predates: the
concurrent-dispatch .lgx out-links, the staged SDK out-link (a symlink into the
nix store), and the per-spec .logoscore daemon state — none of which belong in
the committed tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(timeout): the timeout belongs to the call, not to the client

The first cut exposed the timeout as a client-scoped VIEW —
`with_timeout(Duration) -> Self` on both the proxy and the generated
client, after which every method called through the returned handle
inherited that bound. That is the wrong unit. How long a call may
reasonably take is a fact about the METHOD: `fetch_price` and
`rebuild_index` do not want the same bound, and a client-wide setting
cannot say so without building a second client.

So the generated surface is now a parallel entry point per method:

    add(a, b)                                   unchanged
    add_with_timeout(a, b, timeout)             new
    add_async(a, b, cb)                         unchanged
    add_async_with_timeout(a, b, timeout, cb)   new

which is what shipped in C++ (logos-cpp-sdk#132's `fooAsyncResult`) —
there overloading was available and still not used, and Rust has none, so
a distinct name was always the only spelling.

This DOUBLES the generated surface and that is a deliberate stopgap, said
so in the emitted doc comments ("STOPGAP — a later breaking release folds
this back into the single entry point") so the duplication reads as
intended rather than accidental. The eventual fix is the breaking change:
`timeout` as a parameter of the one entry point per method.

`PluginProxy::with_timeout` is REMOVED rather than kept as the
implementation vehicle, along with the proxy's `timeout` field and the
`timeout()` getter. It was the rejected concept one level down: a public
API whose semantics are "bound to the client" cannot underpin a generated
surface whose semantics are "bound to the call", and anyone writing
hand-rolled proxy code would keep reaching for it. Removing the stored
field also turns "a bound cannot leak into a call that did not ask for
one" from a property the tests assert into one the type system enforces —
there is no state left to leak. And it is on the path to the breaking
change: `call_json_with_timeout` collapses into `call_json(..., timeout)`
by deleting a twin, whereas a scoped view would need separate deprecation.

In its place, each of the five `lp_invoke*` entry points grows a bounded
twin over a shared private `*_inner(..., TimeoutMs)`: `call_with_timeout`,
`call_with_params_with_timeout`, `call_sync_with_timeout`,
`call_json_with_timeout`, `call_json_async_with_timeout`. The async one
delivers `InvalidTimeout` to its callback rather than returning a Result,
so it keeps one error channel like every other undispatchable async call.

Everything else stands: `TimeoutMs::from_duration` and its refusals
(sub-millisecond would become the ABI's "use the default" sentinel, i.e.
20s; > c_int::MAX ms would wrap negative into the same), the value reaching
all five call sites, the 0.3.0 bump, and `LogosError::InvalidTimeout`. The
only casualty is `TimeoutMs::as_duration`, which existed solely to back the
deleted getter.

Verification — `tests/ipc-test.sh`, against two real cdylib modules under a
headless logoscore daemon. The caller fixture now holds ONE provider client
for the process (address readable and asserted constant), so every timing
below provably went through the same object:

    same-client pair (sleep=4000ms): A timeout=700ms -> 703ms, B timeout=2600ms -> 2524ms
      OK  two calls on ONE client took their OWN timeouts (Δ = 1821ms)
    sync  timed_call(sleep=6000ms, timeout=800ms)  -> 841ms
    sync  timed_call(sleep=6000ms, no timeout)     -> -6072ms   (completed)
    async (sleep=6000ms, timeout=800ms)  ->  761ms
    async (sleep=6000ms, timeout=3000ms) -> 3089ms
    async (sleep=25000ms, no timeout)    -> 19564ms  (the protocol's own 20s)
      OK  sub-millisecond timeout refused rather than rounded into the 20s default

Both new checks were confirmed to fail without the change:

  * timeouts accepted and dropped on the floor: the 6s call under 800ms
    completes at 6027ms and the protocol log says `timeout: 20000`.
  * a LATCHED bound (client-scoped semantics simulated in TimeoutMs::as_abi):
    steps 3a and 3b still pass — which is why they were not enough — and the
    pair check catches it: "two different timeouts on one client produced the
    same elapsed time (A=761ms, B=840ms)".

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:51:01 -03:00

117 lines
2.7 KiB
TOML

# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "logos-lidl-gen"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"syn",
]
[[package]]
name = "logos-rust-sdk"
version = "0.3.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"