From 58963cfe194c192a395879a0fe15afbbd48609f6 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Wed, 11 Nov 2026 13:54:15 -0500 Subject: [PATCH 01/15] docs(cli): record demo and ping node contracts --- docs/ARCHITECTURE.md | 12 +- packages/cli/docs/ARCHITECTURE.md | 454 ++++++++++++++++-- packages/cli/docs/PLAN.md | 42 +- .../libp2p/protocols/docs/ARCHITECTURE.md | 6 +- 4 files changed, 450 insertions(+), 64 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5c05d85..e3c8b3a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -64,7 +64,7 @@ packages/ mux/ Muxer values: yamux (over net/yamux), mplex negotiate/ multistream-select, upgrader host/ swarm/host, peerstore, dedup, deadlines - protocols/ identify, ping, echo (demo) + protocols/ identify, ping, req/resp, gossipsub cli/ command-line executables: demo node, ping node, interop harness; config and wiring ``` @@ -104,8 +104,10 @@ then, version skew is a bigger risk than coupling. the *wiring*. The wiring turns the generic seams into a running node. It picks the `Transport`, the `SecurityProtocol` list, the muxers, and the stream handlers. From the spike, `cli/` gets `Main.lean`, -`PingNode.lean`, and the interop harness executables. Two rules keep -`cli/` small: +`PingNode.lean`, and the interop harness executables. On this tree +`Main.lean` is the `gossipsub-interop` executable. The demo +executable is `Demo.lean`, and the ping node is `PingNode.lean`. +Two rules keep `cli/` small: - `cli/` contains **no protocol logic and no reusable code**. Code that a second binary might want goes down into `libp2p/` or lower. @@ -868,8 +870,8 @@ Everything with libp2p semantics, in subpackages: session table (§3.8). The package also holds the handler registry (`HashMap ProtocolId StreamHandler`), the peerstore, and `Host.Error`, which wraps `Core.Error` with arms of its own. -- **`protocols/`**: identify (with push) and ping, plus req/resp and - gossipsub when they land (§8). +- **`protocols/`**: identify (with push), ping, req/resp, and + gossipsub (§8). - **Policy seams**: connection gater, resource manager, address ranker. The interfaces exist now, and the implementations come later. diff --git a/packages/cli/docs/ARCHITECTURE.md b/packages/cli/docs/ARCHITECTURE.md index 912a72c..a45a45c 100644 --- a/packages/cli/docs/ARCHITECTURE.md +++ b/packages/cli/docs/ARCHITECTURE.md @@ -15,55 +15,67 @@ row 18). | Module | Holds, and why | |---|---| -| `Main.lean` | the demo node, the `demo` executable; the §3.1 assembly as running code, the spike's `Main.lean` mapped here | +| `Main.lean` | the `gossipsub-interop` executable; `lakefile.toml` sets its root to `Main` | +| `Demo.lean` | the demo node, the `demo` executable; the §3.1 assembly as running code, the spike's `Main.lean` mapped here | | `PingNode.lean` | the ping node, the `pingnode` executable; the `serve` and `dial` modes and their argument parsing | +| `GossipsubSession.lean` | the gossipsub participant's host assembly | +| `GossipsubInterop.lean` | the gossipsub participant's script driver | | `Interop.lean` | the transport-interop harness; the host assembly and the listener and dialer modes | | `Interop/Main.lean` | the `interop` executable's `main`, apart from `Interop.lean` so the suite can import the harness | | `Interop/Config.lean` | the test plan's environment variables, read into a `Config`, and the timing record | | `Interop/Resp.lean` | RESP2, pure: the reply header parse with its caps, and the command encoding | | `Interop/Redis.lean` | the harness coordination client, private to the `interop` executable: `PING`, `RPUSH`, and `BLPOP` over one connection; the spike's `LeanP2P/Interop/Redis.lean` mapped here | -The package is executables only, with no library root (see -Decisions). The W0.1 scaffold gives every leaf a nominal `lean_lib`; -the `cli` root stays empty, and the executables are the package. -Config parsing lives in each binary, because the three -contracts differ. Argument parsing uses `leanprover/lean4-cli` as -the first choice. The library is maintained under the leanprover -organization, and CLAUDE.md's "Lean on Lean" principle prefers a -maintained library before a hand-rolled one. The §2 rule bans -reusable code inside `cli/`; the parsing mechanism lives in the -dependency, and each binary keeps its own parsing code. The -hand-rolled parser is the recorded fallback if the pin does not fit -the v4.34 toolchain. The harness's go-side helpers are scripts -under the root `scripts/`, test-only per CLAUDE.md's dependencies -rule. +`Demo.lean` and `PingNode.lean` land with unit 1. `Main.lean` stays +the gossipsub participant. The Lake lib `Cli` is nominal: `Cli.lean` +declares no API, and the lib exists so the suite can import the +harness (see Decisions). Unit 1 widens that lib's globs for `Demo` +and `PingNode` in the commit that adds the files. Config parsing +lives in each binary, because the contracts differ. Argument parsing +uses `leanprover/lean4-cli` as the first choice. The library is +maintained under the leanprover organization, and CLAUDE.md's "Lean +on Lean" principle prefers a maintained library before a hand-rolled +one. The §2 rule bans reusable code inside `cli/`; the parsing +mechanism lives in the dependency, and each binary keeps its own +parsing code. The hand-rolled parser is the recorded fallback if the +pin does not fit the v4.34 toolchain. The tag `v4.34.0` of +`leanprover/lean4-cli` carries `lean-toolchain` `leanprover/lean4:v4.34.0`, +and its package, lib, and root module are all named `Cli`. This +package's lib and its module `Cli` use that same name, so the commit +that adds the dependency renames this lib and this empty module +together. The namespace `Cli` stays. That tag also sets +`experimental.module`. This tree has not compiled the dependency. +The harness's go-side helpers are scripts under the root `scripts/`, +test-only per CLAUDE.md's dependencies rule. ## Public surface -There is no library API, by design: every module here serves `main` -alone, and no consumer has anything to import (§2). The surface is the -three CLI contracts. +There is no library API for a consumer project (§2). The test suite +imports the harness. The surface of `demo`, `pingnode`, and `interop` +is the three CLI contracts. `gossipsub-interop` is the executable +rooted at `Main.lean`, and its contract stays with the gossipsub +work. -- **`demo`** (the spike's `Main.lean`). The spike demo takes no - arguments. It listens on `/ip4/127.0.0.1/tcp/0`, generates two fresh - Ed25519 identities, registers the ping and identify handlers and the - echo demo handler, dials by peer id, pings, identifies, echoes, and - closes both hosts. Whether W5.8 adds a listen-address flag, a dial - target, or an identity key file is the unit's to fix. +- **`demo`** (the spike's `Main.lean`, the file `Demo.lean` here). + The executable takes no arguments. The behavior section below is + the contract. The echo home is the open question in that section. - **`pingnode`** (the spike's `PingNode.lean`). `pingnode serve ` hosts `127.0.0.1:`, serves - `/ipfs/ping/1.0.0` and identify, as the spike's node does, prints + `/ipfs/ping/1.0.0` and identify, prints `ADDR `, and exits when stdin closes. - `pingnode dial ` dials an address whose `/p2p/` - suffix supplies the expected identity (the host pair's rule), - pings once, and prints `PING OK rtt=ms`. A usage error exits 2. + `pingnode dial ` dials an address whose final + `/p2p/` component supplies the expected identity, pings once, + and prints `PING OK rtt=ms`. A usage error exits 2. A runtime + failure exits 1. The behavior section defines both. - **`interop`** (the spike's `Interop.lean`). Configuration arrives as environment variables, in the shape of the libp2p transport-interop test plan: `transport` (`tcp`), `muxer` (`yamux` or `mplex`), `security` (`noise`), `is_dialer`, `ip`, `redis_addr`, - and `test_timeout_seconds`. `libp2p/mux` holds no mplex value yet - (P2, the mux PLAN, unit 2), so a run with `muxer=mplex` exits - nonzero at the host start. The listener publishes its dialable + and `test_timeout_seconds`. `Interop.startHost` offers + `Mux.yamux` or `Mux.mplex`, the muxer named in the environment. + The W5.9 gate still runs yamux. go-libp2p v0.50 has no mplex in + its defaults, so the matrix skips those cells, and + `scripts/mplex-interop.sh` covers `Mux.mplex`. The listener publishes its dialable multiaddr with the `/p2p/` suffix through Redis, and the dialer fetches it, dials, pings once, and prints the timing JSON with the test plan's keys verbatim, `handshakePlusOneRTTMillis` and @@ -74,10 +86,11 @@ three CLI contracts. ## Owned contracts and how the package keeps them -1. **No protocol logic.** Every handler value and every client - function comes from `libp2p/protocols`: ping and identify from that - pair's surface, and the echo demo handler from its test support, as - that pair records. Every seam value comes from its implementation +1. **No protocol logic.** Ping and identify come from + `libp2p/protocols`: the handler values, `Ping.ping`, and + `Identify.client`. The demo echo has no assigned home. The open + question records the two documents that leave it open. Every seam + value comes from its implementation package: the TCP transport from `net/streams`, which alone binds `Std.Async.TCP` (§9, delta 7), the security value from `libp2p/security`, and the muxer values from `libp2p/mux`. The @@ -88,13 +101,15 @@ three CLI contracts. moves it down. 3. **Errors match on `Host.Error` alone** (§3.5). A binary prints the arm and picks its exit code, and it never matches on a lower - package's error inductive. + package's error inductive. The behavior section names the codes, + the stderr line, and the one map into `Host.Error`. 4. **The ordered lists are assembled here and nowhere below** (§3.1). No code below `cli/` hard-codes which transport or muxer exists, and the demo's lists are the running statement of the composition. -5. **Every exit closes and joins.** Each mode closes the host before it - returns, so the shutdown order of §5.5 joins every task the binary - owns. +5. **Every exit closes and joins.** Each mode closes every host that + `Host.new` returned before the mode returns. A fault from one + `Host.close` still runs the close of the other host. `Host.close` + is the shutdown order of §5.5. 6. **The RESP2 reader is capped before allocation** (§5.1, PRINCIPLES 2.3). The Redis client reads server-supplied line and bulk lengths, each read is capped, and an over-long length is an @@ -108,18 +123,349 @@ three CLI contracts. `parseHeaderBulkOverCap` and `parseHeaderArrayOverCap` state both caps on the header parse. +## W5.8 behavior + +This section binds the demo and the ping node. Unit 1 implements it. +The open question at the end has no home yet. The interop contract +above stays in force. + +### Executables + +| Executable | File | Module | Lake root | +|---|---|---|---| +| `demo` | `Demo.lean` | `Demo` | `Demo` | +| `pingnode` | `PingNode.lean` | `PingNode` | `PingNode` | + +Both files sit in `packages/cli`. The namespaces are `Cli.Demo` and +`Cli.PingNode`, the shape `Cli.GossipsubSession` already uses. +`gossipsub-interop` keeps the root `Main`. `interop` keeps the root +`Interop.Main`. Unit 1 adds the two `lean_exe` entries. The current +`lakefile.toml` has neither name. + +### Arguments + +`demo` takes an empty argument list. Any argument is a usage error. + +`pingnode serve` takes one port. `pingnode dial` takes one multiaddr. +A missing subcommand, an unknown subcommand, a missing operand, and +an extra operand are usage errors. + +A usage error prints one line to stderr and exits 2. The process has +created no host. + +The text `0` is a valid port. Any other valid text starts with a +digit from `1` to `9`, uses only digits, and names a value through +65535. `startListening` returns the address the kernel bound for +port 0, and the `ADDR` line prints that address. Any other port +text is a usage error. + +`dial` parses the operand with `Multiaddr.parse?`. A parse failure +is a usage error. The stderr line is the failure text and the +operand. + +The last component of a dial address is `/p2p/`. +`Host.splitPeer` returns the transport address and a `PeerId`. A +missing final component, a peer component in another position, an +address whose only component is `/p2p/`, and any other +`splitPeer` failure are usage errors. The dial then uses that peer +id as the expected identity. A port of 0 inside a parsed dial +address reaches the transport, and the transport reports the result +as a runtime failure. + +### Exit status + +Success exits 0. A usage error exits 2. A runtime failure exits 1. + +Contract lines go to stdout. Diagnostics go to stderr. The peer id +text is `PeerId.toString`. The address text is `Multiaddr.toString`. +The same-peer check compares `a.hash == b.hash` (`PeerId.lean`, +"Comparing peer ids"). + +A runtime failure is a `Host.Error`, an initialization failure, or a +thrown `IO.Error`. + +Initialization is `Crypto.ed25519Generate`, then +`PrivKey.ofData? .ed25519`, then `Noise.Config.generate`, then +`Host.new`. Each step returns its own error. The stderr line is the +rendered value. `Host.new` answers `Identity.Error` when the key +derives no peer id. + +`startListening`, `dial`, `dialPeer`, and `newStream` answer +`Host.Error`. The stderr line is `Host.Error.render`. `Host.close` +throws the first fault of a joined task (`Shutdown.lean`). That +throw is the fault channel. + +`Ping.ping` answers `Ping.Error`. `Identify.client` answers +`Identify.Error`. The exchange wrapper maps the first through +`Ping.mapPingError` and the second through `Identify.mapIdentifyError`, +then wraps the `Core.Error` as `Host.Error.stream`. The command +matches that `Host.Error`. + +`Identify.client` answers `.ok none` when the stream ends before any +byte (`Identify.client`). That value stays the end-of-stream channel +of §3.5. The demo prints one stderr line and exits 1. + +`Ping.ping` records that the echo path answers `.ok (some rtt)`. An +`.ok none` from that call takes the same exit as the identify +`none`. + +A thrown `IO.Error` stays a fault. The binary prints it, closes the +hosts it created, and exits 1. + +### Composition + +Both binaries build one `Host.Config`. + +- The identity is a fresh Ed25519 key from the two calls above. +- `transports` is `[Streams.Tcp.transport]`. +- `security` is `[Noise.securityProtocol cfg]`. +- `muxers` is `[Mux.yamux, Mux.mplex]`. That order is the mux + architecture and root §8. +- The gater, the resource manager, the ranker, and the clock keep + the defaults on `Host.Config`. +- `onFailure` prints `Host.Error.render` to stderr, and it includes + the peer and the protocol when the failure carries them. A policy + refusal stays inside the host (the host architecture, Decisions). + One inbound failure leaves the accept loop running. A fault of a + host task surfaces when `Host.close` joins the task. + +The demo and the ping node register ping and identify. Echo +registration waits on the open question. + +`Identify.respondHandler` is registered after `startListening` +returns. Its listen-address list is that bound address, as +`Multiaddr.toBytes`. Its protocol list is the ids just registered, +each through `ProtocolId.toString`. The agent string is `leanp2p`. +The observed address stays the `StreamContext` value inside +`respondHandler`. The protocol version stays +`Constants.identifyProtocolVersion`, which `respond` writes. + +The demo's identify client requires three facts in the record. The +derived peer hash equals the dialed peer hash. The bound address is +present. Every registered protocol id is present. A missing fact +exits 1. + +### Deadlines + +The upgrade deadline stays in the host. `upgrade` in +`Libp2p/Host/Upgrade.lean` runs `Conc.withinDeadline` for +`Streams.unauthDeadlineMs`, and the expiry hook is +`Connection.closeQuietly`. + +`Identify.client` takes a timeout and resets the stream when the +timeout expires. The demo passes `Streams.unauthDeadlineMs`. The ping +node serves identify and makes no identify client call. + +`Ping.ping` sends `Constants.pingSize` bytes from `Crypto.randomBytes` +and waits on the echo with the stream's own read. A generator +failure is a fault. The caller +wraps that call in `Conc.withinDeadline` for +`Streams.unauthDeadlineMs`. The duration is that constant, whose home +stays `net/streams`. +The bound covers the exchange, and it starts once `newStream` has +returned that stream. §5.4 leaves an authenticated idle connection +open. Expiry resets the stream, and the connection stays up. + +The expiry hook is the one in `Identify.readFrame`. `onExpiry` has +type `BaseIO Unit`, and `Stream.reset` is `Async`, so the wrapper +spawns `s.reset` on a `TaskSet` that the call owns and joins after +the race. `Stream.close` ends the write direction. The parked read +wakes from `Stream.reset`. + +On yamux, `Yamux.Core.resetLocal` ends both directions. The loop +action `.endWrites` wakes a writer parked on credit, and `.reset` +wakes a reader (`Yamux/Loop.lean`). On mplex, `Port.abort` closes +the inbox and wakes a parked read. A queued mplex write receives +the reset when the writer finds the stream closed for writing +(`Mplex/Session.lean`). + +`Conc.Error.deadlineExpired` maps to +`Host.Error.stream (.timeout ...)`. `Conc.Error.closed` maps to +`Host.Error.stream (.closed ...)`. + +`Ping.pingHandler` parks until the stream ends or the session ends. +`Host.close` shuts the sessions down. The +serve task records that the session end wakes a handler parked on +its read (`Swarm.serveSession`). + +### Stream opening + +`Host.newStream` dials through `dialPeer` when no session is live +(`Swarm.newStream`). `dialPeer` dials through `dialAddr`, and +`upgradeConn` runs `upgrade`. `upgrade` bounds that dial with +`withinDeadline` and `Connection.closeQuietly`. A live session skips +the dial. + +The call then opens a mux stream and runs `negotiateStream`. The +answer is the selected id and the stream. `openStream` parks on the +session until the session answers the open. `negotiateStream` then +parks on `stream.read` while the peer sends nothing on that stream +(`StreamSelect.lean`). The module records that no measure bounds +that loop. The caller holds the stream only after the selection. + +The exchange deadline starts after that return. It resets the stream +the caller holds, and the connection stays up. + +A session close reaches a parked stream read. Yamux `teardown` +closes every stream inbox (`Yamux/Loop.lean`). Mplex `endStreams` +aborts every port (`Mplex/Session.lean`). `Host.close` closes each +live session and then joins the session tasks (`Shutdown.lean`). +The host keeps the session handle. The close the CLI can call is +`Host.close`. + +`Host.close` closes the listeners and every session of that host. +§5.4 leaves an authenticated idle connection open. A command closes +a host it owns after the command has failed. `withinDeadline` can +take `newStream` as its work. `onExpiry` has type `BaseIO Unit`, and +`Host.close` is `Async`. A result that arrives after the timer can +hold the stream. `release` frees a value the caller never sees, and +the default `release` drops the value (`Conc/Deadline.lean`). + +### Open question: the deadline around `Host.newStream` + +Which close may a timer around `Host.newStream` run, and which +duration does it use? During negotiation the caller holds no stream. +The exchange reset runs on the stream the call has returned. The +upgrade deadline covers the dial that runs when no session is live. +`Host.close` wakes a read parked in the session through the session +close, and the same call ends the host. No current contract assigns +this timer. An answer has to name the close and the duration before +the dial or the demo adds one. + +The question blocks a negotiation bound on `pingnode dial` and on +each `newStream` of the demo. `pingnode serve` opens no outbound +stream. + +The question for the issue: + +What bounds protocol negotiation inside `Host.newStream`? The stream +returns only after `negotiateStream` finishes, and that read parks +while an authenticated peer stays silent on the stream +(`Libp2p/Host/StreamSelect.lean`). The CLI holds no stream to reset +until the call returns. `Host.close` closes the session and wakes +parked stream reads, and it also ends the host. May `pingnode dial` +and the demo start that close from a timer around `newStream`, and +what duration applies? + +### Stream retirement + +`newStream` returns the stream to its caller (`Swarm.newStream`). + +On success the caller uses `Stream.close`, after the exchange has +its bytes. Ping closes after the echo check. Identify closes after +the client holds the record. `respondTo` has already half-closed +the responder. + +On a protocol failure the caller uses `Stream.reset`. On a timeout +the expiry hook has already reset the stream. On a fault the caller +resets the stream when the stream exists, and the fault propagates +after that reset. The success path is the path that calls +`Stream.close`. + +### Server lifetime + +These rules bind `pingnode serve`. + +The mode registers identify with the bound address, prints `ADDR`, +and flushes stdout. The flush follows a successful listen and that +registration. While stdin stays open, the process accepts a dial and +serves it. The unit 1 check covers that dial with +`LEAN_NUM_THREADS=1`, and again under `+RTS -N1`. + +Stdin EOF is the shutdown signal. `Handle.lines` in the v4.34 +`Init/System/IO.lean` stops when `getLine` returns an empty string, +and the spike's serve loop treats that empty result as EOF. The loop +discards each line. The bytes belong to the operator, so the §5.1 +caps stay on the peer frames. After EOF the mode calls `Host.close` +and exits. + +An inbound peer failure reaches stderr through `onFailure`. The +accept loop keeps running. A host-task fault reaches stderr when +`close` throws it, and the process then exits 1. + +The read's scheduling is an implementation choice, and this slice has +not run it. `IO.asTask` defaults to `Task.Priority.default` +(`Init/Core.lean`). Priorities through `Task.Priority.max` run on the +shared worker pool. `Task.Priority.dedicated` asks for a separate +thread. The unit 1 dial check is what shows whether the chosen +priority keeps the accept loop moving under one pool worker. +`scripts/park-threads.lean` measures `IO.wait`. + +A task that runs the read ends when `getLine` returns or throws. The +owner joins the task after that return, and then calls `Host.close`. +`Host.close` closes listeners, pending connections, and sessions +(`Shutdown.lean`). The sequence leaves stdin open. A cleanup path +joins the reader after that return. The read stays an `IO` action. A +blocking call inside `Async` holds a pool worker (the conc +architecture, Decisions). + +### Demo lifetime + +The demo runs these steps in order. + +1. Generate two Ed25519 identities. +2. Build two hosts. +3. Register ping on the listener. +4. Listen on `/ip4/127.0.0.1/tcp/0`. +5. Register identify with the bound address and the ids registered + so far. +6. Store that address with `addAddrs`, then `dialPeer`. +7. Open a `newStream` for ping, then identify. The echo stream waits + on the open question. +8. Close the dialer, then the listener. + +Every exit closes each host that `Host.new` returned. The call is +`Host.close`. A fault from the first close still runs the second +close. The process exits 1 with the first fault. A demo that +received `onFailure` also exits 1, after both closes. + +On success, stdout carries the dial line, the ping rtt, and the +identify summary. The spike's `Main.lean` is the shape of those +lines. The echo line waits on the open question. + +### Open question: the demo echo home + +The demo exchange includes an echo. The spike's `Main.lean` registers +`/demo/echo/1.0.0` on the listener, reads one chunk, writes those +bytes, and half-closes. The client writes `echo-me` and reads the +reply. + +§2 keeps protocol logic out of `cli/`. §4.9 names identify, ping, +req/resp, and gossipsub. The protocols architecture follows that +list, and the package exports no echo handler. `Tests/Host.lean` +keeps a private handler for `/test/echo`. + +Issue #22 and PR #50 assign no home. The review of PR #50 on 6 +November 2026 records the CLI sentence "echo must come from protocol +test support" under "Checked, no change needed." The merged protocols +package has no such support. + +The implementation slice places the handler and the client after a +maintainer assigns the home. The ping node registers ping and +identify. + +The question for the issue: + +Where does the W5.8 demo echo handler live, and where does its client +live? `cli/` holds no protocol logic (§2). `protocols/` lists +identify, ping, req/resp, and gossipsub (§4.9) and exports no echo +handler. The spike demo still echoes on `/demo/echo/1.0.0`. + ## Decisions -- **Executables only, with no library root.** §2 gives `cli/` no - public API, and the dependency chain of §2 puts it where nothing can - import it. A root module would exist for re-export, and the only - thing to re-export is `main`. +- **A nominal lib, and no public API.** §2 gives `cli/` no public + API, and the dependency chain of §2 puts it where nothing below + can import it. `Cli.lean` declares nothing. The Lake lib `Cli` + exists so the suite can import `Interop` and the gossipsub + modules. Its current roots are `Cli`, `Interop`, + `GossipsubInterop`, and `GossipsubSession`. - **The go-side helpers stay scripts, test-only.** CLAUDE.md's dependencies rule keeps Go and Rust tooling test-only, confined to - the harness, and never on a shipped or proof path. The scripts are - `pingd.go`, `ping-interop.sh`, `interop-matrix.sh`, and - `interop-slowreader/` under the root `scripts/`, beside the W0.3 - vector tooling (§7 row 11's note). The spike also kept a + the harness, and never on a shipped or proof path. The tree holds + `interop-matrix.sh` and `interop-slowreader/` under the root + `scripts/`, beside the W0.3 vector tooling (§7 row 11's note). + Unit 1 adds `pingd.go` and `ping-interop.sh`, in the spike's + ping-interop shape. The spike also kept a test-plans runner image, `interop/leanp2p/{Makefile,Dockerfile}`. The target drops the image, and `scripts/interop-matrix.sh` is the gate's only form, so docker appears only inside the scripts. @@ -149,14 +495,17 @@ three CLI contracts. addresses. - **The demo composition doubles as the composition documentation** (root §2: "shows, as running code, how to compose the stack"). A - reader who wants to compose the stack reads `Main.lean`. + reader who wants to compose the stack reads `Demo.lean`. ## The stack The harness composes the real packages: an Ed25519 identity from `Libp2p.Identity`, the TCP transport, `Noise.securityProtocol` with a -static key from `Noise.Config.generate`, `Mux.yamux`, `Libp2p.Host`, -and ping's handler and initiator from `Libp2p.Protocols.Ping`. +static key from `Noise.Config.generate`, one muxer from the test +plan, `Libp2p.Host`, and ping's handler and initiator from +`Libp2p.Protocols.Ping`. The demo and the ping node use that same +identity, transport, and Noise construction, and their muxer list is +`[Mux.yamux, Mux.mplex]`. ## What the package must not depend on @@ -166,8 +515,11 @@ and ping's handler and initiator from `Libp2p.Protocols.Ping`. - One new external dependency, `leanprover/lean4-cli`, for argument parsing. CLAUDE.md's dependency rule records it: the require block, `lake update`, the pinned rev, and `lake-manifest.json` committed. + The candidate pin is tag `v4.34.0`. The modules section records the + `Cli` name collision and that this tree has not compiled the pin. The hand-rolled parser is the recorded fallback if the pin does not - fit v4.34. The coordination client is in-repo Lean, as the spike's + fit v4.34, and the argument behavior above is the same on either + route. The coordination client is in-repo Lean, as the spike's is, with no Redis client package. - No build glue. The executables build through the declarative package metadata (PRINCIPLES 4.2). diff --git a/packages/cli/docs/PLAN.md b/packages/cli/docs/PLAN.md index f254e3a..8540446 100644 --- a/packages/cli/docs/PLAN.md +++ b/packages/cli/docs/PLAN.md @@ -13,7 +13,7 @@ W5.9 gate runs live, outside the suite runner. | Unit | Root deps | Deliverable | Done when | |---|---|---|---| | 0 | none | this documentation pair, the first work unit of the package (ARCHITECTURE §2) | written, and consistent with the root docs | -| 1 | W5.7 | the demo node and the ping node binaries, with the composition wiring, as running code that documents it (root W5.8) | the root W5.8 row holds, and the unit 1 milestones pass | +| 1 | W5.7 | the demo node and the ping node binaries, with the composition wiring, as running code that documents it (root W5.8) | the root W5.8 row holds, the W5.8 behavior section of the package architecture holds, and the unit 1 milestones pass | | 2 | 1 | the transport-interop harness, closed by its gate: the go-libp2p matrix `tcp + /noise + yamux`, dial and listen, plus the silent-peer and slow-reader scenarios (root W5.9) | the gate passes, and the unit 2 milestones pass | Unit 0 is done. @@ -28,11 +28,43 @@ the same pattern, a live run against foreign nodes. ## Test milestones (unit 1) +The executable roots are `Demo` and `PingNode`, in `packages/cli`. +`Main` stays the root of `gossipsub-interop`. The commit that adds +the files widens the `Cli` lib globs, and it adds the `demo` and +`pingnode` executables. The W5.8 behavior section of the package +architecture is the contract those binaries implement, including the +argument table, the exit codes, the ping and identify deadlines, the +stream retirement, and the two-host close. Those deadlines start +after `newStream` returns. The demo echo home and the deadline around +`Host.newStream` are the open questions in that architecture. The +echo leg waits on the first. The dial and the demo add no bound of +their own on the negotiation inside `newStream` until the second has +an answer. + - The demo runs end to end over loopback: two hosts dial, complete the - upgrade, identify, and exchange pings and an echo. The spike's - node-suite legs are the carried tests (the spike's `Tests/Node.lean` - drives two hosts over loopback TCP through ping, identify, and - echo). + upgrade, identify, and exchange pings. The listener's identify + record carries the bound address and the registered protocol ids. + The echo leg joins this run after the echo question has a home. + Each `newStream` uses the exchange deadline after the call returns. + A bound on the negotiation inside the call waits on the stream-open + question. + The spike's node-suite legs are the carried tests (the spike's + `Tests/Node.lean` drives two hosts over loopback TCP through ping, + identify, and echo). +- `pingnode serve` prints the bound address, flushes it, and stays up + until stdin EOF while an inbound dial still completes. This check + runs with `LEAN_NUM_THREADS=1` and under `+RTS -N1`. The server + lifetime section names the read as an implementation choice. + `scripts/park-threads.lean` measures `IO.wait`. +- A usage error exits 2 before a host exists: a `demo` argument, a + missing or unknown `pingnode` subcommand, an extra operand, a port + outside 0..65535, a multiaddr that fails `Multiaddr.parse?`, and a + dial address that `Host.splitPeer` rejects. +- `pingnode dial` against `pingnode serve` prints `PING OK`, and both + processes close their hosts on the failure paths of the behavior + section. The run uses the exchange deadline after `newStream` + returns. A bound on the negotiation inside that call waits on the + stream-open question. - Ping interop with go (the spike's `ping-interop.sh` shape): the script builds `pingd.go` in docker (go-libp2p v0.50.0, CGO off), starts both nodes on loopback, and dials in both directions over diff --git a/packages/libp2p/protocols/docs/ARCHITECTURE.md b/packages/libp2p/protocols/docs/ARCHITECTURE.md index d34e6cb..da8d434 100644 --- a/packages/libp2p/protocols/docs/ARCHITECTURE.md +++ b/packages/libp2p/protocols/docs/ARCHITECTURE.md @@ -41,9 +41,9 @@ table lands in `Identify.lean`. The `NoiseHandshakePayload` table went to `libp2p/security` beside the handshake it serves. The `Envelope` and `PeerRecord` tables stay deferred (§8; §10, row 17). One protocol per module line keeps the responsibility split of PRINCIPLES 1.2; this -package holds four protocols, and no module holds two. The echo demo of -§2's tree comment (`identify, ping, echo (demo)`) is not in this -package; §4.9 names no echo, so no module line covers it. +package holds four protocols, and no module holds two. §4.9 names +identify, ping, req/resp, and gossipsub, and the module list follows +that sentence. The package exports no echo handler. ## Public surface From 42fcf0420157cda56a0707cfe63868ca69553a53 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Wed, 11 Nov 2026 14:04:51 -0500 Subject: [PATCH 02/15] feat(cli): scaffold demo and pingnode binaries --- docs/DOCS_DEFERRED.md | 7 ++-- lake-manifest.json | 10 +++++ lakefile.toml | 29 +++++++++++-- packages/cli/Cli.lean | 9 ---- packages/cli/CliHarness.lean | 7 ++++ packages/cli/Demo.lean | 20 +++++++++ packages/cli/PingNode.lean | 20 +++++++++ packages/cli/docs/ARCHITECTURE.md | 69 +++++++++++++++++-------------- packages/cli/docs/PLAN.md | 12 +++--- scripts/lint-imports.sh | 6 ++- 10 files changed, 133 insertions(+), 56 deletions(-) delete mode 100644 packages/cli/Cli.lean create mode 100644 packages/cli/CliHarness.lean create mode 100644 packages/cli/Demo.lean create mode 100644 packages/cli/PingNode.lean diff --git a/docs/DOCS_DEFERRED.md b/docs/DOCS_DEFERRED.md index daedee3..a2ad501 100644 --- a/docs/DOCS_DEFERRED.md +++ b/docs/DOCS_DEFERRED.md @@ -341,10 +341,9 @@ findings, for the stage-close cleanup batch: primitives", and one word carries one meaning. - Deferred. `packages/codec/snappy/Snappy.lean:4-5`, unit W6.2 appears in two consecutive sentences; cut one statement of it. -- Deferred. `packages/cli/Cli.lean:7`, "the executables arrive with - W5.8" covers all three binaries the docstring names, but the - interop harness arrives with W5.9. The sentence also joins two - independent claims. +- Closed by the cli scaffold. `packages/cli/Cli.lean` is now + `packages/cli/CliHarness.lean`, and the docstring no longer says + that the executables arrive with W5.8. - Deferred. `packages/crypto/Crypto.lean:4-5`, "one import surface over the pure copies and the backend bindings" names two of the three legs §3.6 enumerates (the etheorem packages are the third) and reads the diff --git a/lake-manifest.json b/lake-manifest.json index f754f81..f75ad0e 100644 --- a/lake-manifest.json +++ b/lake-manifest.json @@ -8,6 +8,16 @@ "inherited": false, "dir": "packages/codec/snappy/native", "configFile": "lakefile.lean"}, + {"url": "https://github.com/leanprover/lean4-cli", + "type": "git", + "subDir": null, + "scope": "", + "rev": "e92c9f15fdfacc8536f31cfb3b7ad26c3c8cd204", + "name": "Cli", + "manifestFile": "lake-manifest.json", + "inputRev": "e92c9f15fdfacc8536f31cfb3b7ad26c3c8cd204", + "inherited": false, + "configFile": "lakefile.toml"}, {"url": "https://github.com/etheorem/etheorem", "type": "git", "subDir": "packages/hazmat/LeanHazmatSecp256k1", diff --git a/lakefile.toml b/lakefile.toml index 9c57070..37d96da 100644 --- a/lakefile.toml +++ b/lakefile.toml @@ -23,7 +23,9 @@ defaultTargets = [ "Libp2pNegotiate", "Libp2pHost", "Libp2pProtocols", - "Cli", + "CliHarness", + "demo", + "pingnode", "Tests", ] # `lake test` builds and runs the executable named here. @@ -78,6 +80,15 @@ git = "https://github.com/etheorem/etheorem" rev = "169cf718a7d149a2973aa4e6468994720a6f356e" subDir = "packages/hazmat/LeanHazmatSecp256k1" +# Argument parsing for the demo and the ping node. Tag `v4.34.0` +# (`e92c9f15fdfacc8536f31cfb3b7ad26c3c8cd204`). The package, its lib, +# and its root module are named `Cli`, so the nominal lib below is +# `CliHarness`. +[[require]] +name = "Cli" +git = "https://github.com/leanprover/lean4-cli" +rev = "e92c9f15fdfacc8536f31cfb3b7ad26c3c8cd204" + # The native helper builds the Snappy static archive. This file stays # the root configuration. The helper adds no Lean library. [[require]] @@ -188,10 +199,10 @@ roots = ["Libp2p.Protocols"] globs = ["Libp2p.Protocols.*"] [[lean_lib]] -name = "Cli" +name = "CliHarness" srcDir = "packages/cli" -roots = ["Cli", "Interop", "GossipsubInterop", "GossipsubSession"] -globs = ["Cli", "Interop.*", "GossipsubInterop", "GossipsubSession"] +roots = ["CliHarness", "Demo", "PingNode", "Interop", "GossipsubInterop", "GossipsubSession"] +globs = ["CliHarness", "Demo", "PingNode", "Interop.*", "GossipsubInterop", "GossipsubSession"] # The transport-interop harness of root PLAN W5.9. [[lean_exe]] @@ -237,3 +248,13 @@ root = "Tests.Interop.Noise" name = "gossipsub-interop" srcDir = "packages/cli" root = "Main" + +[[lean_exe]] +name = "demo" +srcDir = "packages/cli" +root = "Demo" + +[[lean_exe]] +name = "pingnode" +srcDir = "packages/cli" +root = "PingNode" diff --git a/packages/cli/Cli.lean b/packages/cli/Cli.lean deleted file mode 100644 index 473067d..0000000 --- a/packages/cli/Cli.lean +++ /dev/null @@ -1,9 +0,0 @@ -/-! -# Cli - -The executables: the demo node, the ping node, and the interop harness -(ARCHITECTURE §2, "What `cli/` is for"). Scaffold root. -The package is executables only. This nominal library root stays empty. -The executables arrive with W5.8 and W5.9. The package docs live under -`docs/` beside this file. --/ diff --git a/packages/cli/CliHarness.lean b/packages/cli/CliHarness.lean new file mode 100644 index 0000000..a76d8b4 --- /dev/null +++ b/packages/cli/CliHarness.lean @@ -0,0 +1,7 @@ +/-! +# CliHarness + +The nominal library root of `cli/`. The module declares no API. +The parser package owns the module name `Cli`, so this root is +`CliHarness`. The binaries keep the declaration namespace `Cli`. +-/ diff --git a/packages/cli/Demo.lean b/packages/cli/Demo.lean new file mode 100644 index 0000000..9796a5b --- /dev/null +++ b/packages/cli/Demo.lean @@ -0,0 +1,20 @@ +/-! +# Demo + +The `demo` executable. The entry reports that the node is not +implemented yet, and it exits 1. The W5.8 behavior section is the +contract for the later slices. +-/ + +set_option autoImplicit false + +namespace Cli.Demo + +/-- Print the scaffold diagnostic and exit 1. -/ +def main (_args : List String) : IO UInt32 := do + IO.eprintln "demo: not implemented" + return 1 + +end Cli.Demo + +def main : List String → IO UInt32 := Cli.Demo.main diff --git a/packages/cli/PingNode.lean b/packages/cli/PingNode.lean new file mode 100644 index 0000000..2f70b65 --- /dev/null +++ b/packages/cli/PingNode.lean @@ -0,0 +1,20 @@ +/-! +# PingNode + +The `pingnode` executable. The entry reports that the node is not +implemented yet, and it exits 1. The W5.8 behavior section is the +contract for the later slices. +-/ + +set_option autoImplicit false + +namespace Cli.PingNode + +/-- Print the scaffold diagnostic and exit 1. -/ +def main (_args : List String) : IO UInt32 := do + IO.eprintln "pingnode: not implemented" + return 1 + +end Cli.PingNode + +def main : List String → IO UInt32 := Cli.PingNode.main diff --git a/packages/cli/docs/ARCHITECTURE.md b/packages/cli/docs/ARCHITECTURE.md index a45a45c..0d272a5 100644 --- a/packages/cli/docs/ARCHITECTURE.md +++ b/packages/cli/docs/ARCHITECTURE.md @@ -26,27 +26,30 @@ row 18). | `Interop/Resp.lean` | RESP2, pure: the reply header parse with its caps, and the command encoding | | `Interop/Redis.lean` | the harness coordination client, private to the `interop` executable: `PING`, `RPUSH`, and `BLPOP` over one connection; the spike's `LeanP2P/Interop/Redis.lean` mapped here | -`Demo.lean` and `PingNode.lean` land with unit 1. `Main.lean` stays -the gossipsub participant. The Lake lib `Cli` is nominal: `Cli.lean` -declares no API, and the lib exists so the suite can import the -harness (see Decisions). Unit 1 widens that lib's globs for `Demo` -and `PingNode` in the commit that adds the files. Config parsing -lives in each binary, because the contracts differ. Argument parsing -uses `leanprover/lean4-cli` as the first choice. The library is -maintained under the leanprover organization, and CLAUDE.md's "Lean -on Lean" principle prefers a maintained library before a hand-rolled -one. The §2 rule bans reusable code inside `cli/`; the parsing -mechanism lives in the dependency, and each binary keeps its own -parsing code. The hand-rolled parser is the recorded fallback if the -pin does not fit the v4.34 toolchain. The tag `v4.34.0` of -`leanprover/lean4-cli` carries `lean-toolchain` `leanprover/lean4:v4.34.0`, -and its package, lib, and root module are all named `Cli`. This -package's lib and its module `Cli` use that same name, so the commit -that adds the dependency renames this lib and this empty module -together. The namespace `Cli` stays. That tag also sets -`experimental.module`. This tree has not compiled the dependency. -The harness's go-side helpers are scripts under the root `scripts/`, -test-only per CLAUDE.md's dependencies rule. +`Demo.lean` and `PingNode.lean` are the executable roots. Each +`main` prints `not implemented` to stderr and exits 1. `Main.lean` +stays the gossipsub participant. The Lake lib is `CliHarness`. +`CliHarness.lean` declares no API, and the lib exists so the suite +can import the harness (see Decisions). Its globs include `Demo` +and `PingNode`. Config parsing lives in each binary, because the +contracts differ. Argument parsing uses `leanprover/lean4-cli`. +The library is maintained under the leanprover organization, and +CLAUDE.md's "Lean on Lean" principle prefers a maintained library +before a hand-rolled one. The §2 rule bans reusable code inside +`cli/`; the parsing mechanism lives in the dependency, and each +binary keeps its own parsing code. The hand-rolled parser remains +the recorded fallback if a later pin fails the v4.34 toolchain. +The tag `v4.34.0` is commit +`e92c9f15fdfacc8536f31cfb3b7ad26c3c8cd204`. Its `lean-toolchain` is +`leanprover/lean4:v4.34.0`, and its package, lib, and root module +are all named `Cli`. An isolated consumer imported `Cli`, built a +`Cmd`, and called `validate` on this toolchain, and that build +completed. This package's lib and empty root are `CliHarness`, so +the module name `Cli` belongs to the parser. The namespace `Cli` +stays. That tag sets `experimental.module`. These executables do +not parse arguments yet. The harness's go-side helpers are scripts +under the root `scripts/`, test-only per CLAUDE.md's dependencies +rule. ## Public surface @@ -139,8 +142,9 @@ above stays in force. Both files sit in `packages/cli`. The namespaces are `Cli.Demo` and `Cli.PingNode`, the shape `Cli.GossipsubSession` already uses. `gossipsub-interop` keeps the root `Main`. `interop` keeps the root -`Interop.Main`. Unit 1 adds the two `lean_exe` entries. The current -`lakefile.toml` has neither name. +`Interop.Main`. `lakefile.toml` names `demo` and `pingnode`. Each +entry prints `not implemented` to stderr and exits 1. The argument +table below is the contract for the later slice. ### Arguments @@ -455,10 +459,10 @@ handler. The spike demo still echoes on `/demo/echo/1.0.0`. - **A nominal lib, and no public API.** §2 gives `cli/` no public API, and the dependency chain of §2 puts it where nothing below - can import it. `Cli.lean` declares nothing. The Lake lib `Cli` - exists so the suite can import `Interop` and the gossipsub - modules. Its current roots are `Cli`, `Interop`, - `GossipsubInterop`, and `GossipsubSession`. + can import it. `CliHarness.lean` declares nothing. The Lake lib + `CliHarness` exists so the suite can import `Interop` and the + gossipsub modules. Its roots are `CliHarness`, `Demo`, `PingNode`, + `Interop`, `GossipsubInterop`, and `GossipsubSession`. - **The go-side helpers stay scripts, test-only.** CLAUDE.md's dependencies rule keeps Go and Rust tooling test-only, confined to the harness, and never on a shipped or proof path. The tree holds @@ -515,11 +519,12 @@ identity, transport, and Noise construction, and their muxer list is - One new external dependency, `leanprover/lean4-cli`, for argument parsing. CLAUDE.md's dependency rule records it: the require block, `lake update`, the pinned rev, and `lake-manifest.json` committed. - The candidate pin is tag `v4.34.0`. The modules section records the - `Cli` name collision and that this tree has not compiled the pin. - The hand-rolled parser is the recorded fallback if the pin does not - fit v4.34, and the argument behavior above is the same on either - route. The coordination client is in-repo Lean, as the spike's + The pin is tag `v4.34.0`, commit + `e92c9f15fdfacc8536f31cfb3b7ad26c3c8cd204`. The modules section + records the consumer that compiled that commit and the rename to + `CliHarness`. The hand-rolled parser remains the recorded fallback + if a later pin fails v4.34, and the argument behavior above is the + same on either route. The coordination client is in-repo Lean, as the spike's is, with no Redis client package. - No build glue. The executables build through the declarative package metadata (PRINCIPLES 4.2). diff --git a/packages/cli/docs/PLAN.md b/packages/cli/docs/PLAN.md index 8540446..f5175a4 100644 --- a/packages/cli/docs/PLAN.md +++ b/packages/cli/docs/PLAN.md @@ -29,11 +29,13 @@ the same pattern, a live run against foreign nodes. ## Test milestones (unit 1) The executable roots are `Demo` and `PingNode`, in `packages/cli`. -`Main` stays the root of `gossipsub-interop`. The commit that adds -the files widens the `Cli` lib globs, and it adds the `demo` and -`pingnode` executables. The W5.8 behavior section of the package -architecture is the contract those binaries implement, including the -argument table, the exit codes, the ping and identify deadlines, the +`Main` stays the root of `gossipsub-interop`. The lib is +`CliHarness`. Its globs include `Demo` and `PingNode`, and the +lakefile names the `demo` and `pingnode` executables. Each entry +prints `not implemented` and exits 1. The W5.8 behavior section +of the package architecture is the contract those binaries implement, +including the argument table, the exit codes, the ping and identify +deadlines, the stream retirement, and the two-host close. Those deadlines start after `newStream` returns. The demo echo home and the deadline around `Host.newStream` are the open questions in that architecture. The diff --git a/scripts/lint-imports.sh b/scripts/lint-imports.sh index c9cfd20..20f64b2 100755 --- a/scripts/lint-imports.sh +++ b/scripts/lint-imports.sh @@ -26,7 +26,9 @@ # allows but no docs row names is headroom, and the docs win on a # conflict. A file directly # under the group directory is the group root (today the Crypto facade -# and the Cli root); it may import every leaf of its group. base has +# and `CliHarness.lean`); it may import every leaf of its group. The +# module `Cli` is the parser package, and it sits at this end of the +# chain. base has # no order in the docs, so # the rule skips it. The §3.1 carve-out: # within libp2p, negotiate and host import core and no implementation, @@ -148,7 +150,7 @@ while IFS= read -r -d '' f; do *) seg=${mod%%.*} case $seg in - Cli|Interop) gname=cli; grank=0 ;; + Cli|CliHarness|Interop) gname=cli; grank=0 ;; Streams|StreamsShim|Yamux) gname=net; grank=2 ;; Crypto|CryptoPure|Backend) gname=crypto; grank=3 ;; Varint|Multiformats|Proto|Snappy) gname=codec; grank=4 ;; From 11d9b6faab34187abdbe184980b18cc11b158396 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Wed, 11 Nov 2026 14:17:29 -0500 Subject: [PATCH 03/15] feat(cli): validate demo and pingnode arguments --- Tests/Cli.lean | 97 ++++++++++++++++++++++++++ Tests/Main.lean | 2 + packages/cli/Demo.lean | 41 +++++++++-- packages/cli/PingNode.lean | 112 ++++++++++++++++++++++++++++-- packages/cli/docs/ARCHITECTURE.md | 37 +++++++--- packages/cli/docs/PLAN.md | 13 ++-- 6 files changed, 277 insertions(+), 25 deletions(-) create mode 100644 Tests/Cli.lean diff --git a/Tests/Cli.lean b/Tests/Cli.lean new file mode 100644 index 0000000..779d9e0 --- /dev/null +++ b/Tests/Cli.lean @@ -0,0 +1,97 @@ +import Tests.Support + +/-! +# Demo and pingnode arguments + +The checks run the built binaries. Each one records the exit code, +an empty stdout, and the stderr line. A valid list reaches the +unfinished diagnostic. A usage error exits 2. +-/ + +set_option autoImplicit false + +namespace Tests.Cli + +/-- An Ed25519 peer id from the identity suite. -/ +private def peer : String := + "12D3KooWD3eckifWpRn9wQpMG9R9hX3sD158z7EqHWmweQAJU5SA" + +/-- Run one built executable and compare its exit, stdout, and stderr. -/ +private def expect (runner : Runner) (name exe : String) (args : Array String) + (code : UInt32) (stderr : String) : IO Unit := do + let out ← IO.Process.output { cmd := s!".lake/build/bin/{exe}", args } + runner.check s!"{name}: exit {code}" (out.exitCode == code) + runner.check s!"{name}: stdout empty" (out.stdout == "") + runner.check s!"{name}: stderr" (out.stderr == stderr ++ "\n") + +/-- The argument table of the demo and the ping node. -/ +def run (runner : Runner) : IO Unit := do + runner.section "cli arguments" "packages/cli/Demo.lean, packages/cli/PingNode.lean" + expect runner "demo empty" "demo" #[] 1 "demo: not implemented" + expect runner "demo one argument" "demo" #["foo"] 2 + "Redundant positional argument `foo`." + expect runner "demo two arguments" "demo" #["foo", "bar"] 2 + "Redundant positional argument `foo`." + expect runner "demo help" "demo" #["--help"] 2 "Unknown flag `--help`." + expect runner "demo short help" "demo" #["-h"] 2 "Unknown flag `-h`." + expect runner "demo version" "demo" #["--version"] 2 "Unknown flag `--version`." + expect runner "demo end of flags" "demo" #["--"] 2 "unexpected argument: --" + expect runner "pingnode missing subcommand" "pingnode" #[] 2 "missing subcommand" + expect runner "pingnode unknown subcommand" "pingnode" #["foo"] 2 + "Redundant positional argument `foo`." + expect runner "pingnode help" "pingnode" #["--help"] 2 "Unknown flag `--help`." + expect runner "pingnode serve missing port" "pingnode" #["serve"] 2 + "Missing positional argument `.`" + expect runner "pingnode serve 0" "pingnode" #["serve", "0"] 1 + "pingnode: not implemented" + expect runner "pingnode serve 1" "pingnode" #["serve", "1"] 1 + "pingnode: not implemented" + expect runner "pingnode serve 65535" "pingnode" #["serve", "65535"] 1 + "pingnode: not implemented" + expect runner "pingnode serve 65536" "pingnode" #["serve", "65536"] 2 + "invalid port: 65536" + expect runner "pingnode serve 00" "pingnode" #["serve", "00"] 2 "invalid port: 00" + expect runner "pingnode serve 01" "pingnode" #["serve", "01"] 2 "invalid port: 01" + expect runner "pingnode serve sign" "pingnode" #["serve", "-1"] 2 "Unknown flag `-1`." + expect runner "pingnode serve plus" "pingnode" #["serve", "+1"] 2 "invalid port: +1" + expect runner "pingnode serve leading space" "pingnode" #["serve", " 1"] 2 + "invalid port: 1" + expect runner "pingnode serve trailing space" "pingnode" #["serve", "1 "] 2 + "invalid port: 1 " + expect runner "pingnode serve nondigit" "pingnode" #["serve", "1a"] 2 + "invalid port: 1a" + expect runner "pingnode serve extra" "pingnode" #["serve", "1", "extra"] 2 + "Redundant positional argument `extra`." + let dial := s!"/ip4/127.0.0.1/tcp/4001/p2p/{peer}" + expect runner "pingnode dial missing address" "pingnode" #["dial"] 2 + "Missing positional argument `.`" + expect runner "pingnode dial" "pingnode" #[ "dial", dial ] 1 + "pingnode: not implemented" + expect runner "pingnode dial port 0" "pingnode" + #[ "dial", s!"/ip4/127.0.0.1/tcp/0/p2p/{peer}" ] 1 + "pingnode: not implemented" + expect runner "pingnode dial extra" "pingnode" #[ "dial", dial, "extra" ] 2 + "Redundant positional argument `extra`." + expect runner "pingnode dial parse" "pingnode" #["dial", "not-an-addr"] 2 + "Multiformats.Error.badAddress not-an-addr" + expect runner "pingnode dial bare peer component" "pingnode" #["dial", "/p2p"] 2 + "Multiformats.Error.badValue /p2p" + expect runner "pingnode dial no peer" "pingnode" #["dial", "/ip4/127.0.0.1/tcp/4001"] 2 + "dial address has no final /p2p peer: /ip4/127.0.0.1/tcp/4001" + expect runner "pingnode dial peer only" "pingnode" #[ "dial", s!"/p2p/{peer}" ] 2 + s!"no transport handles /p2p/{peer} /p2p/{peer}" + expect runner "pingnode dial peer not final" "pingnode" + #[ "dial", s!"/ip4/127.0.0.1/p2p/{peer}/tcp/4001" ] 2 + s!"dial address has no final /p2p peer: /ip4/127.0.0.1/p2p/{peer}/tcp/4001" + expect runner "pingnode dial repeated peer" "pingnode" + #[ "dial", s!"/ip4/127.0.0.1/tcp/4001/p2p/{peer}/p2p/{peer}" ] 2 + s!"dial address has an earlier /p2p peer: /ip4/127.0.0.1/tcp/4001/p2p/{peer}/p2p/{peer}" + expect runner "pingnode dial earlier peer" "pingnode" + #[ "dial", s!"/ip4/127.0.0.1/p2p/{peer}/tcp/4001/p2p/{peer}" ] 2 + s!"dial address has an earlier /p2p peer: /ip4/127.0.0.1/p2p/{peer}/tcp/4001/p2p/{peer}" + expect runner "pingnode serve newline" "pingnode" #["serve", "1\n"] 2 + "invalid port: 1\\n" + expect runner "pingnode dial newline" "pingnode" #["dial", "not-an-addr\n"] 2 + "Multiformats.Error.badAddress not-an-addr\\n" + +end Tests.Cli diff --git a/Tests/Main.lean b/Tests/Main.lean index 0461964..b777ecd 100644 --- a/Tests/Main.lean +++ b/Tests/Main.lean @@ -38,6 +38,7 @@ import Tests.Host import Tests.Protocols import Tests.ReqResp import Tests.Interop +import Tests.Cli /-! # Test entry point @@ -91,4 +92,5 @@ def main : IO UInt32 := do Tests.Protocols.run r Tests.ReqResp.run r Tests.Interop.run r + Tests.Cli.run r r.summary diff --git a/packages/cli/Demo.lean b/packages/cli/Demo.lean index 9796a5b..308c0f8 100644 --- a/packages/cli/Demo.lean +++ b/packages/cli/Demo.lean @@ -1,20 +1,51 @@ +import Cli + /-! # Demo -The `demo` executable. The entry reports that the node is not -implemented yet, and it exits 1. The W5.8 behavior section is the -contract for the later slices. +The `demo` executable. `Cmd.parse` checks the argument list. `Cmd.mk` +would add help and version flags, so this command is built with +`Cmd.init` and an empty flag list. A usage error exits 2. An empty +argument list reaches the unfinished node and exits 1. -/ set_option autoImplicit false namespace Cli.Demo -/-- Print the scaffold diagnostic and exit 1. -/ -def main (_args : List String) : IO UInt32 := do +open Cli + +/-- The command carries no flags and no operands. -/ +private def command : Cmd := + .init { + name := "demo" + parentNames := #[] + version? := none + description := "Run the loopback demo." + flags := #[] + positionalArgs := #[] + variableArg? := none + } (fun _ => pure 0) #[] none + +/-- Print one usage line and exit 2. -/ +private def usage (line : String) : IO UInt32 := do + IO.eprintln line + return 2 + +/-- The node is not built in this slice. -/ +private def unfinished : IO UInt32 := do IO.eprintln "demo: not implemented" return 1 +/-- Check `args`, then report that the node is not implemented. -/ +def main (args : List String) : IO UInt32 := do + match command.parse args with + | .error err => usage err.kind.msg + | .ok _ => + -- `parse` treats `--` as the end of flags, so an empty command + -- can still have seen an argument. + if args.isEmpty then unfinished else usage s!"unexpected argument: {" ".intercalate args}" + end Cli.Demo def main : List String → IO UInt32 := Cli.Demo.main diff --git a/packages/cli/PingNode.lean b/packages/cli/PingNode.lean index 2f70b65..9348ec7 100644 --- a/packages/cli/PingNode.lean +++ b/packages/cli/PingNode.lean @@ -1,20 +1,122 @@ +import Cli +import Multiformats +import Libp2p.Host.Swarm + /-! # PingNode -The `pingnode` executable. The entry reports that the node is not -implemented yet, and it exits 1. The W5.8 behavior section is the -contract for the later slices. +The `pingnode` executable. `Cmd.parse` accepts `serve ` or +`dial `. `Cmd.init` keeps the help and version flags out +of the command. A usage error exits 2, before any host exists. +`splitPeer` drops the trailing peer. A `p2p` component left in the +transport prefix is a usage error. A line break in an operand is +escaped in the usage line. A valid argument list reaches the +unfinished node and exits 1. -/ set_option autoImplicit false namespace Cli.PingNode -/-- Print the scaffold diagnostic and exit 1. -/ -def main (_args : List String) : IO UInt32 := do +open Cli +open Libp2p.Host (Host) + +/-- One string operand. -/ +private def operand (name description : String) : Arg := + { name, description, type := String } + +/-- A subcommand with one operand and no flags. -/ +private def leaf (name argName description : String) : Cmd := + .init { + name + parentNames := #["pingnode"] + version? := none + description + flags := #[] + positionalArgs := #[operand argName description] + variableArg? := none + } (fun _ => pure 0) #[] none + +/-- `serve` and `dial`, and no flags on the root. -/ +private def command : Cmd := + .init { + name := "pingnode" + parentNames := #[] + version? := none + description := "Serve or dial a ping." + flags := #[] + positionalArgs := #[] + variableArg? := none + } (fun _ => pure 0) #[ + leaf "serve" "port" "Listen on a port.", + leaf "dial" "multiaddr" "Dial a multiaddr." + ] none + +/-- Replace a newline with `\n` and a carriage return with `\r`. -/ +private def oneLine (text : String) : String := + (text.replace "\r" "\\r").replace "\n" "\\n" + +/-- Print one usage line and exit 2. -/ +private def usage (line : String) : IO UInt32 := do + IO.eprintln (oneLine line) + return 2 + +/-- The node is not built in this slice. -/ +private def unfinished : IO UInt32 := do IO.eprintln "pingnode: not implemented" return 1 +/-- `0`, or a decimal from 1 to 65535 with no leading zero. -/ +private def validPort (text : String) : Bool := + if text == "0" then true + else + text.all Char.isDigit && text.front?.any (· != '0') && + match text.toNat? with + | some n => n ≤ 65535 + | none => false + +/-- The operand of the selected subcommand. -/ +private def operandValue (parsed : Parsed) (name : String) : Option String := + parsed.positionalArg? name |>.map (·.value) + +/-- True when `transport` still contains a `/p2p` component. -/ +private def transportHasPeer (transport : Multiformats.Multiaddr) : Bool := + transport.components.any fun c => + decide (c.proto = Multiformats.Protocol.«p2p») + +/-- Accept a final `/p2p/` whose transport prefix has no `/p2p`. -/ +private def validDial (text : String) : Except String Unit := do + let addr ← match Multiformats.Multiaddr.parse? text with + | .ok addr => pure addr + | .error err => throw s!"{reprStr err} {text}" + match Host.splitPeer addr with + | .ok (transport, some _) => + if transportHasPeer transport then + throw s!"dial address has an earlier /p2p peer: {text}" + else pure () + | .ok (_, none) => throw s!"dial address has no final /p2p peer: {text}" + | .error err => throw s!"{err} {text}" + +/-- Check `args`, then report that the node is not implemented. -/ +def main (args : List String) : IO UInt32 := do + match command.parse args with + | .error err => usage err.kind.msg + | .ok (cmd, parsed) => + match cmd.meta.name with + | "serve" => + match operandValue parsed "port" with + | some port => + if validPort port then unfinished else usage s!"invalid port: {port}" + | none => usage "missing port" + | "dial" => + match operandValue parsed "multiaddr" with + | some text => + match validDial text with + | .ok () => unfinished + | .error line => usage line + | none => usage "missing multiaddr" + | _ => usage "missing subcommand" + end Cli.PingNode def main : List String → IO UInt32 := Cli.PingNode.main diff --git a/packages/cli/docs/ARCHITECTURE.md b/packages/cli/docs/ARCHITECTURE.md index 0d272a5..30537c8 100644 --- a/packages/cli/docs/ARCHITECTURE.md +++ b/packages/cli/docs/ARCHITECTURE.md @@ -27,7 +27,9 @@ row 18). | `Interop/Redis.lean` | the harness coordination client, private to the `interop` executable: `PING`, `RPUSH`, and `BLPOP` over one connection; the spike's `LeanP2P/Interop/Redis.lean` mapped here | `Demo.lean` and `PingNode.lean` are the executable roots. Each -`main` prints `not implemented` to stderr and exits 1. `Main.lean` +`main` checks its arguments with `Cmd.parse`. A usage error exits 2. +A valid list prints `not implemented` to stderr and exits 1. +`Main.lean` stays the gossipsub participant. The Lake lib is `CliHarness`. `CliHarness.lean` declares no API, and the lib exists so the suite can import the harness (see Decisions). Its globs include `Demo` @@ -142,9 +144,9 @@ above stays in force. Both files sit in `packages/cli`. The namespaces are `Cli.Demo` and `Cli.PingNode`, the shape `Cli.GossipsubSession` already uses. `gossipsub-interop` keeps the root `Main`. `interop` keeps the root -`Interop.Main`. `lakefile.toml` names `demo` and `pingnode`. Each -entry prints `not implemented` to stderr and exits 1. The argument -table below is the contract for the later slice. +`Interop.Main`. `lakefile.toml` names `demo` and `pingnode`. The +argument table below is in force. A valid list then prints +`not implemented` to stderr and exits 1. ### Arguments @@ -168,13 +170,26 @@ is a usage error. The stderr line is the failure text and the operand. The last component of a dial address is `/p2p/`. -`Host.splitPeer` returns the transport address and a `PeerId`. A -missing final component, a peer component in another position, an -address whose only component is `/p2p/`, and any other -`splitPeer` failure are usage errors. The dial then uses that peer -id as the expected identity. A port of 0 inside a parsed dial -address reaches the transport, and the transport reports the result -as a runtime failure. +`Host.splitPeer` returns the transport address and a `PeerId`. +`splitPeer` removes only that trailing component. A `p2p` component +that remains in the transport prefix is a usage error. A missing +final component, a peer component in another position, an address +whose only component is `/p2p/`, and any other `splitPeer` +failure are usage errors. The dial then uses that peer id as the +expected identity. A port of 0 inside a parsed dial address reaches +the transport, and the transport reports the result as a runtime +failure. This slice accepts that address and then takes the +unfinished path. + +The commands are `Cmd.init` values with an empty flag list, so help +and version flags are absent. `Cmd.parse` answers the parser error. +A usage line is that message, `reprStr` of a `Multiaddr.parse?` +error followed by the operand, `Host.Error.render` of a `splitPeer` +error followed by the operand, or `dial address has an earlier /p2p +peer:` followed by the operand. `.ok (_, none)` from `splitPeer` is +a usage error. A newline in an operand is written as `\n`, and a +carriage return as `\r`, before the line is printed. The process +creates no host. ### Exit status diff --git a/packages/cli/docs/PLAN.md b/packages/cli/docs/PLAN.md index f5175a4..26bccab 100644 --- a/packages/cli/docs/PLAN.md +++ b/packages/cli/docs/PLAN.md @@ -31,8 +31,9 @@ the same pattern, a live run against foreign nodes. The executable roots are `Demo` and `PingNode`, in `packages/cli`. `Main` stays the root of `gossipsub-interop`. The lib is `CliHarness`. Its globs include `Demo` and `PingNode`, and the -lakefile names the `demo` and `pingnode` executables. Each entry -prints `not implemented` and exits 1. The W5.8 behavior section +lakefile names the `demo` and `pingnode` executables. A usage +error exits 2. A valid list prints `not implemented` and exits 1. +The W5.8 behavior section of the package architecture is the contract those binaries implement, including the argument table, the exit codes, the ping and identify deadlines, the @@ -60,8 +61,12 @@ an answer. `scripts/park-threads.lean` measures `IO.wait`. - A usage error exits 2 before a host exists: a `demo` argument, a missing or unknown `pingnode` subcommand, an extra operand, a port - outside 0..65535, a multiaddr that fails `Multiaddr.parse?`, and a - dial address that `Host.splitPeer` rejects. + outside 0..65535, a multiaddr that fails `Multiaddr.parse?`, a + dial address that `Host.splitPeer` rejects, and a dial address + whose transport prefix still contains a `p2p` component. A line + break in an operand stays inside that one stderr line. + `Tests/Cli.lean` runs the built binaries for these cases. A valid + list still exits 1 with the unfinished diagnostic. - `pingnode dial` against `pingnode serve` prints `PING OK`, and both processes close their hosts on the failure paths of the behavior section. The run uses the exchange deadline after `newStream` From 200b8ebd5ab74cac9b1b0aec060a2da1887c880b Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Wed, 11 Nov 2026 14:51:01 -0500 Subject: [PATCH 04/15] feat(cli): serve ping and identify from pingnode --- Tests/Cli.lean | 142 +++++++++- Tests/CliLive.lean | 439 ++++++++++++++++++++++++++++++ Tests/Main.lean | 9 +- packages/cli/PingNode.lean | 147 +++++++++- packages/cli/docs/ARCHITECTURE.md | 80 +++--- packages/cli/docs/PLAN.md | 24 +- 6 files changed, 783 insertions(+), 58 deletions(-) create mode 100644 Tests/CliLive.lean diff --git a/Tests/Cli.lean b/Tests/Cli.lean index 779d9e0..f4c6f4d 100644 --- a/Tests/Cli.lean +++ b/Tests/Cli.lean @@ -1,11 +1,12 @@ +import Conc import Tests.Support /-! # Demo and pingnode arguments -The checks run the built binaries. Each one records the exit code, -an empty stdout, and the stderr line. A valid list reaches the -unfinished diagnostic. A usage error exits 2. +The checks run the built binaries. A usage error exits 2 with an +empty stdout. `demo` and `pingnode dial` still reach the unfinished +diagnostic. `pingnode serve` with a valid port listens. -/ set_option autoImplicit false @@ -16,6 +17,132 @@ namespace Tests.Cli private def peer : String := "12D3KooWD3eckifWpRn9wQpMG9R9hX3sD158z7EqHWmweQAJU5SA" +/-- The single stdout line, when the process wrote exactly one. -/ +private def oneStdout (stdout : String) : Option String := + if stdout.endsWith "\n" && !((stdout.dropEnd 1).any (· == '\n')) then + some (stdout.dropEnd 1).copy + else none + +/-- The port text of a loopback TCP address. -/ +private def boundPort (addr : String) : Option String := + let head := "/ip4/127.0.0.1/tcp/" + if addr.startsWith head then some (addr.drop head.length).copy else none + +/-- A kernel-assigned port: digits, no leading zero, and at most 65535. -/ +private def realPort (text : String) : Bool := + text != "0" && text.all Char.isDigit && text.front?.any (· != '0') && + match text.toNat? with + | some n => n ≤ 65535 + | none => false + +/-- Pause the caller. -/ +def pause (ms : Nat) : IO Unit := do + let park ← Conc.Park.sleep ms () + Conc.Async.run (Conc.select #[park] : Conc.Async Unit) + +/-- Kill a child that is still running, then reap it. A second call +meets a process that is already gone. -/ +def reap {cfg : IO.Process.StdioConfig} (child : IO.Process.Child cfg) : IO Unit := do + try + match ← child.tryWait with + | some _ => pure () + | none => + child.kill + discard child.wait + catch _ => pure () + +/-- Wait for the child. At the deadline, kill it and reap it. -/ +def waitChild {cfg : IO.Process.StdioConfig} (child : IO.Process.Child cfg) + (ms : Nat) : IO (UInt32 × Bool) := do + let start ← IO.monoMsNow + repeat + match ← child.tryWait with + | some code => return (code, false) + | none => + if (← IO.monoMsNow) - start ≥ ms then + child.kill + return (← child.wait, true) + pause 20 + +/-- The captured output of one bounded run. -/ +structure Captured where + exitCode : UInt32 + stdout : String + stderr : String + timedOut : Bool + +/-- Run `cmd` until it exits or `ms` passes. The deadline kills the +child, which closes the pipes and wakes the readers. This function +then joins both readers. -/ +def capture (cmd : String) (args : Array String) (ms : Nat) + (env : Array (String × Option String) := #[]) : IO Captured := do + let child ← IO.Process.spawn { + cmd + args + env + stdin := .null + stdout := .piped + stderr := .piped + } + let mut outTask : Option (Task (Except IO.Error String)) := none + let mut errTask : Option (Task (Except IO.Error String)) := none + let mut code : UInt32 := 0 + let mut timedOut := false + let mut fault : Option IO.Error := none + try + outTask := some (← IO.asTask child.stdout.readToEnd Task.Priority.dedicated) + errTask := some (← IO.asTask child.stderr.readToEnd Task.Priority.dedicated) + let waited ← waitChild child ms + code := waited.1 + timedOut := waited.2 + catch e => + fault := some e + -- The kill closes both pipes, so a reader that exists can finish. + reap child + let mut stdout := "" + let mut stderr := "" + if let some task := outTask then + try + stdout ← IO.ofExcept (← IO.wait task) + catch e => + if fault.isNone then fault := some e + if let some task := errTask then + try + stderr ← IO.ofExcept (← IO.wait task) + catch e => + if fault.isNone then fault := some e + if let some e := fault then throw e + return { exitCode := code, stdout, stderr, timedOut } + +/-- `serve` with a valid port. Port 0 must bind. Another port may be +busy or unprivileged, and then the process exits 1 with empty stdout. +The process has 10 s. A miss kills it and joins its readers. -/ +private def expectServe (runner : Runner) (name port : String) (requireBind : Bool) : + IO Unit := do + let out ← capture ".lake/build/bin/pingnode" #["serve", port] 10000 + if out.timedOut then + runner.check s!"{name}: exited within 10 s" false + return + if out.exitCode == 0 then + match oneStdout out.stdout with + | some line => + match line.splitOn " " with + | ["ADDR", addr, peer] => + match boundPort addr with + | some got => + let portOk := if port == "0" then realPort got else got == port + runner.check s!"{name}: bound address" (out.stderr == "" && peer != "" && portOk) + | none => runner.check s!"{name}: loopback address" false + | _ => runner.check s!"{name}: ADDR fields" false + | none => runner.check s!"{name}: one stdout line" false + else if requireBind then + runner.check s!"{name}: exit 0, got {out.exitCode}, stderr {out.stderr}" false + else + runner.check s!"{name}: runtime exit" (out.exitCode == 1) + runner.check s!"{name}: stdout empty" (out.stdout == "") + runner.check s!"{name}: runtime stderr" + (out.stderr != "" && !out.stderr.startsWith "invalid port:") + /-- Run one built executable and compare its exit, stdout, and stderr. -/ private def expect (runner : Runner) (name exe : String) (args : Array String) (code : UInt32) (stderr : String) : IO Unit := do @@ -42,12 +169,9 @@ def run (runner : Runner) : IO Unit := do expect runner "pingnode help" "pingnode" #["--help"] 2 "Unknown flag `--help`." expect runner "pingnode serve missing port" "pingnode" #["serve"] 2 "Missing positional argument `.`" - expect runner "pingnode serve 0" "pingnode" #["serve", "0"] 1 - "pingnode: not implemented" - expect runner "pingnode serve 1" "pingnode" #["serve", "1"] 1 - "pingnode: not implemented" - expect runner "pingnode serve 65535" "pingnode" #["serve", "65535"] 1 - "pingnode: not implemented" + expectServe runner "pingnode serve 0" "0" true + expectServe runner "pingnode serve 1" "1" false + expectServe runner "pingnode serve 65535" "65535" false expect runner "pingnode serve 65536" "pingnode" #["serve", "65536"] 2 "invalid port: 65536" expect runner "pingnode serve 00" "pingnode" #["serve", "00"] 2 "invalid port: 00" diff --git a/Tests/CliLive.lean b/Tests/CliLive.lean new file mode 100644 index 0000000..70cc6ec --- /dev/null +++ b/Tests/CliLive.lean @@ -0,0 +1,439 @@ +import Conc +import Crypto +import Streams +import Multiformats +import Libp2p.Identity +import Libp2p.Security +import Libp2p.Mux +import Libp2p.Host +import Libp2p.Protocols.Ping +import Libp2p.Protocols.Identify +import Tests.Cli +import Tests.Support + +/-! +# The pingnode server + +The checks run the built `pingnode` with `LEAN_NUM_THREADS=1`. One +client host dials it over TCP and Noise while stdin stays open. The +other checks cover EOF, a busy port, a failed `ADDR` write, an inbound +failure, a deadline that kills a sleeper, and a registered task that +returns an error. + +Each reader task belongs to the check that started it. The check wakes +the task by closing the pipe it reads, and then joins it. +-/ + +set_option autoImplicit false + +namespace Tests.CliLive + +open Libp2p.Host (Host) +open Libp2p.Identity (PeerId PrivKey) + +/-- The built server. -/ +private def bin : String := ".lake/build/bin/pingnode" + +/-- One worker in the server process. -/ +private def oneWorker : Array (String × Option String) := + #[("LEAN_NUM_THREADS", some "1")] + +/-- Drop the trailing line break from `getLine`. -/ +private def stripNl (text : String) : String := + let text := if text.endsWith "\n" then (text.dropEnd 1).copy else text + if text.endsWith "\r" then (text.dropEnd 1).copy else text + +/-- The port text of a loopback TCP address. -/ +private def portOf (addr : String) : Option String := + let head := "/ip4/127.0.0.1/tcp/" + if addr.startsWith head then some (addr.drop head.length).copy else none + +/-- Keep the first fault. -/ +private def note (primary : IO.Ref (Option IO.Error)) (e : IO.Error) : IO Unit := do + if (← primary.get).isNone then primary.set (some e) + +/-- Read one stdout line. The task's owner is this function. A miss +kills `child`, which closes the pipe and wakes the read, and then this +function joins the task. -/ +private def getLineWithin {cfg : IO.Process.StdioConfig} (child : IO.Process.Child cfg) + (h : IO.FS.Handle) (ms : Nat) : IO String := do + let task ← IO.asTask h.getLine Task.Priority.dedicated + let mut timedOut := false + let mut fault : Option IO.Error := none + try + let start ← IO.monoMsNow + repeat + if ← IO.hasFinished task then + break + if (← IO.monoMsNow) - start ≥ ms then + timedOut := true + break + Tests.Cli.pause 20 + catch e => + fault := some e + if timedOut || fault.isSome then + Tests.Cli.reap child + let result ← IO.wait task + if let some e := fault then throw e + if timedOut then + throw <| IO.userError s!"no stdout line within {ms} ms" + IO.ofExcept result + +/-- Append stderr lines until EOF. The caller owns the task and joins +it after the write end closes. -/ +private def collectLines (h : IO.FS.Handle) (lines : IO.Ref (Array String)) : IO Unit := do + repeat + let line ← h.getLine + if line.isEmpty then return + lines.modify (·.push (stripNl line)) + +/-- A fresh client host with the same stack as `pingnode serve`. -/ +private def clientHost : IO Host := do + let seed ← match ← Crypto.ed25519Generate.run with + | .error e => throw <| IO.userError (reprStr e) + | .ok seed => pure seed + let key ← match PrivKey.ofData? .ed25519 seed.bytes with + | .error e => throw <| IO.userError (reprStr e) + | .ok key => pure key + let noise ← match ← (Libp2p.Security.Noise.Config.generate key).run with + | .error e => throw <| IO.userError (reprStr e) + | .ok noise => pure noise + match ← (Host.new { + identity := key + transports := [Streams.Tcp.transport] + security := [Libp2p.Security.Noise.securityProtocol noise] + muxers := [Libp2p.Mux.yamux, Libp2p.Mux.mplex] + } : BaseIO _) with + | .error e => throw <| IO.userError (reprStr e) + | .ok host => return host + +/-- Dial `peer`, ping once, and read its identify record. `host.close` +finishes before this function returns, including when the exchange throws. -/ +private def exchange (addr : Multiformats.Multiaddr) (peer : PeerId) : IO Unit := do + let host ← clientHost + let mut primary : Option IO.Error := none + try + Conc.Async.run do + match ← host.dial addr (some peer) with + | .error e => throw <| IO.userError s!"dial: {e.render}" + | .ok got => + if got.hash != peer.hash then + throw <| IO.userError "dial proved a different peer" + let payload ← match ← (Crypto.randomBytes Libp2p.Protocols.Ping.payloadSize).run with + | .error e => throw <| IO.userError (reprStr e) + | .ok payload => pure payload + match ← host.newStream peer [Libp2p.Protocols.Ping.protocolId] with + | .error e => throw <| IO.userError s!"ping stream: {e.render}" + | .ok (_, stream) => + match ← Libp2p.Protocols.Ping.ping stream Conc.Clock.system payload with + | .error e => + stream.reset + throw <| IO.userError s!"ping: {reprStr e}" + | .ok none => + stream.reset + throw <| IO.userError "ping returned none" + | .ok (some _) => stream.close + match ← host.newStream peer [Libp2p.Protocols.Identify.protocolId] with + | .error e => throw <| IO.userError s!"identify stream: {e.render}" + | .ok (_, stream) => + match ← Libp2p.Protocols.Identify.client stream Conc.Clock.system + Streams.unauthDeadlineMs (some peer) with + | .error e => + stream.reset + throw <| IO.userError s!"identify: {reprStr e}" + | .ok none => + stream.reset + throw <| IO.userError "identify returned none" + | .ok (some (msg, got)) => + stream.close + if got.hash != peer.hash then + throw <| IO.userError "identify peer differs" + if msg.agentVersion != some "leanp2p" then + throw <| IO.userError s!"agent {repr msg.agentVersion}" + if !(msg.listenAddrs.any (· == addr.toBytes)) then + throw <| IO.userError "identify omitted the bound address" + let protocols := [ + Libp2p.Protocols.Ping.protocolId.toString, + Libp2p.Protocols.Identify.protocolId.toString + ] + if msg.protocols != protocols then + throw <| IO.userError s!"protocols {msg.protocols}" + catch e => + primary := some e + try + Conc.Async.run host.close + catch e => + if primary.isNone then primary := some e + if let some e := primary then throw e + +/-- Open a TCP connection and close it, so the server records one inbound failure. -/ +private def disturb (addr : Multiformats.Multiaddr) : IO Unit := do + match ← Conc.Async.run ((Streams.Tcp.transport).dial addr) with + | none => throw <| IO.userError "disturb found no listener" + | some conn => conn.close + +/-- Run `act` on a task this function owns. A miss closes `stdin` and +kills `child`, which wakes the dial, and then this function joins the +task before it throws. -/ +private def tracked {cfg : IO.Process.StdioConfig} (child : IO.Process.Child cfg) + (stdinRef : IO.Ref (Option IO.FS.Handle)) + (extra : IO.Ref (Array (Task (Except IO.Error Unit)))) + (act : IO Unit) (ms : Nat) : IO Unit := do + let task ← IO.asTask act Task.Priority.dedicated + extra.modify (·.push task) + let mut timedOut := false + let mut fault : Option IO.Error := none + try + let start ← IO.monoMsNow + repeat + if ← IO.hasFinished task then + break + if (← IO.monoMsNow) - start ≥ ms then + timedOut := true + break + Tests.Cli.pause 20 + catch e => + fault := some e + if timedOut || fault.isSome then + stdinRef.set none + Tests.Cli.reap child + let result ← IO.wait task + if let some e := fault then throw e + if timedOut then + throw <| IO.userError s!"exchange missed {ms} ms" + IO.ofExcept result + +/-- The server after `ADDR` is visible. `stdin` is the last owner of +the pipe. `lines` fills from the stderr task. -/ +private structure Session where + child : IO.Process.Child { stdin := .null, stdout := .piped, stderr := .piped } + stdin : IO.Ref (Option IO.FS.Handle) + lines : IO.Ref (Array String) + extra : IO.Ref (Array (Task (Except IO.Error Unit))) + addr : Multiformats.Multiaddr + peer : PeerId + +/-- Spawn `pingnode serve 0`, read `ADDR`, run `k`, then drop stdin, +reap the child, and join every task `k` registered. The child dying +wakes those tasks. `IO.wait` returns `Except`, so a `.error` is +recorded here. The first fault is the one that propagates, and the +throw follows the joins. -/ +private def withSession (k : Session → IO Unit) : IO Unit := do + let primary ← IO.mkRef (none : Option IO.Error) + let stdinRef ← IO.mkRef (none : Option IO.FS.Handle) + let lines ← IO.mkRef (#[] : Array String) + let extra ← IO.mkRef (#[] : Array (Task (Except IO.Error Unit))) + let child ← do + let (stdin, child) ← (← IO.Process.spawn { + cmd := bin + args := #["serve", "0"] + stdin := .piped + stdout := .piped + stderr := .piped + env := oneWorker + }).takeStdin + stdinRef.set (some stdin) + pure child + try + let errTask ← IO.asTask (collectLines child.stderr lines) Task.Priority.dedicated + extra.modify (·.push errTask) + let line ← getLineWithin child child.stdout 10000 + let (addrText, peerText) ← match (stripNl line).splitOn " " with + | ["ADDR", addrText, peerText] => pure (addrText, peerText) + | _ => throw <| IO.userError s!"bad ADDR line: {line}" + let some port := portOf addrText | throw <| IO.userError s!"address {addrText}" + if port == "0" || !port.all Char.isDigit then + throw <| IO.userError s!"bound port {port}" + let addr ← match Multiformats.Multiaddr.parse? addrText with + | .ok addr => pure addr + | .error e => throw <| IO.userError (reprStr e) + let peer ← match PeerId.ofString? peerText with + | .ok peer => pure peer + | .error e => throw <| IO.userError (reprStr e) + k { child, stdin := stdinRef, lines, extra, addr, peer } + catch e => + note primary e + stdinRef.set none + Tests.Cli.reap child + for task in ← extra.get do + try + match ← IO.wait task with + | .ok () => pure () + | .error e => note primary e + catch e => + note primary e + if let some e ← primary.get then throw e + +/-- `serve 0` accepts a ping and an identify while stdin stays open. -/ +private def live (runner : Runner) : IO Unit := do + withSession fun session => do + let some input ← session.stdin.get | throw <| IO.userError "stdin is already closed" + input.putStr "hello\n" + input.flush + tracked session.child session.stdin session.extra + (exchange session.addr session.peer) 20000 + session.stdin.set none + let (code, killed) ← Tests.Cli.waitChild session.child 10000 + let rest ← session.child.stdout.readToEnd + -- Join the stderr reader after the process is gone, so the list + -- holds every line it wrote. The checks below run only after + -- every joined result is `.ok`. + let mut fault : Option IO.Error := none + for task in ← session.extra.get do + try + match ← IO.wait task with + | .ok () => pure () + | .error e => + if fault.isNone then fault := some e + catch e => + if fault.isNone then fault := some e + if let some e := fault then throw e + let lines ← session.lines.get + runner.check "serve live exit" (code == 0 && !killed) + runner.check "serve live stderr" (lines == #[]) + runner.check "serve live no extra stdout" (rest == "") + +/-- One inbound failure, then a successful exchange on the same server. -/ +private def inboundThenServe (runner : Runner) : IO Unit := do + withSession fun session => do + disturb session.addr + let start ← IO.monoMsNow + let mut seen : Option String := none + repeat + let got ← session.lines.get + match got[0]? with + | some text => + seen := some text + break + | none => + if (← IO.monoMsNow) - start ≥ 5000 then + throw <| IO.userError "no inbound failure line" + Tests.Cli.pause 20 + let some line := seen | throw <| IO.userError "no inbound failure line" + runner.check "serve inbound failure" (line.startsWith "upgrade failed:") + tracked session.child session.stdin session.extra + (exchange session.addr session.peer) 20000 + session.stdin.set none + let (code, killed) ← Tests.Cli.waitChild session.child 10000 + runner.check "serve continues after failure" (code == 0 && !killed) + +/-- Immediate EOF still prints `ADDR` and exits 0. -/ +private def immediateEof (runner : Runner) : IO Unit := do + let out ← Tests.Cli.capture bin #["serve", "0"] 10000 oneWorker + if out.timedOut then + runner.check "serve immediate EOF exited" false + return + let line := stripNl out.stdout + let ok := match line.splitOn " " with + | ["ADDR", addr, peer] => + match portOf addr with + | some port => port != "0" && port.all Char.isDigit && peer != "" + | none => false + | _ => false + runner.check "serve immediate EOF" (out.exitCode == 0 && out.stderr == "" && + out.stdout.endsWith "\n" && ok) + +/-- A port that is already bound exits 1 with no `ADDR` line. -/ +private def busyPort (runner : Runner) : IO Unit := do + let want ← match Multiformats.Multiaddr.parse? "/ip4/127.0.0.1/tcp/0" with + | .ok addr => pure addr + | .error e => throw <| IO.userError (reprStr e) + let listener ← Conc.Async.run ((Streams.Tcp.transport).listen want) + try + let some port := portOf listener.addr.toString + | throw <| IO.userError s!"listener {listener.addr}" + let out ← Tests.Cli.capture bin #["serve", port] 10000 oneWorker + if out.timedOut then + runner.check "serve busy exited" false + return + runner.check "serve busy exit" (out.exitCode == 1) + runner.check "serve busy stdout" (out.stdout == "") + runner.check "serve busy stderr" + (out.stderr != "" && (out.stderr.splitOn "ADDR").length == 1) + finally + listener.close + Conc.Async.run listener.join + +/-- A closed stdout fails the `ADDR` write, and the process still exits. +The stderr reader is joined after the child is reaped, which wakes it. -/ +private def closedStdout (runner : Runner) : IO Unit := do + let child ← IO.Process.spawn { + cmd := "/bin/sh" + args := #["-c", "exec \"$1\" serve 0 >&-", "sh", bin] + stdin := .null + stdout := .null + stderr := .piped + env := oneWorker + } + let mut errTask : Option (Task (Except IO.Error String)) := none + let mut code : Option UInt32 := none + let mut fault : Option IO.Error := none + try + errTask := some (← IO.asTask child.stderr.readToEnd Task.Priority.dedicated) + let (got, killed) ← Tests.Cli.waitChild child 10000 + code := if killed then none else some got + catch e => + fault := some e + Tests.Cli.reap child + let err ← match errTask with + | none => pure "" + | some task => + try + IO.ofExcept (← IO.wait task) + catch e => + if fault.isNone then fault := some e + pure "" + if let some e := fault then + runner.check s!"serve closed stdout: {e}" false + return + runner.check "serve closed stdout exits" (code == some 1) + runner.check "serve closed stdout stderr" (err != "") + +/-- A registered task returns an error. Cleanup still reaps the child +and joins the later task, and the error is the one that surfaces. -/ +private def registeredFault (runner : Runner) : IO Unit := do + let followed ← IO.mkRef false + let pid ← IO.mkRef (0 : UInt32) + let mut caught : Option String := none + try + withSession fun session => do + pid.set session.child.pid + let failed ← IO.asTask (throw <| IO.userError "registered task failed") + Task.Priority.dedicated + session.extra.modify (·.push failed) + -- This read parks on stdout. The reap closes that pipe, and the + -- join is what waits for the flag. + let later ← IO.asTask (do + discard <| session.child.stdout.readToEnd + followed.set true) Task.Priority.dedicated + session.extra.modify (·.push later) + catch e => + caught := some (toString e) + let id ← pid.get + let probe ← Tests.Cli.capture "/bin/kill" #["-0", toString id] 1000 + runner.check "registered task observed" (caught == some "registered task failed") + runner.check "registered sibling joined" (← followed.get) + runner.check "registered child reaped" (id != 0 && !probe.timedOut && probe.exitCode != 0) + +/-- A sleeper that ignores its deadline is killed, reaped, and its +readers are joined by `capture`. -/ +private def timeoutCleanup (runner : Runner) : IO Unit := do + let start ← IO.monoMsNow + let out ← Tests.Cli.capture "/bin/sleep" #["30"] 500 + let elapsed := (← IO.monoMsNow) - start + runner.check "timeout cleanup reaped" out.timedOut + runner.check "timeout cleanup bounded" (elapsed < 5000) + runner.check "timeout cleanup readers" (out.stdout == "" && out.stderr == "") + +/-- The server checks. -/ +def run (runner : Runner) : IO Unit := do + runner.section "pingnode serve" "packages/cli/PingNode.lean" + live runner + inboundThenServe runner + immediateEof runner + busyPort runner + closedStdout runner + timeoutCleanup runner + registeredFault runner + +end Tests.CliLive diff --git a/Tests/Main.lean b/Tests/Main.lean index b777ecd..168d253 100644 --- a/Tests/Main.lean +++ b/Tests/Main.lean @@ -39,6 +39,7 @@ import Tests.Protocols import Tests.ReqResp import Tests.Interop import Tests.Cli +import Tests.CliLive /-! # Test entry point @@ -48,11 +49,16 @@ One module per suite, each naming the upstream file it mirrors. failures. Documented gaps (todo) and skipped vectors stay visible in the summary without failing the run. The concurrency-sensitive suites also pass under a single capability: `lake test -- +RTS -N1 -RTS` -(ARCHITECTURE §6). +(ARCHITECTURE §6). `LEANP2P_SUITE=cli` runs `Tests.Cli` and +`Tests.CliLive` and skips the other suites. -/ def main : IO UInt32 := do let r ← Tests.Runner.new + if (← IO.getEnv "LEANP2P_SUITE") == some "cli" then + Tests.Cli.run r + Tests.CliLive.run r + return ← r.summary Tests.Vectors.run r Tests.Gossipsub.Core.run r Tests.Gossipsub.Sign.run r @@ -93,4 +99,5 @@ def main : IO UInt32 := do Tests.ReqResp.run r Tests.Interop.run r Tests.Cli.run r + Tests.CliLive.run r r.summary diff --git a/packages/cli/PingNode.lean b/packages/cli/PingNode.lean index 9348ec7..a2b47f8 100644 --- a/packages/cli/PingNode.lean +++ b/packages/cli/PingNode.lean @@ -1,6 +1,14 @@ import Cli +import Conc +import Crypto +import Streams import Multiformats -import Libp2p.Host.Swarm +import Libp2p.Identity +import Libp2p.Security +import Libp2p.Mux +import Libp2p.Host +import Libp2p.Protocols.Ping +import Libp2p.Protocols.Identify /-! # PingNode @@ -10,7 +18,11 @@ The `pingnode` executable. `Cmd.parse` accepts `serve ` or of the command. A usage error exits 2, before any host exists. `splitPeer` drops the trailing peer. A `p2p` component left in the transport prefix is a usage error. A line break in an operand is -escaped in the usage line. A valid argument list reaches the +escaped in the usage line. + +`serve` builds one host after that check. It listens, registers +identify, prints `ADDR`, and flushes. It then reads stdin in `IO`. +EOF calls `Host.close` and exits 0. `dial` still reaches the unfinished node and exits 1. -/ @@ -19,7 +31,8 @@ set_option autoImplicit false namespace Cli.PingNode open Cli -open Libp2p.Host (Host) +open Libp2p.Host (Host Failure) +open Libp2p.Identity (PeerId PrivKey) /-- One string operand. -/ private def operand (name description : String) : Arg := @@ -61,11 +74,133 @@ private def usage (line : String) : IO UInt32 := do IO.eprintln (oneLine line) return 2 -/-- The node is not built in this slice. -/ +/-- The dial path is not built in this slice. -/ private def unfinished : IO UInt32 := do IO.eprintln "pingnode: not implemented" return 1 +/-- The agent string identify advertises. -/ +private def agent : String := "leanp2p" + +/-- The stderr line for one inbound failure. -/ +private def failureLine (f : Failure) : String := Id.run do + let mut line := f.error.render + if let some peer := f.peer then + line := s!"{line} peer {peer}" + if let some protocol := f.protocol then + line := s!"{line} protocol {protocol}" + return line + +/-- Keep the first diagnostic write that threw. `modify` holds the cell +for the whole update, so the first stored fault stays. -/ +private def keepFault (faults : IO.Ref (Option IO.Error)) (e : IO.Error) : BaseIO Unit := + faults.modify fun old => if old.isNone then some e else old + +/-- Write the failure from the host task that saw it. `Host.close` +joins that task, so the write has finished when `close` returns. A +thrown write stays in `faults` and the accept loop continues. -/ +private def reportFailure (faults : IO.Ref (Option IO.Error)) (f : Failure) : BaseIO Unit := + EIO.catchExceptions (IO.eprintln (failureLine f)) (keepFault faults) + +/-- A fresh Ed25519 host: TCP, Noise, yamux, then mplex, and ping. -/ +private def openHost (faults : IO.Ref (Option IO.Error)) : IO (Except String Host) := do + let seed ← match ← Crypto.ed25519Generate.run with + | .error e => return .error (reprStr e) + | .ok seed => pure seed + let key ← match PrivKey.ofData? .ed25519 seed.bytes with + | .error e => return .error (reprStr e) + | .ok key => pure key + let noise ← match ← (Libp2p.Security.Noise.Config.generate key).run with + | .error e => return .error (reprStr e) + | .ok noise => pure noise + match ← (Host.new { + identity := key + transports := [Streams.Tcp.transport] + security := [Libp2p.Security.Noise.securityProtocol noise] + muxers := [Libp2p.Mux.yamux, Libp2p.Mux.mplex] + handlers := [Libp2p.Protocols.Ping.pingHandler] + onFailure := reportFailure faults + } : BaseIO _) with + | .error e => return .error (reprStr e) + | .ok host => return .ok host + +/-- Run `body`, then `Host.close`. A fault of either path exits 1. +The first fault is the line on stderr. A diagnostic write that threw +during the run exits 1 after `close` joins the host task. -/ +private def guarding (host : Host) (faults : IO.Ref (Option IO.Error)) + (body : IO UInt32) : IO UInt32 := do + let mut code : UInt32 := 0 + try + code ← body + catch e => + code := 1 + try IO.eprintln s!"{e}" catch _ => pure () + try + Conc.Async.run host.close + catch e => + if code == 0 then + try IO.eprintln s!"{e}" catch _ => pure () + code := 1 + match ← faults.get with + | some e => + if code == 0 then + try IO.eprintln s!"{e}" catch _ => pure () + return 1 + | none => + return code + +/-- The address `serve` asks the kernel to bind. -/ +private def listenAddr (port : Nat) : IO Multiformats.Multiaddr := do + match Multiformats.Multiaddr.parse? s!"/ip4/127.0.0.1/tcp/{port}" with + | .ok addr => return addr + | .error e => throw <| IO.userError (reprStr e) + +/-- Print `ADDR` and flush before the stdin read. -/ +private def publishReady (bound : Multiformats.Multiaddr) (peer : PeerId) : IO Unit := do + let out ← IO.getStdout + out.putStr s!"ADDR {bound} {peer}\n" + out.flush + +/-- Read stdin until EOF. A line that is not EOF is discarded. -/ +private def waitStdin : IO Unit := do + let stdin ← IO.getStdin + repeat + let line ← stdin.getLine + if line.isEmpty then return + +/-- Listen, serve ping and identify, and stop on stdin EOF. -/ +private def serve (portText : String) : IO UInt32 := do + let some port := portText.toNat? + | do + IO.eprintln s!"invalid port: {oneLine portText}" + return 1 + let faults ← IO.mkRef (none : Option IO.Error) + match ← openHost faults with + | .error line => + IO.eprintln line + return 1 + | .ok host => + guarding host faults do + match ← Conc.Async.run (host.startListening (← listenAddr port)) with + | .error e => + IO.eprintln e.render + return 1 + | .ok bound => + let key ← match host.config.identity.toPublic with + | .error e => + IO.eprintln (reprStr e) + return 1 + | .ok key => pure key + let protocols := [ + Libp2p.Protocols.Ping.protocolId.toString, + Libp2p.Protocols.Identify.protocolId.toString + ] + host.setHandler (Libp2p.Protocols.Identify.respondHandler key [bound.toBytes] + protocols agent) + publishReady bound host.localPeer + waitStdin + return 0 + /-- `0`, or a decimal from 1 to 65535 with no leading zero. -/ private def validPort (text : String) : Bool := if text == "0" then true @@ -97,7 +232,7 @@ private def validDial (text : String) : Except String Unit := do | .ok (_, none) => throw s!"dial address has no final /p2p peer: {text}" | .error err => throw s!"{err} {text}" -/-- Check `args`, then report that the node is not implemented. -/ +/-- Check `args`. `serve` runs the host. `dial` is still unfinished. -/ def main (args : List String) : IO UInt32 := do match command.parse args with | .error err => usage err.kind.msg @@ -106,7 +241,7 @@ def main (args : List String) : IO UInt32 := do | "serve" => match operandValue parsed "port" with | some port => - if validPort port then unfinished else usage s!"invalid port: {port}" + if validPort port then serve port else usage s!"invalid port: {port}" | none => usage "missing port" | "dial" => match operandValue parsed "multiaddr" with diff --git a/packages/cli/docs/ARCHITECTURE.md b/packages/cli/docs/ARCHITECTURE.md index 30537c8..4701dbf 100644 --- a/packages/cli/docs/ARCHITECTURE.md +++ b/packages/cli/docs/ARCHITECTURE.md @@ -28,7 +28,8 @@ row 18). `Demo.lean` and `PingNode.lean` are the executable roots. Each `main` checks its arguments with `Cmd.parse`. A usage error exits 2. -A valid list prints `not implemented` to stderr and exits 1. +`demo` and `pingnode dial` then print `not implemented` to stderr +and exit 1. `pingnode serve` listens. `Main.lean` stays the gossipsub participant. The Lake lib is `CliHarness`. `CliHarness.lean` declares no API, and the lib exists so the suite @@ -145,8 +146,9 @@ Both files sit in `packages/cli`. The namespaces are `Cli.Demo` and `Cli.PingNode`, the shape `Cli.GossipsubSession` already uses. `gossipsub-interop` keeps the root `Main`. `interop` keeps the root `Interop.Main`. `lakefile.toml` names `demo` and `pingnode`. The -argument table below is in force. A valid list then prints -`not implemented` to stderr and exits 1. +argument table below is in force. `demo` and `pingnode dial` then +print `not implemented` to stderr and exit 1. `pingnode serve` +listens. ### Arguments @@ -242,11 +244,14 @@ Both binaries build one `Host.Config`. architecture and root §8. - The gater, the resource manager, the ranker, and the clock keep the defaults on `Host.Config`. -- `onFailure` prints `Host.Error.render` to stderr, and it includes - the peer and the protocol when the failure carries them. A policy - refusal stays inside the host (the host architecture, Decisions). - One inbound failure leaves the accept loop running. A fault of a - host task surfaces when `Host.close` joins the task. +- `onFailure` writes `Host.Error.render` to stderr in the host task + that saw the failure, and it includes the peer and the protocol + when the failure carries them. The write finishes before that task + ends. `Host.close` joins the task. A write that throws is kept, and + the process exits 1 after `close`. A policy refusal stays inside + the host (the host architecture, Decisions). One inbound failure + leaves the accept loop running. A fault of a host task surfaces + when `Host.close` joins the task. The demo and the ping node register ping and identify. Echo registration waits on the open question. @@ -388,35 +393,36 @@ These rules bind `pingnode serve`. The mode registers identify with the bound address, prints `ADDR`, and flushes stdout. The flush follows a successful listen and that registration. While stdin stays open, the process accepts a dial and -serves it. The unit 1 check covers that dial with -`LEAN_NUM_THREADS=1`, and again under `+RTS -N1`. - -Stdin EOF is the shutdown signal. `Handle.lines` in the v4.34 -`Init/System/IO.lean` stops when `getLine` returns an empty string, -and the spike's serve loop treats that empty result as EOF. The loop -discards each line. The bytes belong to the operator, so the §5.1 -caps stay on the peer frames. After EOF the mode calls `Host.close` -and exits. - -An inbound peer failure reaches stderr through `onFailure`. The -accept loop keeps running. A host-task fault reaches stderr when -`close` throws it, and the process then exits 1. - -The read's scheduling is an implementation choice, and this slice has -not run it. `IO.asTask` defaults to `Task.Priority.default` -(`Init/Core.lean`). Priorities through `Task.Priority.max` run on the -shared worker pool. `Task.Priority.dedicated` asks for a separate -thread. The unit 1 dial check is what shows whether the chosen -priority keeps the accept loop moving under one pool worker. -`scripts/park-threads.lean` measures `IO.wait`. - -A task that runs the read ends when `getLine` returns or throws. The -owner joins the task after that return, and then calls `Host.close`. -`Host.close` closes listeners, pending connections, and sessions -(`Shutdown.lean`). The sequence leaves stdin open. A cleanup path -joins the reader after that return. The read stays an `IO` action. A -blocking call inside `Async` holds a pool worker (the conc -architecture, Decisions). +serves it. `Tests/CliLive.lean` covers that dial with +`LEAN_NUM_THREADS=1`. + +Stdin EOF is the shutdown signal. The mode calls `Handle.getLine` on +stdin in `IO`, after the flush, and outside `Async`. An empty string +is EOF. Any other line is discarded. The bytes belong to the +operator, so the §5.1 caps stay on the peer frames. After EOF the +mode calls `Host.close` and exits 0, unless that close throws. A +thrown `IO.Error` from the read or from the flush exits 1, and that +path calls `Host.close` too. + +An inbound peer failure reaches stderr through `onFailure`, on the +host task that saw it. The accept loop keeps running. A write that +throws is kept until `close` returns, and the process then exits 1. +A host-task fault reaches stderr when `close` throws it, and the +process then exits 1. + +The read has no reader task. The calling thread blocks in `getLine`. +The accept loop is the host task that `startListening` started. +`Tests/CliLive.lean` reads `ADDR` while stdin stays open, completes +ping and identify, and then closes stdin. The server exits inside +the test timeout. `Host.close` leaves stdin open. A blocking call +inside `Async` holds a pool worker (the conc architecture, +Decisions), so this read stays in `IO`. The focused command +`LEANP2P_SUITE=cli LEAN_NUM_THREADS=1 .lake/build/bin/tests +RTS -N1 -RTS` +passed 121 checks, with 0 failed, 0 todo, and 0 skipped. That +command runs the CLI suites alone. The full `lake test` was not +rerun for this slice. Its recorded baseline still has one failure +in `Tests/Streams.lean`: `the teardown aborted the stuck write: +the peer meets a reset`. ### Demo lifetime diff --git a/packages/cli/docs/PLAN.md b/packages/cli/docs/PLAN.md index 26bccab..894f0b0 100644 --- a/packages/cli/docs/PLAN.md +++ b/packages/cli/docs/PLAN.md @@ -32,7 +32,8 @@ The executable roots are `Demo` and `PingNode`, in `packages/cli`. `Main` stays the root of `gossipsub-interop`. The lib is `CliHarness`. Its globs include `Demo` and `PingNode`, and the lakefile names the `demo` and `pingnode` executables. A usage -error exits 2. A valid list prints `not implemented` and exits 1. +error exits 2. `demo` and `pingnode dial` print `not implemented` +and exit 1. `pingnode serve` listens. The W5.8 behavior section of the package architecture is the contract those binaries implement, including the argument table, the exit codes, the ping and identify @@ -55,9 +56,21 @@ an answer. `Tests/Node.lean` drives two hosts over loopback TCP through ping, identify, and echo). - `pingnode serve` prints the bound address, flushes it, and stays up - until stdin EOF while an inbound dial still completes. This check - runs with `LEAN_NUM_THREADS=1` and under `+RTS -N1`. The server - lifetime section names the read as an implementation choice. + until stdin EOF while an inbound dial still completes. + `Tests/CliLive.lean` runs that check with `LEAN_NUM_THREADS=1`. + The server reads stdin with `Handle.getLine` in `IO`, outside + `Async`. The same file checks immediate EOF, a busy port, a failed + `ADDR` write, an inbound failure followed by a live exchange, and + a sleeper that misses its deadline, and a registered task that + returns an error. Each of those process checks reaps the child and + joins the readers it started. A task error from one of those joins + fails the check after the remaining joins. The focused command + `LEANP2P_SUITE=cli LEAN_NUM_THREADS=1 .lake/build/bin/tests +RTS -N1 -RTS` + passed 121 checks, with 0 failed, 0 todo, and 0 skipped. That + command runs the CLI suites alone. The full `lake test` was not + rerun for this slice. Its recorded baseline still has one failure + in `Tests/Streams.lean`: `the teardown aborted the stuck write: + the peer meets a reset`. `scripts/park-threads.lean` measures `IO.wait`. - A usage error exits 2 before a host exists: a `demo` argument, a missing or unknown `pingnode` subcommand, an extra operand, a port @@ -66,7 +79,8 @@ an answer. whose transport prefix still contains a `p2p` component. A line break in an operand stays inside that one stderr line. `Tests/Cli.lean` runs the built binaries for these cases. A valid - list still exits 1 with the unfinished diagnostic. + `demo` or `pingnode dial` list still exits 1 with the unfinished + diagnostic. A valid `pingnode serve` port is not a usage error. - `pingnode dial` against `pingnode serve` prints `PING OK`, and both processes close their hosts on the failure paths of the behavior section. The run uses the exchange deadline after `newStream` From ab0031d3b527d5ba53fcd602aad8b65a3ff5e647 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Wed, 11 Nov 2026 15:22:38 -0500 Subject: [PATCH 05/15] docs(cli): track pending contract decisions --- packages/cli/docs/ARCHITECTURE.md | 13 +++++++++++++ packages/cli/docs/PLAN.md | 12 +++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/cli/docs/ARCHITECTURE.md b/packages/cli/docs/ARCHITECTURE.md index 4701dbf..48021bd 100644 --- a/packages/cli/docs/ARCHITECTURE.md +++ b/packages/cli/docs/ARCHITECTURE.md @@ -360,6 +360,14 @@ The question blocks a negotiation bound on `pingnode dial` and on each `newStream` of the demo. `pingnode serve` opens no outbound stream. +The pending proposal is +https://github.com/etheorem/leanp2p/issues/22#issuecomment-6805941244. +It asks whether `pingnode dial` and the demo may apply +`Streams.unauthDeadlineMs` to `Host.newStream`, treat expiry as a +command failure, and close the host they own. The proposal stays +pending. It blocks the dial implementation and each outbound +`newStream` of the demo. The server path stays as implemented. + The question for the issue: What bounds protocol negotiation inside `Host.newStream`? The stream @@ -469,6 +477,11 @@ The implementation slice places the handler and the client after a maintainer assigns the home. The ping node registers ping and identify. +The same comment, +https://github.com/etheorem/leanp2p/issues/22#issuecomment-6805941244, +asks where the handler and the client for `/demo/echo/1.0.0` live. +That home stays pending. It blocks the demo echo leg. + The question for the issue: Where does the W5.8 demo echo handler live, and where does its client diff --git a/packages/cli/docs/PLAN.md b/packages/cli/docs/PLAN.md index 894f0b0..24402af 100644 --- a/packages/cli/docs/PLAN.md +++ b/packages/cli/docs/PLAN.md @@ -40,10 +40,12 @@ including the argument table, the exit codes, the ping and identify deadlines, the stream retirement, and the two-host close. Those deadlines start after `newStream` returns. The demo echo home and the deadline around -`Host.newStream` are the open questions in that architecture. The -echo leg waits on the first. The dial and the demo add no bound of -their own on the negotiation inside `newStream` until the second has -an answer. +`Host.newStream` are the open questions in that architecture. Both +are pending on +https://github.com/etheorem/leanp2p/issues/22#issuecomment-6805941244. +The 30 s `newStream` proposal in that comment stays pending. It +blocks `pingnode dial` and the demo's outbound streams. The echo +home blocks the demo echo leg. `pingnode serve` is implemented. - The demo runs end to end over loopback: two hosts dial, complete the upgrade, identify, and exchange pings. The listener's identify @@ -85,7 +87,7 @@ an answer. processes close their hosts on the failure paths of the behavior section. The run uses the exchange deadline after `newStream` returns. A bound on the negotiation inside that call waits on the - stream-open question. + pending proposal in the comment linked above. - Ping interop with go (the spike's `ping-interop.sh` shape): the script builds `pingd.go` in docker (go-libp2p v0.50.0, CGO off), starts both nodes on loopback, and dials in both directions over From 416bc8e33bd99502799ae3a924f34188ee9a8246 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Wed, 11 Nov 2026 16:30:21 -0500 Subject: [PATCH 06/15] test(cli): add bounded go-to-lean interop check --- packages/cli/docs/ARCHITECTURE.md | 36 ++++- packages/cli/docs/PLAN.md | 44 +++++- scripts/ping-go-interop.sh | 179 ++++++++++++++++++++++++ scripts/ping-go/go.mod | 93 ++++++++++++ scripts/ping-go/go.sum | 225 ++++++++++++++++++++++++++++++ scripts/ping-go/main.go | 75 ++++++++++ 6 files changed, 642 insertions(+), 10 deletions(-) create mode 100755 scripts/ping-go-interop.sh create mode 100644 scripts/ping-go/go.mod create mode 100644 scripts/ping-go/go.sum create mode 100644 scripts/ping-go/main.go diff --git a/packages/cli/docs/ARCHITECTURE.md b/packages/cli/docs/ARCHITECTURE.md index 48021bd..adffe53 100644 --- a/packages/cli/docs/ARCHITECTURE.md +++ b/packages/cli/docs/ARCHITECTURE.md @@ -416,7 +416,9 @@ An inbound peer failure reaches stderr through `onFailure`, on the host task that saw it. The accept loop keeps running. A write that throws is kept until `close` returns, and the process then exits 1. A host-task fault reaches stderr when `close` throws it, and the -process then exits 1. +process then exits 1. A Go dialer that closes the TCP connection +after a successful ping can take this path. The Go-helper decision +records the runs and the open question. The read has no reader task. The calling thread blocks in `getLine`. The accept loop is the host task that `startListening` started. @@ -502,8 +504,36 @@ handler. The spike demo still echoes on `/demo/echo/1.0.0`. the harness, and never on a shipped or proof path. The tree holds `interop-matrix.sh` and `interop-slowreader/` under the root `scripts/`, beside the W0.3 vector tooling (§7 row 11's note). - Unit 1 adds `pingd.go` and `ping-interop.sh`, in the spike's - ping-interop shape. The spike also kept a + The Go-to-Lean ping check is `scripts/ping-go/` and + `scripts/ping-go-interop.sh`. The Go peer dials `pingnode serve`, + so this leg stands while the outbound `newStream` proposal is + pending. The module requires go-libp2p v0.50.0. The host Go + toolchain builds the helper with `CGO_ENABLED=0`. That module asks + for Go 1.26. The helper runs on the host loopback. + Shutdown after a successful Go ping is still open. Go closes the + TCP connection with RST (linger 0). A TCP read of that RST answers + EOF, and the Noise session ends. A later write throws `closedError` + in `Libp2p/Security/Transport.lean`: `resourceVanished` code 0, + `noise: the secure connection is closed`. Yamux records the write. + The session ended because the connection ended, so `Session.join` + throws the write (`packages/net/yamux/docs/ARCHITECTURE.md`). The + ping handler also answers `.closed`. `serveStream` reports that as + `stream failed: closed: the stream closed`. `Host.close` throws the + write fault, and `guarding` returns 1. + A nonzero `resourceVanished` code stays a fault. Code 0 on this + path is the secure connection's own closed error. Treating that + error as the peer's end would change the yamux sentence that throws + a failed write when the session ends at the connection. The + question stands: after the peer's RST has ended the secure + connection, should join still throw `resourceVanished` code 0 with + that Noise detail, and should `pingnode serve` still exit 1? + `finish_child` in `scripts/ping-go-interop.sh` sends SIGTERM, waits + at most 2 seconds, sends SIGKILL if the child is alive, and reaps + it. The ping deadline, the stdin holder, and the EXIT trap share + that helper. The trap keeps the original status. A child that has + already exited is reaped. The Go module pin and the Lean + toolchain stay as they are. Lean-to-Go and the bidirectional gate + stay unfinished. The spike also kept a test-plans runner image, `interop/leanp2p/{Makefile,Dockerfile}`. The target drops the image, and `scripts/interop-matrix.sh` is the gate's only form, so docker appears only inside the scripts. diff --git a/packages/cli/docs/PLAN.md b/packages/cli/docs/PLAN.md index 24402af..c2ba053 100644 --- a/packages/cli/docs/PLAN.md +++ b/packages/cli/docs/PLAN.md @@ -88,13 +88,43 @@ home blocks the demo echo leg. `pingnode serve` is implemented. section. The run uses the exchange deadline after `newStream` returns. A bound on the negotiation inside that call waits on the pending proposal in the comment linked above. -- Ping interop with go (the spike's `ping-interop.sh` shape): the - script builds `pingd.go` in docker (go-libp2p v0.50.0, CGO off), - starts both nodes on loopback, and dials in both directions over - `tcp + /noise + yamux`: lean dials go, and go dials lean. The - spike selected the muxer from the Noise handshake extension. The - target advertises no muxers there, so both sides negotiate the - muxer on the wire (the negotiate pair's decision). +- Go-to-Lean ping is in this slice. The Go peer dials `pingnode + serve`, while the outbound `newStream` proposal stays pending. + `scripts/ping-go/main.go` dials one multiaddr over TCP, Noise, and + yamux, and prints `PING OK rtt=`. The module requires + `github.com/libp2p/go-libp2p v0.50.0`. The host Go toolchain builds + it with `CGO_ENABLED=0`. The module asks for Go 1.26. + `scripts/ping-go-interop.sh` starts `pingnode serve 0` with + `LEAN_NUM_THREADS=1`, joins the `ADDR` multiaddr and the peer id, + and requires one ping result. Stdin EOF then waits for server exit + 0. `finish_child` ends an owned child that misses its wait: SIGTERM, + at most 2 seconds (`term_grace`), SIGKILL, then `wait`. A child + that has already exited is reaped. The trap keeps the script's + status. Three runs of `scripts/ping-go-interop.sh` used + `LEAN_NUM_THREADS=1` on the server. Run 1 exited 0 and printed + `PASS go-to-lean PING OK rtt=532.916µs`. Run 2 exited 0 and printed + `PASS go-to-lean PING OK rtt=778.708µs`. Both had empty stderr. + Run 3 exited 1. Stderr was `FAIL go-to-lean: server exit 1`, then + `stream failed: closed: the stream closed peer 12D3KooWPDx7LZXLcXg8CgvnLimnKnQeM9rXKPeS9jJgNgmfLAGs protocol /ipfs/ping/1.0.0`, + then `resource vanished (error code: 0, noise: the secure connection is closed)`. + The architecture decision records the cause. The yamux rule that + throws this write stays in place. The shutdown fault stays open. + `PING_GO_BIN=/usr/bin/false scripts/ping-go-interop.sh` exited 1 + with `FAIL go-to-lean: go exit 1`. The `pingnode` process, the + stdin holder, and the Go command were gone. A test-only peer that + ignores SIGTERM, passed as `PING_GO_BIN`, made the script exit 1 + after 28 seconds, including its build, with + `FAIL go-to-lean: go ping missed 25 s`. The Go peer, the Lean + server, and the stdin holder were gone. The cleanup bound is 31 + seconds: the 25 second ping deadline, then 2 seconds for the peer, + 2 seconds for the holder, and 2 seconds for the server. The check + stays outside `lake test`. + Lean-to-Go, the demo, and the bidirectional gate remain unfinished. + The two questions at + https://github.com/etheorem/leanp2p/issues/22#issuecomment-6805941244 + remain pending. The spike selected its muxer from the Noise + handshake extension. This stack negotiates the muxer on the wire + (the negotiate pair's decision). - The in-suite legs pass under `+RTS -N1` (§6; PRINCIPLES 5.3). ## Test milestones (unit 2) diff --git a/scripts/ping-go-interop.sh b/scripts/ping-go-interop.sh new file mode 100755 index 0000000..815de69 --- /dev/null +++ b/scripts/ping-go-interop.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# Go dials `pingnode serve` over TCP, Noise, and yamux. +# +# Readiness is `ADDR `. The dial address joins +# those fields with `/p2p/`. The address field has no peer suffix. +# +# The server runs with LEAN_NUM_THREADS=1. Its stdin stays open until +# the ping returns, then the holder exits and the server sees EOF. +# A failed Go command takes the same cleanup and the script exits 1. +# Set PING_GO_BIN to replace the helper for that failure check. +# +# The helper builds with the host Go toolchain, CGO off, from +# scripts/ping-go. go-libp2p v0.50.0 asks for Go 1.26. An older go +# command downloads that toolchain. +# +# `finish_child` ends one owned child. It sends SIGTERM, waits at most +# `term_grace` seconds, sends SIGKILL if the child is still alive, and +# reaps it. A child that has already exited is reaped. The ping +# deadline, the stdin holder, and the EXIT cleanup use this helper. +# The server's own shutdown wait stays a poll: stdin EOF is the wake, +# and the helper runs only if that wait is abandoned. +set -euo pipefail + +root=$(cd "$(dirname "$0")/.." && pwd) +work=$(mktemp -d "${TMPDIR:-/tmp}/ping-go-interop.XXXXXX") +bin="$root/.tmp/ping-go/ping-go" +server_pid= +hold_pid= +go_pid= +server_status= +term_grace=2 + +finish_child() { + local pid=$1 + local start now + if [[ -z ${pid:-} ]]; then + return 0 + fi + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + start=$(date +%s) + while kill -0 "$pid" 2>/dev/null; do + now=$(date +%s) + if (( now - start >= term_grace )); then + kill -9 "$pid" 2>/dev/null || true + break + fi + sleep 0.2 + done + fi + wait "$pid" 2>/dev/null || true +} + +reap() { + local status=$? + if [[ -n ${go_pid:-} ]]; then + finish_child "$go_pid" + go_pid= + fi + if [[ -n ${hold_pid:-} ]]; then + finish_child "$hold_pid" + hold_pid= + fi + if [[ -n ${server_pid:-} ]]; then + finish_child "$server_pid" + server_pid= + fi + rm -rf "$work" + exit "$status" +} +trap reap EXIT + +fail() { + echo "FAIL go-to-lean: $*" >&2 + if [[ -s "$work/server.err" ]]; then + cat "$work/server.err" >&2 + fi + if [[ -s "$work/go.err" ]]; then + cat "$work/go.err" >&2 + fi + exit 1 +} + +mkdir -p "$root/.tmp/ping-go" +( cd "$root" && lake build pingnode ) +( cd "$root/scripts/ping-go" && CGO_ENABLED=0 go build -o "$bin" . ) + +mkfifo "$work/in" +sleep 3600 >"$work/in" & +hold_pid=$! +LEAN_NUM_THREADS=1 "$root/.lake/build/bin/pingnode" serve 0 \ + <"$work/in" >"$work/server.out" 2>"$work/server.err" & +server_pid=$! + +line= +start=$(date +%s) +while true; do + if [[ -s "$work/server.out" ]]; then + line=$(head -n 1 "$work/server.out" || true) + if [[ "$line" == ADDR\ * ]]; then + break + fi + fi + if ! kill -0 "$server_pid" 2>/dev/null; then + fail "server exited before ADDR" + fi + now=$(date +%s) + if (( now - start >= 15 )); then + fail "no ADDR line within 15 s" + fi + sleep 0.2 +done + +set -- $line +if [[ $# -ne 3 || $1 != ADDR ]]; then + fail "readiness line: $line" +fi +addr=$2 +peer=$3 +case "$addr" in + */p2p/*) fail "readiness address already has a peer suffix: $addr" ;; +esac +case "$addr" in + /ip4/127.0.0.1/tcp/*) ;; + *) fail "readiness address: $addr" ;; +esac +port=${addr##*/} +if [[ ! "$port" =~ ^[1-9][0-9]*$ ]] || (( port > 65535 )); then + fail "bound port: $port" +fi +if [[ -z "$peer" ]]; then + fail "readiness line has no peer id" +fi +dial="${addr}/p2p/${peer}" + +go_bin=${PING_GO_BIN:-$bin} +"$go_bin" "$dial" >"$work/go.out" 2>"$work/go.err" & +go_pid=$! +start=$(date +%s) +while kill -0 "$go_pid" 2>/dev/null; do + now=$(date +%s) + if (( now - start >= 25 )); then + finish_child "$go_pid" + go_pid= + fail "go ping missed 25 s" + fi + sleep 0.2 +done +set +e +wait "$go_pid" +go_status=$? +set -e +go_pid= +if [[ "$go_status" -ne 0 ]]; then + fail "go exit $go_status" +fi +if ! grep -q '^PING OK rtt=.' "$work/go.out"; then + fail "go printed no ping result" +fi + +finish_child "$hold_pid" +hold_pid= +start=$(date +%s) +while kill -0 "$server_pid" 2>/dev/null; do + now=$(date +%s) + if (( now - start >= 15 )); then + fail "server missed the shutdown deadline" + fi + sleep 0.2 +done +set +e +wait "$server_pid" +server_status=$? +set -e +server_pid= +if [[ "$server_status" -ne 0 ]]; then + fail "server exit $server_status" +fi +echo "PASS go-to-lean $(head -n 1 "$work/go.out")" diff --git a/scripts/ping-go/go.mod b/scripts/ping-go/go.mod new file mode 100644 index 0000000..56409d9 --- /dev/null +++ b/scripts/ping-go/go.mod @@ -0,0 +1,93 @@ +module leanp2p/scripts/ping-go + +go 1.26.0 + +require github.com/libp2p/go-libp2p v0.50.0 + +require ( + filippo.io/bigmod v0.1.1-0.20260103110540-f8a47775ebe5 // indirect + filippo.io/keygen v1.0.0 // indirect + github.com/benbjohnson/clock v1.3.5 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect + github.com/dunglas/httpsfv v1.1.1 // indirect + github.com/filecoin-project/go-clock v0.1.0 // indirect + github.com/flynn/noise v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/huin/goupnp v1.3.0 // indirect + github.com/ipfs/go-cid v0.6.2 // indirect + github.com/jackpal/go-nat-pmp v1.0.2 // indirect + github.com/jbenet/go-temp-err-catcher v0.1.0 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/koron/go-ssdp v0.9.1 // indirect + github.com/libp2p/go-buffer-pool v0.1.0 // indirect + github.com/libp2p/go-flow-metrics v0.3.0 // indirect + github.com/libp2p/go-libp2p-asn-util v0.4.1 // indirect + github.com/libp2p/go-msgio v0.3.0 // indirect + github.com/libp2p/go-netroute v0.4.0 // indirect + github.com/libp2p/go-reuseport v0.4.0 // indirect + github.com/libp2p/go-yamux/v5 v5.1.0 // indirect + github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd // indirect + github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect + github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // indirect + github.com/minio/sha256-simd v1.0.1 // indirect + github.com/mr-tron/base58 v1.3.0 // indirect + github.com/multiformats/go-base32 v0.1.0 // indirect + github.com/multiformats/go-base36 v0.2.0 // indirect + github.com/multiformats/go-multiaddr v0.16.1 // indirect + github.com/multiformats/go-multiaddr-dns v0.6.0 // indirect + github.com/multiformats/go-multiaddr-fmt v0.1.0 // indirect + github.com/multiformats/go-multibase v0.3.0 // indirect + github.com/multiformats/go-multicodec v0.10.0 // indirect + github.com/multiformats/go-multihash v0.2.3 // indirect + github.com/multiformats/go-multistream v0.6.1 // indirect + github.com/multiformats/go-varint v0.1.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect + github.com/pion/datachannel v1.5.10 // indirect + github.com/pion/dtls/v3 v3.1.2 // indirect + github.com/pion/ice/v4 v4.0.10 // indirect + github.com/pion/interceptor v0.1.40 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/mdns/v2 v2.0.7 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.16 // indirect + github.com/pion/rtp v1.8.19 // indirect + github.com/pion/sctp v1.8.39 // indirect + github.com/pion/sdp/v3 v3.0.18 // indirect + github.com/pion/srtp/v3 v3.0.6 // indirect + github.com/pion/stun/v3 v3.1.1 // indirect + github.com/pion/transport/v3 v3.0.7 // indirect + github.com/pion/transport/v4 v4.0.1 // indirect + github.com/pion/turn/v4 v4.0.2 // indirect + github.com/pion/webrtc/v4 v4.1.2 // indirect + github.com/prometheus/client_golang v1.24.1 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.62.0 // indirect + github.com/quic-go/webtransport-go v0.13.0 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/wlynxg/anet v0.0.5 // indirect + go.uber.org/dig v1.19.0 // indirect + go.uber.org/fx v1.24.0 // indirect + go.uber.org/mock v0.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20260718201538-764159d718ef // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/telemetry v0.0.0-20260717140457-bdb89881bb75 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.48.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + lukechampine.com/blake3 v1.4.1 // indirect +) diff --git a/scripts/ping-go/go.sum b/scripts/ping-go/go.sum new file mode 100644 index 0000000..cd91825 --- /dev/null +++ b/scripts/ping-go/go.sum @@ -0,0 +1,225 @@ +filippo.io/bigmod v0.1.1-0.20260103110540-f8a47775ebe5 h1:JA0fFr+kxpqTdxR9LOBiTWpGNchqmkcsgmdeJZRclZ0= +filippo.io/bigmod v0.1.1-0.20260103110540-f8a47775ebe5/go.mod h1:OjOXDNlClLblvXdwgFFOQFJEocLhhtai8vGLy0JCZlI= +filippo.io/keygen v1.0.0 h1:u0/Fhxlgz3uPv+XxhfgTq3BJt5VesIPM5ue/OuG7qjQ= +filippo.io/keygen v1.0.0/go.mod h1:9nnw1SlYHYuPSo/3wjQzNjSbeHlq2NsKo5iEtfJPWP0= +github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/canonical/go-sp800.90a-drbg v0.0.0-20210314144037-6eeb1040d6c3 h1:oe6fCvaEpkhyW3qAicT0TnGtyht/UrgvOwMcEgLb7Aw= +github.com/canonical/go-sp800.90a-drbg v0.0.0-20210314144037-6eeb1040d6c3/go.mod h1:qdP0gaj0QtgX2RUZhnlVrceJ+Qln8aSlDyJwelLLFeM= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c h1:pFUpOrbxDR6AkioZ1ySsx5yxlDQZ8stG2b88gTPxgJU= +github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c/go.mod h1:6UhI8N9EjYm1c2odKpFpAYeR8dsBeM7PtzQhRgxRr9U= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/dunglas/httpsfv v1.1.1 h1:HoSs101zIE9I23DlqlmljJ/OIi7ILwrH347pXhRZdxI= +github.com/dunglas/httpsfv v1.1.1/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= +github.com/filecoin-project/go-clock v0.1.0 h1:SFbYIM75M8NnFm1yMHhN9Ahy3W5bEZV9gd6MPfXbKVU= +github.com/filecoin-project/go-clock v0.1.0/go.mod h1:4uB/O4PvOjlx1VCMdZ9MyDZXRm//gkj1ELEbxfI1AZs= +github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg= +github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/ipfs/go-cid v0.6.2 h1:VuGwJd+KJTaMJ4S4d5EEf9SXc17YUblS5axCbocn9YE= +github.com/ipfs/go-cid v0.6.2/go.mod h1:Xhwg8NzHeK9xPCEZkCw4idzPiuNMpX3fARuI5Iwj1Lo= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/jbenet/go-temp-err-catcher v0.1.0 h1:zpb3ZH6wIE8Shj2sKS+khgRvf7T7RABoLk/+KKHggpk= +github.com/jbenet/go-temp-err-catcher v0.1.0/go.mod h1:0kJRvmDZXNMIiJirNPEYfhpPwbGVtZVWC34vc5WLsDk= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/koron/go-ssdp v0.9.1 h1:zvxbAAuJftJIZ8Jh8mda+LI7V92hYZf/sKprmOxpxwA= +github.com/koron/go-ssdp v0.9.1/go.mod h1:C43c047jWkDaeg9YuZlSh/QGqOieuWV6dbhWi/jcaLk= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= +github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= +github.com/libp2p/go-flow-metrics v0.3.0 h1:q31zcHUvHnwDO0SHaukewPYgwOBSxtt830uJtUx6784= +github.com/libp2p/go-flow-metrics v0.3.0/go.mod h1:nuhlreIwEguM1IvHAew3ij7A8BMlyHQJ279ao24eZZo= +github.com/libp2p/go-libp2p v0.50.0 h1:A0tBP6mr6GV7l5ip2Q04a/IxPqVYYaCiif5s8JuXkN4= +github.com/libp2p/go-libp2p v0.50.0/go.mod h1:RjqB+dxCWNZ33yw0yeK5Pu157oNxKuaTZY6vLed4t8w= +github.com/libp2p/go-libp2p-asn-util v0.4.1 h1:xqL7++IKD9TBFMgnLPZR6/6iYhawHKHl950SO9L6n94= +github.com/libp2p/go-libp2p-asn-util v0.4.1/go.mod h1:d/NI6XZ9qxw67b4e+NgpQexCIiFYJjErASrYW4PFDN8= +github.com/libp2p/go-libp2p-testing v0.12.0 h1:EPvBb4kKMWO29qP4mZGyhVzUyR25dvfUIK5WDu6iPUA= +github.com/libp2p/go-libp2p-testing v0.12.0/go.mod h1:KcGDRXyN7sQCllucn1cOOS+Dmm7ujhfEyXQL5lvkcPg= +github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0= +github.com/libp2p/go-msgio v0.3.0/go.mod h1:nyRM819GmVaF9LX3l03RMh10QdOroF++NBbxAb0mmDM= +github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT7/Q= +github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA= +github.com/libp2p/go-reuseport v0.4.0 h1:nR5KU7hD0WxXCJbmw7r2rhRYruNRl2koHw8fQscQm2s= +github.com/libp2p/go-reuseport v0.4.0/go.mod h1:ZtI03j/wO5hZVDFo2jKywN6bYKWLOy8Se6DrI2E1cLU= +github.com/libp2p/go-yamux/v5 v5.1.0 h1:8Qlxj4E9JGJAQVW6+uj2o7mqkqsIVlSUGmTWhlXzoHE= +github.com/libp2p/go-yamux/v5 v5.1.0/go.mod h1:tgIQ07ObtRR/I0IWsFOyQIL9/dR5UXgc2s8xKmNZv1o= +github.com/marcopolo/simnet v0.0.7 h1:DpH8BMGsF9+1w13L8rvCaAhb6nYJdY+dIXncDrssvUs= +github.com/marcopolo/simnet v0.0.7/go.mod h1:tfQF1u2DmaB6WHODMtQaLtClEf3a296CKQLq5gAsIS0= +github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd h1:br0buuQ854V8u83wA0rVZ8ttrq5CpaPZdvrK0LP2lOk= +github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd/go.mod h1:QuCEs1Nt24+FYQEqAAncTDPJIuGs+LxK1MCiFL25pMU= +github.com/mikioh/tcp v0.0.0-20190314235350-803a9b46060c h1:bzE/A84HN25pxAuk9Eej1Kz9OUelF97nAc82bDquQI8= +github.com/mikioh/tcp v0.0.0-20190314235350-803a9b46060c/go.mod h1:0SQS9kMwD2VsyFEB++InYyBJroV/FRmBgcydeSUcJms= +github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b h1:z78hV3sbSMAUoyUMM0I83AUIT6Hu17AWfgjzIbtrYFc= +github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b/go.mod h1:lxPUiZwKoFL8DUUmalo2yJJUCxbPKtm8OKfqr2/FTNU= +github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc h1:PTfri+PuQmWDqERdnNMiD9ZejrlswWrCpBEZgWOiTrc= +github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc/go.mod h1:cGKTAVKx4SxOuR/czcZ/E2RSJ3sfHs8FpHhQ5CWMf9s= +github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ= +github.com/minio/sha256-simd v0.1.1-0.20190913151208-6de447530771/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI= +github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8= +github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= +github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= +github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= +github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= +github.com/multiformats/go-multiaddr v0.1.1/go.mod h1:aMKBKNEYmzmDmxfX88/vz+J5IU55txyt0p4aiWVohjo= +github.com/multiformats/go-multiaddr v0.16.1 h1:fgJ0Pitow+wWXzN9do+1b8Pyjmo8m5WhGfzpL82MpCw= +github.com/multiformats/go-multiaddr v0.16.1/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0= +github.com/multiformats/go-multiaddr-dns v0.6.0 h1:yKIW08WJHSPJ8bDAT2O/5fypCaUu9Bjl8r/1eJ4XAW8= +github.com/multiformats/go-multiaddr-dns v0.6.0/go.mod h1:dwIQwdORZfnNQCeS7xLXyn+7626oRmMsVP30Uronhf0= +github.com/multiformats/go-multiaddr-fmt v0.1.0 h1:WLEFClPycPkp4fnIzoFoV9FVd49/eQsuaL3/CWe167E= +github.com/multiformats/go-multiaddr-fmt v0.1.0/go.mod h1:hGtDIW4PU4BqJ50gW2quDuPVjyWNZxToGUh/HwTZYJo= +github.com/multiformats/go-multibase v0.3.0 h1:8helZD2+4Db7NNWFiktk2NePbF0boolBe6bDQvM4r68= +github.com/multiformats/go-multibase v0.3.0/go.mod h1:MoBLQPCkRTOL3eveIPO81860j2AQY8JwcnNlRkGRUfI= +github.com/multiformats/go-multicodec v0.10.0 h1:UpP223cig/Cx8J76jWt91njpK3GTAO1w02sdcjZDSuc= +github.com/multiformats/go-multicodec v0.10.0/go.mod h1:wg88pM+s2kZJEQfRCKBNU+g32F5aWBEjyFHXvZLTcLI= +github.com/multiformats/go-multihash v0.0.8/go.mod h1:YSLudS+Pi8NHE7o6tb3D8vrpKa63epEDmG8nTduyAew= +github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= +github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= +github.com/multiformats/go-multistream v0.6.1 h1:4aoX5v6T+yWmc2raBHsTvzmFhOI8WVOer28DeBBEYdQ= +github.com/multiformats/go-multistream v0.6.1/go.mod h1:ksQf6kqHAb6zIsyw7Zm+gAuVo57Qbq84E27YlYqavqw= +github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= +github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= +github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= +github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= +github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= +github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= +github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= +github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= +github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= +github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= +github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= +github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= +github.com/pion/rtp v1.8.19 h1:jhdO/3XhL/aKm/wARFVmvTfq0lC/CvN1xwYKmduly3c= +github.com/pion/rtp v1.8.19/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= +github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE= +github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= +github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI= +github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8= +github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4= +github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY= +github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw= +github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM= +github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= +github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= +github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= +github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= +github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps= +github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs= +github.com/pion/webrtc/v4 v4.1.2 h1:mpuUo/EJ1zMNKGE79fAdYNFZBX790KE7kQQpLMjjR54= +github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZsGN+8U= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8= +github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= +github.com/quic-go/webtransport-go v0.13.0 h1:RJLrTUHlTj8jJaQlQJUy0z0Mf7u1fVM0I6L1b9pe2M0= +github.com/quic-go/webtransport-go v0.13.0/go.mod h1:K83X9YHbAqgSLO6ikS6BXCMdWOvqh9JTHALulvb2JVk= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= +go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200602180216-279210d13fed/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20260718201538-764159d718ef h1:LkZ48HFgy/TvhTI0bcWkjgFkgLyKUwcTbDjS0DUjw+A= +golang.org/x/exp v0.0.0-20260718201538-764159d718ef/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260717140457-bdb89881bb75 h1:I9ygRooEYoVHV0SRNOSr/KVjTf5EeJ52BuNkVjsP2GU= +golang.org/x/telemetry v0.0.0-20260717140457-bdb89881bb75/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= +lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= diff --git a/scripts/ping-go/main.go b/scripts/ping-go/main.go new file mode 100644 index 0000000..a8992d4 --- /dev/null +++ b/scripts/ping-go/main.go @@ -0,0 +1,75 @@ +// Dial one peer and complete one ping. +// +// The argument is a multiaddr whose last component is the expected +// peer id. The host is TCP, Noise, and yamux. A failed setup, +// connection, deadline, or ping exits 1. A bad argument count exits 2. +// The host closes on every return after it exists. +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/libp2p/go-libp2p" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/p2p/muxer/yamux" + "github.com/libp2p/go-libp2p/p2p/protocol/ping" + "github.com/libp2p/go-libp2p/p2p/security/noise" + "github.com/libp2p/go-libp2p/p2p/transport/tcp" +) + +const pingBudget = 20 * time.Second + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: ping-go ") + os.Exit(2) + } + if err := run(os.Args[1]); err != nil { + fmt.Fprintf(os.Stderr, "ping-go: %s\n", err) + os.Exit(1) + } +} + +func run(target string) (err error) { + info, err := peer.AddrInfoFromString(target) + if err != nil { + return fmt.Errorf("address: %s", err) + } + host, err := libp2p.New( + libp2p.Transport(tcp.NewTCPTransport), + libp2p.Security(noise.ID, noise.New), + libp2p.Muxer("/yamux/1.0.0", yamux.DefaultTransport), + libp2p.NoListenAddrs, + ) + if err != nil { + return fmt.Errorf("host: %s", err) + } + defer func() { + if cerr := host.Close(); cerr != nil && err == nil { + err = fmt.Errorf("close: %s", cerr) + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), pingBudget) + defer cancel() + if err := host.Connect(ctx, *info); err != nil { + return fmt.Errorf("connect: %s", err) + } + results := ping.Ping(ctx, host, info.ID) + select { + case result, ok := <-results: + if !ok { + return fmt.Errorf("ping returned no result") + } + if result.Error != nil { + return fmt.Errorf("ping: %s", result.Error) + } + fmt.Printf("PING OK rtt=%s\n", result.RTT) + return nil + case <-ctx.Done(): + return fmt.Errorf("ping deadline: %s", ctx.Err()) + } +} From e5ab8fef592ff88d3ae891973dc3c1a0efce2e84 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Wed, 11 Nov 2026 16:33:25 -0500 Subject: [PATCH 07/15] docs(cli): link the go-to-lean shutdown question --- packages/cli/docs/ARCHITECTURE.md | 12 +++++++++--- packages/cli/docs/PLAN.md | 5 ++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/cli/docs/ARCHITECTURE.md b/packages/cli/docs/ARCHITECTURE.md index adffe53..8350f4d 100644 --- a/packages/cli/docs/ARCHITECTURE.md +++ b/packages/cli/docs/ARCHITECTURE.md @@ -524,9 +524,15 @@ handler. The spike demo still echoes on `/demo/echo/1.0.0`. path is the secure connection's own closed error. Treating that error as the peer's end would change the yamux sentence that throws a failed write when the session ends at the connection. The - question stands: after the peer's RST has ended the secure - connection, should join still throw `resourceVanished` code 0 with - that Noise detail, and should `pingnode serve` still exit 1? + reproducer is commit `416bc8e33bd99502799ae3a924f34188ee9a8246`. + The question is + https://github.com/etheorem/leanp2p/issues/22#issuecomment-6807493655. + It asks whether join throws `resourceVanished` code 0 with that + Noise detail after the peer's RST has ended the secure connection, + and whether `pingnode serve` exits 1. The question stays unresolved. + It is separate from the outbound-deadline question and the + echo-home question at + https://github.com/etheorem/leanp2p/issues/22#issuecomment-6805941244. `finish_child` in `scripts/ping-go-interop.sh` sends SIGTERM, waits at most 2 seconds, sends SIGKILL if the child is alive, and reaps it. The ping deadline, the stdin holder, and the EXIT trap share diff --git a/packages/cli/docs/PLAN.md b/packages/cli/docs/PLAN.md index c2ba053..3636d75 100644 --- a/packages/cli/docs/PLAN.md +++ b/packages/cli/docs/PLAN.md @@ -108,7 +108,10 @@ home blocks the demo echo leg. `pingnode serve` is implemented. `stream failed: closed: the stream closed peer 12D3KooWPDx7LZXLcXg8CgvnLimnKnQeM9rXKPeS9jJgNgmfLAGs protocol /ipfs/ping/1.0.0`, then `resource vanished (error code: 0, noise: the secure connection is closed)`. The architecture decision records the cause. The yamux rule that - throws this write stays in place. The shutdown fault stays open. + throws this write stays in place. The shutdown fault stays open, at + https://github.com/etheorem/leanp2p/issues/22#issuecomment-6807493655. + That question stays unresolved. It is separate from the + outbound-deadline question and the echo-home question. `PING_GO_BIN=/usr/bin/false scripts/ping-go-interop.sh` exited 1 with `FAIL go-to-lean: go exit 1`. The `pingnode` process, the stdin holder, and the Go command were gone. A test-only peer that From b2f9d58cc0c869968d2aa65c7c366c2f9c224451 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Wed, 11 Nov 2026 16:53:31 -0500 Subject: [PATCH 08/15] test(cli): coordinate go-to-lean shutdown --- docs/ARCHITECTURE.md | 5 +- packages/cli/docs/ARCHITECTURE.md | 202 +++++++++--------- packages/cli/docs/PLAN.md | 88 ++++---- .../libp2p/protocols/docs/ARCHITECTURE.md | 9 +- packages/libp2p/protocols/docs/PLAN.md | 4 +- scripts/ping-go-interop.sh | 87 ++++++-- scripts/ping-go/main.go | 55 ++++- 7 files changed, 272 insertions(+), 178 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e3c8b3a..8fb7f0a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -871,7 +871,10 @@ Everything with libp2p semantics, in subpackages: (`HashMap ProtocolId StreamHandler`), the peerstore, and `Host.Error`, which wraps `Core.Error` with arms of its own. - **`protocols/`**: identify (with push), ping, req/resp, and - gossipsub (§8). + gossipsub (§8). A contributor working decision on the CLI branch + places the future demo echo handler and client at + `Libp2p.Protocols.TestSupport.Echo`. That module sits beside the + four protocols, and this tree does not implement it yet. - **Policy seams**: connection gater, resource manager, address ranker. The interfaces exist now, and the implementations come later. diff --git a/packages/cli/docs/ARCHITECTURE.md b/packages/cli/docs/ARCHITECTURE.md index 8350f4d..5bb673d 100644 --- a/packages/cli/docs/ARCHITECTURE.md +++ b/packages/cli/docs/ARCHITECTURE.md @@ -64,7 +64,8 @@ work. - **`demo`** (the spike's `Main.lean`, the file `Demo.lean` here). The executable takes no arguments. The behavior section below is - the contract. The echo home is the open question in that section. + the contract. The echo home is the working decision in that + section. This slice does not add the module. - **`pingnode`** (the spike's `PingNode.lean`). `pingnode serve ` hosts `127.0.0.1:`, serves `/ipfs/ping/1.0.0` and identify, prints @@ -94,8 +95,9 @@ work. 1. **No protocol logic.** Ping and identify come from `libp2p/protocols`: the handler values, `Ping.ping`, and - `Identify.client`. The demo echo has no assigned home. The open - question records the two documents that leave it open. Every seam + `Identify.client`. The demo echo home is + `Libp2p.Protocols.TestSupport.Echo`, recorded below. This slice + does not add that module. Every seam value comes from its implementation package: the TCP transport from `net/streams`, which alone binds `Std.Async.TCP` (§9, delta 7), the security value from @@ -132,8 +134,8 @@ work. ## W5.8 behavior This section binds the demo and the ping node. Unit 1 implements it. -The open question at the end has no home yet. The interop contract -above stays in force. +The echo home at the end is the working decision. This slice does +not add that module. The interop contract above stays in force. ### Executables @@ -254,7 +256,7 @@ Both binaries build one `Host.Config`. when `Host.close` joins the task. The demo and the ping node register ping and identify. Echo -registration waits on the open question. +registration waits for `Libp2p.Protocols.TestSupport.Echo`. `Identify.respondHandler` is registered after `startListening` returns. Its listen-address list is that bound address, as @@ -345,39 +347,28 @@ take `newStream` as its work. `onExpiry` has type `BaseIO Unit`, and hold the stream. `release` frees a value the caller never sees, and the default `release` drops the value (`Conc/Deadline.lean`). -### Open question: the deadline around `Host.newStream` +### Working decision: the deadline around `Host.newStream` -Which close may a timer around `Host.newStream` run, and which -duration does it use? During negotiation the caller holds no stream. -The exchange reset runs on the stream the call has returned. The -upgrade deadline covers the dial that runs when no session is live. -`Host.close` wakes a read parked in the session through the session -close, and the same call ends the host. No current contract assigns -this timer. An answer has to name the close and the duration before -the dial or the demo adds one. +This branch selects a contributor working decision. The review +context is +https://github.com/etheorem/leanp2p/issues/22#issuecomment-6805941244. -The question blocks a negotiation bound on `pingnode dial` and on -each `newStream` of the demo. `pingnode serve` opens no outbound -stream. +During negotiation the caller holds no stream. The stream returns +only after `negotiateStream` finishes, and that read parks while an +authenticated peer stays silent +(`Libp2p/Host/StreamSelect.lean`). `Host.close` closes the session +and wakes parked stream reads. -The pending proposal is -https://github.com/etheorem/leanp2p/issues/22#issuecomment-6805941244. -It asks whether `pingnode dial` and the demo may apply -`Streams.unauthDeadlineMs` to `Host.newStream`, treat expiry as a -command failure, and close the host they own. The proposal stays -pending. It blocks the dial implementation and each outbound -`newStream` of the demo. The server path stays as implemented. - -The question for the issue: - -What bounds protocol negotiation inside `Host.newStream`? The stream -returns only after `negotiateStream` finishes, and that read parks -while an authenticated peer stays silent on the stream -(`Libp2p/Host/StreamSelect.lean`). The CLI holds no stream to reset -until the call returns. `Host.close` closes the session and wakes -parked stream reads, and it also ends the host. May `pingnode dial` -and the demo start that close from a timer around `newStream`, and -what duration applies? +One-shot `pingnode dial` and `demo` apply `Streams.unauthDeadlineMs` +(30 seconds) around `Host.newStream`. Expiry fails the command and +closes the host that command owns. The command joins its work and +its cleanup tasks. A result that arrives after the timer goes to +`release`. + +The policy adds no library idle timeout. `Host` keeps its interface. +An authenticated idle connection stays open under §5.4. +`pingnode serve` opens no outbound stream. The dial slice implements +the timer. This slice records the policy. ### Stream retirement @@ -416,9 +407,10 @@ An inbound peer failure reaches stderr through `onFailure`, on the host task that saw it. The accept loop keeps running. A write that throws is kept until `close` returns, and the process then exits 1. A host-task fault reaches stderr when `close` throws it, and the -process then exits 1. A Go dialer that closes the TCP connection -after a successful ping can take this path. The Go-helper decision -records the runs and the open question. +process then exits 1. An abrupt Go close after a successful ping +can take this path. The coordinated Go-to-Lean check closes this +stdin first, while the Go host is still up. The Go-helper decision +records both lifecycles. The read has no reader task. The calling thread blocks in `getLine`. The accept loop is the host task that `startListening` started. @@ -445,8 +437,9 @@ The demo runs these steps in order. 5. Register identify with the bound address and the ids registered so far. 6. Store that address with `addAddrs`, then `dialPeer`. -7. Open a `newStream` for ping, then identify. The echo stream waits - on the open question. +7. Open a `newStream` for ping, then identify, then the echo id + from `Libp2p.Protocols.TestSupport.Echo`. The echo module waits + for its slice. Each `newStream` uses the 30 second budget above. 8. Close the dialer, then the listener. Every exit closes each host that `Host.new` returned. The call is @@ -456,41 +449,29 @@ received `onFailure` also exits 1, after both closes. On success, stdout carries the dial line, the ping rtt, and the identify summary. The spike's `Main.lean` is the shape of those -lines. The echo line waits on the open question. - -### Open question: the demo echo home - -The demo exchange includes an echo. The spike's `Main.lean` registers -`/demo/echo/1.0.0` on the listener, reads one chunk, writes those -bytes, and half-closes. The client writes `echo-me` and reads the -reply. +lines. The echo line waits for `Libp2p.Protocols.TestSupport.Echo`. -§2 keeps protocol logic out of `cli/`. §4.9 names identify, ping, -req/resp, and gossipsub. The protocols architecture follows that -list, and the package exports no echo handler. `Tests/Host.lean` -keeps a private handler for `/test/echo`. +### Working decision: the demo echo home -Issue #22 and PR #50 assign no home. The review of PR #50 on 6 -November 2026 records the CLI sentence "echo must come from protocol -test support" under "Checked, no change needed." The merged protocols -package has no such support. +This branch selects a contributor working decision. The review +context is +https://github.com/etheorem/leanp2p/issues/22#issuecomment-6805941244. -The implementation slice places the handler and the client after a -maintainer assigns the home. The ping node registers ping and +The handler and the client live at +`Libp2p.Protocols.TestSupport.Echo`, below `cli/`. The protocol id +is `/demo/echo/1.0.0`, in that one module. The value is a +`StreamHandler`. The executable imports the value and holds no echo +logic. + +§2 keeps protocol logic out of `cli/`. The review of PR #50 on 6 +November 2026 records "echo must come from protocol test support." +§4.9 still names identify, ping, req/resp, and gossipsub. Test +support sits beside that list. The spike handler reads one chunk, +writes those bytes, and half-closes. The client writes `echo-me` +and reads the reply. This slice does not add the module. The demo +echo leg waits for that slice. The ping node registers ping and identify. -The same comment, -https://github.com/etheorem/leanp2p/issues/22#issuecomment-6805941244, -asks where the handler and the client for `/demo/echo/1.0.0` live. -That home stays pending. It blocks the demo echo leg. - -The question for the issue: - -Where does the W5.8 demo echo handler live, and where does its client -live? `cli/` holds no protocol logic (§2). `protocols/` lists -identify, ping, req/resp, and gossipsub (§4.9) and exports no echo -handler. The spike demo still echoes on `/demo/echo/1.0.0`. - ## Decisions - **A nominal lib, and no public API.** §2 gives `cli/` no public @@ -505,41 +486,52 @@ handler. The spike demo still echoes on `/demo/echo/1.0.0`. `interop-matrix.sh` and `interop-slowreader/` under the root `scripts/`, beside the W0.3 vector tooling (§7 row 11's note). The Go-to-Lean ping check is `scripts/ping-go/` and - `scripts/ping-go-interop.sh`. The Go peer dials `pingnode serve`, - so this leg stands while the outbound `newStream` proposal is - pending. The module requires go-libp2p v0.50.0. The host Go - toolchain builds the helper with `CGO_ENABLED=0`. That module asks - for Go 1.26. The helper runs on the host loopback. - Shutdown after a successful Go ping is still open. Go closes the - TCP connection with RST (linger 0). A TCP read of that RST answers - EOF, and the Noise session ends. A later write throws `closedError` - in `Libp2p/Security/Transport.lean`: `resourceVanished` code 0, - `noise: the secure connection is closed`. Yamux records the write. - The session ended because the connection ended, so `Session.join` - throws the write (`packages/net/yamux/docs/ARCHITECTURE.md`). The - ping handler also answers `.closed`. `serveStream` reports that as - `stream failed: closed: the stream closed`. `Host.close` throws the - write fault, and `guarding` returns 1. - A nonzero `resourceVanished` code stays a fault. Code 0 on this - path is the secure connection's own closed error. Treating that - error as the peer's end would change the yamux sentence that throws - a failed write when the session ends at the connection. The - reproducer is commit `416bc8e33bd99502799ae3a924f34188ee9a8246`. - The question is + `scripts/ping-go-interop.sh`. The Go peer dials `pingnode serve`. + The module requires go-libp2p v0.50.0. The host Go toolchain builds + the helper with `CGO_ENABLED=0`. That module asks for Go 1.26. The + helper runs on the host loopback. + This branch selects a contributor working decision for shutdown. + The review context is https://github.com/etheorem/leanp2p/issues/22#issuecomment-6807493655. - It asks whether join throws `resourceVanished` code 0 with that - Noise detail after the peer's RST has ended the secure connection, - and whether `pingnode serve` exits 1. The question stays unresolved. - It is separate from the outbound-deadline question and the - echo-home question at - https://github.com/etheorem/leanp2p/issues/22#issuecomment-6805941244. - `finish_child` in `scripts/ping-go-interop.sh` sends SIGTERM, waits - at most 2 seconds, sends SIGKILL if the child is alive, and reaps - it. The ping deadline, the stdin holder, and the EXIT trap share - that helper. The trap keeps the original status. A child that has - already exited is reaped. The Go module pin and the Lean - toolchain stay as they are. Lean-to-Go and the bidirectional gate - stay unfinished. The spike also kept a + Noise, yamux, the host, and the CLI keep their fault propagation. + An abrupt Go close still uses TCP RST (linger 0). A TCP read of + that RST answers EOF, and the Noise session ends. A later write + throws `closedError` in `Libp2p/Security/Transport.lean`: + `resourceVanished` code 0, `noise: the secure connection is closed`. + Yamux records the write. The session ended because the connection + ended, so `Session.join` throws the write + (`packages/net/yamux/docs/ARCHITECTURE.md`). `Host.close` throws + that fault, and `guarding` returns 1. A nonzero + `resourceVanished` code stays a fault. Commit + `416bc8e33bd99502799ae3a924f34188ee9a8246` is the abrupt-close + reproducer. Its three runs were exit 0 + (`PASS go-to-lean PING OK rtt=532.916µs`), exit 0 + (`PASS go-to-lean PING OK rtt=778.708µs`), and exit 1 with + `stream failed: closed: the stream closed` and + `resource vanished (error code: 0, noise: the secure connection is closed)`. + The coordinated check keeps the Go host up after the ping. The + helper cancels `ping.Ping`, reads the result channel until it + closes, prints the result, and waits for stdin EOF. The driver + reads `ADDR`, starts Go, requires `PING OK` while Go stays alive, + closes the server stdin, requires server exit 0, closes the Go + stdin, and requires Go exit 0. Three runs of + `scripts/ping-go-interop.sh` with `LEAN_NUM_THREADS=1` exited 0 and + printed `PASS go-to-lean PING OK rtt=834.084µs`, + `PASS go-to-lean PING OK rtt=549.5µs`, and + `PASS go-to-lean PING OK rtt=669.667µs`. Each stderr was empty. + `finish_child` sends SIGTERM, waits at most 2 seconds, sends + SIGKILL, and reaps the child. The owned children are the Go + process, the Go stdin holder, the server stdin holder, and the + server. The trap keeps the original status. A child that has + already exited is reaped. `PING_GO_BIN=/usr/bin/false` exited 1 + with `FAIL go-to-lean: go exit 1`. A test-only peer that ignores + SIGTERM exited 1 after 28.3 seconds, including its build, with + `FAIL go-to-lean: go ping missed 25 s`. The cleanup bound on that + miss is 33 seconds: the 25 second ping deadline, then 2 seconds + for each of the four children. Those processes were gone after + each check. The Go module pin and the Lean toolchain stay as they + are. Lean-to-Go and the bidirectional gate stay unfinished. The + spike also kept a test-plans runner image, `interop/leanp2p/{Makefile,Dockerfile}`. The target drops the image, and `scripts/interop-matrix.sh` is the gate's only form, so docker appears only inside the scripts. diff --git a/packages/cli/docs/PLAN.md b/packages/cli/docs/PLAN.md index 3636d75..d14a8d1 100644 --- a/packages/cli/docs/PLAN.md +++ b/packages/cli/docs/PLAN.md @@ -39,21 +39,22 @@ of the package architecture is the contract those binaries implement, including the argument table, the exit codes, the ping and identify deadlines, the stream retirement, and the two-host close. Those deadlines start -after `newStream` returns. The demo echo home and the deadline around -`Host.newStream` are the open questions in that architecture. Both -are pending on +after `newStream` returns. This branch selects two contributor +working decisions. The review context is https://github.com/etheorem/leanp2p/issues/22#issuecomment-6805941244. -The 30 s `newStream` proposal in that comment stays pending. It -blocks `pingnode dial` and the demo's outbound streams. The echo -home blocks the demo echo leg. `pingnode serve` is implemented. +One-shot `pingnode dial` and `demo` apply a 30 second budget around +`Host.newStream`. Expiry fails the command and closes the owned +host. The dial slice implements that timer. The demo echo handler +and client belong at `Libp2p.Protocols.TestSupport.Echo`. This slice +does not add that module. `pingnode serve` is implemented. - The demo runs end to end over loopback: two hosts dial, complete the upgrade, identify, and exchange pings. The listener's identify record carries the bound address and the registered protocol ids. - The echo leg joins this run after the echo question has a home. - Each `newStream` uses the exchange deadline after the call returns. - A bound on the negotiation inside the call waits on the stream-open - question. + The echo leg joins this run when + `Libp2p.Protocols.TestSupport.Echo` exists. Each `newStream` uses + the exchange deadline after the call returns, and the 30 second + budget around the call. The spike's node-suite legs are the carried tests (the spike's `Tests/Node.lean` drives two hosts over loopback TCP through ping, identify, and echo). @@ -86,48 +87,43 @@ home blocks the demo echo leg. `pingnode serve` is implemented. - `pingnode dial` against `pingnode serve` prints `PING OK`, and both processes close their hosts on the failure paths of the behavior section. The run uses the exchange deadline after `newStream` - returns. A bound on the negotiation inside that call waits on the - pending proposal in the comment linked above. + returns, and the 30 second budget around that call. The dial slice + implements the budget. - Go-to-Lean ping is in this slice. The Go peer dials `pingnode - serve`, while the outbound `newStream` proposal stays pending. - `scripts/ping-go/main.go` dials one multiaddr over TCP, Noise, and - yamux, and prints `PING OK rtt=`. The module requires + serve`. `scripts/ping-go/main.go` dials one multiaddr over TCP, + Noise, and yamux, prints `PING OK rtt=`, and waits for + stdin EOF before it closes the host. The module requires `github.com/libp2p/go-libp2p v0.50.0`. The host Go toolchain builds it with `CGO_ENABLED=0`. The module asks for Go 1.26. `scripts/ping-go-interop.sh` starts `pingnode serve 0` with - `LEAN_NUM_THREADS=1`, joins the `ADDR` multiaddr and the peer id, - and requires one ping result. Stdin EOF then waits for server exit - 0. `finish_child` ends an owned child that misses its wait: SIGTERM, - at most 2 seconds (`term_grace`), SIGKILL, then `wait`. A child - that has already exited is reaped. The trap keeps the script's - status. Three runs of `scripts/ping-go-interop.sh` used - `LEAN_NUM_THREADS=1` on the server. Run 1 exited 0 and printed - `PASS go-to-lean PING OK rtt=532.916µs`. Run 2 exited 0 and printed - `PASS go-to-lean PING OK rtt=778.708µs`. Both had empty stderr. - Run 3 exited 1. Stderr was `FAIL go-to-lean: server exit 1`, then - `stream failed: closed: the stream closed peer 12D3KooWPDx7LZXLcXg8CgvnLimnKnQeM9rXKPeS9jJgNgmfLAGs protocol /ipfs/ping/1.0.0`, - then `resource vanished (error code: 0, noise: the secure connection is closed)`. - The architecture decision records the cause. The yamux rule that - throws this write stays in place. The shutdown fault stays open, at + `LEAN_NUM_THREADS=1`, joins the `ADDR` fields, and requires the + ping line while Go stays alive. It then closes the server stdin, + requires server exit 0, closes the Go stdin, and requires Go exit + 0. `finish_child` ends an owned child that misses its wait: + SIGTERM, at most 2 seconds (`term_grace`), SIGKILL, then `wait`. + The owned children are the Go process, the Go stdin holder, the + server stdin holder, and the server. A child that has already + exited is reaped. The trap keeps the script's status. The abrupt + Go-first close remains the historical case in the architecture + decision, at commit `416bc8e33bd99502799ae3a924f34188ee9a8246` and https://github.com/etheorem/leanp2p/issues/22#issuecomment-6807493655. - That question stays unresolved. It is separate from the - outbound-deadline question and the echo-home question. + That close can still surface the failed write. The library fault + rules stay in place. Three coordinated runs exited 0 and printed + `PASS go-to-lean PING OK rtt=834.084µs`, + `PASS go-to-lean PING OK rtt=549.5µs`, and + `PASS go-to-lean PING OK rtt=669.667µs`, each with empty stderr. `PING_GO_BIN=/usr/bin/false scripts/ping-go-interop.sh` exited 1 - with `FAIL go-to-lean: go exit 1`. The `pingnode` process, the - stdin holder, and the Go command were gone. A test-only peer that - ignores SIGTERM, passed as `PING_GO_BIN`, made the script exit 1 - after 28 seconds, including its build, with - `FAIL go-to-lean: go ping missed 25 s`. The Go peer, the Lean - server, and the stdin holder were gone. The cleanup bound is 31 - seconds: the 25 second ping deadline, then 2 seconds for the peer, - 2 seconds for the holder, and 2 seconds for the server. The check - stays outside `lake test`. - Lean-to-Go, the demo, and the bidirectional gate remain unfinished. - The two questions at - https://github.com/etheorem/leanp2p/issues/22#issuecomment-6805941244 - remain pending. The spike selected its muxer from the Noise - handshake extension. This stack negotiates the muxer on the wire - (the negotiate pair's decision). + with `FAIL go-to-lean: go exit 1`. A test-only peer that ignores + SIGTERM, passed as `PING_GO_BIN`, made the script exit 1 after 28.3 + seconds, including its build, with + `FAIL go-to-lean: go ping missed 25 s`. The Go process, both stdin + holders, and the server were gone. The cleanup bound on that miss + is 33 seconds: the 25 second ping deadline, then 2 seconds for + each of the four children. The check stays outside `lake test`. + Lean-to-Go, the demo, echo, and the bidirectional gate remain + unfinished. The spike selected its muxer from the Noise handshake + extension. This stack negotiates the muxer on the wire (the + negotiate pair's decision). - The in-suite legs pass under `+RTS -N1` (§6; PRINCIPLES 5.3). ## Test milestones (unit 2) diff --git a/packages/libp2p/protocols/docs/ARCHITECTURE.md b/packages/libp2p/protocols/docs/ARCHITECTURE.md index da8d434..54190ae 100644 --- a/packages/libp2p/protocols/docs/ARCHITECTURE.md +++ b/packages/libp2p/protocols/docs/ARCHITECTURE.md @@ -43,7 +43,14 @@ to `libp2p/security` beside the handshake it serves. The `Envelope` and module line keeps the responsibility split of PRINCIPLES 1.2; this package holds four protocols, and no module holds two. §4.9 names identify, ping, req/resp, and gossipsub, and the module list follows -that sentence. The package exports no echo handler. +that sentence. The package exports no echo handler. A contributor +working decision on the CLI branch places the future demo echo +handler and client at `Libp2p.Protocols.TestSupport.Echo`. The +protocol id is `/demo/echo/1.0.0`, in that one module, as a +`StreamHandler`. Test support sits beside the four protocols. The +review context is +https://github.com/etheorem/leanp2p/issues/22#issuecomment-6805941244. +This package does not implement that module yet. ## Public surface diff --git a/packages/libp2p/protocols/docs/PLAN.md b/packages/libp2p/protocols/docs/PLAN.md index e39f7ee..a192769 100644 --- a/packages/libp2p/protocols/docs/PLAN.md +++ b/packages/libp2p/protocols/docs/PLAN.md @@ -36,7 +36,9 @@ in the codec group wrapping upstream `google/snappy`. - `cli/` (W5.8) wires identify and ping into the demo node and the ping node (root PLAN W5.8; the spike's `Main.lean` and `PingNode.lean` - show the wiring). + show the wiring). A contributor working decision places the demo + echo handler and client at `Libp2p.Protocols.TestSupport.Echo`. + That module is not in this plan's units yet. - The consensus client consumes req/resp and gossipsub through the handler values and the client side (§8). SSZ payload types stay with the client. diff --git a/scripts/ping-go-interop.sh b/scripts/ping-go-interop.sh index 815de69..356af4c 100755 --- a/scripts/ping-go-interop.sh +++ b/scripts/ping-go-interop.sh @@ -4,10 +4,11 @@ # Readiness is `ADDR `. The dial address joins # those fields with `/p2p/`. The address field has no peer suffix. # -# The server runs with LEAN_NUM_THREADS=1. Its stdin stays open until -# the ping returns, then the holder exits and the server sees EOF. -# A failed Go command takes the same cleanup and the script exits 1. -# Set PING_GO_BIN to replace the helper for that failure check. +# The server runs with LEAN_NUM_THREADS=1. Go prints one ping result +# and stays up. The driver then closes the server's stdin and waits +# for the server to exit 0. It then closes Go's stdin and waits for +# Go to exit 0. PASS follows both exits. A peer that exits early +# fails the script. Set PING_GO_BIN to replace the helper. # # The helper builds with the host Go toolchain, CGO off, from # scripts/ping-go. go-libp2p v0.50.0 asks for Go 1.26. An older go @@ -16,9 +17,10 @@ # `finish_child` ends one owned child. It sends SIGTERM, waits at most # `term_grace` seconds, sends SIGKILL if the child is still alive, and # reaps it. A child that has already exited is reaped. The ping -# deadline, the stdin holder, and the EXIT cleanup use this helper. -# The server's own shutdown wait stays a poll: stdin EOF is the wake, -# and the helper runs only if that wait is abandoned. +# deadline, both stdin holders, the Go teardown deadline, and the +# EXIT cleanup use this helper. The server shutdown wait and the Go +# exit wait are polls. EOF is the wake. The helper runs for a child +# that misses its wait, and for each holder when its EOF is due. set -euo pipefail root=$(cd "$(dirname "$0")/.." && pwd) @@ -27,6 +29,7 @@ bin="$root/.tmp/ping-go/ping-go" server_pid= hold_pid= go_pid= +go_hold_pid= server_status= term_grace=2 @@ -57,6 +60,10 @@ reap() { finish_child "$go_pid" go_pid= fi + if [[ -n ${go_hold_pid:-} ]]; then + finish_child "$go_hold_pid" + go_hold_pid= + fi if [[ -n ${hold_pid:-} ]]; then finish_child "$hold_pid" hold_pid= @@ -134,10 +141,32 @@ fi dial="${addr}/p2p/${peer}" go_bin=${PING_GO_BIN:-$bin} -"$go_bin" "$dial" >"$work/go.out" 2>"$work/go.err" & +mkfifo "$work/go.in" +sleep 3600 >"$work/go.in" & +go_hold_pid=$! +"$go_bin" "$dial" <"$work/go.in" >"$work/go.out" 2>"$work/go.err" & go_pid=$! start=$(date +%s) -while kill -0 "$go_pid" 2>/dev/null; do +while true; do + if [[ -s "$work/go.out" ]] && grep -q '^PING OK rtt=.' "$work/go.out"; then + if ! kill -0 "$go_pid" 2>/dev/null; then + set +e + wait "$go_pid" + go_status=$? + set -e + go_pid= + fail "go exited after the ping result: $go_status" + fi + break + fi + if ! kill -0 "$go_pid" 2>/dev/null; then + set +e + wait "$go_pid" + go_status=$? + set -e + go_pid= + fail "go exit $go_status" + fi now=$(date +%s) if (( now - start >= 25 )); then finish_child "$go_pid" @@ -146,22 +175,17 @@ while kill -0 "$go_pid" 2>/dev/null; do fi sleep 0.2 done -set +e -wait "$go_pid" -go_status=$? -set -e -go_pid= -if [[ "$go_status" -ne 0 ]]; then - fail "go exit $go_status" -fi -if ! grep -q '^PING OK rtt=.' "$work/go.out"; then - fail "go printed no ping result" -fi +if ! kill -0 "$go_pid" 2>/dev/null; then + fail "go exited before the server shutdown" +fi finish_child "$hold_pid" hold_pid= start=$(date +%s) while kill -0 "$server_pid" 2>/dev/null; do + if ! kill -0 "$go_pid" 2>/dev/null; then + fail "go exited during the server shutdown" + fi now=$(date +%s) if (( now - start >= 15 )); then fail "server missed the shutdown deadline" @@ -176,4 +200,27 @@ server_pid= if [[ "$server_status" -ne 0 ]]; then fail "server exit $server_status" fi +if ! kill -0 "$go_pid" 2>/dev/null; then + fail "go exited before its teardown" +fi +finish_child "$go_hold_pid" +go_hold_pid= +start=$(date +%s) +while kill -0 "$go_pid" 2>/dev/null; do + now=$(date +%s) + if (( now - start >= 15 )); then + finish_child "$go_pid" + go_pid= + fail "go missed the teardown deadline" + fi + sleep 0.2 +done +set +e +wait "$go_pid" +go_status=$? +set -e +go_pid= +if [[ "$go_status" -ne 0 ]]; then + fail "go teardown exit $go_status" +fi echo "PASS go-to-lean $(head -n 1 "$work/go.out")" diff --git a/scripts/ping-go/main.go b/scripts/ping-go/main.go index a8992d4..dbd4a40 100644 --- a/scripts/ping-go/main.go +++ b/scripts/ping-go/main.go @@ -1,14 +1,20 @@ -// Dial one peer and complete one ping. +// Dial one peer, complete one ping, and close on stdin EOF. // // The argument is a multiaddr whose last component is the expected // peer id. The host is TCP, Noise, and yamux. A failed setup, // connection, deadline, or ping exits 1. A bad argument count exits 2. -// The host closes on every return after it exists. +// +// ping.Ping runs until its context ends. After the first successful +// result the helper cancels that context, reads the result channel +// until it closes, prints the result, and waits for stdin EOF. The +// host stays open through that wait. The host closes on every return +// after it exists. package main import ( "context" "fmt" + "io" "os" "time" @@ -59,6 +65,7 @@ func run(target string) (err error) { return fmt.Errorf("connect: %s", err) } results := ping.Ping(ctx, host, info.ID) + var rtt time.Duration select { case result, ok := <-results: if !ok { @@ -67,9 +74,49 @@ func run(target string) (err error) { if result.Error != nil { return fmt.Errorf("ping: %s", result.Error) } - fmt.Printf("PING OK rtt=%s\n", result.RTT) - return nil + rtt = result.RTT case <-ctx.Done(): return fmt.Errorf("ping deadline: %s", ctx.Err()) } + if err := stopPing(cancel, results); err != nil { + return err + } + fmt.Printf("PING OK rtt=%s\n", rtt) + if err := os.Stdout.Sync(); err != nil { + return fmt.Errorf("stdout: %s", err) + } + return waitStdin() +} + +// stopPing ends ping.Ping and waits until its result channel closes. +// The library resets the ping stream when the context ends. The bound +// is pingBudget. +func stopPing(cancel context.CancelFunc, results <-chan ping.Result) error { + cancel() + timer := time.NewTimer(pingBudget) + defer timer.Stop() + for { + select { + case _, ok := <-results: + if !ok { + return nil + } + case <-timer.C: + return fmt.Errorf("ping stop deadline") + } + } +} + +// waitStdin reads until EOF. Bytes before EOF are discarded. +func waitStdin() error { + buf := make([]byte, 256) + for { + _, err := os.Stdin.Read(buf) + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("teardown: %s", err) + } + } } From c736828699f73452cb1571200937bfadf3786e1a Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Wed, 11 Nov 2026 17:23:08 -0500 Subject: [PATCH 09/15] feat(cli): implement bounded pingnode dial --- Tests/Cli.lean | 26 ++-- Tests/CliLive.lean | 194 +++++++++++++++++++++++++++++- packages/cli/PingNode.lean | 149 ++++++++++++++++++++--- packages/cli/docs/ARCHITECTURE.md | 98 ++++++++++++--- packages/cli/docs/PLAN.md | 50 ++++++-- 5 files changed, 468 insertions(+), 49 deletions(-) diff --git a/Tests/Cli.lean b/Tests/Cli.lean index f4c6f4d..99b1d34 100644 --- a/Tests/Cli.lean +++ b/Tests/Cli.lean @@ -5,8 +5,10 @@ import Tests.Support # Demo and pingnode arguments The checks run the built binaries. A usage error exits 2 with an -empty stdout. `demo` and `pingnode dial` still reach the unfinished -diagnostic. `pingnode serve` with a valid port listens. +empty stdout. A dial to port 0 is an immediate local failure. +`capture` bounds it at 10 seconds. The child exits inside that +deadline with code 1, empty stdout, and a nonempty stderr. +`pingnode serve` with a valid port listens. -/ set_option autoImplicit false @@ -143,6 +145,17 @@ private def expectServe (runner : Runner) (name port : String) (requireBind : Bo runner.check s!"{name}: runtime stderr" (out.stderr != "" && !out.stderr.startsWith "invalid port:") +/-- A dial that passes validation and then fails before a ping. +The deadline bounds an immediate local failure. The child must exit +inside it, with code 1, empty stdout, and a nonempty stderr. -/ +private def expectDialFail (runner : Runner) (name : String) (args : Array String) : + IO Unit := do + let out ← capture ".lake/build/bin/pingnode" args 10000 + runner.check s!"{name}: bounded" !out.timedOut + runner.check s!"{name}: exit 1" (out.exitCode == 1) + runner.check s!"{name}: stdout empty" (out.stdout == "") + runner.check s!"{name}: runtime stderr" (out.stderr != "") + /-- Run one built executable and compare its exit, stdout, and stderr. -/ private def expect (runner : Runner) (name exe : String) (args : Array String) (code : UInt32) (stderr : String) : IO Unit := do @@ -189,11 +202,10 @@ def run (runner : Runner) : IO Unit := do let dial := s!"/ip4/127.0.0.1/tcp/4001/p2p/{peer}" expect runner "pingnode dial missing address" "pingnode" #["dial"] 2 "Missing positional argument `.`" - expect runner "pingnode dial" "pingnode" #[ "dial", dial ] 1 - "pingnode: not implemented" - expect runner "pingnode dial port 0" "pingnode" - #[ "dial", s!"/ip4/127.0.0.1/tcp/0/p2p/{peer}" ] 1 - "pingnode: not implemented" + -- Port 0 fails in the kernel before a listener is required. The + -- capture bound is 10 seconds. A refused port that the suite binds + -- itself lives in `Tests/CliLive.lean`. + expectDialFail runner "pingnode dial port 0" #[ "dial", s!"/ip4/127.0.0.1/tcp/0/p2p/{peer}" ] expect runner "pingnode dial extra" "pingnode" #[ "dial", dial, "extra" ] 2 "Redundant positional argument `extra`." expect runner "pingnode dial parse" "pingnode" #["dial", "not-an-addr"] 2 diff --git a/Tests/CliLive.lean b/Tests/CliLive.lean index 70cc6ec..8d84b89 100644 --- a/Tests/CliLive.lean +++ b/Tests/CliLive.lean @@ -5,7 +5,9 @@ import Multiformats import Libp2p.Identity import Libp2p.Security import Libp2p.Mux +import Libp2p.Core import Libp2p.Host +import Libp2p.Negotiate import Libp2p.Protocols.Ping import Libp2p.Protocols.Identify import Tests.Cli @@ -18,7 +20,10 @@ The checks run the built `pingnode` with `LEAN_NUM_THREADS=1`. One client host dials it over TCP and Noise while stdin stays open. The other checks cover EOF, a busy port, a failed `ADDR` write, an inbound failure, a deadline that kills a sleeper, and a registered task that -returns an error. +returns an error. The dial checks run the built `pingnode dial` against +this server, a closed port, a different peer id, a peer that accepts +the stream and stays silent, and a peer that reads the payload and +does not echo it. Each reader task belongs to the check that started it. The check wakes the task by closing the pipe it reads, and then joins it. @@ -87,8 +92,9 @@ private def collectLines (h : IO.FS.Handle) (lines : IO.Ref (Array String)) : IO if line.isEmpty then return lines.modify (·.push (stripNl line)) -/-- A fresh client host with the same stack as `pingnode serve`. -/ -private def clientHost : IO Host := do +/-- A fresh host with the same stack as `pingnode serve`. `handlers` +serves inbound streams. -/ +private def hosted (handlers : List Libp2p.Core.StreamHandler) : IO Host := do let seed ← match ← Crypto.ed25519Generate.run with | .error e => throw <| IO.userError (reprStr e) | .ok seed => pure seed @@ -103,10 +109,15 @@ private def clientHost : IO Host := do transports := [Streams.Tcp.transport] security := [Libp2p.Security.Noise.securityProtocol noise] muxers := [Libp2p.Mux.yamux, Libp2p.Mux.mplex] + handlers } : BaseIO _) with | .error e => throw <| IO.userError (reprStr e) | .ok host => return host +/-- A fresh client host with the same stack as `pingnode serve`. -/ +private def clientHost : IO Host := + hosted [] + /-- Dial `peer`, ping once, and read its identify record. `host.close` finishes before this function returns, including when the exchange throws. -/ private def exchange (addr : Multiformats.Multiaddr) (peer : PeerId) : IO Unit := do @@ -425,6 +436,177 @@ private def timeoutCleanup (runner : Runner) : IO Unit := do runner.check "timeout cleanup bounded" (elapsed < 5000) runner.check "timeout cleanup readers" (out.stdout == "" && out.stderr == "") +/-- `stdout` is one `PING OK` line. -/ +private def pingStdout (stdout : String) : Bool := + match stdout.splitOn "rtt=" with + | ["PING OK ", rest] => rest.endsWith "ms\n" && rest != "ms\n" + | _ => false + +/-- Close `host` and keep the earlier fault. -/ +private def closeHost (host : Host) (primary : Option IO.Error) : IO (Option IO.Error) := do + try + Conc.Async.run host.close + return primary + catch e => + return primary <|> some e + +/-- `pingnode dial` against the live server prints one RTT and exits 0. +The server then sees stdin EOF and exits 0. -/ +private def dialOk (runner : Runner) : IO Unit := do + withSession fun session => do + let target := s!"{session.addr}/p2p/{session.peer}" + let out ← Tests.Cli.capture bin #["dial", target] 20000 oneWorker + runner.check "dial ping exits" (!out.timedOut && out.exitCode == 0) + runner.check "dial ping stdout" (pingStdout out.stdout) + runner.check "dial ping stderr empty" (out.stderr == "") + session.stdin.set none + let (code, killed) ← Tests.Cli.waitChild session.child 10000 + runner.check "dial server exits" (code == 0 && !killed) + +/-- A port this check bound and then closed refuses the dial. -/ +private def dialRefused (runner : Runner) : IO Unit := do + let loop ← match Multiformats.Multiaddr.parse? "/ip4/127.0.0.1/tcp/0" with + | .ok addr => pure addr + | .error e => throw <| IO.userError (reprStr e) + let listener ← Conc.Async.run (Streams.Tcp.listen loop) + listener.close + Conc.Async.run listener.join + let host ← clientHost + let mut fault : Option IO.Error := none + try + let target := s!"{listener.addr}/p2p/{host.localPeer}" + let out ← Tests.Cli.capture bin #["dial", target] 10000 oneWorker + runner.check "dial refused exits" (!out.timedOut && out.exitCode == 1) + runner.check "dial refused stdout" (out.stdout == "") + runner.check "dial refused stderr" + (out.stderr == s!"nobody listens at {listener.addr}\n") + catch e => + fault := some e + fault ← closeHost host fault + if let some e := fault then throw e + +/-- The address is the server's, and the peer id is a different host. -/ +private def dialMismatch (runner : Runner) : IO Unit := do + let other ← clientHost + let mut fault : Option IO.Error := none + try + withSession fun session => do + let target := s!"{session.addr}/p2p/{other.localPeer}" + let out ← Tests.Cli.capture bin #["dial", target] 15000 oneWorker + runner.check "dial mismatch exits" (!out.timedOut && out.exitCode == 1) + runner.check "dial mismatch stdout" (out.stdout == "") + runner.check "dial mismatch stderr" + (out.stderr == "upgrade failed: authentication failure: remote peer id differs from the expected peer id\n") + session.stdin.set none + let (code, killed) ← Tests.Cli.waitChild session.child 10000 + runner.check "dial mismatch server exits" (code == 0 && !killed) + catch e => + fault := some e + fault ← closeHost other fault + if let some e := fault then throw e + +/-- Accept one connection, take the ping stream, and do not read it. +The second accept parks until the dialer closes the session. -/ +private def stallSession (listener : Streams.Listener) + (sec : Libp2p.Core.SecurityProtocol) : IO Unit := do + Conc.Async.run do + match ← listener.accept with + | none => pure () + | some conn => + match ← (Libp2p.Negotiate.upgrade Libp2p.Core.Role.responder conn ByteArray.empty none + [sec] [Libp2p.Mux.yamux]).run with + | .error _ => pure () + | .ok (session, _) => + match ← session.accept with + | some _ => discard <| session.accept + | none => pure () + session.close + discard <| session.join.run + +/-- The peer finishes Noise and yamux, then stays silent during +protocol selection. The dialer's own budget ends the command. -/ +private def dialStall (runner : Runner) : IO Unit := do + let seed ← match ← Crypto.ed25519Generate.run with + | .error e => throw <| IO.userError (reprStr e) + | .ok seed => pure seed + let key ← match PrivKey.ofData? .ed25519 seed.bytes with + | .error e => throw <| IO.userError (reprStr e) + | .ok key => pure key + let noise ← match ← (Libp2p.Security.Noise.Config.generate key).run with + | .error e => throw <| IO.userError (reprStr e) + | .ok noise => pure noise + let pub ← match key.toPublic with + | .error e => throw <| IO.userError (reprStr e) + | .ok pub => pure pub + let peer ← match PeerId.ofPubKey pub with + | .error e => throw <| IO.userError (reprStr e) + | .ok peer => pure peer + let loop ← match Multiformats.Multiaddr.parse? "/ip4/127.0.0.1/tcp/0" with + | .ok addr => pure addr + | .error e => throw <| IO.userError (reprStr e) + let listener ← Conc.Async.run (Streams.Tcp.listen loop) + let task ← IO.asTask + (stallSession listener (Libp2p.Security.Noise.securityProtocol noise)) + Task.Priority.dedicated + let mut fault : Option IO.Error := none + try + let target := s!"{listener.addr}/p2p/{peer}" + let start ← IO.monoMsNow + let out ← Tests.Cli.capture bin #["dial", target] 50000 oneWorker + let elapsed := (← IO.monoMsNow) - start + runner.check "dial stall exits" (!out.timedOut && out.exitCode == 1) + runner.check "dial stall stdout" (out.stdout == "") + runner.check "dial stall stderr" + (out.stderr == "stream failed: timeout: the stream opening deadline expired\n") + runner.check "dial stall bounded" (elapsed ≥ 25000 && elapsed < 45000) + catch e => + fault := some e + listener.close + try + match ← IO.wait task with + | .ok () => pure () + | .error e => + if fault.isNone then fault := some e + catch e => + if fault.isNone then fault := some e + if let some e := fault then throw e + +/-- Read the payload and park. The handler does not write the echo. -/ +private def silentPing : Libp2p.Core.StreamHandler where + id := Libp2p.Protocols.Ping.protocolId + handle _ s := ExceptT.mk do + match ← s.read with + | .ok (some _) => + match ← s.read with + | _ => return .ok () + | _ => return .ok () + +/-- The peer selects ping and does not return an echo. The exchange +budget ends the command, and `Host.close` joins the parked handler. -/ +private def dialNoEcho (runner : Runner) : IO Unit := do + let host ← hosted [silentPing] + let mut fault : Option IO.Error := none + try + let loop ← match Multiformats.Multiaddr.parse? "/ip4/127.0.0.1/tcp/0" with + | .ok addr => pure addr + | .error e => throw <| IO.userError (reprStr e) + let bound ← match ← Conc.Async.run (host.startListening loop) with + | .error e => throw <| IO.userError e.render + | .ok bound => pure bound + let target := s!"{bound}/p2p/{host.localPeer}" + let start ← IO.monoMsNow + let out ← Tests.Cli.capture bin #["dial", target] 50000 oneWorker + let elapsed := (← IO.monoMsNow) - start + runner.check "dial no echo exits" (!out.timedOut && out.exitCode == 1) + runner.check "dial no echo stdout" (out.stdout == "") + runner.check "dial no echo stderr" + (out.stderr == "stream failed: timeout: the ping exchange deadline expired\n") + runner.check "dial no echo bounded" (elapsed ≥ 25000 && elapsed < 45000) + catch e => + fault := some e + fault ← closeHost host fault + if let some e := fault then throw e + /-- The server checks. -/ def run (runner : Runner) : IO Unit := do runner.section "pingnode serve" "packages/cli/PingNode.lean" @@ -435,5 +617,11 @@ def run (runner : Runner) : IO Unit := do closedStdout runner timeoutCleanup runner registeredFault runner + runner.section "pingnode dial" "packages/cli/PingNode.lean" + dialOk runner + dialRefused runner + dialMismatch runner + dialStall runner + dialNoEcho runner end Tests.CliLive diff --git a/packages/cli/PingNode.lean b/packages/cli/PingNode.lean index a2b47f8..6df33fe 100644 --- a/packages/cli/PingNode.lean +++ b/packages/cli/PingNode.lean @@ -3,6 +3,7 @@ import Conc import Crypto import Streams import Multiformats +import Libp2p.Core import Libp2p.Identity import Libp2p.Security import Libp2p.Mux @@ -22,8 +23,9 @@ escaped in the usage line. `serve` builds one host after that check. It listens, registers identify, prints `ADDR`, and flushes. It then reads stdin in `IO`. -EOF calls `Host.close` and exits 0. `dial` still reaches the -unfinished node and exits 1. +EOF calls `Host.close` and exits 0. `dial` dials the expected peer, +opens one ping stream under the 30 second command budget, completes +one exchange, prints `PING OK`, and closes the host. -/ set_option autoImplicit false @@ -31,7 +33,7 @@ set_option autoImplicit false namespace Cli.PingNode open Cli -open Libp2p.Host (Host Failure) +open Libp2p.Host (Host Error Failure) open Libp2p.Identity (PeerId PrivKey) /-- One string operand. -/ @@ -74,11 +76,6 @@ private def usage (line : String) : IO UInt32 := do IO.eprintln (oneLine line) return 2 -/-- The dial path is not built in this slice. -/ -private def unfinished : IO UInt32 := do - IO.eprintln "pingnode: not implemented" - return 1 - /-- The agent string identify advertises. -/ private def agent : String := "leanp2p" @@ -219,20 +216,144 @@ private def transportHasPeer (transport : Multiformats.Multiaddr) : Bool := transport.components.any fun c => decide (c.proto = Multiformats.Protocol.«p2p») -/-- Accept a final `/p2p/` whose transport prefix has no `/p2p`. -/ -private def validDial (text : String) : Except String Unit := do +/-- Accept a final `/p2p/` whose transport prefix has no `/p2p`. +The pair is the address `Host.dial` takes and the expected peer. -/ +private def validDial (text : String) : + Except String (Multiformats.Multiaddr × PeerId) := do let addr ← match Multiformats.Multiaddr.parse? text with | .ok addr => pure addr | .error err => throw s!"{reprStr err} {text}" match Host.splitPeer addr with - | .ok (transport, some _) => + | .ok (transport, some peer) => if transportHasPeer transport then throw s!"dial address has an earlier /p2p peer: {text}" - else pure () + else pure (addr, peer) | .ok (_, none) => throw s!"dial address has no final /p2p peer: {text}" | .error err => throw s!"{err} {text}" -/-- Check `args`. `serve` runs the host. `dial` is still unfinished. -/ +/-- The command budget around `Host.newStream` and the ping exchange. +The home of the duration is `Streams.unauthDeadlineMs`. -/ +private def commandBudget : Nat := Streams.unauthDeadlineMs + +/-- Reset `stream`, and suppress a secondary reset error. -/ +private def resetStream (stream : Streams.Stream) : IO Unit := do + try Conc.Async.run stream.reset catch _ => pure () + +/-- Join `cleanup`. A fault of the join is kept when `primary` is empty. -/ +private def joinCleanup (cleanup : Conc.TaskSet) (primary : Option IO.Error) : + IO (Option IO.Error) := do + try + Conc.Async.run cleanup.joinAll + return primary + catch e => + return primary <|> some e + +/-- Print `line` and exit 1. -/ +private def failLine (line : String) : IO UInt32 := do + IO.eprintln line + return 1 + +/-- One ping on `stream`. The exchange deadline starts here. A timeout +or a protocol failure resets the stream. A validated echo closes it. -/ +private def onePing (stream : Streams.Stream) : IO UInt32 := do + let payload ← match ← (Crypto.randomBytes Libp2p.Protocols.Ping.payloadSize).run with + | .error e => + resetStream stream + IO.eprintln (reprStr e) + return 1 + | .ok payload => pure payload + let resets ← Conc.TaskSet.new + let raced ← try + Conc.Async.run <| + Conc.withinDeadline Conc.Clock.system commandBudget + (resets.spawn stream.reset) + (Libp2p.Protocols.Ping.ping stream Conc.Clock.system payload) + catch e => + resets.spawn stream.reset + let fault ← joinCleanup resets (some e) + IO.eprintln s!"{fault.getD e}" + return 1 + let later ← joinCleanup resets none + match raced with + | .error .deadlineExpired => + IO.eprintln (Error.stream (.timeout "the ping exchange deadline expired")).render + if let some err := later then IO.eprintln s!"{err}" + return 1 + | .error .closed => + resetStream stream + IO.eprintln (Error.stream (.closed "the ping exchange select closed")).render + if let some err := later then IO.eprintln s!"{err}" + return 1 + | .ok (.error e) => + resetStream stream + IO.eprintln (Error.stream (Libp2p.Protocols.Ping.mapPingError e)).render + if let some err := later then IO.eprintln s!"{err}" + return 1 + | .ok (.ok none) => + resetStream stream + IO.eprintln (Error.stream (.protocol "ping returned no round trip")).render + if let some err := later then IO.eprintln s!"{err}" + return 1 + | .ok (.ok (some rtt)) => + if let some err := later then + resetStream stream + IO.eprintln s!"{err}" + return 1 + try + Conc.Async.run stream.close + catch e => + IO.eprintln s!"{e}" + return 1 + IO.println s!"PING OK rtt={rtt}ms" + return 0 + +/-- Open the ping stream under `commandBudget`. Expiry starts +`Host.close` on `cleanup`, which wakes the parked open. A late stream +is reset through the same set. -/ +private def openPing (host : Host) (peer : PeerId) (cleanup : Conc.TaskSet) : + IO (Except Conc.Error (Except Error (Libp2p.Core.ProtocolId × Streams.Stream))) := do + try + Conc.Async.run <| + Conc.withinDeadline Conc.Clock.system commandBudget + (cleanup.spawn host.close) + (host.newStream peer [Libp2p.Protocols.Ping.protocolId]) + (fun + | .ok (_, stream) => cleanup.spawn stream.reset + | .error _ => pure ()) + catch e => + cleanup.spawn host.close + let fault ← joinCleanup cleanup (some e) + if let some err := fault then throw err + throw e + +/-- Dial `addr`, require `peer`, and print one ping. `guarding` closes +the host on every return. -/ +private def dial (addr : Multiformats.Multiaddr) (peer : PeerId) : IO UInt32 := do + let faults ← IO.mkRef (none : Option IO.Error) + match ← openHost faults with + | .error line => failLine line + | .ok host => + guarding host faults do + match ← Conc.Async.run (host.dial addr (some peer)) with + | .error e => failLine e.render + | .ok _ => + let cleanup ← Conc.TaskSet.new + let opened ← openPing host peer cleanup + match opened with + | .error .deadlineExpired => + let later ← joinCleanup cleanup none + IO.eprintln (Error.stream (.timeout "the stream opening deadline expired")).render + if let some err := later then IO.eprintln s!"{err}" + return 1 + | .error .closed => + let later ← joinCleanup cleanup none + IO.eprintln (Error.stream (.closed "the stream opening select closed")).render + if let some err := later then IO.eprintln s!"{err}" + return 1 + | .ok (.error e) => failLine e.render + | .ok (.ok (_, stream)) => onePing stream + +/-- Check `args`. `serve` runs the host. `dial` pings the expected peer. -/ def main (args : List String) : IO UInt32 := do match command.parse args with | .error err => usage err.kind.msg @@ -247,7 +368,7 @@ def main (args : List String) : IO UInt32 := do match operandValue parsed "multiaddr" with | some text => match validDial text with - | .ok () => unfinished + | .ok (addr, peer) => dial addr peer | .error line => usage line | none => usage "missing multiaddr" | _ => usage "missing subcommand" diff --git a/packages/cli/docs/ARCHITECTURE.md b/packages/cli/docs/ARCHITECTURE.md index 5bb673d..20f8ee8 100644 --- a/packages/cli/docs/ARCHITECTURE.md +++ b/packages/cli/docs/ARCHITECTURE.md @@ -28,8 +28,9 @@ row 18). `Demo.lean` and `PingNode.lean` are the executable roots. Each `main` checks its arguments with `Cmd.parse`. A usage error exits 2. -`demo` and `pingnode dial` then print `not implemented` to stderr -and exit 1. `pingnode serve` listens. +`demo` then prints `not implemented` to stderr and exits 1. +`pingnode serve` listens. `pingnode dial` dials the expected peer +and prints one ping. `Main.lean` stays the gossipsub participant. The Lake lib is `CliHarness`. `CliHarness.lean` declares no API, and the lib exists so the suite @@ -49,8 +50,7 @@ are all named `Cli`. An isolated consumer imported `Cli`, built a `Cmd`, and called `validate` on this toolchain, and that build completed. This package's lib and empty root are `CliHarness`, so the module name `Cli` belongs to the parser. The namespace `Cli` -stays. That tag sets `experimental.module`. These executables do -not parse arguments yet. The harness's go-side helpers are scripts +stays. That tag sets `experimental.module`. Each executable parses its own arguments. The harness's go-side helpers are scripts under the root `scripts/`, test-only per CLAUDE.md's dependencies rule. @@ -148,9 +148,9 @@ Both files sit in `packages/cli`. The namespaces are `Cli.Demo` and `Cli.PingNode`, the shape `Cli.GossipsubSession` already uses. `gossipsub-interop` keeps the root `Main`. `interop` keeps the root `Interop.Main`. `lakefile.toml` names `demo` and `pingnode`. The -argument table below is in force. `demo` and `pingnode dial` then -print `not implemented` to stderr and exit 1. `pingnode serve` -listens. +argument table below is in force. `demo` then prints `not +implemented` to stderr and exits 1. `pingnode serve` listens. +`pingnode dial` dials the expected peer and prints one ping. ### Arguments @@ -179,11 +179,15 @@ The last component of a dial address is `/p2p/`. that remains in the transport prefix is a usage error. A missing final component, a peer component in another position, an address whose only component is `/p2p/`, and any other `splitPeer` -failure are usage errors. The dial then uses that peer id as the -expected identity. A port of 0 inside a parsed dial address reaches -the transport, and the transport reports the result as a runtime -failure. This slice accepts that address and then takes the -unfinished path. +failure are usage errors. The dial uses that address and that +peer id as the arguments of `Host.dial`. A port of 0 inside a +parsed dial address reaches the transport, and the transport +reports the result as a runtime failure. `Tests/Cli.lean` covers +that address with `capture` and a 10 second deadline. The child +exits inside the deadline, the exit code is 1, stdout is empty, and +stderr is a nonempty runtime diagnostic. The kernel text stays with +the transport. `Tests/CliLive.lean` covers a port the test closed +and matches `nobody listens at `. The commands are `Cmd.init` values with an empty flag list, so help and version flags are absent. `Cmd.parse` answers the parser error. @@ -367,8 +371,74 @@ its cleanup tasks. A result that arrives after the timer goes to The policy adds no library idle timeout. `Host` keeps its interface. An authenticated idle connection stays open under §5.4. -`pingnode serve` opens no outbound stream. The dial slice implements -the timer. This slice records the policy. +`pingnode serve` opens no outbound stream. `pingnode dial` +implements the timer. `demo` still waits for its slice. + +### Dial + +`validDial` returns the parsed multiaddr and the expected `PeerId`. +`dial` passes that pair to `Host.dial`. The `some` peer is the +expected identity `checkIdentity` compares during the upgrade. + +The command then opens one ping stream with `Host.newStream`. +`Conc.withinDeadline` applies `Streams.unauthDeadlineMs` around that +call. The clock is `Clock.system`. Expiry spawns `Host.close` on a +`TaskSet` the command owns. The close wakes the parked open. The +command joins the open and the cleanup set. `release` resets a +stream that arrives after the timer. The default `release` drops its +value, and a stream needs this reset. + +An `IO.Error` from the open starts the same close, and the command +joins it. The thrown error stays the first fault. An `Except` +failure from `newStream` returns that `Host.Error`. `guarding` then +closes the host. `Host.close` is idempotent, so the expiry close and +the later `guarding` close both run. + +The payload comes from `Crypto.randomBytes` at `Ping.payloadSize`. +`Ping.ping` runs under a second `withinDeadline` of the same +duration. That budget starts after `newStream` returns. Expiry +resets the stream. A protocol failure resets the stream and maps +the error through `Ping.mapPingError` into `Host.Error.stream`. A +thrown `IO.Error` stays a fault. Success calls `Stream.close`, then +prints `PING OK rtt=ms`. The process prints that line only after +the echo check. + +A usage error exits 2. Success exits 0. A runtime failure or a +cleanup failure exits 1. Every host `openHost` returns reaches +`Host.close`. + +`Tests/CliLive.lean` covers a successful dial, a port the test +bound and then closed, an expected-peer mismatch, a peer that stays +silent during protocol selection, and a peer that reads the payload +and writes no echo. The two silent peers run until the command +budget. The capture bound is 50 seconds, and the command exits +inside it. Stdout is empty on each failure before a successful ping. +The refused dial prints `nobody listens at `. The mismatch +prints `upgrade failed: authentication failure: remote peer id +differs from the expected peer id`. The silent selection prints +`stream failed: timeout: the stream opening deadline expired`. The +missing echo prints `stream failed: timeout: the ping exchange +deadline expired`. + +The dial slice recorded `lake build demo pingnode tests`, then +`LEANP2P_SUITE=cli .lake/build/bin/tests` and `LEANP2P_SUITE=cli +LEAN_NUM_THREADS=1 .lake/build/bin/tests +RTS -N1 -RTS`. Each of +those suite runs passed 137 checks, with 0 failed, 0 todo, and 0 +skipped. The port-zero correction builds with `lake build tests`. +`LEANP2P_SUITE=cli LEAN_NUM_THREADS=1 .lake/build/bin/tests +RTS -N1 +-RTS` passed 138 checks, with 0 failed, 0 todo, and 0 skipped. That +command runs the CLI suites alone. The added check is the bounded +port-zero exit. `scripts/lint-imports.sh` checked 189 module files, +with 0 violations. `git diff --check` reported no whitespace errors. +The serve slice's focused command passed 121 checks. The full +`lake test` was not rerun. Its recorded baseline still has one +failure in `Tests/Streams.lean`: `the teardown aborted the stuck +write: the peer meets a reset`. + +`demo` still prints `not implemented` and exits 1. +`Libp2p.Protocols.TestSupport.Echo` is still absent. Lean-to-Go and +the bidirectional gate stay unfinished. The abrupt Go-first close +stays the historical case in the Go-helper decision. ### Stream retirement diff --git a/packages/cli/docs/PLAN.md b/packages/cli/docs/PLAN.md index d14a8d1..8b6553b 100644 --- a/packages/cli/docs/PLAN.md +++ b/packages/cli/docs/PLAN.md @@ -32,8 +32,9 @@ The executable roots are `Demo` and `PingNode`, in `packages/cli`. `Main` stays the root of `gossipsub-interop`. The lib is `CliHarness`. Its globs include `Demo` and `PingNode`, and the lakefile names the `demo` and `pingnode` executables. A usage -error exits 2. `demo` and `pingnode dial` print `not implemented` -and exit 1. `pingnode serve` listens. +error exits 2. `demo` prints `not implemented` and exits 1. +`pingnode serve` listens. `pingnode dial` dials the expected peer +and prints one ping. The W5.8 behavior section of the package architecture is the contract those binaries implement, including the argument table, the exit codes, the ping and identify @@ -44,9 +45,10 @@ working decisions. The review context is https://github.com/etheorem/leanp2p/issues/22#issuecomment-6805941244. One-shot `pingnode dial` and `demo` apply a 30 second budget around `Host.newStream`. Expiry fails the command and closes the owned -host. The dial slice implements that timer. The demo echo handler +host. `pingnode dial` implements that timer. The demo echo handler and client belong at `Libp2p.Protocols.TestSupport.Echo`. This slice -does not add that module. `pingnode serve` is implemented. +does not add that module. `pingnode serve` and `pingnode dial` are +implemented. `demo` still prints `not implemented`. - The demo runs end to end over loopback: two hosts dial, complete the upgrade, identify, and exchange pings. The listener's identify @@ -82,13 +84,39 @@ does not add that module. `pingnode serve` is implemented. whose transport prefix still contains a `p2p` component. A line break in an operand stays inside that one stderr line. `Tests/Cli.lean` runs the built binaries for these cases. A valid - `demo` or `pingnode dial` list still exits 1 with the unfinished - diagnostic. A valid `pingnode serve` port is not a usage error. -- `pingnode dial` against `pingnode serve` prints `PING OK`, and both - processes close their hosts on the failure paths of the behavior - section. The run uses the exchange deadline after `newStream` - returns, and the 30 second budget around that call. The dial slice - implements the budget. + `demo` list exits 1 with the unfinished diagnostic. A dial to + port 0 uses `capture` with a 10 second deadline. The child exits + inside that deadline with code 1, empty stdout, and a nonempty + runtime diagnostic. A valid `pingnode serve` port reaches + `startListening`. +- `pingnode dial` against `pingnode serve` prints `PING OK rtt=ms` + and exits 0. The server then exits 0 on stdin EOF. The command + passes the parsed address and the expected peer to `Host.dial`. + `Host.newStream` runs under `Streams.unauthDeadlineMs`. Expiry + closes the owned host and joins the open and the cleanup task. + `release` resets a late stream. The exchange deadline starts after + `newStream` returns, and expiry resets that stream. A protocol + failure resets the stream and maps through `Ping.mapPingError`. + `Tests/CliLive.lean` also covers a closed port, an expected-peer + mismatch, a peer that stays silent during protocol selection, and + a peer that reads the payload and writes no echo. Each failure + before a successful ping has empty stdout. The capture bound on + the two silent peers is 50 seconds, and the command exits inside + it. The dial slice recorded `lake build demo pingnode tests`, + then `LEANP2P_SUITE=cli .lake/build/bin/tests` and `LEANP2P_SUITE=cli + LEAN_NUM_THREADS=1 .lake/build/bin/tests +RTS -N1 -RTS`. Each of + those suite runs passed 137 checks, with 0 failed, 0 todo, and 0 + skipped. The port-zero correction builds with `lake build tests`. + `LEANP2P_SUITE=cli LEAN_NUM_THREADS=1 .lake/build/bin/tests +RTS -N1 + -RTS` passed 138 checks, with 0 failed, 0 todo, and 0 skipped. + That command runs the CLI suites alone. The added check is the + bounded port-zero exit. `scripts/lint-imports.sh` checked 189 + module files, with 0 violations. `git diff --check` reported no + whitespace errors. The 121-check command above is the serve + slice. The full `lake test` was not rerun. Its recorded baseline + still has one failure in `Tests/Streams.lean`: `the teardown + aborted the stuck write: the peer meets a reset`. Lean-to-Go, the + demo, echo, and the bidirectional gate remain unfinished. - Go-to-Lean ping is in this slice. The Go peer dials `pingnode serve`. `scripts/ping-go/main.go` dials one multiaddr over TCP, Noise, and yamux, prints `PING OK rtt=`, and waits for From 57c2b28852554c9686bedfad9c830a6c692356ec Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Wed, 11 Nov 2026 17:31:52 -0500 Subject: [PATCH 10/15] test(cli): verify lean-to-go ping interoperability --- packages/cli/docs/ARCHITECTURE.md | 40 ++++++-- packages/cli/docs/PLAN.md | 37 ++++++- scripts/ping-go-interop.sh | 165 ++++++++++++++++++++++++++++-- scripts/ping-go/main.go | 77 ++++++++++++-- 4 files changed, 288 insertions(+), 31 deletions(-) diff --git a/packages/cli/docs/ARCHITECTURE.md b/packages/cli/docs/ARCHITECTURE.md index 20f8ee8..f235f1a 100644 --- a/packages/cli/docs/ARCHITECTURE.md +++ b/packages/cli/docs/ARCHITECTURE.md @@ -436,9 +436,10 @@ failure in `Tests/Streams.lean`: `the teardown aborted the stuck write: the peer meets a reset`. `demo` still prints `not implemented` and exits 1. -`Libp2p.Protocols.TestSupport.Echo` is still absent. Lean-to-Go and -the bidirectional gate stay unfinished. The abrupt Go-first close -stays the historical case in the Go-helper decision. +`Libp2p.Protocols.TestSupport.Echo` is still absent. The Lean-to-Go +ping check is recorded in the Go-helper decision. The bidirectional +gate stays unfinished. The abrupt Go-first close stays the historical +case in the Go-helper decision. ### Stream retirement @@ -555,8 +556,9 @@ identify. the harness, and never on a shipped or proof path. The tree holds `interop-matrix.sh` and `interop-slowreader/` under the root `scripts/`, beside the W0.3 vector tooling (§7 row 11's note). - The Go-to-Lean ping check is `scripts/ping-go/` and - `scripts/ping-go-interop.sh`. The Go peer dials `pingnode serve`. + The ping checks are `scripts/ping-go/` and + `scripts/ping-go-interop.sh`. `ping-go ` dials + `pingnode serve`. `ping-go listen` serves `pingnode dial`. The module requires go-libp2p v0.50.0. The host Go toolchain builds the helper with `CGO_ENABLED=0`. That module asks for Go 1.26. The helper runs on the host loopback. @@ -600,7 +602,33 @@ identify. miss is 33 seconds: the 25 second ping deadline, then 2 seconds for each of the four children. Those processes were gone after each check. The Go module pin and the Lean toolchain stay as they - are. Lean-to-Go and the bidirectional gate stay unfinished. The + are. `ping-go listen` binds `/ip4/127.0.0.1/tcp/0` with TCP, Noise, + and yamux, and enables the host ping service. It prints + `ADDR ` for the bound address, waits for stdin + EOF, and then closes the host. `scripts/ping-go-interop.sh lean-to-go` + runs `pingnode dial` with `LEAN_NUM_THREADS=1`, requires `PING OK` + and dialer exit 0, then closes the Go stdin and requires Go exit 0. + The owned children are the Go process, the Go stdin holder, and the + dialer. Three runs exited 0 and printed `PASS lean-to-go PING OK + rtt=1ms`, `PASS lean-to-go PING OK rtt=0ms`, and `PASS lean-to-go + PING OK rtt=1ms`. `scripts/ping-go-interop.sh` with no argument + printed `PASS go-to-lean PING OK rtt=973.708µs`. + `PING_LEAN_BIN=/usr/bin/false scripts/ping-go-interop.sh lean-to-go` + exited 1 with `FAIL lean-to-go: lean exit 1`. A test-only dialer + that ignores SIGTERM exited 1 after 29 seconds, including its + build, with `FAIL lean-to-go: lean ping missed 25 s`. Those + processes were gone after each check. The reviewer independently + confirmed both ping directions, the false dialer failure, and the + stalled dialer. That stalled run failed after 28.3 seconds, and + every tracked child was reaped. The confirmation is separate from + the 29 second run above. The cleanup bound on that + miss is 31 seconds: the 25 second ping deadline, then 2 seconds + for the dialer, the Go process, and the Go stdin holder. + `gofmt -l scripts/ping-go/main.go` printed nothing. `CGO_ENABLED=0 + go build` from `scripts/ping-go` completed. `bash -n + scripts/ping-go-interop.sh` completed. Lean sources are unchanged + in this slice, so the 138-check result above stands. The + bidirectional gate, the demo, and echo stay unfinished. The spike also kept a test-plans runner image, `interop/leanp2p/{Makefile,Dockerfile}`. The target drops the image, and `scripts/interop-matrix.sh` is the diff --git a/packages/cli/docs/PLAN.md b/packages/cli/docs/PLAN.md index 8b6553b..03882a8 100644 --- a/packages/cli/docs/PLAN.md +++ b/packages/cli/docs/PLAN.md @@ -115,8 +115,8 @@ implemented. `demo` still prints `not implemented`. whitespace errors. The 121-check command above is the serve slice. The full `lake test` was not rerun. Its recorded baseline still has one failure in `Tests/Streams.lean`: `the teardown - aborted the stuck write: the peer meets a reset`. Lean-to-Go, the - demo, echo, and the bidirectional gate remain unfinished. + aborted the stuck write: the peer meets a reset`. The demo, echo, + and the bidirectional gate remain unfinished. - Go-to-Lean ping is in this slice. The Go peer dials `pingnode serve`. `scripts/ping-go/main.go` dials one multiaddr over TCP, Noise, and yamux, prints `PING OK rtt=`, and waits for @@ -148,10 +148,39 @@ implemented. `demo` still prints `not implemented`. holders, and the server were gone. The cleanup bound on that miss is 33 seconds: the 25 second ping deadline, then 2 seconds for each of the four children. The check stays outside `lake test`. - Lean-to-Go, the demo, echo, and the bidirectional gate remain - unfinished. The spike selected its muxer from the Noise handshake + The spike selected its muxer from the Noise handshake extension. This stack negotiates the muxer on the wire (the negotiate pair's decision). +- Lean-to-Go ping uses the same helper and driver. `ping-go listen` + binds `/ip4/127.0.0.1/tcp/0`, enables the host ping service, and + prints `ADDR` for the bound address and the host peer id. The host + stays up until stdin EOF, then closes. `ping-go ` stays + the dial invocation. `scripts/ping-go-interop.sh lean-to-go` starts + that listener, runs `pingnode dial` with `LEAN_NUM_THREADS=1`, + requires `PING OK` and dialer exit 0, then closes the Go stdin and + requires Go exit 0. The owned children are the Go process, the Go + stdin holder, and the dialer. Three runs exited 0 and printed + `PASS lean-to-go PING OK rtt=1ms`, `PASS lean-to-go PING OK rtt=0ms`, + and `PASS lean-to-go PING OK rtt=1ms`. `scripts/ping-go-interop.sh` + with no argument printed `PASS go-to-lean PING OK rtt=973.708µs`. + `PING_LEAN_BIN=/usr/bin/false scripts/ping-go-interop.sh lean-to-go` + exited 1 with `FAIL lean-to-go: lean exit 1`. A test-only dialer + that ignores SIGTERM, passed as `PING_LEAN_BIN`, made the script + exit 1 after 29 seconds, including its build, with + `FAIL lean-to-go: lean ping missed 25 s`. The Go process, the stdin + holder, and the dialer were gone. The reviewer independently + confirmed both ping directions, the false dialer failure, and the + stalled dialer. That stalled run failed after 28.3 seconds, and + every tracked child was reaped. The confirmation is separate from + the 29 second run above. The cleanup bound on that miss + is 31 seconds: the 25 second ping deadline, then 2 seconds for + the dialer, the Go process, and the Go stdin holder. + `gofmt -l scripts/ping-go/main.go` printed nothing. `CGO_ENABLED=0 + go build` from `scripts/ping-go` completed. `bash -n + scripts/ping-go-interop.sh` completed. `git diff --check` reported + no whitespace errors. Lean sources are unchanged, so the 138-check + result above stands. The demo, echo, and the bidirectional gate + remain unfinished. This check stays outside `lake test`. - The in-suite legs pass under `+RTS -N1` (§6; PRINCIPLES 5.3). ## Test milestones (unit 2) diff --git a/scripts/ping-go-interop.sh b/scripts/ping-go-interop.sh index 356af4c..bc89b96 100755 --- a/scripts/ping-go-interop.sh +++ b/scripts/ping-go-interop.sh @@ -1,14 +1,25 @@ #!/usr/bin/env bash -# Go dials `pingnode serve` over TCP, Noise, and yamux. +# Ping interoperability over TCP, Noise, and yamux. +# +# `ping-go-interop.sh` and `ping-go-interop.sh go-to-lean` run the +# Go dialer against `pingnode serve`. `ping-go-interop.sh lean-to-go` +# runs `pingnode dial` against the Go listener. # # Readiness is `ADDR `. The dial address joins # those fields with `/p2p/`. The address field has no peer suffix. # -# The server runs with LEAN_NUM_THREADS=1. Go prints one ping result -# and stays up. The driver then closes the server's stdin and waits -# for the server to exit 0. It then closes Go's stdin and waits for -# Go to exit 0. PASS follows both exits. A peer that exits early -# fails the script. Set PING_GO_BIN to replace the helper. +# Go-to-Lean: the server runs with LEAN_NUM_THREADS=1. Go prints one +# ping result and stays up. The driver then closes the server's stdin +# and waits for the server to exit 0. It then closes Go's stdin and +# waits for Go to exit 0. PASS follows both exits. +# +# Lean-to-Go: the Go listener prints ADDR and stays up. The dialer +# runs with LEAN_NUM_THREADS=1. The driver requires the ping line and +# dialer exit 0, then closes Go's stdin and waits for Go to exit 0. +# PASS follows that exit. Set PING_LEAN_BIN to replace the dialer. +# +# A peer that exits early fails the script. Set PING_GO_BIN to replace +# the helper. # # The helper builds with the host Go toolchain, CGO off, from # scripts/ping-go. go-libp2p v0.50.0 asks for Go 1.26. An older go @@ -17,12 +28,25 @@ # `finish_child` ends one owned child. It sends SIGTERM, waits at most # `term_grace` seconds, sends SIGKILL if the child is still alive, and # reaps it. A child that has already exited is reaped. The ping -# deadline, both stdin holders, the Go teardown deadline, and the -# EXIT cleanup use this helper. The server shutdown wait and the Go -# exit wait are polls. EOF is the wake. The helper runs for a child -# that misses its wait, and for each holder when its EOF is due. +# deadline, the stdin holders, the teardown deadlines, and the EXIT +# cleanup use this helper. The shutdown waits are polls. EOF is the +# wake. The helper runs for a child that misses its wait, and for +# each holder when its EOF is due. set -euo pipefail +mode=${1:-go-to-lean} +if [[ $# -gt 1 ]]; then + echo "usage: ping-go-interop.sh [go-to-lean|lean-to-go]" >&2 + exit 2 +fi +case "$mode" in + go-to-lean|lean-to-go) ;; + *) + echo "usage: ping-go-interop.sh [go-to-lean|lean-to-go]" >&2 + exit 2 + ;; +esac + root=$(cd "$(dirname "$0")/.." && pwd) work=$(mktemp -d "${TMPDIR:-/tmp}/ping-go-interop.XXXXXX") bin="$root/.tmp/ping-go/ping-go" @@ -30,6 +54,7 @@ server_pid= hold_pid= go_pid= go_hold_pid= +lean_pid= server_status= term_grace=2 @@ -56,6 +81,10 @@ finish_child() { reap() { local status=$? + if [[ -n ${lean_pid:-} ]]; then + finish_child "$lean_pid" + lean_pid= + fi if [[ -n ${go_pid:-} ]]; then finish_child "$go_pid" go_pid= @@ -78,13 +107,16 @@ reap() { trap reap EXIT fail() { - echo "FAIL go-to-lean: $*" >&2 + echo "FAIL ${mode}: $*" >&2 if [[ -s "$work/server.err" ]]; then cat "$work/server.err" >&2 fi if [[ -s "$work/go.err" ]]; then cat "$work/go.err" >&2 fi + if [[ -s "$work/lean.err" ]]; then + cat "$work/lean.err" >&2 + fi exit 1 } @@ -92,6 +124,7 @@ mkdir -p "$root/.tmp/ping-go" ( cd "$root" && lake build pingnode ) ( cd "$root/scripts/ping-go" && CGO_ENABLED=0 go build -o "$bin" . ) +go_to_lean() { mkfifo "$work/in" sleep 3600 >"$work/in" & hold_pid=$! @@ -224,3 +257,113 @@ if [[ "$go_status" -ne 0 ]]; then fail "go teardown exit $go_status" fi echo "PASS go-to-lean $(head -n 1 "$work/go.out")" +} + +lean_to_go() { + local line start now addr peer port dial go_bin lean_bin lean_status go_status + mkfifo "$work/go.in" + sleep 3600 >"$work/go.in" & + go_hold_pid=$! + go_bin=${PING_GO_BIN:-$bin} + "$go_bin" listen <"$work/go.in" >"$work/go.out" 2>"$work/go.err" & + go_pid=$! + + line= + start=$(date +%s) + while true; do + if [[ -s "$work/go.out" ]]; then + line=$(head -n 1 "$work/go.out" || true) + if [[ "$line" == ADDR\ * ]]; then + break + fi + fi + if ! kill -0 "$go_pid" 2>/dev/null; then + fail "go exited before ADDR" + fi + now=$(date +%s) + if (( now - start >= 15 )); then + fail "no ADDR line within 15 s" + fi + sleep 0.2 + done + + set -- $line + if [[ $# -ne 3 || $1 != ADDR ]]; then + fail "readiness line: $line" + fi + addr=$2 + peer=$3 + case "$addr" in + */p2p/*) fail "readiness address already has a peer suffix: $addr" ;; + esac + case "$addr" in + /ip4/127.0.0.1/tcp/*) ;; + *) fail "readiness address: $addr" ;; + esac + port=${addr##*/} + if [[ ! "$port" =~ ^[1-9][0-9]*$ ]] || (( port > 65535 )); then + fail "bound port: $port" + fi + if [[ -z "$peer" ]]; then + fail "readiness line has no peer id" + fi + dial="${addr}/p2p/${peer}" + + lean_bin=${PING_LEAN_BIN:-$root/.lake/build/bin/pingnode} + LEAN_NUM_THREADS=1 "$lean_bin" dial "$dial" \ + >"$work/lean.out" 2>"$work/lean.err" & + lean_pid=$! + start=$(date +%s) + while true; do + if ! kill -0 "$lean_pid" 2>/dev/null; then + set +e + wait "$lean_pid" + lean_status=$? + set -e + lean_pid= + break + fi + now=$(date +%s) + if (( now - start >= 25 )); then + finish_child "$lean_pid" + lean_pid= + fail "lean ping missed 25 s" + fi + sleep 0.2 + done + if [[ "$lean_status" -ne 0 ]]; then + fail "lean exit $lean_status" + fi + if ! grep -q '^PING OK rtt=.' "$work/lean.out"; then + fail "lean ping result missing" + fi + if ! kill -0 "$go_pid" 2>/dev/null; then + fail "go exited before its teardown" + fi + finish_child "$go_hold_pid" + go_hold_pid= + start=$(date +%s) + while kill -0 "$go_pid" 2>/dev/null; do + now=$(date +%s) + if (( now - start >= 15 )); then + finish_child "$go_pid" + go_pid= + fail "go missed the teardown deadline" + fi + sleep 0.2 + done + set +e + wait "$go_pid" + go_status=$? + set -e + go_pid= + if [[ "$go_status" -ne 0 ]]; then + fail "go teardown exit $go_status" + fi + echo "PASS lean-to-go $(head -n 1 "$work/lean.out")" +} + +case "$mode" in + go-to-lean) go_to_lean ;; + lean-to-go) lean_to_go ;; +esac diff --git a/scripts/ping-go/main.go b/scripts/ping-go/main.go index dbd4a40..aa0161b 100644 --- a/scripts/ping-go/main.go +++ b/scripts/ping-go/main.go @@ -1,14 +1,18 @@ -// Dial one peer, complete one ping, and close on stdin EOF. +// Dial one peer, or listen for one, and close on stdin EOF. // -// The argument is a multiaddr whose last component is the expected -// peer id. The host is TCP, Noise, and yamux. A failed setup, -// connection, deadline, or ping exits 1. A bad argument count exits 2. +// `ping-go ` dials. The argument's last component is the +// expected peer id. `ping-go listen` binds `/ip4/127.0.0.1/tcp/0` and +// prints `ADDR ` for the address the kernel bound. +// The host is TCP, Noise, and yamux. Listen uses the host's enabled +// ping service. A failed setup, connection, deadline, or ping exits 1. +// A bad argument count exits 2. // // ping.Ping runs until its context ends. After the first successful // result the helper cancels that context, reads the result channel // until it closes, prints the result, and waits for stdin EOF. The -// host stays open through that wait. The host closes on every return -// after it exists. +// listen path prints ADDR and waits for the same EOF. The host stays +// open through that wait. The host closes on every return after it +// exists. package main import ( @@ -16,9 +20,11 @@ import ( "fmt" "io" "os" + "strings" "time" "github.com/libp2p/go-libp2p" + "github.com/libp2p/go-libp2p/core/host" "github.com/libp2p/go-libp2p/core/peer" "github.com/libp2p/go-libp2p/p2p/muxer/yamux" "github.com/libp2p/go-libp2p/p2p/protocol/ping" @@ -29,14 +35,65 @@ import ( const pingBudget = 20 * time.Second func main() { - if len(os.Args) != 2 { + switch { + case len(os.Args) == 2 && os.Args[1] == "listen": + if err := listen(); err != nil { + fmt.Fprintf(os.Stderr, "ping-go: %s\n", err) + os.Exit(1) + } + case len(os.Args) == 2: + if err := run(os.Args[1]); err != nil { + fmt.Fprintf(os.Stderr, "ping-go: %s\n", err) + os.Exit(1) + } + default: fmt.Fprintln(os.Stderr, "usage: ping-go ") + fmt.Fprintln(os.Stderr, " ping-go listen") os.Exit(2) } - if err := run(os.Args[1]); err != nil { - fmt.Fprintf(os.Stderr, "ping-go: %s\n", err) - os.Exit(1) +} + +func listen() (err error) { + h, err := libp2p.New( + libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0"), + libp2p.Transport(tcp.NewTCPTransport), + libp2p.Security(noise.ID, noise.New), + libp2p.Muxer("/yamux/1.0.0", yamux.DefaultTransport), + libp2p.Ping(true), + ) + if err != nil { + return fmt.Errorf("host: %s", err) + } + defer func() { + if cerr := h.Close(); cerr != nil && err == nil { + err = fmt.Errorf("close: %s", cerr) + } + }() + addr, err := loopbackAddr(h) + if err != nil { + return err + } + fmt.Printf("ADDR %s %s\n", addr, h.ID()) + if err := os.Stdout.Sync(); err != nil { + return fmt.Errorf("stdout: %s", err) + } + return waitStdin() +} + +func loopbackAddr(h host.Host) (string, error) { + const prefix = "/ip4/127.0.0.1/tcp/" + for _, addr := range h.Network().ListenAddresses() { + text := addr.String() + if !strings.HasPrefix(text, prefix) || strings.Contains(text, "/p2p/") { + continue + } + port := strings.TrimPrefix(text, prefix) + if port == "" || port == "0" || strings.Contains(port, "/") { + continue + } + return text, nil } + return "", fmt.Errorf("no loopback listen address") } func run(target string) (err error) { From 19e5c397da364c93d75980187b0f3fa496ae10a8 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Wed, 11 Nov 2026 17:40:43 -0500 Subject: [PATCH 11/15] feat(protocols): add demo echo test support --- Tests/Main.lean | 4 + Tests/Protocols.lean | 178 ++++++++++++++++++ docs/ARCHITECTURE.md | 5 +- packages/cli/docs/ARCHITECTURE.md | 48 +++-- packages/cli/docs/PLAN.md | 21 ++- .../Libp2p/Protocols/TestSupport/Echo.lean | 66 +++++++ .../libp2p/protocols/docs/ARCHITECTURE.md | 24 ++- packages/libp2p/protocols/docs/PLAN.md | 13 +- 8 files changed, 327 insertions(+), 32 deletions(-) create mode 100644 packages/libp2p/protocols/Libp2p/Protocols/TestSupport/Echo.lean diff --git a/Tests/Main.lean b/Tests/Main.lean index 168d253..668090e 100644 --- a/Tests/Main.lean +++ b/Tests/Main.lean @@ -51,6 +51,7 @@ summary without failing the run. The concurrency-sensitive suites also pass under a single capability: `lake test -- +RTS -N1 -RTS` (ARCHITECTURE §6). `LEANP2P_SUITE=cli` runs `Tests.Cli` and `Tests.CliLive` and skips the other suites. +`LEANP2P_SUITE=protocols` runs `Tests.Protocols`. -/ def main : IO UInt32 := do @@ -59,6 +60,9 @@ def main : IO UInt32 := do Tests.Cli.run r Tests.CliLive.run r return ← r.summary + if (← IO.getEnv "LEANP2P_SUITE") == some "protocols" then + Tests.Protocols.run r + return ← r.summary Tests.Vectors.run r Tests.Gossipsub.Core.run r Tests.Gossipsub.Sign.run r diff --git a/Tests/Protocols.lean b/Tests/Protocols.lean index 051a042..24237fa 100644 --- a/Tests/Protocols.lean +++ b/Tests/Protocols.lean @@ -12,6 +12,7 @@ import Libp2p.Core import Libp2p.Host import Libp2p.Protocols import Libp2p.Protocols.Identity +import Libp2p.Protocols.TestSupport.Echo /-! # Identify and ping suite @@ -127,6 +128,40 @@ private def mkEnd (readP writeP : Conc.Pipe) : Streams.Stream := { close := pure () reset := readP.close } +/-- One echo end. `close` closes the write pipe, so a half-close is +visible as EOF on the other end. -/ +private def mkEchoEnd (readP writeP : Conc.Pipe) : Streams.Stream := { + read := do + match ← readP.read with + | none => return .ok none + | some chunk => return .ok (some chunk) + write := fun bytes => + (Except.mapError (fun e => match e with + | Conc.Error.closed => Streams.Error.closed + | Conc.Error.deadlineExpired => Streams.Error.timeout)) <$> writeP.write bytes + close := writeP.close + reset := do + readP.close + writeP.close +} + +/-- A stream whose read and write are the supplied actions. `close` +sets `closed`. -/ +private def echoScript (readAct : Conc.Async (Except Streams.Error (Option ByteArray))) + (writeAct : ByteArray → Conc.Async (Except Streams.Error Unit)) + (closed : IO.Ref Bool) : Streams.Stream := { + read := readAct + write := writeAct + close := closed.set true + reset := pure () +} + +/-- True when the answer is the core error `want`. -/ +private def echoIs {α : Type} (x : Except Libp2p.Core.Error α) (want : Libp2p.Core.Error) : Bool := + match x with + | .error e => decide (e = want) + | _ => false + /-- The pair: `a` reads `ba` and writes `ab`; `b` reads `ab` and writes `ba`. -/ private def mkPair : BaseIO Pair := do @@ -521,6 +556,146 @@ private def asyncChecks (r : Runner) : Conc.Async Unit := do (failsWith (Ping.Error.wrongSize 31) res) tc.close +/-- The echo handler and client, on a half-closing pipe and on scripted +streams. -/ +private def echoChecks (r : Runner) : Conc.Async Unit := do + -- echo: one chunk comes back, and the handler half-closes + do + let ab ← Conc.Pipe.new 4 + let ba ← Conc.Pipe.new 4 + let a := mkEchoEnd ba ab + let b := mkEchoEnd ab ba + let ctx ← ctxOf (testPeer 0) + let ts ← Conc.TaskSet.new + ts.spawn do + runHandler Libp2p.Protocols.TestSupport.Echo.handler ctx b + let payload := Bytes.ofString "echo-me" + let res ← Libp2p.Protocols.TestSupport.Echo.exchange a payload + let after ← a.read + ts.joinAll + r.check "echo e2e returns the sent bytes" + (match res with | .ok (some got) => got == payload | _ => false) + r.check "echo e2e half-close ends the reply" (isOkNone after) + + -- echo: an initial EOF half-closes and returns + do + let ab ← Conc.Pipe.new 4 + let ba ← Conc.Pipe.new 4 + let a := mkEchoEnd ba ab + let b := mkEchoEnd ab ba + ab.close + let res ← Libp2p.Protocols.TestSupport.Echo.serve b + let after ← a.read + r.check "echo handler initial EOF completes" (match res with | .ok () => true | _ => false) + r.check "echo handler initial EOF half-closes" (isOkNone after) + + -- echo: a read error maps and does not half-close + do + let closed ← IO.mkRef false + let s := echoScript (pure (.error .reset)) (fun _ => pure (.ok ())) closed + let res ← Libp2p.Protocols.TestSupport.Echo.serve s + r.check "echo handler read reset maps" (echoIs res (.protocol "the peer reset the stream")) + r.check "echo handler read reset skips the half-close" !(← closed.get) + + -- echo: a write error maps and does not half-close + do + let closed ← IO.mkRef false + let chunk := Bytes.ofString "echo-me" + let s := echoScript (pure (.ok (some chunk))) (fun _ => pure (.error .closed)) closed + let res ← Libp2p.Protocols.TestSupport.Echo.serve s + r.check "echo handler write closed maps" (echoIs res (.closed "the stream closed")) + r.check "echo handler write closed skips the half-close" !(← closed.get) + + -- echo: a thrown read stays an IO error + do + let closed ← IO.mkRef false + let s := echoScript (throw <| IO.userError "echo read fault") (fun _ => pure (.ok ())) closed + let caught ← try + discard <| Libp2p.Protocols.TestSupport.Echo.serve s + pure none + catch e => + pure (some (toString e)) + r.check "echo handler read fault stays an IO error" (caught == some "echo read fault") + r.check "echo handler read fault skips the half-close" !(← closed.get) + + -- echo: a thrown write stays an IO error + do + let closed ← IO.mkRef false + let chunk := Bytes.ofString "echo-me" + let s := echoScript (pure (.ok (some chunk))) (fun _ => throw <| IO.userError "echo write fault") closed + let caught ← try + discard <| Libp2p.Protocols.TestSupport.Echo.serve s + pure none + catch e => + pure (some (toString e)) + r.check "echo handler write fault stays an IO error" (caught == some "echo write fault") + r.check "echo handler write fault skips the half-close" !(← closed.get) + + -- echo: a thrown half-close stays an IO error + do + let closed ← IO.mkRef false + let chunk := Bytes.ofString "echo-me" + let s : Streams.Stream := { + read := pure (.ok (some chunk)) + write := fun got => pure (if got == chunk then .ok () else .error .closed) + close := do + closed.set true + throw <| IO.userError "echo close fault" + reset := pure () + } + let caught ← try + discard <| Libp2p.Protocols.TestSupport.Echo.serve s + pure none + catch e => + pure (some (toString e)) + r.check "echo handler close fault stays an IO error" (caught == some "echo close fault") + r.check "echo handler close fault reached the half-close" (← closed.get) + + -- echo: the client returns EOF, a mapped write error, and a thrown read + do + let payload := Bytes.ofString "echo-me" + let eof : Streams.Stream := { + read := pure (.ok none) + write := fun _ => pure (.ok ()) + close := pure () + reset := pure () + } + let res ← Libp2p.Protocols.TestSupport.Echo.exchange eof payload + r.check "echo client EOF returns absent" (match res with | .ok none => true | _ => false) + let refused : Streams.Stream := { + read := pure (.ok none) + write := fun _ => pure (.error .timeout) + close := pure () + reset := pure () + } + let bad ← Libp2p.Protocols.TestSupport.Echo.exchange refused payload + r.check "echo client write timeout maps" + (echoIs bad (.timeout "the stream deadline expired")) + let thrown : Streams.Stream := { + read := throw <| IO.userError "echo client read fault" + write := fun _ => pure (.ok ()) + close := pure () + reset := pure () + } + let caught ← try + discard <| Libp2p.Protocols.TestSupport.Echo.exchange thrown payload + pure none + catch e => + pure (some (toString e)) + r.check "echo client read fault stays an IO error" (caught == some "echo client read fault") + let writeFault : Streams.Stream := { + read := pure (.ok none) + write := fun _ => throw <| IO.userError "echo client write fault" + close := pure () + reset := pure () + } + let writeCaught ← try + discard <| Libp2p.Protocols.TestSupport.Echo.exchange writeFault payload + pure none + catch e => + pure (some (toString e)) + r.check "echo client write fault stays an IO error" (writeCaught == some "echo client write fault") + /-! ## Identify and push between two hosts -/ /-- `h` with the handler `sh` registered under its id, wrapped so that @@ -720,6 +895,8 @@ def run (r : Runner) : IO Unit := do (succeedsWith (good, pid) (Identify.validate (msgPayload good) none)) -- ping: the spec constants and the echo check + r.check "echo protocol id constant" (Libp2p.Protocols.TestSupport.Echo.protocolId.toString == "/demo/echo/1.0.0") + r.check "echo handler carries the protocol id" (Libp2p.Protocols.TestSupport.Echo.handler.id == Libp2p.Protocols.TestSupport.Echo.protocolId) r.check "ping protocol id constant" (Ping.protocolId.toString == "/ipfs/ping/1.0.0") r.check "ping payload size 32" (Ping.payloadSize == 32) let sent := List.replicate 32 7 @@ -733,6 +910,7 @@ def run (r : Runner) : IO Unit := do (failsWith .mismatch (Ping.checkEcho sent altered)) Conc.Async.run (asyncChecks r) + Conc.Async.run (echoChecks r) Conc.Async.run (hostChecks r) end Tests.Protocols diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8fb7f0a..8ac1549 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -872,9 +872,10 @@ Everything with libp2p semantics, in subpackages: `Host.Error`, which wraps `Core.Error` with arms of its own. - **`protocols/`**: identify (with push), ping, req/resp, and gossipsub (§8). A contributor working decision on the CLI branch - places the future demo echo handler and client at + places the demo echo handler and client at `Libp2p.Protocols.TestSupport.Echo`. That module sits beside the - four protocols, and this tree does not implement it yet. + four protocols. It reads one chunk, echoes those bytes, and + half-closes. The demo executable wires it. - **Policy seams**: connection gater, resource manager, address ranker. The interfaces exist now, and the implementations come later. diff --git a/packages/cli/docs/ARCHITECTURE.md b/packages/cli/docs/ARCHITECTURE.md index f235f1a..f6b2964 100644 --- a/packages/cli/docs/ARCHITECTURE.md +++ b/packages/cli/docs/ARCHITECTURE.md @@ -96,8 +96,9 @@ work. 1. **No protocol logic.** Ping and identify come from `libp2p/protocols`: the handler values, `Ping.ping`, and `Identify.client`. The demo echo home is - `Libp2p.Protocols.TestSupport.Echo`, recorded below. This slice - does not add that module. Every seam + `Libp2p.Protocols.TestSupport.Echo`, recorded below. That module + provides the handler and the client. The demo executable still + wires them. Every seam value comes from its implementation package: the TCP transport from `net/streams`, which alone binds `Std.Async.TCP` (§9, delta 7), the security value from @@ -259,8 +260,9 @@ Both binaries build one `Host.Config`. leaves the accept loop running. A fault of a host task surfaces when `Host.close` joins the task. -The demo and the ping node register ping and identify. Echo -registration waits for `Libp2p.Protocols.TestSupport.Echo`. +The demo and the ping node register ping and identify. The demo +registers `Libp2p.Protocols.TestSupport.Echo.handler` when the demo +slice wires it. `Identify.respondHandler` is registered after `startListening` returns. Its listen-address list is that bound address, as @@ -436,7 +438,8 @@ failure in `Tests/Streams.lean`: `the teardown aborted the stuck write: the peer meets a reset`. `demo` still prints `not implemented` and exits 1. -`Libp2p.Protocols.TestSupport.Echo` is still absent. The Lean-to-Go +`Libp2p.Protocols.TestSupport.Echo` provides the handler and the +client. The demo executable is unfinished. The Lean-to-Go ping check is recorded in the Go-helper decision. The bidirectional gate stays unfinished. The abrupt Go-first close stays the historical case in the Go-helper decision. @@ -509,8 +512,9 @@ The demo runs these steps in order. so far. 6. Store that address with `addAddrs`, then `dialPeer`. 7. Open a `newStream` for ping, then identify, then the echo id - from `Libp2p.Protocols.TestSupport.Echo`. The echo module waits - for its slice. Each `newStream` uses the 30 second budget above. + from `Libp2p.Protocols.TestSupport.Echo`. The handler and the + client are available. The demo executable wires them in its + slice. Each `newStream` uses the 30 second budget above. 8. Close the dialer, then the listener. Every exit closes each host that `Host.new` returned. The call is @@ -520,7 +524,7 @@ received `onFailure` also exits 1, after both closes. On success, stdout carries the dial line, the ping rtt, and the identify summary. The spike's `Main.lean` is the shape of those -lines. The echo line waits for `Libp2p.Protocols.TestSupport.Echo`. +lines. The echo line waits for the demo executable. ### Working decision: the demo echo home @@ -538,10 +542,27 @@ logic. November 2026 records "echo must come from protocol test support." §4.9 still names identify, ping, req/resp, and gossipsub. Test support sits beside that list. The spike handler reads one chunk, -writes those bytes, and half-closes. The client writes `echo-me` -and reads the reply. This slice does not add the module. The demo -echo leg waits for that slice. The ping node registers ping and -identify. +writes those bytes, and half-closes. The client writes one payload and reads one reply. +`Libp2p.Protocols.TestSupport.Echo` provides `serve`, `handler`, and +`exchange`. The protocol id lives in that module. An initial EOF +half-closes and returns. A `Streams.Error` maps through +`mapStreamError`. A thrown `IO.Error` stays thrown. The caller owns +the stream. `serve` half-closes on an orderly read. `exchange` leaves +the stream open. One chunk is the whole exchange. The demo chooses +the payload and prints the result. The demo executable is unfinished. +The ping node registers ping and identify. + +`lake build tests` completed. `LEANP2P_SUITE=protocols +.lake/build/bin/tests` passed 80 checks, with 0 failed, 0 todo, and +0 skipped. `LEANP2P_SUITE=protocols LEAN_NUM_THREADS=1 +.lake/build/bin/tests +RTS -N1 -RTS` passed 80 checks, with 0 failed, +0 todo, and 0 skipped. That command runs `Tests.Protocols`. +`scripts/lint-imports.sh` checked 190 module files, with 0 +violations. `git diff --check` reported no whitespace errors. The +138-check CLI result stays the dial slice's record. The full +`lake test` was not rerun. Its recorded baseline still has one +failure in `Tests/Streams.lean`: `the teardown aborted the stuck +write: the peer meets a reset`. ## Decisions @@ -628,7 +649,8 @@ identify. go build` from `scripts/ping-go` completed. `bash -n scripts/ping-go-interop.sh` completed. Lean sources are unchanged in this slice, so the 138-check result above stands. The - bidirectional gate, the demo, and echo stay unfinished. The + bidirectional gate and the demo executable stay unfinished. Echo + support is `Libp2p.Protocols.TestSupport.Echo`. The spike also kept a test-plans runner image, `interop/leanp2p/{Makefile,Dockerfile}`. The target drops the image, and `scripts/interop-matrix.sh` is the diff --git a/packages/cli/docs/PLAN.md b/packages/cli/docs/PLAN.md index 03882a8..9ecb8b9 100644 --- a/packages/cli/docs/PLAN.md +++ b/packages/cli/docs/PLAN.md @@ -46,15 +46,16 @@ https://github.com/etheorem/leanp2p/issues/22#issuecomment-6805941244. One-shot `pingnode dial` and `demo` apply a 30 second budget around `Host.newStream`. Expiry fails the command and closes the owned host. `pingnode dial` implements that timer. The demo echo handler -and client belong at `Libp2p.Protocols.TestSupport.Echo`. This slice -does not add that module. `pingnode serve` and `pingnode dial` are -implemented. `demo` still prints `not implemented`. +and client are `Libp2p.Protocols.TestSupport.Echo`. That module +reads one chunk, echoes those bytes, and half-closes. The demo +executable still prints `not implemented`. `pingnode serve` and +`pingnode dial` are implemented. - The demo runs end to end over loopback: two hosts dial, complete the upgrade, identify, and exchange pings. The listener's identify record carries the bound address and the registered protocol ids. - The echo leg joins this run when - `Libp2p.Protocols.TestSupport.Echo` exists. Each `newStream` uses + The echo leg uses `Libp2p.Protocols.TestSupport.Echo`. The demo + executable wires that leg. Each `newStream` uses the exchange deadline after the call returns, and the 30 second budget around the call. The spike's node-suite legs are the carried tests (the spike's @@ -115,8 +116,9 @@ implemented. `demo` still prints `not implemented`. whitespace errors. The 121-check command above is the serve slice. The full `lake test` was not rerun. Its recorded baseline still has one failure in `Tests/Streams.lean`: `the teardown - aborted the stuck write: the peer meets a reset`. The demo, echo, - and the bidirectional gate remain unfinished. + aborted the stuck write: the peer meets a reset`. The demo + executable and the bidirectional gate remain unfinished. Echo + support is `Libp2p.Protocols.TestSupport.Echo`. - Go-to-Lean ping is in this slice. The Go peer dials `pingnode serve`. `scripts/ping-go/main.go` dials one multiaddr over TCP, Noise, and yamux, prints `PING OK rtt=`, and waits for @@ -179,8 +181,9 @@ implemented. `demo` still prints `not implemented`. go build` from `scripts/ping-go` completed. `bash -n scripts/ping-go-interop.sh` completed. `git diff --check` reported no whitespace errors. Lean sources are unchanged, so the 138-check - result above stands. The demo, echo, and the bidirectional gate - remain unfinished. This check stays outside `lake test`. + result above stands. The demo executable and the bidirectional gate + remain unfinished. Echo support is `Libp2p.Protocols.TestSupport.Echo`. + This check stays outside `lake test`. - The in-suite legs pass under `+RTS -N1` (§6; PRINCIPLES 5.3). ## Test milestones (unit 2) diff --git a/packages/libp2p/protocols/Libp2p/Protocols/TestSupport/Echo.lean b/packages/libp2p/protocols/Libp2p/Protocols/TestSupport/Echo.lean new file mode 100644 index 0000000..62f33c2 --- /dev/null +++ b/packages/libp2p/protocols/Libp2p/Protocols/TestSupport/Echo.lean @@ -0,0 +1,66 @@ +import Streams.Stream +import Libp2p.Core +import Libp2p.Protocols.StreamError + +/-! +# Demo echo support + +`/demo/echo/1.0.0` is the demo's echo protocol. The spike handler in +`Tests/Node.lean` reads one chunk, writes those bytes, and half-closes. +An initial EOF is an orderly end: the handler half-closes and returns. + +This module holds that handler and the matching client. The client +writes one payload and reads one reply. The demo chooses the payload +and prints the result. This module does neither. + +One chunk is the whole exchange. A later chunk stays on the stream. +The caller owns the stream. `serve` half-closes the write direction +on an orderly read. `exchange` leaves the stream open. A `Streams.Error` +maps through `mapStreamError`. A thrown `IO.Error` stays thrown. +-/ + +set_option autoImplicit false + +namespace Libp2p.Protocols.TestSupport.Echo + +/-- The demo echo protocol id. -/ +def protocolId : Libp2p.Core.ProtocolId := + { toString := "/demo/echo/1.0.0" } + +/-- Read one chunk, write those bytes, and half-close. An initial EOF +half-closes and returns `.ok`. A stream error maps through +`mapStreamError`, and the call does not half-close. A thrown +`IO.Error` from the read, the write, or the half-close stays thrown. +The caller owns `s`. -/ +def serve (s : Streams.Stream) : Conc.Async (Except Libp2p.Core.Error Unit) := do + match ← s.read with + | .error e => return .error (mapStreamError e) + | .ok none => + s.close + return .ok () + | .ok (some bytes) => + match ← s.write bytes with + | .error e => return .error (mapStreamError e) + | .ok () => + s.close + return .ok () + +/-- The handler value. `id` is `protocolId`. `handle` ignores the +context and runs `serve`. -/ +def handler : Libp2p.Core.StreamHandler where + id := protocolId + handle _ s := ExceptT.mk (serve s) + +/-- Write `payload` and read one reply. `.ok none` is EOF before a +reply. A stream error maps through `mapStreamError`. A thrown +`IO.Error` stays thrown. The caller owns `s` and closes it. -/ +def exchange (s : Streams.Stream) (payload : ByteArray) : + Conc.Async (Except Libp2p.Core.Error (Option ByteArray)) := do + match ← s.write payload with + | .error e => return .error (mapStreamError e) + | .ok () => + match ← s.read with + | .error e => return .error (mapStreamError e) + | .ok reply => return .ok reply + +end Libp2p.Protocols.TestSupport.Echo diff --git a/packages/libp2p/protocols/docs/ARCHITECTURE.md b/packages/libp2p/protocols/docs/ARCHITECTURE.md index 54190ae..3d8cb32 100644 --- a/packages/libp2p/protocols/docs/ARCHITECTURE.md +++ b/packages/libp2p/protocols/docs/ARCHITECTURE.md @@ -19,6 +19,7 @@ group is §7, row 10). The spike has no req/resp or gossipsub code. | `Libp2p/Protocols/Constants.lean` | the protocol ids and the caps: `/ipfs/id/1.0.0`, `/ipfs/id/push/1.0.0`, `/ipfs/ping/1.0.0`, the `/eth2/beacon_chain/req///ssz_snappy` pattern of §8, gossipsub's `/meshsub/1.1.0`, one message cap per protocol, and the protocol version that the identify responder writes; for req/resp also `MAX_PAYLOAD_SIZE`, `max_compressed_len`, the result codes, and the constants of the snappy framing format; one home per fact (§3.8, §3.4) | | `Libp2p/Protocols/Identify.lean` | the `Identify` message with its `ProtoMessage` instance (§3.2), `Identify.Error`, the validate, the client that reads and validates one response, the push sender, and the handler values for identify and push | | `Libp2p/Protocols/Ping.lean` | `Ping.Error`, the echo handler, and the initiator that sends 32 bytes and awaits the echo | +| `Libp2p/Protocols/TestSupport/Echo.lean` | the demo echo id `/demo/echo/1.0.0`, the one-chunk handler, and the stream client | | `Libp2p/Protocols/Identity.lean` | the boundary to `libp2p/identity`: the parse of the `publicKey` field, the peer id derivation, and the key serialization, each over the `List Nat` byte model; the pure half and the tests call these | | `Libp2p/Protocols/ReqResp/Crc32c.lean` | CRC-32C (RFC 3720) and the checksum mask of the snappy framing format: the bitwise model and the table path that runs | | `Libp2p/Protocols/ReqResp/SnappyFrame.lean` | one chunk of the snappy framing format: the closed set of chunk types and the chunk codec, a `Codec` with a `LawfulCodec` instance (§3.2), plus the `ByteArray` header read the core runs | @@ -43,14 +44,23 @@ to `libp2p/security` beside the handshake it serves. The `Envelope` and module line keeps the responsibility split of PRINCIPLES 1.2; this package holds four protocols, and no module holds two. §4.9 names identify, ping, req/resp, and gossipsub, and the module list follows -that sentence. The package exports no echo handler. A contributor -working decision on the CLI branch places the future demo echo -handler and client at `Libp2p.Protocols.TestSupport.Echo`. The -protocol id is `/demo/echo/1.0.0`, in that one module, as a -`StreamHandler`. Test support sits beside the four protocols. The -review context is +that sentence. A contributor working decision on the CLI branch +places the demo echo handler and client at +`Libp2p.Protocols.TestSupport.Echo`. The protocol id is +`/demo/echo/1.0.0`, in that one module, as a `StreamHandler`. Test +support sits beside the four protocols. The review context is https://github.com/etheorem/leanp2p/issues/22#issuecomment-6805941244. -This package does not implement that module yet. +The root `Libp2p.Protocols` module does not re-export it. A consumer +imports `Libp2p.Protocols.TestSupport.Echo`. + +`serve` reads one chunk. It writes those bytes and half-closes. An +initial EOF half-closes and returns `.ok`. A `Streams.Error` maps +through `mapStreamError`, and that path does not half-close. A thrown +`IO.Error` stays thrown. The caller owns the stream. `exchange` +writes one payload and reads one reply. `.ok none` is EOF before a +reply. `exchange` does not close the stream. One chunk is the whole +exchange. A later chunk stays unread. The demo chooses the payload +and prints the result. The demo executable is unfinished. ## Public surface diff --git a/packages/libp2p/protocols/docs/PLAN.md b/packages/libp2p/protocols/docs/PLAN.md index a192769..41354b3 100644 --- a/packages/libp2p/protocols/docs/PLAN.md +++ b/packages/libp2p/protocols/docs/PLAN.md @@ -38,7 +38,18 @@ in the codec group wrapping upstream `google/snappy`. node (root PLAN W5.8; the spike's `Main.lean` and `PingNode.lean` show the wiring). A contributor working decision places the demo echo handler and client at `Libp2p.Protocols.TestSupport.Echo`. - That module is not in this plan's units yet. + That module reads one chunk, echoes those bytes, and half-closes. + The demo executable wires it. The module is outside units 1 to 3. + `lake build tests` completed. `LEANP2P_SUITE=protocols + .lake/build/bin/tests` passed 80 checks, with 0 failed, 0 todo, and + 0 skipped. `LEANP2P_SUITE=protocols LEAN_NUM_THREADS=1 + .lake/build/bin/tests +RTS -N1 -RTS` passed 80 checks, with 0 + failed, 0 todo, and 0 skipped. That command runs `Tests.Protocols`. + `scripts/lint-imports.sh` checked 190 module files, with 0 + violations. `git diff --check` reported no whitespace errors. + The full `lake test` was not rerun. Its recorded baseline still has + one failure in `Tests/Streams.lean`: `the teardown aborted the stuck + write: the peer meets a reset`. - The consensus client consumes req/resp and gossipsub through the handler values and the client side (§8). SSZ payload types stay with the client. From f994be408a7d3cbe9c835380fd701b0aa4a48b88 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Wed, 11 Nov 2026 18:19:18 -0500 Subject: [PATCH 12/15] feat(cli): complete loopback demo --- Tests/Cli.lean | 1 - Tests/CliLive.lean | 77 ++- docs/ARCHITECTURE.md | 3 +- packages/cli/Demo.lean | 441 +++++++++++++++++- packages/cli/docs/ARCHITECTURE.md | 137 ++++-- packages/cli/docs/PLAN.md | 63 ++- .../libp2p/protocols/docs/ARCHITECTURE.md | 4 +- packages/libp2p/protocols/docs/PLAN.md | 3 +- 8 files changed, 656 insertions(+), 73 deletions(-) diff --git a/Tests/Cli.lean b/Tests/Cli.lean index 99b1d34..dbdf55d 100644 --- a/Tests/Cli.lean +++ b/Tests/Cli.lean @@ -167,7 +167,6 @@ private def expect (runner : Runner) (name exe : String) (args : Array String) /-- The argument table of the demo and the ping node. -/ def run (runner : Runner) : IO Unit := do runner.section "cli arguments" "packages/cli/Demo.lean, packages/cli/PingNode.lean" - expect runner "demo empty" "demo" #[] 1 "demo: not implemented" expect runner "demo one argument" "demo" #["foo"] 2 "Redundant positional argument `foo`." expect runner "demo two arguments" "demo" #["foo", "bar"] 2 diff --git a/Tests/CliLive.lean b/Tests/CliLive.lean index 8d84b89..5f882c2 100644 --- a/Tests/CliLive.lean +++ b/Tests/CliLive.lean @@ -23,7 +23,9 @@ failure, a deadline that kills a sleeper, and a registered task that returns an error. The dial checks run the built `pingnode dial` against this server, a closed port, a different peer id, a peer that accepts the stream and stays silent, and a peer that reads the payload and -does not echo it. +does not echo it. The demo checks run the built `demo`. One check +requires the four success lines. The other closes stdout and requires +exit 1 inside 30 seconds. Each reader task belongs to the check that started it. The check wakes the task by closing the pipe it reads, and then joins it. @@ -39,6 +41,9 @@ open Libp2p.Identity (PeerId PrivKey) /-- The built server. -/ private def bin : String := ".lake/build/bin/pingnode" +/-- The built demo. -/ +private def demoBin : String := ".lake/build/bin/demo" + /-- One worker in the server process. -/ private def oneWorker : Array (String × Option String) := #[("LEAN_NUM_THREADS", some "1")] @@ -607,6 +612,73 @@ private def dialNoEcho (runner : Runner) : IO Unit := do fault ← closeHost host fault if let some e := fault then throw e +/-- The text between `head` and `tail`, when both ends match. -/ +private def between (text head tail : String) : Option String := + if text.startsWith head && text.endsWith tail then + some (((text.drop head.length).dropEnd tail.length).copy) + else none + +/-- True when `text` is `ping rtt: ms`. -/ +private def pingRtt (text : String) : Bool := + match between text "ping rtt: " " ms" with + | some digits => digits != "" && digits.all Char.isDigit + | none => false + +/-- The four stdout lines of a successful demo, in order. -/ +private def demoStdout (stdout : String) : Bool := + if !stdout.endsWith "\n" then false + else + match ((stdout.dropEnd 1).copy).splitOn "\n" with + | [dial, ping, identified, echo] => + match between dial "dialed " " (noise + yamux, remote verified)", + between identified "identified: " ", 1 addr(s), 3 protocol(s)" with + | some dialed, some named => + dialed != "" && dialed == named && pingRtt ping && echo == "echo: echo-me" + | _, _ => false + | _ => false + +/-- The demo completes ping, identify, and echo, and exits 0. -/ +private def demoOk (runner : Runner) : IO Unit := do + let out ← Tests.Cli.capture demoBin #[] 30000 oneWorker + runner.check "demo exits" (!out.timedOut && out.exitCode == 0) + runner.check "demo stdout" (demoStdout out.stdout) + runner.check "demo stderr empty" (out.stderr == "") + +/-- A closed stdout fails the first contract line, and the process still +exits. The stderr reader is joined after the child is reaped. -/ +private def demoClosedStdout (runner : Runner) : IO Unit := do + let child ← IO.Process.spawn { + cmd := "/bin/sh" + args := #["-c", "exec \"$1\" >&-", "sh", demoBin] + stdin := .null + stdout := .null + stderr := .piped + env := oneWorker + } + let mut errTask : Option (Task (Except IO.Error String)) := none + let mut code : Option UInt32 := none + let mut fault : Option IO.Error := none + try + errTask := some (← IO.asTask child.stderr.readToEnd Task.Priority.dedicated) + let (got, killed) ← Tests.Cli.waitChild child 30000 + code := if killed then none else some got + catch e => + fault := some e + Tests.Cli.reap child + let err ← match errTask with + | none => pure "" + | some task => + try + IO.ofExcept (← IO.wait task) + catch e => + if fault.isNone then fault := some e + pure "" + if let some e := fault then + runner.check s!"demo closed stdout: {e}" false + return + runner.check "demo closed stdout exits" (code == some 1) + runner.check "demo closed stdout stderr" (err != "") + /-- The server checks. -/ def run (runner : Runner) : IO Unit := do runner.section "pingnode serve" "packages/cli/PingNode.lean" @@ -623,5 +695,8 @@ def run (runner : Runner) : IO Unit := do dialMismatch runner dialStall runner dialNoEcho runner + runner.section "demo" "packages/cli/Demo.lean" + demoOk runner + demoClosedStdout runner end Tests.CliLive diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8ac1549..6e9aa7a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -875,7 +875,8 @@ Everything with libp2p semantics, in subpackages: places the demo echo handler and client at `Libp2p.Protocols.TestSupport.Echo`. That module sits beside the four protocols. It reads one chunk, echoes those bytes, and - half-closes. The demo executable wires it. + half-closes. The empty `demo` command registers the handler and + calls `exchange` with the bytes of `echo-me`. - **Policy seams**: connection gater, resource manager, address ranker. The interfaces exist now, and the implementations come later. diff --git a/packages/cli/Demo.lean b/packages/cli/Demo.lean index 308c0f8..46fa140 100644 --- a/packages/cli/Demo.lean +++ b/packages/cli/Demo.lean @@ -1,12 +1,30 @@ import Cli +import Conc +import Crypto +import Streams +import Bytes +import Multiformats +import Libp2p.Core +import Libp2p.Identity +import Libp2p.Security +import Libp2p.Mux +import Libp2p.Host +import Libp2p.Protocols.Ping +import Libp2p.Protocols.Identify +import Libp2p.Protocols.TestSupport.Echo /-! # Demo The `demo` executable. `Cmd.parse` checks the argument list. `Cmd.mk` would add help and version flags, so this command is built with -`Cmd.init` and an empty flag list. A usage error exits 2. An empty -argument list reaches the unfinished node and exits 1. +`Cmd.init` and an empty flag list. A usage error exits 2, before any +host exists. + +An empty argument list builds two hosts. The listener serves ping, +`/demo/echo/1.0.0`, and identify. The dialer stores the bound address, +dials with `dialPeer`, and runs ping, identify, and echo. The dialer +closes first. A fault from that close still closes the listener. -/ set_option autoImplicit false @@ -14,6 +32,8 @@ set_option autoImplicit false namespace Cli.Demo open Cli +open Libp2p.Host (Host Error Failure) +open Libp2p.Identity (PeerId PrivKey) /-- The command carries no flags and no operands. -/ private def command : Cmd := @@ -32,19 +52,426 @@ private def usage (line : String) : IO UInt32 := do IO.eprintln line return 2 -/-- The node is not built in this slice. -/ -private def unfinished : IO UInt32 := do - IO.eprintln "demo: not implemented" +/-- The agent string identify advertises. -/ +private def agent : String := "leanp2p" + +/-- The bytes the demo writes on the echo stream. -/ +private def echoPayload : ByteArray := Bytes.ofString "echo-me" + +/-- The protocol ids the listener serves, in registration order. -/ +private def registered : List String := [ + Libp2p.Protocols.Ping.protocolId.toString, + Libp2p.Protocols.TestSupport.Echo.protocolId.toString, + Libp2p.Protocols.Identify.protocolId.toString +] + +/-- The command budget around `Host.newStream`, ping, and echo. +Identify takes the same duration as its own client timeout. The home +of the duration is `Streams.unauthDeadlineMs`. -/ +private def commandBudget : Nat := Streams.unauthDeadlineMs + +/-- The stderr line for one inbound failure. -/ +private def failureLine (f : Failure) : String := Id.run do + let mut line := f.error.render + if let some peer := f.peer then + line := s!"{line} peer {peer}" + if let some protocol := f.protocol then + line := s!"{line} protocol {protocol}" + return line + +/-- The callback record for both hosts. `reported` is set when +`onFailure` runs, including a write that succeeds. `writeFault` is a +thrown diagnostic write. The command does not turn a `Host.Error` +into an `IO.Error`. -/ +private structure CallbackState where + reported : Bool + writeFault : Option IO.Error + +/-- No callback has run. -/ +private def emptyCallback : CallbackState := + { reported := false, writeFault := none } + +/-- The status after both closes. A nonzero code stays a failure. A +reported callback and a stored write fault each force 1. -/ +private def exitAfterCleanup (code : UInt32) (state : CallbackState) : UInt32 := + if code != 0 || state.reported || state.writeFault.isSome then 1 else 0 + +example : exitAfterCleanup 0 { reported := true, writeFault := none } = 1 := rfl + +example : exitAfterCleanup 0 + { reported := false, writeFault := some (IO.userError "diag") } = 1 := rfl + +example : exitAfterCleanup 0 + { reported := true, writeFault := some (IO.userError "diag") } = 1 := rfl + +example : exitAfterCleanup 1 emptyCallback = 1 := rfl + +example : exitAfterCleanup 0 emptyCallback = 0 := rfl + +/-- Keep the first diagnostic write that threw. `modify` holds the cell +for the whole update, so the first stored fault stays. The reported +flag is left as it is. -/ +private def keepFault (faults : IO.Ref CallbackState) (e : IO.Error) : BaseIO Unit := + faults.modify fun old => + if old.writeFault.isNone then { old with writeFault := some e } else old + +/-- Record the callback, then write its line. The flag is set before +the write, so a successful write still leaves `reported`. A thrown +write stays in `writeFault`. `Host.close` joins this task. -/ +private def reportFailure (faults : IO.Ref CallbackState) (f : Failure) : BaseIO Unit := do + faults.modify fun old => { old with reported := true } + EIO.catchExceptions (IO.eprintln (failureLine f)) (keepFault faults) + +/-- A fresh Ed25519 host: TCP, Noise, yamux, then mplex. `handlers` +are the protocols the host serves at `Host.new`. -/ +private def openHost (faults : IO.Ref CallbackState) + (handlers : List Libp2p.Core.StreamHandler) : IO (Except String Host) := do + let seed ← match ← Crypto.ed25519Generate.run with + | .error e => return .error (reprStr e) + | .ok seed => pure seed + let key ← match PrivKey.ofData? .ed25519 seed.bytes with + | .error e => return .error (reprStr e) + | .ok key => pure key + let noise ← match ← (Libp2p.Security.Noise.Config.generate key).run with + | .error e => return .error (reprStr e) + | .ok noise => pure noise + match ← (Host.new { + identity := key + transports := [Streams.Tcp.transport] + security := [Libp2p.Security.Noise.securityProtocol noise] + muxers := [Libp2p.Mux.yamux, Libp2p.Mux.mplex] + handlers + onFailure := reportFailure faults + } : BaseIO _) with + | .error e => return .error (reprStr e) + | .ok host => return .ok host + +/-- Print `line` and exit 1. -/ +private def failLine (line : String) : IO UInt32 := do + IO.eprintln line return 1 -/-- Check `args`, then report that the node is not implemented. -/ +/-- Print one host error and exit 1. -/ +private def failHost (e : Error) : IO UInt32 := + failLine e.render + +/-- Reset `stream`, and suppress a secondary reset error. -/ +private def resetStream (stream : Streams.Stream) : IO Unit := do + try Conc.Async.run stream.reset catch _ => pure () + +/-- Join `cleanup`. A fault of the join is kept when `primary` is empty. -/ +private def joinCleanup (cleanup : Conc.TaskSet) (primary : Option IO.Error) : + IO (Option IO.Error) := do + try + Conc.Async.run cleanup.joinAll + return primary + catch e => + return primary <|> some e + +/-- Print a cleanup fault that followed the line already written. -/ +private def showLater (later : Option IO.Error) : IO Unit := do + if let some err := later then + try IO.eprintln s!"{err}" catch _ => pure () + +/-- The address the listener asks the kernel to bind. -/ +private def loopback : IO Multiformats.Multiaddr := do + match Multiformats.Multiaddr.parse? "/ip4/127.0.0.1/tcp/0" with + | .ok addr => return addr + | .error e => throw <| IO.userError (reprStr e) + +/-- Open one stream under `commandBudget`. Expiry starts `Host.close` +on the dialer, which wakes the parked open. A late stream is reset +through the same set. The caller owns a stream this function returns. -/ +private def withStream (dialer : Host) (peer : PeerId) (id : Libp2p.Core.ProtocolId) + (exchange : Streams.Stream → IO UInt32) : IO UInt32 := do + let cleanup ← Conc.TaskSet.new + let opened ← try + Conc.Async.run <| + Conc.withinDeadline Conc.Clock.system commandBudget + (cleanup.spawn dialer.close) + (dialer.newStream peer [id]) + (fun + | .ok (_, stream) => cleanup.spawn stream.reset + | .error _ => pure ()) + catch e => + cleanup.spawn dialer.close + let fault ← joinCleanup cleanup (some e) + if let some err := fault then throw err + throw e + match opened with + | .error .deadlineExpired => + let later ← joinCleanup cleanup none + IO.eprintln (Error.stream (.timeout "the stream opening deadline expired")).render + showLater later + return 1 + | .error .closed => + let later ← joinCleanup cleanup none + IO.eprintln (Error.stream (.closed "the stream opening select closed")).render + showLater later + return 1 + | .ok (.error e) => + let later ← joinCleanup cleanup none + IO.eprintln e.render + showLater later + return 1 + | .ok (.ok (_, stream)) => + match ← joinCleanup cleanup none with + | some err => + resetStream stream + IO.eprintln s!"{err}" + return 1 + | none => + exchange stream + +/-- One ping. The exchange deadline starts after `newStream` returns. +A timeout or a protocol failure resets the stream. A validated echo +closes it. `.ok none` stays the end-of-stream channel. -/ +private def runPing (dialer : Host) (peer : PeerId) : IO UInt32 := + withStream dialer peer Libp2p.Protocols.Ping.protocolId fun stream => do + let payload ← match ← (Crypto.randomBytes Libp2p.Protocols.Ping.payloadSize).run with + | .error e => + resetStream stream + IO.eprintln (reprStr e) + return 1 + | .ok payload => pure payload + let resets ← Conc.TaskSet.new + let raced ← try + Conc.Async.run <| + Conc.withinDeadline Conc.Clock.system commandBudget + (resets.spawn stream.reset) + (Libp2p.Protocols.Ping.ping stream Conc.Clock.system payload) + catch e => + resets.spawn stream.reset + let fault ← joinCleanup resets (some e) + IO.eprintln s!"{fault.getD e}" + return 1 + let later ← joinCleanup resets none + match raced with + | .error .deadlineExpired => + IO.eprintln (Error.stream (.timeout "the ping exchange deadline expired")).render + showLater later + return 1 + | .error .closed => + resetStream stream + IO.eprintln (Error.stream (.closed "the ping exchange select closed")).render + showLater later + return 1 + | .ok (.error e) => + resetStream stream + IO.eprintln (Error.stream (Libp2p.Protocols.Ping.mapPingError e)).render + showLater later + return 1 + | .ok (.ok none) => + resetStream stream + IO.eprintln (Error.stream (.closed "ping returned no round trip")).render + showLater later + return 1 + | .ok (.ok (some rtt)) => + if let some err := later then + resetStream stream + IO.eprintln s!"{err}" + return 1 + try + Conc.Async.run stream.close + catch e => + IO.eprintln s!"{e}" + return 1 + IO.println s!"ping rtt: {rtt} ms" + return 0 + +/-- The facts the demo checks after `Identify.client`. The client +receives `some peer` and owns the peer comparison. This check requires +the bound address and every registered protocol id. -/ +private def requireIdentify (msg : Libp2p.Protocols.Identify) + (bound : Multiformats.Multiaddr) : Except Error Unit := do + if !msg.listenAddrs.contains bound.toBytes then + throw <| Error.stream (.protocol "identify: the bound address is absent") + if !registered.all fun id => msg.protocols.contains id then + throw <| Error.stream (.protocol "identify: a registered protocol is absent") + return () + +/-- One identify exchange. The client owns the read deadline and resets +the stream when that deadline expires. `.ok none` stays the +end-of-stream channel. -/ +private def runIdentify (dialer : Host) (peer : PeerId) (bound : Multiformats.Multiaddr) : + IO UInt32 := + withStream dialer peer Libp2p.Protocols.Identify.protocolId fun stream => do + let got ← Conc.Async.run + (Libp2p.Protocols.Identify.client stream Conc.Clock.system commandBudget (some peer)) + match got with + | .error e => + resetStream stream + return ← failHost (Error.stream (Libp2p.Protocols.Identify.mapIdentifyError e)) + | .ok none => + resetStream stream + return ← failHost (Error.stream (.closed "identify: the stream ended before a record")) + | .ok (some (msg, pid)) => + match requireIdentify msg bound with + | .error e => + resetStream stream + return ← failHost e + | .ok () => + try + Conc.Async.run stream.close + catch e => + IO.eprintln s!"{e}" + return 1 + IO.println s!"identified: {PeerId.toString pid}, {msg.listenAddrs.length} addr(s), {msg.protocols.length} protocol(s)" + return 0 + +/-- One echo exchange. The deadline starts after `newStream` returns. +The handler reads one chunk. A reply that differs, and an EOF before a +reply, both exit 1. The caller closes the stream after a match. -/ +private def runEcho (dialer : Host) (peer : PeerId) : IO UInt32 := + withStream dialer peer Libp2p.Protocols.TestSupport.Echo.protocolId fun stream => do + let resets ← Conc.TaskSet.new + let raced ← try + Conc.Async.run <| + Conc.withinDeadline Conc.Clock.system commandBudget + (resets.spawn stream.reset) + (Libp2p.Protocols.TestSupport.Echo.exchange stream echoPayload) + catch e => + resets.spawn stream.reset + let fault ← joinCleanup resets (some e) + IO.eprintln s!"{fault.getD e}" + return 1 + let later ← joinCleanup resets none + match raced with + | .error .deadlineExpired => + IO.eprintln (Error.stream (.timeout "the echo exchange deadline expired")).render + showLater later + return 1 + | .error .closed => + resetStream stream + IO.eprintln (Error.stream (.closed "the echo exchange select closed")).render + showLater later + return 1 + | .ok (.error e) => + resetStream stream + IO.eprintln (Error.stream e).render + showLater later + return 1 + | .ok (.ok none) => + resetStream stream + IO.eprintln (Error.stream (.closed "echo: the stream ended before a reply")).render + showLater later + return 1 + | .ok (.ok (some got)) => + if got != echoPayload then + resetStream stream + IO.eprintln (Error.stream (.protocol "echo: the reply differs")).render + showLater later + return 1 + if let some err := later then + resetStream stream + IO.eprintln s!"{err}" + return 1 + try + Conc.Async.run stream.close + catch e => + IO.eprintln s!"{e}" + return 1 + IO.println "echo: echo-me" + return 0 + +/-- Listen, dial, and run ping, identify, and echo. The caller closes +both hosts. -/ +private def runDemo (listener dialer : Host) : IO UInt32 := do + listener.setHandler Libp2p.Protocols.Ping.pingHandler + listener.setHandler Libp2p.Protocols.TestSupport.Echo.handler + let bound ← match ← Conc.Async.run (listener.startListening (← loopback)) with + | .error e => return ← failHost e + | .ok bound => pure bound + let key ← match listener.config.identity.toPublic with + | .error e => return ← failLine (reprStr e) + | .ok key => pure key + listener.setHandler (Libp2p.Protocols.Identify.respondHandler key [bound.toBytes] + registered agent) + dialer.addAddrs listener.localPeer [bound] + let peer ← match ← Conc.Async.run (dialer.dialPeer listener.localPeer) with + | .error e => return ← failHost e + | .ok peer => pure peer + IO.println s!"dialed {PeerId.toString peer} (noise + yamux, remote verified)" + let pingCode ← runPing dialer peer + if pingCode != 0 then return pingCode + let identifyCode ← runIdentify dialer peer bound + if identifyCode != 0 then return identifyCode + runEcho dialer peer + +/-- Close `dialer`, then `listener`. A fault from the first close still +runs the second. A close fault is printed only when `code` is still +success, so the first fault stays the line on stderr. -/ +private def closeBoth (dialer listener : Host) (code : UInt32) : IO UInt32 := do + let mut code := code + try + Conc.Async.run dialer.close + catch e => + if code == 0 then + try IO.eprintln s!"{e}" catch _ => pure () + code := 1 + try + Conc.Async.run listener.close + catch e => + if code == 0 then + try IO.eprintln s!"{e}" catch _ => pure () + code := 1 + return code + +/-- Run `body`, then close the dialer and the listener. `closeBoth` +joins both host tasks before this function reads the callback. A +reported callback exits 1. A stored write fault exits 1, and it is +printed only when the body and the closes left `code` at 0. A fault +already printed stays the stderr line. -/ +private def guarding (dialer listener : Host) (faults : IO.Ref CallbackState) + (body : IO UInt32) : IO UInt32 := do + let mut code : UInt32 := 0 + try + code ← body + catch e => + code := 1 + try IO.eprintln s!"{e}" catch _ => pure () + code ← closeBoth dialer listener code + let state ← faults.get + if let some e := state.writeFault then + if code == 0 then + try IO.eprintln s!"{e}" catch _ => pure () + return exitAfterCleanup code state + +/-- Close the listener after the dialer failed to appear. The listener +line stays the one already chosen. A close fault does not replace it. -/ +private def dropListener (listener : Host) (report : IO Unit) : IO UInt32 := do + try + Conc.Async.run listener.close + catch _ => pure () + try report catch _ => pure () + return 1 + +/-- Build both hosts. The listener is owned as soon as its `Host.new` +returns. A failure while the dialer is built closes that listener. -/ +private def demonstrate : IO UInt32 := do + let faults ← IO.mkRef emptyCallback + let listener ← try + match ← openHost faults [] with + | .error line => return ← failLine line + | .ok host => pure host + catch e => + try IO.eprintln s!"{e}" catch _ => pure () + return 1 + try + match ← openHost faults [] with + | .error line => return ← dropListener listener (IO.eprintln line) + | .ok dialer => return ← guarding dialer listener faults (runDemo listener dialer) + catch e => + dropListener listener (IO.eprintln s!"{e}") + +/-- Check `args`. An empty list runs the two-host demo. -/ def main (args : List String) : IO UInt32 := do match command.parse args with | .error err => usage err.kind.msg | .ok _ => -- `parse` treats `--` as the end of flags, so an empty command -- can still have seen an argument. - if args.isEmpty then unfinished else usage s!"unexpected argument: {" ".intercalate args}" + if args.isEmpty then demonstrate else usage s!"unexpected argument: {" ".intercalate args}" end Cli.Demo diff --git a/packages/cli/docs/ARCHITECTURE.md b/packages/cli/docs/ARCHITECTURE.md index f6b2964..533a838 100644 --- a/packages/cli/docs/ARCHITECTURE.md +++ b/packages/cli/docs/ARCHITECTURE.md @@ -28,7 +28,7 @@ row 18). `Demo.lean` and `PingNode.lean` are the executable roots. Each `main` checks its arguments with `Cmd.parse`. A usage error exits 2. -`demo` then prints `not implemented` to stderr and exits 1. +An empty `demo` list runs the loopback exchange and exits 0. `pingnode serve` listens. `pingnode dial` dials the expected peer and prints one ping. `Main.lean` @@ -97,7 +97,7 @@ work. `libp2p/protocols`: the handler values, `Ping.ping`, and `Identify.client`. The demo echo home is `Libp2p.Protocols.TestSupport.Echo`, recorded below. That module - provides the handler and the client. The demo executable still + provides the handler and the client. The demo executable wires them. Every seam value comes from its implementation package: the TCP transport from `net/streams`, which alone binds @@ -135,8 +135,8 @@ work. ## W5.8 behavior This section binds the demo and the ping node. Unit 1 implements it. -The echo home at the end is the working decision. This slice does -not add that module. The interop contract above stays in force. +The echo home at the end is the working decision. The interop +contract above stays in force. ### Executables @@ -149,8 +149,8 @@ Both files sit in `packages/cli`. The namespaces are `Cli.Demo` and `Cli.PingNode`, the shape `Cli.GossipsubSession` already uses. `gossipsub-interop` keeps the root `Main`. `interop` keeps the root `Interop.Main`. `lakefile.toml` names `demo` and `pingnode`. The -argument table below is in force. `demo` then prints `not -implemented` to stderr and exits 1. `pingnode serve` listens. +argument table below is in force. An empty `demo` list runs the +loopback exchange. `pingnode serve` listens. `pingnode dial` dials the expected peer and prints one ping. ### Arguments @@ -254,15 +254,17 @@ Both binaries build one `Host.Config`. - `onFailure` writes `Host.Error.render` to stderr in the host task that saw the failure, and it includes the peer and the protocol when the failure carries them. The write finishes before that task - ends. `Host.close` joins the task. A write that throws is kept, and - the process exits 1 after `close`. A policy refusal stays inside + ends. `Host.close` joins the task. A write that throws is kept + as an `IO.Error`, and the process exits 1 after `close`. The demo + also records that the callback ran. That flag is not an + `IO.Error`. The Demo lifetime section reads it after both closes. + `pingnode serve` does not keep that flag. A policy refusal stays inside the host (the host architecture, Decisions). One inbound failure leaves the accept loop running. A fault of a host task surfaces when `Host.close` joins the task. The demo and the ping node register ping and identify. The demo -registers `Libp2p.Protocols.TestSupport.Echo.handler` when the demo -slice wires it. +also registers `Libp2p.Protocols.TestSupport.Echo.handler`. `Identify.respondHandler` is registered after `startListening` returns. Its listen-address list is that bound address, as @@ -272,10 +274,9 @@ The observed address stays the `StreamContext` value inside `respondHandler`. The protocol version stays `Constants.identifyProtocolVersion`, which `respond` writes. -The demo's identify client requires three facts in the record. The -derived peer hash equals the dialed peer hash. The bound address is -present. Every registered protocol id is present. A missing fact -exits 1. +`Identify.client` receives `some peer` and validates that peer. The +demo then requires the bound address and every registered protocol +id. A missing fact exits 1. ### Deadlines @@ -374,7 +375,9 @@ its cleanup tasks. A result that arrives after the timer goes to The policy adds no library idle timeout. `Host` keeps its interface. An authenticated idle connection stays open under §5.4. `pingnode serve` opens no outbound stream. `pingnode dial` -implements the timer. `demo` still waits for its slice. +implements the timer. `demo` applies the same budget to each +`newStream`, and to the ping and echo exchanges. Identify passes +the same duration to `Identify.client`. ### Dial @@ -437,10 +440,8 @@ The serve slice's focused command passed 121 checks. The full failure in `Tests/Streams.lean`: `the teardown aborted the stuck write: the peer meets a reset`. -`demo` still prints `not implemented` and exits 1. -`Libp2p.Protocols.TestSupport.Echo` provides the handler and the -client. The demo executable is unfinished. The Lean-to-Go -ping check is recorded in the Go-helper decision. The bidirectional +`demo` runs the two-host loop and exits 0. The Lean-to-Go ping +check is recorded in the Go-helper decision. The bidirectional gate stays unfinished. The abrupt Go-first close stays the historical case in the Go-helper decision. @@ -506,25 +507,56 @@ The demo runs these steps in order. 1. Generate two Ed25519 identities. 2. Build two hosts. -3. Register ping on the listener. +3. Register ping and `Libp2p.Protocols.TestSupport.Echo.handler` + on the listener. 4. Listen on `/ip4/127.0.0.1/tcp/0`. -5. Register identify with the bound address and the ids registered - so far. +5. Register identify with the bound address and the three ids: + ping, `/demo/echo/1.0.0`, and identify. 6. Store that address with `addAddrs`, then `dialPeer`. -7. Open a `newStream` for ping, then identify, then the echo id - from `Libp2p.Protocols.TestSupport.Echo`. The handler and the - client are available. The demo executable wires them in its - slice. Each `newStream` uses the 30 second budget above. +7. Open a `newStream` for ping, then identify, then the echo id. + Each open uses the 30 second budget above. Expiry closes the + dialer. The command still closes the listener. 8. Close the dialer, then the listener. -Every exit closes each host that `Host.new` returned. The call is -`Host.close`. A fault from the first close still runs the second -close. The process exits 1 with the first fault. A demo that -received `onFailure` also exits 1, after both closes. - -On success, stdout carries the dial line, the ping rtt, and the -identify summary. The spike's `Main.lean` is the shape of those -lines. The echo line waits for the demo executable. +The listener is owned as soon as its `Host.new` returns. A failure +while the dialer is built closes that listener, and the dialer's +fault stays the stderr line. Every later exit closes the dialer, +then the listener. The call is `Host.close`. A fault from the first +close still runs the second close. The process exits 1 with the +first fault. + +`reportFailure` sets `reported` before it writes the line. The flag +stays set when the write succeeds. A thrown write is stored in +`writeFault` and does not clear the flag. The command does not build +an `IO.Error` from the `Host.Error`. `guarding` reads both fields +after `closeBoth`. That close joins the tasks that called the +callback, so the read sees the finished callback. `exitAfterCleanup` +returns 1 when `reported` is set, when `writeFault` is set, or when +the body or a close already failed. A stored write fault is printed +only when that earlier code is still 0. A line already printed stays +the stderr line. + +`reportFailure`, `closeBoth`, and `guarding` are private. The +executable has no injection flag, so the process checks cannot +deliver a `Failure` through `onFailure`. The examples on +`exitAfterCleanup` cover a reported callback after a successful body, +a stored write fault, both together, a failed body, and a clean +success. The trace above is the path from the callback through both +closes to that status. + +On success, stdout carries four lines. The spike's `Main.lean` is +the shape. The peer id text is `PeerId.toString`. + +``` +dialed (noise + yamux, remote verified) +ping rtt: ms +identified: , 1 addr(s), 3 protocol(s) +echo: echo-me +``` + +The identify line counts the one bound address and the three +registered protocol ids. The echo line follows a reply equal to the +bytes of `echo-me`. Stderr is empty. The process exits 0. ### Working decision: the demo echo home @@ -549,8 +581,9 @@ half-closes and returns. A `Streams.Error` maps through `mapStreamError`. A thrown `IO.Error` stays thrown. The caller owns the stream. `serve` half-closes on an orderly read. `exchange` leaves the stream open. One chunk is the whole exchange. The demo chooses -the payload and prints the result. The demo executable is unfinished. -The ping node registers ping and identify. +the payload and prints the result. The demo sends the bytes of +`echo-me`, requires the same bytes back, and prints `echo: echo-me`. +A later chunk stays unread. The ping node registers ping and identify. `lake build tests` completed. `LEANP2P_SUITE=protocols .lake/build/bin/tests` passed 80 checks, with 0 failed, 0 todo, and @@ -559,10 +592,27 @@ The ping node registers ping and identify. 0 todo, and 0 skipped. That command runs `Tests.Protocols`. `scripts/lint-imports.sh` checked 190 module files, with 0 violations. `git diff --check` reported no whitespace errors. The -138-check CLI result stays the dial slice's record. The full -`lake test` was not rerun. Its recorded baseline still has one -failure in `Tests/Streams.lean`: `the teardown aborted the stuck -write: the peer meets a reset`. +138-check CLI result stays the dial slice's record. + +`lake build demo pingnode tests` completed. `LEANP2P_SUITE=cli +.lake/build/bin/tests` passed 140 checks, with 0 failed, 0 todo, and +0 skipped. `LEANP2P_SUITE=cli LEAN_NUM_THREADS=1 .lake/build/bin/tests ++RTS -N1 -RTS` passed 140 checks, with 0 failed, 0 todo, and 0 +skipped. That command runs `Tests.Cli` and `Tests.CliLive`. The +success check requires the four lines above, empty stderr, and exit +0. A closed stdout exits 1 inside 30 seconds, with a nonempty stderr. +`scripts/lint-imports.sh` checked 190 module files, with 0 +violations. `git diff --check` reported no whitespace errors. The +reviewer independently reproduced that 140-check result. After the +callback correction, `lake build demo pingnode tests` completed. +`LEANP2P_SUITE=cli LEAN_NUM_THREADS=1 .lake/build/bin/tests +RTS -N1 +-RTS` passed 140 checks, with 0 failed, 0 todo, and 0 skipped. The +success check and the closed-stdout check stay in that run. The +callback examples are checked when `demo` builds. The 80-check +result stays the echo suite's record. The full `lake test` was not +rerun. Its recorded baseline still has one failure in +`Tests/Streams.lean`: `the teardown aborted the stuck write: the +peer meets a reset`. The W5.9 gate and issue #22 stay open. ## Decisions @@ -648,9 +698,10 @@ write: the peer meets a reset`. `gofmt -l scripts/ping-go/main.go` printed nothing. `CGO_ENABLED=0 go build` from `scripts/ping-go` completed. `bash -n scripts/ping-go-interop.sh` completed. Lean sources are unchanged - in this slice, so the 138-check result above stands. The - bidirectional gate and the demo executable stay unfinished. Echo - support is `Libp2p.Protocols.TestSupport.Echo`. The + in this slice, so the 138-check result above stands. That slice + left the demo executable unfinished. The bidirectional gate stays + unfinished. Echo support is `Libp2p.Protocols.TestSupport.Echo`. + The demo command is the Demo lifetime section. The spike also kept a test-plans runner image, `interop/leanp2p/{Makefile,Dockerfile}`. The target drops the image, and `scripts/interop-matrix.sh` is the diff --git a/packages/cli/docs/PLAN.md b/packages/cli/docs/PLAN.md index 9ecb8b9..57052d2 100644 --- a/packages/cli/docs/PLAN.md +++ b/packages/cli/docs/PLAN.md @@ -32,9 +32,9 @@ The executable roots are `Demo` and `PingNode`, in `packages/cli`. `Main` stays the root of `gossipsub-interop`. The lib is `CliHarness`. Its globs include `Demo` and `PingNode`, and the lakefile names the `demo` and `pingnode` executables. A usage -error exits 2. `demo` prints `not implemented` and exits 1. -`pingnode serve` listens. `pingnode dial` dials the expected peer -and prints one ping. +error exits 2. An empty `demo` list runs the loopback exchange and +exits 0. `pingnode serve` listens. `pingnode dial` dials the expected +peer and prints one ping. The W5.8 behavior section of the package architecture is the contract those binaries implement, including the argument table, the exit codes, the ping and identify @@ -47,20 +47,44 @@ One-shot `pingnode dial` and `demo` apply a 30 second budget around `Host.newStream`. Expiry fails the command and closes the owned host. `pingnode dial` implements that timer. The demo echo handler and client are `Libp2p.Protocols.TestSupport.Echo`. That module -reads one chunk, echoes those bytes, and half-closes. The demo -executable still prints `not implemented`. `pingnode serve` and -`pingnode dial` are implemented. +reads one chunk, echoes those bytes, and half-closes. An empty +`demo` list runs ping, identify, and that echo, then closes both +hosts. `pingnode serve` and `pingnode dial` are implemented. - The demo runs end to end over loopback: two hosts dial, complete the upgrade, identify, and exchange pings. The listener's identify record carries the bound address and the registered protocol ids. - The echo leg uses `Libp2p.Protocols.TestSupport.Echo`. The demo - executable wires that leg. Each `newStream` uses - the exchange deadline after the call returns, and the 30 second - budget around the call. + The echo leg uses `Libp2p.Protocols.TestSupport.Echo`. The payload + is the bytes of `echo-me`, and the reply must match. Stdout is the + dial line, `ping rtt: ms`, the identify summary + `1 addr(s), 3 protocol(s)`, and `echo: echo-me`. Stderr is empty. + Each `newStream` uses the exchange deadline after the call returns, + and the 30 second budget around the call. Identify passes that + duration to `Identify.client`, which validates the expected peer. + The demo then requires the bound address and the registered + protocol ids. A callback that runs, including one whose stderr + write succeeds, exits 1 after both closes. A thrown diagnostic + write is a separate `IO.Error` and also exits 1 after those closes. + `pingnode serve` does not keep the callback flag. The spike's node-suite legs are the carried tests (the spike's `Tests/Node.lean` drives two hosts over loopback TCP through ping, - identify, and echo). + identify, and echo). `lake build demo pingnode tests` completed. + `LEANP2P_SUITE=cli .lake/build/bin/tests` passed 140 checks, with + 0 failed, 0 todo, and 0 skipped. `LEANP2P_SUITE=cli + LEAN_NUM_THREADS=1 .lake/build/bin/tests +RTS -N1 -RTS` passed 140 + checks, with 0 failed, 0 todo, and 0 skipped. A closed stdout exits + 1 inside 30 seconds. `scripts/lint-imports.sh` checked 190 module + files, with 0 violations. `git diff --check` reported no whitespace + errors. The reviewer independently reproduced that 140-check + result. After the callback correction, `lake build demo pingnode + tests` completed. `LEANP2P_SUITE=cli LEAN_NUM_THREADS=1 + .lake/build/bin/tests +RTS -N1 -RTS` passed 140 checks, with 0 + failed, 0 todo, and 0 skipped. The 138-check result stays the dial + slice's record. The 80-check result stays the echo suite's record. + The full `lake test` was not rerun. Its recorded baseline still has + one failure in `Tests/Streams.lean`: `the teardown aborted the stuck + write: the peer meets a reset`. The W5.9 gate and issue #22 stay + open. - `pingnode serve` prints the bound address, flushes it, and stays up until stdin EOF while an inbound dial still completes. `Tests/CliLive.lean` runs that check with `LEAN_NUM_THREADS=1`. @@ -84,8 +108,9 @@ executable still prints `not implemented`. `pingnode serve` and dial address that `Host.splitPeer` rejects, and a dial address whose transport prefix still contains a `p2p` component. A line break in an operand stays inside that one stderr line. - `Tests/Cli.lean` runs the built binaries for these cases. A valid - `demo` list exits 1 with the unfinished diagnostic. A dial to + `Tests/Cli.lean` runs the built binaries for these cases. The + successful `demo` run lives in `Tests/CliLive.lean` and uses + `capture` with a 30 second deadline. A dial to port 0 uses `capture` with a 10 second deadline. The child exits inside that deadline with code 1, empty stdout, and a nonempty runtime diagnostic. A valid `pingnode serve` port reaches @@ -116,9 +141,9 @@ executable still prints `not implemented`. `pingnode serve` and whitespace errors. The 121-check command above is the serve slice. The full `lake test` was not rerun. Its recorded baseline still has one failure in `Tests/Streams.lean`: `the teardown - aborted the stuck write: the peer meets a reset`. The demo - executable and the bidirectional gate remain unfinished. Echo - support is `Libp2p.Protocols.TestSupport.Echo`. + aborted the stuck write: the peer meets a reset`. That slice left + the demo executable unfinished. The bidirectional gate remains + unfinished. Echo support is `Libp2p.Protocols.TestSupport.Echo`. - Go-to-Lean ping is in this slice. The Go peer dials `pingnode serve`. `scripts/ping-go/main.go` dials one multiaddr over TCP, Noise, and yamux, prints `PING OK rtt=`, and waits for @@ -181,8 +206,10 @@ executable still prints `not implemented`. `pingnode serve` and go build` from `scripts/ping-go` completed. `bash -n scripts/ping-go-interop.sh` completed. `git diff --check` reported no whitespace errors. Lean sources are unchanged, so the 138-check - result above stands. The demo executable and the bidirectional gate - remain unfinished. Echo support is `Libp2p.Protocols.TestSupport.Echo`. + result above stands. That slice left the demo executable unfinished. + The bidirectional gate remains unfinished. Echo support is + `Libp2p.Protocols.TestSupport.Echo`. The demo command is recorded + in the unit 1 demo bullet. This check stays outside `lake test`. - The in-suite legs pass under `+RTS -N1` (§6; PRINCIPLES 5.3). diff --git a/packages/libp2p/protocols/docs/ARCHITECTURE.md b/packages/libp2p/protocols/docs/ARCHITECTURE.md index 3d8cb32..a72c64b 100644 --- a/packages/libp2p/protocols/docs/ARCHITECTURE.md +++ b/packages/libp2p/protocols/docs/ARCHITECTURE.md @@ -60,7 +60,9 @@ through `mapStreamError`, and that path does not half-close. A thrown writes one payload and reads one reply. `.ok none` is EOF before a reply. `exchange` does not close the stream. One chunk is the whole exchange. A later chunk stays unread. The demo chooses the payload -and prints the result. The demo executable is unfinished. +and prints the result. The `demo` command registers `handler`, writes +the bytes of `echo-me` through `exchange`, and requires those bytes +back. ## Public surface diff --git a/packages/libp2p/protocols/docs/PLAN.md b/packages/libp2p/protocols/docs/PLAN.md index 41354b3..aa1d64c 100644 --- a/packages/libp2p/protocols/docs/PLAN.md +++ b/packages/libp2p/protocols/docs/PLAN.md @@ -39,7 +39,8 @@ in the codec group wrapping upstream `google/snappy`. show the wiring). A contributor working decision places the demo echo handler and client at `Libp2p.Protocols.TestSupport.Echo`. That module reads one chunk, echoes those bytes, and half-closes. - The demo executable wires it. The module is outside units 1 to 3. + The `demo` command registers the handler and calls `exchange` with + the bytes of `echo-me`. The module is outside units 1 to 3. `lake build tests` completed. `LEANP2P_SUITE=protocols .lake/build/bin/tests` passed 80 checks, with 0 failed, 0 todo, and 0 skipped. `LEANP2P_SUITE=protocols LEAN_NUM_THREADS=1 From 57c696a25c205805ab67971cbe0f8323cf8e4c40 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Wed, 11 Nov 2026 22:04:29 -0500 Subject: [PATCH 13/15] fix(cli): flush demo contract output --- packages/cli/Demo.lean | 17 ++++-- packages/cli/docs/ARCHITECTURE.md | 86 +++++++++++++++++++++++++++++-- packages/cli/docs/PLAN.md | 47 +++++++++++++++++ 3 files changed, 142 insertions(+), 8 deletions(-) diff --git a/packages/cli/Demo.lean b/packages/cli/Demo.lean index 46fa140..7006ceb 100644 --- a/packages/cli/Demo.lean +++ b/packages/cli/Demo.lean @@ -146,6 +146,15 @@ private def openHost (faults : IO.Ref CallbackState) | .error e => return .error (reprStr e) | .ok host => return .ok host +/-- Print one contract line and flush it. `IO.println` can keep the +bytes in the stdout buffer. A closed stdout then stays silent until +process exit. The flush makes that fault a thrown `IO.Error` inside +the command, which `guarding` records before it closes the hosts. -/ +private def writeLine (line : String) : IO Unit := do + let out ← IO.getStdout + out.putStr s!"{line}\n" + out.flush + /-- Print `line` and exit 1. -/ private def failLine (line : String) : IO UInt32 := do IO.eprintln line @@ -276,7 +285,7 @@ private def runPing (dialer : Host) (peer : PeerId) : IO UInt32 := catch e => IO.eprintln s!"{e}" return 1 - IO.println s!"ping rtt: {rtt} ms" + writeLine s!"ping rtt: {rtt} ms" return 0 /-- The facts the demo checks after `Identify.client`. The client @@ -316,7 +325,7 @@ private def runIdentify (dialer : Host) (peer : PeerId) (bound : Multiformats.Mu catch e => IO.eprintln s!"{e}" return 1 - IO.println s!"identified: {PeerId.toString pid}, {msg.listenAddrs.length} addr(s), {msg.protocols.length} protocol(s)" + writeLine s!"identified: {PeerId.toString pid}, {msg.listenAddrs.length} addr(s), {msg.protocols.length} protocol(s)" return 0 /-- One echo exchange. The deadline starts after `newStream` returns. @@ -371,7 +380,7 @@ private def runEcho (dialer : Host) (peer : PeerId) : IO UInt32 := catch e => IO.eprintln s!"{e}" return 1 - IO.println "echo: echo-me" + writeLine "echo: echo-me" return 0 /-- Listen, dial, and run ping, identify, and echo. The caller closes @@ -391,7 +400,7 @@ private def runDemo (listener dialer : Host) : IO UInt32 := do let peer ← match ← Conc.Async.run (dialer.dialPeer listener.localPeer) with | .error e => return ← failHost e | .ok peer => pure peer - IO.println s!"dialed {PeerId.toString peer} (noise + yamux, remote verified)" + writeLine s!"dialed {PeerId.toString peer} (noise + yamux, remote verified)" let pingCode ← runPing dialer peer if pingCode != 0 then return pingCode let identifyCode ← runIdentify dialer peer bound diff --git a/packages/cli/docs/ARCHITECTURE.md b/packages/cli/docs/ARCHITECTURE.md index 533a838..f159931 100644 --- a/packages/cli/docs/ARCHITECTURE.md +++ b/packages/cli/docs/ARCHITECTURE.md @@ -240,6 +240,64 @@ of §3.5. The demo prints one stderr line and exits 1. A thrown `IO.Error` stays a fault. The binary prints it, closes the hosts it created, and exits 1. +### Usage + +`lake build` writes `.lake/build/bin/demo` and +`.lake/build/bin/pingnode`. + +`demo` takes no arguments. + +``` +.lake/build/bin/demo +``` + +Success prints four lines and exits 0. The peer id and the round-trip +time belong to that run. + +``` +dialed (noise + yamux, remote verified) +ping rtt: ms +identified: , 1 addr(s), 3 protocol(s) +echo: echo-me +``` + +`pingnode serve 0` asks the kernel for a loopback port. The process +prints one line and leaves stdin open. + +``` +.lake/build/bin/pingnode serve 0 +``` + +``` +ADDR /ip4/127.0.0.1/tcp/ +``` + +A second terminal builds the dial address from that line. The address +is the `ADDR` address field, then `/p2p/`, then the peer field. + +``` +.lake/build/bin/pingnode dial /ip4/127.0.0.1/tcp//p2p/ +``` + +A successful dial prints one line and exits 0. + +``` +PING OK rtt=ms +``` + +The server keeps serving while its stdin stays open. EOF stops it. +In a terminal, Ctrl-D sends that EOF. The server then calls +`Host.close` and exits 0. + +The demo writes its four lines with `putStr` and `flush`. A closed +stdout throws inside `demo`. `guarding` then closes both hosts and +exits 1. `pingnode serve` flushes `ADDR` after listen, as the server +lifetime section records. + +Exit 0 is success. Exit 2 is a usage error, and the process has +created no host. Exit 1 is a runtime failure. Contract lines go to +stdout. Diagnostics go to stderr. + ### Composition Both binaries build one `Host.Config`. @@ -609,10 +667,30 @@ callback correction, `lake build demo pingnode tests` completed. -RTS` passed 140 checks, with 0 failed, 0 todo, and 0 skipped. The success check and the closed-stdout check stay in that run. The callback examples are checked when `demo` builds. The 80-check -result stays the echo suite's record. The full `lake test` was not -rerun. Its recorded baseline still has one failure in -`Tests/Streams.lean`: `the teardown aborted the stuck write: the -peer meets a reset`. The W5.9 gate and issue #22 stay open. +result stays the echo suite's record. That callback correction did +not rerun the full `lake test`. The gate record for `f994be4` +records the later full runs. The W5.9 gate and issue #22 stay open. + +### Gate record for `f994be4` + +This record is the local suite that included the demo flush, and the +GitHub checks on `f994be4` before that flush was published. The +140-check commands above run `Tests.Cli` and `Tests.CliLive` only. + +`lake build` exited 0. Its log has no warning line. +`scripts/lint-imports.sh` exited 0 and checked 190 module files, with +0 violations. `lake test` exited 1: 28095 passed, 1 failed, 0 todo, +and 7 skipped. `lake test -- +RTS -N1 -RTS` exited 1 with the same +summary. The failure is `Tests/Streams.lean` line 985, `the teardown +aborted the stuck write: the peer meets a reset`. The CLI sections +of those runs are not a full pass. GitHub `build-and-test` on +`f994be4` failed `demo closed stdout exits` and `demo closed stdout +stderr` during `lake test`, then skipped the one-worker suite, the +import lint, and vector provenance. Those two checks predate the +demo flush. +`scripts/ping-go-interop.sh` exited 0 with `PASS go-to-lean PING OK +rtt=710.459µs`. `scripts/ping-go-interop.sh lean-to-go` exited 0 with +`PASS lean-to-go PING OK rtt=0ms`. ## Decisions diff --git a/packages/cli/docs/PLAN.md b/packages/cli/docs/PLAN.md index 57052d2..8c560ed 100644 --- a/packages/cli/docs/PLAN.md +++ b/packages/cli/docs/PLAN.md @@ -213,6 +213,53 @@ hosts. `pingnode serve` and `pingnode dial` are implemented. This check stays outside `lake test`. - The in-suite legs pass under `+RTS -N1` (§6; PRINCIPLES 5.3). +## W5.8 acceptance + +Issue #22's definition of done is the measure. This section is the +snapshot of the local gates that included the demo flush, and of the +GitHub checks on commit `f994be4` before that flush was published. +Focused CLI counts above are not these gates. + +- The public surface of `cli/` is the nominal lib. It declares no + API. The owned contracts are the W5.8 behavior section. `demo` and + `pingnode` implement that section, including echo and the callback + flag. `reportFailure`, `closeBoth`, and `guarding` stay private. + The executable has no injection flag, so the process checks do not + deliver a `Failure` through `onFailure`. +- The loopback demo dials, runs ping, identify, and echo, then closes + the dialer and the listener with `Host.close`. `Tests/CliLive.lean` + checks the four stdout lines, empty stderr, and exit 0. +- The carried suite is `lake test`. It is not green. The command + exited 1. The summary was 28095 passed, 1 failed, 0 todo, and 7 + skipped. The failure is `Tests/Streams.lean` line 985, the check + `the teardown aborted the stuck write: the peer meets a reset`. + The peer's raw read ended without the reset error. That failure is + the recorded baseline. It is separate from the CLI checks. The same + command under `lake test -- +RTS -N1 -RTS` exited 1 with the same + summary and the same failure. These two results do not satisfy the + full-suite requirement. +- `lake build` exited 0. The captured log has no warning line. + `scripts/lint-imports.sh` exited 0 and checked 190 module files, + with 0 violations. A search of the Lean sources found no `sorry`. +- GitHub `build-and-test` on `f994be4` failed in `Run lake test`. + Its summary was 28099 passed, 2 failed, 0 todo, and 4 skipped. + The failures were `demo closed stdout exits` and `demo closed + stdout stderr`. The later CI steps, including `+RTS -N1`, the + import lint, and vector provenance, were skipped. The local + closed-stdout checks passed in the suite above. The demo's four + lines use `putStr` and `flush` in the flush commit. That Linux + result belongs to `f994be4` and stays the record of the unflushed + demo. +- `snappy-native-hardening` passed. `License acceptance guard` passed. + `AI co-author guard` passed. All three ran on `f994be4`. +- `scripts/ping-go-interop.sh` exited 0 and printed `PASS go-to-lean + PING OK rtt=710.459µs`. `scripts/ping-go-interop.sh lean-to-go` + exited 0 and printed `PASS lean-to-go PING OK rtt=0ms`. These + checks stay outside `lake test`. The W5.9 matrix, the silent-peer + gate, and the slow-reader gate stay open. Issue #22 stays open. + +The unit is not done while `lake test` fails. + ## Test milestones (unit 2) - The gate: the interop matrix against go-libp2p v0.50.0, over From 7731255aac3a3a4db7f1bd36be4eda84cd3d43f2 Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Wed, 11 Nov 2026 22:27:32 -0500 Subject: [PATCH 14/15] docs(cli): record verified Linux acceptance gates --- packages/cli/docs/ARCHITECTURE.md | 57 ++++++++++++++++++++++++- packages/cli/docs/PLAN.md | 70 ++++++++++++++++++++++++++----- 2 files changed, 116 insertions(+), 11 deletions(-) diff --git a/packages/cli/docs/ARCHITECTURE.md b/packages/cli/docs/ARCHITECTURE.md index f159931..77cf662 100644 --- a/packages/cli/docs/ARCHITECTURE.md +++ b/packages/cli/docs/ARCHITECTURE.md @@ -669,7 +669,9 @@ success check and the closed-stdout check stay in that run. The callback examples are checked when `demo` builds. The 80-check result stays the echo suite's record. That callback correction did not rerun the full `lake test`. The gate record for `f994be4` -records the later full runs. The W5.9 gate and issue #22 stay open. +records the later full runs. The Linux gates section records the +verified W5.8 run. Maintainer review of that run is pending. W5.9 +is unit 2. ### Gate record for `f994be4` @@ -692,6 +694,59 @@ demo flush. rtt=710.459µs`. `scripts/ping-go-interop.sh lean-to-go` exited 0 with `PASS lean-to-go PING OK rtt=0ms`. +### Linux gates on `57c696a` + +Commit `57c696a25c205805ab67971cbe0f8323cf8e4c40` flushes the demo's +four contract lines. GitHub Actions run `43145740577` records +`headSha` as that commit: +https://github.com/etheorem/leanp2p/actions/runs/43145740577. +The W5.8 implementation and these required gates are verified. +Maintainer review is pending. + +`Run lake build` passed. The log ends with `Build completed +successfully (458 jobs).` The build step has no warning line. + +`Run lake test` passed. The summary is 28101 passed, 0 failed, 0 +todo, and 4 skipped. The log contains no `FAIL` line. Run +`43118933597` on `f994be4` failed `demo closed stdout exits` and +`demo closed stdout stderr`: +https://github.com/etheorem/leanp2p/actions/runs/43118933597. +That summary was 28099 passed, 2 failed, 0 todo, and 4 skipped. This +run passes 28101 checks. Both names are absent from this failure log +and from this skip list. + +`Run lake test -- +RTS -N1 -RTS` passed. The summary is 28101 passed, +0 failed, 0 todo, and 4 skipped. The log contains no `FAIL` line. +Both closed-stdout names are absent from that failure log and from +that skip list. + +Both runs skip these four checks: `documented memory text +divergence`, `documented memory component divergence`, `mss generic +model pin`, and `upgrade noise yamux matrix over tcp`. + +`Run the import lint` passed. It printed `lint-imports: checked 190 +module files, 0 violations`. + +`Verify vector provenance` passed. `scripts/vendor-pull.sh --verify` +printed `OK` for each pin and then `done`. `python3 +scripts/extract-vectors.py` wrote the vector files. The step's +`git status --porcelain -- Tests/vectors` check then passed. + +`snappy-native-hardening` passed in run `43145740577`. `License +acceptance guard` passed in run `43145740639`: +https://github.com/etheorem/leanp2p/actions/runs/43145740639. +`AI co-author guard` passed in run `43145740610`: +https://github.com/etheorem/leanp2p/actions/runs/43145740610. +Each run records the same `headSha`. A later body edit reran the +license guard as `43146878809` on that same head: +https://github.com/etheorem/leanp2p/actions/runs/43146878809. + +The local gate record above stays in place. Local `lake test` still +exits 1 on `Tests/Streams.lean` line 985, `the teardown aborted the +stuck write: the peer meets a reset`. The callback-testing limitation +stays. Echo stays one chunk. The three working decisions stay +contributor-selected. W5.9 is unit 2, and it follows this unit. + ## Decisions - **A nominal lib, and no public API.** §2 gives `cli/` no public diff --git a/packages/cli/docs/PLAN.md b/packages/cli/docs/PLAN.md index 8c560ed..2320f33 100644 --- a/packages/cli/docs/PLAN.md +++ b/packages/cli/docs/PLAN.md @@ -81,10 +81,11 @@ hosts. `pingnode serve` and `pingnode dial` are implemented. .lake/build/bin/tests +RTS -N1 -RTS` passed 140 checks, with 0 failed, 0 todo, and 0 skipped. The 138-check result stays the dial slice's record. The 80-check result stays the echo suite's record. - The full `lake test` was not rerun. Its recorded baseline still has - one failure in `Tests/Streams.lean`: `the teardown aborted the stuck - write: the peer meets a reset`. The W5.9 gate and issue #22 stay - open. + The full `lake test` was not rerun for that callback correction. + Its recorded local baseline still has one failure in + `Tests/Streams.lean`: `the teardown aborted the stuck write: the + peer meets a reset`. The Linux gates section records the verified + W5.8 run. Maintainer review of that run is pending. W5.9 is unit 2. - `pingnode serve` prints the bound address, flushes it, and stays up until stdin EOF while an inbound dial still completes. `Tests/CliLive.lean` runs that check with `LEAN_NUM_THREADS=1`. @@ -229,15 +230,15 @@ Focused CLI counts above are not these gates. - The loopback demo dials, runs ping, identify, and echo, then closes the dialer and the listener with `Host.close`. `Tests/CliLive.lean` checks the four stdout lines, empty stderr, and exit 0. -- The carried suite is `lake test`. It is not green. The command +- The local carried suite is the macOS baseline. `lake test` exited 1. The summary was 28095 passed, 1 failed, 0 todo, and 7 skipped. The failure is `Tests/Streams.lean` line 985, the check `the teardown aborted the stuck write: the peer meets a reset`. The peer's raw read ended without the reset error. That failure is the recorded baseline. It is separate from the CLI checks. The same command under `lake test -- +RTS -N1 -RTS` exited 1 with the same - summary and the same failure. These two results do not satisfy the - full-suite requirement. + summary and the same failure. These two local commands remain that + baseline. The required gates are the Linux run below. - `lake build` exited 0. The captured log has no warning line. `scripts/lint-imports.sh` exited 0 and checked 190 module files, with 0 violations. A search of the Lean sources found no `sorry`. @@ -255,10 +256,59 @@ Focused CLI counts above are not these gates. - `scripts/ping-go-interop.sh` exited 0 and printed `PASS go-to-lean PING OK rtt=710.459µs`. `scripts/ping-go-interop.sh lean-to-go` exited 0 and printed `PASS lean-to-go PING OK rtt=0ms`. These - checks stay outside `lake test`. The W5.9 matrix, the silent-peer - gate, and the slow-reader gate stay open. Issue #22 stays open. + checks stay outside `lake test`. W5.9 is unit 2. This snapshot + predates the verified Linux gates below. -The unit is not done while `lake test` fails. +The local `lake test` record above still fails. The flush commit's +Linux gates follow. + +## Linux gates on `57c696a` + +Commit `57c696a25c205805ab67971cbe0f8323cf8e4c40` is `fix(cli): flush +demo contract output`. GitHub Actions run `43145740577` records +`headSha` as that commit: +https://github.com/etheorem/leanp2p/actions/runs/43145740577. +These results are the Linux run. They are separate from the local +snapshot above. The W5.8 implementation and these required gates are +verified. Maintainer review is pending. + +- `Run lake build` passed. The log ends with `Build completed + successfully (458 jobs).` The build step has no warning line. +- `Run lake test` passed. The summary is 28101 passed, 0 failed, 0 + todo, and 4 skipped. The log contains no `FAIL` line. Run + `43118933597` on `f994be4` failed `demo closed stdout exits` and + `demo closed stdout stderr`: + https://github.com/etheorem/leanp2p/actions/runs/43118933597. + That summary was 28099 passed, 2 failed, 0 todo, and 4 skipped. + This run passes 28101 checks. Both names are absent from this + failure log and from this skip list. +- `Run lake test -- +RTS -N1 -RTS` passed. The summary is 28101 + passed, 0 failed, 0 todo, and 4 skipped. The log contains no + `FAIL` line. Both closed-stdout names are absent from that failure + log and from that skip list. +- Both runs skip `documented memory text divergence`, `documented + memory component divergence`, `mss generic model pin`, and + `upgrade noise yamux matrix over tcp`. +- `Run the import lint` passed. It printed `lint-imports: checked + 190 module files, 0 violations`. +- `Verify vector provenance` passed. `scripts/vendor-pull.sh + --verify` printed `OK` for each pin and then `done`. `python3 + scripts/extract-vectors.py` wrote the vector files. The step's + `git status --porcelain -- Tests/vectors` check then passed. +- `snappy-native-hardening` passed in the same workflow run. + `License acceptance guard` passed in run `43145740639`: + https://github.com/etheorem/leanp2p/actions/runs/43145740639. + `AI co-author guard` passed in run `43145740610`: + https://github.com/etheorem/leanp2p/actions/runs/43145740610. + Each run records the same `headSha`. A later body edit reran the + license guard as `43146878809` on that same head: + https://github.com/etheorem/leanp2p/actions/runs/43146878809. + +The local `lake test` baseline remains `Tests/Streams.lean` line 985, +`the teardown aborted the stuck write: the peer meets a reset`. The +callback-testing limitation stays. Echo stays one chunk. The three +working decisions stay contributor-selected. W5.9 is unit 2, and it +follows this unit. ## Test milestones (unit 2) From d4c4de405e37ccd4deba4422d3fd1888cce2d09c Mon Sep 17 00:00:00 2001 From: Mouzayan Delbourgo Date: Wed, 11 Nov 2026 22:57:40 -0500 Subject: [PATCH 15/15] fix(cli): keep demo usage errors on one line --- Tests/Cli.lean | 4 ++++ packages/cli/Demo.lean | 9 +++++++-- packages/cli/docs/ARCHITECTURE.md | 20 ++++++++++++++++---- packages/cli/docs/PLAN.md | 9 ++++++++- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/Tests/Cli.lean b/Tests/Cli.lean index dbdf55d..9a52179 100644 --- a/Tests/Cli.lean +++ b/Tests/Cli.lean @@ -169,6 +169,10 @@ def run (runner : Runner) : IO Unit := do runner.section "cli arguments" "packages/cli/Demo.lean, packages/cli/PingNode.lean" expect runner "demo one argument" "demo" #["foo"] 2 "Redundant positional argument `foo`." + expect runner "demo newline argument" "demo" #["foo\n"] 2 + "Redundant positional argument `foo\\n`." + expect runner "demo carriage return argument" "demo" #["foo\r"] 2 + "Redundant positional argument `foo\\r`." expect runner "demo two arguments" "demo" #["foo", "bar"] 2 "Redundant positional argument `foo`." expect runner "demo help" "demo" #["--help"] 2 "Unknown flag `--help`." diff --git a/packages/cli/Demo.lean b/packages/cli/Demo.lean index 7006ceb..c56999e 100644 --- a/packages/cli/Demo.lean +++ b/packages/cli/Demo.lean @@ -19,7 +19,8 @@ import Libp2p.Protocols.TestSupport.Echo The `demo` executable. `Cmd.parse` checks the argument list. `Cmd.mk` would add help and version flags, so this command is built with `Cmd.init` and an empty flag list. A usage error exits 2, before any -host exists. +host exists. A line break in an argument is escaped in the usage +line. An empty argument list builds two hosts. The listener serves ping, `/demo/echo/1.0.0`, and identify. The dialer stores the bound address, @@ -47,9 +48,13 @@ private def command : Cmd := variableArg? := none } (fun _ => pure 0) #[] none +/-- Replace a newline with `\n` and a carriage return with `\r`. -/ +private def oneLine (text : String) : String := + (text.replace "\r" "\\r").replace "\n" "\\n" + /-- Print one usage line and exit 2. -/ private def usage (line : String) : IO UInt32 := do - IO.eprintln line + IO.eprintln (oneLine line) return 2 /-- The agent string identify advertises. -/ diff --git a/packages/cli/docs/ARCHITECTURE.md b/packages/cli/docs/ARCHITECTURE.md index 77cf662..22fdab0 100644 --- a/packages/cli/docs/ARCHITECTURE.md +++ b/packages/cli/docs/ARCHITECTURE.md @@ -65,7 +65,7 @@ work. - **`demo`** (the spike's `Main.lean`, the file `Demo.lean` here). The executable takes no arguments. The behavior section below is the contract. The echo home is the working decision in that - section. This slice does not add the module. + section. - **`pingnode`** (the spike's `PingNode.lean`). `pingnode serve ` hosts `127.0.0.1:`, serves `/ipfs/ping/1.0.0` and identify, prints @@ -196,9 +196,9 @@ A usage line is that message, `reprStr` of a `Multiaddr.parse?` error followed by the operand, `Host.Error.render` of a `splitPeer` error followed by the operand, or `dial address has an earlier /p2p peer:` followed by the operand. `.ok (_, none)` from `splitPeer` is -a usage error. A newline in an operand is written as `\n`, and a -carriage return as `\r`, before the line is printed. The process -creates no host. +a usage error. A newline in a `demo` argument or a `pingnode` +operand is written as `\n`, and a carriage return as `\r`, before +the line is printed. The process creates no host. ### Exit status @@ -673,6 +673,18 @@ records the later full runs. The Linux gates section records the verified W5.8 run. Maintainer review of that run is pending. W5.9 is unit 2. +`lake build demo pingnode tests` completed with 514 jobs. +`LEANP2P_SUITE=cli LEAN_NUM_THREADS=1 .lake/build/bin/tests +RTS -N1 +-RTS` passed 146 checks, with 0 failed, 0 todo, and 0 skipped. The +newline argument and the carriage-return argument each exit 2, write +empty stdout, and write one escaped diagnostic with one terminating +newline. The 140-check result stays the earlier demo record. The +local macOS full suite remains the Streams baseline. `lake test` and +`lake test -- +RTS -N1 -RTS` each exited 1: 28095 passed, 1 failed, +0 todo, and 7 skipped. The failure is `Tests/Streams.lean` line 985, +`the teardown aborted the stuck write: the peer meets a reset`. This +correction did not rerun that suite. + ### Gate record for `f994be4` This record is the local suite that included the demo flush, and the diff --git a/packages/cli/docs/PLAN.md b/packages/cli/docs/PLAN.md index 2320f33..af5cc42 100644 --- a/packages/cli/docs/PLAN.md +++ b/packages/cli/docs/PLAN.md @@ -81,6 +81,12 @@ hosts. `pingnode serve` and `pingnode dial` are implemented. .lake/build/bin/tests +RTS -N1 -RTS` passed 140 checks, with 0 failed, 0 todo, and 0 skipped. The 138-check result stays the dial slice's record. The 80-check result stays the echo suite's record. + The usage-line escape then ran `lake build demo pingnode tests`, + which completed with 514 jobs. `LEANP2P_SUITE=cli + LEAN_NUM_THREADS=1 .lake/build/bin/tests +RTS -N1 -RTS` passed 146 + checks, with 0 failed, 0 todo, and 0 skipped. The 140-check result + stays the earlier demo record. This correction did not rerun the + full `lake test`. The full `lake test` was not rerun for that callback correction. Its recorded local baseline still has one failure in `Tests/Streams.lean`: `the teardown aborted the stuck write: the @@ -108,7 +114,8 @@ hosts. `pingnode serve` and `pingnode dial` are implemented. outside 0..65535, a multiaddr that fails `Multiaddr.parse?`, a dial address that `Host.splitPeer` rejects, and a dial address whose transport prefix still contains a `p2p` component. A line - break in an operand stays inside that one stderr line. + break in a `demo` argument or a `pingnode` operand stays inside + that one stderr line, written as `\n` or `\r`. `Tests/Cli.lean` runs the built binaries for these cases. The successful `demo` run lives in `Tests/CliLive.lean` and uses `capture` with a 30 second deadline. A dial to