diff --git a/AGENTS.md b/AGENTS.md index 6d1ecf9..a6c1fd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,6 @@ just browsers # chromium + firefox lanes (`just browsers-install` on just websocket-conformance # their suite under this host (spawns their echod) just smoke-tls # polymorph-tls suite (issue #18) just smoke-c0 # C0 smoke legs -just iroh-exam # needs iroh-relay on PATH ``` Conformance discipline: the harness fails loudly on unexpected failures *and* diff --git a/README.md b/README.md index 35cdec9..a13a5f7 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Pre-1.0, but densely gated: | `ct-runner/` | conformance-suite runner for the polymorph-test L1 contract | | `harness/` + `tools/browser` | official-suite harness; Deno lane + Chromium/Firefox/WebKit lanes | | `contracts/` | the versioned interface contracts (plan format, embedder API, intrinsics, digest) | -| `ports/`, `exams/` | polymorph host-module ports and consumer exams (reference implementations pending upstreaming) | +| `ports/` | polymorph host-module ports (reference implementations pending upstreaming; the consumer exams retired upstream — docs/consumers.md) | ## Quick start diff --git a/docs/consumers.md b/docs/consumers.md index 4779acc..a073284 100644 --- a/docs/consumers.md +++ b/docs/consumers.md @@ -115,7 +115,7 @@ the in-repo references is | `ports/websocket` | `polymorph:websocket/connections` host module | their conformance suite 55/55 incl. TLS (`conformance/run.ts`); their deltic-deno + deltic-browser rows run the upstreamed copy | | `ports/webcrypto` | `polymorph:webcrypto` host module (full surface — [#3](https://github.com/lann/deltic/issues/3) closed) | KATs vs their vectors + iroh exec-model integration | | `ports/webrtc` | `polymorph:webrtc-datachannels/connections` host module | their echo-demo component over real data channels; their full driver-ct loopback matrix (solo+pair, 37/37) runs under the upstreamed copy ([polymorph-webrtc-datachannels#149](https://github.com/polymorph-components/polymorph-webrtc-datachannels/pull/149)) | -| `exams/iroh-endpoint` | the endpoint exit exam | 5/5: bind+identity, relay echo, WebRTC upgrade, jco#11/#13 assertions, teardown; upstreamed as their `host-deltic/` + `just exam-deltic` ([polymorph-iroh#36](https://github.com/polymorph-components/polymorph-iroh/pull/36), merged — all host modules from the sibling checkouts, incl. polymorph-webcrypto's own deltic module) | +| `exams/iroh-endpoint` (retired) | the endpoint exit exam | served and retired (2026-08-11, in-repo through 5/5 + IROH-1): upstreamed as their `host-deltic/` + `just exam-deltic` ([polymorph-iroh#36](https://github.com/polymorph-components/polymorph-iroh/pull/36), merged), after which the in-repo copy was re-testing deltic against a staling guest snapshot — the consumer's own deltic leg is the living exam; the runtime defects it caught are pinned in deltic's suites (R-1/R-2, entry-deferral). History: `git log -- exams/iroh-endpoint` | | `ct-runner` | L3 runner for the polymorph-test L1 contract | golden-tested L4 JSONL; drives the websocket suite | | `tools/smoke-c0` | C0 smoke legs + report | legs 1–4 (`REPORT.md`) | | `tools/smoke-tls` | polymorph-tls conformance under deltic ([#18](https://github.com/lann/deltic/issues/18)) | translate 8/8; suites: zero failures, zero xfails on every composition — tag gating ([#25](https://github.com/lann/deltic/issues/25), `ct-runner/src/tags.ts`) schedules the per-target inapplicable cases to `not-applicable` exactly like their harness legs; the callback-null-context defect it found ([#24](https://github.com/lann/deltic/issues/24)) is fixed — attribution sentinels, `runtime/src/jspi/bridge.ts` | diff --git a/exams/iroh-endpoint/README.md b/exams/iroh-endpoint/README.md deleted file mode 100644 index 0b13f74..0000000 --- a/exams/iroh-endpoint/README.md +++ /dev/null @@ -1,89 +0,0 @@ -# C3-IROH — the iroh endpoint exit exam - -Runs the **real polymorph-iroh endpoint component** under deltic on -Deno: the workload that is structurally dead under jco -([lann/jco#11](https://github.com/lann/jco/issues/11) — a detached pump task -holding in-flight imports deadlocks every later export call; #13 cross-task -wakeups). Their JS consumer driver -(`polymorph-iroh/host-jco/src/run-endpoint.mjs`) is, in their README's words, -"ready for when it lands". This exam ports that driving logic — not the jco -wiring — onto `@deltic/runtime/embedder` plus the committed -`ports/{websocket,webcrypto,webrtc}` and `wasi-shims`. - -```sh -# once: the stock upstream relay, built inside the consumer tree -(cd /home/lmartin/p/polymorph/polymorph-iroh/.deps/iroh && \ - cargo build --release -p iroh-relay --features server --bin iroh-relay) - -deno run -A --unstable-net exams/iroh-endpoint/run.ts # spawns/reaps the relay itself -deno check run.ts src # from this directory -``` - -Nothing here writes into a consumer tree. The endpoint component is read from -`polymorph-iroh/target/wasm32-wasip2/release/iroh_endpoint.wasm`; if that -artifact is missing or stale (checked by its import set, not its mtime) it is -rebuilt with `CARGO_TARGET_DIR=/tmp/opencode/c3-iroh-target`. - -## Scenarios - -| # | Scenario | What it proves | -|---|---|---| -| 1 | bind + identity | `Endpoint.bind` mints an Ed25519 identity through `ports/webcrypto` and stands up the relay connection; the detached pump is then alive and **later export calls still complete** (lann/jco#11). Also asserts **zero `wasi:sockets` calls** — the browser profile. | -| 2 | relay echo | Two endpoint instances in one process, dialled by endpoint id through a stock `iroh-relay --dev`; one authenticated echo each way over QUIC streams. Their `endpoint-relay` matrix row. | -| 3 | WebRTC upgrade | The same connection, dialled on the relay with a `webrtc` upgrade hint, moves onto a data channel — `connection.path` reports `webrtc`. Browser reach beyond jco, made concrete. | -| 4 | concurrency proof points | 40 export calls against two live pump tasks (jco#11); `endpoint.accept` parked **before** the dial and woken by the pump, plus `accept-bi`/`wait-closed` woken by peer activity (jco#13). | -| 5 | teardown | `close` + awaited `wait-closed`, idempotent `close`, handle drop, and the relay process reaped. | - -**Not claimed here:** lann/jco#14 (composed async calls). The endpoint is a -single component in this exam — no `wac plug` — so that row belongs to the -experiment-mosh composed client, not to this file. - -## FINDING C3-IROH-1 — the consumer defect the exam retries around - -`polymorph-iroh/endpoint/src/endpoint_impl.rs:13` states an invariant: "the -`RefCell` borrows never cross an await". They do: - -``` -State::drain() # runs under shared.borrow_mut() - -> noq / rustls handshake work - -> Signer::sign # core/src/crypto/sign.rs:104 - -> wit_bindgen::block_on(polymorph:webcrypto/signature#signing-key.sign) -``` - -`block_on` on an **async import** is a yield point: the callback-ABI -activation returns to the host and is resumed later, so another task of the -same instance may run while `drain`'s borrow is live. Every other endpoint -task (`connect`, `accept`, `open-bi`, …) parks in `wait_until` -(`endpoint_impl.rs:939`), whose first act is `shared.borrow_mut()` — panicking -`RefCell already borrowed` and aborting the guest with an `unreachable` trap. -The panic site is the victim; the borrow-holder is the culprit. - -Confirmed directly by instrumenting `ports/webcrypto`'s `SigningKey.sign`: the -trap always lands **between the enter and exit of the TLS CertificateVerify -signature**, never elsewhere. - -This is latent on every host. deltic reaches it more often because -a RESOLVED task that blocks mid-frame releases `inst.exclusiveThread` -(`runtime/src/jspi/bridge.ts:349-394` — a documented wasmtime-tracking -divergence from `definitions.py`'s `canon_lift`, which holds the slot across -such a block). The pump rides `bind`'s *resolved* task, so its `block_on` -opens the instance to the parked poller. - -The exam therefore retries scenarios 2–4 a bounded number of times and prints -every observed panic. Scenarios 1 and 5 are deterministic. - -## Port friction recorded (worked around, not patched) - -1. **`ports/webcrypto`** publishes the `signing-key-options` resource class - only under `polymorph:webcrypto/ed25519-sign@0.1.0`, but the WIT that - *defines* it is `signature` (`polymorph-webcrypto/wit/webcrypto.wit:604`); - `ed25519-sign` merely `use`s it. Instantiating this endpoint against the - stock fragment fails with a `PlanError`. `src/harness.ts`'s - `webcryptoFragment()` re-publishes the same class under the defining - interface; the real fix belongs in `ports/webcrypto`. -2. **`ports/webrtc`** resolves `node-datachannel` with a bare specifier from - its own source file, but Deno resolves that against the *entry project's* - scope. A standalone consumer therefore silently loses the backend (the - top-level `await …catch` swallows it) and `new PeerConnection` throws a - trap. Fixed here by mapping the specifier explicitly in `deno.json`'s - `imports`; a consumer that only copies `package.json` will hit this. diff --git a/exams/iroh-endpoint/deno.json b/exams/iroh-endpoint/deno.json deleted file mode 100644 index 2bda270..0000000 --- a/exams/iroh-endpoint/deno.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "nodeModulesDir": "auto", - "//": "Standalone (non-workspace) exam package, mirroring ports/webrtc: everything in the repo is imported by RELATIVE path, and the single alias below is the one ports/{websocket,webrtc,webcrypto} and wasi-shims use internally. It resolves to the SAME file URL as the relative imports here, so there is exactly one `WitError`/`Stream`/`GuestResource` module instance and `instanceof` holds across the boundary (contracts/embedder-api.md's error-model brand hazard).", - "imports": { - "@deltic/runtime/embedder": "../../runtime/src/embedder/mod.ts", - "node-datachannel/polyfill": "npm:node-datachannel@0.32.3/polyfill", - "node-datachannel": "npm:node-datachannel@0.32.3", - "werift": "npm:werift@0.22.2" - }, - "tasks": { - "check": "deno check run.ts src", - "exam": "deno run -A run.ts" - } -} diff --git a/exams/iroh-endpoint/deno.lock b/exams/iroh-endpoint/deno.lock deleted file mode 100644 index 6bb4187..0000000 --- a/exams/iroh-endpoint/deno.lock +++ /dev/null @@ -1,592 +0,0 @@ -{ - "version": "5", - "specifiers": { - "npm:node-datachannel@0.32.3": "0.32.3", - "npm:werift@0.22.2": "0.22.2" - }, - "npm": { - "@fidm/asn1@1.0.4": { - "integrity": "sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==" - }, - "@fidm/x509@1.2.1": { - "integrity": "sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==", - "dependencies": [ - "@fidm/asn1", - "tweetnacl" - ] - }, - "@leichtgewicht/ip-codec@2.0.5": { - "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==" - }, - "@minhducsun2002/leb128@1.0.0": { - "integrity": "sha512-eFrYUPDVHeuwWHluTG1kwNQUEUcFjVKYwPkU8z9DR1JH3AW7JtJsG9cRVGmwz809kKtGfwGJj58juCZxEvnI/g==" - }, - "@noble/curves@1.9.7": { - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "dependencies": [ - "@noble/hashes" - ] - }, - "@noble/hashes@1.8.0": { - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==" - }, - "@peculiar/asn1-cms@2.8.0": { - "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "@peculiar/asn1-x509-attr", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-csr@2.8.0": { - "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-ecc@2.8.0": { - "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-pfx@2.8.0": { - "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", - "dependencies": [ - "@peculiar/asn1-cms", - "@peculiar/asn1-pkcs8", - "@peculiar/asn1-rsa", - "@peculiar/asn1-schema", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-pkcs8@2.8.0": { - "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-pkcs9@2.8.0": { - "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", - "dependencies": [ - "@peculiar/asn1-cms", - "@peculiar/asn1-pfx", - "@peculiar/asn1-pkcs8", - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "@peculiar/asn1-x509-attr", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-rsa@2.8.0": { - "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-schema@2.8.0": { - "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", - "dependencies": [ - "@peculiar/utils", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-x509-attr@2.8.0": { - "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-x509@2.8.0": { - "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/utils", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/utils@2.0.3": { - "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "dependencies": [ - "tslib@2.8.1" - ] - }, - "@peculiar/x509@1.14.3": { - "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", - "dependencies": [ - "@peculiar/asn1-cms", - "@peculiar/asn1-csr", - "@peculiar/asn1-ecc", - "@peculiar/asn1-pkcs9", - "@peculiar/asn1-rsa", - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "pvtsutils", - "reflect-metadata", - "tslib@2.8.1", - "tsyringe" - ] - }, - "@shinyoshiaki/binary-data@0.6.1": { - "integrity": "sha512-7HDb/fQAop2bCmvDIzU5+69i+UJaFgIVp99h1VzK1mpg1JwSODOkjbqD7ilTYnqlnadF8C4XjpwpepxDsGY6+w==", - "dependencies": [ - "generate-function", - "is-plain-object" - ] - }, - "@shinyoshiaki/jspack@0.0.6": { - "integrity": "sha512-SdsNhLjQh4onBlyPrn4ia1Pdx5bXT88G/LIEpOYAjx2u4xeY/m/HB5yHqlkJB1uQR3Zw4R3hBWLj46STRAN0rg==" - }, - "@types/dom-mediacapture-transform@0.1.12": { - "integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==", - "dependencies": [ - "@types/dom-webcodecs" - ] - }, - "@types/dom-webcodecs@0.1.13": { - "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==" - }, - "aes-js@3.1.2": { - "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==" - }, - "asn1js@3.0.10": { - "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", - "dependencies": [ - "pvtsutils", - "pvutils", - "tslib@2.8.1" - ] - }, - "base64-js@1.5.1": { - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" - }, - "big-integer@1.6.52": { - "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==" - }, - "bl@4.1.0": { - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dependencies": [ - "buffer@5.7.1", - "inherits", - "readable-stream" - ] - }, - "buffer-crc32@1.0.0": { - "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==" - }, - "buffer@5.7.1": { - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dependencies": [ - "base64-js", - "ieee754" - ] - }, - "buffer@6.0.3": { - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dependencies": [ - "base64-js", - "ieee754" - ] - }, - "chownr@1.1.4": { - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" - }, - "date-fns@4.4.0": { - "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==" - }, - "debug@4.4.0": { - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dependencies": [ - "ms" - ] - }, - "decompress-response@6.0.0": { - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dependencies": [ - "mimic-response" - ] - }, - "deep-extend@0.6.0": { - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==" - }, - "detect-libc@2.1.2": { - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" - }, - "dns-packet@5.6.1": { - "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", - "dependencies": [ - "@leichtgewicht/ip-codec" - ] - }, - "end-of-stream@1.4.5": { - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dependencies": [ - "once" - ] - }, - "expand-template@2.0.3": { - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==" - }, - "fs-constants@1.0.0": { - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" - }, - "generate-function@2.3.1": { - "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", - "dependencies": [ - "is-property" - ] - }, - "github-from-package@0.0.0": { - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" - }, - "ieee754@1.2.1": { - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" - }, - "inherits@2.0.4": { - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "ini@1.3.8": { - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" - }, - "int64-buffer@1.1.0": { - "integrity": "sha512-94smTCQOvigN4d/2R/YDjz8YVG0Sufvv2aAh8P5m42gwhCsDAJqnbNOrxJsrADuAFAA69Q/ptGzxvNcNuIJcvw==" - }, - "ip@2.0.1": { - "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==" - }, - "is-plain-object@2.0.4": { - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dependencies": [ - "isobject" - ] - }, - "is-property@1.0.2": { - "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" - }, - "isobject@3.0.1": { - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==" - }, - "lodash@4.18.1": { - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" - }, - "mediabunny@1.52.3": { - "integrity": "sha512-rMGwH5fykDCSA55LG9aWkE433wwHrycq3J5mRf+djBnHBZzmJGvIwg6Qfcfr4rRkzkmrdmewxQozLkOM1H1C6Q==", - "dependencies": [ - "@types/dom-mediacapture-transform", - "@types/dom-webcodecs" - ] - }, - "mimic-response@3.1.0": { - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==" - }, - "minimist@1.2.8": { - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" - }, - "mkdirp-classic@0.5.3": { - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" - }, - "mp4box@0.5.4": { - "integrity": "sha512-GcCH0fySxBurJtvr0dfhz0IxHZjc1RP+F+I8xw+LIwkU1a+7HJx8NCDiww1I5u4Hz6g4eR1JlGADEGJ9r4lSfA==" - }, - "ms@2.1.3": { - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "multicast-dns@7.2.5": { - "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", - "dependencies": [ - "dns-packet", - "thunky" - ], - "bin": true - }, - "nano-time@1.0.0": { - "integrity": "sha512-flnngywOoQ0lLQOTRNexn2gGSNuM9bKj9RZAWSzhQ+UJYaAFG9bac4DW9VHjUAzrOaIcajHybCTHe/bkvozQqA==", - "dependencies": [ - "big-integer" - ] - }, - "napi-build-utils@2.0.0": { - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" - }, - "node-abi@3.94.0": { - "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", - "dependencies": [ - "semver" - ] - }, - "node-datachannel@0.32.3": { - "integrity": "sha512-Aok1ZhLsll472lRefgWYuWJ0070jh0ecHravTdRyZEmoESumebMEQV8Y+poBwSW2ZbEwAokAOGsK5Cu8pDDT2g==", - "dependencies": [ - "prebuild-install" - ], - "scripts": true - }, - "once@1.4.0": { - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": [ - "wrappy" - ] - }, - "p-cancelable@2.1.1": { - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==" - }, - "prebuild-install@7.1.3": { - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "dependencies": [ - "detect-libc", - "expand-template", - "github-from-package", - "minimist", - "mkdirp-classic", - "napi-build-utils", - "node-abi", - "pump", - "rc", - "simple-get", - "tar-fs", - "tunnel-agent" - ], - "deprecated": true, - "bin": true - }, - "pump@3.0.4": { - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dependencies": [ - "end-of-stream", - "once" - ] - }, - "pvtsutils@1.3.6": { - "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "dependencies": [ - "tslib@2.8.1" - ] - }, - "pvutils@1.2.0": { - "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==" - }, - "rc@1.2.8": { - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dependencies": [ - "deep-extend", - "ini", - "minimist", - "strip-json-comments" - ], - "bin": true - }, - "readable-stream@3.6.2": { - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": [ - "inherits", - "string_decoder", - "util-deprecate" - ] - }, - "reflect-metadata@0.2.2": { - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" - }, - "rx.mini@1.4.0": { - "integrity": "sha512-8w5cSc1mwNja7fl465DXOkVvIOkpvh2GW4jo31nAIvX4WTXCsRnKJGUfiDBzWtYRInEcHAUYIZfzusjIrea8gA==" - }, - "safe-buffer@5.2.1": { - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" - }, - "semver@7.8.5": { - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "bin": true - }, - "simple-concat@1.0.1": { - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==" - }, - "simple-get@4.0.1": { - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "dependencies": [ - "decompress-response", - "once", - "simple-concat" - ] - }, - "string_decoder@1.3.0": { - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dependencies": [ - "safe-buffer" - ] - }, - "strip-json-comments@2.0.1": { - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==" - }, - "tar-fs@2.1.5": { - "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", - "dependencies": [ - "chownr", - "mkdirp-classic", - "pump", - "tar-stream" - ] - }, - "tar-stream@2.2.0": { - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dependencies": [ - "bl", - "end-of-stream", - "fs-constants", - "inherits", - "readable-stream" - ] - }, - "thunky@1.1.0": { - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" - }, - "tslib@1.14.1": { - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, - "tslib@2.8.1": { - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" - }, - "tsyringe@4.10.0": { - "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", - "dependencies": [ - "tslib@1.14.1" - ] - }, - "tunnel-agent@0.6.0": { - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dependencies": [ - "safe-buffer" - ] - }, - "turbo-crc32@1.0.1": { - "integrity": "sha512-8yyRd1ZdNp+AQLGqi3lTaA2k81JjlIZOyFQEsi7GQWBgirnQOxjqVtDEbYHM2Z4yFdJ5AQw0fxBLLnDCl6RXoQ==" - }, - "tweetnacl@1.0.3": { - "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" - }, - "util-deprecate@1.0.2": { - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, - "uuid@11.1.1": { - "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", - "bin": true - }, - "werift-common@0.0.3": { - "integrity": "sha512-ma3E4BqKTyZVLhrdfTVs2T1tg9seeUtKMRn5e64LwgrogWa62+3LAUoLBUSl1yPWhgSkXId7GmcHuWDen9IJeQ==", - "dependencies": [ - "@shinyoshiaki/jspack", - "debug" - ] - }, - "werift-dtls@0.5.8": { - "integrity": "sha512-oH39cdCpVLqKcPZRMGQLcN+tIBixvMGqjCB9qCrH39Tu9Mmu8u2+zTzyl0Fod87EK6PNPgoNqMJt64xlSMfLnw==", - "dependencies": [ - "@fidm/x509", - "@noble/curves", - "@peculiar/x509", - "@shinyoshiaki/binary-data", - "debug", - "tweetnacl" - ] - }, - "werift-ice@0.2.2": { - "integrity": "sha512-td52pHp+JmFnUn5jfDr/SSNO0dMCbknhuPdN1tFp9cfRj5jaktN63qnAdUuZC20QCC3ETWdsOthcm+RalHpFCQ==", - "dependencies": [ - "@shinyoshiaki/jspack", - "buffer-crc32", - "debug", - "int64-buffer", - "ip", - "lodash", - "multicast-dns", - "p-cancelable", - "rx.mini" - ] - }, - "werift-rtp@0.8.9": { - "integrity": "sha512-uLFOawIXw8FblIp1akfIVYFuRNtNo7csRxYypLz0t++sUNCN7sPeghVqET/c3Mq7Tg2QA3oAb15fXIIuJEp2AA==", - "dependencies": [ - "buffer@6.0.3", - "debug", - "mediabunny" - ] - }, - "werift-sctp@0.0.11": { - "integrity": "sha512-7109yuI5U7NTEHjqjn0A8VeynytkgVaxM6lRr1Ziv0D8bPcaB8A7U/P88M7WaCpWDoELHoXiRUjQycMWStIgjQ==", - "dependencies": [ - "@shinyoshiaki/jspack" - ] - }, - "werift@0.22.2": { - "integrity": "sha512-R+dfzOknUiGH8EcxGjWfN4404+Npj4tT1L5HpqZLjw0ARCO0B19i9gAQOo6ESzzTE+L8L1wxb1KIspOeoko+TQ==", - "dependencies": [ - "@fidm/x509", - "@minhducsun2002/leb128", - "@noble/curves", - "@peculiar/x509", - "@shinyoshiaki/binary-data", - "@shinyoshiaki/jspack", - "aes-js", - "buffer@6.0.3", - "buffer-crc32", - "date-fns", - "debug", - "int64-buffer", - "ip", - "lodash", - "mp4box", - "multicast-dns", - "nano-time", - "turbo-crc32", - "tweetnacl", - "uuid", - "werift-common", - "werift-dtls", - "werift-ice", - "werift-rtp", - "werift-sctp" - ] - }, - "wrappy@1.0.2": { - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - } - }, - "workspace": { - "dependencies": [ - "npm:node-datachannel@0.32.3", - "npm:werift@0.22.2" - ], - "packageJson": { - "dependencies": [ - "npm:node-datachannel@0.32.3", - "npm:werift@0.22.2" - ] - }, - "links": { - "jsr:@deltic/ct-runner@0.1.0": {}, - "jsr:@deltic/examples@0.0.0": {}, - "jsr:@deltic/harness": {}, - "jsr:@deltic/runtime": {}, - "jsr:@deltic/wasi-shims@0.1.0": {} - } - } -} diff --git a/exams/iroh-endpoint/package.json b/exams/iroh-endpoint/package.json deleted file mode 100644 index 64c497b..0000000 --- a/exams/iroh-endpoint/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "private": true, - "//": "Bare npm specifiers reached from ports/webrtc/src/webrtc.ts resolve against THIS package scope when the exam is the entry project, so the webrtc backend deps are pinned here to the exact versions ports/webrtc/package.json pins.", - "dependencies": { - "node-datachannel": "0.32.3", - "werift": "0.22.2" - } -} diff --git a/exams/iroh-endpoint/run.ts b/exams/iroh-endpoint/run.ts deleted file mode 100644 index 6a48bec..0000000 --- a/exams/iroh-endpoint/run.ts +++ /dev/null @@ -1,495 +0,0 @@ -// C3-IROH — the iroh endpoint exit exam (docs/milestones.md, track C3). -// -// Runs the polymorph-iroh ENDPOINT COMPONENT under deltic on Deno, -// with the committed ports supplying every non-WASI import. This is the -// workload that is structurally dead under jco (lann/jco#11: a detached pump -// task holding in-flight imports deadlocks every later export call; #13: -// cross-task wakeups), driven by the logic of the consumer's own JS driver -// (polymorph-iroh/host-jco/src/run-endpoint.mjs), which their README calls -// "ready for when it lands". -// -// deno run -A --unstable-net exams/iroh-endpoint/run.ts -// -// `--unstable-net` is not needed by anything here today (see src/sockets.ts: -// the browser profile binds no UDP socket and the exam asserts zero -// `wasi:sockets` calls); it is accepted so the flag can stay in the recipe if -// a UDP leg is ever added. -// -// --------------------------------------------------------------------------- -// FINDING C3-IROH-1 (consumer defect, reported not patched) — why scenarios 2 -// and 3 retry. -// -// `endpoint/src/endpoint_impl.rs` states as an invariant (line 13) that "the -// `RefCell` borrows never cross an await". They do: -// -// State::drain() <- runs under shared.borrow_mut() -// -> noq/rustls handshake work -// -> Signer::sign (core/src/crypto/sign.rs:104) -// -> wit_bindgen::block_on(polymorph:webcrypto/signature#signing-key.sign) -// -// `block_on` on an async import is a yield point: the callback-ABI activation -// returns to the host and is resumed later, so ANOTHER task of the same -// instance can run while `drain`'s borrow is live. The endpoint's other tasks -// (`connect`, `accept`, `open-bi`, …) all park in `wait_until` -// (endpoint_impl.rs:939), whose first act is `shared.borrow_mut()` — which -// panics `RefCell already borrowed`, aborting the guest with an `unreachable` -// trap. Observed rate: ~90% of runs with `accept` parked across the -// handshake, ~40% with the accept deferred (each parked poller re-arms every -// POLL_NS = 5 ms, and the signing window is comparable). -// -// Whose bug: the guest's, and it is latent on every host. deltic -// makes it reachable more often because a RESOLVED task that blocks mid-frame -// releases `inst.exclusiveThread` here (runtime/src/jspi/bridge.ts:349-394, a -// documented wasmtime-tracking divergence from definitions.py `canon_lift`, -// which holds the slot across such a block). The pump rides `bind`'s resolved -// task, so its `block_on` opens the instance to the parked poller. Both facts -// are in the report; neither is patched from this track. -// -// The exam therefore RETRIES scenarios 2 and 3 a bounded number of times and -// reports the observed panic count as a first-class datum. Every other -// scenario is deterministic. -// --------------------------------------------------------------------------- - -import { - bindEndpoint, - deadline, - describeError, - type EndpointInstance, - hex, - newEndpointInstance, - type Relay, - shortId, - startRelay, - utf8, -} from "./src/harness.ts"; -import { resetUdpCallLog, udpCallLog } from "./src/sockets.ts"; -import type { Connection, Endpoint, PathKind, TransportAddr } from "./src/types.ts"; -import { - check, - installPanicWatchdog, - readAll, - scenario, - settle, - takeGuestPanics, - type Verdict, - verdicts_, -} from "./src/scenario.ts"; - -const ALPN = utf8.encode("iroh-demo/0"); -const MESSAGE = "hello through the endpoint surface"; - -/** - * Bounded retries around FINDING C3-IROH-1. The budget is per-shape because - * the shapes lose the race at very different rates: with `accept` parked - * across the handshake there are two pollers (the acceptor and the dialer) - * live during the two CertificateVerify signatures, and the measured - * per-attempt survival is roughly 1 in 4; with the accept deferred it is - * closer to 3 in 5. Both budgets put the all-attempts-fail probability under - * ~1%, which is what the reproducibility gate needs. - */ -const ECHO_ATTEMPTS = 8; -const PARKED_ACCEPT_ATTEMPTS = 20; - -// --- the echo exchange (ported from run-endpoint.mjs:48-93) ---------------- - -interface EchoOptions { - /** `endpoint-options.webrtc`, and whether to offer a `webrtc` addr hint. */ - readonly webrtc: boolean; - /** Park `endpoint.accept()` BEFORE the client dials (the jco#13 shape). */ - readonly parkAccept: boolean; - /** Extra cross-task assertions run inside the exchange. */ - readonly onExchange?: (ctx: ExchangeContext) => Promise; -} - -interface ExchangeContext { - readonly server: EndpointInstance; - readonly client: EndpointInstance; - readonly serverEndpoint: Endpoint; - readonly clientEndpoint: Endpoint; - readonly clientConn: Connection; - readonly notes: string[]; -} - -interface EchoReport { - readonly serverId: string; - readonly clientId: string; - readonly handshakeMs: number; - readonly roundtripMs: number; - readonly received: string; - readonly echoed: string; - readonly clientPath: PathKind; - readonly serverPath: PathKind; - readonly notes: string[]; -} - -async function echoOnce(relay: Relay, options: EchoOptions): Promise { - const notes: string[] = []; - const server = await newEndpointInstance({ label: "server" }); - const client = await newEndpointInstance({ label: "client" }); - - const bindOptions = { alpns: [ALPN], relayUrl: relay.url, webrtc: options.webrtc }; - const sep = await deadline(bindEndpoint(server, bindOptions), 30_000, "server bind"); - const cep = await deadline(bindEndpoint(client, bindOptions), 30_000, "client bind"); - const serverId = await sep.id(); - const clientId = await cep.id(); - - // The dial hints. A `webrtc` entry is an UPGRADE HINT, not a dial target: - // the handshake runs on the relay and the packets move to the data channel - // once it opens (wit/iroh.wit:168-173). - const addrs: TransportAddr[] = [{ tag: "relay", val: relay.url }]; - if (options.webrtc) addrs.push({ tag: "webrtc", val: relay.url }); - - // The server's accept. Parked BEFORE the dial it is the jco#13 shape (a - // cross-task wakeup delivered to a task that parked first); deferred it - // reads the connection out of the pump's `accept_queue` afterwards. Both - // are legal drivings of the surface; see FINDING C3-IROH-1 for why the - // parked form is the more fragile one against this guest build. - let acceptPromise: Promise | undefined; - if (options.parkAccept) acceptPromise = sep.accept(); - - const t0 = performance.now(); - const clientConn = await deadline( - cep.connect({ endpointId: serverId, addrs }, ALPN), - 60_000, - "client connect", - ); - const handshakeMs = performance.now() - t0; - - const serverSide = (async () => { - const conn = await deadline(acceptPromise ?? sep.accept(), 60_000, "server accept"); - const [send, recv] = await deadline(conn.acceptBi(), 60_000, "server accept-bi"); - const received = await readAll(recv); - await send.write(utf8.encode(received.toUpperCase())); - await send.finish(); - // Teardown discipline (experiment-mosh finding 16b): the peer's close - // must be awaited, or CONNECTION_CLOSE may go unsent. - await deadline(conn.waitClosed(), 30_000, "server wait-closed"); - const path = await conn.path(); - return { received, peer: await conn.peer(), path, conn }; - })(); - - const [send, recv] = await deadline(clientConn.openBi(), 30_000, "client open-bi"); - const t1 = performance.now(); - await send.write(utf8.encode(MESSAGE)); - await send.finish(); - const echoed = await deadline(readAll(recv), 60_000, "client read echo"); - const roundtripMs = performance.now() - t1; - - if (options.onExchange) { - await options.onExchange({ - server, - client, - serverEndpoint: sep, - clientEndpoint: cep, - clientConn, - notes, - }); - } - - // Read the wire AFTER `onExchange`: the WebRTC upgrade runs in the - // background and `connection.path` is explicitly NOT latched - // (wit/iroh.wit:205-209), so sampling it before the scenario's own - // bounded wait would race the move it is trying to observe. - const clientPath = await clientConn.path(); - - await clientConn.close(0, "done"); - await deadline(clientConn.waitClosed(), 30_000, "client wait-closed"); - const s = await deadline(serverSide, 30_000, "server side"); - - if (hex(s.peer) !== hex(clientId)) { - throw new Error( - `the server authenticated ${shortId(s.peer)}, not the client's ${shortId(clientId)}`, - ); - } - - await sep.close(); - await cep.close(); - - return { - serverId: hex(serverId), - clientId: hex(clientId), - handshakeMs, - roundtripMs, - received: s.received, - echoed, - clientPath, - serverPath: s.path, - notes, - }; -} - -/** - * Run `echoOnce` until it completes or the attempt budget runs out, counting - * the FINDING C3-IROH-1 guest panics separately from real failures. - */ -async function echoWithRetries( - relay: Relay, - v: Verdict, - options: EchoOptions, -): Promise { - let lastError = ""; - let panics = 0; - const budget = options.parkAccept ? PARKED_ACCEPT_ATTEMPTS : ECHO_ATTEMPTS; - for (let attempt = 1; attempt <= budget; attempt++) { - takeGuestPanics(); - try { - const report = await echoOnce(relay, options); - await settle(); - const late = takeGuestPanics(); - if (late.length > 0) { - // A panic in a teardown-phase pump is not an echo failure, but it is - // never silently dropped. - panics += late.length; - v.notes.push( - `attempt ${attempt}: echo completed, then ${late.length} late guest panic(s)`, - ); - } - if (attempt > 1 || panics > 0) { - v.notes.push( - `completed on attempt ${attempt}/${budget}; ` + - `${panics} guest panic(s) observed (FINDING C3-IROH-1)`, - ); - } - return report; - } catch (err) { - lastError = describeError(err); - const seen = takeGuestPanics(); - panics += seen.length; - const guestPanic = seen.some((p) => p.includes("Trap")) || lastError.includes("Trap"); - console.log( - ` attempt ${attempt}/${budget} failed: ${lastError}` + - (guestPanic ? " [FINDING C3-IROH-1]" : ""), - ); - await settle(100); - } - } - v.notes.push(`${panics} guest panic(s) across ${budget} attempts (FINDING C3-IROH-1)`); - throw new Error(`no attempt completed; last: ${lastError}`); -} - -// --- the exam --------------------------------------------------------------- - -async function main(): Promise { - installPanicWatchdog(); - console.log("C3-IROH — iroh endpoint exit exam (deltic / Deno)"); - - const relay = await startRelay(); - try { - // -- 1 ------------------------------------------------------------------- - await scenario(1, "bind + identity (webcrypto ed25519 path)", async (v) => { - resetUdpCallLog(); - const inst = await newEndpointInstance({ label: "solo" }); - const t0 = performance.now(); - const ep = await deadline( - bindEndpoint(inst, { alpns: [ALPN], relayUrl: relay.url, webrtc: false }), - 30_000, - "bind", - ); - const bindMs = performance.now() - t0; - console.log(` bind resolved in ${bindMs.toFixed(0)} ms`); - - // `bind` spawned the detached pump and it is ALIVE with in-flight - // imports (the relay websocket receive) from here on. Every call below - // is a later export call against a live pump: the exact lann/jco#11 - // shape. Under jco the first of them deadlocks. - const id = await deadline(ep.id(), 10_000, "id() after bind"); - check(v, id.length === 32, `endpoint id is 32 bytes (Ed25519 public key): ${shortId(id)}`); - const direct = await deadline(ep.directAddr(), 10_000, "direct-addr() after bind"); - check( - v, - direct === undefined, - "direct-addr is none (the browser profile binds no UDP socket)", - ); - const idAgain = await deadline(ep.id(), 10_000, "id() again"); - check(v, hex(idAgain) === hex(id), "the identity is stable across export calls"); - check( - v, - udpCallLog().length === 0, - `zero wasi:sockets calls (browser profile) — log: [${udpCallLog().join(", ")}]`, - ); - await deadline(ep.close(), 10_000, "close() after bind"); - await settle(); - check(v, takeGuestPanics().length === 0, "no guest trap during bind/identity"); - v.detail = `bind ${bindMs.toFixed(0)} ms, id ${shortId(id)}, 3 post-pump export calls`; - }); - - // -- 2 ------------------------------------------------------------------- - await scenario(2, "relay echo between two endpoint instances", async (v) => { - resetUdpCallLog(); - const r = await echoWithRetries(relay, v, { webrtc: false, parkAccept: false }); - check(v, r.received === MESSAGE, `the server received ${JSON.stringify(r.received)}`); - check(v, r.echoed === MESSAGE.toUpperCase(), `the client read back the echo`); - check(v, r.clientPath === "relay", `connection.path is "relay" on the client`); - check(v, r.serverPath === "relay", `connection.path is "relay" on the server`); - check(v, udpCallLog().length === 0, "zero wasi:sockets calls (relay wire only)"); - v.detail = `handshake ${r.handshakeMs.toFixed(0)} ms, roundtrip ${ - r.roundtripMs.toFixed(0) - } ms, ${shortId(hexBytes(r.clientId))} -> ${shortId(hexBytes(r.serverId))}`; - }); - - // -- 3 ------------------------------------------------------------------- - await scenario(3, "WebRTC upgrade of a relay-dialed connection", async (v) => { - resetUdpCallLog(); - let upgradeMs = -1; - const r = await echoWithRetries(relay, v, { - webrtc: true, - parkAccept: false, - onExchange: async (ctx) => { - // "A failed upgrade leaves the connection on the relay" - // (wit/iroh.wit:172): poll `connection.path` for the move rather - // than assuming it, bounded. - const started = performance.now(); - for (let i = 0; i < 100; i++) { - if (await ctx.clientConn.path() === "webrtc") { - upgradeMs = performance.now() - started; - return; - } - await settle(100); - } - ctx.notes.push("the connection never left the relay within 10 s"); - }, - }); - check(v, r.received === MESSAGE, "the echo crossed while the upgrade ran"); - if (r.clientPath === "webrtc") { - check(v, true, `connection.path moved to "webrtc" after ${upgradeMs.toFixed(0)} ms`); - v.detail = `upgraded to the data channel in ${upgradeMs.toFixed(0)} ms`; - } else { - // Best-effort by dispatch, and honestly reported either way. - v.status = "BLOCKED"; - v.detail = - `the echo succeeded but connection.path stayed "${r.clientPath}" (no upgrade observed)`; - } - for (const n of r.notes) v.notes.push(n); - }); - - // -- 4 ------------------------------------------------------------------- - await scenario(4, "concurrency proof points (§17 table rows)", async (v) => { - // 4a — lann/jco#11: an export call AFTER a live detached pump exists. - // Asserted at scale here: many export calls, on two instances, all with - // pumps alive and holding in-flight relay imports. - const a = await newEndpointInstance({ label: "proof-a" }); - const b = await newEndpointInstance({ label: "proof-b" }); - const epA = await deadline( - bindEndpoint(a, { alpns: [ALPN], relayUrl: relay.url, webrtc: false }), - 30_000, - "proof-a bind", - ); - const epB = await deadline( - bindEndpoint(b, { alpns: [ALPN], relayUrl: relay.url, webrtc: false }), - 30_000, - "proof-b bind", - ); - let calls = 0; - for (let i = 0; i < 10; i++) { - await epA.id(); - await epB.id(); - await epA.directAddr(); - await epB.directAddr(); - calls += 4; - } - check(v, calls === 40, `${calls} export calls completed with two live pump tasks (jco#11)`); - await epA.close(); - await epB.close(); - - // 4b — lann/jco#13: cross-task wakeups through waitables. The server - // parks `accept-bi` and `wait-closed` BEFORE the client's work exists; - // both are resolved by the pump on the client's activity, i.e. by a - // wakeup crossing from one task to another. - let parkedFirst = false; - let acceptBiResolvedAfterWrite = false; - const r = await echoWithRetries(relay, v, { - webrtc: false, - parkAccept: true, - onExchange: (ctx) => { - parkedFirst = true; - ctx.notes.push("endpoint.accept was parked before the client dialed"); - return Promise.resolve(); - }, - }); - acceptBiResolvedAfterWrite = r.received === MESSAGE; - check(v, parkedFirst, "endpoint.accept parked before the dial and was woken by the pump"); - check( - v, - acceptBiResolvedAfterWrite, - "connection.accept-bi + wait-closed woke on peer activity (jco#13)", - ); - - // NOT CLAIMED: lann/jco#14's composed async call. The endpoint is a - // SINGLE component here (no `wac plug`), so that row is carried by the - // experiment-mosh composed client, not by this exam. - v.notes.push("jco#14 (composed async calls) is NOT exercised: single component, no wac plug"); - v.detail = `40 post-pump export calls; accept parked across a handshake and woken`; - }); - - // -- 5 ------------------------------------------------------------------- - await scenario(5, "teardown: close + wait-closed, relay reaped", async (v) => { - const inst = await newEndpointInstance({ label: "teardown" }); - const ep = await deadline( - bindEndpoint(inst, { alpns: [ALPN], relayUrl: relay.url, webrtc: false }), - 30_000, - "bind", - ); - await deadline(ep.close(), 10_000, "endpoint close"); - // Idempotent per wit/iroh.wit:187 ("Idempotent. Dropping the resource - // without calling `close` implies `close`."). - await deadline(ep.close(), 10_000, "endpoint close (again)"); - ep.drop(); - await settle(200); - const panics = takeGuestPanics(); - check(v, panics.length === 0, `no guest trap during teardown (${panics.join("; ")})`); - - await relay.stop(); - const reaped = !(await portListening(3340)); - check( - v, - reaped || relayWasAdopted, - relayWasAdopted - ? "the relay was pre-existing and adopted, so this run does not own its lifetime" - : "iroh-relay --dev was reaped", - ); - v.detail = relayWasAdopted ? "endpoint closed; relay adopted (not owned)" : "endpoint closed; relay reaped"; - }); - } finally { - await relay.stop(); - } - - // -- verdict --------------------------------------------------------------- - console.log("\n=== verdict ==="); - let failed = 0; - for (const v of verdicts_()) { - console.log(` ${v.status.padEnd(7)} ${v.n}. ${v.name}${v.detail ? ` — ${v.detail}` : ""}`); - if (v.status === "FAIL") failed++; - } - const blocked = verdicts_().filter((v) => v.status === "BLOCKED").length; - console.log( - failed === 0 - ? `EXAM PASS (${verdicts_().length - blocked} pass, ${blocked} blocked, 0 fail)` - : `EXAM FAIL (${failed} failing scenario(s))`, - ); - return failed === 0 ? 0 : 1; -} - -function hexBytes(text: string): Uint8Array { - return new Uint8Array((text.match(/.{2}/g) ?? []).map((b) => parseInt(b, 16))); -} - -async function portListening(port: number): Promise { - try { - const conn = await Deno.connect({ hostname: "127.0.0.1", port }); - conn.close(); - return true; - } catch { - return false; - } -} - -let relayWasAdopted = false; - -if (import.meta.main) { - relayWasAdopted = await portListening(3340); - const code = await main(); - // node-datachannel's Node-API addon keeps handles alive after the guest has - // dropped every peer connection, so the process would linger. Exiting - // explicitly is the documented exemption (same discipline as ports/webrtc). - Deno.exit(code); -} diff --git a/exams/iroh-endpoint/src/harness.ts b/exams/iroh-endpoint/src/harness.ts deleted file mode 100644 index 95c723e..0000000 --- a/exams/iroh-endpoint/src/harness.ts +++ /dev/null @@ -1,349 +0,0 @@ -// The exam harness: relay lifecycle, artifact translation, and one typed -// handle on `polymorph:iroh/endpoint@0.1.0` per endpoint instance. -// -// Everything here is host wiring; the scenarios in `run.ts` carry the -// verdicts. The driving logic is ported from the consumer's own JS driver -// (polymorph-iroh/host-jco/src/run-endpoint.mjs) — the *logic*, not the jco -// wiring: that driver's `iroh.Endpoint.bind(...)` becomes this file's -// `bindEndpoint(...)` over `instantiate` + the embedder facade. - -import { Translator } from "../../../runtime/src/shim/mod.ts"; -import type { ComponentArtifacts } from "@deltic/runtime/embedder"; -import { instantiate, WitError } from "@deltic/runtime/embedder"; -import { wasiShims } from "../../../wasi-shims/src/mod.ts"; -import { webcryptoImports } from "../../../ports/webcrypto/src/mod.ts"; -import { websocketImports } from "../../../ports/websocket/src/websocket.ts"; -import { webrtcImports } from "../../../ports/webrtc/src/webrtc.ts"; -import { socketsImports } from "./sockets.ts"; -import type { - BindConfig, - Endpoint, - IdentityGenerateExports, - IrohEndpointExports, -} from "./types.ts"; - -const CE_ROOT = new URL("../../../", import.meta.url).pathname; -const CONSUMER = "/home/lmartin/p/polymorph/polymorph-iroh"; - -/** The endpoint component. Read-only; never rebuilt into the consumer tree. */ -export const ENDPOINT_WASM = - `${CONSUMER}/target/wasm32-wasip2/release/iroh_endpoint.wasm`; -/** Rebuild landing zone, outside every consumer tree (dispatch rule). */ -export const REBUILD_TARGET = "/tmp/opencode/c3-iroh-target"; -export const REBUILT_WASM = - `${REBUILD_TARGET}/wasm32-wasip2/release/iroh_endpoint.wasm`; -export const SHIM_WASM = - `${CE_ROOT}target/wasm32-unknown-unknown/release/translator_shim.wasm`; - -/** The stock upstream relay, built by the consumer's `just relay-build`. */ -export const RELAY_BIN = `${CONSUMER}/.deps/iroh/target/release/iroh-relay`; -/** `iroh-relay --dev` serves ws on this address (their README). */ -export const RELAY_PORT = 3340; -export const RELAY_URL = `http://127.0.0.1:${RELAY_PORT}`; - -export const IROH_ENDPOINT_INTERFACE = "polymorph:iroh/endpoint@0.1.0"; -export const IDENTITY_GENERATE_INTERFACE = "polymorph:iroh/identity-generate@0.1.0"; - -// --- artifact --------------------------------------------------------------- - -/** - * The world the exam expects, checked before anything else runs. A prebuilt - * consumer artifact can be stale (the webrtc echo-demo predated a package - * rename), so the import set is the freshness test: if these are absent the - * artifact predates the surface under exam and must be rebuilt. - */ -const REQUIRED_IMPORT_IDS = [ - "polymorph:webrtc-datachannels/connections@0.1.0", - "polymorph:websocket/connections@0.1.0", - "polymorph:webcrypto/ed25519-sign@0.1.0", - "wasi:clocks/monotonic-clock@0.3.0", - "wasi:sockets/types@0.3.0", -]; - -/** - * Export-side freshness: the identity/options surface (the resource-shaped - * `endpoint-options` and the `identity-generate` interface) postdates the - * record-shaped bind an older artifact carries. An artifact without these - * predates the surface under exam. - */ -const REQUIRED_EXPORT_IDS = [ - "polymorph:iroh/endpoint@0.1.0", - "polymorph:iroh/identity-generate@0.1.0", -]; - -let cachedArtifacts: ComponentArtifacts | undefined; - -async function exists(path: string): Promise { - try { - return (await Deno.stat(path)).isFile; - } catch { - return false; - } -} - -async function rebuildEndpoint(): Promise { - console.error(`rebuilding iroh-endpoint into ${REBUILD_TARGET} …`); - const built = await new Deno.Command("cargo", { - args: [ - "build", - "--locked", - "--release", - "-p", - "iroh-endpoint", - "--target", - "wasm32-wasip2", - "--manifest-path", - `${CONSUMER}/Cargo.toml`, - ], - env: { CARGO_TARGET_DIR: REBUILD_TARGET }, - stdout: "inherit", - stderr: "inherit", - }).output(); - if (!built.success) throw new Error("iroh-endpoint rebuild failed"); -} - -/** - * Translate the endpoint component once. The plan and adapters are reused - * across every `instantiate` in the run: two endpoint *instances* are two - * separate component instances over the same immutable artifacts. - */ -export async function loadArtifacts(): Promise { - if (cachedArtifacts) return cachedArtifacts; - if (!await exists(SHIM_WASM)) { - throw new Error( - `translator shim not found at ${SHIM_WASM} — build it with:\n` + - ` cargo build -p translator-shim --target wasm32-unknown-unknown --release`, - ); - } - let path = ENDPOINT_WASM; - if (!await exists(path)) { - if (!await exists(REBUILT_WASM)) await rebuildEndpoint(); - path = REBUILT_WASM; - } - let bytes = await Deno.readFile(path); - const translator = await Translator.create(await Deno.readFile(SHIM_WASM)); - let { plan, adapters } = translator.translate(bytes); - - const staleness = (p: typeof plan): string[] => { - const imports = new Set(p.imports.map((i: { name: string }) => i.name)); - const exports = new Set(p.exports.map((e: { name: string }) => e.name)); - return [ - ...REQUIRED_IMPORT_IDS.filter((id) => !imports.has(id)), - ...REQUIRED_EXPORT_IDS.filter((id) => !exports.has(id)), - ]; - }; - const missing = staleness(plan); - if (missing.length > 0) { - // Staleness verdict: rebuild from source rather than fail on an old - // artifact (the dispatch's CAUTION). - console.error( - `prebuilt ${path} is stale (missing ${missing.join(", ")}); rebuilding`, - ); - await rebuildEndpoint(); - bytes = await Deno.readFile(REBUILT_WASM); - ({ plan, adapters } = translator.translate(bytes)); - const still = staleness(plan); - if (still.length > 0) { - throw new Error( - `rebuilt artifact still missing ${still.join(", ")} — the consumer ` + - `checkout at ${CONSUMER} predates the surface under exam`, - ); - } - } - cachedArtifacts = { plan, componentBytes: bytes, adapters }; - return cachedArtifacts; -} - -// --- instances -------------------------------------------------------------- - -export interface EndpointInstanceOptions { - /** Label used in log lines (`server`, `client`, …). */ - readonly label: string; - /** Extra environment for the guest's `wasi:cli/environment`. */ - readonly env?: Record; -} - -export interface EndpointInstance { - readonly label: string; - readonly api: IrohEndpointExports; - readonly identityGenerate: IdentityGenerateExports; - /** Whatever the guest wrote to stdout/stderr through the WASI shims. */ - stdout(): string; - stderr(): string; -} - -/** - * Stand up one component instance of the endpoint, with the committed ports - * supplying every non-WASI import. - * - * Import fragments are built FRESH per instance: the ports' resource classes - * carry per-instance registry identity, and sharing one record across two - * instantiations would alias two guests onto one table. - */ -export async function newEndpointInstance( - options: EndpointInstanceOptions, -): Promise { - const artifacts = await loadArtifacts(); - const shims = wasiShims({ - cli: { - args: [`iroh-endpoint-${options.label}`], - env: { ...options.env }, - passthrough: Deno.env.get("EXAM_GUEST_LOGS") === "1", - }, - }); - const imports = { - ...shims, - // ports/webcrypto publishes SigningKeyOptions under the DEFINING - // interface (`signature`) since the exam's first run found the gap; - // the stock fragment now links this endpoint unmodified. - ...webcryptoImports(), - ...websocketImports(), - ...webrtcImports(), - ...socketsImports(), - }; - const instance = await instantiate(artifacts, imports); - const api = instance.exports[IROH_ENDPOINT_INTERFACE] as IrohEndpointExports; - const identityGenerate = instance - .exports[IDENTITY_GENERATE_INTERFACE] as IdentityGenerateExports; - if (!api || typeof api.Endpoint?.bind !== "function") { - throw new Error( - `export "${IROH_ENDPOINT_INTERFACE}" missing or shapeless; plan exports: ` + - artifacts.plan.exports.map((e: { name: string }) => e.name).join(", "), - ); - } - if (typeof identityGenerate?.generate !== "function") { - throw new Error(`export "${IDENTITY_GENERATE_INTERFACE}" missing or shapeless`); - } - return { - label: options.label, - api, - identityGenerate, - stdout: () => shims.captured.stdoutText(), - stderr: () => shims.captured.stderrText(), - }; -} - -/** - * `Endpoint.bind`, with the consumer driver's driving shape - * (polymorph-iroh/host-jco/src/run-endpoint.mjs): generate an identity, - * construct `endpoint-options` around it, populate the setters, bind. The - * options resource is consumed by `bind`; the identity's borrow ends at the - * constructor, so it is dropped once the endpoint is up. - */ -export async function bindEndpoint( - instance: EndpointInstance, - config: BindConfig, -): Promise { - const identity = await instance.identityGenerate.generate(); - const options = new instance.api.EndpointOptions(identity); - for (const alpn of config.alpns) await options.addAlpn(alpn); - if (config.relayUrl !== undefined) await options.relayUrl(config.relayUrl); - if (config.udpBindAddr !== undefined) await options.udpBindAddr(config.udpBindAddr); - if (config.webrtc) await options.webrtc(true); - const endpoint = await instance.api.Endpoint.bind(options); - identity.drop(); - return endpoint; -} - -// --- relay ------------------------------------------------------------------ - -export interface Relay { - readonly url: string; - stop(): Promise; -} - -async function portOpen(port: number): Promise { - try { - const conn = await Deno.connect({ hostname: "127.0.0.1", port }); - conn.close(); - return true; - } catch { - return false; - } -} - -/** - * Spawn `iroh-relay --dev` (ws on 127.0.0.1:3340) and wait for it to accept. - * - * If something is already listening on the port we adopt it rather than - * racing a second binder — experiment-mosh finding 15c's port-conflict shape. - * `enable_metrics = false` would go in a `--config-path` file if the metrics - * port ever collides; not needed on this host (recorded in the report). - */ -export async function startRelay(): Promise { - if (await portOpen(RELAY_PORT)) { - console.error(`relay: adopting an already-listening 127.0.0.1:${RELAY_PORT}`); - return { url: RELAY_URL, stop: () => Promise.resolve() }; - } - if (!await exists(RELAY_BIN)) { - throw new Error( - `iroh-relay not found at ${RELAY_BIN} — build it in the consumer tree with:\n` + - ` (cd ${CONSUMER}/.deps/iroh && cargo build --release -p iroh-relay --features server --bin iroh-relay)`, - ); - } - const child = new Deno.Command(RELAY_BIN, { - args: ["--dev"], - stdout: "piped", - stderr: "piped", - }).spawn(); - // Drain the pipes so the relay never blocks on a full stdio buffer, and - // so `stop()` can close them without an unresolved-read sanitizer hit. - const sink = (r: ReadableStream) => - r.pipeTo(new WritableStream({ write() {} })).catch(() => {}); - const drained = Promise.all([sink(child.stdout), sink(child.stderr)]); - - const deadline = Date.now() + 15_000; - while (Date.now() < deadline) { - if (await portOpen(RELAY_PORT)) { - console.error(`relay: iroh-relay --dev listening on ${RELAY_URL} (pid ${child.pid})`); - return { - url: RELAY_URL, - stop: async () => { - try { - child.kill("SIGTERM"); - } catch { /* already gone */ } - await child.status; - await drained; - }, - }; - } - await new Promise((r) => setTimeout(r, 100)); - } - try { - child.kill("SIGKILL"); - } catch { /* ignore */ } - await child.status; - await drained; - throw new Error(`iroh-relay did not listen on ${RELAY_PORT} within 15s`); -} - -// --- small helpers ---------------------------------------------------------- - -export const hex = (bytes: Uint8Array): string => - Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); - -/** Endpoint ids are 64 hex chars; log them short (they are public keys). */ -export const shortId = (bytes: Uint8Array): string => `${hex(bytes).slice(0, 12)}…`; - -export const utf8 = new TextEncoder(); -export const fromUtf8 = new TextDecoder(); - -/** Reject after `ms`, so a wedged scenario names itself instead of hanging. */ -export function deadline(promise: Promise, ms: number, what: string): Promise { - let timer: ReturnType | undefined; - const bomb = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`timed out after ${ms} ms: ${what}`)), ms); - }); - return Promise.race([promise, bomb]).finally(() => { - if (timer !== undefined) clearTimeout(timer); - }) as Promise; -} - -/** Render a rejection, unwrapping the branded WIT error payload. */ -export function describeError(err: unknown): string { - if (err instanceof WitError) { - const p = err.payload as { tag?: string; val?: unknown } | undefined; - return `WitError ${p?.tag ?? "?"}${p?.val === undefined ? "" : `(${String(p.val)})`}`; - } - return err instanceof Error ? `${err.name}: ${err.message}` : String(err); -} diff --git a/exams/iroh-endpoint/src/scenario.ts b/exams/iroh-endpoint/src/scenario.ts deleted file mode 100644 index 8d27c78..0000000 --- a/exams/iroh-endpoint/src/scenario.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Scenario support: verdict bookkeeping, the guest-panic watchdog, and the -// stream helpers the consumer's own driver uses. - -import { describeError, fromUtf8 } from "./harness.ts"; -import type { RecvStream } from "./types.ts"; - -export type Status = "PENDING" | "PASS" | "FAIL" | "BLOCKED" | "SKIP"; - -export interface Verdict { - readonly n: number; - readonly name: string; - status: Status; - detail: string; - readonly notes: string[]; -} - -const verdicts: Verdict[] = []; - -export function verdicts_(): readonly Verdict[] { - return verdicts; -} - -export async function scenario( - n: number, - name: string, - body: (v: Verdict) => Promise, -): Promise { - // Starts PENDING, not FAIL: the body is free to set `detail` (a metrics - // line) without that being read as a failure. Only `check` and a thrown - // error decide FAIL; a body may set BLOCKED/SKIP deliberately. - const v: Verdict = { n, name, status: "PENDING" as Status, detail: "", notes: [] }; - verdicts.push(v); - console.log(`\n=== scenario ${n}: ${name} ===`); - const started = performance.now(); - try { - await body(v); - if (v.status === "PENDING") v.status = "PASS"; - } catch (err) { - if (v.status === "PENDING" || v.status === "FAIL") { - v.status = "FAIL"; - if (v.detail === "") v.detail = describeError(err); - else v.detail = `${v.detail} | ${describeError(err)}`; - } - } - const ms = (performance.now() - started).toFixed(0); - console.log(`${v.status} scenario ${n} (${name}) [${ms} ms] ${v.detail}`); - for (const note of v.notes) console.log(` note: ${note}`); - return v; -} - -/** An assertion whose failure is the scenario's verdict, not a stack trace. */ -export function check(v: Verdict, ok: boolean, what: string): void { - if (ok) { - console.log(` ok: ${what}`); - return; - } - v.status = "FAIL"; - v.detail = `assertion failed: ${what}`; - throw new Error(`assertion failed: ${what}`); -} - -// --- guest-panic watchdog --------------------------------------------------- -// -// A trap raised inside the endpoint's DETACHED PUMP task surfaces as an -// unhandled promise rejection: nothing in the host is awaiting that task. -// Left alone it kills the process, so the watchdog converts it into a -// recorded event that a retry loop can read. See run.ts's FINDING C3-IROH-1 -// for what actually raises it. - -let guestPanics: string[] = []; - -export function installPanicWatchdog(): void { - globalThis.addEventListener("unhandledrejection", (event) => { - const reason = (event as unknown as { reason: unknown }).reason; - const text = reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason); - guestPanics.push(text); - event.preventDefault(); - }); -} - -export function takeGuestPanics(): string[] { - const taken = guestPanics; - guestPanics = []; - return taken; -} - -export function sawGuestPanic(): boolean { - return guestPanics.length > 0; -} - -/** Let the microtask/timer queue turn so a pending trap rejection lands. */ -export function settle(ms = 25): Promise { - return new Promise((r) => setTimeout(r, ms)); -} - -// --- stream helpers (ported from run-endpoint.mjs:31-46) -------------------- - -export const READ_MAX = 16 * 1024; - -export async function readAll(recv: RecvStream): Promise { - const chunks: Uint8Array[] = []; - for (;;) { - const chunk = await recv.read(READ_MAX); - if (chunk === undefined) break; - chunks.push(chunk); - } - const total = chunks.reduce((n, c) => n + c.length, 0); - const merged = new Uint8Array(total); - let at = 0; - for (const c of chunks) { - merged.set(c, at); - at += c.length; - } - return fromUtf8.decode(merged); -} diff --git a/exams/iroh-endpoint/src/sockets.ts b/exams/iroh-endpoint/src/sockets.ts deleted file mode 100644 index cdd504b..0000000 --- a/exams/iroh-endpoint/src/sockets.ts +++ /dev/null @@ -1,141 +0,0 @@ -// `wasi:sockets/types@0.3.0` — the UDP direct path, typed per the p3 draft -// the endpoint component actually links (the resource shape below is -// transcribed from the artifact's own embedded WIT, which agrees with -// polymorph-webrtc-datachannels/wasip3-impl/wit/deps/sockets.wit): -// -// resource udp-socket { -// create: static func(address-family: ip-address-family) -> result; -// bind: func(local-address: ip-socket-address) -> result<_, error-code>; -// send: async func(data: list, remote-address: option) -> result<_, error-code>; -// receive: async func() -> result, ip-socket-address>, error-code>; -// get-local-address: func() -> result; -// } -// -// THE EXAM'S PROFILE IS THE BROWSER PROFILE, WHICH HAS NO UDP. The endpoint -// binds with `udp-bind-addr: none`, and the WIT contract for that field is -// explicit — "`none` binds no socket" (polymorph-iroh/wit/iroh.wit:128). So -// every function here is a FAIL-ON-CALL stub that returns -// `error-code.not-supported`, which is simultaneously: -// -// * an honest capability answer (a browser host would answer the same), and -// * an assertion: `udpCallLog()` staying empty across a whole exam run is -// the executable proof that the relay/WebRTC legs never reach for a -// socket. Scenario 1 asserts exactly that. -// -// If a future endpoint build ever calls `create` unconditionally at bind, -// this file is where the bounded `Deno.listenDatagram` fallback would go; -// see the report for the recorded verdict on which strategy was needed. - -import { WitError } from "@deltic/runtime/embedder"; - -/** `wasi:sockets/types@0.3.0`'s `ip-address-family` enum. */ -export type IpAddressFamily = "ipv4" | "ipv6"; - -/** `ipv4-address` = `tuple`. */ -export type Ipv4Address = [number, number, number, number]; - -/** `ipv6-address` = `tuple`. */ -export type Ipv6Address = [ - number, - number, - number, - number, - number, - number, - number, - number, -]; - -export interface Ipv4SocketAddress { - port: number; - address: Ipv4Address; -} - -export interface Ipv6SocketAddress { - port: number; - flowInfo: number; - address: Ipv6Address; - scopeId: number; -} - -/** The `ip-socket-address` variant, in `{ tag, val }` form. */ -export type IpSocketAddress = - | { tag: "ipv4"; val: Ipv4SocketAddress } - | { tag: "ipv6"; val: Ipv6SocketAddress }; - -/** - * The `error-code` variant. Only the cases this stub can produce are - * spelled as literals; the rest are listed so callers can switch - * exhaustively against the real WIT vocabulary. - */ -export type SocketErrorCode = - | { tag: "access-denied" } - | { tag: "not-supported" } - | { tag: "invalid-argument" } - | { tag: "out-of-memory" } - | { tag: "timeout" } - | { tag: "invalid-state" } - | { tag: "address-not-bindable" } - | { tag: "address-in-use" } - | { tag: "remote-unreachable" } - | { tag: "connection-refused" } - | { tag: "connection-broken" } - | { tag: "connection-reset" } - | { tag: "connection-aborted" } - | { tag: "datagram-too-large" } - | { tag: "other"; val?: string }; - -const callLog: string[] = []; - -/** Every `wasi:sockets` entry point the guest reached for, in order. */ -export function udpCallLog(): readonly string[] { - return callLog; -} - -/** Clear the call log (per-scenario bookkeeping). */ -export function resetUdpCallLog(): void { - callLog.length = 0; -} - -function refuse(what: string): never { - callLog.push(what); - // A host import signals a WIT `err` by throwing a BRANDED `WitError` - // (contracts/embedder-api.md, "Error model"): an unbranded throw would - // become a trap naming the import instead of a guest-visible err. - const payload: SocketErrorCode = { tag: "not-supported" }; - throw new WitError(payload, `wasi:sockets/types@0.3.0: ${what} is not provided by this host`); -} - -/** - * The host-implemented `udp-socket` resource: a plain class with camelCase - * methods and the WIT `static` as a JS static (contracts/embedder-api.md, - * "Resources"). No instance is ever constructed — `create` refuses first. - */ -export class UdpSocket { - static create(_addressFamily: IpAddressFamily): UdpSocket { - return refuse("udp-socket.create"); - } - - bind(_localAddress: IpSocketAddress): void { - return refuse("udp-socket.bind"); - } - - send(_data: Uint8Array, _remoteAddress: IpSocketAddress | undefined): Promise { - return refuse("udp-socket.send"); - } - - receive(): Promise<[Uint8Array, IpSocketAddress]> { - return refuse("udp-socket.receive"); - } - - getLocalAddress(): IpSocketAddress { - return refuse("udp-socket.get-local-address"); - } -} - -export const SOCKETS_TYPES_INTERFACE = "wasi:sockets/types@0.3.0"; - -/** The imports-record fragment for `wasi:sockets/types@0.3.0`. */ -export function socketsImports(): Record { - return { [SOCKETS_TYPES_INTERFACE]: { UdpSocket } }; -} diff --git a/exams/iroh-endpoint/src/types.ts b/exams/iroh-endpoint/src/types.ts deleted file mode 100644 index c3b5670..0000000 --- a/exams/iroh-endpoint/src/types.ts +++ /dev/null @@ -1,141 +0,0 @@ -// A hand-written TS facade for `polymorph:iroh/endpoint@0.1.0`, transcribed -// from the consumer's WIT (polymorph-iroh/wit/iroh.wit) under -// contracts/embedder-api.md's mapping rules: -// -// * resource -> PascalCase class, methods camelCase, statics static -// * every export is Promise-shaped; `result` in RETURN position -// resolves T or rejects `WitError` (so no `{tag}` unwrapping here) -// * enums -> kebab-case string literal unions; variants -> `{ tag, val }` -// * `list` -> Uint8Array; `option` (outermost) -> `T | undefined` -// * record fields camelCase; option-typed fields are optional properties -// -// This file is types only: bindgen would emit exactly this shape, and the -// embedder facade is fully untyped at runtime, so these are a cast over the -// real instance. - -/** `endpoint-id` = `list` (32 bytes, an Ed25519 public key). */ -export type EndpointId = Uint8Array; - -export interface CustomAddr { - id: bigint; - data: Uint8Array; -} - -export type TransportAddr = - | { tag: "relay"; val: string } - | { tag: "ip"; val: string } - | { tag: "webrtc"; val: string } - | { tag: "custom"; val: CustomAddr }; - -export interface EndpointAddr { - endpointId: EndpointId; - addrs: TransportAddr[]; -} - -export type ConnectionState = "connecting" | "open" | "closed"; - -export type PathKind = "relay" | "ip" | "webrtc"; - -/** `polymorph:iroh/types@0.1.0`'s `error` variant. */ -export type IrohError = - | { tag: "closed" } - | { tag: "reset"; val: string } - | { tag: "connect-failed"; val: string } - | { tag: "invalid-argument"; val: string } - | { tag: "other"; val: string }; - -/** The `identity` resource: a non-extractable Ed25519 key pair. */ -export interface Identity { - /** Sync in WIT, Promise-shaped as an export. */ - endpointId(): Promise; - [Symbol.dispose](): void; - drop(): void; -} - -/** The shape of `instance.exports["polymorph:iroh/identity-generate@0.1.0"]`. */ -export interface IdentityGenerateExports { - generate(): Promise; -} - -/** - * The `endpoint-options` resource: constructed around a borrowed - * `identity`, then populated through the setters (every setting starts - * disabled or unset). Consumed by `Endpoint.bind`. - */ -export interface EndpointOptionsInstance { - addAlpn(alpn: Uint8Array): Promise; - relayUrl(url: string): Promise; - udpBindAddr(addr: string): Promise; - webrtc(enabled: boolean): Promise; - [Symbol.dispose](): void; - drop(): void; -} - -export interface EndpointOptionsClass { - new (identity: Identity): EndpointOptionsInstance; -} - -/** - * The option shape `bindEndpoint` (harness.ts) builds an - * `endpoint-options` resource from. - */ -export interface BindConfig { - alpns: Uint8Array[]; - /** absent = no home relay. */ - relayUrl?: string; - /** absent = bind no UDP socket (the browser profile). */ - udpBindAddr?: string; - webrtc: boolean; -} - -export interface SendStream { - write(bytes: Uint8Array): Promise; - /** Sync in WIT, Promise-shaped as an export (embedder-api "Functions and async"). */ - finish(): Promise; - reset(code: number): Promise; - [Symbol.dispose](): void; - drop(): void; -} - -export interface RecvStream { - /** `result>, error>`: resolves `undefined` at the peer's FIN. */ - read(max: number): Promise; - stop(code: number): Promise; - [Symbol.dispose](): void; - drop(): void; -} - -export interface Connection { - peer(): Promise; - alpn(): Promise; - state(): Promise; - path(): Promise; - openBi(): Promise<[SendStream, RecvStream]>; - openUni(): Promise; - acceptBi(): Promise<[SendStream, RecvStream]>; - acceptUni(): Promise; - close(code: number, reason: string): Promise; - waitClosed(): Promise; - [Symbol.dispose](): void; - drop(): void; -} - -export interface Endpoint { - id(): Promise; - directAddr(): Promise; - connect(addr: EndpointAddr, alpn: Uint8Array): Promise; - accept(): Promise; - close(): Promise; - [Symbol.dispose](): void; - drop(): void; -} - -export interface EndpointClass { - bind(options: EndpointOptionsInstance): Promise; -} - -/** The shape of `instance.exports["polymorph:iroh/endpoint@0.1.0"]`. */ -export interface IrohEndpointExports { - EndpointOptions: EndpointOptionsClass; - Endpoint: EndpointClass; -} diff --git a/justfile b/justfile index 4a0de6d..28f0eb0 100644 --- a/justfile +++ b/justfile @@ -13,10 +13,10 @@ default: # Exactly the CI jobs: the required `core` matrix + the post-merge `browser` job. ci: (gha::core) (gha::browser) -# Includes the consumer smokes and exams CI cannot run (they need the -# polymorph checkouts and iroh-relay; docs/consumers.md). +# Includes the consumer smokes CI cannot run (they need the polymorph +# checkouts; docs/consumers.md). # The full pre-commit pass (AGENTS.md "Gates"): everything. -gates: build test-rust test-runtime test-wasi-shims test-ct-runner test-bundle examples test-translate conformance sched-seeds test-ports test-webrtc shells browsers websocket-conformance smoke-tls smoke-c0 iroh-exam +gates: build test-rust test-runtime test-wasi-shims test-ct-runner test-bundle examples test-translate conformance sched-seeds test-ports test-webrtc shells browsers websocket-conformance smoke-tls smoke-c0 # Fast sanity: builds + native tests + type-checks, no suites. check: build test-rust @@ -205,9 +205,6 @@ bench-boundary *jco: shim ../../target/wasm32-unknown-unknown/release/translator_shim.wasm fi -# The iroh endpoint exit exam (needs iroh-relay on PATH). -iroh-exam: shim - deno run -A --unstable-net exams/iroh-endpoint/run.ts # ----- release ----------------------------------------------------------------