diff --git a/Tests/Cli.lean b/Tests/Cli.lean new file mode 100644 index 00000000..9a521795 --- /dev/null +++ b/Tests/Cli.lean @@ -0,0 +1,236 @@ +import Conc +import Tests.Support + +/-! +# Demo and pingnode arguments + +The checks run the built binaries. A usage error exits 2 with an +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 + +namespace Tests.Cli + +/-- An Ed25519 peer id from the identity suite. -/ +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:") + +/-- 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 + 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 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`." + 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 `.`" + 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" + 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 `.`" + -- 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 + "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/CliLive.lean b/Tests/CliLive.lean new file mode 100644 index 00000000..5f882c28 --- /dev/null +++ b/Tests/CliLive.lean @@ -0,0 +1,702 @@ +import Conc +import Crypto +import Streams +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 +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. 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. 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. +-/ + +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" + +/-- 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")] + +/-- 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 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 + 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] + 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 + 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 == "") + +/-- `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 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" + live runner + inboundThenServe runner + immediateEof runner + busyPort runner + closedStdout runner + timeoutCleanup runner + registeredFault runner + runner.section "pingnode dial" "packages/cli/PingNode.lean" + dialOk runner + dialRefused runner + dialMismatch runner + dialStall runner + dialNoEcho runner + runner.section "demo" "packages/cli/Demo.lean" + demoOk runner + demoClosedStdout runner + +end Tests.CliLive diff --git a/Tests/Main.lean b/Tests/Main.lean index 04619647..668090e9 100644 --- a/Tests/Main.lean +++ b/Tests/Main.lean @@ -38,6 +38,8 @@ import Tests.Host import Tests.Protocols import Tests.ReqResp import Tests.Interop +import Tests.Cli +import Tests.CliLive /-! # Test entry point @@ -47,11 +49,20 @@ 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. +`LEANP2P_SUITE=protocols` runs `Tests.Protocols`. -/ 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 + 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 @@ -91,4 +102,6 @@ def main : IO UInt32 := do Tests.Protocols.run r Tests.ReqResp.run r Tests.Interop.run r + Tests.Cli.run r + Tests.CliLive.run r r.summary diff --git a/Tests/Protocols.lean b/Tests/Protocols.lean index 051a042a..24237fad 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 5c05d85d..6e9aa7a7 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,13 @@ 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). A contributor working decision on the CLI branch + 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 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/docs/DOCS_DEFERRED.md b/docs/DOCS_DEFERRED.md index daedee35..a2ad5013 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 f754f817..f75ad0e3 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 9c570702..37d96da3 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 473067d0..00000000 --- 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 00000000..a76d8b4a --- /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 00000000..c56999e1 --- /dev/null +++ b/packages/cli/Demo.lean @@ -0,0 +1,492 @@ +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, before any +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, +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 + +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 := + .init { + name := "demo" + parentNames := #[] + version? := none + description := "Run the loopback demo." + flags := #[] + positionalArgs := #[] + 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 (oneLine line) + return 2 + +/-- 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 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 + return 1 + +/-- 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 + writeLine 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 + 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. +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 + writeLine "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 + 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 + 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 demonstrate 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 new file mode 100644 index 00000000..6df33fe5 --- /dev/null +++ b/packages/cli/PingNode.lean @@ -0,0 +1,378 @@ +import Cli +import Conc +import Crypto +import Streams +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 + +/-! +# PingNode + +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. + +`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` 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 + +namespace Cli.PingNode + +open Cli +open Libp2p.Host (Host Error Failure) +open Libp2p.Identity (PeerId PrivKey) + +/-- 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 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 + 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`. +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 peer) => + if transportHasPeer transport then + throw s!"dial address has an earlier /p2p peer: {text}" + else pure (addr, peer) + | .ok (_, none) => throw s!"dial address has no final /p2p peer: {text}" + | .error err => throw s!"{err} {text}" + +/-- 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 + | .ok (cmd, parsed) => + match cmd.meta.name with + | "serve" => + match operandValue parsed "port" with + | some port => + if validPort port then serve port else usage s!"invalid port: {port}" + | none => usage "missing port" + | "dial" => + match operandValue parsed "multiaddr" with + | some text => + match validDial text with + | .ok (addr, peer) => dial addr peer + | .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 912a72ca..22fdab09 100644 --- a/packages/cli/docs/ARCHITECTURE.md +++ b/packages/cli/docs/ARCHITECTURE.md @@ -15,55 +15,74 @@ 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 +`Demo.lean` and `PingNode.lean` are the executable roots. Each +`main` checks its arguments with `Cmd.parse`. A usage 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. +`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`. 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. ## 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 working decision 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 +93,13 @@ 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 home is + `Libp2p.Protocols.TestSupport.Echo`, recorded below. That module + 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 `Std.Async.TCP` (§9, delta 7), the security value from `libp2p/security`, and the muxer values from `libp2p/mux`. The @@ -88,13 +110,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 +132,722 @@ 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 echo home at the end is the working decision. 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`. `lakefile.toml` names `demo` and `pingnode`. The +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 + +`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`. +`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 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. +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 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 + +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. + +### 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`. + +- 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` 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 + 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 +also registers `Libp2p.Protocols.TestSupport.Echo.handler`. + +`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. + +`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 + +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`). + +### Working decision: the deadline around `Host.newStream` + +This branch selects a contributor working decision. The review +context is +https://github.com/etheorem/leanp2p/issues/22#issuecomment-6805941244. + +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. + +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. `pingnode dial` +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 + +`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` 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. + +### 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. `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. 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. +`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 + +The demo runs these steps in order. + +1. Generate two Ed25519 identities. +2. Build two hosts. +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 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. + 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. + +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 + +This branch selects a contributor working decision. The review +context is +https://github.com/etheorem/leanp2p/issues/22#issuecomment-6805941244. + +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 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 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 +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. + +`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. That callback correction did +not rerun the full `lake test`. The gate record for `f994be4` +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 +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`. + +### 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 -- **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. `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 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). + 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. + This branch selects a contributor working decision for shutdown. + The review context is + https://github.com/etheorem/leanp2p/issues/22#issuecomment-6807493655. + 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. `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. 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 gate's only form, so docker appears only inside the scripts. @@ -149,14 +877,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 +897,12 @@ 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 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 + 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 f254e3a5..af5cc427 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,20 +28,295 @@ 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 lib is +`CliHarness`. Its globs include `Demo` and `PingNode`, and the +lakefile names the `demo` and `pingnode` executables. A usage +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 +deadlines, the +stream retirement, and the two-host close. Those deadlines start +after `newStream` returns. This branch selects two contributor +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. `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. 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 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). -- 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). + 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 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). `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 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 + 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`. + 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 + 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 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 + 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`. 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 + 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` 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 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`. 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`. + 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. 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). +## 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 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 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`. +- 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`. W5.9 is unit 2. This snapshot + predates the verified Linux gates below. + +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) - The gate: the interop matrix against go-libp2p v0.50.0, over 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 00000000..62f33c2c --- /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 d34e6cb7..a72c64b5 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 | @@ -41,9 +42,27 @@ 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. 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. +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` 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 e39f7eee..aa1d64c3 100644 --- a/packages/libp2p/protocols/docs/PLAN.md +++ b/packages/libp2p/protocols/docs/PLAN.md @@ -36,7 +36,21 @@ 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 reads one chunk, echoes those bytes, and half-closes. + 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 + .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. diff --git a/scripts/lint-imports.sh b/scripts/lint-imports.sh index c9cfd20c..20f64b2a 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 ;; diff --git a/scripts/ping-go-interop.sh b/scripts/ping-go-interop.sh new file mode 100755 index 00000000..bc89b96b --- /dev/null +++ b/scripts/ping-go-interop.sh @@ -0,0 +1,369 @@ +#!/usr/bin/env bash +# 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. +# +# 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 +# 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 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" +server_pid= +hold_pid= +go_pid= +go_hold_pid= +lean_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 ${lean_pid:-} ]]; then + finish_child "$lean_pid" + lean_pid= + fi + if [[ -n ${go_pid:-} ]]; then + 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= + fi + if [[ -n ${server_pid:-} ]]; then + finish_child "$server_pid" + server_pid= + fi + rm -rf "$work" + exit "$status" +} +trap reap EXIT + +fail() { + 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 +} + +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=$! +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} +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 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" + go_pid= + fail "go ping missed 25 s" + fi + sleep 0.2 +done + +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" + 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 +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")" +} + +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/go.mod b/scripts/ping-go/go.mod new file mode 100644 index 00000000..56409d95 --- /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 00000000..cd918257 --- /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 00000000..aa0161b2 --- /dev/null +++ b/scripts/ping-go/main.go @@ -0,0 +1,179 @@ +// Dial one peer, or listen for one, and close on stdin EOF. +// +// `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 +// 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 ( + "context" + "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" + "github.com/libp2p/go-libp2p/p2p/security/noise" + "github.com/libp2p/go-libp2p/p2p/transport/tcp" +) + +const pingBudget = 20 * time.Second + +func main() { + 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) + } +} + +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) { + 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) + var rtt time.Duration + 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) + } + 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) + } + } +}